diff --git a/mnemosyne/library/templates/library/library_list.html b/mnemosyne/library/templates/library/library_list.html
index 1f28008..c4c8e46 100644
--- a/mnemosyne/library/templates/library/library_list.html
+++ b/mnemosyne/library/templates/library/library_list.html
@@ -15,6 +15,18 @@
+
{{ error }}
@@ -53,8 +65,12 @@
{% else %}
{% if not error %}
+ {% if scope == "all" %}
No libraries yet.
Create your first library to get started.
+ {% else %}
+
No libraries match this filter.
+ {% endif %}
{% endif %}
{% endif %}
diff --git a/mnemosyne/library/tests/test_views.py b/mnemosyne/library/tests/test_views.py
new file mode 100644
index 0000000..87c0858
--- /dev/null
+++ b/mnemosyne/library/tests/test_views.py
@@ -0,0 +1,92 @@
+"""Tests for the library CRUD HTML views.
+
+Currently covers ``library_list``'s Daedalus-workspace scope filter. The
+view loads every ``Library`` node from Neo4j and narrows it by a ``scope``
+GET param (``all`` / ``global`` / ``daedalus``). These tests stub out
+Neo4j entirely — patching ``neo4j_available`` and injecting a fake
+``Library`` class via ``sys.modules`` — so they assert on the queryset
+``.filter(...)`` call the view makes and the context it renders, not on
+real graph behaviour. Mirrors the mocking style in
+``test_search_views_admin_scope.py``.
+"""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import MagicMock, patch
+
+from django.contrib.auth import get_user_model
+from django.test import TestCase
+from django.urls import reverse
+
+User = get_user_model()
+
+
+class LibraryListScopeFilterTests(TestCase):
+ """Cover the ``scope`` filter branches of ``library_list``."""
+
+ def setUp(self):
+ self.user = User.objects.create_user(
+ username="op", email="op@example.com", password="pw"
+ )
+ self.client.force_login(self.user)
+ self.url = reverse("library:library-list")
+
+ def _fake_library_cls(self):
+ """Return (Library stub, nodes mock) where ``nodes`` chains fluently.
+
+ ``Library.nodes`` → ``.filter(...)`` → ``.order_by(...)`` all return
+ the same MagicMock so the view's queryset building works regardless
+ of which branch it takes, and ``.filter`` records its kwargs.
+ """
+ fake_nodes = MagicMock()
+ fake_nodes.filter.return_value = fake_nodes
+ fake_nodes.order_by.return_value = []
+ return SimpleNamespace(nodes=fake_nodes), fake_nodes
+
+ def _get(self, fake_library_cls, **params):
+ with patch("library.views.neo4j_available", return_value=True), \
+ patch.dict(
+ "sys.modules",
+ {"library.models": SimpleNamespace(Library=fake_library_cls)},
+ ):
+ return self.client.get(self.url, params)
+
+ def test_default_scope_is_all_and_does_not_filter(self):
+ fake_cls, fake_nodes = self._fake_library_cls()
+ response = self._get(fake_cls)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.context["scope"], "all")
+ fake_nodes.filter.assert_not_called()
+ fake_nodes.order_by.assert_called_once_with("name")
+
+ def test_global_scope_filters_workspace_isnull_true(self):
+ fake_cls, fake_nodes = self._fake_library_cls()
+ response = self._get(fake_cls, scope="global")
+
+ self.assertEqual(response.context["scope"], "global")
+ fake_nodes.filter.assert_called_once_with(workspace_id__isnull=True)
+
+ def test_daedalus_scope_filters_workspace_isnull_false(self):
+ fake_cls, fake_nodes = self._fake_library_cls()
+ response = self._get(fake_cls, scope="daedalus")
+
+ self.assertEqual(response.context["scope"], "daedalus")
+ fake_nodes.filter.assert_called_once_with(workspace_id__isnull=False)
+
+ def test_unknown_scope_does_not_filter(self):
+ """An unexpected scope value degrades to the unfiltered list."""
+ fake_cls, fake_nodes = self._fake_library_cls()
+ response = self._get(fake_cls, scope="bogus")
+
+ self.assertEqual(response.context["scope"], "bogus")
+ fake_nodes.filter.assert_not_called()
+
+ def test_neo4j_unavailable_sets_error_and_empty_list(self):
+ with patch("library.views.neo4j_available", return_value=False):
+ response = self.client.get(self.url)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(list(response.context["libraries"]), [])
+ self.assertEqual(response.context["error"], "Neo4j is not available.")
diff --git a/mnemosyne/library/views.py b/mnemosyne/library/views.py
index a48032c..87a30d2 100644
--- a/mnemosyne/library/views.py
+++ b/mnemosyne/library/views.py
@@ -31,14 +31,20 @@ logger = logging.getLogger(__name__)
@login_required
def library_list(request):
- """List all libraries."""
+ """List libraries, optionally filtered by Daedalus-workspace scope."""
+ scope = request.GET.get("scope", "all")
libraries = []
error = None
if neo4j_available():
try:
from .models import Library
- libraries = Library.nodes.order_by("name")
+ qs = Library.nodes
+ if scope == "daedalus":
+ qs = qs.filter(workspace_id__isnull=False)
+ elif scope == "global":
+ qs = qs.filter(workspace_id__isnull=True)
+ libraries = qs.order_by("name")
except Exception as e:
error = f"Could not connect to Neo4j: {e}"
logger.error(error)
@@ -47,7 +53,7 @@ def library_list(request):
return render(
request,
"library/library_list.html",
- {"libraries": libraries, "error": error},
+ {"libraries": libraries, "error": error, "scope": scope},
)