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

Automated start/stop support #297

Merged
merged 15 commits into from
Sep 20, 2023
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
36 changes: 36 additions & 0 deletions iam/api/middleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# This file is part of iam.
# Copyright (C) 2023 Sequent Tech Inc <[email protected]>

# iam is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License.

# iam is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.

# You should have received a copy of the GNU Affero General Public License
# along with iam. If not, see <http://www.gnu.org/licenses/>.

from threading import local
from .decorators import get_login_user

_user = local()

class CurrentUserMiddleware:
def __init__(self, get_response):
self.get_response = get_response

def __call__(self, request):
_user.value, _, _ = get_login_user(request)
response = self.get_response(request)
_user.value = None # clear the user after processing the request
return response

@staticmethod
def get_current_user():
if hasattr(_user, "value"):
return _user.value
else:
return None
18 changes: 18 additions & 0 deletions iam/api/migrations/0052_authapi_scheduled_events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Edulix on 2023-07-12 16:40

from django.db import migrations, models
from django.contrib.postgres.fields import JSONField

class Migration(migrations.Migration):
dependencies = [
('api', '0051_authevent_alternative_auth_methods'),
]

operations = [
migrations.AddField(
model_name='authevent',
name='scheduled_events',
field=JSONField(blank=True, db_index=False, null=True),
preserve_default=False
)
]
230 changes: 227 additions & 3 deletions iam/api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
# along with iam. If not, see <http://www.gnu.org/licenses/>.

import json
import itertools
from datetime import datetime
from celery import current_app
from django.db import models
from django.core.validators import MaxValueValidator, MinValueValidator
from django.core.exceptions import ValidationError
Expand All @@ -24,17 +25,21 @@
from jsonfield import JSONField

from django.dispatch import receiver
from django.db.models.signals import post_save
from django.db.models.signals import post_save, pre_save
from django.db.models import Q
from django.conf import settings
from utils import genhmac
from django.utils import timezone

from contracts.base import check_contract
from contracts import CheckException
from marshmallow import Schema, fields as marshmallow_fields
from marshmallow.exceptions import ValidationError as MarshMallowValidationError
from django.db.models import CharField
from django.db.models.functions import Length

from utils import genhmac
from api.middleware import CurrentUserMiddleware

CharField.register_lookup(Length, 'length')

CENSUS = (
Expand Down Expand Up @@ -280,6 +285,50 @@ def children_event_id_list_validator(value):
except CheckException as e:
raise ValidationError(str(e.data))

class ScheduledEventSchema(Schema):
'''
Schema for elements in the ScheduledEventsSchema, which have a date and
an associated task_id.
'''
event_at = marshmallow_fields.AwareDateTime(
allow_none=True, load_default=None, dump_default=None
)
task_id = marshmallow_fields.String(
allow_none=True, load_default=None, dump_default=None, dump_only=True
)


class ScheduledEventsSchema(Schema):
'''
Schema for the AuthEvent.scheduled_events field
'''
start_voting = marshmallow_fields.Nested(
ScheduledEventSchema,
allow_none=True,
load_default=None,
dump_default=None
)
end_voting = marshmallow_fields.Nested(
ScheduledEventSchema,
allow_none=True,
load_default=None,
dump_default=None
)


def get_schema_validator(klass):
'''
Given a Marshmallow Schema class, returns a Django field validator function
'''
def validator(data):
try:
klass().validate(data)
except MarshMallowValidationError as error:
# Convert the marshmallow validation error to a Django one,
# because Django doesn't know how to handle marshmallow exceptions.
raise ValidationError(error.messages)
return validator


class AuthEvent(models.Model):
'''
Expand Down Expand Up @@ -324,6 +373,25 @@ class AuthEvent(models.Model):
# ]
alternative_auth_methods = JSONField(blank=True, db_index=False, null=True)

# the election scheduled events, like start, stop, allow_tally, tally.
# Example:
# {
# "start_voting": {
# "event_at": "2023-07-13T09:42:12.564355",
# "task_id": None
# },
# "end_voting": {
# "event_at": "2023-07-13T09:42:12.564355",
# "task_id": "7f560718-24d2-4a96-824d-da3787703a06"
# }
# }
scheduled_events = JSONField(
blank=True,
db_index=False,
null=True,
validators=[get_schema_validator(ScheduledEventsSchema)]
)

# used by iam_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(
Expand Down Expand Up @@ -444,6 +512,7 @@ def serialize(self, restrict=False):
'allow_user_resend': self.check_allow_user_resend()
}
},
'scheduled_events': self.scheduled_events,
'openid_connect_providers': [
provider['public_info']
for provider in settings.OPENID_CONNECT_PROVIDERS_CONF
Expand Down Expand Up @@ -601,10 +670,161 @@ def autofill_fields(self, from_user=None, to_user=None):
to_user.userdata.metadata[name] = value
to_user.userdata.save()

def allow_set_status(self, status):
'''
Returns true if the new status is allowed
'''
if not settings.ENFORCE_STATE_CONTROLS:
return True

if (
status == AuthEvent.STARTED and
self.status != AuthEvent.NOT_STARTED
) or (
status == AuthEvent.SUSPENDED and
self.status != AuthEvent.STARTED and
self.status != AuthEvent.RESUMED
) or (
status == AuthEvent.RESUMED and
self.status != AuthEvent.SUSPENDED
) or (
status == AuthEvent.PENDING and
self.status != AuthEvent.STOPPED
) or (
status == AuthEvent.SUCCESS and
self.status != AuthEvent.PENDING
) or (
status == AuthEvent.STOPPED and
self.status != AuthEvent.STARTED and
self.status != AuthEvent.RESUMED and
self.status != AuthEvent.SUSPENDED
):
return False

def __str__(self):
return "%s - %s" % (self.id, self.census)


@receiver(pre_save, sender=AuthEvent)
def update_scheduled_events(sender, instance, **kwargs):
'''
Update the scheduled events in django celery
'''
old_instance = None
try:
old_instance = AuthEvent.objects.get(pk=instance.pk)
except:
# It's a new instance so it has no previous state
pass

# scheduled_events can be empty, but once a task_id has been assigned, it
# cannot be removed unless it has been revoked in this very function. No
# other part of the code changes an scheduled event task_id. This allows us
# to reliably update and revoke tasks by looking them up using the task_id.
default_events = dict(
start_voting=None,
end_voting=None,
)

alt_status = dict(
start_voting='start',
end_voting='stop',
)

events = (instance.scheduled_events
if instance.scheduled_events != None
else default_events
)
old_events = (old_instance.scheduled_events
if old_instance != None and old_instance.scheduled_events != None
else default_events
)
main_event = (instance.parent if instance.parent != None else instance)
user = CurrentUserMiddleware.get_current_user()
for event_name, event_data in events.items():
old_event_data = old_events.get(event_name, None)
old_event_date = (
old_event_data['event_at']
if old_event_data != None
else None
)
old_task_id = (
old_event_data.get('task_id')
if old_event_data != None
else None
)
event_date = (
event_data['event_at']
if event_data != None
else None
)
task_id = (
event_data.get('task_id')
if event_data != None
else None
)

# first ensure only this function changes task_ids
if old_task_id != task_id and event_data != None:
event_data['task_id'] = old_task_id

# nothing changed so we should just continue
if old_event_date == event_date:
continue

# date changed, so we need to cancel previous task if there was one
if old_task_id != None:
current_app.control.revoke(old_task_id)

# change the task id to None since now we revoked it
if event_data != None:
event_data['task_id'] = None

# log the action
action = Action(
executer=user,
receiver=None,
action_name=f'authevent:{event_name}:revoked',
event=main_event,
metadata=dict(
auth_event=instance.pk,
old_event_date=old_event_date,
old_task_id=old_task_id
)
)
action.save()

# we need to schedule the new task
if event_date != None:
eta = datetime.fromisoformat(event_date)
if eta < timezone.now():
print("not scheduling event in the past")
continue
from api.tasks import set_status_task
task_id = event_data['task_id'] = set_status_task.apply_async(
args=[
alt_status[event_name],
user.id,
main_event.id
],
eta=eta,
retry=False
).id
# log the action, only if authevent is created
if main_event.pk != None:
action = Action(
executer=user,
receiver=None,
action_name=f'authevent:{event_name}:scheduled',
event=main_event,
metadata=dict(
auth_event=instance.pk,
new_event_date=event_date,
new_task_id=task_id
)
)
action.save()

STATUSES = (
('act', 'Active'),
('pen', 'Pending'),
Expand Down Expand Up @@ -743,6 +963,10 @@ def create_user_data(sender, instance, created, *args, **kwargs):
('user:added-to-census', 'user:added-to-census'),
('user:deleted-from-census', 'user:deleted-from-census'),
('user:resend-authcode', 'user:resend-authcode'),
('authevent:start_voting:scheduled', 'authevent:start_voting:scheduled'),
('authevent:start_voting:revoked', 'authevent:start_voting:revoked'),
('authevent:end_voting:scheduled', 'authevent:end_voting:scheduled'),
('authevent:end_voting:revoked', 'authevent:end_voting:revoked'),
)

class Action(models.Model):
Expand Down
Loading
Loading