feat(links): log CONTENT_EDIT on text changes (no content persisted)

Previously, editing a custom link's text content created a spurious
URL_CHANGE row (fixed last commit) but no audit entry at all for the
actual content change. The product requirement: a change-log row SHOULD
be created so auditors see content was edited, but should NOT persist
the content itself.

LinkUpdateView now:

- Captures self.original_text in get_initial() alongside
  self.original_url.
- After super().form_valid() writes the new text, compares old vs new
  text. If different, creates a LinkChangeLog with
  change_type=CONTENT_EDIT and metadata={'changed': True} — no content
  payload. Applies to both LINK and CUSTOM link types.
- URL-change logging behaviour is unchanged.

Tests in TestCustomLinkEditUrlChangeLog now cover:
  - custom link text edit creates CONTENT_EDIT, no URL_CHANGE
  - custom link text unchanged creates no log
  - regular link text change creates CONTENT_EDIT
  - regular link URL change still logs URL_CHANGE
This commit is contained in:
2026-07-16 11:24:04 +10:00
parent cb620b6b4e
commit 174b296c62
2 changed files with 85 additions and 3 deletions
+19
View File
@@ -171,6 +171,10 @@ class LinkUpdateView(UpdateView):
def get_initial(self):
initial = super().get_initial()
self.original_url = self.object.original_url
# Capture the existing content so form_valid can detect text edits
# and log them as a CONTENT_EDIT change (without persisting the
# content itself).
self.original_text = self.object.text
return initial
def get_context_data(self, **kwargs):
@@ -216,6 +220,21 @@ class LinkUpdateView(UpdateView):
else:
logger.info("URL did not change")
# Log a CONTENT_EDIT entry when the link's text changed, regardless of
# link type. We deliberately don't persist the actual content — only a
# marker that it changed (per product requirement).
new_text = form.cleaned_data.get('text') or ''
if (self.original_text or '') != new_text:
try:
LinkChangeLog.objects.create(
link=self.object,
change_type=LinkChangeLog.ChangeType.CONTENT_EDIT,
metadata={'changed': True},
)
logger.info(f"Created CONTENT_EDIT LinkChangeLog for link {self.object.pk}")
except Exception as e:
logger.error(f"Error creating CONTENT_EDIT LinkChangeLog for link {self.object.pk}: {str(e)}")
return response
def form_invalid(self, form):
+66 -3
View File
@@ -242,7 +242,11 @@ class TestCustomLinkEditUrlChangeLog:
"URL changed" entry, because custom-link URLs are derived from the
alias and the original_url form field is hidden for custom links.
Regression test for the bug where every edit of a custom link
produced a LinkChangeLog row."""
produced a LinkChangeLog row.
A CONTENT_EDIT entry, however, SHOULD be created whenever the text
changes (for either custom or regular links). The actual content is
NOT persisted in the log — only a marker that it changed."""
def _update(self, client, link, **payload):
data = {
@@ -264,7 +268,12 @@ class TestCustomLinkEditUrlChangeLog:
original_url="/custom/mynote",
text="old body text",
)
before = LinkChangeLog.objects.filter(link=link).count()
before_url_logs = LinkChangeLog.objects.filter(
link=link, change_type=LinkChangeLog.ChangeType.URL_CHANGE
).count()
before_content_logs = LinkChangeLog.objects.filter(
link=link, change_type=LinkChangeLog.ChangeType.CONTENT_EDIT
).count()
response = self._update(client, link, text="new body text")
assert response.status_code == 200
@@ -272,7 +281,18 @@ class TestCustomLinkEditUrlChangeLog:
link.refresh_from_db()
assert link.text == "new body text"
assert link.original_url == "/custom/mynote"
assert LinkChangeLog.objects.filter(link=link).count() == before
# No spurious URL-change log
assert LinkChangeLog.objects.filter(
link=link, change_type=LinkChangeLog.ChangeType.URL_CHANGE
).count() == before_url_logs
# A content-edit log IS created, without persisting the content
content_logs = LinkChangeLog.objects.filter(
link=link, change_type=LinkChangeLog.ChangeType.CONTENT_EDIT
).order_by("-changed_at")
assert content_logs.count() == before_content_logs + 1
assert content_logs.first().metadata == {"changed": True}
assert "new body text" not in str(content_logs.first().metadata)
assert "old body text" not in str(content_logs.first().metadata)
def test_regular_link_text_unchanged_creates_no_url_change_log(self, client):
link = Link.objects.create(
@@ -296,6 +316,49 @@ class TestCustomLinkEditUrlChangeLog:
assert response.status_code == 200
assert LinkChangeLog.objects.filter(link=link).count() == before
def test_custom_link_text_unchanged_creates_no_content_log(self, client):
link = Link.objects.create(
alias="mynote",
link_type=Link.LinkType.CUSTOM,
original_url="/custom/mynote",
text="same body text",
)
before = LinkChangeLog.objects.filter(link=link).count()
response = self._update(client, link, text="same body text")
assert response.status_code == 200
assert LinkChangeLog.objects.filter(link=link).count() == before
def test_regular_link_text_change_creates_content_log(self, client):
"""Regular (LINK-type) links can also carry text content; editing that
text should produce a CONTENT_EDIT entry too."""
link = Link.objects.create(
alias="gh",
link_type=Link.LinkType.LINK,
original_url="https://github.com",
text="original notes",
)
before = LinkChangeLog.objects.filter(link=link).count()
response = client.post(
f"/link/{link.pk}/edit/",
{
"alias": "gh",
"original_url": "https://github.com",
"link_type": Link.LinkType.LINK,
"description": "",
"text": "edited notes",
},
follow=True,
)
assert response.status_code == 200
content_logs = LinkChangeLog.objects.filter(
link=link, change_type=LinkChangeLog.ChangeType.CONTENT_EDIT
).order_by("-changed_at")
assert content_logs.count() == before + 1
assert content_logs.first().metadata == {"changed": True}
assert "edited notes" not in str(content_logs.first().metadata)
def test_regular_link_url_change_logs_change(self, client):
link = Link.objects.create(
alias="gh",