From 174b296c62c3df9494353452a584a8d7e6fbcfed Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Thu, 16 Jul 2026 11:24:04 +1000 Subject: [PATCH] feat(links): log CONTENT_EDIT on text changes (no content persisted) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- links/views.py | 19 ++++++++++++ tests/test_links_api.py | 69 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/links/views.py b/links/views.py index ef31d18..b8a4a98 100644 --- a/links/views.py +++ b/links/views.py @@ -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): diff --git a/tests/test_links_api.py b/tests/test_links_api.py index 49a9159..60fc59d 100644 --- a/tests/test_links_api.py +++ b/tests/test_links_api.py @@ -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",