diff --git a/api_schemas/event_schemas.py b/api_schemas/event_schemas.py index eaa23a96..f62e644f 100644 --- a/api_schemas/event_schemas.py +++ b/api_schemas/event_schemas.py @@ -2,7 +2,7 @@ from api_schemas.base_schema import BaseSchema from db_models.priority_model import Priority_DB from helpers.constants import MAX_EVENT_DESC, MAX_EVENT_TITLE -from helpers.types import ALCOHOL_EVENT_TYPES, EVENT_DOT_TYPES, datetime_utc +from helpers.types import ALCOHOL_EVENT_TYPES, EVENT_DOT_TYPES, GROUP_TYPE, datetime_utc from pydantic import StringConstraints if TYPE_CHECKING: @@ -31,6 +31,8 @@ class EventRead(BaseSchema): can_signup: bool drink_package: bool is_nollning_event: bool + mentor_group_types: list[GROUP_TYPE] + allow_other_mentors: bool alcohol_event_type: str dress_code: str price: int @@ -64,6 +66,8 @@ class EventCreate(BaseSchema): can_signup: bool drink_package: bool is_nollning_event: bool + mentor_group_types: list[GROUP_TYPE] + allow_other_mentors: bool alcohol_event_type: ALCOHOL_EVENT_TYPES dress_code: str price: int @@ -90,11 +94,14 @@ class EventUpdate(BaseSchema): can_signup: bool | None = None drink_package: bool | None = None is_nollning_event: bool | None = None + mentor_group_types: list[GROUP_TYPE] | None = None + allow_other_mentors: bool | None = None priorities: list[str] | None = None alcohol_event_type: ALCOHOL_EVENT_TYPES | None = None dress_code: str | None = None price: int | None = None dot: EVENT_DOT_TYPES | None = None + lottery: bool | None = None class AddEventTag(BaseSchema): diff --git a/api_schemas/user_schemas.py b/api_schemas/user_schemas.py index 04b8d3d9..815863c9 100644 --- a/api_schemas/user_schemas.py +++ b/api_schemas/user_schemas.py @@ -97,6 +97,12 @@ class UserInEventRead(SimpleUserRead): email: str +class UserForEventSignupRead(SimpleUserRead): + groups: list["GroupRead"] + standard_food_preferences: list[str] | None + other_food_preferences: str | None + + class AdminUserReadForCarBookings(SimpleUserRead): email: str telephone_number: PhoneNumber diff --git a/db_models/event_model.py b/db_models/event_model.py index c2ea9381..efd629c9 100644 --- a/db_models/event_model.py +++ b/db_models/event_model.py @@ -1,6 +1,6 @@ -from helpers.types import ALCOHOL_EVENT_TYPES, EVENT_DOT_TYPES, datetime_utc -from typing import TYPE_CHECKING -from sqlalchemy import ForeignKey, String +from helpers.types import ALCOHOL_EVENT_TYPES, EVENT_DOT_TYPES, GROUP_TYPE, datetime_utc +from typing import TYPE_CHECKING, get_args +from sqlalchemy import JSON, ForeignKey, String from sqlalchemy.orm import mapped_column, Mapped, relationship from helpers.constants import MAX_EVENT_DESC, MAX_EVENT_DRESS_CODE, MAX_EVENT_LOCATION, MAX_EVENT_TITLE from .base_model import BaseModel_DB @@ -69,6 +69,10 @@ class Event_DB(BaseModel_DB): ) is_nollning_event: Mapped[bool] = mapped_column(default=False) + mentor_group_types: Mapped[list[GROUP_TYPE]] = mapped_column( + JSON, default_factory=lambda: list(get_args(GROUP_TYPE)) + ) + allow_other_mentors: Mapped[bool] = mapped_column(default=False) dot: Mapped[EVENT_DOT_TYPES] = mapped_column(default="None") diff --git a/routes/event_router.py b/routes/event_router.py index dcca34f9..61cd92ae 100644 --- a/routes/event_router.py +++ b/routes/event_router.py @@ -21,7 +21,6 @@ from helpers.types import ALLOWED_EXT, ALLOWED_IMG_SIZES, ALLOWED_IMG_TYPES, ASSETS_BASE_PATH from pathlib import Path - event_router = APIRouter() @@ -50,7 +49,9 @@ def get_event_priorities(db: DB_dependency): return list(priorities) -@event_router.patch("/confirmed/{event_id}", response_model=EventRead) +@event_router.patch( + "/confirmed/{event_id}", response_model=EventRead, dependencies=[Permission.require("manage", "Event")] +) def confirm_places( db: DB_dependency, event_id: int, diff --git a/routes/event_signup_router.py b/routes/event_signup_router.py index 918d8721..213c6ab3 100644 --- a/routes/event_signup_router.py +++ b/routes/event_signup_router.py @@ -1,13 +1,21 @@ -from typing import Annotated +from typing import Annotated, get_args from fastapi import APIRouter, HTTPException, status +from api_schemas.user_schemas import UserForEventSignupRead from database import DB_dependency from db_models.event_model import Event_DB from db_models.user_model import User_DB -from services.event_signup_service import signup_to_event, signoff_from_event, update_event_signup, check_me_signup +from db_models.group_model import Group_DB +from helpers.types import GROUP_TYPE +from services.event_signup_service import ( + get_allowed_groups, + signup_to_event, + signoff_from_event, + update_event_signup, + check_me_signup, +) from user.permission import Permission from api_schemas.event_signup_schemas import EventSignupCreate, EventSignupRead, EventSignupUpdate - event_signup_router = APIRouter() @@ -88,6 +96,15 @@ def get_me_event_signup(event_id: int, me: Annotated[User_DB, Permission.member( return check_me_signup(event_id, me, db) +@event_signup_router.get("/me/{event_id}", response_model=UserForEventSignupRead) +def get_me_for_event_signup(event_id: int, user: Annotated[User_DB, Permission.member()], db: DB_dependency): # type: ignore + event = db.query(Event_DB).filter_by(id=event_id).one_or_none() + if event is None: + raise HTTPException(404, detail="Event not found") + + return {**user.__dict__, "groups": get_allowed_groups(event, user)} # type: ignore + + # @event_signup_router.get("/{event_id}", response_model=list[EventSignupRead]) # def get_all_signups(event_id: int, db: DB_dependency): # signups = db.query(EventUser_DB).filter(EventUser_DB.event_id == event_id).all() diff --git a/services/event_service.py b/services/event_service.py index 94513d93..8e87cf2d 100644 --- a/services/event_service.py +++ b/services/event_service.py @@ -50,6 +50,8 @@ def create_new_event(data: EventCreate, db: Session): drink_package=data.drink_package, location=data.location, is_nollning_event=data.is_nollning_event, + mentor_group_types=data.mentor_group_types, + allow_other_mentors=data.allow_other_mentors, dress_code=data.dress_code, price=data.price, alcohol_event_type=data.alcohol_event_type, @@ -89,6 +91,10 @@ def update_event(event_id: int, data: EventUpdate, db: Session): if not event: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Event not found") + if data.lottery is not None and data.lottery != event.lottery: + if event.event_users_confirmed or any(eu.confirmed_status for eu in event.event_users): + raise HTTPException(400, detail="Cannot change lottery after signups are confirmed") + if data.price is not None and data.price < 0: raise HTTPException(400, detail="Price cannot be lower than 0") diff --git a/services/event_signup_service.py b/services/event_signup_service.py index 0f3138da..570231fc 100644 --- a/services/event_signup_service.py +++ b/services/event_signup_service.py @@ -1,11 +1,15 @@ from datetime import UTC, datetime +from typing import get_args from fastapi import HTTPException, status from sqlalchemy.orm import Session from db_models.event_model import Event_DB from db_models.event_user_model import EventUser_DB from db_models.user_model import User_DB +from db_models.group_model import Group_DB +from db_models.group_user_model import GroupUser_DB from api_schemas.event_signup_schemas import EventSignupCreate, EventSignupUpdate from helpers.constants import DEFAULT_USER_PRIORITY +from helpers.types import GROUP_TYPE def signup_to_event(event: Event_DB, user: User_DB, data: EventSignupCreate, manage_permission: bool, db: Session): @@ -30,6 +34,13 @@ def signup_to_event(event: Event_DB, user: User_DB, data: EventSignupCreate, man ): raise HTTPException(400, detail="User already signed up to chosen event") + if ( + manage_permission == False + and data.group_name is not None + and not is_group_allowed(event, user, data.group_name) + ): + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="User cannot sign up with this group") + signup = EventUser_DB(user=user, user_id=user.id, event=event, event_id=event.id) for var, value in vars(data).items(): @@ -76,6 +87,13 @@ def update_event_signup(event: Event_DB, data: EventSignupUpdate, user_id: int, if signup is None: raise HTTPException(status.HTTP_404_NOT_FOUND) + if ( + manage_permission == False + and data.group_name is not None + and not is_group_allowed(event, db.query(User_DB).filter(User_DB.id == user_id).one(), data.group_name) + ): + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="User cannot sign up with this group") + for var, value in vars(data).items(): if var == "priority" and not value: setattr(signup, "priority", DEFAULT_USER_PRIORITY) @@ -102,3 +120,40 @@ def check_me_signup(event_id: int, me: User_DB, db: Session): raise HTTPException(404, detail="Signup not found") return signup + + +def get_allowed_groups(event: Event_DB, user: User_DB): + allowed_groups: list[Group_DB] = [] + if event.is_nollning_event: + allowed_group_types = event.mentor_group_types or list(get_args(GROUP_TYPE)) + for gu in user.group_users: + if _is_group_allowed(gu, event, allowed_group_types): + allowed_groups.append(gu.group) + else: + allowed_groups = user.groups + + return allowed_groups + + +def is_group_allowed(event: Event_DB, user: User_DB, group_name: str | None): + if event.is_nollning_event: + allowed_group_types = event.mentor_group_types or list(get_args(GROUP_TYPE)) + is_event_allowed = False + for gu in user.group_users: + if group_name == gu.group.name: + if _is_group_allowed(gu, event, allowed_group_types): + is_event_allowed = True + break + + if not is_event_allowed: + return False + + return True + + +def _is_group_allowed(gu: GroupUser_DB, e: Event_DB, agt: list[GROUP_TYPE]): + if gu.group.group_type in agt: + return True + elif (gu.group_user_type == "Mentor") and e.allow_other_mentors: + return True + return False diff --git a/tests/basic_factories.py b/tests/basic_factories.py index 39a1bed4..d7c8235c 100644 --- a/tests/basic_factories.py +++ b/tests/basic_factories.py @@ -128,3 +128,54 @@ def patch_sub_election(client, sub_election_id, token=None, **kwargs): data = sub_election_data_factory(**kwargs) headers = auth_headers(token) if token else {} return client.patch(f"/sub-election/{sub_election_id}", json=data, headers=headers) + + +def event_data_factory(**kwargs): + """Factory for creating event payloads with sensible default times.""" + now = datetime.datetime.now(timezone.utc) + default_data = { + "starts_at": (now + datetime.timedelta(days=7)).isoformat(), + "ends_at": (now + datetime.timedelta(days=7, hours=3)).isoformat(), + "signup_start": (now - datetime.timedelta(days=1)).isoformat(), + "signup_end": (now + datetime.timedelta(days=6)).isoformat(), + "title_sv": "Testevenemang", + "title_en": "Test Event", + "description_sv": "Svensk beskrivning", + "description_en": "English description", + "location": "Kårhuset", + "max_event_users": 0, + "priorities": [], + "all_day": False, + "recurring": False, + "food": False, + "closed": False, + "can_signup": True, + "drink_package": False, + "is_nollning_event": False, + "mentor_group_types": ["Mentor", "Mission", "Default", "Committee"], + "allow_other_mentors": False, + "alcohol_event_type": "None", + "dress_code": "Ovve", + "price": 0, + "dot": "None", + "lottery": False, + } + return {**default_data, **kwargs} + + +def add_user_to_group(db_session, user, name, group_type, group_user_type="Mentee"): + """Create a group of a given type and put the user in it. Returns the group.""" + from db_models.group_model import Group_DB + from db_models.group_user_model import GroupUser_DB + + group = Group_DB(name=name, group_type=group_type) + db_session.add(group) + db_session.commit() + + group_user = GroupUser_DB( + user=user, user_id=user.id, group=group, group_id=group.id, group_user_type=group_user_type + ) + db_session.add(group_user) + db_session.commit() + + return group diff --git a/tests/basic_fixtures.py b/tests/basic_fixtures.py index 21691249..35a19176 100644 --- a/tests/basic_fixtures.py +++ b/tests/basic_fixtures.py @@ -281,3 +281,43 @@ def open_sub_election(db_session, open_election, admin_post, member_post): db_session.commit() db_session.refresh(sub_election) return sub_election + + +@pytest.fixture() +def event(client, admin_token, admin_council_id): + """Create and return a plain event which is open for signup.""" + + response = client.post( + "/events/", json=event_data_factory(council_id=admin_council_id), headers=auth_headers(admin_token) + ) + assert response.status_code in (200, 201), response.text + + return response.json() + + +@pytest.fixture() +def nollning_event(client, admin_token, admin_council_id): + """Create and return a nollning event which only accepts groups of type "Mentor".""" + + response = client.post( + "/events/", + json=event_data_factory(council_id=admin_council_id, is_nollning_event=True, mentor_group_types=["Mentor"]), + headers=auth_headers(admin_token), + ) + assert response.status_code in (200, 201), response.text + + return response.json() + + +@pytest.fixture() +def mentor_group(db_session, membered_user): + """Create a group of type "Mentor" with the member user as a mentee.""" + + return add_user_to_group(db_session, membered_user, "Fadderiet", "Mentor", "Mentee") + + +@pytest.fixture() +def mission_group(db_session, membered_user): + """Create a group of type "Mission" with the member user as a mentee.""" + + return add_user_to_group(db_session, membered_user, "Uppdraget", "Mission", "Mentee") diff --git a/tests/test_event_signup.py b/tests/test_event_signup.py new file mode 100644 index 00000000..97be0916 --- /dev/null +++ b/tests/test_event_signup.py @@ -0,0 +1,152 @@ +# type: ignore +import pytest +from .basic_factories import add_user_to_group, auth_headers, event_data_factory + + +def test_signup_with_allowed_group_type(client, member_token, membered_user, nollning_event, mentor_group): + """A group whose type is in mentor_group_types is accepted.""" + response = client.post( + f"/event-signup/{nollning_event['id']}", + json={"user_id": membered_user.id, "group_name": mentor_group.name}, + headers=auth_headers(member_token), + ) + + assert response.status_code in (200, 201), response.text + assert response.json()["group_name"] == mentor_group.name + + +def test_signup_with_disallowed_group_type(client, member_token, membered_user, nollning_event, mission_group): + """A group whose type is not in mentor_group_types is rejected.""" + response = client.post( + f"/event-signup/{nollning_event['id']}", + json={"user_id": membered_user.id, "group_name": mission_group.name}, + headers=auth_headers(member_token), + ) + + assert response.status_code == 403 + + +def test_signup_with_group_the_user_is_not_in(client, member_token, membered_user, nollning_event, mentor_group): + """A group name the user isn't a member of is rejected, even if the type is allowed.""" + response = client.post( + f"/event-signup/{nollning_event['id']}", + json={"user_id": membered_user.id, "group_name": "Något helt annat"}, + headers=auth_headers(member_token), + ) + + assert response.status_code == 403 + + +def test_signup_without_group(client, member_token, membered_user, nollning_event, mission_group): + """Signing up without picking a group is not restricted by the group types.""" + response = client.post( + f"/event-signup/{nollning_event['id']}", + json={"user_id": membered_user.id}, + headers=auth_headers(member_token), + ) + + assert response.status_code in (200, 201), response.text + assert response.json()["group_name"] is None + + +def test_admin_can_sign_up_user_with_disallowed_group( + client, admin_token, membered_user, nollning_event, mission_group +): + """Someone who may manage events is not restricted by the group types.""" + response = client.post( + f"/event-signup/{nollning_event['id']}", + json={"user_id": membered_user.id, "group_name": mission_group.name}, + headers=auth_headers(admin_token), + ) + + assert response.status_code in (200, 201), response.text + assert response.json()["group_name"] == mission_group.name + + +@pytest.mark.parametrize("allow_other_mentors", [True, False]) +def test_signup_as_mentor_of_group_with_other_type( + client, + admin_token, + admin_council_id, + member_token, + membered_user, + db_session, + allow_other_mentors, +): + """A mentor may sign up with a group of a disallowed type only if the event allows it.""" + group = add_user_to_group(db_session, membered_user, "Uppdraget", "Mission", "Mentor") + data = event_data_factory( + council_id=admin_council_id, + is_nollning_event=True, + mentor_group_types=["Mentor"], + allow_other_mentors=allow_other_mentors, + ) + event = client.post("/events/", json=data, headers=auth_headers(admin_token)).json() + + response = client.post( + f"/event-signup/{event['id']}", + json={"user_id": membered_user.id, "group_name": group.name}, + headers=auth_headers(member_token), + ) + + if allow_other_mentors: + assert response.status_code in (200, 201), response.text + else: + assert response.status_code == 403 + + +def test_non_nollning_event_ignores_group_types(client, member_token, membered_user, event, mission_group): + """Group types only restrict nollning events.""" + response = client.post( + f"/event-signup/{event['id']}", + json={"user_id": membered_user.id, "group_name": mission_group.name}, + headers=auth_headers(member_token), + ) + + assert response.status_code in (200, 201), response.text + + +def test_update_signup_without_group_name_is_allowed(client, member_token, membered_user, nollning_event, mentor_group): + """ + Regression: patching some other field of a signup on a nollning event used to be + rejected with 403, because an omitted group_name was checked as if it were a group + the user isn't in. + """ + signup = client.post( + f"/event-signup/{nollning_event['id']}", + json={"user_id": membered_user.id, "group_name": mentor_group.name}, + headers=auth_headers(member_token), + ) + assert signup.status_code in (200, 201), signup.text + + response = client.patch( + f"/event-signup/{nollning_event['id']}", json={"priority": "Nolla"}, headers=auth_headers(member_token) + ) + + assert response.status_code == 200, response.text + assert response.json()["priority"] == "Nolla" + + +def test_update_signup_to_disallowed_group( + client, member_token, membered_user, nollning_event, mentor_group, mission_group +): + """Switching to a group of a disallowed type is rejected, and nothing else is changed.""" + signup = client.post( + f"/event-signup/{nollning_event['id']}", + json={"user_id": membered_user.id, "group_name": mentor_group.name}, + headers=auth_headers(member_token), + ) + assert signup.status_code in (200, 201), signup.text + + response = client.patch( + f"/event-signup/{nollning_event['id']}", + json={"group_name": mission_group.name, "priority": "Nolla"}, + headers=auth_headers(member_token), + ) + + assert response.status_code == 403 + signup_after = client.get( + f"/event-signup/me-signup/{nollning_event['id']}", headers=auth_headers(member_token) + ).json() + assert signup_after["group_name"] == mentor_group.name + assert signup_after["priority"] != "Nolla" diff --git a/tests/test_events.py b/tests/test_events.py new file mode 100644 index 00000000..c1523a13 --- /dev/null +++ b/tests/test_events.py @@ -0,0 +1,157 @@ +# type: ignore +import pytest +from .basic_factories import add_user_to_group, auth_headers, event_data_factory + + +class TestCreateEvent: + """Test POST /events/ endpoint""" + + def test_create_event_success(self, client, admin_token, admin_council_id): + """Admin can create an event and gets the stored values back.""" + data = event_data_factory(council_id=admin_council_id) + + response = client.post("/events/", json=data, headers=auth_headers(admin_token)) + + assert response.status_code in (200, 201), response.text + event = response.json() + assert event["title_sv"] == data["title_sv"] + assert event["council_id"] == admin_council_id + assert event["price"] == data["price"] + assert event["signup_count"] == 0 + + def test_create_nollning_event_with_group_types(self, client, admin_token, admin_council_id): + """The nollning group settings are persisted as given.""" + data = event_data_factory( + council_id=admin_council_id, + is_nollning_event=True, + mentor_group_types=["Mentor", "Mission"], + allow_other_mentors=True, + ) + + response = client.post("/events/", json=data, headers=auth_headers(admin_token)) + + assert response.status_code in (200, 201), response.text + event = response.json() + assert event["is_nollning_event"] is True + assert event["mentor_group_types"] == ["Mentor", "Mission"] + assert event["allow_other_mentors"] is True + + def test_create_event_negative_price(self, client, admin_token, admin_council_id): + """Negative prices are rejected.""" + data = event_data_factory(council_id=admin_council_id, price=-1) + + response = client.post("/events/", json=data, headers=auth_headers(admin_token)) + + assert response.status_code == 400 + + def test_create_event_ends_before_starts(self, client, admin_token, admin_council_id): + """An event cannot end before it starts.""" + default_data = event_data_factory() + data = event_data_factory( + council_id=admin_council_id, starts_at=default_data["ends_at"], ends_at=default_data["starts_at"] + ) + + response = client.post("/events/", json=data, headers=auth_headers(admin_token)) + + assert response.status_code == 400 + + @pytest.mark.parametrize("token_fixture", ["member_token", "non_member_token"]) + def test_create_event_forbidden(self, client, request, admin_council_id, token_fixture): + """Members and non-members cannot create events.""" + token = request.getfixturevalue(token_fixture) + data = event_data_factory(council_id=admin_council_id) + + response = client.post("/events/", json=data, headers=auth_headers(token)) + + assert response.status_code == 403 + + def test_create_event_unauthenticated(self, client, admin_council_id): + """Unauthenticated requests get 401.""" + response = client.post("/events/", json=event_data_factory(council_id=admin_council_id)) + + assert response.status_code == 401 + + +class TestGetEvents: + """Test GET /events/ and GET /events/{eventId} endpoints""" + + def test_get_all_events(self, client, event): + """Created events are listed.""" + response = client.get("/events/") + + assert response.status_code == 200 + assert event["id"] in [listed["id"] for listed in response.json()] + + def test_get_single_event(self, client, event): + """A single event can be fetched by id.""" + response = client.get(f"/events/{event['id']}") + + assert response.status_code == 200 + assert response.json()["title_en"] == event["title_en"] + + +class TestUpdateEvent: + """Test PATCH /events/{event_id} endpoint""" + + def test_update_event_success(self, client, admin_token, event): + """Admin can patch a subset of the fields, leaving the rest alone.""" + response = client.patch( + f"/events/{event['id']}", json={"title_sv": "Nytt namn", "price": 100}, headers=auth_headers(admin_token) + ) + + assert response.status_code == 200 + assert response.json()["title_sv"] == "Nytt namn" + assert response.json()["price"] == 100 + assert response.json()["title_en"] == event["title_en"] + + def test_update_event_group_settings(self, client, admin_token, nollning_event): + """The nollning group settings can be changed after creation.""" + response = client.patch( + f"/events/{nollning_event['id']}", + json={"mentor_group_types": ["Mission"], "allow_other_mentors": True}, # Mentor before + headers=auth_headers(admin_token), + ) + + assert response.status_code == 200 + assert response.json()["mentor_group_types"] == ["Mission"] + assert response.json()["allow_other_mentors"] is True + + def test_update_event_lottery(self, client, admin_token, event): + """Lottery can be toggled through the update endpoint.""" + assert event["lottery"] is False + + response = client.patch(f"/events/{event['id']}", json={"lottery": True}, headers=auth_headers(admin_token)) + + assert response.status_code == 200 + assert response.json()["lottery"] is True + + def test_update_event_negative_price(self, client, admin_token, event): + """Negative prices are rejected on update too.""" + response = client.patch(f"/events/{event['id']}", json={"price": -5}, headers=auth_headers(admin_token)) + + assert response.status_code == 400 + + def test_update_event_forbidden(self, client, member_token, event): + """Members cannot update events.""" + response = client.patch( + f"/events/{event['id']}", json={"title_sv": "Kapad"}, headers=auth_headers(member_token) + ) + + assert response.status_code == 403 + + +class TestDeleteEvent: + """Test DELETE /events/{event_id} endpoint""" + + def test_delete_event_success(self, client, admin_token, event): + """Admin can delete an event, after which it is gone.""" + response = client.delete(f"/events/{event['id']}", headers=auth_headers(admin_token)) + + assert response.status_code == 200 + assert client.get(f"/events/{event['id']}").status_code == 404 + + def test_delete_event_forbidden(self, client, member_token, event): + """Members cannot delete events.""" + response = client.delete(f"/events/{event['id']}", headers=auth_headers(member_token)) + + assert response.status_code == 403