(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:
@@ -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
|
||||
|
||||
@@ -375,6 +375,7 @@
|
||||
<a href="{% url 'ssh_credentials' %}">SSH Keys</a>
|
||||
<a href="{% url 'self_check' %}">Self Check</a>
|
||||
<a href="{% url 'logs' %}">Logs</a>
|
||||
<a href="{% url 'changelog' %}">Changelog</a>
|
||||
<a href="/api/status/">Status API</a>
|
||||
<span class="spacer"></span>
|
||||
<span class="muted">{{ request.user.username }}</span>
|
||||
|
||||
41
src/pobsync_backend/templates/pobsync_backend/changelog.html
Normal file
41
src/pobsync_backend/templates/pobsync_backend/changelog.html
Normal 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 %}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user