feat(library): add workspace scope filter to library list view
Add a `scope` GET parameter to `library_list` allowing users to filter libraries by all, global-only, or Daedalus workspace-only. Includes a filter form in the template, an updated empty state message, and tests covering each scope branch with mocked Neo4j dependencies.
This commit is contained in:
@@ -15,6 +15,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<form method="get" class="mb-4 flex flex-wrap gap-3 items-end">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Scope</span></label>
|
||||||
|
<select name="scope" class="select select-bordered select-sm">
|
||||||
|
<option value="all" {% if scope == "all" %}selected{% endif %}>All libraries</option>
|
||||||
|
<option value="global" {% if scope == "global" %}selected{% endif %}>Global only</option>
|
||||||
|
<option value="daedalus" {% if scope == "daedalus" %}selected{% endif %}>Daedalus workspaces only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline">Filter</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
{% if error %}
|
{% if error %}
|
||||||
<div class="alert alert-warning mb-4">
|
<div class="alert alert-warning mb-4">
|
||||||
<span>{{ error }}</span>
|
<span>{{ error }}</span>
|
||||||
@@ -53,8 +65,12 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
{% if not error %}
|
{% if not error %}
|
||||||
<div class="text-center py-12 opacity-60">
|
<div class="text-center py-12 opacity-60">
|
||||||
|
{% if scope == "all" %}
|
||||||
<p class="text-lg">No libraries yet.</p>
|
<p class="text-lg">No libraries yet.</p>
|
||||||
<p class="mt-2">Create your first library to get started.</p>
|
<p class="mt-2">Create your first library to get started.</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-lg">No libraries match this filter.</p>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
92
mnemosyne/library/tests/test_views.py
Normal file
92
mnemosyne/library/tests/test_views.py
Normal file
@@ -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.")
|
||||||
@@ -31,14 +31,20 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def library_list(request):
|
def library_list(request):
|
||||||
"""List all libraries."""
|
"""List libraries, optionally filtered by Daedalus-workspace scope."""
|
||||||
|
scope = request.GET.get("scope", "all")
|
||||||
libraries = []
|
libraries = []
|
||||||
error = None
|
error = None
|
||||||
if neo4j_available():
|
if neo4j_available():
|
||||||
try:
|
try:
|
||||||
from .models import Library
|
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:
|
except Exception as e:
|
||||||
error = f"Could not connect to Neo4j: {e}"
|
error = f"Could not connect to Neo4j: {e}"
|
||||||
logger.error(error)
|
logger.error(error)
|
||||||
@@ -47,7 +53,7 @@ def library_list(request):
|
|||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"library/library_list.html",
|
"library/library_list.html",
|
||||||
{"libraries": libraries, "error": error},
|
{"libraries": libraries, "error": error, "scope": scope},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user