diff --git a/api_schemas/council_schema.py b/api_schemas/council_schema.py index 8e666c24..732e3e41 100644 --- a/api_schemas/council_schema.py +++ b/api_schemas/council_schema.py @@ -1,5 +1,5 @@ from api_schemas.base_schema import BaseSchema -from api_schemas.post_schemas import PostRead +from api_schemas.post_schemas import PostRead, SimplePostRead from api_schemas.event_schemas import EventRead @@ -8,6 +8,7 @@ class CouncilCreate(BaseSchema): name_en: str description_sv: str | None = None description_en: str | None = None + contact_post_id: int | None = None class CouncilRead(BaseSchema): @@ -18,6 +19,7 @@ class CouncilRead(BaseSchema): events: list[EventRead] description_sv: str | None description_en: str | None + contact_post: SimplePostRead | None class CouncilUpdate(BaseSchema): @@ -25,9 +27,11 @@ class CouncilUpdate(BaseSchema): name_en: str | None = None description_sv: str | None = None description_en: str | None = None + contact_post_id: int | None = None class SimpleCouncilRead(BaseSchema): id: int name_sv: str name_en: str + contact_post: SimplePostRead | None diff --git a/api_schemas/post_schemas.py b/api_schemas/post_schemas.py index 40215665..d1f56155 100644 --- a/api_schemas/post_schemas.py +++ b/api_schemas/post_schemas.py @@ -8,6 +8,14 @@ class _PostPermissionRead(BaseSchema): target: str +class SimplePostRead(BaseSchema): + id: int + name_sv: str + name_en: str + council_id: int + email: str + + class PostRead(BaseSchema): id: int name_sv: str diff --git a/db_models/council_model.py b/db_models/council_model.py index a2d7da15..b516a5ac 100644 --- a/db_models/council_model.py +++ b/db_models/council_model.py @@ -1,6 +1,6 @@ from typing import TYPE_CHECKING, Optional -from sqlalchemy import String +from sqlalchemy import String, ForeignKey from db_models.room_booking_model import RoomBooking_DB from .base_model import BaseModel_DB @@ -22,7 +22,9 @@ class Council_DB(BaseModel_DB): name_en: Mapped[str] = mapped_column(String(MAX_COUNCIL_NAME), unique=True) - posts: Mapped[list["Post_DB"]] = relationship(back_populates="council", init=False) + posts: Mapped[list["Post_DB"]] = relationship( + back_populates="council", foreign_keys="Post_DB.council_id", init=False + ) events: Mapped[list["Event_DB"]] = relationship(back_populates="council", cascade="all, delete-orphan", init=False) @@ -38,4 +40,13 @@ class Council_DB(BaseModel_DB): description_en: Mapped[Optional[str]] = mapped_column(String(MAX_COUNCIL_DESC)) + contact_post_id: Mapped[Optional[int]] = mapped_column( + ForeignKey("post_table.id", use_alter=True, ondelete="SET NULL", name="council_contact_post_id_fkey"), + default=None, + ) + + contact_post: Mapped[Optional["Post_DB"]] = relationship( + foreign_keys=[contact_post_id], post_update=True, init=False + ) + pass diff --git a/db_models/post_model.py b/db_models/post_model.py index 40348d8d..ef074aca 100644 --- a/db_models/post_model.py +++ b/db_models/post_model.py @@ -32,7 +32,7 @@ class Post_DB(BaseModel_DB): name_en: Mapped[str] = mapped_column(String(MAX_POST_NAME)) council_id: Mapped[int] = mapped_column(ForeignKey("council_table.id")) - council: Mapped["Council_DB"] = relationship(back_populates="posts", init=False) + council: Mapped["Council_DB"] = relationship(back_populates="posts", foreign_keys=[council_id], init=False) elected_at_semester: Mapped[Optional[POST_ELECTION_SEMESTERS]] = mapped_column(default=None) elected_by: Mapped[Optional[ELECTION_ELECTORS]] = mapped_column(default=None) diff --git a/routes/council_router.py b/routes/council_router.py index 6dc4623e..8a0f6197 100644 --- a/routes/council_router.py +++ b/routes/council_router.py @@ -1,13 +1,14 @@ from typing import Annotated from fastapi import APIRouter, HTTPException, status +from sqlalchemy.orm import selectinload from api_schemas.council_schema import CouncilCreate, CouncilRead, CouncilUpdate from db_models.council_model import Council_DB from user.permission import Permission from database import DB_dependency from db_models.council_model import Council_DB +from db_models.post_model import Post_DB from db_models.user_model import User_DB - council_router = APIRouter() @@ -16,6 +17,10 @@ def create_council(data: CouncilCreate, db: DB_dependency): council = db.query(Council_DB).filter_by(name_sv=data.name_sv).one_or_none() if council is not None: raise HTTPException(status.HTTP_400_BAD_REQUEST, "Council already exists") + if data.contact_post_id is not None: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, "Contact post must belong to the council, so it cannot be set on creation" + ) council = Council_DB( name_sv=data.name_sv, description_sv=data.description_sv, @@ -29,7 +34,11 @@ def create_council(data: CouncilCreate, db: DB_dependency): @council_router.get("/", response_model=list[CouncilRead]) def get_all_councils(current_user: Annotated[User_DB, Permission.member()], db: DB_dependency): - return db.query(Council_DB).all() + return ( + db.query(Council_DB) + .options(selectinload(Council_DB.contact_post), selectinload(Council_DB.events)) + .all() + ) @council_router.get("/{council_id}", response_model=CouncilRead) @@ -49,7 +58,20 @@ def update_council(council_id: int, data: CouncilUpdate, db: DB_dependency): if council is None: raise HTTPException(404, detail="Council not found") + if "contact_post_id" in data.model_fields_set: + if data.contact_post_id is None: + council.contact_post = None + else: + post = db.query(Post_DB).filter_by(id=data.contact_post_id).one_or_none() + if post is None: + raise HTTPException(404, detail="Post not found") + if post.council_id != council.id: + raise HTTPException(400, detail="Post does not belong to this council") + council.contact_post = post + for var, value in vars(data).items(): + if var == "contact_post_id": + continue setattr(council, var, value) if value is not None else None db.commit() diff --git a/routes/event_router.py b/routes/event_router.py index f134d5f9..d80074e7 100644 --- a/routes/event_router.py +++ b/routes/event_router.py @@ -2,12 +2,14 @@ import os from fastapi import APIRouter, File, HTTPException, Response, UploadFile, status from fastapi.responses import FileResponse +from sqlalchemy.orm import selectinload from psycopg import IntegrityError from api_schemas.csv_schemas.event_user_csv_schema import EventUserCsvSchema from api_schemas.event_signup_schemas import EventSignupRead from api_schemas.tag_schema import EventTagRead from database import DB_dependency from db_models.event_model import Event_DB +from db_models.council_model import Council_DB from api_schemas.event_schemas import AddEventTag, EventCreate, EventRead, EventUpdate from db_models.event_user_model import EventUser_DB from db_models.user_model import User_DB @@ -26,7 +28,11 @@ @event_router.get("/", response_model=list[EventRead]) def get_all_events(db: DB_dependency): - events = db.query(Event_DB).all() + events = ( + db.query(Event_DB) + .options(selectinload(Event_DB.council).selectinload(Council_DB.contact_post)) + .all() + ) return events diff --git a/tests/test_councils.py b/tests/test_councils.py index e29d5d8b..fe155e8a 100644 --- a/tests/test_councils.py +++ b/tests/test_councils.py @@ -291,3 +291,91 @@ def test_update_council_none_values_ignored(self, client, admin_token, db_sessio assert data["description_sv"] == "Original Description" # Should remain unchanged assert data["name_en"] == "Original Name EN" # Should remain unchanged assert data["description_en"] == "Updated Description EN" + + +class TestCouncilContactPost: + """Test the contact_post_id foreign key on councils""" + + @pytest.fixture + def post(self, db_session): + """A post in a freshly created council.""" + from db_models.council_model import Council_DB + from db_models.post_model import Post_DB + + council = Council_DB(**council_data_factory()) + db_session.add(council) + db_session.commit() + post = Post_DB(name_sv="Ordförande", name_en="Chairperson", council_id=council.id) + db_session.add(post) + db_session.commit() + return post + + @pytest.fixture + def council(self, db_session, post): + """A council whose contact post is already set.""" + post.council.contact_post = post + db_session.commit() + return post.council + + def test_set_contact_post(self, client, admin_token, post): + """Contact post can be set and is returned when reading the council.""" + response = update_council(client, post.council_id, admin_token, contact_post_id=post.id) + + assert response.status_code == 200 + assert response.json()["contact_post"]["id"] == post.id + assert get_council(client, post.council_id, admin_token).json()["contact_post"]["name_en"] == "Chairperson" + + def test_clear_contact_post_with_explicit_null(self, client, admin_token, db_session, council): + """Explicitly sending null clears the contact post, unlike other fields.""" + response = update_council(client, council.id, admin_token, contact_post_id=None) + + assert response.status_code == 200 + assert response.json()["contact_post"] is None + db_session.refresh(council) + assert council.contact_post_id is None + + def test_omitting_contact_post_leaves_it_untouched(self, client, admin_token, council): + """A patch that does not mention contact_post_id must not clear it.""" + response = update_council(client, council.id, admin_token, name_en="Renamed Council") + + assert response.status_code == 200 + assert response.json()["name_en"] == "Renamed Council" + assert response.json()["contact_post"]["id"] == council.contact_post_id + + def test_set_contact_post_to_missing_post(self, client, admin_token, council): + """An unknown post id is a 404, not a foreign key violation.""" + response = update_council(client, council.id, admin_token, contact_post_id=99999) + + assert response.status_code == 404 + assert "Post not found" in response.json()["detail"] + + def test_create_council_with_contact_post_is_rejected(self, client, admin_token): + """A new council owns no posts yet, so a contact post cannot be set on creation.""" + response = create_council(client, admin_token, name_sv="Bad Contact", contact_post_id=99999) + + assert response.status_code == 400 + assert "must belong to the council" in response.json()["detail"] + + def test_set_contact_post_from_another_council(self, client, admin_token, post): + """A post owned by a different council cannot be used as contact post.""" + other = create_council(client, admin_token, name_sv="Annat utskott", name_en="Other Council").json() + response = update_council(client, other["id"], admin_token, contact_post_id=post.id) + + assert response.status_code == 400 + assert "does not belong to this council" in response.json()["detail"] + + def test_deleting_contact_post_keeps_council(self, client, admin_token, db_session, council): + """Deleting the contact post must clear the reference, not delete the council.""" + from db_models.council_model import Council_DB + from db_models.post_model import Post_DB + + council_id, post_id = council.id, council.contact_post_id + + response = client.delete(f"/posts/{post_id}", headers=auth_headers(admin_token)) + assert response.status_code == 204 + + db_session.expire_all() + assert db_session.query(Post_DB).filter_by(id=post_id).one_or_none() is None + surviving = db_session.query(Council_DB).filter_by(id=council_id).one_or_none() + assert surviving is not None, "council must survive deletion of its contact post" + assert surviving.contact_post_id is None