11 Commits

Author SHA1 Message Date
d01afd6203 🐾 feat(settings): register kairos-mail source bucket for ingest
Kairos is about to start announcing rendered mail documents to
POST /library/api/ingest/ with source="kairos-mail". Without a registry
entry, unknown sources fall back to the daedalus bucket and every worker
fetch fails — this adds the KAIROS_S3_* env vars (pattern-copy of the
spelunker block) and the SOURCE_S3_BUCKETS["kairos-mail"] entry, plumbed
through .env.example and the worker service in docker-compose.

Settings-only; no code changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 07:36:12 -04:00
840b9435a3 ci: remove branch and PR tags from docker metadata
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 3m25s
CVE Scan & Docker Build / build-and-push (push) Successful in 2m34s
Drop type=ref tags for branch and PR events, keeping only SHA-based
and latest tags to simplify the image tagging scheme.
2026-06-25 08:14:56 -04:00
6120e9cd1f fix(mnemosyne): correct celery healthcheck command
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 4m14s
CVE Scan & Docker Build / build-and-push (push) Successful in 47s
The exec-form CMD has no shell, so $$HOSTNAME was never expanded,
causing "celery@$HOSTNAME" to match no node and every healthcheck to
fail. Remove the -d filter (one worker per container makes it
unnecessary) and add -t 8 to accommodate broker round-trip latency.
2026-06-19 11:39:38 -04:00
31a98b4f3a feat(library): add workspace scope filter to library list view
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 3m52s
Build & Deploy Docs / build-and-deploy (push) Successful in 1m15s
CVE Scan & Docker Build / build-and-push (push) Successful in 2m43s
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.
2026-06-19 06:45:19 -04:00
3394726ca1 Merge branch 'main' of ssh://git.helu.ca:22022/r/mnemosyne
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 4m5s
Build & Deploy Docs / build-and-deploy (push) Successful in 1m26s
CVE Scan & Docker Build / build-and-push (push) Successful in 2m35s
2026-06-19 06:30:21 -04:00
03e3155bd6 fix(settings): load .env from repo root instead of project dir 2026-06-19 06:30:07 -04:00
929a3c8c3c fix(library): expose Delete control for Daedalus-managed libraries in UI
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 3m37s
Build & Deploy Docs / build-and-deploy (push) Successful in 1m9s
CVE Scan & Docker Build / build-and-push (push) Successful in 2m56s
The backend already allows admin delete of a workspace-scoped Library via
the shared delete_library_cascade (commit 142e967), and the confirm-delete
page carries the Daedalus caution. But the detail template was never updated
to match: the Delete button stayed hardcoded `disabled` for any library with
a workspace_id, and the banner said "Do not delete it manually." So the
working route was unreachable from the UI — admins still could not clear an
orphaned Library blocking workspace re-registration.

Make Delete an unconditional enabled link to library:library-delete (the
confirm page already warns), and rewrite the workspace banner to describe the
escape hatch (deleting here removes embedded content; Daedalus recreates and
re-embeds on the next sync) instead of forbidding it.

Template-only change; no view/model/migration changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 23:03:47 -04:00
2af72d6e82 ci: build only on push to main, not on pull_request
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 3m30s
CVE Scan & Docker Build / build-and-push (push) Successful in 2m33s
Drop the pull_request:[main] trigger so the CVE scan + Docker build runs
only when changes land on main, not when a PR is opened against it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 06:14:52 -04:00
70b1fc510b Merge pull request 'fix(tests): repair stale mock.patch targets after service refactors' (#2) from fix/stale-test-patch-targets into main
Some checks failed
CVE Scan & Docker Build / security-scan (push) Has been cancelled
CVE Scan & Docker Build / build-and-push (push) Has been cancelled
Build & Deploy Docs / build-and-deploy (push) Successful in 1m11s
Reviewed-on: #2
2026-06-18 02:01:25 +00:00
46ca2a934d Merge pull request 'feat/workspace-name-conflict-409' (#1) from feat/workspace-name-conflict-409 into main
Some checks failed
CVE Scan & Docker Build / security-scan (push) Has been cancelled
CVE Scan & Docker Build / build-and-push (push) Has been cancelled
Build & Deploy Docs / build-and-deploy (push) Has been cancelled
Reviewed-on: #1
2026-06-18 02:00:55 +00:00
539d9b6c34 fix(tests): repair stale mock.patch targets after service refactors
All checks were successful
CVE Scan & Docker Build / security-scan (pull_request) Successful in 5m24s
CVE Scan & Docker Build / build-and-push (pull_request) Successful in 2m58s
Several library tests patched symbols at import paths that no longer
expose them, so they errored (AttributeError) instead of testing anything
— giving false confidence. The underlying code is correct; only the test
patch targets were stale after earlier refactors moved imports
function-local.

- test_pipeline: patch source modules (library.models.Item,
  llm_manager.models.LLMModel, library.services.parsers.DocumentParser,
  .chunker.ContentTypeChunker, .embedding_client.EmbeddingClient,
  .vision.VisionAnalyzer, .concepts.ConceptExtractor) since pipeline.py
  imports them inside methods. default_storage stays (still module-level).
- test_search_api: patch library.services.search.SearchService (the view
  imports it function-local).
- test_tasks: patch library.services.pipeline.EmbeddingPipeline (tasks.py
  imports it function-local).
- test_search_views_admin_scope: patch library.utils.neo4j_available; the
  guard moved to utils when views._all_library_uids became a thin alias.
- test_concepts: remove SampleIndexSelectionTests — _select_sample_indices
  was deleted in the document-level concept-extraction refactor (dead test).

Not addressed here: SearchAPIAuthTest / SearchAPIValidationTest return 302
instead of 401/400. Static analysis ruled out routing, middleware, and DRF
config; reproducing needs a running server (DB-backed). Flagged for sandbox
diagnosis — not a stale-patch issue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 20:12:46 -04:00
13 changed files with 201 additions and 78 deletions

View File

@@ -91,6 +91,19 @@ SPELUNKER_S3_REGION_NAME=us-east-1
SPELUNKER_S3_USE_SSL=True
SPELUNKER_S3_VERIFY=True
# --- Kairos S3 (cross-bucket reads for ingest, source="kairos-mail") ---
# Consumed by: worker only
# Kairos renders synced mail to text documents in its own bucket and posts
# ingest requests with source="kairos-mail". These creds should be scoped
# read-only to the Kairos bucket in your secret manager.
KAIROS_S3_ENDPOINT_URL=https://nyx.helu.ca:8555
KAIROS_S3_ACCESS_KEY_ID=
KAIROS_S3_SECRET_ACCESS_KEY=
KAIROS_S3_BUCKET_NAME=kairos
KAIROS_S3_REGION_NAME=us-east-1
KAIROS_S3_USE_SSL=True
KAIROS_S3_VERIFY=True
# --- Celery / RabbitMQ (Oberon) ---------------------------------------------
# Consumed by: app (producer), worker (consumer). NOT mcp.
# Remember to percent-encode any password characters that have meaning in a

View File

@@ -3,8 +3,6 @@ name: CVE Scan & Docker Build
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: git.helu.ca
@@ -75,8 +73,6 @@ jobs:
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix=
type=raw,value=latest,enable=${{ gitea.ref == 'refs/heads/main' }}

View File

@@ -346,6 +346,13 @@ services:
- SPELUNKER_S3_REGION_NAME=${SPELUNKER_S3_REGION_NAME}
- SPELUNKER_S3_USE_SSL=${SPELUNKER_S3_USE_SSL}
- SPELUNKER_S3_VERIFY=${SPELUNKER_S3_VERIFY}
- KAIROS_S3_ENDPOINT_URL=${KAIROS_S3_ENDPOINT_URL}
- KAIROS_S3_ACCESS_KEY_ID=${KAIROS_S3_ACCESS_KEY_ID}
- KAIROS_S3_SECRET_ACCESS_KEY=${KAIROS_S3_SECRET_ACCESS_KEY}
- KAIROS_S3_BUCKET_NAME=${KAIROS_S3_BUCKET_NAME}
- KAIROS_S3_REGION_NAME=${KAIROS_S3_REGION_NAME}
- KAIROS_S3_USE_SSL=${KAIROS_S3_USE_SSL}
- KAIROS_S3_VERIFY=${KAIROS_S3_VERIFY}
# Celery / RabbitMQ
- CELERY_BROKER_URL=${CELERY_BROKER_URL}
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND}
@@ -371,7 +378,12 @@ services:
volumes:
- media:/mnt/media
healthcheck:
test: ["CMD", "celery", "-A", "mnemosyne", "inspect", "ping", "-d", "celery@$$HOSTNAME"]
# No -d destination: exec-form CMD has no shell, so $$HOSTNAME never
# expanded and the literal "celery@$HOSTNAME" matched no node → every
# check failed. There's one worker per container, so an unfiltered ping
# (any node replies = healthy) is correct. -t gives the reply room to
# round-trip through the broker on Oberon (~450ms observed) under jitter.
test: ["CMD", "celery", "-A", "mnemosyne", "inspect", "ping", "-t", "8"]
interval: 60s
timeout: 10s
retries: 3

View File

@@ -25,14 +25,7 @@
</div>
<div class="flex gap-2">
<a href="{% url 'library:library-edit' uid=library.uid %}" class="btn btn-sm btn-outline">Edit</a>
{% if library.workspace_id %}
<button type="button" class="btn btn-sm btn-error btn-outline" disabled
title="This library is managed by Daedalus. Delete it from the Daedalus workspace, not here.">
Delete
</button>
{% else %}
<a href="{% url 'library:library-delete' uid=library.uid %}" class="btn btn-sm btn-error btn-outline">Delete</a>
{% endif %}
</div>
</div>
@@ -43,8 +36,11 @@
<div class="text-sm opacity-80">
This library was created for Daedalus workspace
<code class="font-mono">{{ library.workspace_id }}</code>.
Items here are owned by the workspace; deleting the workspace in
Daedalus will remove this library. Do not delete it manually.
Normally you manage it from Daedalus. Deleting it here removes its
embedded content from Mnemosyne, but the source files still live in
Daedalus — it will be recreated and re-embedded on the next sync.
Use Delete to clear an orphaned library that is blocking workspace
re-registration.
</div>
</div>
</div>

View File

@@ -15,6 +15,18 @@
</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 %}
<div class="alert alert-warning mb-4">
<span>{{ error }}</span>
@@ -53,8 +65,12 @@
{% else %}
{% if not error %}
<div class="text-center py-12 opacity-60">
{% if scope == "all" %}
<p class="text-lg">No libraries yet.</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>
{% endif %}
{% endif %}

View File

@@ -48,30 +48,3 @@ class ConceptExtractionParsingTests(TestCase):
result = self.extractor._parse_concept_response(response)
self.assertEqual(len(result), 1)
self.assertEqual(result[0]["name"], "valid")
class SampleIndexSelectionTests(TestCase):
"""Tests for sample index selection."""
def setUp(self):
self.extractor = ConceptExtractor(MagicMock())
def test_small_total_returns_all(self):
indices = self.extractor._select_sample_indices(5, max_samples=10)
self.assertEqual(indices, [0, 1, 2, 3, 4])
def test_equal_total_returns_all(self):
indices = self.extractor._select_sample_indices(10, max_samples=10)
self.assertEqual(indices, list(range(10)))
def test_large_total_returns_max_samples(self):
indices = self.extractor._select_sample_indices(100, max_samples=10)
self.assertEqual(len(indices), 10)
# Should be evenly spaced
self.assertEqual(indices[0], 0)
self.assertEqual(indices[-1], 90)
def test_returns_integers(self):
indices = self.extractor._select_sample_indices(50, max_samples=7)
for idx in indices:
self.assertIsInstance(idx, int)

View File

@@ -48,7 +48,7 @@ class EmbeddingPipelineInitTests(TestCase):
class PipelineItemNotFoundTests(TestCase):
"""Tests for handling missing items."""
@patch("library.services.pipeline.Item")
@patch("library.models.Item")
def test_process_nonexistent_item_raises(self, mock_item_cls):
mock_item_cls.nodes.get.side_effect = Exception("Not found")
@@ -57,7 +57,7 @@ class PipelineItemNotFoundTests(TestCase):
pipeline.process_item("nonexistent-uid")
self.assertIn("Item not found", str(ctx.exception))
@patch("library.services.pipeline.Item")
@patch("library.models.Item")
def test_reprocess_nonexistent_item_raises(self, mock_item_cls):
mock_item_cls.nodes.get.side_effect = Exception("Not found")
@@ -69,9 +69,9 @@ class PipelineItemNotFoundTests(TestCase):
class PipelineNoEmbeddingModelTests(TestCase):
"""Tests for handling missing system embedding model."""
@patch("library.services.pipeline.LLMModel")
@patch("llm_manager.models.LLMModel")
@patch("library.services.pipeline.default_storage")
@patch("library.services.pipeline.DocumentParser")
@patch("library.services.parsers.DocumentParser")
def test_no_embedding_model_raises(self, mock_parser, mock_storage, mock_llm):
"""Pipeline raises ValueError if no system embedding model is configured."""
mock_llm.get_system_embedding_model.return_value = None
@@ -86,7 +86,7 @@ class PipelineNoEmbeddingModelTests(TestCase):
mock_item.chunks.all.return_value = []
mock_item.images.all.return_value = []
with patch("library.services.pipeline.Item") as mock_item_cls:
with patch("library.models.Item") as mock_item_cls:
mock_item_cls.nodes.get.return_value = mock_item
# Mock S3 read
@@ -166,11 +166,11 @@ class PipelineVisionStageTests(TestCase):
item.images.all.return_value = []
return item
@patch("library.services.pipeline.ConceptExtractor")
@patch("library.services.pipeline.EmbeddingClient")
@patch("library.services.pipeline.ContentTypeChunker")
@patch("library.services.pipeline.DocumentParser")
@patch("library.services.pipeline.LLMModel")
@patch("library.services.concepts.ConceptExtractor")
@patch("library.services.embedding_client.EmbeddingClient")
@patch("library.services.chunker.ContentTypeChunker")
@patch("library.services.parsers.DocumentParser")
@patch("llm_manager.models.LLMModel")
@patch("library.services.pipeline.default_storage")
def test_no_vision_model_marks_images_skipped(
self, mock_storage, mock_llm, mock_parser_cls,
@@ -227,12 +227,12 @@ class PipelineVisionStageTests(TestCase):
img_node.save.assert_called()
self.assertEqual(result["images_analyzed"], 0)
@patch("library.services.pipeline.VisionAnalyzer")
@patch("library.services.pipeline.ConceptExtractor")
@patch("library.services.pipeline.EmbeddingClient")
@patch("library.services.pipeline.ContentTypeChunker")
@patch("library.services.pipeline.DocumentParser")
@patch("library.services.pipeline.LLMModel")
@patch("library.services.vision.VisionAnalyzer")
@patch("library.services.concepts.ConceptExtractor")
@patch("library.services.embedding_client.EmbeddingClient")
@patch("library.services.chunker.ContentTypeChunker")
@patch("library.services.parsers.DocumentParser")
@patch("llm_manager.models.LLMModel")
@patch("library.services.pipeline.default_storage")
def test_vision_model_triggers_analysis(
self, mock_storage, mock_llm, mock_parser_cls,
@@ -287,7 +287,7 @@ class PipelineVisionStageTests(TestCase):
mock_vision_cls.assert_called_once_with(mock_vision_model, user=None)
mock_analyzer.analyze_images.assert_called_once()
@patch("library.services.pipeline.LLMModel")
@patch("llm_manager.models.LLMModel")
def test_no_images_skips_vision_entirely(self, mock_llm):
"""When there are no images, vision stage is a no-op regardless of model."""
mock_vision_model = MagicMock()
@@ -309,10 +309,10 @@ class PipelineVisionStageTests(TestCase):
patch.object(pipeline, "_store_chunks", return_value=[]), \
patch.object(pipeline, "_store_images", return_value=[]), \
patch.object(pipeline, "_associate_images_with_chunks"), \
patch("library.services.pipeline.DocumentParser") as mock_parser_cls, \
patch("library.services.pipeline.ContentTypeChunker") as mock_chunker_cls, \
patch("library.services.pipeline.EmbeddingClient"), \
patch("library.services.pipeline.VisionAnalyzer") as mock_vision_cls:
patch("library.services.parsers.DocumentParser") as mock_parser_cls, \
patch("library.services.chunker.ContentTypeChunker") as mock_chunker_cls, \
patch("library.services.embedding_client.EmbeddingClient"), \
patch("library.services.vision.VisionAnalyzer") as mock_vision_cls:
mock_parser = MagicMock()
mock_parser.parse_bytes.return_value = MagicMock(images=[], text_blocks=[])

View File

@@ -100,7 +100,7 @@ class SearchAPIResponseTest(TestCase):
self.client = APIClient()
self.client.force_authenticate(user=self.user)
@patch("library.api.views.SearchService")
@patch("library.services.search.SearchService")
def test_successful_search_response_format(self, MockService):
"""Successful search returns expected JSON structure."""
mock_response = SearchResponse(
@@ -159,7 +159,7 @@ class SearchAPIResponseTest(TestCase):
self.assertEqual(image["image_uid"], "img1")
self.assertEqual(image["image_type"], "diagram")
@patch("library.api.views.SearchService")
@patch("library.services.search.SearchService")
def test_vector_only_endpoint(self, MockService):
"""Vector-only endpoint sets correct search types."""
mock_response = SearchResponse(
@@ -184,7 +184,7 @@ class SearchAPIResponseTest(TestCase):
self.assertEqual(call_args.search_types, ["vector"])
self.assertFalse(call_args.rerank)
@patch("library.api.views.SearchService")
@patch("library.services.search.SearchService")
def test_fulltext_only_endpoint(self, MockService):
"""Fulltext-only endpoint sets correct search types."""
mock_response = SearchResponse(
@@ -208,7 +208,7 @@ class SearchAPIResponseTest(TestCase):
self.assertEqual(call_args.search_types, ["fulltext"])
self.assertFalse(call_args.rerank)
@patch("library.api.views.SearchService")
@patch("library.services.search.SearchService")
def test_reranker_skip_reason_surfaced_in_json(self, MockService):
"""``reranker_skip_reason`` propagates through the JSON API."""
mock_response = SearchResponse(

View File

@@ -48,7 +48,7 @@ class AllLibraryUidsHelperTests(TestCase):
def test_returns_empty_when_neo4j_unavailable(self):
"""Helper must not touch ``Library.nodes`` if Neo4j is down."""
with patch("library.views.neo4j_available", return_value=False):
with patch("library.utils.neo4j_available", return_value=False):
self.assertEqual(views._all_library_uids(), [])
def test_returns_every_library_uid(self):
@@ -62,7 +62,7 @@ class AllLibraryUidsHelperTests(TestCase):
fake_nodes.all.return_value = fake_libs
fake_library_cls = SimpleNamespace(nodes=fake_nodes)
with patch("library.views.neo4j_available", return_value=True), \
with patch("library.utils.neo4j_available", return_value=True), \
patch.dict("sys.modules", {"library.models": SimpleNamespace(Library=fake_library_cls)}):
result = views._all_library_uids()
@@ -83,7 +83,7 @@ class AllLibraryUidsHelperTests(TestCase):
fake_nodes.all.return_value = fake_libs
fake_library_cls = SimpleNamespace(nodes=fake_nodes)
with patch("library.views.neo4j_available", return_value=True), \
with patch("library.utils.neo4j_available", return_value=True), \
patch.dict("sys.modules", {"library.models": SimpleNamespace(Library=fake_library_cls)}):
result = views._all_library_uids()
@@ -95,7 +95,7 @@ class AllLibraryUidsHelperTests(TestCase):
fake_nodes.all.side_effect = RuntimeError("neo4j blew up")
fake_library_cls = SimpleNamespace(nodes=fake_nodes)
with patch("library.views.neo4j_available", return_value=True), \
with patch("library.utils.neo4j_available", return_value=True), \
patch.dict("sys.modules", {"library.models": SimpleNamespace(Library=fake_library_cls)}):
self.assertEqual(views._all_library_uids(), [])

View File

@@ -13,7 +13,7 @@ from django.test import TestCase, override_settings
class EmbedItemTaskTests(TestCase):
"""Tests for the embed_item task."""
@patch("library.tasks.EmbeddingPipeline")
@patch("library.services.pipeline.EmbeddingPipeline")
def test_embed_item_success(self, mock_pipeline_cls):
from library.tasks import embed_item
@@ -31,7 +31,7 @@ class EmbedItemTaskTests(TestCase):
self.assertEqual(result["item_uid"], "test-uid-123")
mock_pipeline.process_item.assert_called_once()
@patch("library.tasks.EmbeddingPipeline")
@patch("library.services.pipeline.EmbeddingPipeline")
def test_embed_item_failure(self, mock_pipeline_cls):
from library.tasks import embed_item
@@ -49,7 +49,7 @@ class EmbedItemTaskTests(TestCase):
class ReembedItemTaskTests(TestCase):
"""Tests for the reembed_item task."""
@patch("library.tasks.EmbeddingPipeline")
@patch("library.services.pipeline.EmbeddingPipeline")
def test_reembed_item_success(self, mock_pipeline_cls):
from library.tasks import reembed_item

View 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.")

View File

@@ -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},
)

View File

@@ -23,8 +23,9 @@ env = environ.Env(
DEBUG=(bool, True),
)
# Read .env file if it exists
environ.Env.read_env(BASE_DIR / ".env")
# Read .env file if it exists. Lives at the repo root (one level above the
# Django project package), shared with the docker-compose stack.
environ.Env.read_env(BASE_DIR.parent / ".env")
# --- Security ---
SECRET_KEY = env("SECRET_KEY", default="django-insecure-change-me-in-production")
@@ -267,6 +268,15 @@ SPELUNKER_S3_REGION_NAME = env("SPELUNKER_S3_REGION_NAME", default="us-east-1")
SPELUNKER_S3_USE_SSL = env.bool("SPELUNKER_S3_USE_SSL", default=False)
SPELUNKER_S3_VERIFY = env.bool("SPELUNKER_S3_VERIFY", default=True)
# Kairos renders synced mail to text documents in its own bucket.
KAIROS_S3_ENDPOINT_URL = env("KAIROS_S3_ENDPOINT_URL", default="")
KAIROS_S3_ACCESS_KEY_ID = env("KAIROS_S3_ACCESS_KEY_ID", default="")
KAIROS_S3_SECRET_ACCESS_KEY = env("KAIROS_S3_SECRET_ACCESS_KEY", default="")
KAIROS_S3_BUCKET_NAME = env("KAIROS_S3_BUCKET_NAME", default="kairos")
KAIROS_S3_REGION_NAME = env("KAIROS_S3_REGION_NAME", default="us-east-1")
KAIROS_S3_USE_SSL = env.bool("KAIROS_S3_USE_SSL", default=False)
KAIROS_S3_VERIFY = env.bool("KAIROS_S3_VERIFY", default=True)
# Registry keyed by the ingest `source` field. Unknown/blank sources fall
# back to "daedalus" for backwards compatibility.
SOURCE_S3_BUCKETS = {
@@ -288,6 +298,15 @@ SOURCE_S3_BUCKETS = {
"use_ssl": SPELUNKER_S3_USE_SSL,
"verify": SPELUNKER_S3_VERIFY,
},
"kairos-mail": {
"endpoint_url": KAIROS_S3_ENDPOINT_URL,
"access_key_id": KAIROS_S3_ACCESS_KEY_ID,
"secret_access_key": KAIROS_S3_SECRET_ACCESS_KEY,
"bucket_name": KAIROS_S3_BUCKET_NAME,
"region_name": KAIROS_S3_REGION_NAME,
"use_ssl": KAIROS_S3_USE_SSL,
"verify": KAIROS_S3_VERIFY,
},
}
# --- Celery / RabbitMQ ---