diff --git a/ietf/person/factories.py b/ietf/person/factories.py index 3ee4fcdf2..4cf36b367 100644 --- a/ietf/person/factories.py +++ b/ietf/person/factories.py @@ -3,6 +3,7 @@ import factory +from factory.fuzzy import FuzzyChoice import faker import faker.config import os @@ -18,7 +19,7 @@ from django.utils.encoding import force_text import debug # pyflakes:ignore -from ietf.person.models import Person, Alias, Email +from ietf.person.models import Person, Alias, Email, PersonalApiKey, PersonApiKeyEvent, PERSON_API_KEY_ENDPOINTS from ietf.person.name import normalize_name, unidecode_name @@ -144,3 +145,20 @@ class EmailFactory(factory.DjangoModelFactory): active = True primary = False origin = factory.LazyAttribute(lambda obj: obj.person.user.username if obj.person.user else '') + + +class PersonalApiKeyFactory(factory.DjangoModelFactory): + person = factory.SubFactory(PersonFactory) + endpoint = FuzzyChoice(PERSON_API_KEY_ENDPOINTS) + + class Meta: + model = PersonalApiKey + +class PersonApiKeyEventFactory(factory.DjangoModelFactory): + key = factory.SubFactory(PersonalApiKeyFactory) + person = factory.LazyAttribute(lambda o: o.key.person) + type = 'apikey_login' + desc = factory.Faker('sentence', nb_words=6) + + class Meta: + model = PersonApiKeyEvent diff --git a/ietf/person/management/commands/purge_old_personal_api_key_events.py b/ietf/person/management/commands/purge_old_personal_api_key_events.py new file mode 100644 index 000000000..e60f784b5 --- /dev/null +++ b/ietf/person/management/commands/purge_old_personal_api_key_events.py @@ -0,0 +1,59 @@ +# Copyright The IETF Trust 2021, All Rights Reserved +# -*- coding: utf-8 -*- + +from datetime import datetime, timedelta +from django.core.management.base import BaseCommand, CommandError +from django.db.models import Max, Min + +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'] + + 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 dry_run: + self.stdout.write('Dry run requested, records will not be deleted\n') + + self.stdout.write('Finding events older than {}\n'.format(_format_count(keep_days))) + self.stdout.flush() + + now = datetime.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: + 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' + 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() diff --git a/ietf/person/management/commands/tests.py b/ietf/person/management/commands/tests.py new file mode 100644 index 000000000..f30a80bf5 --- /dev/null +++ b/ietf/person/management/commands/tests.py @@ -0,0 +1,121 @@ +# 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 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 = datetime.datetime.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') + 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)) + 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)) + self._assert_purge_dry_run_results(output, 0, recent_events) + + output = self._call_command('purge_old_personal_api_key_events', str(keep_days)) + 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') + 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=datetime.datetime.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])