diff --git a/lego/apps/events/constants.py b/lego/apps/events/constants.py index 6e0f57f76..1bb1196cb 100644 --- a/lego/apps/events/constants.py +++ b/lego/apps/events/constants.py @@ -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" diff --git a/lego/apps/events/fields.py b/lego/apps/events/fields.py index 520f41d57..1162e894e 100644 --- a/lego/apps/events/fields.py +++ b/lego/apps/events/fields.py @@ -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 diff --git a/lego/apps/events/filters.py b/lego/apps/events/filters.py index 8e1062174..3be2e3cd5 100644 --- a/lego/apps/events/filters.py +++ b/lego/apps/events/filters.py @@ -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 diff --git a/lego/apps/events/models.py b/lego/apps/events/models.py index 634913a81..0791a7a92 100644 --- a/lego/apps/events/models.py +++ b/lego/apps/events/models.py @@ -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() diff --git a/lego/apps/events/permissions.py b/lego/apps/events/permissions.py index ca6055f07..cbe042c3a 100644 --- a/lego/apps/events/permissions.py +++ b/lego/apps/events/permissions.py @@ -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 @@ -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 @@ -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): diff --git a/lego/apps/events/serializers/events.py b/lego/apps/events/serializers/events.py index efa42afcf..90aa06c42 100644 --- a/lego/apps/events/serializers/events.py +++ b/lego/apps/events/serializers/events.py @@ -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 @@ -17,6 +20,7 @@ RegistrationCountField, SpotsLeftField, TotalCapacityField, + WaitingRegistrationCountField, ) from lego.apps.events.models import Event, Pool, Registration from lego.apps.events.serializers.pools import ( @@ -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 @@ -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) @@ -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", @@ -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. """ @@ -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( diff --git a/lego/apps/events/serializers/registrations.py b/lego/apps/events/serializers/registrations.py index 39b4dff6c..b218ae3bc 100644 --- a/lego/apps/events/serializers/registrations.py +++ b/lego/apps/events/serializers/registrations.py @@ -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 ) diff --git a/lego/apps/events/tasks.py b/lego/apps/events/tasks.py index 0b711c7a7..0e8dd5e50 100644 --- a/lego/apps/events/tasks.py +++ b/lego/apps/events/tasks.py @@ -78,27 +78,63 @@ def on_failure(self, return_value, *args): ) +def admit_registration(registration_id: int) -> Registration: + """ + The registration pipeline: lock, admit, notify, and kick off payment. + Shared by async_register and the synchronous interest-event path in + RegistrationViewSet - error handling is up to the caller. + """ + with transaction.atomic(): + registration = Registration.objects.select_for_update().get(id=registration_id) + registration.event.register(registration) + transaction.on_commit( + lambda: notify_event_registration( + constants.SOCKET_REGISTRATION_SUCCESS, registration + ) + ) + if registration.can_pay: + chain( + async_initiate_payment.s(registration_id), + save_and_notify_payment.s(registration_id), + ).delay() + return registration + + +def withdraw_registration(registration_id: int) -> Registration: + """ + The unregistration counterpart to admit_registration. + """ + registration = Registration.objects.get(id=registration_id) + pool_id = registration.pool_id + with transaction.atomic(): + registration.event.unregister(registration) + activation_time = registration.event.get_earliest_registration_time( + registration.user + ) + transaction.on_commit( + lambda: notify_event_registration( + constants.SOCKET_UNREGISTRATION_SUCCESS, + registration, + from_pool=pool_id, + activation_time=activation_time, + ) + ) + if ( + registration.payment_intent_id + and registration.payment_status != constants.PAYMENT_SUCCESS + ): + async_cancel_payment.delay(registration_id) + return registration + + @celery_app.task(base=AsyncRegister, bind=True) def async_register(self, registration_id, logger_context=None): self.setup_logger(logger_context) try: - with transaction.atomic(): - self.registration = Registration.objects.select_for_update().get( - id=registration_id - ) - self.registration.event.register(self.registration) - transaction.on_commit( - lambda: notify_event_registration( - constants.SOCKET_REGISTRATION_SUCCESS, self.registration - ) - ) + self.registration = Registration.objects.get(id=registration_id) + admit_registration(registration_id) log.info("registration_success", registration_id=self.registration.id) - if self.registration.can_pay: - chain( - async_initiate_payment.s(registration_id), - save_and_notify_payment.s(registration_id), - ).delay() except EventHasClosed as e: log.warn( @@ -118,26 +154,8 @@ def async_unregister(self, registration_id, logger_context=None): self.setup_logger(logger_context) registration = Registration.objects.get(id=registration_id) - pool_id = registration.pool_id try: - with transaction.atomic(): - registration.event.unregister(registration) - activation_time = registration.event.get_earliest_registration_time( - registration.user - ) - transaction.on_commit( - lambda: notify_event_registration( - constants.SOCKET_UNREGISTRATION_SUCCESS, - registration, - from_pool=pool_id, - activation_time=activation_time, - ) - ) - if ( - registration.payment_intent_id - and registration.payment_status != constants.PAYMENT_SUCCESS - ): - async_cancel_payment.delay(registration_id) + withdraw_registration(registration_id) log.info("unregistration_success", registration_id=registration.id) except EventHasClosed as e: log.warn( diff --git a/lego/apps/events/tests/test_events_api.py b/lego/apps/events/tests/test_events_api.py index 8c2aa1b17..4080cce88 100644 --- a/lego/apps/events/tests/test_events_api.py +++ b/lego/apps/events/tests/test_events_api.py @@ -3,6 +3,7 @@ from unittest import mock, skipIf from django.conf import settings +from django.db import IntegrityError, OperationalError from django.urls import reverse from django.utils import timezone from rest_framework import status @@ -21,7 +22,7 @@ from lego.apps.events.tests.utils import get_dummy_users, make_penalty_expire from lego.apps.followers.models import FollowEvent from lego.apps.surveys.models import Submission, Survey -from lego.apps.users.constants import GROUP_GRADE, PHOTO_CONSENT_DOMAINS +from lego.apps.users.constants import GROUP_GRADE, LEADER, PHOTO_CONSENT_DOMAINS from lego.apps.users.models import AbakusGroup, Penalty, PhotoConsent, User from lego.utils.test_utils import BaseAPITestCase, BaseAPITransactionTestCase @@ -1020,6 +1021,12 @@ def test_event_creation_forbidden_event_type(self): self.event_id = self.event_response.json().pop("id", None) self.assertIsNone(self.event_id) + def test_creator_keeps_administrate_on_own_event(self): + """Creators without the administrate keyword keep object-based access + to the administrate pages - only interest events are stripped""" + response = self.client.get(_get_detail_url(self.event_id)) + self.assertIn("administrate", response.json()["actionGrant"]) + class PoolsTestCase(BaseAPITestCase): fixtures = [ @@ -2478,3 +2485,418 @@ def test_event_consent_waiting_list(self): [(c.year, c.semester, c.domain) for c in user_consents], "The users consent should exist on the event response", ) + + +_test_interest_event_data = { + "title": "InterestEvent1", + "description": "Ingress1", + "text": "Ingress1", + "eventType": "interest_event", + "eventStatusType": "NORMAL", + "responsibleGroup": 26, + "location": "Abakus-kontoret", + "startTime": "2030-09-01T13:20:30Z", + "endTime": "2030-09-01T15:20:30Z", + "canViewGroups": [], +} + + +class CreateInterestEventTestCase(BaseAPITestCase): + fixtures = [ + "test_abakus_groups.yaml", + "test_companies.yaml", + "test_users.yaml", + "test_events.yaml", + ] + + def setUp(self): + self.leader, self.member, self.admin = get_dummy_users(3) + self.interest_group = AbakusGroup.objects.get(pk=26) + self.interest_group.add_user(self.leader, role=LEADER) + self.interest_group.add_user(self.member) + AbakusGroup.objects.get(name="Webkom").add_user(self.admin) + + def test_leader_can_create_for_own_group(self): + """Interest group leaders can create events for their own group""" + self.client.force_authenticate(self.leader) + response = self.client.post(_get_list_url(), _test_interest_event_data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + response = self.client.get(_get_detail_url(response.json()["id"])) + action_grant = response.json()["actionGrant"] + self.assertIn("edit", action_grant) + self.assertNotIn("administrate", action_grant) + + event = Event.objects.get(id=response.json()["id"]) + self.assertEqual(event.description, "Ingress1") + self.assertEqual(event.event_status_type, constants.INFINITE) + self.assertFalse(event.use_captcha) + self.assertFalse(event.heed_penalties) + self.assertFalse(event.feedback_required) + self.assertEqual(event.registration_deadline_hours, 0) + self.assertEqual(event.unregistration_deadline_hours, 0) + + pool = event.pools.get() + self.assertEqual(pool.name, "Abakus") + self.assertEqual(pool.capacity, 0) + self.assertEqual( + list(pool.permission_groups.values_list("name", flat=True)), ["Abakus"] + ) + self.assertLessEqual(pool.activation_date, timezone.now()) + + def test_leader_cannot_create_for_inactive_group(self): + """Interest events cannot be created for deactivated interest groups""" + self.interest_group.active = False + self.interest_group.save() + self.client.force_authenticate(self.leader) + response = self.client.post(_get_list_url(), _test_interest_event_data) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_leader_cannot_create_for_other_group(self): + """Leaders cannot create interest events for groups they do not lead""" + self.client.force_authenticate(self.leader) + response = self.client.post( + _get_list_url(), {**_test_interest_event_data, "responsibleGroup": 27} + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_member_cannot_create(self): + """Regular interest group members cannot create interest events""" + self.client.force_authenticate(self.member) + response = self.client.post(_get_list_url(), _test_interest_event_data) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_keyword_admin_can_create(self): + """Users with keyword permissions can still create interest events""" + self.client.force_authenticate(self.admin) + response = self.client.post(_get_list_url(), _test_interest_event_data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_requires_interest_responsible_group(self): + """Interest events must be organized by an interest group""" + self.client.force_authenticate(self.admin) + response = self.client.post( + _get_list_url(), {**_test_interest_event_data, "responsibleGroup": 20} + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_submitted_pool_is_forced_open(self): + """Submitted pools keep their capacity but are opened to all of Abakus""" + self.client.force_authenticate(self.leader) + response = self.client.post( + _get_list_url(), + { + **_test_interest_event_data, + "pools": [ + { + "name": "Egen pool", + "capacity": 20, + "activationDate": "2030-08-01T10:00:00Z", + "permissionGroups": [26], + } + ], + }, + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + pool = Event.objects.get(id=response.json()["id"]).pools.get() + self.assertEqual(pool.capacity, 20) + self.assertEqual( + list(pool.permission_groups.values_list("name", flat=True)), ["Abakus"] + ) + self.assertLessEqual(pool.activation_date, timezone.now()) + + def test_priced_and_pinned_are_forced_off(self): + """Interest events are always free and never pinned""" + self.client.force_authenticate(self.leader) + response = self.client.post( + _get_list_url(), + { + **_test_interest_event_data, + "isPriced": True, + "priceMember": 10000, + "pinned": True, + }, + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + event = Event.objects.get(id=response.json()["id"]) + self.assertFalse(event.is_priced) + self.assertFalse(event.pinned) + + def test_non_creator_fields_are_locked(self): + """Fields outside the interest event contract are dropped or forced""" + self.client.force_authenticate(self.leader) + response = self.client.post( + _get_list_url(), + { + **_test_interest_event_data, + "company": 1, + "responsibleUsers": [self.member.pk], + "useConsent": True, + "isPriced": True, + "priceMember": 10000, + "mergeTime": "2030-09-01T14:00:00Z", + "canViewGroups": [26], + }, + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + event = Event.objects.get(id=response.json()["id"]) + self.assertIsNone(event.company) + self.assertEqual(list(event.responsible_users.all()), []) + self.assertFalse(event.use_consent) + self.assertFalse(event.is_priced) + self.assertFalse(event.require_auth) + self.assertEqual(list(event.can_view_groups.all()), []) + + def test_cannot_move_event_to_group_not_led(self): + """A leader cannot re-home their event to a group they do not lead""" + self.client.force_authenticate(self.leader) + response = self.client.post(_get_list_url(), _test_interest_event_data) + event_id = response.json()["id"] + + response = self.client.patch( + _get_detail_url(event_id), {"responsibleGroup": 27} + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(Event.objects.get(id=event_id).responsible_group_id, 26) + + def test_can_move_event_between_led_groups(self): + """A leader of both groups can move an event between them""" + AbakusGroup.objects.get(pk=27).add_user(self.leader, role=LEADER) + self.client.force_authenticate(self.leader) + response = self.client.post(_get_list_url(), _test_interest_event_data) + event_id = response.json()["id"] + + response = self.client.patch( + _get_detail_url(event_id), {"responsibleGroup": 27} + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Event.objects.get(id=event_id).responsible_group_id, 27) + + def test_exclude_event_type_filter(self): + """The event overview excludes interest events via exclude_event_type""" + self.client.force_authenticate(self.leader) + response = self.client.post(_get_list_url(), _test_interest_event_data) + event_id = response.json()["id"] + + response = self.client.get( + _get_list_url(), {"exclude_event_type": "interest_event", "page_size": 60} + ) + self.assertNotIn(event_id, [e["id"] for e in response.json()["results"]]) + + response = self.client.get(_get_list_url(), {"page_size": 60}) + self.assertIn(event_id, [e["id"] for e in response.json()["results"]]) + + def test_capacity_only_pool_is_accepted(self): + """The frontend sends interest event pools with only a capacity""" + self.client.force_authenticate(self.leader) + response = self.client.post( + _get_list_url(), + {**_test_interest_event_data, "pools": [{"capacity": 20}]}, + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + pool = Event.objects.get(id=response.json()["id"]).pools.get() + self.assertEqual(pool.capacity, 20) + self.assertEqual( + list(pool.permission_groups.values_list("name", flat=True)), ["Abakus"] + ) + self.assertLessEqual(pool.activation_date, timezone.now()) + + def test_capacity_only_pool_edit(self): + """Capacity can be edited by sending only the pool id and capacity""" + self.client.force_authenticate(self.leader) + response = self.client.post( + _get_list_url(), + {**_test_interest_event_data, "pools": [{"capacity": 20}]}, + ) + event = Event.objects.get(id=response.json()["id"]) + pool = event.pools.get() + + response = self.client.patch( + _get_detail_url(event.id), + { + "eventType": "interest_event", + "responsibleGroup": 26, + "pools": [{"id": pool.id, "capacity": 30}], + }, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + pool.refresh_from_db() + self.assertEqual(pool.capacity, 30) + + def test_current_leaders_manage_their_groups_events(self): + """Leaders can edit and delete group events someone else created""" + self.client.force_authenticate(self.admin) + response = self.client.post(_get_list_url(), _test_interest_event_data) + event_id = response.json()["id"] + + self.client.force_authenticate(self.leader) + response = self.client.get(_get_detail_url(event_id)) + action_grant = response.json()["actionGrant"] + self.assertIn("edit", action_grant) + self.assertIn("delete", action_grant) + self.assertNotIn("administrate", action_grant) + + response = self.client.patch(_get_detail_url(event_id), {"title": "Nytt"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = self.client.delete(_get_detail_url(event_id)) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + + def test_leader_cannot_manage_other_groups_events(self): + """Leadership of one group grants nothing on another group's events""" + self.client.force_authenticate(self.admin) + response = self.client.post( + _get_list_url(), {**_test_interest_event_data, "responsibleGroup": 27} + ) + event_id = response.json()["id"] + + self.client.force_authenticate(self.leader) + response = self.client.get(_get_detail_url(event_id)) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertNotIn("edit", response.json()["actionGrant"]) + + # Denied actions on a specific event are hidden as 404 by get_object + response = self.client.patch(_get_detail_url(event_id), {"title": "Nytt"}) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + response = self.client.delete(_get_detail_url(event_id)) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_interest_events_are_visible_to_members(self): + """Interest events are not hidden behind require_auth object permissions""" + self.client.force_authenticate(self.leader) + response = self.client.post(_get_list_url(), _test_interest_event_data) + event_id = response.json()["id"] + + self.client.force_authenticate(self.member) + response = self.client.get(_get_detail_url(event_id)) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_leader_can_edit_own_event(self): + """Leaders can edit interest events they created""" + self.client.force_authenticate(self.leader) + response = self.client.post(_get_list_url(), _test_interest_event_data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + response = self.client.patch( + _get_detail_url(response.json()["id"]), + { + "title": "Nytt navn", + "eventType": "interest_event", + "responsibleGroup": 26, + }, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json()["title"], "Nytt navn") + + def test_anonymous_cannot_register(self): + """Registration requires an authenticated user""" + event = Event.objects.get(title="POOLS_NO_REGISTRATIONS") + response = self.client.post(_get_registrations_list_url(event.id), {}) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + +class InterestEventRegistrationApiTestCase(BaseAPITransactionTestCase): + fixtures = [ + "test_abakus_groups.yaml", + "test_companies.yaml", + "test_users.yaml", + "test_events.yaml", + ] + + def setUp(self): + self.leader, self.member = get_dummy_users(2) + interest_group = AbakusGroup.objects.get(pk=26) + interest_group.add_user(self.leader, role=LEADER) + AbakusGroup.objects.get(name="Abakus").add_user(self.leader) + AbakusGroup.objects.get(name="Abakus").add_user(self.member) + + self.client.force_authenticate(self.leader) + response = self.client.post(_get_list_url(), _test_interest_event_data) + self.event = Event.objects.get(id=response.json()["id"]) + Event.objects.filter(id=self.event.id).update( + start_time=timezone.now() + timedelta(hours=3), + end_time=timezone.now() + timedelta(hours=5), + ) + self.client.force_authenticate(self.member) + + @mock.patch("lego.apps.events.views.async_register") + def test_registration_is_synchronous(self, mocked_task): + """Interest event registrations are admitted in the request""" + response = self.client.post(_get_registrations_list_url(self.event.id), {}) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.json()["status"], constants.SUCCESS_REGISTER) + self.assertIsNotNone(response.json()["pool"]) + mocked_task.delay.assert_not_called() + + @mock.patch("lego.apps.events.views.async_register") + def test_full_event_waitlists_synchronously(self, mocked_task): + """Full interest events waitlist in the request, without overselling""" + pool = self.event.pools.get() + pool.capacity = 1 + pool.save() + self.client.force_authenticate(self.leader) + self.client.post(_get_registrations_list_url(self.event.id), {}) + self.client.force_authenticate(self.member) + + response = self.client.post(_get_registrations_list_url(self.event.id), {}) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.json()["status"], constants.SUCCESS_REGISTER) + self.assertIsNone(response.json()["pool"]) + self.assertEqual(pool.registrations.count(), 1) + mocked_task.delay.assert_not_called() + + def test_registration_after_start_fails(self): + """Registering after the event has started fails with an error""" + Event.objects.filter(id=self.event.id).update( + start_time=timezone.now() - timedelta(hours=1) + ) + response = self.client.post(_get_registrations_list_url(self.event.id), {}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + registration = Registration.objects.get(event=self.event, user=self.member) + self.assertEqual(registration.status, constants.FAILURE_REGISTER) + + @mock.patch("lego.apps.events.views.async_register") + @mock.patch( + "lego.apps.events.models.Event.register", side_effect=IntegrityError("lock") + ) + def test_falls_back_to_async_on_contention(self, mocked_register, mocked_task): + """Contention falls back to the async pipeline instead of failing""" + response = self.client.post(_get_registrations_list_url(self.event.id), {}) + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + self.assertEqual(response.json()["status"], constants.PENDING_REGISTER) + mocked_task.delay.assert_called_once() + + @mock.patch("lego.apps.events.views.async_register") + @mock.patch( + "lego.apps.events.models.Event.register", + side_effect=OperationalError("deadlock"), + ) + def test_falls_back_to_async_on_unexpected_error( + self, mocked_register, mocked_task + ): + """No error may strand the registration in PENDING_REGISTER, as that + blocks every later attempt""" + response = self.client.post(_get_registrations_list_url(self.event.id), {}) + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + self.assertEqual(response.json()["status"], constants.PENDING_REGISTER) + mocked_task.delay.assert_called_once() + + @mock.patch("lego.apps.events.views.async_unregister") + def test_unregistration_is_synchronous(self, mocked_task): + """Interest event unregistrations complete in the request""" + registration_response = self.client.post( + _get_registrations_list_url(self.event.id), {} + ) + response = self.client.delete( + _get_registrations_detail_url( + self.event.id, registration_response.json()["id"] + ) + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json()["status"], constants.SUCCESS_UNREGISTER) + mocked_task.delay.assert_not_called() diff --git a/lego/apps/events/tests/test_penalties.py b/lego/apps/events/tests/test_penalties.py index dc7d998a3..72e665182 100644 --- a/lego/apps/events/tests/test_penalties.py +++ b/lego/apps/events/tests/test_penalties.py @@ -361,7 +361,9 @@ def test_penalties_created_on_unregister(self): event.unregistration_deadline = timezone.now() - timedelta(days=1) event.save() - registration = event.registrations.first() + # Only pooled registrations are penalized, and the event also has a + # waiting list registration + registration = event.registrations.exclude(pool=None).first() penalties_before = registration.user.number_of_penalties() event.unregister(registration) diff --git a/lego/apps/events/tests/test_registrations.py b/lego/apps/events/tests/test_registrations.py index 60b80d691..c1fb56561 100644 --- a/lego/apps/events/tests/test_registrations.py +++ b/lego/apps/events/tests/test_registrations.py @@ -2,10 +2,11 @@ from django.utils import timezone -from lego.apps.events.constants import INFINITE -from lego.apps.events.exceptions import EventNotReady +from lego.apps.events.constants import INFINITE, INTEREST_EVENT, PRESENCE_CHOICES +from lego.apps.events.exceptions import EventNotReady, UnansweredSurveyException from lego.apps.events.models import Event, Pool, Registration from lego.apps.followers.models import FollowEvent +from lego.apps.surveys.models import Survey from lego.apps.users.models import AbakusGroup, User from lego.utils.test_utils import BaseTestCase @@ -1049,6 +1050,70 @@ def test_that_is_ready_flag_disables_new_registrations(self): event.register(registration) +class InterestEventRegistrationTestCase(BaseTestCase): + fixtures = [ + "test_abakus_groups.yaml", + "test_users.yaml", + "test_companies.yaml", + "test_events.yaml", + ] + + def setUp(self): + self.event = Event.objects.get(title="POOLS_NO_REGISTRATIONS") + self.event.event_type = INTEREST_EVENT + self.event.heed_penalties = False + self.event.registration_deadline_hours = 0 + self.event.start_time = timezone.now() + timedelta(hours=1) + self.event.end_time = timezone.now() + timedelta(hours=3) + self.event.save() + + self.user = get_dummy_users(1)[0] + AbakusGroup.objects.get(name="Abakus").add_user(self.user) + + def test_can_register_right_before_start(self): + """Interest events have no registration deadline before start""" + registration = Registration.objects.get_or_create( + event=self.event, user=self.user + )[0] + self.event.register(registration) + self.assertIsNotNone(registration.pool) + + def test_can_register_with_unanswered_survey(self): + """Unanswered surveys do not block interest event registration""" + attended = Event.objects.get(title="POOLS_WITH_REGISTRATIONS") + Registration.objects.get_or_create( + event=attended, + user=self.user, + defaults={"presence": PRESENCE_CHOICES.PRESENT}, + ) + Survey.objects.create(event=attended) + self.assertGreater(len(self.user.unanswered_surveys()), 0) + + registration = Registration.objects.get_or_create( + event=self.event, user=self.user + )[0] + self.event.register(registration) + self.assertIsNotNone(registration.pool) + + def test_unanswered_survey_still_blocks_normal_events(self): + """The survey gate stays in place for other event types""" + attended = Event.objects.get(title="POOLS_WITH_REGISTRATIONS") + Registration.objects.get_or_create( + event=attended, + user=self.user, + defaults={"presence": PRESENCE_CHOICES.PRESENT}, + ) + Survey.objects.create(event=attended) + + self.event.event_type = "event" + self.event.save() + registration = Registration.objects.get_or_create( + event=self.event, user=self.user + )[0] + with self.assertRaises(UnansweredSurveyException): + self.event.register(registration) + + class InfiniteEventCapacityTestCase(BaseTestCase): fixtures = [ "test_abakus_groups.yaml", diff --git a/lego/apps/events/views.py b/lego/apps/events/views.py index 0c2fc00f4..274498079 100644 --- a/lego/apps/events/views.py +++ b/lego/apps/events/views.py @@ -1,4 +1,7 @@ -from django.db import transaction +from collections.abc import Callable +from typing import Any + +from django.db import IntegrityError, transaction from django.db.models import Count, Prefetch, Q from django.http import Http404 from django.shortcuts import get_object_or_404 @@ -6,10 +9,12 @@ from django_filters.rest_framework import DjangoFilterBackend from rest_framework import decorators, filters, mixins, permissions, status, viewsets from rest_framework.exceptions import PermissionDenied, ValidationError +from rest_framework.request import Request from rest_framework.response import Response from rest_framework.serializers import BaseSerializer from celery.canvas import chain +from structlog import get_logger from lego.apps.events import constants from lego.apps.events.exceptions import ( @@ -21,6 +26,7 @@ APIPaymentExists, APIRegistrationExists, APIRegistrationsExistsInPool, + EventHasClosed, NoSuchPool, NoSuchRegistration, RegistrationExists, @@ -51,6 +57,7 @@ RegistrationSearchSerializer, ) from lego.apps.events.tasks import ( + admit_registration, async_cancel_payment, async_initiate_payment, async_register, @@ -58,6 +65,7 @@ async_unregister, check_for_bump_on_pool_creation_or_expansion, save_and_notify_payment, + withdraw_registration, ) from lego.apps.events.websockets import notify_event_registration from lego.apps.files.constants import IMAGE @@ -69,6 +77,8 @@ from lego.apps.users.models import PhotoConsent, User from lego.utils.functions import request_plausible_statistics, verify_captcha +log = get_logger() + class EventViewSet(AllowedPermissionsMixin, viewsets.ModelViewSet): filterset_class = EventsFilterSet @@ -105,6 +115,7 @@ def get_queryset(self): if self.action in ["list", "upcoming", "previous"]: queryset = Event.objects.select_related( "company", + "responsible_group", ).prefetch_related( "pools", "pools__registrations", @@ -384,13 +395,15 @@ def get_queryset(self): event_id = self.kwargs.get("event_pk", None) return Registration.objects.filter(event=event_id).prefetch_related("user") - def create(self, request, *args, **kwargs): + def create(self, request: Request, *args: Any, **kwargs: Any) -> Response: serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) event_id = self.kwargs.get("event_pk", None) event = Event.objects.get(id=event_id) - if not get_permission_handler(Event).has_perm(request.user, VIEW, obj=event): + if not get_permission_handler(Event).has_perm( + request.user, VIEW, obj=event # type: ignore[arg-type] + ): raise PermissionDenied() if event.use_captcha and not verify_captcha( @@ -399,6 +412,7 @@ def create(self, request, *args, **kwargs): raise ValidationError({"error": "Bad captcha"}) current_user = request.user + is_interest_event = event.event_type == constants.INTEREST_EVENT with transaction.atomic(): registration, is_new = Registration.objects.get_or_create( @@ -419,23 +433,85 @@ def create(self, request, *args, **kwargs): registration.status = constants.PENDING_REGISTER registration.feedback = feedback registration.save(current_user=current_user) - transaction.on_commit(lambda: async_register.delay(registration.id)) + if not is_interest_event: + transaction.on_commit(lambda: async_register.delay(registration.id)) + + response_status: int = status.HTTP_202_ACCEPTED + if is_interest_event: + response_status = self.sync_registration( + admit_registration, + registration.id, + constants.FAILURE_REGISTER, + async_register, + success_status=status.HTTP_201_CREATED, + ) + registration.refresh_from_db() registration_serializer = RegistrationReadSerializer( registration, context={"user": registration.user} ) - return Response( - data=registration_serializer.data, status=status.HTTP_202_ACCEPTED - ) + return Response(data=registration_serializer.data, status=response_status) + + def sync_registration( + self, + action: Callable[[int], Registration], + registration_id: int, + failure_status: str, + fallback_task: Any, + success_status: int, + ) -> int: + """ + Run a registration action inside the request so interest event + responses carry the final outcome, returning the response status: + success_status when the action completed, 202 when the async task + was enqueued instead - making the worst case the normal async + pipeline. + """ + try: + action(registration_id) + return success_status + except EventHasClosed as e: + Registration.objects.filter(id=registration_id).update( + status=failure_status + ) + raise ValidationError({"error": "Arrangementet har startet"}) from e + except (ValueError, IntegrityError): + fallback_task.delay(registration_id) + return status.HTTP_202_ACCEPTED + except Exception: + # Nothing may leave the registration stuck in PENDING_*, as that + # blocks every later attempt. Unexpected errors also fall back to + # the async pipeline, which retries or marks the registration + # failed. + log.exception( + "sync_registration_unexpected_error", + registration_id=registration_id, + ) + fallback_task.delay(registration_id) + return status.HTTP_202_ACCEPTED - def destroy(self, request, *args, **kwargs): + def destroy(self, request: Request, *args: Any, **kwargs: Any) -> Response: with transaction.atomic(): instance = self.get_object() + is_interest_event = instance.event.event_type == constants.INTEREST_EVENT instance.status = constants.PENDING_UNREGISTER instance.save() - transaction.on_commit(lambda: async_unregister.delay(instance.id)) + if not is_interest_event: + transaction.on_commit(lambda: async_unregister.delay(instance.id)) + + response_status: int = status.HTTP_202_ACCEPTED + if is_interest_event: + response_status = self.sync_registration( + withdraw_registration, + instance.id, + constants.FAILURE_UNREGISTER, + async_unregister, + success_status=status.HTTP_200_OK, + ) + instance.refresh_from_db() + serializer = RegistrationReadSerializer(instance) - return Response(data=serializer.data, status=status.HTTP_202_ACCEPTED) + return Response(data=serializer.data, status=response_status) def update(self, request, *args, **kwargs): registration = self.get_object() diff --git a/lego/apps/frontpage/tests.py b/lego/apps/frontpage/tests.py index 049a29c49..182be1e5f 100644 --- a/lego/apps/frontpage/tests.py +++ b/lego/apps/frontpage/tests.py @@ -1,6 +1,9 @@ from django.urls import reverse +from django.utils import timezone from rest_framework import status +from lego.apps.events.constants import INTEREST_EVENT +from lego.apps.events.models import Event from lego.apps.users.models import AbakusGroup, User from lego.utils.test_utils import BaseAPITestCase @@ -46,3 +49,14 @@ def test_pinned_is_first_not_logged_in(self): self.assertTrue(first["pinned"]) # .. but that the second is before the first self.assertGreater(first["startTime"], second["startTime"]) + + def test_interest_events_are_excluded(self): + """Interest events have their own page and stay off the frontpage""" + event = Event.objects.filter(end_time__gt=timezone.now()).first() + self.assertIsNotNone(event) + Event.objects.filter(id=event.id).update(event_type=INTEREST_EVENT) + + self.client.force_authenticate(self.user) + res = self.client.get(_get_frontpage()) + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertNotIn(event.id, [e["id"] for e in res.json()["events"]]) diff --git a/lego/apps/frontpage/views.py b/lego/apps/frontpage/views.py index 406ac3ea2..b90ce3d8e 100644 --- a/lego/apps/frontpage/views.py +++ b/lego/apps/frontpage/views.py @@ -5,7 +5,7 @@ from lego.apps.articles.models import Article from lego.apps.articles.serializers import PublicArticleSerializer -from lego.apps.events.constants import SUCCESS_UNREGISTER +from lego.apps.events.constants import INTEREST_EVENT, SUCCESS_UNREGISTER from lego.apps.events.models import Event, Pool, Registration from lego.apps.events.serializers.events import FrontpageEventSerializer from lego.apps.permissions.constants import LIST @@ -58,6 +58,8 @@ def get_serializer_context(): queryset_events_base = ( Event.objects.all() .filter(end_time__gt=timezone.now()) + # Interest events live on their own page, like in the events list + .exclude(event_type=INTEREST_EVENT) .order_by("-pinned", "start_time", "id") .prefetch_related("pools", "pools__registrations", "company", "tags") ) diff --git a/lego/apps/ical/viewsets.py b/lego/apps/ical/viewsets.py index d122c3063..c96f76d73 100644 --- a/lego/apps/ical/viewsets.py +++ b/lego/apps/ical/viewsets.py @@ -7,6 +7,7 @@ from rest_framework.response import Response from rest_framework.settings import api_settings +from lego.apps.events.constants import INTEREST_EVENT from lego.apps.events.models import Event from lego.apps.ical import constants, utils from lego.apps.ical.authentication import ICalTokenAuthentication @@ -120,7 +121,12 @@ def registrations(self, request): permission_handler = get_permission_handler(Event) events = permission_handler.filter_queryset( - request.user, Event.objects.all().filter(end_time__gt=timezone.now()) + request.user, + # Interest events have no registration opening to be reminded of - + # they are open from the moment they are created + Event.objects.all() + .filter(end_time__gt=timezone.now()) + .exclude(event_type=INTEREST_EVENT), ) for event in events: @@ -154,10 +160,15 @@ def events(self, request): permission_handler = get_permission_handler(Event) events = permission_handler.filter_queryset( request.user, - Event.objects.all().filter( + # Interest events stay out of the general feed, like on the + # frontpage and event list. Registered users get them through + # the personal feed via their FollowEvent. + Event.objects.all() + .filter( end_time__gt=timezone.now() - timedelta(days=constants.HISTORY_BACKWARDS_IN_DAYS) - ), + ) + .exclude(event_type=INTEREST_EVENT), ) utils.add_events_to_ical_feed(feed, events) diff --git a/lego/apps/users/management/__init__.py b/lego/apps/users/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lego/apps/users/management/commands/__init__.py b/lego/apps/users/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lego/apps/users/management/commands/reconcile_interest_groups.py b/lego/apps/users/management/commands/reconcile_interest_groups.py new file mode 100644 index 000000000..a0b5582f7 --- /dev/null +++ b/lego/apps/users/management/commands/reconcile_interest_groups.py @@ -0,0 +1,31 @@ +from typing import Any + +from django.core.management.base import BaseCommand, CommandParser + +from lego.apps.users import constants +from lego.apps.users.models import AbakusGroup + + +class Command(BaseCommand): + help = "Promote a co-leader or deactivate active interest groups without a leader." + + def add_arguments(self, parser: CommandParser) -> None: + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would change without writing to the database.", + ) + + def handle(self, *args: Any, **options: Any) -> None: + dry_run = options["dry_run"] + prefix = "Would " if dry_run else "" + + groups = AbakusGroup.objects.filter( + type=constants.GROUP_INTEREST, active=True + ).order_by("name") + for group in groups: + action = group.reconcile_leadership(dry_run=dry_run) + if action: + self.stdout.write( + self.style.SUCCESS(f'{prefix}{action} in "{group.name}".') + ) diff --git a/lego/apps/users/models.py b/lego/apps/users/models.py index da868d924..ed810d07b 100644 --- a/lego/apps/users/models.py +++ b/lego/apps/users/models.py @@ -205,6 +205,34 @@ def remove_user(self, user): for membership in memberships: membership.delete() + def reconcile_leadership( + self, exclude_membership_id: int | None = None, dry_run: bool = False + ) -> str | None: + if self.type != constants.GROUP_INTEREST or not self.active: + return None + active_memberships = Membership.objects.filter( + abakus_group=self, is_active=True + ) + if active_memberships.filter(role=constants.LEADER).exists(): + return None + successor = ( + active_memberships.filter(role=constants.CO_LEADER) + .exclude(pk=exclude_membership_id) + .order_by("created_at") + .first() + ) + if successor: + if not dry_run: + successor.role = constants.LEADER + successor.save() + return f"promote {successor.user.username}" + if active_memberships.filter(role=constants.CO_LEADER).exists(): + return None + if not dry_run: + self.active = False + self.save() + return "deactivate" + def natural_key(self): return (self.name,) diff --git a/lego/apps/users/permissions.py b/lego/apps/users/permissions.py index 23801b489..556197d97 100644 --- a/lego/apps/users/permissions.py +++ b/lego/apps/users/permissions.py @@ -155,8 +155,8 @@ def has_perm( if abakus_group.type in constants.OPEN_GROUPS: if perm == LIST: return True - elif perm == DELETE: - # Leaders should be able to remove memberships. + elif perm in (EDIT, DELETE): + # Leaders should be able to change and remove memberships. return abakus_group.memberships.filter( user=user, role__in=EDIT_ROLES ).exists() diff --git a/lego/apps/users/serializers/abakus_groups.py b/lego/apps/users/serializers/abakus_groups.py index 8fb0827ce..d49a5701d 100644 --- a/lego/apps/users/serializers/abakus_groups.py +++ b/lego/apps/users/serializers/abakus_groups.py @@ -1,9 +1,19 @@ +from typing import Any + +from django.db.models import Case, IntegerField, When from rest_framework import serializers from lego.apps.files.fields import ImageField from lego.apps.users import constants from lego.apps.users.models import AbakusGroup, Membership +MEMBERSHIP_ROLE_PRIORITY = Case( + When(role=constants.LEADER, then=0), + When(role=constants.CO_LEADER, then=1), + default=2, + output_field=IntegerField(), +) + class DetailedAbakusGroupSerializer(serializers.ModelSerializer): logo = ImageField( @@ -69,12 +79,40 @@ class Meta: fields = ("name", "id") +class MembershipRoleSerializer(serializers.ModelSerializer): + class Meta: + model = Membership + fields = ("id", "role") + + class PublicListAbakusGroupSerializer(PublicAbakusGroupSerializer): logo = ImageField(required=False, options={"height": 400, "width": 400}) + user_membership = serializers.SerializerMethodField() class Meta: model = AbakusGroup - fields = PublicAbakusGroupSerializer.Meta.fields + ("number_of_users",) + fields = PublicAbakusGroupSerializer.Meta.fields + ( + "number_of_users", + "user_membership", + ) + + def get_user_membership(self, group: AbakusGroup) -> dict[str, Any] | None: + if hasattr(group, "user_membership"): + membership = group.user_membership[0] if group.user_membership else None + else: + request = self.context.get("request", None) + if not request or not request.user.is_authenticated: + return None + membership = ( + Membership.objects.filter( + abakus_group=group, user=request.user, is_active=True + ) + .order_by(MEMBERSHIP_ROLE_PRIORITY) + .first() + ) + if not membership: + return None + return MembershipRoleSerializer(membership).data class PublicDetailedAbakusGroupSerializer(PublicListAbakusGroupSerializer): diff --git a/lego/apps/users/serializers/memberships.py b/lego/apps/users/serializers/memberships.py index f4706a3b8..dea88514d 100644 --- a/lego/apps/users/serializers/memberships.py +++ b/lego/apps/users/serializers/memberships.py @@ -1,7 +1,11 @@ +from typing import Any + from rest_framework import serializers +from lego.apps.users import constants from lego.apps.users.fields import PublicUserField from lego.apps.users.models import AbakusGroup, Membership, MembershipHistory, User +from lego.apps.users.permissions import EDIT_ROLES from lego.apps.users.serializers.abakus_groups import PublicAbakusGroupSerializer @@ -49,8 +53,37 @@ class Meta: ) read_only_fields = ("created_at", "abakus_group") - def validate(self, attrs): + def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: group = AbakusGroup.objects.get(pk=self.context["view"].kwargs["group_pk"]) + instance = self.instance if isinstance(self.instance, Membership) else None + if instance is not None and "user" in attrs and attrs["user"] != instance.user: + raise serializers.ValidationError( + {"user": "Cannot change the user of a membership."} + ) + # Role changes must leave a leader behind. Leaving the group + # (is_active=False or DELETE) is the deliberate exit path instead, + # where reconcile_leadership promotes a co-leader or deactivates the + # group. + demotes_last_leader = ( + instance is not None + and group.type == constants.GROUP_INTEREST + and group.active + and instance.role == constants.LEADER + and "role" in attrs + and attrs["role"] != constants.LEADER + and attrs.get("is_active", instance.is_active) + and not Membership.objects.filter( + abakus_group=group, is_active=True, role__in=EDIT_ROLES + ) + .exclude(pk=instance.pk) + .exists() + ) + if demotes_last_leader: + raise serializers.ValidationError( + { + "role": "Interest groups must have a leader. Promote someone else first." + } + ) return {"abakus_group": group, **attrs} diff --git a/lego/apps/users/tasks.py b/lego/apps/users/tasks.py index 94877fb64..da219ce28 100644 --- a/lego/apps/users/tasks.py +++ b/lego/apps/users/tasks.py @@ -7,7 +7,8 @@ from structlog import get_logger from lego import celery_app -from lego.apps.users.models import User +from lego.apps.users import constants +from lego.apps.users.models import AbakusGroup, User from lego.apps.users.notifications import DeletedUserNotification, InactiveNotification from lego.utils.tasks import AbakusTask, send_email @@ -20,6 +21,21 @@ ) +@celery_app.task(serializer="json", bind=True, base=AbakusTask) +def reconcile_interest_group_leadership( + self: AbakusTask, logger_context: dict | None = None +) -> None: + self.setup_logger(logger_context) + + groups = AbakusGroup.objects.filter(type=constants.GROUP_INTEREST, active=True) + for group in groups: + action = group.reconcile_leadership() + if action: + log.info( + "interest_group_leadership_reconciled", group=group.name, action=action + ) + + def send_inactive_notification(user): notification = InactiveNotification(user, max_inactive_days=MAX_INACTIVE_DAYS) notification.notify() diff --git a/lego/apps/users/tests/test_abakusgroup_api.py b/lego/apps/users/tests/test_abakusgroup_api.py index a87bcac84..bd334f7b2 100644 --- a/lego/apps/users/tests/test_abakusgroup_api.py +++ b/lego/apps/users/tests/test_abakusgroup_api.py @@ -3,7 +3,7 @@ from lego.apps.users import constants from lego.apps.users.constants import GROUP_COMMITTEE, GROUP_INTEREST, LEADER -from lego.apps.users.models import AbakusGroup, User +from lego.apps.users.models import AbakusGroup, Membership, User from lego.apps.users.serializers.abakus_groups import PublicAbakusGroupSerializer from lego.utils.test_utils import BaseAPITestCase @@ -56,7 +56,13 @@ def successful_list(self, user): keys, set( fields - + ["numberOfUsers", "contactEmail", "showBadge", "logoPlaceholder"] + + [ + "numberOfUsers", + "contactEmail", + "showBadge", + "logoPlaceholder", + "userMembership", + ] ), ) @@ -67,6 +73,59 @@ def test_without_auth(self): def test_with_auth(self): self.successful_list(self.user) + def test_user_membership(self): + group = AbakusGroup.objects.get(name="AbaBrygg") + membership = group.add_user(self.user) + non_member_group = AbakusGroup.objects.exclude( + pk__in=self.user.abakus_groups.values_list("pk", flat=True) + ).first() + + self.client.force_authenticate(user=self.user) + response = self.client.get(_get_list_url()) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + groups = {g["id"]: g for g in response.json()["results"]} + self.assertEqual( + groups[group.pk]["userMembership"], + {"id": membership.pk, "role": constants.MEMBER}, + ) + self.assertIsNone(groups[non_member_group.pk]["userMembership"]) + + def test_user_membership_prefers_leader_role(self): + group = AbakusGroup.objects.get(name="AbaBrygg") + group.add_user(self.user) + leader_membership = Membership.objects.create( + user=self.user, abakus_group=group, role=constants.LEADER + ) + + self.client.force_authenticate(user=self.user) + response = self.client.get(_get_list_url()) + + groups = {g["id"]: g for g in response.json()["results"]} + self.assertEqual( + groups[group.pk]["userMembership"], + {"id": leader_membership.pk, "role": constants.LEADER}, + ) + + def test_user_membership_inactive(self): + group = AbakusGroup.objects.get(name="AbaBrygg") + membership = group.add_user(self.user) + membership.is_active = False + membership.save() + + self.client.force_authenticate(user=self.user) + response = self.client.get(_get_list_url()) + + groups = {g["id"]: g for g in response.json()["results"]} + self.assertIsNone(groups[group.pk]["userMembership"]) + + def test_user_membership_without_auth(self): + response = self.client.get(_get_list_url()) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + for group in response.json()["results"]: + self.assertIsNone(group["userMembership"]) + def test_with_filter_type(self): """Groups can be filtered on multiple types""" self.client.force_authenticate(self.user) @@ -326,6 +385,127 @@ def test_leader_cannot_join_for_another(self): ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + def test_leader_can_promote_member_to_co_leader(self): + membership = self.interest_group.add_user(self.abakule) + self.client.force_authenticate(user=self.leader) + response = self.client.patch( + _get_membership_detail_url(self.interest_group.pk, membership.pk), + {"role": constants.CO_LEADER}, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + membership.refresh_from_db() + self.assertEqual(membership.role, constants.CO_LEADER) + + def test_member_cannot_change_own_role(self): + membership = self.interest_group.add_user(self.abakule) + self.client.force_authenticate(user=self.abakule) + response = self.client.patch( + _get_membership_detail_url(self.interest_group.pk, membership.pk), + {"role": LEADER}, + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_leader_cannot_move_membership_to_another_user(self): + membership = self.interest_group.add_user(self.abakule) + self.client.force_authenticate(user=self.leader) + response = self.client.patch( + _get_membership_detail_url(self.interest_group.pk, membership.pk), + {"user": self.abakommer.pk}, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_leader_leaving_deactivates_group(self): + self.client.force_authenticate(user=self.leader) + membership = Membership.objects.get( + user=self.leader, abakus_group=self.interest_group + ) + response = self.client.delete( + _get_membership_detail_url(self.interest_group.pk, membership.pk) + ) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.interest_group.refresh_from_db() + self.assertFalse(self.interest_group.active) + + def test_leader_leaving_promotes_co_leader(self): + co_leader = self.interest_group.add_user(self.abakule, role=constants.CO_LEADER) + self.client.force_authenticate(user=self.leader) + membership = Membership.objects.get( + user=self.leader, abakus_group=self.interest_group + ) + response = self.client.delete( + _get_membership_detail_url(self.interest_group.pk, membership.pk) + ) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + co_leader.refresh_from_db() + self.interest_group.refresh_from_db() + self.assertEqual(co_leader.role, LEADER) + self.assertTrue(self.interest_group.active) + + def test_last_leader_cannot_demote_to_co_leader(self): + self.client.force_authenticate(user=self.leader) + membership = Membership.objects.get( + user=self.leader, abakus_group=self.interest_group + ) + response = self.client.patch( + _get_membership_detail_url(self.interest_group.pk, membership.pk), + {"role": constants.CO_LEADER}, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + membership.refresh_from_db() + self.assertEqual(membership.role, LEADER) + + def test_leader_demotion_promotes_remaining_co_leader(self): + co_leader = self.interest_group.add_user(self.abakule, role=constants.CO_LEADER) + self.client.force_authenticate(user=self.leader) + membership = Membership.objects.get( + user=self.leader, abakus_group=self.interest_group + ) + response = self.client.patch( + _get_membership_detail_url(self.interest_group.pk, membership.pk), + {"role": constants.CO_LEADER}, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + membership.refresh_from_db() + co_leader.refresh_from_db() + self.interest_group.refresh_from_db() + self.assertEqual(membership.role, constants.CO_LEADER) + self.assertEqual(co_leader.role, LEADER) + self.assertTrue(self.interest_group.active) + + def test_last_leader_cannot_demote_to_member(self): + """Role changes must leave a leader - leaving the group is the only + way out for the last leader""" + self.client.force_authenticate(user=self.leader) + membership = Membership.objects.get( + user=self.leader, abakus_group=self.interest_group + ) + response = self.client.patch( + _get_membership_detail_url(self.interest_group.pk, membership.pk), + {"role": constants.MEMBER}, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + membership.refresh_from_db() + self.interest_group.refresh_from_db() + self.assertEqual(membership.role, LEADER) + self.assertTrue(self.interest_group.active) + + def test_leader_demotion_to_member_with_co_leader_promotes(self): + """Demoting to member is fine when a co-leader can take over""" + co_leader = self.interest_group.add_user(self.abakule, role=constants.CO_LEADER) + self.client.force_authenticate(user=self.leader) + membership = Membership.objects.get( + user=self.leader, abakus_group=self.interest_group + ) + response = self.client.patch( + _get_membership_detail_url(self.interest_group.pk, membership.pk), + {"role": constants.MEMBER}, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + co_leader.refresh_from_db() + self.interest_group.refresh_from_db() + self.assertEqual(co_leader.role, LEADER) + self.assertTrue(self.interest_group.active) + def test_prevent_users_without_grade_cannot_join_interestgroup(self): self.client.force_authenticate(user=self.abakulingutenklasse) response = self.client.post( diff --git a/lego/apps/users/tests/test_models.py b/lego/apps/users/tests/test_models.py index 423a88597..0dd7e7d9c 100644 --- a/lego/apps/users/tests/test_models.py +++ b/lego/apps/users/tests/test_models.py @@ -30,6 +30,60 @@ def test_natural_key(self): self.assertEqual(self.non_committee, found_group) +class ReconcileLeadershipTestCase(BaseTestCase): + fixtures = ["test_abakus_groups.yaml", "test_users.yaml"] + + def setUp(self): + self.group = AbakusGroup.objects.get(name="AbaBrygg") + self.first = User.objects.get(username="test1") + self.second = User.objects.get(username="test2") + + def test_promotes_oldest_co_leader(self): + oldest = self.group.add_user(self.first, role=constants.CO_LEADER) + self.group.add_user(self.second, role=constants.CO_LEADER) + + action = self.group.reconcile_leadership() + + oldest.refresh_from_db() + self.assertEqual(action, f"promote {self.first.username}") + self.assertEqual(oldest.role, constants.LEADER) + self.assertTrue(self.group.active) + + def test_deactivates_group_without_leadership(self): + self.group.add_user(self.first) + + action = self.group.reconcile_leadership() + + self.assertEqual(action, "deactivate") + self.group.refresh_from_db() + self.assertFalse(self.group.active) + + def test_dry_run_changes_nothing(self): + membership = self.group.add_user(self.first, role=constants.CO_LEADER) + + action = self.group.reconcile_leadership(dry_run=True) + + membership.refresh_from_db() + self.assertEqual(action, f"promote {self.first.username}") + self.assertEqual(membership.role, constants.CO_LEADER) + + def test_noop_with_leader(self): + self.group.add_user(self.first, role=constants.LEADER) + self.assertIsNone(self.group.reconcile_leadership()) + + def test_noop_for_inactive_group(self): + self.group.active = False + self.group.save() + self.group.add_user(self.first, role=constants.CO_LEADER) + self.assertIsNone(self.group.reconcile_leadership()) + + def test_noop_for_non_interest_group(self): + webkom = AbakusGroup.objects.get(name="Webkom") + self.assertIsNone(webkom.reconcile_leadership()) + webkom.refresh_from_db() + self.assertTrue(webkom.active) + + class AbakusGroupHierarchyTestCase(BaseTestCase): fixtures = ["initial_files.yaml", "initial_abakus_groups.yaml"] diff --git a/lego/apps/users/views/abakus_groups.py b/lego/apps/users/views/abakus_groups.py index d7217c16b..e0bd0ff59 100644 --- a/lego/apps/users/views/abakus_groups.py +++ b/lego/apps/users/views/abakus_groups.py @@ -1,12 +1,14 @@ +from django.db.models import Prefetch, QuerySet from rest_framework import viewsets from lego.apps.permissions.api.views import AllowedPermissionsMixin from lego.apps.permissions.constants import EDIT from lego.apps.users import constants from lego.apps.users.filters import AbakusGroupFilterSet -from lego.apps.users.models import AbakusGroup +from lego.apps.users.models import AbakusGroup, Membership from lego.apps.users.permissions import PreventPermissionElevation from lego.apps.users.serializers.abakus_groups import ( + MEMBERSHIP_ROLE_PRIORITY, DetailedAbakusGroupSerializer, PublicAbakusGroupSerializer, PublicDetailedAbakusGroupSerializer, @@ -39,8 +41,19 @@ def get_serializer_class(self): return DetailedAbakusGroupSerializer - def get_queryset(self): + def get_queryset(self) -> QuerySet[AbakusGroup]: if self.action == "retrieve": return AbakusGroup.objects_with_text.prefetch_related("users").all() - return self.queryset + queryset = self.queryset + if self.request.user.is_authenticated: + queryset = queryset.prefetch_related( + Prefetch( + "membership_set", + queryset=Membership.objects.filter( + user=self.request.user, is_active=True + ).order_by(MEMBERSHIP_ROLE_PRIORITY), + to_attr="user_membership", + ) + ) + return queryset diff --git a/lego/apps/users/views/memberships.py b/lego/apps/users/views/memberships.py index 58a2928ac..2badbf2a7 100644 --- a/lego/apps/users/views/memberships.py +++ b/lego/apps/users/views/memberships.py @@ -1,6 +1,7 @@ from django_filters.rest_framework import DjangoFilterBackend from rest_framework import filters, status, viewsets from rest_framework.response import Response +from rest_framework.serializers import BaseSerializer from lego.apps.permissions.api.filters import LegoPermissionFilter from lego.apps.permissions.api.views import AllowedPermissionsMixin @@ -41,3 +42,14 @@ def create(self, request, *args, **kwargs): return Response(status=status.HTTP_403_FORBIDDEN) return super(MembershipViewSet, self).create(request, *args, **kwargs) + + def perform_update(self, serializer: BaseSerializer[Membership]) -> None: + membership = serializer.save() + membership.abakus_group.reconcile_leadership( + exclude_membership_id=membership.pk + ) + + def perform_destroy(self, instance: Membership) -> None: + group = instance.abakus_group + super().perform_destroy(instance) + group.reconcile_leadership() diff --git a/lego/settings/celery.py b/lego/settings/celery.py index 1e6020fc0..8409c6d29 100644 --- a/lego/settings/celery.py +++ b/lego/settings/celery.py @@ -69,6 +69,10 @@ "task": "lego.apps.meetings.tasks.generate_weekly_recurring_meetings", "schedule": crontab(hour=0, minute=0), }, + "reconcile_interest_group_leadership": { + "task": "lego.apps.users.tasks.reconcile_interest_group_leadership", + "schedule": crontab(hour=5, minute=0), + }, } app.conf.update( diff --git a/lego/utils/functions.py b/lego/utils/functions.py index 691eef0ba..4ac13b97c 100644 --- a/lego/utils/functions.py +++ b/lego/utils/functions.py @@ -13,13 +13,13 @@ log = get_logger() -def verify_captcha(captcha_response): +def verify_captcha(captcha_response: str | None) -> bool: try: r = requests.post( settings.CAPTCHA_URL, {"secret": settings.CAPTCHA_KEY, "response": captcha_response}, ) - return r.json().get("success", False) + return bool(r.json().get("success", False)) except requests.exceptions.RequestException as e: log.error( "captcha_validation_error",