-
Notifications
You must be signed in to change notification settings - Fork 12
assign workinghours to participants on federated instances #1503
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
base: main
Are you sure you want to change the base?
Changes from all commits
e11332c
746ff18
314f81c
0a61e84
9eadfbd
5391730
dfa982b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
|
|
@@ -18,11 +18,14 @@ | |
| ViewUserModelObjectPermissions, | ||
| ) | ||
| from ephios.api.serializers import ( | ||
| ConsequenceSerializer, | ||
| ParticipationSerializer, | ||
| UserinfoParticipationSerializer, | ||
| UserProfileSerializer, | ||
| WorkingHoursSerializer, | ||
| ) | ||
| from ephios.core.models import AbstractParticipation, UserProfile | ||
| from ephios.core.models import AbstractParticipation, UserProfile, WorkingHours | ||
| from ephios.plugins.federation.models import FederatedConsequence, FederatedGuest | ||
|
|
||
|
|
||
| class UserProfileMeView(RetrieveAPIView): | ||
|
|
@@ -84,3 +87,28 @@ 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): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Again, this is specifically for federated consequences, the name should reflect that. Can it be moved to the plugin? |
||
| serializer_class = ConsequenceSerializer | ||
| permission_classes = [TokenHasScope] | ||
| required_scopes = [] | ||
| filter_backends = [DjangoFilterBackend] | ||
| 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) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,15 +13,17 @@ | |
| from guardian.shortcuts import get_objects_for_user | ||
|
|
||
| from ephios.core.models import ( | ||
| Consequence, | ||
| LocalConsequence, | ||
| Event, | ||
| Qualification, | ||
| QualificationGrant, | ||
| Shift, | ||
| 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,21 +40,29 @@ def consequence_handler_from_slug(slug): | |
|
|
||
| def editable_consequences(user): | ||
| handlers = list(installed_consequence_handlers()) | ||
| qs = Consequence.objects.all().select_related("user") | ||
| consequence_classes = AbstractConsequence.__subclasses__() | ||
|
|
||
| qs = AbstractConsequence.objects.filter(slug__in=map(operator.attrgetter("slug"), handlers)).distinct() | ||
| q_obj = Q() | ||
| for handler in handlers: | ||
| qs = handler.filter_queryset(qs, user) | ||
| return qs.filter(slug__in=map(operator.attrgetter("slug"), handlers)).distinct() | ||
| 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): | ||
| qs = Consequence.objects.filter(user=user, state=Consequence.States.NEEDS_CONFIRMATION) | ||
| qs = LocalConsequence.objects.filter(user=user, state=LocalConsequence.States.NEEDS_CONFIRMATION) | ||
| return qs | ||
|
|
||
|
|
||
| class ConsequenceError(Exception): | ||
| pass | ||
|
|
||
|
|
||
| class UnsupportedConsequenceTarget(ConsequenceError): | ||
| pass | ||
|
|
||
|
|
||
| class BaseConsequenceHandler: | ||
| @property | ||
| def slug(self): | ||
|
|
@@ -74,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 | ||
|
|
||
|
|
@@ -88,19 +97,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,23 +121,19 @@ 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")), | ||
| ) | ||
|
|
||
| @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): | ||
|
|
@@ -135,23 +142,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 +198,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( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. atm this is just "deleted event" when coming from a federated instance |
||
| qualification=qualification_title, event=event_title | ||
| ) | ||
| else: | ||
| s = _("{user} acquires '{qualification}'.").format( | ||
| user=user, | ||
| s = _("acquires '{qualification}'.").format( | ||
| qualification=qualification_title, | ||
| ) | ||
|
|
||
|
|
@@ -208,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() | ||
| ) | ||
| ) | ||
|
Comment on lines
+217
to
227
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The new version doesn't work for me. Postgres complains about having to implicitly typecast somewhere. I think it might be the json values in list checks. |
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this belongs in the concrete federated code, because in an normal API we would want to serialize consequences in any state?!