diff --git a/api_schemas/tool_booking_schema.py b/api_schemas/tool_booking_schema.py new file mode 100644 index 00000000..3c5d9164 --- /dev/null +++ b/api_schemas/tool_booking_schema.py @@ -0,0 +1,41 @@ +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 | None + + +class SimpleToolBookingRead(BaseSchema): + id: int + tool: SimpleToolRead + amount: int + start_time: datetime_utc + end_time: datetime_utc + description: str | None + + +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..9fe1c146 --- /dev/null +++ b/db_models/tool_booking_model.py @@ -0,0 +1,29 @@ +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", ondelete="CASCADE")) + 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) diff --git a/db_models/tool_model.py b/db_models/tool_model.py new file mode 100644 index 00000000..7a98f821 --- /dev/null +++ b/db_models/tool_model.py @@ -0,0 +1,27 @@ +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) 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 78602b55..b52fabca 100644 --- a/helpers/constants.py +++ b/helpers/constants.py @@ -106,6 +106,10 @@ 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 # Keyval, used for example for storing the links to different very important documents (e.g. reglementet) MAX_KEYVAL_KEY = 100 diff --git a/helpers/types.py b/helpers/types.py index 640a1631..cf1305f6 100644 --- a/helpers/types.py +++ b/helpers/types.py @@ -60,6 +60,8 @@ def force_utc(date: datetime): "EncloseMoose", "MailAlias", "GuildMeeting", + "Tools", + "ToolBookings", "Keyvals", ] diff --git a/routes/__init__.py b/routes/__init__.py index d6ed3559..407908cf 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -32,6 +32,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 from .keyval_router import keyval_router from .prereg_member_router import prereg_member_router @@ -100,6 +102,9 @@ 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"]) main_router.include_router(keyval_router, prefix="/keyvals", tags=["keyvals"]) main_router.include_router(prereg_member_router, prefix="/prereg-members", tags=["prereg members"]) diff --git a/routes/tool_booking_router.py b/routes/tool_booking_router.py new file mode 100644 index 00000000..fb2ce67a --- /dev/null +++ b/routes/tool_booking_router.py @@ -0,0 +1,244 @@ +from fastapi import APIRouter, HTTPException +from sqlalchemy import and_ +from api_schemas.tool_booking_schema import ( + ToolBookingCreate, + ToolBookingRead, + ToolBookingUpdate, + SimpleToolBookingRead, +) +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) +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_simple_booking/{booking_id}", + response_model=SimpleToolBookingRead, +) +def get_public_tool_booking(booking_id: int, db: DB_dependency, current_user: Annotated[User_DB, Permission.member()]): + 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_simple_all", + response_model=list[SimpleToolBookingRead], +) +def get_public_all_tool_bookings(db: DB_dependency, current_user: Annotated[User_DB, Permission.member()]): + 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 < end_time, start_time < ToolBooking_DB.end_time)) + .all() + ) + return bookings + + +@tool_booking_router.get( + "/get_simple_between_times", + response_model=list[SimpleToolBookingRead], +) +def get_public_tool_bookings_between_times( + db: DB_dependency, + start_time: datetime_utc, + end_time: datetime_utc, + current_user: Annotated[User_DB, Permission.member()], +): + bookings = ( + db.query(ToolBooking_DB) + .filter(and_(ToolBooking_DB.start_time < end_time, start_time < ToolBooking_DB.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.get( + "/get_simple_by_tool/", + response_model=list[SimpleToolBookingRead], +) +def get_public_tool_bookings_by_tool( + tool_id: int, db: DB_dependency, current_user: Annotated[User_DB, Permission.member()] +): + 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 None: + data.amount = tool_booking.amount + elif data.amount <= 0: + raise HTTPException(400, "Amount must be positive") + + if ( + data.amount != tool_booking.amount + or data.start_time != tool_booking.start_time + or data.end_time != tool_booking.end_time + ): + 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(): + # description may be explicitly set to None or "", other fields are only updated if given + if var == "description": + if "description" in data.model_fields_set: + tool_booking.description = value + else: + setattr(tool_booking, var, value) + + 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..c828de59 --- /dev/null +++ b/routes/tool_router.py @@ -0,0 +1,89 @@ +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 +from typing import Annotated +from db_models.user_model import User_DB + + +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]) +def get_all_tools(db: DB_dependency,current_user: Annotated[User_DB, Permission.member()]): + return db.query(Tool_DB).all() + + +@tool_router.get("/{tool_id}", response_model=ToolRead) +def get_tool(tool_id: int, db: DB_dependency,current_user: Annotated[User_DB, Permission.member()]): + 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 bdf61afd..cc1b8530 100644 --- a/seed.py +++ b/seed.py @@ -222,6 +222,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"]), Permission(action="manage", target="Keyvals", 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 diff --git a/tests/basic_fixtures.py b/tests/basic_fixtures.py index 639e9784..ab099222 100644 --- a/tests/basic_fixtures.py +++ b/tests/basic_fixtures.py @@ -99,6 +99,10 @@ def admin_post(db_session): Permission_DB(action="manage", target="Document"), Permission_DB(action="view", target="GuildMeeting"), Permission_DB(action="manage", target="GuildMeeting"), + Permission_DB(action="view", target="Tools"), + Permission_DB(action="manage", target="Tools"), + Permission_DB(action="view", target="ToolBookings"), + Permission_DB(action="manage", target="ToolBookings"), Permission_DB(action="view", target="Keyvals"), Permission_DB(action="manage", target="Keyvals"), ] diff --git a/tests/test_tool_bookings.py b/tests/test_tool_bookings.py new file mode 100644 index 00000000..6d801f89 --- /dev/null +++ b/tests/test_tool_bookings.py @@ -0,0 +1,397 @@ +# type: ignore +from datetime import datetime, timezone +from db_models.tool_booking_model import ToolBooking_DB +from .basic_factories import auth_headers + + +def dt(day, hour, minute=0): + return datetime(2030, 1, day, hour, minute, tzinfo=timezone.utc) + + +def create_tool(client, token, **kwargs): + body = { + "name_sv": "Borrmaskin", + "name_en": "Drill", + "amount": 3, + "description_sv": "En borrmaskin", + "description_en": "A drill", + **kwargs, + } + return client.post("/tools/", json=body, headers=auth_headers(token)) + + +def create_tool_booking(client, token, tool_id, start, end, amount=1, description="example description"): + body = { + "tool_id": tool_id, + "amount": amount, + "start_time": start.isoformat(), + "end_time": end.isoformat(), + "description": description, + } + return client.post("/tool-booking/", json=body, headers=auth_headers(token)) + + +def patch_tool_booking(client, token, booking_id, **kwargs): + return client.patch(f"/tool-booking/{booking_id}", json=kwargs, headers=auth_headers(token)) + + +# Testing tools + + +def test_admin_create_tool(client, admin_token): + resp = create_tool(client, admin_token) + assert resp.status_code in (200, 201), resp.text + data = resp.json() + assert data["name_sv"] == "Borrmaskin" + assert data["name_en"] == "Drill" + assert data["amount"] == 3 + assert data["description_en"] == "A drill" + + +def test_member_cannot_create_tool(client, member_token): + resp = create_tool(client, member_token) + assert resp.status_code == 403 + + +def test_create_tool_duplicate_names(client, admin_token): + assert create_tool(client, admin_token).status_code in (200, 201) + + resp = create_tool(client, admin_token, name_en="Other") + assert resp.status_code == 400 + resp = create_tool(client, admin_token, name_sv="Annan") + assert resp.status_code == 400 + + +def test_create_tool_nonpositive_amount(client, admin_token): + assert create_tool(client, admin_token, amount=0).status_code == 400 + assert create_tool(client, admin_token, amount=-1).status_code == 400 + + +def test_member_can_view_tools(client, admin_token, member_token): + tool_id = create_tool(client, admin_token).json()["id"] + + resp = client.get("/tools/", headers=auth_headers(member_token)) + assert resp.status_code == 200 + assert [tool["id"] for tool in resp.json()] == [tool_id] + + resp = client.get(f"/tools/{tool_id}", headers=auth_headers(member_token)) + assert resp.status_code == 200 + assert resp.json()["name_en"] == "Drill" + + +def test_unauthenticated_cannot_view_tools(client, admin_token): + create_tool(client, admin_token) + assert client.get("/tools/").status_code == 401 + + +def test_get_nonexistent_tool(client, admin_token): + assert client.get("/tools/999999", headers=auth_headers(admin_token)).status_code == 404 + + +def test_update_tool(client, admin_token): + tool_id = create_tool(client, admin_token).json()["id"] + + body = {"name_sv": "Såg", "name_en": "Saw", "amount": 5, "description_en": "A saw"} + resp = client.patch(f"/tools/update_tool/{tool_id}", json=body, headers=auth_headers(admin_token)) + assert resp.status_code == 200, resp.text + data = resp.json() + assert data["name_en"] == "Saw" + assert data["amount"] == 5 + assert data["description_en"] == "A saw" + # Not given, so unchanged + assert data["description_sv"] == "En borrmaskin" + + +def test_update_tool_conflicting_name(client, admin_token): + create_tool(client, admin_token) + other_id = create_tool(client, admin_token, name_sv="Såg", name_en="Saw").json()["id"] + + body = {"name_sv": "Borrmaskin", "name_en": "Saw", "amount": 1} + resp = client.patch(f"/tools/update_tool/{other_id}", json=body, headers=auth_headers(admin_token)) + assert resp.status_code == 400 + + body = {"name_sv": "Såg", "name_en": "Drill", "amount": 1} + resp = client.patch(f"/tools/update_tool/{other_id}", json=body, headers=auth_headers(admin_token)) + assert resp.status_code == 400 + + +def test_member_cannot_update_or_delete_tool(client, admin_token, member_token): + tool_id = create_tool(client, admin_token).json()["id"] + + body = {"name_sv": "Såg", "name_en": "Saw", "amount": 5} + resp = client.patch(f"/tools/update_tool/{tool_id}", json=body, headers=auth_headers(member_token)) + assert resp.status_code == 403 + assert client.delete(f"/tools/{tool_id}", headers=auth_headers(member_token)).status_code == 403 + + +def test_delete_tool_deletes_bookings(client, admin_token, db_session): + tool_id = create_tool(client, admin_token).json()["id"] + booking_id = create_tool_booking(client, admin_token, tool_id, dt(8, 10), dt(8, 12)).json()["id"] + + resp = client.delete(f"/tools/{tool_id}", headers=auth_headers(admin_token)) + assert resp.status_code == 200 + assert client.get(f"/tools/{tool_id}", headers=auth_headers(admin_token)).status_code == 404 + assert db_session.query(ToolBooking_DB).filter_by(id=booking_id).one_or_none() is None + + +# Test tool bookings + + +def test_admin_create_tool_booking(client, admin_token, admin_user): + tool_id = create_tool(client, admin_token).json()["id"] + + resp = create_tool_booking(client, admin_token, tool_id, dt(8, 10), dt(8, 12), amount=2) + assert resp.status_code in (200, 201), resp.text + data = resp.json() + assert data["tool"]["id"] == tool_id + assert data["amount"] == 2 + assert data["user"]["id"] == admin_user.id + assert data["description"] == "example description" + + +def test_member_cannot_create_tool_booking(client, admin_token, member_token): + tool_id = create_tool(client, admin_token).json()["id"] + resp = create_tool_booking(client, member_token, tool_id, dt(8, 10), dt(8, 12)) + assert resp.status_code == 403 + + +def test_create_tool_booking_invalid(client, admin_token): + tool_id = create_tool(client, admin_token).json()["id"] + + # Nonexistent tool + assert create_tool_booking(client, admin_token, 999999, dt(8, 10), dt(8, 12)).status_code == 404 + # Nonpositive amount + assert create_tool_booking(client, admin_token, tool_id, dt(8, 10), dt(8, 12), amount=0).status_code == 400 + # End before start + assert create_tool_booking(client, admin_token, tool_id, dt(8, 12), dt(8, 10)).status_code == 400 + # End equal to start + assert create_tool_booking(client, admin_token, tool_id, dt(8, 10), dt(8, 10)).status_code == 400 + # More than exists + assert create_tool_booking(client, admin_token, tool_id, dt(8, 10), dt(8, 12), amount=4).status_code == 400 + + +def test_create_tool_booking_overbooking(client, admin_token): + tool_id = create_tool(client, admin_token, amount=3).json()["id"] + + assert create_tool_booking(client, admin_token, tool_id, dt(8, 10), dt(8, 14), amount=2).status_code == 200 + # Only 1 left between 10 and 14 + assert create_tool_booking(client, admin_token, tool_id, dt(8, 12), dt(8, 16), amount=2).status_code == 400 + assert create_tool_booking(client, admin_token, tool_id, dt(8, 12), dt(8, 16), amount=1).status_code == 200 + # Now fully booked 12-14 + assert create_tool_booking(client, admin_token, tool_id, dt(8, 13), dt(8, 13, 30)).status_code == 400 + # Bookings touching at the edges do not overlap + assert create_tool_booking(client, admin_token, tool_id, dt(8, 8), dt(8, 10), amount=3).status_code == 200 + assert create_tool_booking(client, admin_token, tool_id, dt(8, 16), dt(8, 18), amount=3).status_code == 200 + + +def test_create_tool_booking_non_overlapping_existing(client, admin_token): + # Two existing bookings overlap the new one but not each other, so the peak is 2, not 4 + tool_id = create_tool(client, admin_token, amount=3).json()["id"] + + assert create_tool_booking(client, admin_token, tool_id, dt(8, 8), dt(8, 10), amount=2).status_code == 200 + assert create_tool_booking(client, admin_token, tool_id, dt(8, 11), dt(8, 13), amount=2).status_code == 200 + assert create_tool_booking(client, admin_token, tool_id, dt(8, 9), dt(8, 12), amount=1).status_code == 200 + assert create_tool_booking(client, admin_token, tool_id, dt(8, 9), dt(8, 12), amount=1).status_code == 400 + + +def test_bookings_on_different_tools_do_not_conflict(client, admin_token): + drill_id = create_tool(client, admin_token, amount=1).json()["id"] + saw_id = create_tool(client, admin_token, name_sv="Såg", name_en="Saw", amount=1).json()["id"] + + assert create_tool_booking(client, admin_token, drill_id, dt(8, 10), dt(8, 12)).status_code == 200 + assert create_tool_booking(client, admin_token, saw_id, dt(8, 10), dt(8, 12)).status_code == 200 + + +def test_get_tool_booking(client, admin_token, member_token, admin_user): + tool_id = create_tool(client, admin_token).json()["id"] + booking_id = create_tool_booking(client, admin_token, tool_id, dt(8, 10), dt(8, 12), amount=2).json()["id"] + + resp = client.get(f"/tool-booking/get_booking/{booking_id}", headers=auth_headers(admin_token)) + assert resp.status_code == 200 + assert resp.json()["user"]["id"] == admin_user.id + assert resp.json()["amount"] == 2 + + # Members can't see who booked + resp = client.get(f"/tool-booking/get_booking/{booking_id}", headers=auth_headers(member_token)) + assert resp.status_code == 403 + + resp = client.get(f"/tool-booking/get_simple_booking/{booking_id}", headers=auth_headers(member_token)) + assert resp.status_code == 200 + data = resp.json() + assert "user" not in data + assert data["amount"] == 2 + assert data["tool"]["id"] == tool_id + + resp = client.get("/tool-booking/get_simple_booking/999999", headers=auth_headers(member_token)) + assert resp.status_code == 404 + + +def test_get_all_tool_bookings(client, admin_token, member_token): + tool_id = create_tool(client, admin_token).json()["id"] + create_tool_booking(client, admin_token, tool_id, dt(8, 10), dt(8, 12)) + create_tool_booking(client, admin_token, tool_id, dt(9, 10), dt(9, 12)) + + resp = client.get("/tool-booking/get_all", headers=auth_headers(admin_token)) + assert resp.status_code == 200 + assert len(resp.json()) == 2 + assert all("user" in booking for booking in resp.json()) + + assert client.get("/tool-booking/get_all", headers=auth_headers(member_token)).status_code == 403 + + resp = client.get("/tool-booking/get_simple_all", headers=auth_headers(member_token)) + assert resp.status_code == 200 + assert len(resp.json()) == 2 + assert all("user" not in booking for booking in resp.json()) + + assert client.get("/tool-booking/get_simple_all").status_code == 401 + + +def test_get_tool_bookings_between_times(client, admin_token, member_token): + tool_id = create_tool(client, admin_token).json()["id"] + inside = create_tool_booking(client, admin_token, tool_id, dt(8, 11), dt(8, 12)).json()["id"] + over_start = create_tool_booking(client, admin_token, tool_id, dt(8, 9), dt(8, 11)).json()["id"] + over_end = create_tool_booking(client, admin_token, tool_id, dt(8, 13), dt(8, 15)).json()["id"] + around = create_tool_booking(client, admin_token, tool_id, dt(8, 8), dt(8, 16)).json()["id"] + # Outside or only touching the edges + create_tool_booking(client, admin_token, tool_id, dt(8, 8), dt(8, 10)) + create_tool_booking(client, admin_token, tool_id, dt(8, 14), dt(8, 16)) + create_tool_booking(client, admin_token, tool_id, dt(9, 10), dt(9, 14)) + + params = {"start_time": dt(8, 10).isoformat(), "end_time": dt(8, 14).isoformat()} + expected = {inside, over_start, over_end, around} + + resp = client.get("/tool-booking/get_between_times", params=params, headers=auth_headers(admin_token)) + assert resp.status_code == 200, resp.text + assert {booking["id"] for booking in resp.json()} == expected + + resp = client.get("/tool-booking/get_simple_between_times", params=params, headers=auth_headers(member_token)) + assert resp.status_code == 200, resp.text + assert {booking["id"] for booking in resp.json()} == expected + + resp = client.get("/tool-booking/get_between_times", params=params, headers=auth_headers(member_token)) + assert resp.status_code == 403 + + +def test_get_tool_bookings_by_tool(client, admin_token, member_token): + drill_id = create_tool(client, admin_token).json()["id"] + saw_id = create_tool(client, admin_token, name_sv="Såg", name_en="Saw").json()["id"] + drill_booking = create_tool_booking(client, admin_token, drill_id, dt(8, 10), dt(8, 12)).json()["id"] + create_tool_booking(client, admin_token, saw_id, dt(8, 10), dt(8, 12)) + + resp = client.get("/tool-booking/get_by_tool/", params={"tool_id": drill_id}, headers=auth_headers(admin_token)) + assert resp.status_code == 200 + assert [booking["id"] for booking in resp.json()] == [drill_booking] + + resp = client.get( + "/tool-booking/get_simple_by_tool/", params={"tool_id": drill_id}, headers=auth_headers(member_token) + ) + assert resp.status_code == 200 + assert [booking["id"] for booking in resp.json()] == [drill_booking] + + resp = client.get("/tool-booking/get_by_tool/", params={"tool_id": drill_id}, headers=auth_headers(member_token)) + assert resp.status_code == 403 + + resp = client.get("/tool-booking/get_by_tool/", params={"tool_id": 999999}, headers=auth_headers(admin_token)) + assert resp.status_code == 404 + + +def test_delete_tool_booking(client, admin_token, member_token): + tool_id = create_tool(client, admin_token, amount=1).json()["id"] + booking_id = create_tool_booking(client, admin_token, tool_id, dt(8, 10), dt(8, 12)).json()["id"] + + assert client.delete(f"/tool-booking/{booking_id}", headers=auth_headers(member_token)).status_code == 403 + + resp = client.delete(f"/tool-booking/{booking_id}", headers=auth_headers(admin_token)) + assert resp.status_code == 200 + assert client.get(f"/tool-booking/get_booking/{booking_id}", headers=auth_headers(admin_token)).status_code == 404 + assert client.delete(f"/tool-booking/{booking_id}", headers=auth_headers(admin_token)).status_code == 404 + + # The tool is free again + assert create_tool_booking(client, admin_token, tool_id, dt(8, 10), dt(8, 12)).status_code == 200 + + +def test_update_tool_booking(client, admin_token): + tool_id = create_tool(client, admin_token, amount=3).json()["id"] + booking_id = create_tool_booking(client, admin_token, tool_id, dt(8, 10), dt(8, 12)).json()["id"] + + resp = patch_tool_booking( + client, admin_token, booking_id, amount=3, start_time=dt(8, 9).isoformat(), end_time=dt(8, 13).isoformat() + ) + assert resp.status_code == 200, resp.text + data = resp.json() + assert data["amount"] == 3 + assert datetime.fromisoformat(data["start_time"]) == dt(8, 9) + assert datetime.fromisoformat(data["end_time"]) == dt(8, 13) + assert data["description"] == "example description" + + +def test_update_tool_booking_invalid(client, admin_token, member_token): + tool_id = create_tool(client, admin_token).json()["id"] + booking_id = create_tool_booking(client, admin_token, tool_id, dt(8, 10), dt(8, 12)).json()["id"] + + assert patch_tool_booking(client, admin_token, 999999, amount=1).status_code == 404 + assert patch_tool_booking(client, admin_token, booking_id, amount=0).status_code == 400 + assert patch_tool_booking(client, admin_token, booking_id, end_time=dt(8, 9).isoformat()).status_code == 400 + assert patch_tool_booking(client, admin_token, booking_id, start_time=dt(8, 12).isoformat()).status_code == 400 + assert patch_tool_booking(client, member_token, booking_id, amount=1).status_code == 403 + + +def test_update_tool_booking_amount_overbooking(client, admin_token): + tool_id = create_tool(client, admin_token, amount=3).json()["id"] + create_tool_booking(client, admin_token, tool_id, dt(8, 10), dt(8, 12), amount=2) + booking_id = create_tool_booking(client, admin_token, tool_id, dt(8, 11), dt(8, 13)).json()["id"] + + assert patch_tool_booking(client, admin_token, booking_id, amount=2).status_code == 400 + # The booking itself is not counted as overlapping + assert patch_tool_booking(client, admin_token, booking_id, amount=1).status_code == 200 + + +def test_update_tool_booking_time_overbooking(client, admin_token): + tool_id = create_tool(client, admin_token, amount=2).json()["id"] + create_tool_booking(client, admin_token, tool_id, dt(8, 10), dt(8, 12), amount=2) + booking_id = create_tool_booking(client, admin_token, tool_id, dt(8, 14), dt(8, 16), amount=1).json()["id"] + + # Moving into a fully booked time without changing the amount + resp = patch_tool_booking( + client, admin_token, booking_id, start_time=dt(8, 11).isoformat(), end_time=dt(8, 13).isoformat() + ) + assert resp.status_code == 400 + resp = patch_tool_booking(client, admin_token, booking_id, start_time=dt(8, 11).isoformat()) + assert resp.status_code == 400 + + # Moving to a free time is fine + resp = patch_tool_booking(client, admin_token, booking_id, start_time=dt(8, 12).isoformat()) + assert resp.status_code == 200, resp.text + + +def test_update_tool_booking_description(client, admin_token): + tool_id = create_tool(client, admin_token).json()["id"] + booking_id = create_tool_booking(client, admin_token, tool_id, dt(8, 10), dt(8, 12)).json()["id"] + + resp = patch_tool_booking(client, admin_token, booking_id, description="new description") + assert resp.status_code == 200 + assert resp.json()["description"] == "new description" + + # Not given, so unchanged + resp = patch_tool_booking(client, admin_token, booking_id, amount=2) + assert resp.status_code == 200 + assert resp.json()["description"] == "new description" + + resp = patch_tool_booking(client, admin_token, booking_id, description="") + assert resp.status_code == 200 + assert resp.json()["description"] == "" + + resp = patch_tool_booking(client, admin_token, booking_id, description=None) + assert resp.status_code == 200 + assert resp.json()["description"] is None + + +def test_deleting_user_deletes_tool_bookings(client, admin_token, admin_user, db_session): + tool_id = create_tool(client, admin_token).json()["id"] + booking_id = create_tool_booking(client, admin_token, tool_id, dt(8, 10), dt(8, 12)).json()["id"] + + db_session.delete(admin_user) + db_session.commit() + db_session.expire_all() + + assert db_session.query(ToolBooking_DB).filter_by(id=booking_id).one_or_none() is None