feat: purge_personal_api_key_events() task (#7485)

* feat: purge_personal_api_key_events() task

* feat: log number of events purged

* test: test new task

* fix: name task properly

* chore: create daily PeriodicTask

* chore: remove old management command

* chore: remove tests of old command

* test: finish removing now-empty tests.py
This commit is contained in:
Jennifer Richards 2024-05-30 10:23:49 -03:00 committed by GitHub
parent 607a5c84b2
commit 020bdeb058
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 48 additions and 191 deletions

View file

@ -36,6 +36,3 @@ $DTDIR/ietf/manage.py populate_yang_model_dirs -v0
# Re-run yang checks on active documents
$DTDIR/ietf/manage.py run_yang_model_checks -v0
# Purge older PersonApiKeyEvents
$DTDIR/ietf/manage.py purge_old_personal_api_key_events 14

View file

@ -1,64 +0,0 @@
# Copyright The IETF Trust 2021, All Rights Reserved
# -*- coding: utf-8 -*-
from datetime import timedelta
from django.core.management.base import BaseCommand, CommandError
from django.db.models import Max, Min
from django.utils import timezone
from ietf.person.models import PersonApiKeyEvent
class Command(BaseCommand):
help = 'Purge PersonApiKeyEvent instances older than KEEP_DAYS days'
def add_arguments(self, parser):
parser.add_argument('keep_days', type=int,
help='Delete events older than this many days')
parser.add_argument('-n', '--dry-run', action='store_true', default=False,
help="Don't delete events, just show what would be done")
def handle(self, *args, **options):
keep_days = options['keep_days']
dry_run = options['dry_run']
verbosity = options.get("verbosity", 1)
def _format_count(count, unit='day'):
return '{} {}{}'.format(count, unit, ('' if count == 1 else 's'))
if keep_days < 0:
raise CommandError('Negative keep_days not allowed ({} was specified)'.format(keep_days))
if verbosity > 1:
self.stdout.write('purge_old_personal_api_key_events: Finding events older than {}\n'.format(_format_count(keep_days)))
if dry_run:
self.stdout.write('Dry run requested, records will not be deleted\n')
self.stdout.flush()
now = timezone.now()
old_events = PersonApiKeyEvent.objects.filter(
time__lt=now - timedelta(days=keep_days)
)
stats = old_events.aggregate(Min('time'), Max('time'))
old_count = old_events.count()
if old_count == 0:
if verbosity > 1:
self.stdout.write('No events older than {} found\n'.format(_format_count(keep_days)))
return
oldest_date = stats['time__min']
oldest_ago = now - oldest_date
newest_date = stats['time__max']
newest_ago = now - newest_date
action_fmt = 'Would delete {}\n' if dry_run else 'Deleting {}\n'
if verbosity > 1:
self.stdout.write(action_fmt.format(_format_count(old_count, 'event')))
self.stdout.write(' Oldest at {} ({} ago)\n'.format(oldest_date, _format_count(oldest_ago.days)))
self.stdout.write(' Most recent at {} ({} ago)\n'.format(newest_date, _format_count(newest_ago.days)))
self.stdout.flush()
if not dry_run:
old_events.delete()

View file

@ -1,122 +0,0 @@
# Copyright The IETF Trust 2021, All Rights Reserved
# -*- coding: utf-8 -*-
import datetime
from io import StringIO
from django.core.management import call_command, CommandError
from django.utils import timezone
from ietf.person.factories import PersonApiKeyEventFactory
from ietf.person.models import PersonApiKeyEvent, PersonEvent
from ietf.utils.test_utils import TestCase
class CommandTests(TestCase):
@staticmethod
def _call_command(command_name, *args, **options):
out = StringIO()
options['stdout'] = out
call_command(command_name, *args, **options)
return out.getvalue()
def _assert_purge_results(self, cmd_output, expected_delete_count, expected_kept_events):
self.assertNotIn('Dry run requested', cmd_output)
if expected_delete_count == 0:
delete_text = 'No events older than'
else:
delete_text = 'Deleting {} event'.format(expected_delete_count)
self.assertIn(delete_text, cmd_output)
self.assertCountEqual(
PersonApiKeyEvent.objects.all(),
expected_kept_events,
'Wrong events were deleted'
)
def _assert_purge_dry_run_results(self, cmd_output, expected_delete_count, expected_kept_events):
self.assertIn('Dry run requested', cmd_output)
if expected_delete_count == 0:
delete_text = 'No events older than'
else:
delete_text = 'Would delete {} event'.format(expected_delete_count)
self.assertIn(delete_text, cmd_output)
self.assertCountEqual(
PersonApiKeyEvent.objects.all(),
expected_kept_events,
'Events were deleted when dry-run option was used'
)
def test_purge_old_personal_api_key_events(self):
keep_days = 10
# Remember how many PersonEvents were present so we can verify they're cleaned up properly.
personevents_before = PersonEvent.objects.count()
now = timezone.now()
# The first of these events will be timestamped a fraction of a second more than keep_days
# days ago by the time we call the management command, so will just barely chosen for purge.
old_events = [
PersonApiKeyEventFactory(time=now - datetime.timedelta(days=n))
for n in range(keep_days, 2 * keep_days + 1)
]
num_old_events = len(old_events)
recent_events = [
PersonApiKeyEventFactory(time=now - datetime.timedelta(days=n))
for n in range(0, keep_days)
]
# We did not create recent_event timestamped exactly keep_days ago because it would
# be treated as an old_event by the management command. Create an event a few seconds
# on the "recent" side of keep_days old to test the threshold.
recent_events.append(
PersonApiKeyEventFactory(
time=now + datetime.timedelta(seconds=3) - datetime.timedelta(days=keep_days)
)
)
num_recent_events = len(recent_events)
# call with dry run
output = self._call_command('purge_old_personal_api_key_events', str(keep_days), '--dry-run', '-v2')
self._assert_purge_dry_run_results(output, num_old_events, old_events + recent_events)
# call for real
output = self._call_command('purge_old_personal_api_key_events', str(keep_days), '-v2')
self._assert_purge_results(output, num_old_events, recent_events)
self.assertEqual(PersonEvent.objects.count(), personevents_before + num_recent_events,
'PersonEvents were not cleaned up properly')
# repeat - there should be nothing left to delete
output = self._call_command('purge_old_personal_api_key_events', '--dry-run', str(keep_days), '-v2')
self._assert_purge_dry_run_results(output, 0, recent_events)
output = self._call_command('purge_old_personal_api_key_events', str(keep_days), '-v2')
self._assert_purge_results(output, 0, recent_events)
self.assertEqual(PersonEvent.objects.count(), personevents_before + num_recent_events,
'PersonEvents were not cleaned up properly')
# and now delete the remaining events
output = self._call_command('purge_old_personal_api_key_events', '0', '-v2')
self._assert_purge_results(output, num_recent_events, [])
self.assertEqual(PersonEvent.objects.count(), personevents_before,
'PersonEvents were not cleaned up properly')
def test_purge_old_personal_api_key_events_rejects_invalid_arguments(self):
"""The purge_old_personal_api_key_events command should reject invalid arguments"""
event = PersonApiKeyEventFactory(time=timezone.now() - datetime.timedelta(days=30))
with self.assertRaises(CommandError):
self._call_command('purge_old_personal_api_key_events')
with self.assertRaises(CommandError):
self._call_command('purge_old_personal_api_key_events', '-15')
with self.assertRaises(CommandError):
self._call_command('purge_old_personal_api_key_events', '15.3')
with self.assertRaises(CommandError):
self._call_command('purge_old_personal_api_key_events', '15', '15')
with self.assertRaises(CommandError):
self._call_command('purge_old_personal_api_key_events', 'abc', '15')
self.assertCountEqual(PersonApiKeyEvent.objects.all(), [event])

20
ietf/person/tasks.py Normal file
View file

@ -0,0 +1,20 @@
# Copyright The IETF Trust 2024, All Rights Reserved
#
# Celery task definitions
#
import datetime
from celery import shared_task
from django.utils import timezone
from ietf.utils import log
from .models import PersonApiKeyEvent
@shared_task
def purge_personal_api_key_events_task(keep_days):
keep_since = timezone.now() - datetime.timedelta(days=keep_days)
old_events = PersonApiKeyEvent.objects.filter(time__lt=keep_since)
count = len(old_events)
old_events.delete()
log.log(f"Deleted {count} PersonApiKeyEvents older than {keep_since}")

View file

@ -4,6 +4,7 @@
import datetime
import json
import mock
from io import StringIO, BytesIO
from PIL import Image
@ -25,8 +26,9 @@ from ietf.group.models import Group
from ietf.nomcom.models import NomCom
from ietf.nomcom.test_data import nomcom_test_data
from ietf.nomcom.factories import NomComFactory, NomineeFactory, NominationFactory, FeedbackFactory, PositionFactory
from ietf.person.factories import EmailFactory, PersonFactory
from ietf.person.models import Person, Alias
from ietf.person.factories import EmailFactory, PersonFactory, PersonApiKeyEventFactory
from ietf.person.models import Person, Alias, PersonApiKeyEvent
from ietf.person.tasks import purge_personal_api_key_events_task
from ietf.person.utils import (merge_persons, determine_merge_order, send_merge_notification,
handle_users, get_extra_primary, dedupe_aliases, move_related_objects, merge_nominees,
handle_reviewer_settings, get_dots)
@ -450,3 +452,16 @@ class PersonUtilsTests(TestCase):
self.assertEqual(get_dots(ncmember),['nomcom'])
ncchair = RoleFactory(group__acronym='nomcom2020',group__type_id='nomcom',name_id='chair').person
self.assertEqual(get_dots(ncchair),['nomcom'])
class TaskTests(TestCase):
@mock.patch("ietf.person.tasks.log.log")
def test_purge_personal_api_key_events_task(self, mock_log):
now = timezone.now()
old_event = PersonApiKeyEventFactory(time=now - datetime.timedelta(days=1, minutes=1))
young_event = PersonApiKeyEventFactory(time=now - datetime.timedelta(days=1, minutes=-1))
purge_personal_api_key_events_task(keep_days=1)
self.assertFalse(PersonApiKeyEvent.objects.filter(pk=old_event.pk).exists())
self.assertTrue(PersonApiKeyEvent.objects.filter(pk=young_event.pk).exists())
self.assertTrue(mock_log.called)
self.assertIn("Deleted 1", mock_log.call_args[0][0])

View file

@ -241,6 +241,17 @@ class Command(BaseCommand):
),
)
PeriodicTask.objects.get_or_create(
name="Purge old personal API key events",
task="ietf.person.tasks.purge_personal_api_key_events_task",
kwargs=json.dumps(dict(keep_days=14)),
defaults=dict(
enabled=False,
crontab=self.crontabs["daily"],
description="Purge PersonApiKeyEvent instances older than 14 days",
),
)
def show_tasks(self):
for label, crontab in self.crontabs.items():
tasks = PeriodicTask.objects.filter(crontab=crontab).order_by(