diff --git a/authapi/api/migrations/0048_authevent_allowed_statuses.py b/authapi/api/migrations/0048_authevent_allowed_statuses.py new file mode 100644 index 00000000..4ec997ea --- /dev/null +++ b/authapi/api/migrations/0048_authevent_allowed_statuses.py @@ -0,0 +1,27 @@ +# Generated by Edulix on 2021-12-30 11:00 + +from django.db import migrations, models + +class Migration(migrations.Migration): + dependencies = [ + ('api', '0047_userdata_use_generated_auth_code'), + ] + + operations = [ + migrations.AlterField( + model_name='authevent', + name='status', + field=models.CharField( + default='notstarted', + max_length=15, + choices=[ + ('notstarted', 'not-started'), + ('started', 'started'), + ('stopped', 'stopped'), + ('resumed', 'resumed'), + ('suspended', 'suspended') + ] + ), + preserve_default=True + ) + ] diff --git a/authapi/api/models.py b/authapi/api/models.py index 4f867f50..064b567f 100644 --- a/authapi/api/models.py +++ b/authapi/api/models.py @@ -46,6 +46,8 @@ ('notstarted', 'not-started'), ('started', 'started'), ('stopped', 'stopped'), + ('resumed', 'resumed'), + ('suspended', 'suspended') ) AE_TALLY_STATUSES = ( @@ -262,19 +264,30 @@ class AuthEvent(models.Model): use case is that each AuthEvent corresponds with an Election, in principle it could be used for any kind of authentication and authorization event. ''' + NOT_STARTED = "notstarted" + STARTED = "started" + STOPPED = "stopped" + SUSPENDED = "suspended" + RESUMED = "resumed" + PENDING = "pending" + SUCCESS = "success" auth_method = models.CharField(max_length=255) census = models.CharField(max_length=5, choices=CENSUS, default="close") auth_method_config = JSONField() extra_fields = JSONField(blank=True, null=True) - status = models.CharField(max_length=15, choices=AE_STATUSES, default="notstarted") - + status = models.CharField( + max_length=15, + choices=AE_STATUSES, + default=NOT_STARTED + ) + # used by authapi_celery to know what tallies to launch, and to serialize # those launches one by one. set/get with (s|g)et_tally_status api calls tally_status = models.CharField( max_length=15, choices=AE_TALLY_STATUSES, - default="notstarted" + default=NOT_STARTED ) created = models.DateTimeField(auto_now_add=True) diff --git a/authapi/api/tasks.py b/authapi/api/tasks.py index 6e83f30f..af7d34da 100644 --- a/authapi/api/tasks.py +++ b/authapi/api/tasks.py @@ -132,7 +132,7 @@ def launch_tally(auth_event): agora_elections_request.status_code, agora_elections_request.text ) - auth_event.tally_status = 'notstarted' + auth_event.tally_status = AuthEvent.STARTED auth_event.save() # log the action @@ -165,7 +165,7 @@ def launch_tally(auth_event): agora_elections_request.status_code, agora_elections_request.text ) - auth_event.tally_status = 'started' + auth_event.tally_status = AuthEvent.STARTED auth_event.save() # log the action @@ -218,7 +218,7 @@ def launch_virtual_tally(auth_event): agora_elections_request.status_code, agora_elections_request.text ) - auth_event.tally_status = 'notstarted' + auth_event.tally_status = AuthEvent.NOT_STARTED auth_event.save() # log the action @@ -246,7 +246,7 @@ def launch_virtual_tally(auth_event): agora_elections_request.status_code, agora_elections_request.text ) - auth_event.tally_status = 'success' + auth_event.tally_status = AuthEvent.SUCCESS auth_event.save() # log the action @@ -319,7 +319,7 @@ def update_tally_status(auth_event): election_state = updated_election['payload']['state'] if election_state in ['tally_error', 'stopped', 'started']: - auth_event.tally_status = 'notstarted' + auth_event.tally_status = AuthEvent.NOT_STARTED auth_event.save() # log the action @@ -334,7 +334,7 @@ def update_tally_status(auth_event): ) action.save() elif election_state in ['tally_ok', 'results_ok', 'results_pub']: - auth_event.tally_status = 'success' + auth_event.tally_status = AuthEvent.SUCCESS auth_event.save() # log the action @@ -383,7 +383,7 @@ def process_tallies(): ''' logger.info('\n\ntasks.process_tallies') tallying_events = AuthEvent.objects\ - .filter(tally_status='started')\ + .filter(tally_status=AuthEvent.STARTED)\ .order_by('id') # Review which tallies have succeeded and update corresponding AuthEvents @@ -391,7 +391,7 @@ def process_tallies(): update_tally_status(auth_event) pending_events = AuthEvent.objects\ - .filter(tally_status='pending')\ + .filter(tally_status=AuthEvent.PENDING)\ .order_by('id') logger.info( diff --git a/authapi/api/urls.py b/authapi/api/urls.py index b6833b5a..d36a6496 100644 --- a/authapi/api/urls.py +++ b/authapi/api/urls.py @@ -54,7 +54,7 @@ url(r'^auth-event/(?P\d+)/resend_auth_code/$', views.resend_auth_code, name='resend_auth_code'), url(r'^auth-event/(?P\d+)/census/send_auth/$', views.census_send_auth, name='census_send_auth'), url(r'^auth-event/(?P\d+)/census/reset-voter/$', views.census_reset_voter, name='census_reset_voter'), - url(r'^auth-event/(?P\d+)/(?P(notstarted|started|stopped))/$', views.ae_status, name='ae_status'), + url(r'^auth-event/(?P\d+)/(?P(notstarted|started|stopped|suspended|resumed))/$', views.ae_status, name='ae_status'), url(r'^auth-event/module/$', views.authevent_module, name='authevent_module'), url(r'^auth-event/module/(?P[-\w]+)/$', views.authevent_module, name='authevent_module'), diff --git a/authapi/api/views.py b/authapi/api/views.py index 9de071bc..2bd851f5 100644 --- a/authapi/api/views.py +++ b/authapi/api/views.py @@ -599,7 +599,14 @@ class Authenticate(View): def post(self, request, pk): try: - e = get_object_or_404(AuthEvent, pk=pk, status="started") + e = get_object_or_404( + AuthEvent, + pk=pk, + status__in=[ + AuthEvent.STARTED, + AuthEvent.RESUMED + ] + ) except: return json_response(status=400, error_codename=ErrorCodes.BAD_REQUEST) @@ -674,7 +681,12 @@ def post(self, request, pk): e = get_object_or_404( AuthEvent, pk=pk, - status__in=['notstarted', 'started'], + status__in=[ + AuthEvent.NOT_STARTED, + AuthEvent.STARTED, + AuthEvent.SUSPENDED, + AuthEvent.RESUMED + ], allow_public_census_query=True) try: @@ -940,12 +952,25 @@ def post(self, request, pk): if "match_census_on_registration" in f and f['match_census_on_registration'] ] - if (e.census == 'close') and (len(match_census_on_registration) == 0 or e.status != 'started'): + if ( + (e.census == 'close') and + ( + len(match_census_on_registration) == 0 or + ( + e.status != AuthEvent.STARTED and + e.status != AuthEvent.RESUMED + ) + ) + ): return json_response( status=400, error_codename="REGISTER_IS_DISABLED") # registration is closed - if e.census == 'open' and e.status != 'started': + if ( + e.census == 'open' and + e.status != AuthEvent.STARTED and + e.status != AuthEvent.RESUMED + ): return json_response( status=400, error_codename="AUTH_EVENT_NOT_STARTED") @@ -976,12 +1001,22 @@ class ResendAuthCode(View): def post(self, request, pk): auth_event = get_object_or_404(AuthEvent, pk=pk) - if (auth_event.census == 'close' and not auth_event.check_allow_user_resend()): + if ( + auth_event.census == 'close' and + not auth_event.check_allow_user_resend() + ): return json_response( status=400, error_codename="AUTH_EVENT_NOT_STARTED") # registration is closed - if (auth_event.census == 'open' or auth_event.check_allow_user_resend()) and auth_event.status != 'started': + if ( + ( + auth_event.census == 'open' or + auth_event.check_allow_user_resend() + ) and + auth_event.status != AuthEvent.STARTED and + auth_event.status != AuthEvent.RESUMED + ): return json_response( status=400, error_codename="AUTH_EVENT_NOT_STARTED") @@ -1017,7 +1052,9 @@ def post(self, request, pk, status): alt = dict( notstarted="notstarted", started='start', - stopped='stop' + stopped='stop', + suspended='suspend', + resumed='resume' )[status] permission_required(request.user, 'AuthEvent', ['edit', alt], pk) @@ -1027,17 +1064,49 @@ def post(self, request, pk, status): children_ids = main_auth_event.children_election_info['natural_order'] else: children_ids = [] - + auth_events = AuthEvent.objects.filter( Q(pk=pk) | Q(parent_id=pk) | Q(parent_id__in=children_ids) ) - + for auth_event in auth_events: # update AuthEvent - if auth_event.status != status: + # enforce state transitions make sense + if settings.ENFORCE_STATE_CONTROLS: + if ( + status == AuthEvent.STARTED and + auth_event.status != AuthEvent.NOT_STARTED + ) or ( + status == AuthEvent.SUSPENDED and + auth_event.status != AuthEvent.STARTED and + auth_event.status != AuthEvent.RESUMED + ) or ( + status == AuthEvent.RESUMED and + auth_event.status != AuthEvent.SUSPENDED + ) or ( + status == AuthEvent.PENDING and + auth_event.status != AuthEvent.STOPPED + ) or ( + status == AuthEvent.SUCCESS and + auth_event.status != AuthEvent.PENDING + ) or ( + status == AuthEvent.STOPPED and + auth_event.status != AuthEvent.STARTED and + auth_event.status != AuthEvent.RESUMED and + auth_event.status != AuthEvent.SUSPENDED + ): + return json_response( + status=400, + data=dict( + next_status=status, + current_status=auth_event.status + ), + error_codename="INVALID_STATUS_TRANSITION" + ) + auth_event.status = status auth_event.save() @@ -1056,7 +1125,7 @@ def post(self, request, pk, status): action.save() # update in agora-elections - if alt in ['start', 'stop']: + if alt in ['start', 'stop', 'suspend', 'resume']: for callback_base in settings.AGORA_ELECTIONS_BASE: callback_url = "%s/api/election/%s/%s" % ( callback_base, @@ -1094,6 +1163,15 @@ def post(self, request, pk, status): error_codename=ErrorCodes.GENERAL_ERROR ) + # if new status is stop and tally_status is pending, move it to + if ( + alt == 'stop' and + auth_event.tally_status != AuthEvent.NOT_STARTED + ): + auth_event.tally_status = AuthEvent.NOT_STARTED + auth_event.save() + + LOGGER.info(\ "AuthEventStatus.post\n"\ "agora_elections.callback_url '%r'\n"\ @@ -1368,7 +1446,11 @@ def post(request, pk): ''' from authmethods.utils import verify_children_election_info permission_required(request.user, 'AuthEvent', 'edit', pk) - auth_event = get_object_or_404(AuthEvent, pk=pk, status='notstarted') + auth_event = get_object_or_404( + AuthEvent, + pk=pk, + status=AuthEvent.NOT_STARTED + ) try: req = parse_json_request(request) except: @@ -1448,10 +1530,18 @@ def post(request, pk=None): requested_id = req.get('id', None) election_exists = False if requested_id and isinstance(requested_id, int): - count_existing_elections = AuthEvent.objects.filter(pk=requested_id).count() - if count_existing_elections != 0: - permission_required(request.user, 'AuthEvent', 'edit', requested_id) - election_exists = True + count_existing_elections = AuthEvent\ + .objects\ + .filter(pk=requested_id)\ + .count() + if count_existing_elections != 0: + permission_required( + request.user, + 'AuthEvent', + 'edit', + requested_id + ) + election_exists = True else: requested_id = None @@ -1701,8 +1791,13 @@ def post(request, pk=None): ae.save() # TODO: Problem if object_id is None, change None by 0 - acl = get_object_or_404(ACL, user=request.user.userdata, - perm='edit', object_type='AuthEvent', object_id=ae.pk) + acl = get_object_or_404( + ACL, + user=request.user.userdata, + perm='edit', + object_type='AuthEvent', + object_id=ae.pk + ) action = Action( executer=request.user, @@ -1997,8 +2092,11 @@ def post(self, request): class UserAuthEvent(View): def get(self, request): ''' Get ids auth-event of request user. ''' - acls = ACL.objects.filter(user=request.user.pk, object_type='AuthEvent', - perm='edit') + acls = ACL.objects.filter( + user=request.user.pk, + object_type='AuthEvent', + perm='edit' + ) ae_ids = [] for acl in acls: ae_ids.append(acl.object_id) @@ -2279,7 +2377,12 @@ class BallotBoxView(View): ''' def post(self, request, pk): - permission_required(request.user, 'AuthEvent', ['edit', 'add-ballot-boxes'], pk) + permission_required( + request.user, + 'AuthEvent', + ['edit', 'add-ballot-boxes'], + pk + ) # parse input try: @@ -2297,8 +2400,8 @@ def post(self, request, pk): if not new_ballot_box.is_valid() or not auth_event.has_ballot_boxes: return json_response( status=400, - error_codename=ErrorCodes.BAD_REQUEST) - + error_codename=ErrorCodes.BAD_REQUEST + ) # try to create new object in the db. might fail if bb already exists try: @@ -2463,7 +2566,7 @@ def post(self, request, pk, ballot_box_pk): BallotBox, pk=ballot_box_pk, auth_event__pk=pk, - auth_event__status="stopped" + auth_event__status=AuthEvent.STOPPED ) # require extra permissions to override tally sheet @@ -2632,7 +2735,7 @@ def post(self, request, pk): # cannot launch tally on an election whose voting period is still open # or has not even started. - if auth_event.status != 'stopped': + if auth_event.status != AuthEvent.STOPPED: return json_response( status=400, error_codename=ErrorCodes.BAD_REQUEST @@ -2694,18 +2797,21 @@ def post(self, request, pk): # set the pending status accordingly for auth_event_to_tally in auth_events: if ( - auth_event_to_tally.tally_status == 'notstarted' or + auth_event_to_tally.tally_status == AuthEvent.NOT_STARTED or ( - auth_event_to_tally.tally_status == 'pending' and + auth_event_to_tally.tally_status == AuthEvent.PENDING and force_tally in ['force-untallied', 'force-all'] ) or ( - auth_event_to_tally.tally_status in ['started', 'success'] and + auth_event_to_tally.tally_status in [ + AuthEvent.STARTED, + AuthEvent.SUCCESS + ] and force_tally in ['force-all'] ) ): # set tally status to pending previous_tally_status = auth_event_to_tally.tally_status - auth_event_to_tally.tally_status = 'pending' + auth_event_to_tally.tally_status = AuthEvent.PENDING auth_event_to_tally.save() # log the action @@ -2718,7 +2824,7 @@ def post(self, request, pk): auth_event=auth_event_to_tally.pk, previous_tally_status=previous_tally_status, force_tally=force_tally, - forced=(previous_tally_status != 'notstarted') + forced=(previous_tally_status != AuthEvent.NOT_STARTED) ) ) action.save() diff --git a/authapi/authapi/celery.py b/authapi/authapi/celery.py index 9c7414b5..e0fbca3d 100644 --- a/authapi/authapi/celery.py +++ b/authapi/authapi/celery.py @@ -21,8 +21,11 @@ def reset_tallies_task(sender=None, conf=None, **kwargs): from api.models import AuthEvent AuthEvent\ .objects\ - .filter(tally_status__in=['pending','started'])\ - .update(tally_status='notstarted') + .filter(tally_status__in=[ + AuthEvent.PENDING, + AuthEvent.STARTED + ])\ + .update(tally_status=AuthEvent.NOT_STARTED) # set the default Django settings module for the 'celery' program. os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'authapi.settings') diff --git a/authapi/authapi/settings.py b/authapi/authapi/settings.py index 5efe7b52..43403ef6 100644 --- a/authapi/authapi/settings.py +++ b/authapi/authapi/settings.py @@ -39,14 +39,19 @@ class CeleryConfig: 'args': [], 'options': { 'expires': 10 - }, - 'time_limit': 10 - }, + } + } } result_backend = 'django-db' CELERY_CONFIG = CeleryConfig +CELERY_ANNOTATIONS = { + 'tasks.process_tallies': { + 'time_limit': 10 + } +} + # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/1.7/howto/deployment/checklist/ diff --git a/authapi/authapi/test_settings.py b/authapi/authapi/test_settings.py index 686806bd..485a3400 100644 --- a/authapi/authapi/test_settings.py +++ b/authapi/authapi/test_settings.py @@ -164,6 +164,8 @@ class CeleryConfig: USE_TZ = True +ENFORCE_STATE_CONTROLS = False + # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/1.7/howto/static-files/ diff --git a/authapi/utils.py b/authapi/utils.py index 80514f34..4fb07d69 100644 --- a/authapi/utils.py +++ b/authapi/utils.py @@ -86,8 +86,16 @@ def json_response(data=None, status=200, message="", field=None, error_codename= error_codename = ErrorCodes.GENERAL_ERROR if isinstance(error_codename, ErrorCodes): error_codename = error_codename.value - data = dict(message=message, field=field, error_codename=error_codename) - jsondata = json.dumps(data) + error_data = dict( + message=message, + field=field, + error_codename=error_codename + ) + if data is not None: + error_data['data'] = data + jsondata = json.dumps(error_data) + else: + jsondata = json.dumps(data) return HttpResponse(jsondata, status=status, content_type='application/json')