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
38 changes: 38 additions & 0 deletions lego/apps/events/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,44 @@
TBA = "TBA"
EVENT_STATUS_TYPES = ((NORMAL, NORMAL), (INFINITE, INFINITE), (OPEN, OPEN), (TBA, TBA))

"""
The complete contract for interest events, enforced by
EventCreateAndUpdateSerializer: creators (interest group leaders) control the
CREATOR_FIELDS, the FORCED_FIELDS always get these values, and any other
event field is dropped from the payload. New event fields are therefore
locked for interest events until explicitly added here.
"""
INTEREST_EVENT_CREATOR_FIELDS = frozenset(
{
"id",
"event_type",
"title",
"description",
"text",
"start_time",
"end_time",
"location",
"mazemap_poi",
"responsible_group",
"pools",
}
)
INTEREST_EVENT_FORCED_FIELDS: dict = {
"event_status_type": INFINITE,
"use_captcha": False,
"heed_penalties": False,
"feedback_required": False,
"feedback_description": "",
"is_priced": False,
"pinned": False,
"registration_deadline_hours": 0,
"unregistration_deadline_hours": 0,
"can_view_groups": (),
"require_auth": False,
"company": None,
"show_company_description": False,
}


class PRESENCE_CHOICES(models.TextChoices):
UNKNOWN = "UNKNOWN"
Expand Down
11 changes: 11 additions & 0 deletions lego/apps/events/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,17 @@ def to_representation(self, value):
return None


class WaitingRegistrationCountField(serializers.Field):
def get_attribute(self, instance: Event) -> Event:
return instance

def to_representation(self, value: Event) -> int | None:
request = self.context.get("request", None)
if request and request.user.is_authenticated:
return value.waiting_registration_count
return None


class TotalCapacityField(serializers.Field):
def get_attribute(self, instance):
return instance
Expand Down
2 changes: 2 additions & 0 deletions lego/apps/events/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ class EventsFilterSet(FilterSet):
date_after = DateFilter("start_time", lookup_expr="gte")
date_before = DateFilter("start_time", lookup_expr="lte")
company = CharFilter("company")
responsible_group_type = CharFilter("responsible_group__type")
exclude_event_type = CharFilter("event_type", exclude=True)

class Meta:
model = Event
Expand Down
7 changes: 4 additions & 3 deletions lego/apps/events/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,9 +309,10 @@ def register(self, registration: Registration) -> Registration:
user: User = registration.user
penalties: int = 0

unanswered_surveys = user.unanswered_surveys()
if len(unanswered_surveys) > 0:
raise UnansweredSurveyException()
if self.event_type != constants.INTEREST_EVENT:
unanswered_surveys = user.unanswered_surveys()
if len(unanswered_surveys) > 0:
raise UnansweredSurveyException()

if self.heed_penalties:
penalties = user.number_of_penalties()
Expand Down
74 changes: 73 additions & 1 deletion lego/apps/events/permissions.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from structlog import get_logger

from lego.apps.events import constants
from lego.apps.permissions.actions import action_to_permission
from lego.apps.permissions.api.permissions import LegoPermissions
from lego.apps.permissions.constants import CREATE, DELETE, EDIT, VIEW
Expand All @@ -12,6 +13,45 @@
class EventPermissionHandler(PermissionHandler["Event"]):
perms_without_object = [CREATE, "administrate"]

def has_perm(
self,
user,
perm,
obj=None,
queryset=None,
check_keyword_permissions=True,
**kwargs,
):
# Interest event leaders and creators manage the event, not the
# attendee pages (allergies, payments) - administrate stays keyword
# gated and must not inherit the creator's object access. Other event
# types keep the object-based access their creators rely on.
if (
perm == "administrate"
and obj is not None
and obj.event_type == constants.INTEREST_EVENT
):
from lego.apps.events.models import Event

obj, queryset = None, Event.objects.none()

has_perm = super().has_perm(
user, perm, obj, queryset, check_keyword_permissions, **kwargs
)
if has_perm:
return True

# Interest events belong to the group, not the creator - the current
# leaders manage them even after leadership changes hands
if (
obj is not None
and perm in (EDIT, DELETE)
and obj.event_type == constants.INTEREST_EVENT
):
return self.is_interest_group_leader(user, obj.responsible_group_id)

return False

def event_type_keyword_permissions(self, event_type, perm):
"""
Get the keyword permission string required for a permission for a specific event type
Expand Down Expand Up @@ -51,7 +91,39 @@ def has_event_type_level_permission(self, user, request, perm):
required_keyword_permissions = self.event_type_keyword_permissions(
event_type, CREATE
)
return user.has_perm(required_keyword_permissions)
if user.has_perm(required_keyword_permissions):
return True

if event_type == constants.INTEREST_EVENT:
return self.is_interest_group_leader(
user, request.data.get("responsible_group")
)

return False

def is_interest_group_leader(self, user, group_id):
"""
Interest events require no keyword permissions - the leaders of the
interest group responsible for the event can create and edit it.
"""
from lego.apps.users.constants import GROUP_INTEREST
from lego.apps.users.models import Membership
from lego.apps.users.permissions import EDIT_ROLES

if not user.is_authenticated or not group_id:
return False
try:
group_id = int(group_id)
except (TypeError, ValueError):
return False
return Membership.objects.filter(
user=user,
abakus_group_id=group_id,
abakus_group__type=GROUP_INTEREST,
abakus_group__active=True,
role__in=EDIT_ROLES,
is_active=True,
).exists()


class RegistrationPermissionHandler(PermissionHandler):
Expand Down
122 changes: 120 additions & 2 deletions lego/apps/events/serializers/events.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from typing import Any

from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction
from django.http import HttpRequest
from django.utils import timezone
from rest_framework import serializers
from rest_framework.fields import CharField

Expand All @@ -17,6 +20,7 @@
RegistrationCountField,
SpotsLeftField,
TotalCapacityField,
WaitingRegistrationCountField,
)
from lego.apps.events.models import Event, Pool, Registration
from lego.apps.events.serializers.pools import (
Expand All @@ -32,8 +36,10 @@
RegistrationReadSerializer,
)
from lego.apps.files.fields import File, ImageField
from lego.apps.permissions.constants import CREATE
from lego.apps.permissions.utils import get_permission_handler
from lego.apps.tags.serializers import TagSerializerMixin
from lego.apps.users.constants import GROUP_GRADE
from lego.apps.users.constants import GROUP_GRADE, GROUP_INTEREST, MEMBER_GROUP
from lego.apps.users.fields import AbakusGroupField, PublicUserField
from lego.apps.users.models import AbakusGroup, PhotoConsent, User
from lego.apps.users.serializers.abakus_groups import PublicAbakusGroupSerializer
Expand Down Expand Up @@ -137,6 +143,8 @@ class EventReadDetailedSerializer(
pools = PoolReadSerializer(many=True)
active_capacity = serializers.ReadOnlyField()
text = ContentSerializerField()
registration_count = RegistrationCountField()
waiting_registration_count = WaitingRegistrationCountField()
registration_close_time = serializers.DateTimeField(read_only=True)
unregistration_close_time = serializers.DateTimeField(read_only=True)

Expand All @@ -159,6 +167,8 @@ class Meta:
"end_time",
"merge_time",
"pools",
"registration_count",
"waiting_registration_count",
"registration_close_time",
"registration_deadline_hours",
"unregistration_close_time",
Expand Down Expand Up @@ -407,7 +417,26 @@ class Meta:
"show_company_description",
) + ObjectPermissionsSerializerMixin.Meta.fields

def validate(self, data):
def to_internal_value(self, data: Any) -> dict[str, Any]:
"""
The frontend only sends id and capacity for interest event pools, so
the backend-owned pool fields get placeholders before field
validation. force_interest_event_pools replaces them in validate.
"""
if isinstance(data, dict):
event_type = data.get(
"event_type", self.instance.event_type if self.instance else None
)
if event_type == constants.INTEREST_EVENT and data.get("pools"):
member_group_id = AbakusGroup.objects.get(name=MEMBER_GROUP).pk
for pool in data["pools"]:
if isinstance(pool, dict):
pool.setdefault("name", MEMBER_GROUP)
pool.setdefault("activation_date", timezone.now())
pool.setdefault("permission_groups", [member_group_id])
return super().to_internal_value(data)

def validate(self, data: dict[str, Any]) -> dict[str, Any]:
"""
Check that start is before finish.
"""
Expand All @@ -418,8 +447,97 @@ def validate(self, data):
"end_time": "User does not have the required permissions for time travel"
}
)

instance = self.instance if isinstance(self.instance, Event) else None
event_type = data.get("event_type", instance.event_type if instance else None)
if event_type == constants.INTEREST_EVENT:
self.enforce_interest_event_contract(data, instance)
return data

def enforce_interest_event_contract(
self, data: dict[str, Any], instance: Event | None
) -> None:
"""
Interest events are open to every Abakus member from creation until
start, always free, and never pinned. Creators only control the
whitelisted content fields - see the contract in constants.py.
"""
responsible_group = data.get(
"responsible_group",
instance.responsible_group if instance else None,
)
if not responsible_group or responsible_group.type != GROUP_INTEREST:
raise serializers.ValidationError(
{
"responsible_group": "Interest events must be organized "
"by an interest group"
}
)
self.validate_interest_event_group_change(data, instance)
for field in (
set(data)
- constants.INTEREST_EVENT_CREATOR_FIELDS
- set(constants.INTEREST_EVENT_FORCED_FIELDS)
):
data.pop(field)
data.update(constants.INTEREST_EVENT_FORCED_FIELDS)
if instance is None or "pools" in data:
data["pools"] = self.force_interest_event_pools(data.get("pools"))

def validate_interest_event_group_change(
self, data: dict[str, Any], instance: Event | None
) -> None:
"""
The permission layer only checks leadership of the responsible group
in request data, so a PATCH without event_type could move an event to
a group the requester does not lead.
"""
if (
instance is None
or "responsible_group" not in data
or data["responsible_group"] == instance.responsible_group
):
return
request = self.context.get("request")
if request is None or not request.user.is_authenticated:
return
handler = get_permission_handler(Event)
allowed = request.user.has_perm(
handler.event_type_keyword_permissions(constants.INTEREST_EVENT, CREATE)
) or handler.is_interest_group_leader(
request.user, data["responsible_group"].pk
)
if not allowed:
raise serializers.ValidationError(
{
"responsible_group": "You must be a leader of the "
"responsible interest group"
}
)

@staticmethod
def force_interest_event_pools(
pools: list[dict[str, Any]] | None,
) -> list[dict[str, Any]]:
"""
The single pool on interest events is decided by the backend, not the
creator: open to every Abakus member immediately, with the creator's
capacity kept. A non-empty pool keeps its stored values, as edits to
it are rejected by PoolCreateAndUpdateSerializer.
"""
pool = (pools or [{}])[0]
pool.setdefault("name", MEMBER_GROUP)
existing = (
Pool.objects.filter(id=pool["id"]).first() if pool.get("id") else None
)
if existing and existing.registration_count > 0:
pool["activation_date"] = existing.activation_date
pool["permission_groups"] = list(existing.permission_groups.all())
else:
pool["activation_date"] = timezone.now()
pool["permission_groups"] = [AbakusGroup.objects.get(name=MEMBER_GROUP)]
return [pool]

def create(self, validated_data):
pools = validated_data.pop("pools", [])
event_status_type = validated_data.get(
Expand Down
4 changes: 3 additions & 1 deletion lego/apps/events/serializers/registrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ class AdminRegistrationCreateAndUpdateSerializer(serializers.Serializer):


class RegistrationCreateAndUpdateSerializer(BasisModelSerializer):
captcha_response = serializers.CharField(required=False)
# Blank is fine for events without captcha; verify_captcha rejects it
# for events that require one
captcha_response = serializers.CharField(required=False, allow_blank=True)
payment_status = SetPaymentStatusField(
required=False, choices=constants.PAYMENT_STATUS_CHOICES
)
Expand Down
Loading