From 206ece740096a54710622954803bb7da519b9efb Mon Sep 17 00:00:00 2001 From: Samuel Dreyer Date: Mon, 13 Oct 2025 16:32:11 +0000 Subject: [PATCH 1/3] Working on a tool model and its routes. --- api_schemas/tool_schema.py | 23 ++++++++++++++ db_models/tool_model.py | 17 ++++++++++ helpers/constants.py | 4 +++ helpers/types.py | 1 + routes/tool_router.py | 65 ++++++++++++++++++++++++++++++++++++++ seed.py | 2 ++ 6 files changed, 112 insertions(+) create mode 100644 api_schemas/tool_schema.py create mode 100644 db_models/tool_model.py create mode 100644 routes/tool_router.py diff --git a/api_schemas/tool_schema.py b/api_schemas/tool_schema.py new file mode 100644 index 00000000..e186e2d8 --- /dev/null +++ b/api_schemas/tool_schema.py @@ -0,0 +1,23 @@ +from api_schemas.base_schema import BaseSchema + + +class ToolCreate(BaseSchema): + name_sv: str + name_en: str + description_sv: str | None = None + description_en: str | None = None + + +class ToolRead(BaseSchema): + id: int + name_sv: str + name_en: str + description_sv: str | None + description_en: str | None + + +class ToolUpdate(BaseSchema): + name_sv: str | None = None + name_en: str | None = None + description_sv: str | None = None + description_en: str | None = None diff --git a/db_models/tool_model.py b/db_models/tool_model.py new file mode 100644 index 00000000..ce800896 --- /dev/null +++ b/db_models/tool_model.py @@ -0,0 +1,17 @@ +from helpers.constants import MAX_TOOL_NAME, MAX_TOOL_DESC +from .base_model import BaseModel_DB +from sqlalchemy.orm import mapped_column, Mapped +from typing import Optional +from sqlalchemy import String + + +class Tool_DB(BaseModel_DB): + __tablename__ = "tool_table" + + id: Mapped[int] = mapped_column(primary_key=True, init=False) + name_sv: Mapped[str] = mapped_column(String(MAX_TOOL_NAME)) + name_en: Mapped[str] = mapped_column(String(MAX_TOOL_NAME)) + description_sv: Mapped[Optional[str]] = mapped_column(String(MAX_TOOL_DESC), default=None) + description_en: Mapped[Optional[str]] = mapped_column(String(MAX_TOOL_DESC), default=None) + + pass diff --git a/helpers/constants.py b/helpers/constants.py index f86b4d1d..a045e2a1 100644 --- a/helpers/constants.py +++ b/helpers/constants.py @@ -95,3 +95,7 @@ # Event User DEFAULT_USER_PRIORITY = "Övrigt" + +# Tool booking +MAX_TOOL_NAME = 100 +MAX_TOOL_DESC = 1000 diff --git a/helpers/types.py b/helpers/types.py index add16770..98d11295 100644 --- a/helpers/types.py +++ b/helpers/types.py @@ -58,6 +58,7 @@ def force_utc(date: datetime): "RoomBookings", "Moosegame", "MailAlias", + "Tools", ] # This is a little ridiculous now, but if we have many actions, this is a neat system. diff --git a/routes/tool_router.py b/routes/tool_router.py new file mode 100644 index 00000000..59fadad6 --- /dev/null +++ b/routes/tool_router.py @@ -0,0 +1,65 @@ +from typing import Annotated +from fastapi import APIRouter, HTTPException, status +from api_schemas.tool_schema import ToolCreate, ToolRead, ToolUpdate +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.user_model import User_DB + + +tool_router = APIRouter() + + +@tool_router.post("/", response_model=ToolRead, dependencies=[Permission.require("manage", "Tools")]) +def create_council(data: ToolCreate, 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") + council = Council_DB( + name_sv=data.name_sv, + description_sv=data.description_sv, + name_en=data.name_en, + description_en=data.description_en, + ) + db.add(council) + db.commit() + return council + + +@tool_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() + + +@tool_router.get("/{council_id}", response_model=CouncilRead) +def get_council(current_user: Annotated[User_DB, Permission.member()], council_id: int, db: DB_dependency): + council = db.query(Council_DB).filter_by(id=council_id).one_or_none() + if council is None: + raise HTTPException(status.HTTP_404_NOT_FOUND) + return council + + +@tool_router.patch( + "/update_council/{council_id}", response_model=CouncilRead, dependencies=[Permission.require("manage", "Council")] +) +def update_council(council_id: int, data: CouncilUpdate, db: DB_dependency): + + council = db.query(Council_DB).filter_by(id=council_id).one_or_none() + if council is None: + raise HTTPException(404, detail="Council not found") + + for var, value in vars(data).items(): + setattr(council, var, value) if value is not None else None + + db.commit() + + return council + + +@tool_router.delete("/{council_id}", response_model=CouncilRead, dependencies=[Permission.require("super", "Council")]) +def delete_council(council_id: int, db: DB_dependency): + council = db.query(Council_DB).filter_by(id=council_id).one_or_none() + db.delete(council) + db.commit() + return council diff --git a/seed.py b/seed.py index 5621928b..37b63e0c 100644 --- a/seed.py +++ b/seed.py @@ -219,6 +219,8 @@ def seed_permissions(db: Session, posts: list[Post_DB]): Permission(action="view", target="Document", posts=["Buggmästare"]), Permission(action="manage", target="Moosegame", posts=["Buggmästare"]), Permission(action="manage", target="UserPost", posts=["Buggmästare"]), + Permission(action="manage", target="Tools", posts=["Buggmästare"]), + Permission(action="view", target="Tools", posts=["Buggmästare"]), ] [ From dd1b998b786c2f18222136ed8c05f0ef4288ad1f Mon Sep 17 00:00:00 2001 From: Samuel Dreyer Date: Mon, 8 Dec 2025 16:53:15 +0000 Subject: [PATCH 2/3] Made models, routes and schemas for tools. Also for tool bookings. I have tested it as much as possible. --- api_schemas/tool_booking_schema.py | 32 +++++ api_schemas/tool_schema.py | 24 +++- db_models/tool_booking_model.py | 31 +++++ db_models/tool_model.py | 18 ++- db_models/user_model.py | 6 + helpers/constants.py | 1 + helpers/types.py | 1 + routes/__init__.py | 6 + routes/tool_booking_router.py | 183 +++++++++++++++++++++++++++++ routes/tool_router.py | 88 ++++++++------ seed.py | 2 + services/tool_booking_service.py | 18 +++ 12 files changed, 368 insertions(+), 42 deletions(-) create mode 100644 api_schemas/tool_booking_schema.py create mode 100644 db_models/tool_booking_model.py create mode 100644 routes/tool_booking_router.py create mode 100644 services/tool_booking_service.py diff --git a/api_schemas/tool_booking_schema.py b/api_schemas/tool_booking_schema.py new file mode 100644 index 00000000..0c467b21 --- /dev/null +++ b/api_schemas/tool_booking_schema.py @@ -0,0 +1,32 @@ +from typing import Annotated +from api_schemas.tool_schema import SimpleToolRead +from api_schemas.user_schemas import SimpleUserRead +from helpers.types import datetime_utc +from pydantic import StringConstraints +from api_schemas.base_schema import BaseSchema +from helpers.constants import MAX_TOOL_BOOKING_DESC + + +class ToolBookingCreate(BaseSchema): + tool_id: int + amount: int + start_time: datetime_utc + end_time: datetime_utc + description: Annotated[str, StringConstraints(max_length=MAX_TOOL_BOOKING_DESC)] + + +class ToolBookingRead(BaseSchema): + id: int + tool: SimpleToolRead + amount: int + user: SimpleUserRead + start_time: datetime_utc + end_time: datetime_utc + description: str + + +class ToolBookingUpdate(BaseSchema): + amount: int | None = None + start_time: datetime_utc | None = None + end_time: datetime_utc | None = None + description: Annotated[str, StringConstraints(max_length=MAX_TOOL_BOOKING_DESC)] | None = None diff --git a/api_schemas/tool_schema.py b/api_schemas/tool_schema.py index e186e2d8..552d5545 100644 --- a/api_schemas/tool_schema.py +++ b/api_schemas/tool_schema.py @@ -1,23 +1,35 @@ from api_schemas.base_schema import BaseSchema +from typing import Annotated +from pydantic import StringConstraints + +from helpers.constants import MAX_TOOL_DESC class ToolCreate(BaseSchema): name_sv: str name_en: str - description_sv: str | None = None - description_en: str | None = None + amount: int + description_sv: Annotated[str, StringConstraints(max_length=MAX_TOOL_DESC)] | None = None + description_en: Annotated[str, StringConstraints(max_length=MAX_TOOL_DESC)] | None = None class ToolRead(BaseSchema): id: int name_sv: str name_en: str + amount: int description_sv: str | None description_en: str | None class ToolUpdate(BaseSchema): - name_sv: str | None = None - name_en: str | None = None - description_sv: str | None = None - description_en: str | None = None + name_sv: str + name_en: str + amount: int + description_sv: Annotated[str, StringConstraints(max_length=MAX_TOOL_DESC)] | None = None + description_en: Annotated[str, StringConstraints(max_length=MAX_TOOL_DESC)] | None = None + + +class SimpleToolRead(BaseSchema): + id: int + amount: int diff --git a/db_models/tool_booking_model.py b/db_models/tool_booking_model.py new file mode 100644 index 00000000..3db5f4c0 --- /dev/null +++ b/db_models/tool_booking_model.py @@ -0,0 +1,31 @@ +from helpers.constants import MAX_TOOL_BOOKING_DESC +from .base_model import BaseModel_DB +from sqlalchemy.orm import mapped_column, Mapped, relationship +from typing import TYPE_CHECKING, Optional +from sqlalchemy import ForeignKey, String +from helpers.types import datetime_utc + +if TYPE_CHECKING: + from .user_model import User_DB + from .tool_model import Tool_DB + + +class ToolBooking_DB(BaseModel_DB): + __tablename__ = "tool_booking_table" + + id: Mapped[int] = mapped_column(primary_key=True, init=False) + + amount: Mapped[int] = mapped_column() + + start_time: Mapped[datetime_utc] = mapped_column() + end_time: Mapped[datetime_utc] = mapped_column() + + tool_id: Mapped[int] = mapped_column(ForeignKey("tool_table.id")) + tool: Mapped["Tool_DB"] = relationship(back_populates="bookings", init=False) + + user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("user_table.id")) + user: Mapped[Optional["User_DB"]] = relationship(back_populates="tool_bookings", init=False) + + description: Mapped[Optional[str]] = mapped_column(String(MAX_TOOL_BOOKING_DESC), default=None) + + pass diff --git a/db_models/tool_model.py b/db_models/tool_model.py index ce800896..548fc09b 100644 --- a/db_models/tool_model.py +++ b/db_models/tool_model.py @@ -1,16 +1,28 @@ from helpers.constants import MAX_TOOL_NAME, MAX_TOOL_DESC from .base_model import BaseModel_DB -from sqlalchemy.orm import mapped_column, Mapped -from typing import Optional -from sqlalchemy import String +from .tool_booking_model import ToolBooking_DB +from sqlalchemy.orm import mapped_column, Mapped, relationship +from typing import TYPE_CHECKING, Optional +from sqlalchemy import String, Integer + +if TYPE_CHECKING: + from .tool_booking_model import ToolBooking_DB class Tool_DB(BaseModel_DB): __tablename__ = "tool_table" id: Mapped[int] = mapped_column(primary_key=True, init=False) + name_sv: Mapped[str] = mapped_column(String(MAX_TOOL_NAME)) name_en: Mapped[str] = mapped_column(String(MAX_TOOL_NAME)) + + amount: Mapped[int] = mapped_column(Integer) + + bookings: Mapped[list["ToolBooking_DB"]] = relationship( + back_populates="tool", cascade="all, delete-orphan", init=False + ) + description_sv: Mapped[Optional[str]] = mapped_column(String(MAX_TOOL_DESC), default=None) description_en: Mapped[Optional[str]] = mapped_column(String(MAX_TOOL_DESC), default=None) diff --git a/db_models/user_model.py b/db_models/user_model.py index c21c836f..b01020ba 100644 --- a/db_models/user_model.py +++ b/db_models/user_model.py @@ -19,6 +19,7 @@ from helpers.types import datetime_utc from .ad_model import BookAd_DB from .car_booking_model import CarBooking_DB +from .tool_booking_model import ToolBooking_DB from helpers.types import datetime_utc if TYPE_CHECKING: @@ -29,6 +30,7 @@ from .news_model import News_DB from .ad_model import BookAd_DB from .cafe_shift_model import CafeShift_DB + from .tool_booking_model import ToolBooking_DB # called by SQLAlchemy when user.posts.append(some_post) @@ -91,6 +93,10 @@ class User_DB(BaseModel_DB, SQLAlchemyBaseUserTable[int]): cafe_shifts: Mapped[list["CafeShift_DB"]] = relationship(back_populates="user", init=False) + tool_bookings: Mapped[list["ToolBooking_DB"]] = relationship( + back_populates="user", cascade="all, delete-orphan", passive_deletes=True, init=False + ) + accesses: Mapped[list["UserDoorAccess_DB"]] = relationship( back_populates="user", cascade="all, delete-orphan", init=False ) diff --git a/helpers/constants.py b/helpers/constants.py index a045e2a1..951f09ff 100644 --- a/helpers/constants.py +++ b/helpers/constants.py @@ -99,3 +99,4 @@ # Tool booking MAX_TOOL_NAME = 100 MAX_TOOL_DESC = 1000 +MAX_TOOL_BOOKING_DESC = 1000 diff --git a/helpers/types.py b/helpers/types.py index 98d11295..65d3c63a 100644 --- a/helpers/types.py +++ b/helpers/types.py @@ -59,6 +59,7 @@ def force_utc(date: datetime): "Moosegame", "MailAlias", "Tools", + "ToolBookings", ] # This is a little ridiculous now, but if we have many actions, this is a neat system. diff --git a/routes/__init__.py b/routes/__init__.py index 872e5941..621030d0 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -30,6 +30,8 @@ from .access_serve_router import access_serve_router from .sub_election_router import sub_election_router from .nomination_router import nomination_router +from .tool_router import tool_router +from .tool_booking_router import tool_booking_router # here comes the big momma router main_router = APIRouter() @@ -91,3 +93,7 @@ main_router.include_router(sub_election_router, prefix="/sub-election", tags=["sub-elections"]) main_router.include_router(nomination_router, prefix="/nominations", tags=["nominations"]) + +main_router.include_router(tool_router, prefix="/tools", tags=["tools"]) + +main_router.include_router(tool_booking_router, prefix="/tool-booking", tags=["tool booking"]) diff --git a/routes/tool_booking_router.py b/routes/tool_booking_router.py new file mode 100644 index 00000000..b20665cf --- /dev/null +++ b/routes/tool_booking_router.py @@ -0,0 +1,183 @@ +from fastapi import APIRouter, HTTPException +from sqlalchemy import and_ +from api_schemas.tool_booking_schema import ( + ToolBookingCreate, + ToolBookingRead, + ToolBookingUpdate, +) +from database import DB_dependency +from typing import Annotated +from db_models.tool_model import Tool_DB +from user.permission import Permission +from db_models.user_model import User_DB +from db_models.tool_booking_model import ToolBooking_DB +from helpers.types import datetime_utc +from services import tool_booking_service + + +tool_booking_router = APIRouter() + + +@tool_booking_router.post( + "/", response_model=ToolBookingRead, dependencies=[Permission.require("manage", "ToolBookings")] +) +def create_tool_booking( + data: ToolBookingCreate, + current_user: Annotated[User_DB, Permission.require("manage", "ToolBookings")], + db: DB_dependency, +): + tool = db.query(Tool_DB).filter(Tool_DB.id == data.tool_id).one_or_none() + if tool is None: + raise HTTPException(404, "Tool not found") + + if data.amount <= 0: + raise HTTPException(400, "Amount must be positive") + + if data.end_time <= data.start_time: + raise HTTPException(400, "End time must be after start time") + + overlapping_bookings = ( + db.query(ToolBooking_DB) + .filter( + and_( + ToolBooking_DB.tool_id == data.tool_id, + ToolBooking_DB.start_time < data.end_time, + data.start_time < ToolBooking_DB.end_time, + ) + ) + .all() + ) + + booked_amount = tool_booking_service.max_booked(overlapping_bookings) + + if booked_amount + data.amount > tool.amount: + raise HTTPException(400, "Not enough tools available at that time") + + tool_booking = ToolBooking_DB( + tool_id=data.tool_id, + amount=data.amount, + start_time=data.start_time, + end_time=data.end_time, + user_id=current_user.id, + description=data.description, + ) + + db.add(tool_booking) + + db.commit() + + return tool_booking + + +@tool_booking_router.get( + "/get_booking/{booking_id}", + response_model=ToolBookingRead, + dependencies=[Permission.require("view", "ToolBookings")], +) +def get_tool_booking(booking_id: int, db: DB_dependency): + booking = db.query(ToolBooking_DB).filter(ToolBooking_DB.id == booking_id).one_or_none() + if booking is None: + raise HTTPException(404, "Tool booking not found") + return booking + + +@tool_booking_router.get( + "/get_all", + response_model=list[ToolBookingRead], + dependencies=[Permission.require("view", "ToolBookings")], +) +def get_all_tool_bookings(db: DB_dependency): + bookings = db.query(ToolBooking_DB).all() + return bookings + + +@tool_booking_router.get( + "/get_between_times", + response_model=list[ToolBookingRead], + dependencies=[Permission.require("view", "ToolBookings")], +) +def get_tool_bookings_between_times(db: DB_dependency, start_time: datetime_utc, end_time: datetime_utc): + bookings = ( + db.query(ToolBooking_DB) + .filter(and_(ToolBooking_DB.start_time >= start_time, ToolBooking_DB.end_time <= end_time)) + .all() + ) + return bookings + + +@tool_booking_router.get( + "/get_by_tool/", + response_model=list[ToolBookingRead], + dependencies=[Permission.require("view", "ToolBookings")], +) +def get_tool_bookings_by_tool(tool_id: int, db: DB_dependency): + tool = db.query(Tool_DB).filter(Tool_DB.id == tool_id).one_or_none() + if tool is None: + raise HTTPException(404, "Tool not found") + bookings = tool.bookings + return bookings + + +@tool_booking_router.delete( + "/{booking_id}", response_model=ToolBookingRead, dependencies=[Permission.require("manage", "ToolBookings")] +) +def remove_tool_booking( + booking_id: int, + db: DB_dependency, +): + booking = db.query(ToolBooking_DB).filter(ToolBooking_DB.id == booking_id).one_or_none() + if booking is None: + raise HTTPException(404, "Tool booking not found") + + db.delete(booking) + db.commit() + return booking + + +@tool_booking_router.patch( + "/{booking_id}", response_model=ToolBookingRead, dependencies=[Permission.require("manage", "ToolBookings")] +) +def update_tool_booking( + booking_id: int, + data: ToolBookingUpdate, + db: DB_dependency, +): + tool_booking = db.query(ToolBooking_DB).filter(ToolBooking_DB.id == booking_id).one_or_none() + if tool_booking is None: + raise HTTPException(404, "Tool booking not found") + + if data.start_time is None: + data.start_time = tool_booking.start_time + if data.end_time is None: + data.end_time = tool_booking.end_time + if data.end_time <= data.start_time: + raise HTTPException(400, "End time must be after start time") + + if data.amount is not None: + if data.amount <= 0: + raise HTTPException(400, "Amount must be positive") + + overlapping_bookings = ( + db.query(ToolBooking_DB) + .filter( + and_( + ToolBooking_DB.id != booking_id, + ToolBooking_DB.tool_id == tool_booking.tool_id, + ToolBooking_DB.start_time < data.end_time, + data.start_time < ToolBooking_DB.end_time, + ) + ) + .all() + ) + + booked_amount = tool_booking_service.max_booked(overlapping_bookings) + + if booked_amount + data.amount > tool_booking.tool.amount: + raise HTTPException(400, "Not enough tools available at that time") + + for var, value in vars(data).items(): + setattr(tool_booking, var, value) if value else None + + db.commit() + db.refresh(tool_booking) + return tool_booking diff --git a/routes/tool_router.py b/routes/tool_router.py index 59fadad6..3f5addd6 100644 --- a/routes/tool_router.py +++ b/routes/tool_router.py @@ -1,65 +1,87 @@ -from typing import Annotated from fastapi import APIRouter, HTTPException, status +from sqlalchemy import and_, or_ from api_schemas.tool_schema import ToolCreate, ToolRead, ToolUpdate -from db_models.council_model import Council_DB +from db_models.tool_model import Tool_DB from user.permission import Permission from database import DB_dependency -from db_models.council_model import Council_DB -from db_models.user_model import User_DB tool_router = APIRouter() @tool_router.post("/", response_model=ToolRead, dependencies=[Permission.require("manage", "Tools")]) -def create_council(data: ToolCreate, 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") - council = Council_DB( +def create_tool(data: ToolCreate, db: DB_dependency): + tool = db.query(Tool_DB).filter(Tool_DB.name_sv == data.name_sv).one_or_none() + if tool is not None: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "There is already a tool with that swedish name") + tool = db.query(Tool_DB).filter(Tool_DB.name_en == data.name_en).one_or_none() + if tool is not None: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "There is already a tool with that english name") + + if data.amount <= 0: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Amount must be positive") + + tool = Tool_DB( name_sv=data.name_sv, - description_sv=data.description_sv, name_en=data.name_en, + amount=data.amount, + description_sv=data.description_sv, description_en=data.description_en, ) - db.add(council) + db.add(tool) db.commit() - return council + return tool -@tool_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() +@tool_router.get("/", response_model=list[ToolRead], dependencies=[Permission.require("view", "Tools")]) +def get_all_tools(db: DB_dependency): + return db.query(Tool_DB).all() -@tool_router.get("/{council_id}", response_model=CouncilRead) -def get_council(current_user: Annotated[User_DB, Permission.member()], council_id: int, db: DB_dependency): - council = db.query(Council_DB).filter_by(id=council_id).one_or_none() - if council is None: - raise HTTPException(status.HTTP_404_NOT_FOUND) - return council +@tool_router.get("/{tool_id}", response_model=ToolRead, dependencies=[Permission.require("view", "Tools")]) +def get_tool(tool_id: int, db: DB_dependency): + tool = db.query(Tool_DB).filter_by(id=tool_id).one_or_none() + if tool is None: + raise HTTPException(404, detail="Tool not found") + return tool @tool_router.patch( - "/update_council/{council_id}", response_model=CouncilRead, dependencies=[Permission.require("manage", "Council")] + "/update_tool/{tool_id}", response_model=ToolRead, dependencies=[Permission.require("manage", "Tools")] ) -def update_council(council_id: int, data: CouncilUpdate, db: DB_dependency): +def update_tool(tool_id: int, data: ToolUpdate, db: DB_dependency): + + tool = db.query(Tool_DB).filter_by(id=tool_id).one_or_none() + if tool is None: + raise HTTPException(404, detail="Tool not found") + + conflicting_tool = ( + db.query(Tool_DB).filter(and_(Tool_DB.id != tool_id, Tool_DB.name_sv == data.name_sv)).one_or_none() + ) + if conflicting_tool is not None: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "There is another tool with that swedish name") + conflicting_tool = ( + db.query(Tool_DB).filter(and_(Tool_DB.id != tool_id, Tool_DB.name_en == data.name_en)).one_or_none() + ) + if conflicting_tool is not None: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "There is another tool with that english name") - council = db.query(Council_DB).filter_by(id=council_id).one_or_none() - if council is None: - raise HTTPException(404, detail="Council not found") + if data.amount <= 0: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Amount must be positive") for var, value in vars(data).items(): - setattr(council, var, value) if value is not None else None + setattr(tool, var, value) if value is not None else None db.commit() - return council + return tool -@tool_router.delete("/{council_id}", response_model=CouncilRead, dependencies=[Permission.require("super", "Council")]) -def delete_council(council_id: int, db: DB_dependency): - council = db.query(Council_DB).filter_by(id=council_id).one_or_none() - db.delete(council) +@tool_router.delete("/{tool_id}", response_model=ToolRead, dependencies=[Permission.require("manage", "Tools")]) +def delete_tool(tool_id: int, db: DB_dependency): + tool = db.query(Tool_DB).filter_by(id=tool_id).one_or_none() + if tool is None: + raise HTTPException(404, detail="Tool not found") + db.delete(tool) db.commit() - return council + return tool diff --git a/seed.py b/seed.py index 37b63e0c..99598725 100644 --- a/seed.py +++ b/seed.py @@ -221,6 +221,8 @@ def seed_permissions(db: Session, posts: list[Post_DB]): Permission(action="manage", target="UserPost", posts=["Buggmästare"]), Permission(action="manage", target="Tools", posts=["Buggmästare"]), Permission(action="view", target="Tools", posts=["Buggmästare"]), + Permission(action="manage", target="ToolBookings", posts=["Buggmästare"]), + Permission(action="view", target="ToolBookings", posts=["Buggmästare"]), ] [ diff --git a/services/tool_booking_service.py b/services/tool_booking_service.py new file mode 100644 index 00000000..959379d9 --- /dev/null +++ b/services/tool_booking_service.py @@ -0,0 +1,18 @@ +from db_models.tool_booking_model import ToolBooking_DB + + +# This method takes the bookings that might clash with your booking +# and returns how many tools are booked at the "booking peak". +def max_booked(bookings: list[ToolBooking_DB]): + # The idea is that the booking peak must occur when one booking has just started + # We check the amount that is booked at the beginning of each booking, and return the maximum + max_booked = 0 + for starting_booking in bookings: + booked_amount = 0 + for booking in bookings: + if booking.start_time <= starting_booking.start_time and starting_booking.start_time < booking.end_time: + booked_amount += booking.amount + if max_booked < booked_amount: + max_booked = booked_amount + + return max_booked From ea6e52893e64440634250ccfdcab78de7b4c259b Mon Sep 17 00:00:00 2001 From: Samuel Dreyer Date: Mon, 8 Dec 2025 17:18:23 +0000 Subject: [PATCH 3/3] Merge branch 'main' into tool-booking --- api_schemas/guild_meeting_schema.py | 22 +++ db_models/guild_meeting_model.py | 19 +++ helpers/constants.py | 6 + helpers/types.py | 1 + ...f246_chore_migration_auto_generated_on_.py | 41 +++++ routes/__init__.py | 3 + routes/document_router.py | 12 +- routes/guild_meeting_router.py | 59 ++++++++ routes/room_booking_router.py | 47 ++++-- routes/sub_election_router.py | 56 ++++++- seed.py | 2 + tests/basic_fixtures.py | 2 + tests/test_elections.py | 90 +++++++++++ tests/test_guild_meeting.py | 140 ++++++++++++++++++ tests/test_room_bookings.py | 38 +++++ user/token_strategy.py | 2 +- user/user_stuff.py | 4 +- 17 files changed, 523 insertions(+), 21 deletions(-) create mode 100644 api_schemas/guild_meeting_schema.py create mode 100644 db_models/guild_meeting_model.py create mode 100644 migrations/versions/355587cbf246_chore_migration_auto_generated_on_.py create mode 100644 routes/guild_meeting_router.py create mode 100644 tests/test_guild_meeting.py diff --git a/api_schemas/guild_meeting_schema.py b/api_schemas/guild_meeting_schema.py new file mode 100644 index 00000000..9c66a513 --- /dev/null +++ b/api_schemas/guild_meeting_schema.py @@ -0,0 +1,22 @@ +from api_schemas.base_schema import BaseSchema + + +class GuildMeetingRead(BaseSchema): + id: int + title_sv: str + title_en: str + date_description_sv: str + date_description_en: str + description_sv: str + description_en: str + is_active: bool + + +class GuildMeetingUpdate(BaseSchema): + title_sv: str | None = None + title_en: str | None = None + date_description_sv: str | None = None + date_description_en: str | None = None + description_sv: str | None = None + description_en: str | None = None + is_active: bool | None = None diff --git a/db_models/guild_meeting_model.py b/db_models/guild_meeting_model.py new file mode 100644 index 00000000..3a19e01f --- /dev/null +++ b/db_models/guild_meeting_model.py @@ -0,0 +1,19 @@ +from sqlalchemy import CheckConstraint +from sqlalchemy.orm import Mapped, mapped_column +from .base_model import BaseModel_DB +from helpers.constants import MAX_GUILD_MEETING_DATE_DESC, MAX_GUILD_MEETING_DESC, MAX_GUILD_MEETING_TITLE +from sqlalchemy import String + + +class GuildMeeting_DB(BaseModel_DB): + __tablename__ = "guild_meeting_table" + __table_args__ = (CheckConstraint("id = 1", name="ck_guild_meeting_singleton"),) + + id: Mapped[int] = mapped_column(primary_key=True, init=False, default=1) + title_sv: Mapped[str] = mapped_column(String(MAX_GUILD_MEETING_TITLE), default="") + title_en: Mapped[str] = mapped_column(String(MAX_GUILD_MEETING_TITLE), default="") + date_description_sv: Mapped[str] = mapped_column(String(MAX_GUILD_MEETING_DATE_DESC), default="") + date_description_en: Mapped[str] = mapped_column(String(MAX_GUILD_MEETING_DATE_DESC), default="") + description_sv: Mapped[str] = mapped_column(String(MAX_GUILD_MEETING_DESC), default="") + description_en: Mapped[str] = mapped_column(String(MAX_GUILD_MEETING_DESC), default="") + is_active: Mapped[bool] = mapped_column(default=True) diff --git a/helpers/constants.py b/helpers/constants.py index 951f09ff..afbb7bfe 100644 --- a/helpers/constants.py +++ b/helpers/constants.py @@ -80,6 +80,7 @@ MAX_DOC_TITLE = 300 MAX_DOC_CATEGORY = 100 MAX_DOC_FILE_NAME = 300 +MAX_FILE_SIZE_MB = 25 # Paths MAX_PATH_LENGTH = 256 @@ -96,6 +97,11 @@ # Event User DEFAULT_USER_PRIORITY = "Övrigt" +# Guild Meeting +MAX_GUILD_MEETING_DATE_DESC = 500 +MAX_GUILD_MEETING_DESC = 10000 +MAX_GUILD_MEETING_TITLE = 200 + # Tool booking MAX_TOOL_NAME = 100 MAX_TOOL_DESC = 1000 diff --git a/helpers/types.py b/helpers/types.py index 65d3c63a..4d80f622 100644 --- a/helpers/types.py +++ b/helpers/types.py @@ -58,6 +58,7 @@ def force_utc(date: datetime): "RoomBookings", "Moosegame", "MailAlias", + "GuildMeeting", "Tools", "ToolBookings", ] diff --git a/migrations/versions/355587cbf246_chore_migration_auto_generated_on_.py b/migrations/versions/355587cbf246_chore_migration_auto_generated_on_.py new file mode 100644 index 00000000..77faf8b1 --- /dev/null +++ b/migrations/versions/355587cbf246_chore_migration_auto_generated_on_.py @@ -0,0 +1,41 @@ +"""chore(migration): auto-generated on ecd14edba7a74d57cfa37a97e9d20721620dec8b + +Revision ID: 355587cbf246 +Revises: 30ef9e4834db +Create Date: 2025-10-22 20:30:49.236082 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '355587cbf246' +down_revision: Union[str, None] = '30ef9e4834db' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('guild_meeting_table', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title_sv', sa.String(length=200), nullable=False), + sa.Column('title_en', sa.String(length=200), nullable=False), + sa.Column('date_description_sv', sa.String(length=500), nullable=False), + sa.Column('date_description_en', sa.String(length=500), nullable=False), + sa.Column('description_sv', sa.String(length=10000), nullable=False), + sa.Column('description_en', sa.String(length=10000), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.CheckConstraint('id = 1', name='ck_guild_meeting_singleton'), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('guild_meeting_table') + # ### end Alembic commands ### diff --git a/routes/__init__.py b/routes/__init__.py index 621030d0..9109b24c 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -30,6 +30,7 @@ from .access_serve_router import access_serve_router from .sub_election_router import sub_election_router from .nomination_router import nomination_router +from .guild_meeting_router import guild_meeting_router from .tool_router import tool_router from .tool_booking_router import tool_booking_router @@ -94,6 +95,8 @@ main_router.include_router(nomination_router, prefix="/nominations", tags=["nominations"]) +main_router.include_router(guild_meeting_router, prefix="/guild-meeting", tags=["guild meeting"]) + main_router.include_router(tool_router, prefix="/tools", tags=["tools"]) main_router.include_router(tool_booking_router, prefix="/tool-booking", tags=["tool booking"]) diff --git a/routes/document_router.py b/routes/document_router.py index dd2a2354..a36f89be 100644 --- a/routes/document_router.py +++ b/routes/document_router.py @@ -5,7 +5,7 @@ from db_models.document_model import Document_DB from api_schemas.document_schema import DocumentRead, DocumentCreate, DocumentUpdate, document_create_form from db_models.user_model import User_DB -from helpers.constants import MAX_DOC_TITLE +from helpers.constants import MAX_DOC_TITLE, MAX_FILE_SIZE_MB from helpers.pdf_checker import validate_pdf_header from user.permission import Permission from fastapi import File, UploadFile, HTTPException @@ -56,6 +56,14 @@ async def upload_document( if ext not in allowed_exts: raise HTTPException(400, "File extension not allowed") + if file.size is None: + raise HTTPException(400, detail="Could not determine file size") + + if file.size > MAX_FILE_SIZE_MB * 1024 * 1024: + raise HTTPException( + 400, detail=f"File size is too large! Compress the file to smaller than {MAX_FILE_SIZE_MB}MB" + ) + file.filename = f"{sanitized_filename}{ext}" file_path = Path(f"{base_path}/{sanitized_filename}{ext}") @@ -133,6 +141,8 @@ def get_document_file( file_path = Path(f"/internal/document{base_path}/{document.file_name}") if not file_path.exists(): + # This will always trigger if we are in local dev, don't worry about it + # If we get this in production something is very wrong raise HTTPException(418, detail="Something is very cooked, contact the Webmasters pls!") response.headers["X-Accel-Redirect"] = str(file_path) diff --git a/routes/guild_meeting_router.py b/routes/guild_meeting_router.py new file mode 100644 index 00000000..e87362ff --- /dev/null +++ b/routes/guild_meeting_router.py @@ -0,0 +1,59 @@ +from fastapi import APIRouter, HTTPException +from database import DB_dependency +from db_models.guild_meeting_model import GuildMeeting_DB +from api_schemas.guild_meeting_schema import GuildMeetingRead, GuildMeetingUpdate +from user.permission import Permission +from typing import Annotated + + +guild_meeting_router = APIRouter() + + +def create_empty_guild_meeting(db: DB_dependency) -> GuildMeeting_DB: + """Create a new guild meeting record with empty/default values""" + gm = GuildMeeting_DB( + title_sv="", + title_en="", + date_description_sv="", + date_description_en="", + description_sv="", + description_en="", + is_active=True, + ) + db.add(gm) + db.commit() + db.refresh(gm) + return gm + + +def get_or_create_guild_meeting(db: DB_dependency, view_permission: bool) -> GuildMeeting_DB: + """Get the guild meeting record, creating it if it doesn't exist""" + gm = db.query(GuildMeeting_DB).filter_by(id=1).one_or_none() + if gm is None: + gm = create_empty_guild_meeting(db) + if gm.is_active or view_permission: + return gm + else: + raise HTTPException(status_code=403, detail="Guild meeting is not active.") + + +@guild_meeting_router.get("/", response_model=GuildMeetingRead) +def get_guild_meeting(db: DB_dependency, view_permission: Annotated[bool, Permission.check("view", "GuildMeeting")]): + return get_or_create_guild_meeting(db, view_permission) + + +@guild_meeting_router.patch( + "/", + response_model=GuildMeetingRead, + dependencies=[Permission.require("manage", "GuildMeeting")], +) +def update_guild_meeting(data: GuildMeetingUpdate, db: DB_dependency): + gm = db.query(GuildMeeting_DB).filter_by(id=1).one_or_none() + if gm is None: + gm = create_empty_guild_meeting(db) + + for var, value in vars(data).items(): + if value is not None: + setattr(gm, var, value) + db.commit() + return gm diff --git a/routes/room_booking_router.py b/routes/room_booking_router.py index 1f301ec7..1b66d14d 100644 --- a/routes/room_booking_router.py +++ b/routes/room_booking_router.py @@ -14,10 +14,17 @@ from helpers.types import ROOMS from services.room_booking_service import create_new_room_booking from helpers.constants import MAX_RECURSION_TIME, MAX_RECURSION_STEPS +from datetime import timezone +from zoneinfo import ZoneInfo room_router = APIRouter() +def _to_local(dt: datetime.datetime, tz: ZoneInfo) -> datetime.datetime: + # Treat naive datetimes as local; convert aware datetimes to the provided local tz + return dt.replace(tzinfo=tz) if dt.tzinfo is None else dt.astimezone(tz) + + @room_router.post( "/", response_model=list[RoomBookingRead], dependencies=[Permission.require("manage", "RoomBookings")] ) @@ -34,28 +41,38 @@ def create_room_booking( if data.recur_interval_days < 1: raise HTTPException(400, "Invalid argument for recurring interval days") - index = 0 - first_start = data.start_time + local_tz = ZoneInfo("Europe/Stockholm") + + # We start at index 1 since the first booking is already created + index = 1 + first_start_local = _to_local(data.start_time, local_tz) + first_end_local = _to_local(data.end_time, local_tz) + # This will bug if the start and end time are in different DST offsets + # but this will never happen :) + duration = first_end_local - first_start_local - delta = datetime.timedelta(days=data.recur_interval_days) - current_start = data.start_time + delta + recur_until_local = _to_local(data.recur_until, local_tz) + + while True: + next_start_local = first_start_local + datetime.timedelta(days=data.recur_interval_days * index) + if not ( + next_start_local <= recur_until_local + and next_start_local < first_start_local + datetime.timedelta(days=MAX_RECURSION_TIME) + and index < MAX_RECURSION_STEPS + ): + break + + next_end_local = next_start_local + duration - while ( - current_start <= data.recur_until - and current_start < first_start + datetime.timedelta(days=MAX_RECURSION_TIME) - and index < MAX_RECURSION_STEPS - ): booking_clone = data.model_copy( update={ - "start_time": current_start, - "end_time": data.end_time + delta, + # Convert back to UTC for storage so UTC changes over DST while local stays constant + "start_time": next_start_local.astimezone(timezone.utc), + "end_time": next_end_local.astimezone(timezone.utc), } ) booking = create_new_room_booking(booking_clone, current_user, db) booking_list.append(booking) - - delta += datetime.timedelta(days=data.recur_interval_days) - current_start = data.start_time + delta index += 1 db.add_all(booking_list) @@ -107,7 +124,7 @@ def get_room_bookings_between_times(db: DB_dependency, data: RoomBookingsBetween dependencies=[Permission.member()], ) def get_bookings_by_room(room: ROOMS, db: DB_dependency): - bookings = db.query(RoomBooking_DB).filter(RoomBooking_DB.room == room) + bookings = db.query(RoomBooking_DB).filter(RoomBooking_DB.room == room).all() return bookings diff --git a/routes/sub_election_router.py b/routes/sub_election_router.py index 39f25982..d2fecd7d 100644 --- a/routes/sub_election_router.py +++ b/routes/sub_election_router.py @@ -1,4 +1,6 @@ from fastapi import APIRouter, HTTPException, status +from db_models.candidate_model import Candidate_DB +from db_models.candidate_post_model import Candidation_DB from database import DB_dependency from db_models.sub_election_model import SubElection_DB from db_models.election_model import Election_DB @@ -186,6 +188,11 @@ def move_election_post(sub_election_id: int, data: MovePostRequest, db: DB_depen status_code=status.HTTP_400_BAD_REQUEST, detail="New sub-election is not in the same election" ) + if new_sub_election.sub_election_id == sub_election.sub_election_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="You are trying to move to the same sub-election" + ) + # Check if the post is already assigned to the new sub-election existing_post = ( db.query(ElectionPost_DB) @@ -201,14 +208,59 @@ def move_election_post(sub_election_id: int, data: MovePostRequest, db: DB_depen # Move the post to the new sub-election election_post.sub_election = new_sub_election - # Update all the candidations and candidates to point to the new sub-election + # Update all the candidations to point to the new sub-election for candidation in election_post.candidations: candidation.sub_election = new_sub_election - candidation.candidate.sub_election = new_sub_election # Update all the nominations to point to the new sub-election for nomination in election_post.nominations: nomination.sub_election = new_sub_election + candidations_to_move = list(election_post.candidations) + candidate_candidations: dict[int, list[Candidation_DB]] = {} + candidate_lookup: dict[int, Candidate_DB] = {} + for candidation in candidations_to_move: + key = candidation.candidate_id + candidate_candidations.setdefault(key, []).append(candidation) + candidate_lookup.setdefault(key, candidation.candidate) + + # Reassign candidates while keeping candidations tied to the correct sub-election + for candidate_id, candidate_candidations_to_move in candidate_candidations.items(): + candidate = candidate_lookup[candidate_id] + + # Check if a candidate from the same user already exists in the target sub-election + existing_candidate = ( + db.query(Candidate_DB) + .filter(Candidate_DB.sub_election_id == new_sub_election.sub_election_id) + .filter(Candidate_DB.user_id == candidate.user_id) + .one_or_none() + ) + + # True if all candidations are being moved + # candidate.candidations should be the same candidate mentioned + # in candidate_candidations_to_move, so if lengths match, all are being moved + moving_only_candidations = len(candidate.candidations) == len(candidate_candidations_to_move) + + if existing_candidate is None and moving_only_candidations: + # Move the candidate wholesale + candidate.sub_election = new_sub_election + target_candidate = candidate + else: + if existing_candidate is None: + target_candidate = Candidate_DB( + sub_election_id=new_sub_election.sub_election_id, + user_id=candidate.user_id, + ) + db.add(target_candidate) + db.flush() + else: + target_candidate = existing_candidate + + for candidation in candidate_candidations_to_move: + candidation.candidate = target_candidate + + if moving_only_candidations: + db.delete(candidate) + db.commit() return new_sub_election diff --git a/seed.py b/seed.py index 99598725..fb5b7f8b 100644 --- a/seed.py +++ b/seed.py @@ -219,6 +219,8 @@ def seed_permissions(db: Session, posts: list[Post_DB]): Permission(action="view", target="Document", posts=["Buggmästare"]), Permission(action="manage", target="Moosegame", posts=["Buggmästare"]), Permission(action="manage", target="UserPost", posts=["Buggmästare"]), + Permission(action="view", target="GuildMeeting", posts=["Buggmästare"]), + Permission(action="manage", target="GuildMeeting", posts=["Buggmästare"]), Permission(action="manage", target="Tools", posts=["Buggmästare"]), Permission(action="view", target="Tools", posts=["Buggmästare"]), Permission(action="manage", target="ToolBookings", posts=["Buggmästare"]), diff --git a/tests/basic_fixtures.py b/tests/basic_fixtures.py index dbe4bd35..5ff38ded 100644 --- a/tests/basic_fixtures.py +++ b/tests/basic_fixtures.py @@ -96,6 +96,8 @@ def admin_post(db_session): Permission_DB(action="manage", target="RoomBookings"), Permission_DB(action="view", target="Document"), Permission_DB(action="manage", target="Document"), + Permission_DB(action="view", target="GuildMeeting"), + Permission_DB(action="manage", target="GuildMeeting"), ] post.permissions.extend(permissions) db_session.commit() diff --git a/tests/test_elections.py b/tests/test_elections.py index acad36fb..f502a4dd 100644 --- a/tests/test_elections.py +++ b/tests/test_elections.py @@ -553,3 +553,93 @@ def test_move_election_post_retains_nominations( noms = moved_post.get("nominations", []) matching = [n for n in noms if n.get("nominee_email") == admin_user.email] assert len(matching) == 1 + + +def test_move_election_post_keeps_remaining_candidations_in_original_sub_election( + admin_token, admin_user, client, admin_post, member_post, open_election +): + # Create sub-election with two posts + resp_sub = create_sub_election( + client, + open_election.election_id, + token=admin_token, + title_sv="Source", + title_en="Source", + post_ids=[admin_post.id, member_post.id], + ) + assert resp_sub.status_code in (200, 201), resp_sub.text + sub_election = resp_sub.json() + sub_election_id = sub_election["sub_election_id"] + + # Create candidations for both posts for the same user + resp_cand_admin = create_candidation( + client, + sub_election_id=sub_election_id, + post_id=admin_post.id, + token=admin_token, + user_id=admin_user.id, + ) + assert resp_cand_admin.status_code in (200, 201), resp_cand_admin.text + + resp_cand_member = create_candidation( + client, + sub_election_id=sub_election_id, + post_id=member_post.id, + token=admin_token, + user_id=admin_user.id, + ) + assert resp_cand_member.status_code in (200, 201), resp_cand_member.text + candidate = resp_cand_member.json() + candidate_id = candidate["candidate_id"] + post_ids_before_move = {candidation["post_id"] for candidation in candidate["candidations"]} + assert post_ids_before_move == {admin_post.id, member_post.id} + + # Destination sub-election without posts + resp_dest = create_sub_election( + client, + open_election.election_id, + token=admin_token, + title_sv="Destination", + title_en="Destination", + ) + assert resp_dest.status_code in (200, 201), resp_dest.text + dest_sub_election = resp_dest.json() + dest_sub_election_id = dest_sub_election["sub_election_id"] + + # Resolve election_post identifier for the admin post + election_post_admin = next(ep for ep in sub_election["election_posts"] if ep["post_id"] == admin_post.id) + election_post_id = election_post_admin["election_post_id"] + + # Move election post to the destination sub-election + resp_move = client.patch( + f"/sub-election/{sub_election_id}/move-election-post", + json={ + "election_post_id": election_post_id, + "new_sub_election_id": dest_sub_election_id, + }, + headers=auth_headers(admin_token), + ) + assert resp_move.status_code in (200, 204), resp_move.text + + # Original sub-election should still have the candidate with the remaining post + resp_candidates_source = client.get(f"/candidate/sub-election/{sub_election_id}", headers=auth_headers(admin_token)) + assert resp_candidates_source.status_code == 200, resp_candidates_source.text + candidates_source = resp_candidates_source.json() + original_candidate = next(c for c in candidates_source if c["candidate_id"] == candidate_id) + remaining_post_ids = {candidation["post_id"] for candidation in original_candidate["candidations"]} + assert remaining_post_ids == {member_post.id} + assert all(candidation["sub_election_id"] == sub_election_id for candidation in original_candidate["candidations"]) + + # Destination sub-election should have a candidate for the moved post only + resp_candidates_dest = client.get( + f"/candidate/sub-election/{dest_sub_election_id}", headers=auth_headers(admin_token) + ) + assert resp_candidates_dest.status_code == 200, resp_candidates_dest.text + candidates_dest = resp_candidates_dest.json() + moved_candidate = next(c for c in candidates_dest if c["user_id"] == admin_user.id) + moved_post_ids = {candidation["post_id"] for candidation in moved_candidate["candidations"]} + assert moved_post_ids == {admin_post.id} + assert all( + candidation["sub_election_id"] == dest_sub_election_id for candidation in moved_candidate["candidations"] + ) + assert moved_candidate["candidate_id"] != candidate_id diff --git a/tests/test_guild_meeting.py b/tests/test_guild_meeting.py new file mode 100644 index 00000000..06d8e62a --- /dev/null +++ b/tests/test_guild_meeting.py @@ -0,0 +1,140 @@ +# type: ignore +from main import app +from .basic_factories import auth_headers + + +def test_get_guild_meeting_admin(client, admin_token): + """Test that admin can view guild meeting info""" + resp = client.get("/guild-meeting/", headers=auth_headers(admin_token)) + assert resp.status_code == 200 + data = resp.json() + assert "id" in data + assert "date_description_sv" in data + assert "date_description_en" in data + assert "description_sv" in data + assert "description_en" in data + assert data["id"] == 1 + + +def test_get_guild_meeting_non_member(client, non_member_token): + """Test that non-members can also view guild meeting info""" + resp = client.get("/guild-meeting/", headers=auth_headers(non_member_token)) + assert resp.status_code == 200 + data = resp.json() + assert "id" in data + assert "date_description_sv" in data + assert "date_description_en" in data + assert "description_sv" in data + assert "description_en" in data + assert data["id"] == 1 + + +def test_update_guild_meeting_admin(client, admin_token): + """Test that admin can update guild meeting info""" + # Update both fields + update_data = { + "date_description_sv": "Torsdag 15 januari 2030 kl 18:00", + "date_description_en": "Thursday, January 15th, 2030 at 18:00", + "description_sv": "Årsmöte med val och budgetgenomgång", + "description_en": "Annual guild meeting with elections and budget review", + } + resp = client.patch("/guild-meeting/", json=update_data, headers=auth_headers(admin_token)) + assert resp.status_code == 200 + data = resp.json() + assert data["date_description_sv"] == update_data["date_description_sv"] + assert data["date_description_en"] == update_data["date_description_en"] + assert data["description_sv"] == update_data["description_sv"] + assert data["description_en"] == update_data["description_en"] + assert data["id"] == 1 + + +def test_update_guild_meeting_empty_fields_admin(client, admin_token): + """Test that admin can clear guild meeting fields""" + update_data = {"date_description_sv": "", "date_description_en": "", "description_sv": "", "description_en": ""} + resp = client.patch("/guild-meeting/", json=update_data, headers=auth_headers(admin_token)) + assert resp.status_code == 200 + data = resp.json() + assert data["date_description_sv"] == "" + assert data["date_description_en"] == "" + assert data["description_sv"] == "" + assert data["description_en"] == "" + + +def test_update_guild_meeting_member_denied(client, member_token): + """Test that regular members cannot update guild meeting info""" + update_data = { + "date_description_sv": "Otillåtet försök", + "date_description_en": "Unauthorized update attempt", + "description_sv": "Detta ska inte fungera", + "description_en": "This should not work", + } + resp = client.patch("/guild-meeting/", json=update_data, headers=auth_headers(member_token)) + assert resp.status_code >= 400 and resp.status_code < 500 + + +def test_update_guild_meeting_non_member_denied(client, non_member_token): + """Test that non-members cannot update guild meeting info""" + update_data = { + "date_description_sv": "Otillåtet försök", + "date_description_en": "Unauthorized update attempt", + "description_sv": "Detta ska inte fungera", + "description_en": "This should not work", + } + resp = client.patch("/guild-meeting/", json=update_data, headers=auth_headers(non_member_token)) + assert resp.status_code >= 400 and resp.status_code < 500 + + +def test_guild_meeting_singleton_constraint(client, admin_token): + """Test that the guild meeting maintains singleton behavior""" + # Get the meeting multiple times to ensure it's always the same record + resp1 = client.get("/guild-meeting/", headers=auth_headers(admin_token)) + resp2 = client.get("/guild-meeting/", headers=auth_headers(admin_token)) + resp3 = client.get("/guild-meeting/", headers=auth_headers(admin_token)) + + assert resp1.status_code == 200 + assert resp2.status_code == 200 + assert resp3.status_code == 200 + + # All should return the same ID + assert resp1.json()["id"] == 1 + assert resp2.json()["id"] == 1 + assert resp3.json()["id"] == 1 + + +def test_guild_meeting_persistence_across_updates(client, admin_token): + """Test that guild meeting updates persist correctly""" + # Set initial values + initial_data = { + "date_description_sv": "Initialdatum", + "date_description_en": "Initial date", + "description_sv": "Initial beskrivning", + "description_en": "Initial description", + } + resp1 = client.patch("/guild-meeting/", json=initial_data, headers=auth_headers(admin_token)) + assert resp1.status_code == 200 + + # Update only one field + partial_update = {"date_description_sv": "Uppdaterat datum", "date_description_en": "Updated date"} + resp2 = client.patch("/guild-meeting/", json=partial_update, headers=auth_headers(admin_token)) + assert resp2.status_code == 200 + + # Verify the other fields persisted + resp3 = client.get("/guild-meeting/", headers=auth_headers(admin_token)) + data = resp3.json() + assert data["date_description_sv"] == "Uppdaterat datum" + assert data["date_description_en"] == "Updated date" + assert data["description_sv"] == "Initial beskrivning" # Should remain unchanged + assert data["description_en"] == "Initial description" # Should remain unchanged + + # Update the other fields + second_update = {"description_sv": "Uppdaterad beskrivning", "description_en": "Updated description"} + resp4 = client.patch("/guild-meeting/", json=second_update, headers=auth_headers(admin_token)) + assert resp4.status_code == 200 + + # Verify all fields are correct + resp5 = client.get("/guild-meeting/", headers=auth_headers(admin_token)) + final_data = resp5.json() + assert final_data["date_description_sv"] == "Uppdaterat datum" + assert final_data["date_description_en"] == "Updated date" + assert final_data["description_sv"] == "Uppdaterad beskrivning" + assert final_data["description_en"] == "Updated description" diff --git a/tests/test_room_bookings.py b/tests/test_room_bookings.py index a1cc9421..9014c131 100644 --- a/tests/test_room_bookings.py +++ b/tests/test_room_bookings.py @@ -2,6 +2,7 @@ from main import app from datetime import datetime, timedelta, timezone from .basic_factories import auth_headers +from zoneinfo import ZoneInfo # Most of this file was copied from the car booking tests, with modifications for room bookings. @@ -9,6 +10,11 @@ STOCKHOLM_TZ = timezone(timedelta(hours=1)) +def _to_local(dt: datetime, tz: ZoneInfo) -> datetime: + # Treat naive datetimes as local; convert aware datetimes to the provided local tz + return dt.replace(tzinfo=tz) if dt.tzinfo is None else dt.astimezone(tz) + + def stockholm_dt(year, month, day, hour, minute=0): # Create Stockholm time, then convert to UTC local = datetime(year, month, day, hour, minute, tzinfo=STOCKHOLM_TZ) @@ -263,3 +269,35 @@ def test_no_unlimited_recurring_bookings(client, admin_token, admin_council_id): assert isinstance(data, list) assert len(data) < 500 # We should never have to set a limit higher than this assert data[-1]["start_time"] < stockholm_dt(2050, 1, 22, 12).isoformat() # Last booking should be way before 3030 + + +def test_dst_booking_handling(client, admin_token, admin_council_id): + # Create a recurring booking that spans DST change + # In 2030, DST in Sweden ends on Oct 27th at 03:00 (clocks go back 1 hour) + start = stockholm_dt(2030, 10, 25, 10) # Oct 25th, 10:00 + end = stockholm_dt(2030, 10, 25, 12) # Oct 25th, 12:00 + resp = create_booking( + client, + admin_token, + start, + end, + "DST spanning booking", + council_id=admin_council_id, + room="LC", + recur_interval_days=1, + recur_until=stockholm_dt(2030, 10, 29, 12), # Recurs until Oct 29th + ) + assert resp.status_code in (200, 201) + data = resp.json() + assert isinstance(data, list) + assert len(data) == 5 + # Check that each booking is 2 hours long in local time + for booking in data: + start_local = _to_local(datetime.fromisoformat(booking["start_time"]), ZoneInfo("Europe/Stockholm")) + end_local = _to_local(datetime.fromisoformat(booking["end_time"]), ZoneInfo("Europe/Stockholm")) + assert end_local - start_local == timedelta(hours=2) + # Check that they don't all have the same UTC start time + start_times = { + _to_local(datetime.fromisoformat(booking["start_time"]), ZoneInfo("Europe/Stockholm")).hour for booking in data + } + assert len(start_times) == 1 diff --git a/user/token_strategy.py b/user/token_strategy.py index e83f4387..0647568e 100644 --- a/user/token_strategy.py +++ b/user/token_strategy.py @@ -41,7 +41,7 @@ async def get_jwt_secret() -> str: JWT_TOKEN_LIFETIME_SECONDS = 3600 * 6 if os.getenv("ENVIRONMENT") == "development" else 900 # Timeout for refresh token, user has to log in again after expiry -LOGIN_TIMEOUT = 3600 * 24 * 30 +LOGIN_TIMEOUT = 3600 * 24 * 90 # class to describe data in access token for our chosen JWT strategy diff --git a/user/user_stuff.py b/user/user_stuff.py index 37ed55f8..6a0213a8 100644 --- a/user/user_stuff.py +++ b/user/user_stuff.py @@ -24,7 +24,7 @@ cookie_name="__Secure-fsek_refresh_token", cookie_max_age=LOGIN_TIMEOUT, cookie_samesite="lax", - cookie_domain="fsektionen.se", + # cookie_domain="fsektionen.se", cookie_path="/auth", # Server path where the cookie is sent cookie_secure=True, # Secure cookie for production cookie_httponly=True, # HttpOnly to prevent JavaScript access @@ -34,7 +34,7 @@ cookie_name="_fsek_stage_refresh_token", cookie_max_age=LOGIN_TIMEOUT, cookie_samesite="lax", - cookie_domain="fsektionen.se", # Use default domain for local development + # cookie_domain="fsektionen.se", # Use default domain for local development cookie_path="/auth", cookie_secure=True, cookie_httponly=True, # HttpOnly to prevent JavaScript access