(release) Add Django changelog page

Expose the repository CHANGELOG.md through a staff-only Django view and
link it from the main navigation.

Render a small safe subset of Markdown without adding a runtime dependency,
copy the changelog into the Docker image, and cover the page with view tests.
This commit is contained in:
2026-05-21 03:10:31 +02:00
parent beca073ddc
commit 404b7f7500
6 changed files with 134 additions and 1 deletions

View File

@@ -10,7 +10,7 @@ RUN apt-get update \
WORKDIR /app WORKDIR /app
COPY pyproject.toml README.md ./ COPY pyproject.toml README.md CHANGELOG.md ./
COPY src ./src COPY src ./src
COPY manage.py ./ COPY manage.py ./
COPY scripts/docker-entrypoint ./scripts/docker-entrypoint COPY scripts/docker-entrypoint ./scripts/docker-entrypoint

View File

@@ -375,6 +375,7 @@
<a href="{% url 'ssh_credentials' %}">SSH Keys</a> <a href="{% url 'ssh_credentials' %}">SSH Keys</a>
<a href="{% url 'self_check' %}">Self Check</a> <a href="{% url 'self_check' %}">Self Check</a>
<a href="{% url 'logs' %}">Logs</a> <a href="{% url 'logs' %}">Logs</a>
<a href="{% url 'changelog' %}">Changelog</a>
<a href="/api/status/">Status API</a> <a href="/api/status/">Status API</a>
<span class="spacer"></span> <span class="spacer"></span>
<span class="muted">{{ request.user.username }}</span> <span class="muted">{{ request.user.username }}</span>

View File

@@ -0,0 +1,41 @@
{% extends "pobsync_backend/base.html" %}
{% block title %}Changelog - pobsync{% endblock %}
{% block content %}
<h1>Changelog</h1>
<section class="actions" aria-label="Changelog actions">
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
</section>
<section class="panel">
<div class="stack spaced">
<div><strong>Installed version:</strong> {{ app_version }}</div>
<div class="muted">Source: {{ changelog_path }}</div>
{% if missing %}
<div class="status warning">missing</div>
{% endif %}
</div>
<div class="stack">
{% for block in changelog_blocks %}
{% if block.kind == "heading" %}
{% if block.level == 1 %}
<h2>{{ block.text }}</h2>
{% else %}
<h3>{{ block.text }}</h3>
{% endif %}
{% elif block.kind == "list" %}
<ul>
{% for item in block.items %}
<li>{{ item }}</li>
{% endfor %}
</ul>
{% else %}
<p>{{ block.text }}</p>
{% endif %}
{% endfor %}
</div>
</section>
{% endblock %}

View File

@@ -31,6 +31,30 @@ class ViewTests(TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertIn("/admin/login/", response["Location"]) 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: def test_dashboard_renders_hosts_and_latest_runs(self) -> None:
self.client.force_login(self.staff_user) self.client.force_login(self.staff_user)
host = HostConfig.objects.create(host="web-01", address="web-01.example.test") host = HostConfig.objects.create(host="web-01", address="web-01.example.test")

View File

@@ -15,6 +15,7 @@ from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone from django.utils import timezone
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from pobsync import __version__
from pobsync.errors import PobsyncError from pobsync.errors import PobsyncError
from pobsync.config.defaults import DEFAULT_EXCLUDES, DEFAULT_RSYNC_ARGS 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) 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 @staff_member_required
def self_check(request): def self_check(request):
checks = collect_self_checks() checks = collect_self_checks()
@@ -793,6 +816,49 @@ def _pretty_json(value: object) -> str:
return json.dumps(value or {}, indent=2, sort_keys=True) 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]: def _snapshot_restore_guidance(snapshot: SnapshotRecord) -> dict[str, str]:
source_path = Path(snapshot.path) / "data" source_path = Path(snapshot.path) / "data"
destination_path = Path("/restore") / snapshot.host.host destination_path = Path("/restore") / snapshot.host.host

View File

@@ -8,6 +8,7 @@ from pobsync_backend import api, views
urlpatterns = [ urlpatterns = [
path("", views.dashboard, name="dashboard"), path("", views.dashboard, name="dashboard"),
path("changelog/", views.changelog, name="changelog"),
path("self-check/", views.self_check, name="self_check"), path("self-check/", views.self_check, name="self_check"),
path("logs/", views.logs, name="logs"), path("logs/", views.logs, name="logs"),
path("config/global/", views.edit_global_config, name="edit_global_config"), path("config/global/", views.edit_global_config, name="edit_global_config"),