diff --git a/Dockerfile b/Dockerfile index 5bcf687..70ce399 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,7 @@ RUN apt-get update \ WORKDIR /app -COPY pyproject.toml README.md ./ +COPY pyproject.toml README.md CHANGELOG.md ./ COPY src ./src COPY manage.py ./ COPY scripts/docker-entrypoint ./scripts/docker-entrypoint diff --git a/src/pobsync_backend/templates/pobsync_backend/base.html b/src/pobsync_backend/templates/pobsync_backend/base.html index 7177a6e..2bda493 100644 --- a/src/pobsync_backend/templates/pobsync_backend/base.html +++ b/src/pobsync_backend/templates/pobsync_backend/base.html @@ -375,6 +375,7 @@ SSH Keys Self Check Logs + Changelog Status API {{ request.user.username }} diff --git a/src/pobsync_backend/templates/pobsync_backend/changelog.html b/src/pobsync_backend/templates/pobsync_backend/changelog.html new file mode 100644 index 0000000..87570fb --- /dev/null +++ b/src/pobsync_backend/templates/pobsync_backend/changelog.html @@ -0,0 +1,41 @@ +{% extends "pobsync_backend/base.html" %} + +{% block title %}Changelog - pobsync{% endblock %} + +{% block content %} +

Changelog

+ +
+ Back to dashboard +
+ +
+
+
Installed version: {{ app_version }}
+
Source: {{ changelog_path }}
+ {% if missing %} +
missing
+ {% endif %} +
+ +
+ {% for block in changelog_blocks %} + {% if block.kind == "heading" %} + {% if block.level == 1 %} +

{{ block.text }}

+ {% else %} +

{{ block.text }}

+ {% endif %} + {% elif block.kind == "list" %} + + {% else %} +

{{ block.text }}

+ {% endif %} + {% endfor %} +
+
+{% endblock %} diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index 0303bf9..f648061 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -31,6 +31,30 @@ class ViewTests(TestCase): self.assertEqual(response.status_code, 302) self.assertIn("/admin/login/", response["Location"]) + def test_changelog_requires_staff_login(self) -> None: + response = self.client.get(reverse("changelog")) + + self.assertEqual(response.status_code, 302) + self.assertIn("/admin/login/", response["Location"]) + + def test_changelog_renders_repository_changelog(self) -> None: + self.client.force_login(self.staff_user) + with TemporaryDirectory() as tmp: + changelog = Path(tmp) / "CHANGELOG.md" + changelog.write_text( + "# Changelog\n\n## 1.0.0 - 2026-05-21\n\n- Django control panel\n- Native systemd installer\n", + encoding="utf-8", + ) + + with override_settings(BASE_DIR=Path(tmp)): + response = self.client.get(reverse("changelog")) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Installed version:") + self.assertContains(response, "1.0.0 - 2026-05-21") + self.assertContains(response, "Django control panel") + self.assertContains(response, "Native systemd installer") + def test_dashboard_renders_hosts_and_latest_runs(self) -> None: self.client.force_login(self.staff_user) host = HostConfig.objects.create(host="web-01", address="web-01.example.test") diff --git a/src/pobsync_backend/views.py b/src/pobsync_backend/views.py index bc78428..2d22176 100644 --- a/src/pobsync_backend/views.py +++ b/src/pobsync_backend/views.py @@ -15,6 +15,7 @@ from django.shortcuts import get_object_or_404, redirect, render from django.utils import timezone from django.views.decorators.http import require_POST +from pobsync import __version__ from pobsync.errors import PobsyncError from pobsync.config.defaults import DEFAULT_EXCLUDES, DEFAULT_RSYNC_ARGS @@ -88,6 +89,28 @@ def dashboard(request): return render(request, "pobsync_backend/dashboard.html", context) +@staff_member_required +def changelog(request): + changelog_path = Path(settings.BASE_DIR) / "CHANGELOG.md" + try: + changelog_text = changelog_path.read_text(encoding="utf-8") + missing = False + except FileNotFoundError: + changelog_text = "CHANGELOG.md was not found in this installation." + missing = True + + return render( + request, + "pobsync_backend/changelog.html", + { + "app_version": __version__, + "changelog_blocks": _parse_changelog(changelog_text), + "changelog_path": changelog_path, + "missing": missing, + }, + ) + + @staff_member_required def self_check(request): checks = collect_self_checks() @@ -793,6 +816,49 @@ def _pretty_json(value: object) -> str: return json.dumps(value or {}, indent=2, sort_keys=True) +def _parse_changelog(text: str) -> list[dict[str, object]]: + blocks: list[dict[str, object]] = [] + paragraph: list[str] = [] + list_items: list[str] = [] + + def flush_paragraph() -> None: + if paragraph: + blocks.append({"kind": "paragraph", "text": " ".join(paragraph)}) + paragraph.clear() + + def flush_list() -> None: + if list_items: + blocks.append({"kind": "list", "items": list(list_items)}) + list_items.clear() + + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line: + flush_paragraph() + flush_list() + continue + + if line.startswith("#"): + flush_paragraph() + flush_list() + marker, _space, heading = line.partition(" ") + level = min(max(len(marker), 1), 3) + blocks.append({"kind": "heading", "level": level, "text": heading.strip() or line.lstrip("#").strip()}) + continue + + if line.startswith("- "): + flush_paragraph() + list_items.append(line[2:].strip()) + continue + + flush_list() + paragraph.append(line) + + flush_paragraph() + flush_list() + return blocks + + def _snapshot_restore_guidance(snapshot: SnapshotRecord) -> dict[str, str]: source_path = Path(snapshot.path) / "data" destination_path = Path("/restore") / snapshot.host.host diff --git a/src/pobsync_server/urls.py b/src/pobsync_server/urls.py index 32cb3b9..1973928 100644 --- a/src/pobsync_server/urls.py +++ b/src/pobsync_server/urls.py @@ -8,6 +8,7 @@ from pobsync_backend import api, views urlpatterns = [ path("", views.dashboard, name="dashboard"), + path("changelog/", views.changelog, name="changelog"), path("self-check/", views.self_check, name="self_check"), path("logs/", views.logs, name="logs"), path("config/global/", views.edit_global_config, name="edit_global_config"),