From e11332c31c129596ce0be9749c0334d8cdb1337f Mon Sep 17 00:00:00 2001 From: Julian B Date: Tue, 4 Mar 2025 22:53:02 +0100 Subject: [PATCH 1/7] add consequences and workinghours to API --- ephios/api/serializers.py | 14 ++++++++++++++ ephios/api/urls.py | 4 ++++ ephios/api/views/users.py | 26 +++++++++++++++++++++++++- 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/ephios/api/serializers.py b/ephios/api/serializers.py index b8a19d462..75496a948 100644 --- a/ephios/api/serializers.py +++ b/ephios/api/serializers.py @@ -12,11 +12,13 @@ from ephios.api.fields import ChoiceDisplayField from ephios.core.models import ( AbstractParticipation, + Consequence, Event, EventType, Qualification, Shift, UserProfile, + WorkingHours, ) from ephios.core.models.events import ParticipationComment from ephios.core.services.qualification import collect_all_included_qualifications @@ -210,3 +212,15 @@ class ParticipationSerializer(UserinfoParticipationSerializer): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) del self.fields["comments"] + + +class WorkingHoursSerializer(ModelSerializer): + class Meta: + model = WorkingHours + fields = ["user", "hours", "reason", "date"] + + +class ConsequenceSerializer(ModelSerializer): + class Meta: + model = Consequence + fields = ["slug", "user", "state", "data"] diff --git a/ephios/api/urls.py b/ephios/api/urls.py index a7dbce08d..f4de336f4 100644 --- a/ephios/api/urls.py +++ b/ephios/api/urls.py @@ -20,11 +20,13 @@ UserinfoParticipationViewSet, ) from ephios.api.views.users import ( + ConsequenceViewSet, OwnParticipationsViewSet, UserByMailView, UserParticipationView, UserProfileMeView, UserViewSet, + WorkingHoursViewSet, ) from ephios.extra.permissions import staff_required @@ -41,6 +43,8 @@ router.register( r"users/(?P[\d]+)/participations", UserParticipationView, basename="user-participations" ) +router.register(r"workinghours", WorkingHoursViewSet) +router.register(r"consequences", ConsequenceViewSet) app_name = "api" urlpatterns = [ diff --git a/ephios/api/views/users.py b/ephios/api/views/users.py index 42b05bcb1..babcfe362 100644 --- a/ephios/api/views/users.py +++ b/ephios/api/views/users.py @@ -18,11 +18,18 @@ ViewUserModelObjectPermissions, ) from ephios.api.serializers import ( + ConsequenceSerializer, ParticipationSerializer, UserinfoParticipationSerializer, UserProfileSerializer, + WorkingHoursSerializer, +) +from ephios.core.models import ( + AbstractParticipation, + Consequence, + UserProfile, + WorkingHours, ) -from ephios.core.models import AbstractParticipation, UserProfile class UserProfileMeView(RetrieveAPIView): @@ -84,3 +91,20 @@ def get_queryset(self): return AbstractParticipation.objects.filter( localparticipation__user=self.kwargs.get("user") ).select_related("shift", "shift__event", "shift__event__type") + + +class WorkingHoursViewSet(viewsets.ReadOnlyModelViewSet): + serializer_class = WorkingHoursSerializer + permission_classes = [IsAuthenticatedOrTokenHasScope, ViewObjectPermissions] + filter_backends = [DjangoFilterBackend] + filterset_fields = ["user", "date"] + required_scopes = ["CONFIDENTIAL_READ"] + queryset = WorkingHours.objects.all() + + +class ConsequenceViewSet(viewsets.ModelViewSet): + serializer_class = ConsequenceSerializer + permission_classes = [IsAuthenticatedOrTokenHasScope, ViewObjectPermissions] + filter_backends = [DjangoFilterBackend] + required_scopes = ["CONFIDENTIAL_WRITE"] + queryset = Consequence.objects.all() From 746ff18b1e26bef9e713f949bb8a1bb3b1518c6b Mon Sep 17 00:00:00 2001 From: "Julian B." Date: Fri, 28 Mar 2025 17:50:06 +0100 Subject: [PATCH 2/7] improve consequence serializer --- ephios/api/serializers.py | 15 +++++++++++++++ ephios/api/views/users.py | 1 + ephios/plugins/federation/consequences.py | 5 +++++ 3 files changed, 21 insertions(+) create mode 100644 ephios/plugins/federation/consequences.py diff --git a/ephios/api/serializers.py b/ephios/api/serializers.py index 75496a948..226ccfb3a 100644 --- a/ephios/api/serializers.py +++ b/ephios/api/serializers.py @@ -10,6 +10,7 @@ from rest_framework.serializers import ModelSerializer from ephios.api.fields import ChoiceDisplayField +from ephios.core.consequences import consequence_handler_from_slug from ephios.core.models import ( AbstractParticipation, Consequence, @@ -224,3 +225,17 @@ class ConsequenceSerializer(ModelSerializer): class Meta: model = Consequence fields = ["slug", "user", "state", "data"] + + def validate_state(self, value): + if value != Consequence.States.NEEDS_CONFIRMATION: + raise serializers.ValidationError( + _("Consequences must be created in needs_confirmation state") + ) + + def validate_slug(self, value): + try: + consequence_handler_from_slug(value) + except ValueError: + raise serializers.ValidationError( + _("Consequence handler for '{slug}' was not found.").format(slug=value) + ) diff --git a/ephios/api/views/users.py b/ephios/api/views/users.py index babcfe362..2cca641d7 100644 --- a/ephios/api/views/users.py +++ b/ephios/api/views/users.py @@ -106,5 +106,6 @@ class ConsequenceViewSet(viewsets.ModelViewSet): serializer_class = ConsequenceSerializer permission_classes = [IsAuthenticatedOrTokenHasScope, ViewObjectPermissions] filter_backends = [DjangoFilterBackend] + filterset_fields = ["slug", "user", "state"] required_scopes = ["CONFIDENTIAL_WRITE"] queryset = Consequence.objects.all() diff --git a/ephios/plugins/federation/consequences.py b/ephios/plugins/federation/consequences.py new file mode 100644 index 000000000..a270127f3 --- /dev/null +++ b/ephios/plugins/federation/consequences.py @@ -0,0 +1,5 @@ +from ephios.core.consequences import BaseConsequenceHandler + + +class FederatedConsequenceHandler(BaseConsequenceHandler): + pass From 314f81c71b315594ab018124f8fc540dbc47e394 Mon Sep 17 00:00:00 2001 From: Julian B Date: Fri, 25 Jul 2025 23:43:08 +0200 Subject: [PATCH 3/7] implement inheritance for consequences --- ephios/api/serializers.py | 7 +- ephios/api/views/users.py | 6 +- ephios/core/admin.py | 4 +- ephios/core/consequences.py | 59 +++++---- ephios/core/forms/users.py | 2 +- ...tconsequence_alter_shift_label_and_more.py | 112 ++++++++++++++++++ ephios/core/models/__init__.py | 2 +- ephios/core/models/users.py | 74 +++++++----- ephios/core/services/notifications/types.py | 10 +- ephios/core/signup/participants.py | 10 +- ephios/core/views/consequences.py | 4 +- ephios/plugins/federation/admin.py | 3 +- ..._federated_instance_identifier_and_more.py | 48 ++++++++ ephios/plugins/federation/models.py | 21 ++++ 14 files changed, 289 insertions(+), 73 deletions(-) create mode 100644 ephios/core/migrations/0039_abstractconsequence_alter_shift_label_and_more.py create mode 100644 ephios/plugins/federation/migrations/0007_federateduser_federated_instance_identifier_and_more.py diff --git a/ephios/api/serializers.py b/ephios/api/serializers.py index 226ccfb3a..807630093 100644 --- a/ephios/api/serializers.py +++ b/ephios/api/serializers.py @@ -13,7 +13,7 @@ from ephios.core.consequences import consequence_handler_from_slug from ephios.core.models import ( AbstractParticipation, - Consequence, + LocalConsequence, Event, EventType, Qualification, @@ -22,6 +22,7 @@ WorkingHours, ) from ephios.core.models.events import ParticipationComment +from ephios.core.models.users import AbstractConsequence from ephios.core.services.qualification import collect_all_included_qualifications from ephios.core.templatetags.settings_extras import make_absolute @@ -223,11 +224,11 @@ class Meta: class ConsequenceSerializer(ModelSerializer): class Meta: - model = Consequence + model = AbstractConsequence fields = ["slug", "user", "state", "data"] def validate_state(self, value): - if value != Consequence.States.NEEDS_CONFIRMATION: + if value != AbstractConsequence.States.NEEDS_CONFIRMATION: raise serializers.ValidationError( _("Consequences must be created in needs_confirmation state") ) diff --git a/ephios/api/views/users.py b/ephios/api/views/users.py index 2cca641d7..28f4eb61e 100644 --- a/ephios/api/views/users.py +++ b/ephios/api/views/users.py @@ -26,10 +26,12 @@ ) from ephios.core.models import ( AbstractParticipation, - Consequence, + LocalConsequence, + LocalParticipation, UserProfile, WorkingHours, ) +from ephios.core.models.users import AbstractConsequence class UserProfileMeView(RetrieveAPIView): @@ -108,4 +110,4 @@ class ConsequenceViewSet(viewsets.ModelViewSet): filter_backends = [DjangoFilterBackend] filterset_fields = ["slug", "user", "state"] required_scopes = ["CONFIDENTIAL_WRITE"] - queryset = Consequence.objects.all() + queryset = AbstractConsequence.objects.all() diff --git a/ephios/core/admin.py b/ephios/core/admin.py index 0311e1529..5f377ac23 100644 --- a/ephios/core/admin.py +++ b/ephios/core/admin.py @@ -2,7 +2,7 @@ from guardian.admin import GuardedModelAdmin from ephios.core.models import ( - Consequence, + LocalConsequence, Event, EventType, LocalParticipation, @@ -22,7 +22,7 @@ admin.site.register(QualificationGrant) admin.site.register(QualificationCategory) admin.site.register(WorkingHours) -admin.site.register(Consequence) +admin.site.register(LocalConsequence) admin.site.register(Shift) admin.site.register(Event, GuardedModelAdmin) diff --git a/ephios/core/consequences.py b/ephios/core/consequences.py index 6dbc7c611..01bca2c41 100644 --- a/ephios/core/consequences.py +++ b/ephios/core/consequences.py @@ -13,7 +13,7 @@ from guardian.shortcuts import get_objects_for_user from ephios.core.models import ( - Consequence, + LocalConsequence, Event, Qualification, QualificationGrant, @@ -21,7 +21,9 @@ UserProfile, WorkingHours, ) +from ephios.core.models.users import AbstractConsequence from ephios.core.signals import register_consequence_handlers +from ephios.core.signup.participants import AbstractParticipant def installed_consequence_handlers(): @@ -38,14 +40,15 @@ def consequence_handler_from_slug(slug): def editable_consequences(user): handlers = list(installed_consequence_handlers()) - qs = Consequence.objects.all().select_related("user") - for handler in handlers: - qs = handler.filter_queryset(qs, user) + # qs = LocalConsequence.objects.all().select_related("user") + # for handler in handlers: + # qs = handler.filter_queryset(qs, user) + qs = AbstractConsequence.objects.all() return qs.filter(slug__in=map(operator.attrgetter("slug"), handlers)).distinct() def pending_consequences(user): - qs = Consequence.objects.filter(user=user, state=Consequence.States.NEEDS_CONFIRMATION) + qs = LocalConsequence.objects.filter(user=user, state=LocalConsequence.States.NEEDS_CONFIRMATION) return qs @@ -53,6 +56,10 @@ class ConsequenceError(Exception): pass +class UnsupportedConsequenceTarget(ConsequenceError): + pass + + class BaseConsequenceHandler: @property def slug(self): @@ -88,19 +95,21 @@ class WorkingHoursConsequenceHandler(BaseConsequenceHandler): @classmethod def create( cls, - user: UserProfile, + participant: AbstractParticipant, when: date, hours: float, reason: str, ): - return Consequence.objects.create( - slug=cls.slug, - user=user, - data={"hours": hours, "date": when, "reason": reason}, - ) + consequence = participant.new_consequence() + consequence.slug = cls.slug + consequence.data = {"hours": hours, "date": when, "reason": reason} + consequence.save() + return consequence @classmethod def execute(cls, consequence): + if not isinstance(consequence, LocalConsequence): + raise UnsupportedConsequenceTarget WorkingHours.objects.create( user=consequence.user, date=consequence.data["date"], @@ -110,8 +119,7 @@ def execute(cls, consequence): @classmethod def render(cls, consequence): - return _("{user} obtains {hours} working hours for {reason} on {date}").format( - user=consequence.user.get_full_name(), + return _("obtains {hours} working hours for {reason} on {date}").format( hours=floatformat(consequence.data.get("hours"), arg=-2), reason=consequence.data.get("reason"), date=date_format(consequence.data.get("date")), @@ -135,23 +143,25 @@ class QualificationConsequenceHandler(BaseConsequenceHandler): @classmethod def create( cls, - user: UserProfile, + participant: AbstractParticipant, qualification: Qualification, expires: datetime = None, shift: Shift = None, ): - return Consequence.objects.create( - slug=cls.slug, - user=user, - data={ + consequence = participant.new_consequence() + consequence.slug = cls.slug + consequence.data = { "qualification_id": qualification.id, "event_id": None if shift is None else shift.event_id, "expires": expires, - }, - ) + } + consequence.save() + return consequence @classmethod def execute(cls, consequence): + if not isinstance(consequence, LocalConsequence): + raise UnsupportedConsequenceTarget qg, created = QualificationGrant.objects.get_or_create( defaults={"expires": consequence.data["expires"]}, user=consequence.user, @@ -189,17 +199,14 @@ def render(cls, consequence): if expires := consequence.data.get("expires"): expires = date_format(expires) - user = consequence.user.get_full_name() - # build string based on available data if event_title: - s = _("{user} acquires '{qualification}' after participating in {event}.").format( - user=user, qualification=qualification_title, event=event_title + s = _("acquires '{qualification}' after participating in {event}.").format( + qualification=qualification_title, event=event_title ) else: - s = _("{user} acquires '{qualification}'.").format( - user=user, + s = _("acquires '{qualification}'.").format( qualification=qualification_title, ) diff --git a/ephios/core/forms/users.py b/ephios/core/forms/users.py index 2bbfcb698..34a9f9fc4 100644 --- a/ephios/core/forms/users.py +++ b/ephios/core/forms/users.py @@ -446,7 +446,7 @@ def __init__(self, *args, **kwargs): def create_consequence(self): WorkingHoursConsequenceHandler.create( - user=self.user, + participant=self.user.as_participant(), when=self.cleaned_data["date"], hours=float(self.cleaned_data["hours"]), reason=self.cleaned_data["reason"], diff --git a/ephios/core/migrations/0039_abstractconsequence_alter_shift_label_and_more.py b/ephios/core/migrations/0039_abstractconsequence_alter_shift_label_and_more.py new file mode 100644 index 000000000..a379b6013 --- /dev/null +++ b/ephios/core/migrations/0039_abstractconsequence_alter_shift_label_and_more.py @@ -0,0 +1,112 @@ +# Generated by Django 5.2.3 on 2025-07-25 20:47 + +import django.db.models.deletion +import ephios.extra.json +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("core", "0038_eventtype_default_description"), + ] + + operations = [ + migrations.CreateModel( + name="AbstractConsequence", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("slug", models.CharField(max_length=255)), + ( + "data", + models.JSONField( + decoder=ephios.extra.json.CustomJSONDecoder, + default=dict, + encoder=ephios.extra.json.CustomJSONEncoder, + ), + ), + ( + "state", + models.TextField( + choices=[ + ("needs_confirmation", "needs confirmation"), + ("confirmed", "confirmed"), + ("executed", "executed"), + ("failed", "failed"), + ("denied", "denied"), + ], + default="needs_confirmation", + max_length=31, + verbose_name="State", + ), + ), + ( + "polymorphic_ctype", + models.ForeignKey( + editable=False, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="polymorphic_%(app_label)s.%(class)s_set+", + to="contenttypes.contenttype", + ), + ), + ], + options={ + "verbose_name": "Abstract consequence", + "verbose_name_plural": "Abstract consequences", + "db_table": "abstractconsequence", + }, + ), + migrations.AlterField( + model_name="shift", + name="label", + field=models.CharField( + blank=True, + help_text="Optional label to help differentiate multiple shifts in an event.", + max_length=255, + verbose_name="label", + ), + ), + migrations.CreateModel( + name="LocalConsequence", + fields=[ + ( + "abstractconsequence_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="core.abstractconsequence", + ), + ), + ( + "user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="affecting_consequences", + to=settings.AUTH_USER_MODEL, + verbose_name="affected user", + ), + ), + ], + options={ + "verbose_name": "Local consequence", + "verbose_name_plural": "Local consequences", + "db_table": "localconsequence", + }, + bases=("core.abstractconsequence",), + ), + migrations.DeleteModel( + name="Consequence", + ), + ] diff --git a/ephios/core/models/__init__.py b/ephios/core/models/__init__.py index 40d02af70..a0d7c5b56 100644 --- a/ephios/core/models/__init__.py +++ b/ephios/core/models/__init__.py @@ -7,7 +7,7 @@ Shift, ) from .users import ( - Consequence, + LocalConsequence, Notification, Qualification, QualificationCategory, diff --git a/ephios/core/models/users.py b/ephios/core/models/users.py index 1e0a57c94..2cd588e52 100644 --- a/ephios/core/models/users.py +++ b/ephios/core/models/users.py @@ -31,6 +31,7 @@ from django.db.models.functions import Lower, TruncDate from django.utils import timezone from django.utils.translation import gettext_lazy as _ +from polymorphic.models import PolymorphicModel from ephios.extra.fields import EndOfDayDateTimeField from ephios.extra.json import CustomJSONDecoder, CustomJSONEncoder @@ -390,20 +391,14 @@ class Meta: ) -class Consequence(Model): +@dont_log # as we log the specific models +class AbstractConsequence(PolymorphicModel): slug = models.CharField(max_length=255) data = models.JSONField(default=dict, encoder=CustomJSONEncoder, decoder=CustomJSONDecoder) - user = models.ForeignKey( - get_user_model(), - on_delete=models.CASCADE, - verbose_name=_("affected user"), - null=True, - related_name="affecting_consequences", - ) - class States(models.TextChoices): NEEDS_CONFIRMATION = "needs_confirmation", _("needs confirmation") + CONFIRMED = "confirmed", _("confirmed") EXECUTED = "executed", _("executed") FAILED = "failed", _("failed") DENIED = "denied", _("denied") @@ -415,10 +410,11 @@ class States(models.TextChoices): verbose_name=_("State"), ) + class Meta: - db_table = "consequence" - verbose_name = _("Consequence") - verbose_name_plural = _("Consequences") + db_table = "abstractconsequence" + verbose_name = _("Abstract consequence") + verbose_name_plural = _("Abstract consequences") @property def handler(self): @@ -426,7 +422,7 @@ def handler(self): return consequences.consequence_handler_from_slug(self.slug) - def confirm(self, user): + def confirm(self): from ephios.core.consequences import ConsequenceError if self.state not in { @@ -441,8 +437,7 @@ def confirm(self, user): self.handler.execute(self) from ephios.core.services.notifications.types import ConsequenceApprovedNotification - if user != self.user: - ConsequenceApprovedNotification.send(self) + ConsequenceApprovedNotification.send(self) except Exception as e: # pylint: disable=broad-except self.state = self.States.FAILED add_log_recorder( @@ -458,7 +453,7 @@ def confirm(self, user): finally: self.save() - def deny(self, user): + def deny(self): from ephios.core.consequences import ConsequenceError if self.state not in {self.States.NEEDS_CONFIRMATION, self.States.FAILED}: @@ -467,28 +462,49 @@ def deny(self, user): self.save() from ephios.core.services.notifications.types import ConsequenceDeniedNotification - if user != self.user: - ConsequenceDeniedNotification.send(self) + ConsequenceDeniedNotification.send(self) def render(self): - return self.handler.render(self) + return f"{self.participant_display_name()} {self.handler.render(self)}" def __str__(self): return self.render() + def participant_display_name(self): + raise NotImplementedError + def attach_log_to_object(self): - if self.user_id: - return UserProfile, self.user_id - return Consequence, self.id + return AbstractConsequence, self.id -register_model_for_logging( - Consequence, - ModelFieldsLogConfig( - unlogged_fields=["id", "slug", "user", "data"], - attach_to_func=lambda consequence: consequence.attach_log_to_object(), - ), -) +class LocalConsequence(AbstractConsequence): + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + verbose_name=_("affected user"), + null=True, + related_name="affecting_consequences", + ) + + class Meta: + db_table = "localconsequence" + verbose_name = _("Local consequence") + verbose_name_plural = _("Local consequences") + + def participant_display_name(self): + return self.user.display_name + + def attach_log_to_object(self): + return UserProfile, self.user_id + + +# register_model_for_logging( +# LocalConsequence, +# ModelFieldsLogConfig( +# unlogged_fields=["id", "slug", "user", "data"], +# attach_to_func=lambda consequence: consequence.attach_log_to_object(), +# ), +# ) @log() diff --git a/ephios/core/services/notifications/types.py b/ephios/core/services/notifications/types.py index f72875335..70f59c0d9 100644 --- a/ephios/core/services/notifications/types.py +++ b/ephios/core/services/notifications/types.py @@ -15,7 +15,7 @@ from ephios.core.dynamic import dynamic_settings from ephios.core.models import AbstractParticipation, Event, LocalParticipation, UserProfile -from ephios.core.models.users import Consequence, Notification +from ephios.core.models.users import LocalConsequence, Notification from ephios.core.signals import register_notification_types from ephios.core.signup.participants import LocalUserParticipant from ephios.core.templatetags.settings_extras import make_absolute @@ -636,7 +636,7 @@ class ConsequenceApprovedNotification(AbstractNotificationHandler): title = _("Your request has been approved") @classmethod - def send(cls, consequence: Consequence): + def send(cls, consequence: LocalConsequence): Notification.objects.create( slug=cls.slug, user=consequence.user, data={"consequence_id": consequence.id} ) @@ -647,7 +647,7 @@ def get_subject(cls, notification): @classmethod def get_body(cls, notification): - consequence = Consequence.objects.get(id=notification.data.get("consequence_id")) + consequence = LocalConsequence.objects.get(id=notification.data.get("consequence_id")) return _('"{consequence}" has been approved.').format(consequence=consequence) @@ -656,7 +656,7 @@ class ConsequenceDeniedNotification(AbstractNotificationHandler): title = _("Your request has been denied") @classmethod - def send(cls, consequence: Consequence): + def send(cls, consequence: LocalConsequence): Notification.objects.create( slug=cls.slug, user=consequence.user, data={"consequence_id": consequence.id} ) @@ -667,7 +667,7 @@ def get_subject(cls, notification): @classmethod def get_body(cls, notification): - consequence = Consequence.objects.get(id=notification.data.get("consequence_id")) + consequence = LocalConsequence.objects.get(id=notification.data.get("consequence_id")) return _('"{consequence}" has been denied.').format(consequence=consequence) diff --git a/ephios/core/signup/participants.py b/ephios/core/signup/participants.py index b83109cc0..10a860c87 100644 --- a/ephios/core/signup/participants.py +++ b/ephios/core/signup/participants.py @@ -9,8 +9,9 @@ from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ -from ephios.core.models import AbstractParticipation, LocalParticipation +from ephios.core.models import AbstractParticipation, LocalParticipation, LocalConsequence from ephios.core.models.events import PlaceholderParticipation +from ephios.core.models.users import AbstractConsequence from ephios.core.services.qualification import ( QualificationUniverse, collect_all_included_qualifications, @@ -52,6 +53,10 @@ def all_participations(self): raise NotImplementedError def collect_all_qualifications(self) -> QuerySet: + def new_consequence(self) -> AbstractConsequence: + raise NotImplementedError + + def collect_all_qualifications(self) -> set: return collect_all_included_qualifications(self.qualifications) @cached_property @@ -92,6 +97,9 @@ def participation_for(self, shift): def all_participations(self): return LocalParticipation.objects.filter(user=self.user) + def new_consequence(self): + return LocalConsequence(user=self.user) + def reverse_signup_action(self, shift): return reverse("core:signup_action", kwargs={"pk": shift.pk}) diff --git a/ephios/core/views/consequences.py b/ephios/core/views/consequences.py index cbcabd74d..1f050e073 100644 --- a/ephios/core/views/consequences.py +++ b/ephios/core/views/consequences.py @@ -14,10 +14,10 @@ def post(self, request, *args, **kwargs): consequence = self.get_object() fail_reason = None if request.POST["action"] == "deny": - consequence.deny(request.user) + consequence.deny() elif request.POST["action"] == "confirm": try: - consequence.confirm(request.user) + consequence.confirm() except ConsequenceError as e: fail_reason = str(e) return JsonResponse( diff --git a/ephios/plugins/federation/admin.py b/ephios/plugins/federation/admin.py index a2fd23325..f7dce4667 100644 --- a/ephios/plugins/federation/admin.py +++ b/ephios/plugins/federation/admin.py @@ -6,7 +6,7 @@ FederatedHost, FederatedParticipation, FederatedUser, - InviteCode, + InviteCode, FederatedConsequence, ) admin.site.register(FederatedGuest) @@ -15,3 +15,4 @@ admin.site.register(FederatedUser) admin.site.register(FederatedParticipation) admin.site.register(InviteCode) +admin.site.register(FederatedConsequence) \ No newline at end of file diff --git a/ephios/plugins/federation/migrations/0007_federateduser_federated_instance_identifier_and_more.py b/ephios/plugins/federation/migrations/0007_federateduser_federated_instance_identifier_and_more.py new file mode 100644 index 000000000..039fee1c6 --- /dev/null +++ b/ephios/plugins/federation/migrations/0007_federateduser_federated_instance_identifier_and_more.py @@ -0,0 +1,48 @@ +# Generated by Django 5.2.3 on 2025-07-25 21:25 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0039_abstractconsequence_alter_shift_label_and_more"), + ("federation", "0006_alter_federateduser_date_of_birth"), + ] + + operations = [ + migrations.AddField( + model_name="federateduser", + name="federated_instance_identifier", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.CreateModel( + name="FederatedConsequence", + fields=[ + ( + "abstractconsequence_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="core.abstractconsequence", + ), + ), + ( + "federated_user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="federation.federateduser" + ), + ), + ], + options={ + "verbose_name": "Federated consequence", + "verbose_name_plural": "Federated consequences", + "db_table": "federatedconsequence", + }, + bases=("core.abstractconsequence",), + ), + ] diff --git a/ephios/plugins/federation/models.py b/ephios/plugins/federation/models.py index 4c5c57b59..ae7deae6d 100644 --- a/ephios/plugins/federation/models.py +++ b/ephios/plugins/federation/models.py @@ -14,6 +14,7 @@ from ephios.core.dynamic import dynamic_settings from ephios.core.models import AbstractParticipation, Event, Qualification from ephios.core.models.events import PARTICIPATION_LOG_CONFIG +from ephios.core.models.users import AbstractConsequence from ephios.core.signup.participants import AbstractParticipant from ephios.modellogging.log import ModelFieldsLogConfig, log, register_model_for_logging @@ -143,6 +144,7 @@ class FederatedUser(models.Model): phone = models.CharField(_("phone number"), max_length=254, blank=True) qualifications = models.ManyToManyField(Qualification) federated_instance = models.ForeignKey(FederatedGuest, on_delete=models.CASCADE) + federated_instance_identifier = models.CharField(null=True, blank=True, max_length=255) created_at = models.DateTimeField(auto_now_add=True) def __str__(self): @@ -162,6 +164,22 @@ class Meta: verbose_name_plural = _("federated users") +class FederatedConsequence(AbstractConsequence): + federated_user = models.ForeignKey(FederatedUser, on_delete=models.CASCADE) + + class Meta: + db_table = "federatedconsequence" + verbose_name = _("Federated consequence") + verbose_name_plural = _("Federated consequences") + + def participant_display_name(self): + return f"{self.federated_user.display_name} ({self.federated_user.federated_instance.name})" + + def confirm(self): + self.state = AbstractConsequence.States.CONFIRMED + self.save() + + @dataclasses.dataclass(frozen=True) class FederatedParticipant(AbstractParticipant): federated_user: FederatedUser @@ -180,6 +198,9 @@ def participation_for(self, shift): def all_participations(self): return FederatedParticipation.objects.filter(federated_user=self.federated_user) + def new_consequence(self) -> AbstractConsequence: + return FederatedConsequence() + def reverse_signup_action(self, shift): return reverse( "federation:shift_signup", From 0a61e84302df0d001487600843dde0a396d5e19a Mon Sep 17 00:00:00 2001 From: "Julian B." Date: Wed, 13 Aug 2025 22:26:05 +0200 Subject: [PATCH 4/7] rework consequence queryset filtering --- ephios/core/consequences.py | 48 +++++++++++------------------ ephios/core/models/users.py | 8 +++++ ephios/plugins/federation/models.py | 8 ++++- 3 files changed, 33 insertions(+), 31 deletions(-) diff --git a/ephios/core/consequences.py b/ephios/core/consequences.py index 01bca2c41..cbc76d183 100644 --- a/ephios/core/consequences.py +++ b/ephios/core/consequences.py @@ -40,11 +40,14 @@ def consequence_handler_from_slug(slug): def editable_consequences(user): handlers = list(installed_consequence_handlers()) - # qs = LocalConsequence.objects.all().select_related("user") - # for handler in handlers: - # qs = handler.filter_queryset(qs, user) - qs = AbstractConsequence.objects.all() - return qs.filter(slug__in=map(operator.attrgetter("slug"), handlers)).distinct() + consequence_classes = AbstractConsequence.__subclasses__() + + qs = AbstractConsequence.objects.filter(slug__in=map(operator.attrgetter("slug"), handlers)).distinct() + q_obj = Q() + for handler in handlers: + for ConcreteConsequence in consequence_classes: + q_obj = q_obj | ConcreteConsequence.filter_editable_by_user(handler, user) + return qs.filter(q_obj) def pending_consequences(user): @@ -81,10 +84,9 @@ def render(cls, consequence): raise NotImplementedError @classmethod - def filter_queryset(cls, qs, user: UserProfile): + def filter_editable_by_user(cls, user: UserProfile) -> Q: """ - Return a filtered that excludes consequences with the slug of this class that the user is not allowed to edit. - Consequences should also be annotated with values needed for rendering. + Return a Q object that include consequences with the slug of this class that the user is allowed to edit. """ raise NotImplementedError @@ -126,15 +128,12 @@ def render(cls, consequence): ) @classmethod - def filter_queryset(cls, qs, user: UserProfile): - return qs.filter( - ~Q(slug=cls.slug) - | Q( - user__groups__in=get_objects_for_user( + def filter_editable_by_user(cls, user: UserProfile): + return Q(slug=cls.slug, + localconsequence__user__groups__in=get_objects_for_user( user, "decide_workinghours_for_group", klass=Group ) ) - ) class QualificationConsequenceHandler(BaseConsequenceHandler): @@ -215,25 +214,14 @@ def render(cls, consequence): return s @classmethod - def filter_queryset(cls, qs, user: UserProfile): - qs = qs.annotate( - qualification_id=Cast(KeyTransform("qualification_id", "data"), IntegerField()), - event_id=Cast(KeyTransform("event_id", "data"), IntegerField()), - ).annotate( - qualification_title=Subquery( - Qualification.objects.filter(id=OuterRef("qualification_id")).values("title")[:1] - ), - event_title=Subquery(Event.objects.filter(id=OuterRef("event_id")).values("title")[:1]), - ) - - return qs.filter( - ~Q(slug=cls.slug) + def filter_editable_by_user(cls, user: UserProfile): + return Q(slug=cls.slug) & Q( # Qualifications can be granted by people who... - | Q( # are responsible for the event the consequence originated from, if applicable - event_id__in=get_objects_for_user(user, perms="change_event", klass=Event), + Q( # are responsible for the event the consequence originated from, if applicable + data__event_id__in=get_objects_for_user(user, perms="change_event", klass=Event), ) | Q( # can edit the affected user anyway - user__in=get_objects_for_user( + localconsequence__user__in=get_objects_for_user( user, perms="change_userprofile", klass=get_user_model() ) ) diff --git a/ephios/core/models/users.py b/ephios/core/models/users.py index 2cd588e52..92c955c79 100644 --- a/ephios/core/models/users.py +++ b/ephios/core/models/users.py @@ -416,6 +416,10 @@ class Meta: verbose_name = _("Abstract consequence") verbose_name_plural = _("Abstract consequences") + @classmethod + def filter_editable_by_user(cls, handler, user): + raise NotImplementedError + @property def handler(self): from ephios.core import consequences @@ -491,6 +495,10 @@ class Meta: verbose_name = _("Local consequence") verbose_name_plural = _("Local consequences") + @classmethod + def filter_editable_by_user(cls, handler, user): + return handler.filter_editable_by_user(user) + def participant_display_name(self): return self.user.display_name diff --git a/ephios/plugins/federation/models.py b/ephios/plugins/federation/models.py index ae7deae6d..4c07f00f6 100644 --- a/ephios/plugins/federation/models.py +++ b/ephios/plugins/federation/models.py @@ -4,7 +4,9 @@ import json from secrets import token_hex +from django.contrib.contenttypes.models import ContentType from django.db import models +from django.db.models import Q from django.urls import reverse from django.utils import timezone from django.utils.safestring import mark_safe @@ -172,6 +174,10 @@ class Meta: verbose_name = _("Federated consequence") verbose_name_plural = _("Federated consequences") + @classmethod + def filter_editable_by_user(cls, handler, user): + return Q(polymorphic_ctype=ContentType.objects.get_for_model(cls)) + def participant_display_name(self): return f"{self.federated_user.display_name} ({self.federated_user.federated_instance.name})" @@ -199,7 +205,7 @@ def all_participations(self): return FederatedParticipation.objects.filter(federated_user=self.federated_user) def new_consequence(self) -> AbstractConsequence: - return FederatedConsequence() + return FederatedConsequence(federated_user=self.federated_user) def reverse_signup_action(self, shift): return reverse( From 9eadfbd825337af153e7e8f21ac7facbff2d0e3d Mon Sep 17 00:00:00 2001 From: Julian Baumann Date: Fri, 5 Sep 2025 00:06:51 +0200 Subject: [PATCH 5/7] fix tests --- ephios/core/signup/participants.py | 3 +-- .../eventautoqualification/consequences.py | 2 +- tests/conftest.py | 4 ++-- tests/core/test_consequences.py | 23 ++++++++++--------- .../test_autoqualification.py | 10 ++++---- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/ephios/core/signup/participants.py b/ephios/core/signup/participants.py index 10a860c87..f5daa1edb 100644 --- a/ephios/core/signup/participants.py +++ b/ephios/core/signup/participants.py @@ -52,11 +52,10 @@ def all_participations(self): """Return all participations for this participant""" raise NotImplementedError - def collect_all_qualifications(self) -> QuerySet: def new_consequence(self) -> AbstractConsequence: raise NotImplementedError - def collect_all_qualifications(self) -> set: + def collect_all_qualifications(self) -> QuerySet: return collect_all_included_qualifications(self.qualifications) @cached_property diff --git a/ephios/plugins/eventautoqualification/consequences.py b/ephios/plugins/eventautoqualification/consequences.py index e548bd569..caa457c28 100644 --- a/ephios/plugins/eventautoqualification/consequences.py +++ b/ephios/plugins/eventautoqualification/consequences.py @@ -59,7 +59,7 @@ def create_qualification_consequence(sender, participation, **kwargs): return consequence = QualificationConsequenceHandler.create( - user=user, + participant=participation.participant, qualification=event.auto_qualification_config.qualification, expires=event.auto_qualification_config.expiration_date, shift=participation.shift, diff --git a/tests/conftest.py b/tests/conftest.py index ae6da0de8..5c7b648ee 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -404,7 +404,7 @@ def qualifications(): @pytest.fixture def qualifications_consequence(volunteer, qualifications, event, tz): return QualificationConsequenceHandler.create( - user=volunteer, + participant=volunteer.as_participant(), shift=event.shifts.first(), qualification=qualifications.nfs, expires=datetime(2065, 4, 1, tzinfo=tz), @@ -414,7 +414,7 @@ def qualifications_consequence(volunteer, qualifications, event, tz): @pytest.fixture def workinghours_consequence(volunteer): return WorkingHoursConsequenceHandler.create( - user=volunteer, when=date(2020, 1, 1), hours=42, reason="testing" + participant=volunteer.as_participant(), when=date(2020, 1, 1), hours=42, reason="testing" ) diff --git a/tests/core/test_consequences.py b/tests/core/test_consequences.py index 2dfed7e9d..584e8c132 100644 --- a/tests/core/test_consequences.py +++ b/tests/core/test_consequences.py @@ -11,7 +11,8 @@ editable_consequences, pending_consequences, ) -from ephios.core.models import Consequence, Qualification +from ephios.core.models import Qualification +from ephios.core.models.users import LocalConsequence, AbstractConsequence class TestQualificationConsequence: @@ -20,7 +21,7 @@ def test_render_qualification_granting(self, qualifications_consequence): def test_render_qualification_without_shift_information(self, volunteer, qualifications, tz): c = QualificationConsequenceHandler.create( - user=volunteer, + participant=volunteer.as_participant(), qualification=qualifications.nfs, expires=datetime.datetime(2064, 4, 1).astimezone(tz), ) @@ -28,7 +29,7 @@ def test_render_qualification_without_shift_information(self, volunteer, qualifi def test_annotation_with_json(self, qualifications_consequence, qualifications): qs = ( - Consequence.objects.filter(state=Consequence.States.NEEDS_CONFIRMATION) + LocalConsequence.objects.filter(state=LocalConsequence.States.NEEDS_CONFIRMATION) .annotate( qualification_id=Cast(KeyTransform("qualification_id", "data"), IntegerField()) ) @@ -48,7 +49,7 @@ def test_confirm_qualification_granting( self, superuser, qualifications_consequence, qualifications ): assert qualifications.nfs not in qualifications_consequence.user.qualifications - qualifications_consequence.confirm(superuser) + qualifications_consequence.confirm() assert qualifications.nfs in qualifications_consequence.user.qualifications def test_extend_qualification( @@ -60,7 +61,7 @@ def test_extend_qualification( qualified_volunteer.qualifications.get( pk=qualifications.nfs.pk, expires=qualifications_consequence.data.get("expires") ) - qualifications_consequence.confirm(qualified_volunteer) + qualifications_consequence.confirm() assert qualified_volunteer.qualifications.get( pk=qualifications.nfs.pk, expires=qualifications_consequence.data.get("expires") ) @@ -83,7 +84,7 @@ def test_request_workinghour(self, django_app, volunteer): form["hours"] = 42 form["reason"] = "testing" form.submit() - Consequence.objects.get( + LocalConsequence.objects.get( user=volunteer, data__date=datetime.datetime.now().date(), data__hours=42, @@ -95,7 +96,7 @@ def test_render_workinghours_consequence(self, workinghours_consequence): def test_confirm_workinghours(self, volunteer, superuser, workinghours_consequence): assert volunteer.get_workhour_items()[0] == datetime.timedelta(0) - workinghours_consequence.confirm(superuser) + workinghours_consequence.confirm() assert volunteer.get_workhour_items()[0] == datetime.timedelta( hours=workinghours_consequence.data.get("hours") ) @@ -108,7 +109,7 @@ def test_consequence_pends_for_user(self, volunteer, workinghours_consequence): def test_post_consequence_confirm(csrf_exempt_django_app, superuser, qualifications_consequence): - assert qualifications_consequence.state == Consequence.States.NEEDS_CONFIRMATION + assert qualifications_consequence.state == LocalConsequence.States.NEEDS_CONFIRMATION POST_DATA = {"action": "confirm"} csrf_exempt_django_app.post( reverse("core:consequence_edit", kwargs=dict(pk=qualifications_consequence.pk)), @@ -116,11 +117,11 @@ def test_post_consequence_confirm(csrf_exempt_django_app, superuser, qualificati params=POST_DATA, ) qualifications_consequence.refresh_from_db() - assert qualifications_consequence.state == Consequence.States.EXECUTED + assert qualifications_consequence.state == LocalConsequence.States.EXECUTED def test_post_consequence_deny(csrf_exempt_django_app, superuser, qualifications_consequence): - assert qualifications_consequence.state == Consequence.States.NEEDS_CONFIRMATION + assert qualifications_consequence.state == LocalConsequence.States.NEEDS_CONFIRMATION POST_DATA = {"action": "deny"} csrf_exempt_django_app.post( reverse("core:consequence_edit", kwargs=dict(pk=qualifications_consequence.pk)), @@ -128,4 +129,4 @@ def test_post_consequence_deny(csrf_exempt_django_app, superuser, qualifications params=POST_DATA, ) qualifications_consequence.refresh_from_db() - assert qualifications_consequence.state == Consequence.States.DENIED + assert qualifications_consequence.state == LocalConsequence.States.DENIED diff --git a/tests/plugins/eventautoqualification/test_autoqualification.py b/tests/plugins/eventautoqualification/test_autoqualification.py index 7080e113c..146787ac7 100644 --- a/tests/plugins/eventautoqualification/test_autoqualification.py +++ b/tests/plugins/eventautoqualification/test_autoqualification.py @@ -8,10 +8,10 @@ from ephios.core.models import ( AbstractParticipation, - Consequence, LocalParticipation, QualificationGrant, ) +from ephios.core.models.users import AbstractConsequence from ephios.core.signals import periodic_signal from ephios.plugins.eventautoqualification.models import EventAutoQualificationConfiguration @@ -176,14 +176,14 @@ def test_consequence_gets_created( if wanted_state: LocalParticipation.objects.create(user=volunteer, shift=shift, state=wanted_state) - assert not Consequence.objects.exists() + assert not AbstractConsequence.objects.exists() periodic_signal.send(None) if not consequence_expected: - assert not Consequence.objects.exists() + assert not AbstractConsequence.objects.exists() else: - assert Consequence.objects.count() == 1 + assert AbstractConsequence.objects.count() == 1 periodic_signal.send(None) - consequence = Consequence.objects.get() + consequence = AbstractConsequence.objects.get() assert consequence.data["qualification_id"] == qualifications.na.id assert consequence.user == volunteer From 5391730b8f9ded9e81d95e5d76baebcf17d97b05 Mon Sep 17 00:00:00 2001 From: Julian Baumann Date: Fri, 5 Sep 2025 23:42:49 +0200 Subject: [PATCH 6/7] fix tests --- ephios/plugins/eventautoqualification/consequences.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ephios/plugins/eventautoqualification/consequences.py b/ephios/plugins/eventautoqualification/consequences.py index caa457c28..c33e0e54b 100644 --- a/ephios/plugins/eventautoqualification/consequences.py +++ b/ephios/plugins/eventautoqualification/consequences.py @@ -66,4 +66,4 @@ def create_qualification_consequence(sender, participation, **kwargs): ) if not event.auto_qualification_config.needs_confirmation: - consequence.confirm(user=None) + consequence.confirm() From dfa982b33ac18bea0b3854242a6ee57971cc2b58 Mon Sep 17 00:00:00 2001 From: Julian Baumann Date: Sat, 6 Sep 2025 22:20:24 +0200 Subject: [PATCH 7/7] fetch federated consequences from host --- ephios/api/serializers.py | 14 ++++-- ephios/api/urls.py | 2 +- ephios/api/views/users.py | 27 +++++------ .../eventautoqualification/consequences.py | 46 +++++++++---------- ephios/plugins/federation/consequences.py | 5 -- ephios/plugins/federation/signals.py | 36 ++++++++++++++- 6 files changed, 82 insertions(+), 48 deletions(-) delete mode 100644 ephios/plugins/federation/consequences.py diff --git a/ephios/api/serializers.py b/ephios/api/serializers.py index 807630093..60a02d3b2 100644 --- a/ephios/api/serializers.py +++ b/ephios/api/serializers.py @@ -13,7 +13,6 @@ from ephios.core.consequences import consequence_handler_from_slug from ephios.core.models import ( AbstractParticipation, - LocalConsequence, Event, EventType, Qualification, @@ -25,6 +24,7 @@ from ephios.core.models.users import AbstractConsequence from ephios.core.services.qualification import collect_all_included_qualifications from ephios.core.templatetags.settings_extras import make_absolute +from ephios.plugins.federation.models import FederatedConsequence class QualificationSerializer(ModelSerializer): @@ -223,15 +223,20 @@ class Meta: class ConsequenceSerializer(ModelSerializer): + user = serializers.CharField( + source="federated_user.federated_instance_identifier", read_only=True + ) + class Meta: - model = AbstractConsequence - fields = ["slug", "user", "state", "data"] + model = FederatedConsequence + fields = ["id", "slug", "user", "state", "data"] def validate_state(self, value): - if value != AbstractConsequence.States.NEEDS_CONFIRMATION: + if not self.instance and value != AbstractConsequence.States.NEEDS_CONFIRMATION: raise serializers.ValidationError( _("Consequences must be created in needs_confirmation state") ) + return value def validate_slug(self, value): try: @@ -240,3 +245,4 @@ def validate_slug(self, value): raise serializers.ValidationError( _("Consequence handler for '{slug}' was not found.").format(slug=value) ) + return value diff --git a/ephios/api/urls.py b/ephios/api/urls.py index f4de336f4..dbee16f75 100644 --- a/ephios/api/urls.py +++ b/ephios/api/urls.py @@ -44,7 +44,7 @@ r"users/(?P[\d]+)/participations", UserParticipationView, basename="user-participations" ) router.register(r"workinghours", WorkingHoursViewSet) -router.register(r"consequences", ConsequenceViewSet) +router.register(r"consequences", ConsequenceViewSet, basename="consequences") app_name = "api" urlpatterns = [ diff --git a/ephios/api/views/users.py b/ephios/api/views/users.py index 28f4eb61e..1d1da0995 100644 --- a/ephios/api/views/users.py +++ b/ephios/api/views/users.py @@ -1,5 +1,5 @@ from django_filters.rest_framework import DjangoFilterBackend -from oauth2_provider.contrib.rest_framework import IsAuthenticatedOrTokenHasScope +from oauth2_provider.contrib.rest_framework import IsAuthenticatedOrTokenHasScope, TokenHasScope from rest_framework import viewsets from rest_framework.exceptions import PermissionDenied from rest_framework.filters import SearchFilter @@ -24,14 +24,8 @@ UserProfileSerializer, WorkingHoursSerializer, ) -from ephios.core.models import ( - AbstractParticipation, - LocalConsequence, - LocalParticipation, - UserProfile, - WorkingHours, -) -from ephios.core.models.users import AbstractConsequence +from ephios.core.models import AbstractParticipation, UserProfile, WorkingHours +from ephios.plugins.federation.models import FederatedConsequence, FederatedGuest class UserProfileMeView(RetrieveAPIView): @@ -106,8 +100,15 @@ class WorkingHoursViewSet(viewsets.ReadOnlyModelViewSet): class ConsequenceViewSet(viewsets.ModelViewSet): serializer_class = ConsequenceSerializer - permission_classes = [IsAuthenticatedOrTokenHasScope, ViewObjectPermissions] + permission_classes = [TokenHasScope] + required_scopes = [] filter_backends = [DjangoFilterBackend] - filterset_fields = ["slug", "user", "state"] - required_scopes = ["CONFIDENTIAL_WRITE"] - queryset = AbstractConsequence.objects.all() + filterset_fields = ["slug", "state"] + + def get_queryset(self): + try: + # request.auth is an auth token, federatedguest is the reverse relation + guest = self.request.auth.federatedguest + except (AttributeError, FederatedGuest.DoesNotExist) as exc: + raise PermissionDenied from exc + return FederatedConsequence.objects.filter(federated_user__federated_instance=guest) diff --git a/ephios/plugins/eventautoqualification/consequences.py b/ephios/plugins/eventautoqualification/consequences.py index c33e0e54b..28ab70623 100644 --- a/ephios/plugins/eventautoqualification/consequences.py +++ b/ephios/plugins/eventautoqualification/consequences.py @@ -5,7 +5,7 @@ from django.db.models import Q from ephios.core.consequences import QualificationConsequenceHandler -from ephios.core.models import AbstractParticipation, LocalParticipation +from ephios.core.models import AbstractParticipation from ephios.plugins.eventautoqualification.models import EventAutoQualificationConfiguration logger = logging.getLogger(__name__) @@ -42,28 +42,26 @@ def create_qualification_consequence(sender, participation, **kwargs): ) if requirements_met: - if not isinstance(participation, LocalParticipation): - logger.warning( - "Cannot create an automatic qualification consequence for non-local participants." - ) - return - - user = participation.user - - # skip if extent/refresh only but the user does not have a grant - if ( - event.auto_qualification_config.extend_only - and not event.auto_qualification_config.qualification_id - in user.qualification_grants.values_list("qualification_id", flat=True) - ): - return + try: + # skip if extent/refresh only but the user does not have a grant + if ( + (user := getattr(participation, "user", None)) + and event.auto_qualification_config.extend_only + and not event.auto_qualification_config.qualification_id + in user.qualification_grants.values_list("qualification_id", flat=True) + ): + return - consequence = QualificationConsequenceHandler.create( - participant=participation.participant, - qualification=event.auto_qualification_config.qualification, - expires=event.auto_qualification_config.expiration_date, - shift=participation.shift, - ) + consequence = QualificationConsequenceHandler.create( + participant=participation.participant, + qualification=event.auto_qualification_config.qualification, + expires=event.auto_qualification_config.expiration_date, + shift=participation.shift, + ) - if not event.auto_qualification_config.needs_confirmation: - consequence.confirm() + if not event.auto_qualification_config.needs_confirmation: + consequence.confirm() + except NotImplementedError: + logger.warning( + f"Cannot create an automatic qualification consequence for participant {participation.participant}." + ) diff --git a/ephios/plugins/federation/consequences.py b/ephios/plugins/federation/consequences.py deleted file mode 100644 index a270127f3..000000000 --- a/ephios/plugins/federation/consequences.py +++ /dev/null @@ -1,5 +0,0 @@ -from ephios.core.consequences import BaseConsequenceHandler - - -class FederatedConsequenceHandler(BaseConsequenceHandler): - pass diff --git a/ephios/plugins/federation/signals.py b/ephios/plugins/federation/signals.py index cb5bfa6d4..e9aaa0695 100644 --- a/ephios/plugins/federation/signals.py +++ b/ephios/plugins/federation/signals.py @@ -1,7 +1,13 @@ +from urllib.parse import urljoin + +import requests +from django.db import transaction from django.dispatch import receiver from django.urls import reverse from django.utils.translation import gettext_lazy as _ +from ephios.core.models import UserProfile +from ephios.core.models.users import AbstractConsequence, LocalConsequence from ephios.core.signals import ( event_forms, nav_link, @@ -69,10 +75,38 @@ def federation_settings_section(sender, request, **kwargs): ) -@receiver(periodic_signal, dispatch_uid="ephios.plugins.federation.signals.periodic_signal") +@receiver(periodic_signal, dispatch_uid="ephios.plugins.federation.signals.delete_expired_invites") def delete_expired_invites(sender, **kwargs): from ephios.plugins.federation.models import InviteCode for invite in InviteCode.objects.all(): if invite.is_expired: invite.delete() + + +@receiver( + periodic_signal, dispatch_uid="ephios.plugins.federation.signals.fetch_federated_consequences" +) +def fetch_federated_consequences(sender, **kwargs): + for federated_host in FederatedHost.objects.all(): + response = requests.get( + urljoin(federated_host.url, "api/consequences?state=confirmed"), + headers={"Authorization": f"Bearer {federated_host.access_token}"}, + ) + response.raise_for_status() + pending_consequences = response.json()["results"] + for consequence in pending_consequences: + with transaction.atomic(): + user = UserProfile.objects.get(pk=consequence["user"]) + LocalConsequence.objects.create( + user=user, + state=AbstractConsequence.States.NEEDS_CONFIRMATION, + slug=consequence["slug"], + data=consequence["data"], + ) + response = requests.patch( + urljoin(federated_host.url, f"api/consequences/{consequence['id']}/"), + data={"state": AbstractConsequence.States.EXECUTED}, + headers={"Authorization": f"Bearer {federated_host.access_token}"}, + ) + response.raise_for_status()