Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Soft-delete attachments when link is removed. #4672

Merged
merged 1 commit into from
Jan 8, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions api/features_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from framework import permissions
from framework import rediscache
from framework import users
from internals import attachments
from internals.enterprise_helpers import *
from internals.core_enums import *
from internals.core_models import FeatureEntry, MilestoneSet, Stage
Expand Down Expand Up @@ -252,6 +253,10 @@ def _patch_update_special_fields(
feature.outstanding_notifications = 0
has_updated = True

if 'screenshot_links' in feature_changes:
attachments.delete_orphan_attachments(
feature.key.integer_id(), feature_changes['screenshot_links'])

# Set enterprise first notification milestones.
if is_update_first_notification_milestone(feature, feature_changes):
feature.first_enterprise_notification_milestone = int(feature_changes['first_enterprise_notification_milestone'])
Expand Down
10 changes: 10 additions & 0 deletions internals/attachments.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,16 @@ def mark_attachment_deleted(attachment: Attachment) -> None:
attachment.put()


def delete_orphan_attachments(feature_id: int, new_value: str) -> None:
"""Soft delete attachments on a feature that are no longer referenced."""
attachments = Attachment.query(
Attachment.feature_id == feature_id).fetch()
for a in attachments:
uri = 'feature/%r/attachment/%r' % (feature_id, a.key.integer_id())
if uri not in new_value:
mark_attachment_deleted(a)


def get_thumbnail(attachment_id: int) -> Thumbnail|None:
"""Return a Thumbnail, if it exsits."""
thumbnail = Thumbnail.query(
Expand Down
30 changes: 30 additions & 0 deletions internals/attachments_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,33 @@ def test_get_attachment_url__GAE(self):
actual,
(f'https://img{digits}-dot-appid.appspot.com/'
f'feature/{feature_id}/attachment/{attach_id}'))

def test_delete_orphan_attachments__none(self):
"""When a feature has no attachments, we do nothing without crashing."""
attachments.delete_orphan_attachments(self.feature_id, '')

def test_delete_orphan_attachments__maintained(self):
"""A feature has an attachment and the links field keeps it: it stays."""
feature_id = self.feature_id
stored = attachments.store_attachment(
feature_id, b'test content', 'text/plain')
with_link = attachments.get_attachment_url(stored)

attachments.delete_orphan_attachments(feature_id, with_link)

all_attach = attachments.Attachment.query().fetch()
self.assertEqual(len(all_attach), 1)
self.assertFalse(all_attach[0].is_deleted)

def test_delete_orphan_attachments__deleted(self):
"""A feature has an attachment and the links field drops it: deleted."""
feature_id = self.feature_id
stored = attachments.store_attachment(
feature_id, b'test content', 'text/plain')
without_link = ''

attachments.delete_orphan_attachments(feature_id, without_link)

all_attach = attachments.Attachment.query().fetch()
self.assertEqual(len(all_attach), 1)
self.assertTrue(all_attach[0].is_deleted)
19 changes: 15 additions & 4 deletions internals/reminders_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
# limitations under the License.

import testing_config # Must be imported before the module under test.
import re
import flask
import settings
from datetime import datetime
Expand Down Expand Up @@ -629,6 +630,16 @@ def test_get_template_data__resolve_overdue_unassigned(self, mock_now_utc):
expected = {'message': expected_message}
self.assertEqual(actual, expected)

def assert_equal_ignoring_ids(self, expected, actual):
"""Compare two strings, but ignore differences in NDB keys."""
feature_re = re.compile(r'/feature/\d+')
gate_re = re.compile(r'\?gate=\d+')
expected = feature_re.sub(expected, '/feature/ID')
expected = gate_re.sub(expected, '?gate=ID')
actual = feature_re.sub(actual, '/feature/ID')
actual = gate_re.sub(actual, '?gate=ID')
self.assertMultiLineEqual(expected, actual)

@mock.patch('internals.slo.now_utc')
def test_get_template_data__old_reviews(self, mock_now_utc):
"""More time has passed. We don't keep reminding."""
Expand Down Expand Up @@ -660,7 +671,7 @@ def test_build_gate_email_tasks__initial_due(self):
self.assertEqual('Review due for: feature one', task['subject'])
self.assertEqual(None, task['reply_to'])
# TESTDATA.make_golden(task['html'], 'test_build_gate_email_tasks__initial_due.html')
self.assertMultiLineEqual(
self.assert_equal_ignoring_ids(
TESTDATA['test_build_gate_email_tasks__initial_due.html'], task['html'])

def test_build_gate_email_tasks__initial_overdue(self):
Expand All @@ -679,7 +690,7 @@ def test_build_gate_email_tasks__initial_overdue(self):
self.assertEqual('ESCALATED: Review due for: feature one', task['subject'])
self.assertEqual(None, task['reply_to'])
# TESTDATA.make_golden(task['html'], 'test_build_gate_email_tasks__initial_overdue.html')
self.assertMultiLineEqual(
self.assert_equal_ignoring_ids(
TESTDATA['test_build_gate_email_tasks__initial_overdue.html'], task['html'])

def test_build_gate_email_tasks__resolution_due(self):
Expand All @@ -698,7 +709,7 @@ def test_build_gate_email_tasks__resolution_due(self):
self.assertEqual('Review due for: feature one', task['subject'])
self.assertEqual(None, task['reply_to'])
# TESTDATA.make_golden(task['html'], 'test_build_gate_email_tasks__resolution_due.html')
self.assertMultiLineEqual(
self.assert_equal_ignoring_ids(
TESTDATA['test_build_gate_email_tasks__resolution_due.html'], task['html'])

def test_build_gate_email_tasks__resolution_overdue(self):
Expand All @@ -717,5 +728,5 @@ def test_build_gate_email_tasks__resolution_overdue(self):
self.assertEqual('ESCALATED: Review due for: feature one', task['subject'])
self.assertEqual(None, task['reply_to'])
# TESTDATA.make_golden(task['html'], 'test_build_gate_email_tasks__resolution_overdue.html')
self.assertMultiLineEqual(
self.assert_equal_ignoring_ids(
TESTDATA['test_build_gate_email_tasks__resolution_overdue.html'], task['html'])
Loading