diff --git a/bundles/admin/scheduleEditor/index.js b/bundles/admin/scheduleEditor/index.js index a32bda766..cd4302d5f 100644 --- a/bundles/admin/scheduleEditor/index.js +++ b/bundles/admin/scheduleEditor/index.js @@ -108,6 +108,7 @@ function dispatch(dispatch) { moveSpeedrun: (source, destination, before) => { dispatch(actions.models.setInternalModelField('speedrun', source, 'moving', true)); dispatch(actions.models.setInternalModelField('speedrun', destination, 'moving', true)); + dispatch(actions.models.setInternalModelField('speedrun', source, 'errors', null)); dispatch( actions.models.command({ type: 'MoveSpeedRun', @@ -116,6 +117,9 @@ function dispatch(dispatch) { other: destination, before: before ? 1 : 0, }, + fail: json => { + dispatch(actions.models.setInternalModelField('speedrun', source, 'errors', json.error)); + }, always: () => { dispatch(actions.models.setInternalModelField('speedrun', source, 'moving', false)); dispatch(actions.models.setInternalModelField('speedrun', destination, 'moving', false)); diff --git a/bundles/admin/scheduleEditor/speedrun.js b/bundles/admin/scheduleEditor/speedrun.js index f31f70d72..448b8a2a5 100644 --- a/bundles/admin/scheduleEditor/speedrun.js +++ b/bundles/admin/scheduleEditor/speedrun.js @@ -18,7 +18,7 @@ class Speedrun extends React.Component { line() { const { speedrun, draft, connectDragPreview, editModel } = this.props; - const fieldErrors = draft ? draft._fields || {} : {}; + const fieldErrors = draft?._fields || {}; const { cancelEdit_, editModel_, updateField_, save_ } = this; return draft ? ( @@ -103,28 +103,40 @@ class Speedrun extends React.Component { speedrun && speedrun.order !== null && speedrun.starttime !== null ? moment(speedrun.starttime).format('dddd, MMMM Do, h:mm a') : 'Unscheduled'; - const spinning = !!(speedrun._internal && (speedrun._internal.moving || speedrun._internal.saving)); + const spinning = !!(speedrun._internal?.moving || speedrun._internal?.saving); + const errors = speedrun._internal?.errors; return ( - - {starttime} - - {moveSpeedrun ? ( - - ) : null} - - {this.line()} - + <> + {errors && Object.entries(errors).map(([key, errors]) => )} + + + {starttime} + {speedrun.anchor_time ? ( + <> +
+ Anchored + + ) : null} + + + {moveSpeedrun ? ( + + ) : null} + + {this.line()} + + ); } @@ -173,6 +185,7 @@ const SpeedrunShape = PropTypes.shape({ //console: PropTypes.string.isRequired, start_time: PropTypes.string, end_time: PropTypes.string, + anchor_time: PropTypes.string, description: PropTypes.string.isRequired, commentators: PropTypes.string.isRequired, }); diff --git a/bundles/public/api/actions/models.js b/bundles/public/api/actions/models.js index bac468bab..cb2389a8b 100644 --- a/bundles/public/api/actions/models.js +++ b/bundles/public/api/actions/models.js @@ -104,6 +104,7 @@ function onNewDraftModel(model) { function newDraftModel(model) { return dispatch => { + dispatch(onSetInternalModelField(model.type, model.pk, 'errors', null)); dispatch(onNewDraftModel(model)); }; } @@ -192,9 +193,13 @@ function saveDraftModels(models) { dispatch(onModelCollectionAdd(model.type, models)); dispatch(onDeleteDraftModel(model)); }) - .catch(response => { - const json = response.json(); - dispatch(onSaveDraftModelError(model, json ? json.error : response.body(), json ? json.fields : {})); + .catch(async response => { + try { + const json = await response.json(); + dispatch(onSaveDraftModelError(model, json.error, json.message_dict || { __all__: json.messages })); + } catch (e) { + dispatch(onSaveDraftModelError(model, await response.body())); + } }) .finally(() => { dispatch(setInternalModelField(model.type, model.pk, 'saving', false)); @@ -285,9 +290,9 @@ function command(command) { command.done(); } }) - .catch(() => { + .catch(e => { if (typeof command.fail === 'function') { - command.fail(); + e.json().then(json => command.fail(json)); } }) .finally(() => { diff --git a/tests/test_api.py b/tests/test_api.py index a75efc006..bd2b52cd5 100755 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -227,6 +227,7 @@ def setUp(self): def format_run(cls, run): return dict( fields=dict( + anchor_time=run.anchor_time, canonical_url=( 'http://testserver' + reverse('tracker:run', args=(run.id,)) ), diff --git a/tests/test_auth.py b/tests/test_auth.py index 41747ed4c..d4bd748cf 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,5 +1,7 @@ import urllib.parse +from unittest import skipIf +import django import post_office.models from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase, override_settings @@ -70,6 +72,11 @@ def test_register_inactive_user(self): ) self.assertContains(resp, 'An e-mail has been sent to your address.') + # TODO: remove skip when 3.2 no longer supported + @skipIf( + django.VERSION < (4, 1), + 'assertFormError requires response object until Django 4.1', + ) def test_register_active_user(self): AuthUser.objects.create( username='existinguser', email='test@email.com', is_active=True @@ -78,8 +85,9 @@ def test_register_active_user(self): reverse('tracker:register'), data={'email': 'test@email.com'} ) self.assertFormError( - resp, - 'form', + resp.context['form'], 'email', - 'This email is already registered. Please log in, (or reset your password if you forgot it).', + [ + 'This email is already registered. Please log in, (or reset your password if you forgot it).' + ], ) diff --git a/tests/test_prize.py b/tests/test_prize.py index 884248a3d..9ebc3eb2f 100644 --- a/tests/test_prize.py +++ b/tests/test_prize.py @@ -1,8 +1,10 @@ import datetime import random from decimal import Decimal +from unittest import skipIf from unittest.mock import patch +import django import post_office.models import pytz from dateutil.parser import parse as parse_date @@ -1467,6 +1469,11 @@ def test_prize_key_import_action(self): reverse('admin:tracker_prize_key_import', args=(self.prize_with_keys.id,)), ) + # TODO: remove skip when 3.2 no longer supported + @skipIf( + django.VERSION < (4, 1), + 'assertFormError requires response object until Django 4.1', + ) def test_prize_key_import_form(self): keys = ['dead-beef-dead-beef-123%d' % i for i in range(5)] response = self.client.get( @@ -1521,7 +1528,7 @@ def test_prize_key_import_form(self): {'keys': keys[0]}, ) self.assertFormError( - response, 'form', 'keys', ['At least one key already exists.'] + response.context['form'], 'keys', ['At least one key already exists.'] ) def test_prize_winner_admin(self): diff --git a/tests/test_speedrun.py b/tests/test_speedrun.py index 9d24f73f5..a60257d6f 100755 --- a/tests/test_speedrun.py +++ b/tests/test_speedrun.py @@ -1,8 +1,11 @@ import datetime import random +from unittest import skipIf +import django import pytz from django.contrib.auth.models import User +from django.core.exceptions import ValidationError from django.test import TransactionTestCase from django.urls import reverse @@ -98,6 +101,49 @@ def test_update_runners_on_m2m(self): self.run1.deprecated_runners, ', '.join(sorted([self.runner2.name])) ) + def test_anchor_time(self): + self.run3.anchor_time = self.run3.starttime + self.run3.save() + self.run1.clean() + with self.subTest('run time drift'), self.assertRaises(ValidationError): + self.run1.run_time = '1:00:00' + self.run1.clean() + self.run1.refresh_from_db() + with self.subTest('setup time drift'), self.assertRaises(ValidationError): + self.run1.run_time = '45:00' + self.run1.setup_time = '20:00' + self.run1.clean() + self.run1.refresh_from_db() + with self.subTest('bad anchor order'), self.assertRaises(ValidationError): + self.run2.anchor_time = self.run3.anchor_time + datetime.timedelta( + minutes=5 + ) + self.run2.clean() + self.run2.refresh_from_db() + with self.subTest('setup time correction'): + self.run2.setup_time = '2:00' + self.run2.save() + self.run2.refresh_from_db() + self.assertEqual(self.run2.setup_time, '0:05:00') + self.run3.refresh_from_db() + self.assertEqual(self.run3.starttime, self.run3.anchor_time) + self.run3.anchor_time += datetime.timedelta(minutes=5) + self.run3.save() + self.run2.refresh_from_db() + self.assertEqual(self.run2.setup_time, '0:10:00') + self.run1.setup_time = '10:00' + self.run1.save() + self.run2.refresh_from_db() + self.assertEqual(self.run2.setup_time, '0:05:00') + self.run2.run_time = '17:00' + self.run2.clean() + self.run2.save() + self.run2.refresh_from_db() + self.assertEqual(self.run2.setup_time, '0:03:00') + with self.subTest('bad anchor time'), self.assertRaises(ValidationError): + self.run3.anchor_time -= datetime.timedelta(days=1) + self.run3.clean() + class TestMoveSpeedRun(TransactionTestCase): def setUp(self): @@ -205,6 +251,18 @@ def test_unordered_to_after(self): self.assertEqual(self.run3.order, 4) self.assertEqual(self.run4.order, 3) + def test_too_long_for_anchor(self): + from tracker.views.commands import MoveSpeedRun + + self.run2.anchor_time = self.run2.starttime + self.run2.save() + + output, status = MoveSpeedRun( + {'moving': self.run3.id, 'other': self.run2.id, 'before': True} + ) + + self.assertEqual(status, 400) + class TestSpeedRunAdmin(TransactionTestCase): def setUp(self): @@ -219,6 +277,13 @@ def setUp(self): self.run2 = models.SpeedRun.objects.create( name='Test Run 2', run_time='0:15:00', setup_time='0:05:00', order=2 ) + self.run3 = models.SpeedRun.objects.create( + name='Test Run 3', + run_time='0:35:00', + setup_time='0:05:00', + anchor_time=today_noon + datetime.timedelta(minutes=90), + order=3, + ) if not User.objects.filter(username='admin').exists(): User.objects.create_superuser('admin', 'nobody@example.com', 'password') @@ -242,6 +307,7 @@ def test_start_run(self): data={ 'run_time': '0:41:20', 'start_time': '%s 12:51:00' % self.event1.date, + 'run_id': self.run2.id, }, ) self.assertEqual(resp.status_code, 302) @@ -249,16 +315,45 @@ def test_start_run(self): self.assertEqual(self.run1.run_time, '0:41:20') self.assertEqual(self.run1.setup_time, '0:09:40') + @skipIf( + django.VERSION < (4, 1), + 'assertFormError requires response object until Django 4.1', + ) def test_invalid_time(self): - self.client.login(username='admin', password='password') - resp = self.client.post( - reverse('admin:start_run', args=(self.run2.id,)), + from tracker.admin.forms import StartRunForm + + form = StartRunForm( + initial={ + 'run_id': self.run2.id, + }, data={ 'run_time': '0:41:20', 'start_time': '%s 11:21:00' % self.event1.date, + 'run_id': self.run2.id, + }, + ) + self.assertFalse(form.is_valid()) + self.assertFormError(form, None, StartRunForm.Errors.invalid_start_time) + + @skipIf( + django.VERSION < (4, 1), + 'assertFormError requires response object until Django 4.1', + ) + def test_anchor_drift(self): + from tracker.admin.forms import StartRunForm + + form = StartRunForm( + initial={ + 'run_id': self.run2.id, + }, + data={ + 'run_time': '0:41:20', + 'start_time': '%s 13:21:00' % self.event1.date, + 'run_id': self.run2.id, }, ) - self.assertEqual(resp.status_code, 400) + self.assertFalse(form.is_valid()) + self.assertFormError(form, None, StartRunForm.Errors.anchor_time_drift) class TestSpeedrunList(TransactionTestCase): diff --git a/tracker/admin/event.py b/tracker/admin/event.py index 057c3e546..d717484db 100644 --- a/tracker/admin/event.py +++ b/tracker/admin/event.py @@ -1,6 +1,5 @@ import csv import time -from datetime import timedelta from decimal import Decimal from io import BytesIO, StringIO @@ -743,6 +742,7 @@ class SpeedRunAdmin(EventLockedMixin, CustomModelAdmin): 'event', 'order', 'starttime', + 'anchor_time', 'run_time', 'setup_time', 'runners', @@ -789,38 +789,44 @@ def bids(self, instance): def start_run(self, request, runs): if len(runs) != 1: self.message_user(request, 'Pick exactly one run.', level=messages.ERROR) + elif runs[0].event.locked: + self.message_user(request, 'Run event is locked.', level=messages.ERROR) elif not runs[0].order: self.message_user(request, 'Run has no order.', level=messages.ERROR) - elif runs[0].order == 1: - self.message_user(request, 'Run is first run.', level=messages.ERROR) else: - return HttpResponseRedirect(reverse('admin:start_run', args=(runs[0].id,))) + prev = models.SpeedRun.objects.filter( + event=runs[0].event_id, order__lt=runs[0].order + ).last() + + if not prev: + self.message_user(request, 'Run is first run.', level=messages.ERROR) + else: + return HttpResponseRedirect( + reverse('admin:start_run', args=(runs[0].id,)) + ) @staticmethod @permission_required('tracker.change_speedrun') def start_run_view(request, run): - run = models.SpeedRun.objects.get(id=run) + run = models.SpeedRun.objects.filter(id=run, event__locked=False).first() + if not run: + raise Http404 prev = models.SpeedRun.objects.filter( event=run.event, order__lt=run.order ).last() + if not prev: + raise Http404 form = StartRunForm( data=request.POST if request.method == 'POST' else None, - initial={'run_time': prev.run_time, 'start_time': run.starttime}, + initial={ + 'run_time': prev.run_time, + 'start_time': run.starttime, + 'run_id': run.id, + }, ) if form.is_valid(): - rt = tracker.models.fields.TimestampField.time_string_to_int( - form.cleaned_data['run_time'] - ) - endtime = prev.starttime + timedelta(milliseconds=rt) - if form.cleaned_data['start_time'] < endtime: - return HttpResponse( - 'Entered data would cause previous run to end after current run started', - status=400, - content_type='text/plain', - ) - prev.run_time = form.cleaned_data['run_time'] - prev.setup_time = str(form.cleaned_data['start_time'] - endtime) - prev.save() + form.save() + prev.refresh_from_db() messages.info(request, 'Previous run time set to %s' % prev.run_time) messages.info(request, 'Previous setup time set to %s' % prev.setup_time) run.refresh_from_db() @@ -834,7 +840,7 @@ def start_run_view(request, run): 'admin/tracker/generic_form.html', { 'site_header': 'Donation Tracker', - 'title': 'Set start time for %s' % run, + 'title': 'Set start time for %s' % run.name, 'breadcrumbs': ( ( reverse('admin:app_list', kwargs=dict(app_label='tracker')), diff --git a/tracker/admin/forms.py b/tracker/admin/forms.py index 312b2ea1e..98c16ee80 100644 --- a/tracker/admin/forms.py +++ b/tracker/admin/forms.py @@ -1,6 +1,9 @@ +import datetime + from ajax_select import make_ajax_field from django import forms as djforms from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ from tracker import models @@ -113,6 +116,55 @@ class Meta: class StartRunForm(djforms.Form): run_time = djforms.CharField(help_text='Run time of previous run') start_time = djforms.DateTimeField(help_text='Start time of current run') + run_id = djforms.IntegerField(widget=djforms.HiddenInput()) + + class Errors: + invalid_start_time = _( + 'Entered data would cause previous run to end after current run started' + ) + anchor_time_drift = _( + 'Entered data would push the next anchored run out of its slot' + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if 'run_id' in self.initial: + self._run = models.SpeedRun.objects.filter( + pk=self.initial['run_id'], event__locked=False + ).first() + if self._run and self._run.order: + self._prev = models.SpeedRun.objects.filter( + event=self._run.event, order__lt=self._run.order + ).last() + else: + self._prev = None + else: + self._run = None + self._prev = None + + def clean(self): + from tracker.models import fields + + cleaned_data = super().clean() + if not self._run: + raise ValidationError('Run either does not exist or is on a locked event') + if not self._prev: + raise ValidationError('Run does not have a previous run') + rt = fields.TimestampField.time_string_to_int(cleaned_data['run_time']) + endtime = self._prev.starttime + datetime.timedelta(milliseconds=rt) + if cleaned_data['start_time'] < endtime: + raise ValidationError(self.Errors.invalid_start_time) + self._prev.run_time = cleaned_data['run_time'] + self._prev.setup_time = str(cleaned_data['start_time'] - endtime) + try: + self._prev.clean() + except ValidationError: + raise ValidationError(self.Errors.anchor_time_drift) + return cleaned_data + + def save(self): + if self.is_valid(): + self._prev.save() class TestEmailForm(djforms.Form): diff --git a/tracker/migrations/0034_add_anchor_time.py b/tracker/migrations/0034_add_anchor_time.py new file mode 100644 index 000000000..c499942ea --- /dev/null +++ b/tracker/migrations/0034_add_anchor_time.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2 on 2023-11-21 21:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracker', '0033_add_layout'), + ] + + operations = [ + migrations.AddField( + model_name='speedrun', + name='anchor_time', + field=models.DateTimeField(blank=True, help_text="If set, will adjust the previous run to ensure this run's start time is always this value, or throw a validation error if it is not possible", null=True), + ), + ] diff --git a/tracker/models/event.py b/tracker/models/event.py index 750540393..a4d8afb21 100644 --- a/tracker/models/event.py +++ b/tracker/models/event.py @@ -1,5 +1,6 @@ import datetime import decimal +import itertools import logging import dateutil.parser @@ -20,6 +21,19 @@ from .fields import TimestampField from .util import LatestEvent +# TODO: remove when 3.10 is oldest supported version + +try: + from itertools import pairwise +except ImportError: + + def pairwise(iterable): + # pairwise('ABCDEFG') --> AB BC CD DE EF FG + a, b = itertools.tee(iterable) + next(b, None) + return zip(a, b) + + __all__ = [ 'Event', 'PostbackURL', @@ -455,6 +469,11 @@ class SpeedRun(models.Model): help_text='Please note that using the schedule editor is much easier', validators=[positive], ) + anchor_time = models.DateTimeField( + blank=True, + null=True, + help_text="If set, will adjust the previous run to ensure this run's start time is always this value, or throw a validation error if it is not possible", + ) run_time = TimestampField(always_show_h=True) setup_time = TimestampField(always_show_h=True) runners = models.ManyToManyField('Runner') @@ -507,33 +526,122 @@ def get_absolute_url(self): def natural_key(self): return self.name, self.event.natural_key() + @property + def run_time_ms(self): + return TimestampField.time_string_to_int(self.run_time) + + @property + def setup_time_ms(self): + return TimestampField.time_string_to_int(self.setup_time) + def clean(self): if not self.name: raise ValidationError('Name cannot be blank') if not self.display_name: self.display_name = self.name - if not self.order: + if self.order: + prev = ( + SpeedRun.objects.filter(order__lt=self.order, event=self.event) + .exclude(pk=self.pk) + .last() + ) + next_anchor = ( + SpeedRun.objects.filter(order__gte=self.order, event=self.event) + .exclude(anchor_time=None) + .exclude(pk=self.pk) + .first() + ) + if prev: + self.starttime = prev.endtime + if next_anchor: + if self.anchor_time and next_anchor.anchor_time < self.anchor_time: + raise ValidationError( + { + 'order': 'Next anchor in the order would occur before this one' + } + ) + for c, n in pairwise( + itertools.chain( + [self], + SpeedRun.objects.filter( + event=self.event, + order__gt=self.order, + order__lte=next_anchor.order, + ).exclude(pk=self.pk), + ) + ): + if n.anchor_time: + if ( + c.starttime + datetime.timedelta(milliseconds=c.run_time_ms) + > n.anchor_time + ): + raise ValidationError( + { + 'setup_time': 'Not enough available drift for next anchor time' + } + ) + else: + n.starttime = c.starttime + datetime.timedelta( + milliseconds=c.run_time_ms + c.setup_time_ms + ) + if self.anchor_time: + if not prev: + raise ValidationError( + { + 'anchor_time': 'Cannot set anchor time for first run in an event' + } + ) + if ( + prev.starttime + datetime.timedelta(milliseconds=prev.run_time_ms) + > self.anchor_time + ): + raise ValidationError( + { + 'anchor_time': 'Previous run does not have enough drift available for anchor time' + } + ) + self.starttime = self.anchor_time + else: self.order = None def save(self, fix_time=True, fix_runners=True, *args, **kwargs): - i = TimestampField.time_string_to_int can_fix_time = self.order is not None and ( - i(self.run_time) != 0 or i(self.setup_time) != 0 + self.run_time_ms != 0 or self.setup_time_ms != 0 ) + if self.order: + prev_run = ( + SpeedRun.objects.filter(event=self.event, order__lt=self.order) + .exclude(pk=self.pk) + .last() + ) + next_run = ( + SpeedRun.objects.filter(event=self.event, order__gt=self.order) + .exclude(pk=self.pk) + .first() + ) + else: + prev_run = next_run = None + # fix our own time if fix_time and can_fix_time: - prev = SpeedRun.objects.filter( - event=self.event, order__lt=self.order - ).last() - if prev: - self.starttime = prev.starttime + datetime.timedelta( - milliseconds=i(prev.run_time) + i(prev.setup_time) - ) + if prev_run: + if self.anchor_time: + self.starttime = self.anchor_time + else: + self.starttime = prev_run.starttime + datetime.timedelta( + milliseconds=prev_run.run_time_ms + prev_run.setup_time_ms + ) else: self.starttime = self.event.datetime + if next_run and next_run.anchor_time: + self.setup_time = ( + next_run.anchor_time + - self.starttime + - datetime.timedelta(milliseconds=self.run_time_ms) + ) self.endtime = self.starttime + datetime.timedelta( - milliseconds=i(self.run_time) + i(self.setup_time) + milliseconds=self.run_time_ms + self.setup_time_ms ) if fix_runners and self.id: @@ -548,38 +656,46 @@ def save(self, fix_time=True, fix_runners=True, *args, **kwargs): # fix up all the others if requested if fix_time: + if prev_run and self.anchor_time: + prev_kwargs = { + k: v for k, v in kwargs.items() if not k.startswith('force') + } + prev_run.save(*args, **prev_kwargs) if can_fix_time: - next = SpeedRun.objects.filter( - event=self.event, order__gt=self.order - ).first() - starttime = self.starttime + datetime.timedelta( - milliseconds=i(self.run_time) + i(self.setup_time) - ) - if next and next.starttime != starttime: - return [self] + next.save(*args, **kwargs) + if next_run: + if next_run.anchor_time: + return [self, next_run] + else: + starttime = self.starttime + datetime.timedelta( + milliseconds=self.run_time_ms + self.setup_time_ms + ) + if next_run.starttime != starttime: + return [self] + next_run.save(*args, **kwargs) elif self.starttime: - prev = ( + prev_run = ( SpeedRun.objects.filter( event=self.event, starttime__lte=self.starttime ) .exclude(order=None) + .exclude(pk=self.pk) .last() ) - if prev: - self.starttime = prev.starttime + datetime.timedelta( - milliseconds=i(prev.run_time) + i(prev.setup_time) + if prev_run: + self.starttime = prev_run.starttime + datetime.timedelta( + milliseconds=prev_run.run_time_ms + prev_run.setup_time_ms ) else: self.starttime = self.event.datetime - next = ( + next_run = ( SpeedRun.objects.filter( event=self.event, starttime__gte=self.starttime ) .exclude(order=None) + .exclude(pk=self.pk) .first() ) - if next and next.starttime != self.starttime: - return [self] + next.save(*args, **kwargs) + if next_run and next_run.starttime != self.starttime: + return [self] + next_run.save(*args, **kwargs) return [self] def name_with_category(self): diff --git a/tracker/models/fields.py b/tracker/models/fields.py index 2cb79654d..9dda1de1d 100644 --- a/tracker/models/fields.py +++ b/tracker/models/fields.py @@ -1,4 +1,6 @@ +import datetime import re +from typing import Union from django.core import validators from django.core.exceptions import ValidationError @@ -11,6 +13,9 @@ # http://stackoverflow.com/questions/3955093/django-return-none-from-onetoonefield-if-related-object-doesnt-exist +timestamp_regex = re.compile(r'(?:(?:(\d{1,3}):)?(\d{1,3}):)?(\d{1,3})(?:\.(\d{1,3}))?') + + class SingleRelatedObjectDescriptorReturnsNone( models.fields.related.ReverseOneToOneDescriptor ): @@ -30,11 +35,11 @@ class OneToOneOrNoneField(models.OneToOneField): class TimestampValidator(validators.RegexValidator): - regex = r'(?:(?:(\d+):)?(?:(\d+):))?(\d+)(?:\.(\d{1,3}))?$' + regex = timestamp_regex def __call__(self, value): super(TimestampValidator, self).__call__(value) - h, m, s, ms = re.match(self.regex, str(value)).groups() + h, m, s, ms = timestamp_regex.match(str(value)).groups() if h is not None and int(m) >= 60: raise ValidationError( 'Minutes cannot be 60 or higher if the hour part is specified' @@ -45,12 +50,13 @@ def __call__(self, value): ) -# TODO: give this a proper unit test and maybe pull it into its own library? or maybe just find an already existing duration field that does what we want +# TODO: give this a proper unit test and maybe pull it into its own library? or maybe just find an already +# existing duration field that does what we want class TimestampField(models.Field): default_validators = [TimestampValidator()] - match_string = re.compile(r'(?:(?:(\d+):)?(?:(\d+):))?(\d+)(?:\.(\d{1,3}))?') + match_string = timestamp_regex def __init__( self, @@ -99,19 +105,26 @@ def to_python(self, value): return '%d' % s @staticmethod - def time_string_to_int(value): - try: - if str(int(value)) == value: - return int(value) * 1000 - except ValueError: - pass + def time_string_to_int(value: Union[int, float, datetime.timedelta, str]): + if isinstance(value, datetime.timedelta): + if value.total_seconds() < 0: + raise ValueError( + f'Value was negative: timedelta(seconds={value.total_seconds()})' + ) + return int(value.total_seconds() * 1000) + if isinstance(value, (int, float)): + if value < 0: + raise ValueError(f'Value was negative: {value}') + return int(value) if not isinstance(value, str): - return value + raise TypeError( + f'expected int, float, timedelta, or str, got {type(value)}' + ) if not value: return 0 match = TimestampField.match_string.match(value) if not match: - raise ValueError('Not a valid timestamp: ' + value) + raise ValueError(f'Not a valid timestamp: {value}') h, m, s, ms = match.groups() s = int(s) m = int(m or s / 60) @@ -138,5 +151,5 @@ def validate(self, value, model_instance): super(TimestampField, self).validate(value, model_instance) try: TimestampField.time_string_to_int(value) - except ValueError: - raise ValidationError('Not a valid timestamp') + except ValueError as e: + raise ValidationError(str(e)) diff --git a/tracker/models/prize.py b/tracker/models/prize.py index 6d5d2f471..8030ef546 100644 --- a/tracker/models/prize.py +++ b/tracker/models/prize.py @@ -15,7 +15,6 @@ from tracker.models import Donation, Event, SpeedRun from tracker.validators import nonzero, positive -from .fields import TimestampField from .util import LatestEvent __all__ = [ @@ -415,9 +414,7 @@ def start_draw_time(self): if self.startrun and self.startrun.order: if self.prev_run: return self.prev_run.endtime - datetime.timedelta( - milliseconds=TimestampField.time_string_to_int( - self.prev_run.setup_time - ) + milliseconds=self.prev_run.setup_time_ms ) return self.startrun.starttime.replace(tzinfo=pytz.utc) elif self.starttime: diff --git a/tracker/views/api.py b/tracker/views/api.py index b1c9557b5..208f30410 100644 --- a/tracker/views/api.py +++ b/tracker/views/api.py @@ -746,14 +746,17 @@ def command(request): if func: if request.user.has_perm(func.permission): output, status = func(data) - output = serializers.serialize('json', output, ensure_ascii=False) + if status == 200: + output = serializers.serialize('json', output, ensure_ascii=False) else: output = json.dumps({'error': 'permission denied'}) status = 403 else: output = json.dumps({'error': 'unrecognized command'}) status = 400 - resp = HttpResponse(output, content_type='application/json;charset=utf-8') + resp = HttpResponse( + output, status=status, content_type='application/json;charset=utf-8' + ) if 'queries' in request.GET and request.user.has_perm('tracker.view_queries'): return HttpResponse( json.dumps(connection.queries, ensure_ascii=False, indent=1), diff --git a/tracker/views/commands.py b/tracker/views/commands.py index f928d6d03..f7e0ebc5b 100644 --- a/tracker/views/commands.py +++ b/tracker/views/commands.py @@ -1,3 +1,8 @@ +import json + +from django.core.exceptions import ValidationError +from django.db import transaction + from tracker.models import SpeedRun __all__ = [ @@ -8,53 +13,80 @@ def MoveSpeedRun(data): moving = SpeedRun.objects.get(pk=data['moving']) other = SpeedRun.objects.get(pk=data['other']) + if moving.event_id != other.event_id: + return json.dumps({'error': 'Runs are not in the same event'}), 400 before = bool(data['before']) - if moving.order is None: - if before: - runs = SpeedRun.objects.filter(event=moving.event, order__gte=other.order) - final = other.order - else: - runs = SpeedRun.objects.filter(event=moving.event, order__gt=other.order) - final = other.order + 1 - runs = runs.reverse() # otherwise fixing the order goes in the wrong direction - first = final - diff = 1 - elif moving.order < other.order: - if before: - runs = SpeedRun.objects.filter( - event=moving.event, order__gt=moving.order, order__lt=other.order - ) - final = other.order - 1 - else: - runs = SpeedRun.objects.filter( - event=moving.event, order__gt=moving.order, order__lte=other.order - ) - final = other.order - first = moving.order - diff = -1 - else: # moving.order > other.order - if before: - runs = SpeedRun.objects.filter( - event=moving.event, order__gte=other.order, order__lt=moving.order - ) - final = other.order + try: + with transaction.atomic(): + if moving.order is None: + if before: + runs = SpeedRun.objects.filter( + event=moving.event, order__gte=other.order + ).select_for_update() + final = other.order + else: + runs = SpeedRun.objects.filter( + event=moving.event, order__gt=other.order + ).select_for_update() + final = other.order + 1 + runs = ( + runs.reverse() + ) # otherwise fixing the order goes in the wrong direction + first = final + diff = 1 + elif moving.order < other.order: + if before: + runs = SpeedRun.objects.filter( + event=moving.event, + order__gt=moving.order, + order__lt=other.order, + ).select_for_update() + final = other.order - 1 + else: + runs = SpeedRun.objects.filter( + event=moving.event, + order__gt=moving.order, + order__lte=other.order, + ).select_for_update() + final = other.order + first = moving.order + diff = -1 + else: # moving.order > other.order + if before: + runs = SpeedRun.objects.filter( + event=moving.event, + order__gte=other.order, + order__lt=moving.order, + ).select_for_update() + final = other.order + else: + runs = SpeedRun.objects.filter( + event=moving.event, + order__gt=other.order, + order__lt=moving.order, + ).select_for_update() + final = other.order + 1 + runs = ( + runs.reverse() + ) # otherwise fixing the order goes in the wrong direction + first = final + diff = 1 + moving.order = None + moving.save(fix_time=False) + for s in runs: + s.order += diff + s.save(fix_time=False) + moving.order = final + moving.save(fix_time=False) + first_run = SpeedRun.objects.get(event=moving.event, order=first) + first_run.clean() + models = first_run.save() + return models, 200 + except ValidationError as e: + if hasattr(e, 'error_dict'): + return json.dumps({'error': e.message_dict}), 400 else: - runs = SpeedRun.objects.filter( - event=moving.event, order__gt=other.order, order__lt=moving.order - ) - final = other.order + 1 - runs = runs.reverse() # otherwise fixing the order goes in the wrong direction - first = final - diff = 1 - moving.order = None - moving.save(fix_time=False) - for s in runs: - s.order += diff - s.save(fix_time=False) - moving.order = final - moving.save(fix_time=False) - models = SpeedRun.objects.get(event=moving.event, order=first).save() - return models, 200 + return json.dumps({'error': e.messages}), 400 MoveSpeedRun.permission = 'tracker.change_speedrun'