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 new file mode 100644 index 00000000..552d5545 --- /dev/null +++ b/api_schemas/tool_schema.py @@ -0,0 +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 + 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 + 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 new file mode 100644 index 00000000..548fc09b --- /dev/null +++ b/db_models/tool_model.py @@ -0,0 +1,29 @@ +from helpers.constants import MAX_TOOL_NAME, MAX_TOOL_DESC +from .base_model import BaseModel_DB +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) + + pass 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 1251a269..afbb7bfe 100644 --- a/helpers/constants.py +++ b/helpers/constants.py @@ -101,3 +101,8 @@ 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 +MAX_TOOL_BOOKING_DESC = 1000 diff --git a/helpers/types.py b/helpers/types.py index c4cbb0ac..4d80f622 100644 --- a/helpers/types.py +++ b/helpers/types.py @@ -59,6 +59,8 @@ def force_utc(date: datetime): "Moosegame", "MailAlias", "GuildMeeting", + "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 079d5b44..9109b24c 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -31,6 +31,8 @@ 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 # here comes the big momma router main_router = APIRouter() @@ -94,3 +96,7 @@ 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/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 new file mode 100644 index 00000000..3f5addd6 --- /dev/null +++ b/routes/tool_router.py @@ -0,0 +1,87 @@ +from fastapi import APIRouter, HTTPException, status +from sqlalchemy import and_, or_ +from api_schemas.tool_schema import ToolCreate, ToolRead, ToolUpdate +from db_models.tool_model import Tool_DB +from user.permission import Permission +from database import DB_dependency + + +tool_router = APIRouter() + + +@tool_router.post("/", response_model=ToolRead, dependencies=[Permission.require("manage", "Tools")]) +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, + name_en=data.name_en, + amount=data.amount, + description_sv=data.description_sv, + description_en=data.description_en, + ) + db.add(tool) + db.commit() + return tool + + +@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("/{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_tool/{tool_id}", response_model=ToolRead, dependencies=[Permission.require("manage", "Tools")] +) +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") + + if data.amount <= 0: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Amount must be positive") + + for var, value in vars(data).items(): + setattr(tool, var, value) if value is not None else None + + db.commit() + + return tool + + +@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 tool diff --git a/seed.py b/seed.py index 03efece1..fb5b7f8b 100644 --- a/seed.py +++ b/seed.py @@ -221,6 +221,10 @@ def seed_permissions(db: Session, posts: list[Post_DB]): 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"]), + 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