Skip to content
Open
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 ephios/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,21 @@
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,
Event,
EventType,
Qualification,
Shift,
UserProfile,
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
from ephios.plugins.federation.models import FederatedConsequence


class QualificationSerializer(ModelSerializer):
Expand Down Expand Up @@ -210,3 +214,35 @@ 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):
user = serializers.CharField(
source="federated_user.federated_instance_identifier", read_only=True
)

class Meta:
model = FederatedConsequence
fields = ["id", "slug", "user", "state", "data"]

def validate_state(self, value):
if not self.instance and value != AbstractConsequence.States.NEEDS_CONFIRMATION:
raise serializers.ValidationError(
_("Consequences must be created in needs_confirmation state")
)
Comment on lines +235 to +238
Copy link
Member

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?!

return value

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)
)
return value
4 changes: 4 additions & 0 deletions ephios/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -41,6 +43,8 @@
router.register(
r"users/(?P<user>[\d]+)/participations", UserParticipationView, basename="user-participations"
)
router.register(r"workinghours", WorkingHoursViewSet)
router.register(r"consequences", ConsequenceViewSet, basename="consequences")

app_name = "api"
urlpatterns = [
Expand Down
32 changes: 30 additions & 2 deletions ephios/api/views/users.py
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
Expand All @@ -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):
Expand Down Expand Up @@ -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):
Copy link
Member

Choose a reason for hiding this comment

The 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)
4 changes: 2 additions & 2 deletions ephios/core/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from guardian.admin import GuardedModelAdmin

from ephios.core.models import (
Consequence,
LocalConsequence,
Event,
EventType,
LocalParticipation,
Expand All @@ -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)
Expand Down
97 changes: 46 additions & 51 deletions ephios/core/consequences.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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):
Expand All @@ -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

Expand All @@ -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"],
Expand All @@ -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):
Expand All @@ -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,
Expand Down Expand Up @@ -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(
Copy link
Member

Choose a reason for hiding this comment

The 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,
)

Expand All @@ -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
Copy link
Member

Choose a reason for hiding this comment

The 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.

Expand Down
2 changes: 1 addition & 1 deletion ephios/core/forms/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
Loading
Loading