Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion api_schemas/council_schema.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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):
Expand All @@ -18,16 +19,19 @@ class CouncilRead(BaseSchema):
events: list[EventRead]
description_sv: str | None
description_en: str | None
contact_post: SimplePostRead | None


class CouncilUpdate(BaseSchema):
name_sv: str | None = None
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
8 changes: 8 additions & 0 deletions api_schemas/post_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 13 additions & 2 deletions db_models/council_model.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)

Expand All @@ -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
2 changes: 1 addition & 1 deletion db_models/post_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
26 changes: 24 additions & 2 deletions routes/council_router.py
Original file line number Diff line number Diff line change
@@ -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()


Expand All @@ -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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is contact_post_id from the CouncilCreate schema ever used?

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,
Expand All @@ -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)
Expand All @@ -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()
Expand Down
8 changes: 7 additions & 1 deletion routes/event_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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


Expand Down
88 changes: 88 additions & 0 deletions tests/test_councils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading