From d18c10996aa922d12d1bc1807ec18ca7974a4e5e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 18:32:46 +0000 Subject: [PATCH] feat(release): add named release anchors with codenames and roadmap integration - Add release_names.json mapping version ranges to codenames - Add release_name property to Settings in app/config.py - Update build metadata script to include codename in RUNTIME_INFO - Display release codename in status dashboard and page footer - Inject release_name globally via template response wrapper - Update ROADMAP.md with codenames for all milestone releases - Add docs/ReleaseNaming.md with naming guide and best practices - Add comprehensive tests for release name resolution Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- ROADMAP.md | 31 ++- app/config.py | 47 ++++ app/views/base.py | 2 + app/views/status.py | 4 + docs/ReleaseNaming.md | 127 +++++++++++ frontend/templates/base.html | 2 +- frontend/templates/status_dashboard.html | 2 +- release_names.json | 36 +++ scripts/generate_build_metadata.sh | 45 +++- tests/test_release_names.py | 267 +++++++++++++++++++++++ 10 files changed, 547 insertions(+), 16 deletions(-) create mode 100644 docs/ReleaseNaming.md create mode 100644 release_names.json create mode 100644 tests/test_release_names.py diff --git a/ROADMAP.md b/ROADMAP.md index 5b9120a9..d5eb0a83 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7,7 +7,20 @@ DocuElevate aims to be the premier open-source intelligent document processing platform, providing seamless integration with cloud storage providers, advanced AI-powered metadata extraction, and enterprise-grade security and scalability. -## Current Status (v0.5.0) +## Release Naming + +Each major milestone release carries a codename to anchor key project moments. These names appear in the status dashboard, build metadata, and changelog. For details, see [docs/ReleaseNaming.md](docs/ReleaseNaming.md). + +| Version Range | Codename | Theme | +|---------------|---------------|--------------------------------------------------| +| 0.5.x | **Foundation** | Core platform, multi-provider storage, AI, UI | +| 0.6.x | **Clarity** | Enhanced search, filtering, UI/UX improvements | +| 0.7.x | **Conductor** | Workflow automation, pipelines, rule-based logic | +| 1.0.x | **Summit** | Enterprise features, multi-tenancy, RBAC | +| 1.1.x | **Bridge** | Collaboration, sharing, analytics | +| 2.0.x | **Horizon** | On-premise AI, platform expansion | + +## Current Status (v0.5.0 "Foundation") ### Core Features ✅ - Multi-provider document storage (Dropbox, Google Drive, OneDrive, Nextcloud, S3, etc.) @@ -23,7 +36,7 @@ DocuElevate aims to be the premier open-source intelligent document processing p - Celery-based async task processing - OAuth2 authentication via Authentik with admin group support -## Short-term Goals (Q1-Q2 2026) - v0.4.x to v0.5.x +## Short-term Goals (Q1-Q2 2026) - v0.4.x to v0.5.x "Foundation" ### Quality & Stability 🎯 - **Test Coverage** (High Priority) @@ -53,7 +66,7 @@ DocuElevate aims to be the premier open-source intelligent document processing p - [x] Integrate Docker builds with releases ### Features - v0.4.0 -- **Enhanced Search & Filtering** +- **Enhanced Search & Filtering** → _preparing for v0.6.0 "Clarity"_ - [ ] Full-text search across documents - [ ] Advanced filtering by metadata, tags, date ranges - [ ] Saved search queries @@ -67,8 +80,8 @@ DocuElevate aims to be the premier open-source intelligent document processing p - [ ] Progress indicators for long-running tasks - [ ] Real-time notifications via WebSocket -### Features - v0.5.0 -- **Workflow Automation** +### Features - v0.5.0 "Foundation" +- **Workflow Automation** → _evolving into v0.7.0 "Conductor"_ - [ ] Custom processing pipelines - [ ] Conditional routing based on document type - [ ] Scheduled batch processing @@ -82,9 +95,9 @@ DocuElevate aims to be the premier open-source intelligent document processing p - [ ] Automatic duplicate detection - [ ] Intelligent document splitting -## Medium-term Goals (Q3-Q4 2026) - v1.0.x +## Medium-term Goals (Q3-Q4 2026) - v1.0.x "Summit" -### Enterprise Features - v1.0.0 +### Enterprise Features - v1.0.0 "Summit" - **Multi-tenancy** - [ ] Organization/team management - [ ] Role-based access control (RBAC) @@ -106,7 +119,7 @@ DocuElevate aims to be the premier open-source intelligent document processing p - [ ] Custom webhook receivers - [ ] GraphQL API -### Features - v1.1.0 +### Features - v1.1.0 "Bridge" - **Collaboration** - [ ] Document sharing with expiring links - [ ] Comments and annotations @@ -121,7 +134,7 @@ DocuElevate aims to be the premier open-source intelligent document processing p - [ ] Cost analysis per provider - [ ] Export reports (PDF, CSV, Excel) -## Long-term Goals (2027+) - v2.0+ +## Long-term Goals (2027+) - v2.0+ "Horizon" ### Strategic Initiatives - **On-Premise AI Models** diff --git a/app/config.py b/app/config.py index 8dce1995..c8e14f9e 100644 --- a/app/config.py +++ b/app/config.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 +import json import os from typing import Any, List, Optional, Union @@ -599,5 +600,51 @@ class Settings(BaseSettings): # Return basic info if file not found return f"Version: {self.version}\nBuild Date: {self.build_date}\nGit SHA: {self.git_sha}" + @property + def release_name(self) -> str | None: + """Get the release codename for the current version from release_names.json. + + Looks up the current version's minor version prefix (e.g., '0.5' for '0.5.3') + in release_names.json to find the associated codename. Returns None if no + codename is defined for the current version. + + Returns: + The release codename string, or None if not found. + """ + version = self.version + if not version or version == "unknown": + return None + + release_names_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "release_names.json") + if not os.path.exists(release_names_file): + return None + + try: + with open(release_names_file, "r") as f: + data = json.load(f) + + releases = data.get("releases", {}) + + # Try exact version match first (e.g., "0.5.0") + if version in releases: + return releases[version].get("codename") + + # Try minor version prefix (e.g., "0.5" for "0.5.3") + parts = version.split(".") + if len(parts) >= 2: + minor_prefix = f"{parts[0]}.{parts[1]}" + if minor_prefix in releases: + return releases[minor_prefix].get("codename") + + # Try major version prefix (e.g., "1" for "1.0.0") + if len(parts) >= 1: + major_prefix = parts[0] + if major_prefix in releases: + return releases[major_prefix].get("codename") + + return None + except (json.JSONDecodeError, KeyError, IndexError): + return None + settings = Settings() diff --git a/app/views/base.py b/app/views/base.py index 8b10c514..941c16ed 100644 --- a/app/views/base.py +++ b/app/views/base.py @@ -30,6 +30,7 @@ def template_response_with_version(*args, **kwargs): # If context dict is provided, add version to it if len(args) >= 2 and isinstance(args[1], dict): args[1].setdefault("version", settings.version) + args[1].setdefault("release_name", getattr(settings, "release_name", None)) # Inject CSRF token from request state when available req = args[1].get("request") if req is not None and hasattr(req.state, "csrf_token"): @@ -38,6 +39,7 @@ def template_response_with_version(*args, **kwargs): args[1].setdefault("ui_default_color_scheme", getattr(settings, "ui_default_color_scheme", "system")) elif "context" in kwargs and isinstance(kwargs["context"], dict): kwargs["context"].setdefault("version", settings.version) + kwargs["context"].setdefault("release_name", getattr(settings, "release_name", None)) req = kwargs["context"].get("request") if req is not None and hasattr(req.state, "csrf_token"): kwargs["context"].setdefault("csrf_token", req.state.csrf_token) diff --git a/app/views/status.py b/app/views/status.py index 8c44c993..585a9df4 100644 --- a/app/views/status.py +++ b/app/views/status.py @@ -73,12 +73,16 @@ async def status_dashboard(request: Request): # Get notification URLs for the notification box notification_urls = getattr(settings, "notification_urls", []) + # Get release codename if available + release_name = getattr(settings, "release_name", None) + return templates.TemplateResponse( "status_dashboard.html", { "request": request, "providers": providers, "app_version": settings.version, + "release_name": release_name, "build_date": build_date, "debug_enabled": getattr(settings, "debug", False), "last_check": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), diff --git a/docs/ReleaseNaming.md b/docs/ReleaseNaming.md new file mode 100644 index 00000000..348aa010 --- /dev/null +++ b/docs/ReleaseNaming.md @@ -0,0 +1,127 @@ +# Release Naming Guide + +DocuElevate uses **automated semantic versioning** via [python-semantic-release](https://github.com/python-semantic-release/python-semantic-release) combined with **named release anchors** (codenames) for milestone releases. This guide explains how the two systems work together. + +## How It Works + +### Automated Versioning (Patch & Minor Releases) + +Every merge to `main` is analyzed by `python-semantic-release`: + +- **`feat:` commits** → minor version bump (e.g., 0.5.0 → 0.6.0) +- **`fix:` / `perf:` commits** → patch version bump (e.g., 0.5.0 → 0.5.1) +- **`docs:` / `chore:` / etc.** → no version bump + +This happens automatically — no manual intervention needed. + +### Named Release Anchors (Codenames) + +Major milestone releases carry a **codename** that anchors the release in project history. Codenames: + +- Are defined in [`release_names.json`](../release_names.json) at the project root +- Map to **minor version ranges** (e.g., all `0.5.x` releases share the codename "Foundation") +- Appear in the status dashboard, build metadata, and footer +- Do **not** interfere with automatic version numbering + +### Current Release Names + +| Version Range | Codename | Description | +|---------------|----------------|----------------------------------------------------------| +| 0.5.x | **Foundation** | Core platform with multi-provider storage, AI, and UI | +| 0.6.x | **Clarity** | Enhanced search, filtering, and improved UI/UX | +| 0.7.x | **Conductor** | Workflow automation, custom pipelines, rule-based logic | +| 1.0.x | **Summit** | Enterprise-ready: multi-tenancy, RBAC, horizontal scaling| +| 1.1.x | **Bridge** | Collaboration features, document sharing, analytics | +| 2.0.x | **Horizon** | On-premise AI, advanced management, platform expansion | + +## Adding a New Release Name + +1. **Edit `release_names.json`** at the project root: + +```json +{ + "releases": { + "0.8": { + "codename": "YourCodename", + "description": "Short description of what this release series focuses on", + "milestone": "v0.8.0 - Your Milestone Name" + } + } +} +``` + +2. **Update `ROADMAP.md`** to include the codename in the appropriate milestone section. + +3. **Update this documentation** to add the new entry to the table above. + +The codename will automatically appear in: +- The application footer (all pages) +- The status dashboard (`/status`) +- Build metadata (`RUNTIME_INFO` file) + +## How the Lookup Works + +The application resolves codenames using a cascading lookup against the current version: + +1. **Exact match**: Checks if the full version (e.g., `0.5.3`) has an entry +2. **Minor prefix**: Checks the minor version prefix (e.g., `0.5`) +3. **Major prefix**: Checks the major version prefix (e.g., `0`) + +This means all patch releases within a minor version series inherit the same codename. + +## Codename Naming Conventions + +When choosing codenames, follow these guidelines: + +- **Use single, evocative words** that relate to the release's theme +- **Keep names professional** — they appear in user-facing UI +- **Pick names that hint at the milestone's focus** (e.g., "Foundation" for core platform, "Conductor" for workflow automation) +- **Avoid names that could become dated** or reference external products +- **Ensure uniqueness** — no two releases should share a codename + +## Integration with Milestones + +Each codename maps to a GitHub milestone. The `milestone` field in `release_names.json` matches the milestone title used for issue tracking: + +``` +v0.7.0 - Workflow Automation → codename: "Conductor" +v1.0.0 - Enterprise → codename: "Summit" +``` + +This creates a clear link between planning (milestones), delivery (releases), and communication (codenames). + +## Best Practices: Blending Automated and Named Releases + +### Do + +- ✅ Let semantic-release handle all version numbering automatically +- ✅ Use codenames for **milestone releases** (minor/major versions), not every patch +- ✅ Reference codenames in release notes and changelogs for major versions +- ✅ Keep `release_names.json` in sync with `ROADMAP.md` +- ✅ Announce codenames in GitHub Release descriptions for milestone versions + +### Don't + +- ❌ Manually edit the `VERSION` file — it's managed by semantic-release +- ❌ Create codenames for every patch release (0.5.1, 0.5.2, etc.) +- ❌ Use codenames that conflict with version numbers +- ❌ Skip updating `release_names.json` when adding a new milestone to the roadmap + +## Where Codenames Appear + +| Location | Format | +|-------------------------|-----------------------------------------| +| Status dashboard | `App Version: 0.5.3 "Foundation"` | +| Page footer | `Version 0.5.3 "Foundation"` | +| RUNTIME_INFO metadata | `Release Name: Foundation` | +| ROADMAP.md | Section headers include codenames | + +## File Reference + +| File | Purpose | +|---------------------------|--------------------------------------------| +| `release_names.json` | Source of truth for version-to-codename map| +| `app/config.py` | `release_name` property reads the JSON | +| `scripts/generate_build_metadata.sh` | Includes codename in RUNTIME_INFO | +| `app/views/base.py` | Injects `release_name` into all templates | +| `ROADMAP.md` | Displays codenames alongside milestones | diff --git a/frontend/templates/base.html b/frontend/templates/base.html index c0555bc5..b5573358 100644 --- a/frontend/templates/base.html +++ b/frontend/templates/base.html @@ -230,7 +230,7 @@ License - Attributions - - Version {{ app_version|default(version, true) }} + Version {{ app_version|default(version, true) }}{% if release_name %} "{{ release_name }}"{% endif %} diff --git a/frontend/templates/status_dashboard.html b/frontend/templates/status_dashboard.html index bd4a0bbc..c6e57e40 100644 --- a/frontend/templates/status_dashboard.html +++ b/frontend/templates/status_dashboard.html @@ -13,7 +13,7 @@ This dashboard shows the status of all configured integrations and targets.
App Version: {{ app_version }}
+App Version: {{ app_version }}{% if release_name %} "{{ release_name }}"{% endif %}
Build Date: {{ build_date }}
Debug Mode: {{ "Enabled" if debug_enabled else "Disabled" }}
{% if last_check %} diff --git a/release_names.json b/release_names.json new file mode 100644 index 00000000..2eeb6e20 --- /dev/null +++ b/release_names.json @@ -0,0 +1,36 @@ +{ + "_description": "Maps version patterns to release codenames. Used by the application at runtime and in build metadata.", + "_format": "Each key is a version string (e.g., '0.5.0') or a minor version prefix (e.g., '0.5'). The value is an object with 'codename' and optionally 'description' and 'milestone'.", + "releases": { + "0.5": { + "codename": "Foundation", + "description": "Core platform with multi-provider storage, AI extraction, and web UI", + "milestone": "v0.5.0 - Core Platform" + }, + "0.6": { + "codename": "Clarity", + "description": "Enhanced search, filtering, and improved UI/UX", + "milestone": "v0.6.0 - Search & UX" + }, + "0.7": { + "codename": "Conductor", + "description": "Workflow automation, custom pipelines, and rule-based processing", + "milestone": "v0.7.0 - Workflow Automation" + }, + "1.0": { + "codename": "Summit", + "description": "Enterprise-ready with multi-tenancy, RBAC, and horizontal scaling", + "milestone": "v1.0.0 - Enterprise" + }, + "1.1": { + "codename": "Bridge", + "description": "Collaboration features, document sharing, and analytics", + "milestone": "v1.1.0 - Collaboration" + }, + "2.0": { + "codename": "Horizon", + "description": "On-premise AI, advanced document management, and platform expansion", + "milestone": "v2.0.0 - Next Generation" + } + } +} diff --git a/scripts/generate_build_metadata.sh b/scripts/generate_build_metadata.sh index 6639b57e..06a0dd4d 100755 --- a/scripts/generate_build_metadata.sh +++ b/scripts/generate_build_metadata.sh @@ -68,19 +68,54 @@ else VERSION="unknown" fi +# Look up release codename from release_names.json +RELEASE_NAME="" +if [ -f "release_names.json" ] && command -v python3 > /dev/null 2>&1; then + MINOR_PREFIX=$(echo "${VERSION}" | cut -d. -f1-2) + RELEASE_NAME=$(python3 -c " +import json, sys +try: + with open('release_names.json') as f: + data = json.load(f) + releases = data.get('releases', {}) + version = '${VERSION}' + minor = '${MINOR_PREFIX}' + codename = None + if version in releases: + codename = releases[version].get('codename') + elif minor in releases: + codename = releases[minor].get('codename') + if codename: + print(codename) +except Exception: + pass +" 2>/dev/null || true) +fi + +if [ -n "${RELEASE_NAME}" ]; then + echo "✓ Release Name: ${RELEASE_NAME}" +fi + # Generate RUNTIME_INFO with combined metadata -cat > RUNTIME_INFO << EOF -DocuElevate Build Information +RUNTIME_INFO_CONTENT="DocuElevate Build Information ============================== Version: ${VERSION} Build Date: ${BUILD_DATE} Git Commit: ${GIT_FULL_SHA} Git Short SHA: ${GIT_SHA} Git Branch: ${GIT_BRANCH} -Commit Date: ${GIT_COMMIT_DATE} +Commit Date: ${GIT_COMMIT_DATE}" + +if [ -n "${RELEASE_NAME}" ]; then + RUNTIME_INFO_CONTENT="${RUNTIME_INFO_CONTENT} +Release Name: ${RELEASE_NAME}" +fi + +RUNTIME_INFO_CONTENT="${RUNTIME_INFO_CONTENT} Build Timestamp: $(date -u '+%Y-%m-%dT%H:%M:%SZ') -============================== -EOF +==============================" + +echo "${RUNTIME_INFO_CONTENT}" > RUNTIME_INFO echo "✓ RUNTIME_INFO generated" echo "" diff --git a/tests/test_release_names.py b/tests/test_release_names.py new file mode 100644 index 00000000..04cb3a0f --- /dev/null +++ b/tests/test_release_names.py @@ -0,0 +1,267 @@ +"""Tests for release naming functionality in app/config.py and app/views/status.py.""" + +import json +import os +from unittest.mock import Mock, patch + +import pytest + + +@pytest.mark.unit +class TestReleaseNameProperty: + """Tests for the Settings.release_name property.""" + + def test_release_name_returns_codename_for_minor_prefix(self, tmp_path): + """Test release_name returns codename matching minor version prefix.""" + release_data = { + "releases": { + "0.5": {"codename": "Foundation", "description": "Core platform"}, + } + } + release_file = tmp_path / "release_names.json" + release_file.write_text(json.dumps(release_data)) + + with ( + patch("app.config.Settings.version", new_callable=lambda: property(lambda self: "0.5.3")), + patch("app.config.os.path.join", return_value=str(release_file)), + patch("app.config.os.path.exists", return_value=True), + ): + from app.config import Settings + + s = Settings.__new__(Settings) + # Directly call the property with mocked file path + with ( + patch.object(type(s), "version", new_callable=lambda: property(lambda self: "0.5.3")), + patch("app.config.os.path.dirname"), + patch("app.config.os.path.join", return_value=str(release_file)), + patch("app.config.os.path.exists", return_value=True), + ): + result = s.release_name + assert result == "Foundation" + + def test_release_name_returns_codename_for_exact_match(self, tmp_path): + """Test release_name returns codename for exact version match.""" + release_data = { + "releases": { + "1.0.0": {"codename": "Summit"}, + } + } + release_file = tmp_path / "release_names.json" + release_file.write_text(json.dumps(release_data)) + + from app.config import Settings + + s = Settings.__new__(Settings) + with ( + patch.object(type(s), "version", new_callable=lambda: property(lambda self: "1.0.0")), + patch("app.config.os.path.dirname"), + patch("app.config.os.path.join", return_value=str(release_file)), + patch("app.config.os.path.exists", return_value=True), + ): + result = s.release_name + assert result == "Summit" + + def test_release_name_returns_none_when_no_match(self, tmp_path): + """Test release_name returns None when version has no codename.""" + release_data = { + "releases": { + "0.5": {"codename": "Foundation"}, + } + } + release_file = tmp_path / "release_names.json" + release_file.write_text(json.dumps(release_data)) + + from app.config import Settings + + s = Settings.__new__(Settings) + with ( + patch.object(type(s), "version", new_callable=lambda: property(lambda self: "0.9.1")), + patch("app.config.os.path.dirname"), + patch("app.config.os.path.join", return_value=str(release_file)), + patch("app.config.os.path.exists", return_value=True), + ): + result = s.release_name + assert result is None + + def test_release_name_returns_none_when_file_missing(self): + """Test release_name returns None when release_names.json doesn't exist.""" + from app.config import Settings + + s = Settings.__new__(Settings) + with ( + patch.object(type(s), "version", new_callable=lambda: property(lambda self: "0.5.0")), + patch("app.config.os.path.dirname"), + patch("app.config.os.path.join", return_value="/nonexistent/release_names.json"), + patch("app.config.os.path.exists", return_value=False), + ): + result = s.release_name + assert result is None + + def test_release_name_returns_none_for_unknown_version(self): + """Test release_name returns None when version is 'unknown'.""" + from app.config import Settings + + s = Settings.__new__(Settings) + with patch.object(type(s), "version", new_callable=lambda: property(lambda self: "unknown")): + result = s.release_name + assert result is None + + def test_release_name_returns_none_for_empty_version(self): + """Test release_name returns None when version is empty.""" + from app.config import Settings + + s = Settings.__new__(Settings) + with patch.object(type(s), "version", new_callable=lambda: property(lambda self: "")): + result = s.release_name + assert result is None + + def test_release_name_handles_invalid_json(self, tmp_path): + """Test release_name handles corrupt JSON gracefully.""" + release_file = tmp_path / "release_names.json" + release_file.write_text("{invalid json") + + from app.config import Settings + + s = Settings.__new__(Settings) + with ( + patch.object(type(s), "version", new_callable=lambda: property(lambda self: "0.5.0")), + patch("app.config.os.path.dirname"), + patch("app.config.os.path.join", return_value=str(release_file)), + patch("app.config.os.path.exists", return_value=True), + ): + result = s.release_name + assert result is None + + def test_release_name_handles_missing_releases_key(self, tmp_path): + """Test release_name handles JSON without 'releases' key.""" + release_file = tmp_path / "release_names.json" + release_file.write_text(json.dumps({"something_else": {}})) + + from app.config import Settings + + s = Settings.__new__(Settings) + with ( + patch.object(type(s), "version", new_callable=lambda: property(lambda self: "0.5.0")), + patch("app.config.os.path.dirname"), + patch("app.config.os.path.join", return_value=str(release_file)), + patch("app.config.os.path.exists", return_value=True), + ): + result = s.release_name + assert result is None + + def test_release_name_prefers_exact_over_minor(self, tmp_path): + """Test release_name prefers exact version match over minor prefix.""" + release_data = { + "releases": { + "0.5.0": {"codename": "ExactMatch"}, + "0.5": {"codename": "MinorMatch"}, + } + } + release_file = tmp_path / "release_names.json" + release_file.write_text(json.dumps(release_data)) + + from app.config import Settings + + s = Settings.__new__(Settings) + with ( + patch.object(type(s), "version", new_callable=lambda: property(lambda self: "0.5.0")), + patch("app.config.os.path.dirname"), + patch("app.config.os.path.join", return_value=str(release_file)), + patch("app.config.os.path.exists", return_value=True), + ): + result = s.release_name + assert result == "ExactMatch" + + +@pytest.mark.unit +class TestReleaseNameInStatusView: + """Tests for release name display in status dashboard.""" + + @patch("app.views.status.get_provider_status") + @patch("app.views.status.templates") + @patch("app.views.status.settings") + @patch("app.views.status.os.path.exists") + @pytest.mark.asyncio + async def test_status_dashboard_includes_release_name( + self, mock_exists, mock_settings, mock_templates, mock_providers + ): + """Test status dashboard passes release_name to template context.""" + from app.views.status import status_dashboard + + mock_exists.return_value = False + mock_providers.return_value = {} + mock_settings.version = "0.5.3" + mock_settings.build_date = "2024-01-01" + mock_settings.debug = False + mock_settings.git_sha = "abc123" + mock_settings.notification_urls = [] + mock_settings.release_name = "Foundation" + + mock_request = Mock() + await status_dashboard(mock_request) + + call_args = mock_templates.TemplateResponse.call_args + context = call_args[0][1] + assert context["release_name"] == "Foundation" + + @patch("app.views.status.get_provider_status") + @patch("app.views.status.templates") + @patch("app.views.status.settings") + @patch("app.views.status.os.path.exists") + @pytest.mark.asyncio + async def test_status_dashboard_handles_missing_release_name( + self, mock_exists, mock_settings, mock_templates, mock_providers + ): + """Test status dashboard handles missing release_name attribute gracefully.""" + from app.views.status import status_dashboard + + mock_exists.return_value = False + mock_providers.return_value = {} + mock_settings.version = "0.9.0" + mock_settings.build_date = "2024-01-01" + mock_settings.debug = False + mock_settings.git_sha = "abc123" + mock_settings.notification_urls = [] + # Simulate settings without release_name attribute + del mock_settings.release_name + + mock_request = Mock() + await status_dashboard(mock_request) + + call_args = mock_templates.TemplateResponse.call_args + context = call_args[0][1] + assert context["release_name"] is None + + +@pytest.mark.unit +class TestReleaseNamesJson: + """Tests for the release_names.json data file.""" + + def test_release_names_json_is_valid(self): + """Test that release_names.json is valid JSON.""" + release_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "release_names.json") + with open(release_file, "r") as f: + data = json.load(f) + + assert "releases" in data + assert isinstance(data["releases"], dict) + + def test_release_names_json_entries_have_codename(self): + """Test that all entries in release_names.json have a codename.""" + release_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "release_names.json") + with open(release_file, "r") as f: + data = json.load(f) + + for version, entry in data["releases"].items(): + assert "codename" in entry, f"Missing codename for version {version}" + assert isinstance(entry["codename"], str), f"Codename for {version} must be a string" + assert len(entry["codename"]) > 0, f"Codename for {version} must not be empty" + + def test_release_names_json_codenames_are_unique(self): + """Test that all codenames in release_names.json are unique.""" + release_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "release_names.json") + with open(release_file, "r") as f: + data = json.load(f) + + codenames = [entry["codename"] for entry in data["releases"].values()] + assert len(codenames) == len(set(codenames)), "Codenames must be unique"