From 2ae0e8bf9e2dc92baca7ce892050884bf10175e7 Mon Sep 17 00:00:00 2001 From: Mikael de Verdier Date: Fri, 31 Jul 2026 14:42:41 +0000 Subject: [PATCH 01/12] added support for enclose.moose: created router, schemas and db_models for both levels and submissions --- .devcontainer/Dockerfile | 1 + api_schemas/enclose_moose_level_schema.py | 35 +++ .../enclose_moose_submission_schema.py | 15 + db_models/enclose_moose_level_model.py | 29 ++ db_models/enclose_moose_submission_model.py | 26 ++ helpers/constants.py | 6 + helpers/types.py | 1 + requirements.txt | 1 + routes/__init__.py | 3 + routes/enclose_moose_router.py | 177 +++++++++++ seed.py | 1 + services/enclose_moose_service.py | 287 ++++++++++++++++++ 12 files changed, 582 insertions(+) create mode 100644 api_schemas/enclose_moose_level_schema.py create mode 100644 api_schemas/enclose_moose_submission_schema.py create mode 100644 db_models/enclose_moose_level_model.py create mode 100644 db_models/enclose_moose_submission_model.py create mode 100644 routes/enclose_moose_router.py create mode 100644 services/enclose_moose_service.py diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 194512a4..4791009c 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -28,6 +28,7 @@ ENV DOCUMENT_BASE_PATH="/workspaces/WebWebWeb/test-assets/documents" ENV ALBUM_BASE_PATH="/workspaces/WebWebWeb/test-assets/albums" ENV ASSETS_BASE_PATH="/workspaces/WebWebWeb/test-assets/assets" ENV MOOSE_GAME_TOKEN="sad_secret_key" +ENV ENCLOSE_MOOSE_TOKEN="happy_secret_key" RUN mkdir -p "$DOCUMENT_BASE_PATH" \ "$ALBUM_BASE_PATH" \ diff --git a/api_schemas/enclose_moose_level_schema.py b/api_schemas/enclose_moose_level_schema.py new file mode 100644 index 00000000..929b11ea --- /dev/null +++ b/api_schemas/enclose_moose_level_schema.py @@ -0,0 +1,35 @@ +from api_schemas.base_schema import BaseSchema +from datetime import datetime, date, UTC + + +class EncloseMooseLevelRead(BaseSchema): + level_id: str + release_date: date + day_index: int | None + name: str + + encoded_grid: str + wall_budget: int + + optimal_score: int + optimal_solution: set[int] # Could consider not showing optimal_solution until player has submitted + optimal_is_unique: bool | None + + +class EncloseMooseLevelCreate(BaseSchema): + level_id: str + release_date: date = datetime.now(UTC).date() + day_index: int | None = None + name: str + + encoded_grid: str + wall_budget: int + + +class EncloseMooseLevelUpdate(BaseSchema): + release_date: date | None = None + day_index: int | None = None + name: str | None = None + + encoded_grid: str | None = None + wall_budget: int | None = None diff --git a/api_schemas/enclose_moose_submission_schema.py b/api_schemas/enclose_moose_submission_schema.py new file mode 100644 index 00000000..a9973686 --- /dev/null +++ b/api_schemas/enclose_moose_submission_schema.py @@ -0,0 +1,15 @@ +from api_schemas.base_schema import BaseSchema +from helpers.types import datetime + + +class EncloseMooseSubmissionRead(BaseSchema): + level_id: str + submission_time: datetime + + player_id: int + player_score: int + player_solution: set[int] + + +class EncloseMooseSubmissionCreate(BaseSchema): + player_solution: set[int] diff --git a/db_models/enclose_moose_level_model.py b/db_models/enclose_moose_level_model.py new file mode 100644 index 00000000..4bcb95df --- /dev/null +++ b/db_models/enclose_moose_level_model.py @@ -0,0 +1,29 @@ +from datetime import date +from helpers.constants import MAX_ENCLOSE_LEVEL_ID, MAX_ENCLOSE_LEVEL_NAME, MAX_ENCLOSE_GRID +from .base_model import BaseModel_DB +from sqlalchemy.orm import relationship, mapped_column, Mapped +from typing import TYPE_CHECKING, Optional +from sqlalchemy import JSON, String, Date + +if TYPE_CHECKING: + from db_models.enclose_moose_submission_model import EncloseMooseSubmission_DB + + +class EncloseMooseLevel_DB(BaseModel_DB): + __tablename__ = "enclose_moose_level_table" + + level_id: Mapped[str] = mapped_column(String(MAX_ENCLOSE_LEVEL_ID), primary_key=True) + release_date: Mapped[Optional[date]] = mapped_column(Date) + day_index: Mapped[Optional[int]] = mapped_column() + name: Mapped[str] = mapped_column(String(MAX_ENCLOSE_LEVEL_NAME)) + + encoded_grid: Mapped[str] = mapped_column(String(MAX_ENCLOSE_GRID)) + wall_budget: Mapped[int] = mapped_column() + + optimal_score: Mapped[int] = mapped_column() + optimal_solution: Mapped[list[int]] = mapped_column(JSON) + optimal_is_unique: Mapped[Optional[bool]] = mapped_column() + + submissions: Mapped[list["EncloseMooseSubmission_DB"]] = relationship( + back_populates="level", cascade="all, delete-orphan", init=False + ) diff --git a/db_models/enclose_moose_submission_model.py b/db_models/enclose_moose_submission_model.py new file mode 100644 index 00000000..7e51ec2f --- /dev/null +++ b/db_models/enclose_moose_submission_model.py @@ -0,0 +1,26 @@ +from helpers.constants import MAX_ENCLOSE_LEVEL_ID +from .base_model import BaseModel_DB +from sqlalchemy.orm import relationship, mapped_column, Mapped +from sqlalchemy import ForeignKey, JSON, String +from typing import TYPE_CHECKING +from helpers.types import datetime_utc + +if TYPE_CHECKING: + from db_models.enclose_moose_level_model import EncloseMooseLevel_DB + + +class EncloseMooseSubmission_DB(BaseModel_DB): + __tablename__ = "enclose_moose_submission_table" + + level_id: Mapped[str] = mapped_column( + String(MAX_ENCLOSE_LEVEL_ID), + ForeignKey("enclose_moose_level_table.level_id", ondelete="CASCADE"), + primary_key=True, + ) + submission_time: Mapped[datetime_utc] = mapped_column() + + player_id: Mapped[int] = mapped_column(primary_key=True) + player_score: Mapped[int] = mapped_column() + player_solution: Mapped[list[int]] = mapped_column(JSON) + + level: Mapped["EncloseMooseLevel_DB"] = relationship(back_populates="submissions", init=False, viewonly=True) diff --git a/helpers/constants.py b/helpers/constants.py index f95b334e..89410f3e 100644 --- a/helpers/constants.py +++ b/helpers/constants.py @@ -106,3 +106,9 @@ # Keyval, used for example for storing the links to different very important documents (e.g. reglementet) MAX_KEYVAL_KEY = 100 MAX_KEYVAL_VALUE = 1000 + + +# enclose.moose +MAX_ENCLOSE_LEVEL_ID = 100 +MAX_ENCLOSE_LEVEL_NAME = 100 +MAX_ENCLOSE_GRID = 10000 diff --git a/helpers/types.py b/helpers/types.py index 34af1a1f..23aedf38 100644 --- a/helpers/types.py +++ b/helpers/types.py @@ -57,6 +57,7 @@ def force_utc(date: datetime): "Council", "RoomBookings", "Moosegame", + "EncloseMoose", "MailAlias", "GuildMeeting", "Keyvals", diff --git a/requirements.txt b/requirements.txt index 85941318..cd7149cd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -36,6 +36,7 @@ Mako==1.3.8 MarkupSafe==3.0.3 numpy==2.2.4 oauthlib==3.3.1 +ortools==9.15.6755 packaging==23.2 pandas==2.2.3 passlib==1.7.4 diff --git a/routes/__init__.py b/routes/__init__.py index f7bef5f6..7a227d68 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -2,6 +2,7 @@ from .mail_alias_router import mail_alias_router from .moose_game_router import moose_game_router +from .enclose_moose_router import enclose_moose_router from .council_router import council_router from .user_router import user_router from .post_router import post_router @@ -86,6 +87,8 @@ main_router.include_router(moose_game_router, prefix="/moose-game", tags=["moose-game"]) +main_router.include_router(enclose_moose_router, prefix="/enclose-moose", tags=["enclose-moose"]) + main_router.include_router(mail_alias_router, prefix="/mail-alias", tags=["mail-alias"]) main_router.include_router(access_serve_router, prefix="/access-serve", tags=["access serve"]) diff --git a/routes/enclose_moose_router.py b/routes/enclose_moose_router.py new file mode 100644 index 00000000..2569b449 --- /dev/null +++ b/routes/enclose_moose_router.py @@ -0,0 +1,177 @@ +import os +from datetime import datetime +from zoneinfo import ZoneInfo +from fastapi import APIRouter, HTTPException, Request +from typing import Annotated +from sqlalchemy.exc import DataError, IntegrityError +from api_schemas.enclose_moose_level_schema import ( + EncloseMooseLevelRead, + EncloseMooseLevelCreate, + EncloseMooseLevelUpdate, +) +from api_schemas.enclose_moose_submission_schema import EncloseMooseSubmissionRead, EncloseMooseSubmissionCreate +from user.permission import Permission +from database import DB_dependency +from db_models.user_model import User_DB +from db_models.enclose_moose_level_model import EncloseMooseLevel_DB +from db_models.enclose_moose_submission_model import EncloseMooseSubmission_DB +from services.enclose_moose_service import level_create, level_update, solution_submit + +ENCLOSE_MOOSE_SECRET = os.getenv("ENCLOSE_MOOSE_TOKEN") + +enclose_moose_router = APIRouter() + + +# Admin routes +@enclose_moose_router.post( + "/admin", response_model=EncloseMooseLevelRead, dependencies=[Permission.require("manage", "EncloseMoose")] +) +def admin_create_level(data: EncloseMooseLevelCreate, db: DB_dependency): + level = level_create(data) + + db.add(level) + try: + db.commit() + except DataError: + db.rollback() + raise HTTPException(400, detail="Some string is too long") + except IntegrityError: + db.rollback() + raise HTTPException(409, detail=f'A level with level_id "{data.level_id}" already exists') + + return level + + +@enclose_moose_router.get( + "/admin/{level_id}", + response_model=EncloseMooseLevelRead, + dependencies=[Permission.require("manage", "EncloseMoose")], +) +def admin_get_level(level_id: str, db: DB_dependency): + level = db.get(EncloseMooseLevel_DB, level_id) + if level is None: + raise HTTPException(404, detail=f'No level with level_id "{level_id}" exists') + + return level + + +@enclose_moose_router.get( + "/admin", response_model=list[EncloseMooseLevelRead], dependencies=[Permission.require("manage", "EncloseMoose")] +) +def admin_get_all_levels(db: DB_dependency): + levels = db.query(EncloseMooseLevel_DB).order_by(EncloseMooseLevel_DB.release_date).all() + + return levels + + +@enclose_moose_router.patch( + "/admin/{level_id}", + response_model=EncloseMooseLevelRead, + dependencies=[Permission.require("manage", "EncloseMoose")], +) +def admin_update_level(level_id: str, data: EncloseMooseLevelUpdate, db: DB_dependency): + level = db.get(EncloseMooseLevel_DB, level_id) + if level is None: + raise HTTPException(404, detail=f'No level with level_id "{level_id}" exists') + + updated_level = level_update(level, data) + try: + db.commit() + except DataError: + db.rollback() + raise HTTPException(400, detail="Some string is too long") + + return updated_level + + +@enclose_moose_router.delete( + "/admin/{level_id}", + response_model=EncloseMooseLevelRead, + dependencies=[Permission.require("manage", "EncloseMoose")], +) +def admin_delete_level(level_id: str, db: DB_dependency): + level = db.get(EncloseMooseLevel_DB, level_id) + if level is None: + raise HTTPException(404, detail=f'No level with level_id "{level_id}" exists') + + db.delete(level) + db.commit() + + return level + + +# Non-admin routes +@enclose_moose_router.get("/{level_id}", response_model=EncloseMooseLevelRead, dependencies=[Permission.member()]) +def get_level(level_id: str, db: DB_dependency): + date_today = datetime.now(ZoneInfo("Europe/Stockholm")).date() + level = ( + db.query(EncloseMooseLevel_DB) + .filter(EncloseMooseLevel_DB.release_date <= date_today, EncloseMooseLevel_DB.level_id == level_id) + .one_or_none() + ) + if level is None: + raise HTTPException(404, detail=f'No level with level_id "{level_id}" exists') + + return level + + +@enclose_moose_router.get("/", response_model=list[EncloseMooseLevelRead], dependencies=[Permission.member()]) +def get_all_levels(db: DB_dependency): + date_today = datetime.now(ZoneInfo("Europe/Stockholm")).date() + + levels = ( + db.query(EncloseMooseLevel_DB) + .filter(EncloseMooseLevel_DB.release_date <= date_today) + .order_by(EncloseMooseLevel_DB.release_date) + .all() + ) + + return levels + + +@enclose_moose_router.post("/{level_id}/submit", response_model=EncloseMooseSubmissionRead) +def submit_solution( + level_id: str, + submission: EncloseMooseSubmissionCreate, + me: Annotated[User_DB, Permission.member()], + request: Request, + db: DB_dependency, +): + token = request.headers.get("enclose-moose-token") + if ENCLOSE_MOOSE_SECRET != token: + raise HTTPException(401, detail="Invalid enclose-moose-token") + + date_today = datetime.now(ZoneInfo("Europe/Stockholm")).date() + level = ( + db.query(EncloseMooseLevel_DB) + .filter(EncloseMooseLevel_DB.release_date <= date_today, EncloseMooseLevel_DB.level_id == level_id) + .one_or_none() + ) + if level is None: + raise HTTPException(404, detail=f'No level with level_id "{level_id}" exists') + + db_submission = solution_submit(level, submission.player_solution, me.id) + db.add(db_submission) + try: + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException( + 409, + detail="The player has already submitted a solution to this level", + ) + + return db_submission + + +@enclose_moose_router.get("/{level_id}/submit", response_model=EncloseMooseSubmissionRead) +def get_submission( + level_id: str, + me: Annotated[User_DB, Permission.member()], + db: DB_dependency, +): + submission = db.get(EncloseMooseSubmission_DB, (level_id, me.id)) + if submission is None: + raise HTTPException(404, detail="No submission exists for this player and level") + + return submission diff --git a/seed.py b/seed.py index ca56f0d8..bdf61afd 100644 --- a/seed.py +++ b/seed.py @@ -218,6 +218,7 @@ def seed_permissions(db: Session, posts: list[Post_DB]): Permission(action="manage", target="Document", posts=["Buggmästare"]), Permission(action="view", target="Document", posts=["Buggmästare"]), Permission(action="manage", target="Moosegame", posts=["Buggmästare"]), + Permission(action="manage", target="EncloseMoose", 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"]), diff --git a/services/enclose_moose_service.py b/services/enclose_moose_service.py new file mode 100644 index 00000000..0fd6b41c --- /dev/null +++ b/services/enclose_moose_service.py @@ -0,0 +1,287 @@ +from collections import deque +from datetime import datetime, timezone +from ortools.sat.python import cp_model +from fastapi import HTTPException +from api_schemas.enclose_moose_level_schema import EncloseMooseLevelCreate, EncloseMooseLevelUpdate +from db_models.enclose_moose_level_model import EncloseMooseLevel_DB +from db_models.enclose_moose_submission_model import EncloseMooseSubmission_DB + + +class EncloseGrid: + def __init__(self, encoded_grid: str, wall_budget: int): + self.wall_budget = wall_budget + + if wall_budget < 0: + raise HTTPException(400, detail="Wall budget must not be negative") + + self.grid_string = encoded_grid.replace("\n", "") + self.N = len(self.grid_string) + + if self.N <= 1: + raise HTTPException(400, detail="Level must be bigger than one square") + + for tile in encoded_grid: + if tile not in ("\n", ".", "~", "H", "C", "G", "S") and not tile.isnumeric(): + raise HTTPException(400, detail=f'Level contained unknown encoding: "{tile}"') + + if "H" not in encoded_grid: + raise HTTPException(400, detail='Level must contain a moose ("H")') + + if "\n" not in encoded_grid: + raise HTTPException(400, detail="Level must be rectangular") + + self.grid_width = encoded_grid.index("\n") + self.grid_height = self.N // self.grid_width + + if len(set(map(len, encoded_grid.split("\n")))) != 1: + raise HTTPException(400, detail="Level must be rectangular") + + self.moose_index = self.grid_string.index("H") + + self.portals: dict[str, set[int]] = {} + for flat_index, tile in enumerate(self.grid_string): + if tile.isnumeric(): + self.portals.setdefault(tile, set()).add(flat_index) + + def get_neighbors(self, flat_index: int): + row_index, column_index = divmod(flat_index, self.grid_width) + neighbors: set[int] = set() + if column_index != 0: + neighbors.add(flat_index - 1) + if column_index != self.grid_width - 1: + neighbors.add(flat_index + 1) + if row_index != 0: + neighbors.add(flat_index - self.grid_width) + if row_index != self.grid_height - 1: + neighbors.add(flat_index + self.grid_width) + + tile = self.grid_string[flat_index] + if tile in self.portals: + for flat_index_portal in self.portals[tile]: + if flat_index_portal != flat_index: + neighbors.add(flat_index_portal) + + return neighbors + + def is_boundary(self, flat_index: int): + row, col = divmod(flat_index, self.grid_width) + + return row == 0 or col == 0 or row == self.grid_height - 1 or col == self.grid_width - 1 + + def score_tile(self, tile: str): + bonus_score_dict = {"C": 3, "G": 10, "S": -5} + bonus_score = 1 + bonus_score_dict.get(tile, 0) + + return bonus_score + + def find_optimal_solution(self): + # Algorithm inspired by https://dynomight.substack.com/p/horse, https://blog.macuyiko.com/post/2026/solving-enclosehorse-with-cp-sat.html and Gemini + + model = cp_model.CpModel() + + w = [model.new_bool_var(f"w_{i}") for i in range(self.N)] # Whether wall is present + e = [model.new_bool_var(f"r_{i}") for i in range(self.N)] # Whether the tile is enclosed + + never_enclosed_indices: set[int] = set() + for flat_index, tile in enumerate(self.grid_string): + if tile == "~" or self.is_boundary(flat_index): + model.add(e[flat_index] == 0) # Water or boundary tiles cannot be enclosed + never_enclosed_indices.add(flat_index) + + if tile != ".": + model.add(w[flat_index] == 0) # Non-grass tiles cannot have walls + + model.add(e[flat_index] + w[flat_index] <= 1) # A tile cannot be both enclosed and have a wall + + model.add(e[self.moose_index] == 1) # Moose must be enclosed + + model.add(sum(w) <= self.wall_budget) # Enforce wall budget + + d = [model.new_int_var(0, self.N, f"d_{i}") for i in range(self.N)] # Distance from moose + model.add(d[self.moose_index] == 0) # Moose is at distance 0 from moose. + + for flat_index, tile in enumerate(self.grid_string): + if flat_index in never_enclosed_indices: + continue + + parents: list[cp_model.IntVar] = [] + for neighbor_index in self.get_neighbors(flat_index): + if self.grid_string[neighbor_index] == "~": + continue + + model.add( + e[flat_index] <= e[neighbor_index] + w[neighbor_index] + ) # If a tile is enclosed, its neighbor must either also be enclosed or have a wall + + if flat_index != self.moose_index: + p_var = model.new_bool_var( + f"p_{neighbor_index}_{flat_index}" + ) # Whether neighbor_index is parent of flat_index + parents.append(p_var) + + model.add_implication(p_var, e[neighbor_index]) # A parent must be an enclosed tile + model.add(d[flat_index] == d[neighbor_index] + 1).only_enforce_if( # pyright: ignore + p_var + ) # Distance increases to prevent cycles (isolated enclosed areas) + + if flat_index != self.moose_index: + if parents: + model.add(sum(parents) == e[flat_index]) # If a tile is enclosed, it has exactly one parent + else: + model.add(e[flat_index] == 0) # If completely surrounded by water, it cannot be enclosed + + enclosed_score = [self.score_tile(tile) * e[flat_index] for flat_index, tile in enumerate(self.grid_string)] + model.maximize(sum(enclosed_score)) + + solver = cp_model.CpSolver() + solver.parameters.max_time_in_seconds = 15 + solver.parameters.num_search_workers = 8 + + status = solver.solve(model) + # print(f"Solving took {solver.wall_time} seconds") + + if status not in (cp_model.INFEASIBLE, cp_model.OPTIMAL): + raise HTTPException( + 400, + detail=f"Level could not be solved, likely because it exceeded the time limit (solver status: {solver.status_name(status)})", + ) + + if status == cp_model.INFEASIBLE: + raise HTTPException(400, detail="Level is unsolvable") + + score = int(solver.objective_value) + wall_indices = set(i for i in range(self.N) if solver.value(w[i]) == 1) + + model.add_bool_or( + [w[i].Not() for i in wall_indices] + [w_i for i, w_i in enumerate(w) if i not in wall_indices] + ) + status2 = solver.solve(model) + if status2 == cp_model.OPTIMAL: + solution_is_unique = int(solver.objective_value) != score + elif status2 == cp_model.INFEASIBLE: + solution_is_unique = True + else: + solution_is_unique = None + + """ # Some debug visualisations + import numpy as np + + e_sol = np.array([solver.value(e[i]) for i in range(self.N)]) + + np.set_printoptions(linewidth=1000) + print("MAP") + grid: list[list[str]] = [] + for flat_index, tile in enumerate(self.grid_string): + if flat_index % self.grid_width == 0: + grid.append([]) + + if flat_index in wall_indices: + grid[-1].append("W") + else: + grid[-1].append(tile) + print(np.array(grid).reshape((self.grid_height, self.grid_width))) + + print("REGION") + print(e_sol.reshape((self.grid_height, self.grid_width))) + print("SCORES") + print( + (e_sol * np.array(list(map(self.score_tile, self.grid_string)))) + .astype(int) + .reshape((self.grid_height, self.grid_width)) + ) + """ + + return score, wall_indices, solution_is_unique + + def score_solution(self, solution: set[int]): + if len(solution) > self.wall_budget: + raise HTTPException(400, "The solution contained too many walls") + + for wall_index in solution: + if wall_index < 0 or wall_index > self.N - 1: + raise HTTPException(400, f"The solution contained a wall that was of out bounds ({wall_index})") + + if self.grid_string[wall_index] != ".": + raise HTTPException(400, f"The solution contained a wall placed on non-grass ({wall_index})") + + queue: deque[int] = deque() + visited: set[int] = set() + score = 0 + + queue.append(self.moose_index) + visited.add(self.moose_index) + + while queue: + current_index = queue.popleft() + score += self.score_tile(self.grid_string[current_index]) + + for neighbor_index in self.get_neighbors(current_index): + is_visited = neighbor_index in visited + is_invalid = neighbor_index < 0 or neighbor_index > self.N - 1 + is_blocked = self.grid_string[neighbor_index] == "~" or neighbor_index in solution + if is_visited or is_invalid or is_blocked: + continue + + is_escaped = self.is_boundary(neighbor_index) + if is_escaped: + raise HTTPException(400, detail="The solution does not enclose the moose") + + queue.append(neighbor_index) + visited.add(neighbor_index) + + return score # len(visited) + bonus score + + +def level_create(data: EncloseMooseLevelCreate): + grid = EncloseGrid(data.encoded_grid, data.wall_budget) + optimal_score, optimal_solution, optimal_is_unique = grid.find_optimal_solution() + + level = EncloseMooseLevel_DB( + level_id=data.level_id, + release_date=data.release_date, + day_index=data.day_index, + name=data.name, + encoded_grid=data.encoded_grid, + wall_budget=data.wall_budget, + optimal_score=optimal_score, + optimal_solution=list(optimal_solution), + optimal_is_unique=optimal_is_unique, + ) + + return level + + +def level_update(level: EncloseMooseLevel_DB, data: EncloseMooseLevelUpdate): + updates = data.model_dump(exclude_unset=True) + + used_encoded_grid = updates.get("encoded_grid", level.encoded_grid) + used_wall_budget = updates.get("wall_budget", level.wall_budget) + if used_encoded_grid != level.encoded_grid or used_wall_budget != level.wall_budget: + level.submissions.clear() + + grid = EncloseGrid(used_encoded_grid, used_wall_budget) + optimal_score, optimal_solution, optimal_is_unique = grid.find_optimal_solution() + + updates["optimal_score"] = optimal_score + updates["optimal_solution"] = list(optimal_solution) + updates["optimal_is_unique"] = optimal_is_unique + + for var, value in updates.items(): + setattr(level, var, value) + + return level + + +def solution_submit(level: EncloseMooseLevel_DB, solution: set[int], player_id: int): + grid = EncloseGrid(level.encoded_grid, level.wall_budget) + player_score = grid.score_solution(solution) + + db_submission = EncloseMooseSubmission_DB( + level_id=level.level_id, + submission_time=datetime.now(timezone.utc), + player_id=player_id, + player_score=player_score, + player_solution=list(solution), + ) + + return db_submission From c26115338da9adf6b1efffca8260ca9dc2aebe7d Mon Sep 17 00:00:00 2001 From: Mikael de Verdier Date: Fri, 31 Jul 2026 22:34:30 +0000 Subject: [PATCH 02/12] minor improvement to default release_date --- api_schemas/enclose_moose_level_schema.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/api_schemas/enclose_moose_level_schema.py b/api_schemas/enclose_moose_level_schema.py index 929b11ea..2bf264fe 100644 --- a/api_schemas/enclose_moose_level_schema.py +++ b/api_schemas/enclose_moose_level_schema.py @@ -1,5 +1,6 @@ from api_schemas.base_schema import BaseSchema -from datetime import datetime, date, UTC +from datetime import datetime, date +from zoneinfo import ZoneInfo class EncloseMooseLevelRead(BaseSchema): @@ -18,7 +19,7 @@ class EncloseMooseLevelRead(BaseSchema): class EncloseMooseLevelCreate(BaseSchema): level_id: str - release_date: date = datetime.now(UTC).date() + release_date: date = datetime.now(ZoneInfo("Europe/Stockholm")).date() day_index: int | None = None name: str From 33d496f3cf0dbd1facf136c25e6814adabaab233 Mon Sep 17 00:00:00 2001 From: Mikael de Verdier Date: Fri, 31 Jul 2026 22:35:52 +0000 Subject: [PATCH 03/12] tests (yay) for enclose_moose_router --- tests/basic_fixtures.py | 1 + tests/test_enclose_moose.py | 182 ++++++++++++++++++++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 tests/test_enclose_moose.py diff --git a/tests/basic_fixtures.py b/tests/basic_fixtures.py index 21691249..0aa6239e 100644 --- a/tests/basic_fixtures.py +++ b/tests/basic_fixtures.py @@ -90,6 +90,7 @@ def admin_post(db_session): Permission_DB(action="view", target="Council"), Permission_DB(action="manage", target="User"), Permission_DB(action="manage", target="RoomBookings"), + Permission_DB(action="manage", target="EncloseMoose"), Permission_DB(action="view", target="UserPost"), Permission_DB(action="manage", target="UserPost"), Permission_DB(action="view", target="RoomBookings"), diff --git a/tests/test_enclose_moose.py b/tests/test_enclose_moose.py new file mode 100644 index 00000000..f984c52f --- /dev/null +++ b/tests/test_enclose_moose.py @@ -0,0 +1,182 @@ +# type: ignore +import pytest +from .basic_factories import auth_headers +from datetime import datetime, UTC, timedelta + + +def create_level(client, token, **kwargs): + default_data = {"level_id": "test_level", "name": "test", "encoded_grid": ".~.\n.H.\n~.~", "wall_budget": 4} + return client.post("/enclose-moose/admin", json=default_data | kwargs, headers=auth_headers(token)) + + +def patch_level(client, token, level_id, **kwargs): + return client.patch(f"/enclose-moose/admin/{level_id}", json=kwargs, headers=auth_headers(token)) + + +def delete_level(client, token, level_id): + return client.delete(f"/enclose-moose/admin/{level_id}", headers=auth_headers(token)) + + +def admin_get_level(client, token, level_id): + return client.get(f"/enclose-moose/admin/{level_id}", headers=auth_headers(token)) + + +def get_level(client, token, level_id): + return client.get(f"/enclose-moose/{level_id}", headers=auth_headers(token)) + + +def admin_get_all_levels(client, token): + return client.get(f"/enclose-moose/admin", headers=auth_headers(token)) + + +def get_all_levels(client, token): + return client.get(f"/enclose-moose", headers=auth_headers(token)) + + +def submit_solution(client, token, level_id, player_solution, secret_header="happy_secret_key"): + headers = auth_headers(token) + if secret_header is not None: + headers["enclose-moose-token"] = secret_header + + body = {"player_solution": player_solution} + return client.post(f"/enclose-moose/{level_id}/submit", json=body, headers=headers) + + +def get_submission(client, token, level_id): + return client.get(f"/enclose-moose/{level_id}/submit", headers=auth_headers(token)) + + +def test_admin_manage_level(client, admin_token): + res_create_invalid = create_level(client, admin_token, encoded_grid=".~.") + assert res_create_invalid.status_code == 400 + + res_create_unsolvable = create_level(client, admin_token, wall_budget=1) + assert res_create_unsolvable.status_code == 400 + + res_create = create_level(client, admin_token, level_id="released_test") + assert res_create.status_code == 200 + + res_patch = patch_level(client, admin_token, "released_test", name="updated_name") + assert res_patch.status_code == 200 + assert res_patch.json()["name"] == "updated_name" + + res_delete = delete_level(client, admin_token, "released_test") + assert res_delete.status_code == 200 + + res_get = admin_get_level(client, admin_token, "released_test") + assert res_get.status_code == 404 + + +def test_admin_duplicate_level( + client, admin_token +): # Has to be a seperate test because the 409 (IntegrityError) otherwise expires the session state (problematic because it uses the same session for every request, unlike prod) + create_level(client, admin_token, level_id="released_test") + + res_create_duplicate = create_level(client, admin_token, level_id="released_test") + assert res_create_duplicate.status_code == 409 + + +def test_member_cannot_access_admin_routes(client, member_token, admin_token): + res_create = create_level(client, member_token, level_id="released_test") + assert res_create.status_code == 403 + + create_level(client, admin_token, level_id="released_test") + + res_admin_get = admin_get_level(client, member_token, "released_test") + assert res_admin_get.status_code == 403 + + res_patch = patch_level(client, member_token, "released_test", name="updated_name") + assert res_patch.status_code == 403 + res_patch_get = get_level(client, member_token, "released_test") + assert res_patch_get.json()["name"] != "updated_name" + + res_del = delete_level(client, member_token, "released_test") + assert res_del.status_code == 403 + + res_del_get = get_level(client, member_token, "released_test") + assert res_del_get.status_code == 200 + + +def test_get_levels(client, member_token, admin_token): + future_date = (datetime.now(UTC).date() + timedelta(days=2)).isoformat() + create_level(client, admin_token, level_id="unreleased_test", release_date=future_date) + create_level(client, admin_token, level_id="released_test") + + res_get_admin_unreleased = admin_get_level(client, admin_token, "unreleased_test") + assert res_get_admin_unreleased.status_code == 200 + + res_get_admin_released = admin_get_level(client, admin_token, "released_test") + assert res_get_admin_released.status_code == 200 + + res_get_member_unreleased = get_level(client, member_token, "unreleased_test") + assert res_get_member_unreleased.status_code == 404 + + res_get_member_released = get_level(client, member_token, "released_test") + assert res_get_member_released.status_code == 200 + + res_admin_get_all = admin_get_all_levels(client, admin_token) + assert res_admin_get_all.status_code == 200 + assert len(res_admin_get_all.json()) == 2 + + res_get_all_member = get_all_levels(client, member_token) + assert res_get_all_member.status_code == 200 + assert len(res_get_all_member.json()) == 1 + + +def test_submission(client, member_token, admin_token): + res_non_existant = submit_solution(client, member_token, "released_test", player_solution=[3, 5, 7]) + assert res_non_existant.status_code == 404 + + res_get_non_existant = get_submission(client, member_token, "released_test") + assert res_get_non_existant.status_code == 404 + + create_level(client, admin_token, level_id="released_test") + res_member = submit_solution(client, member_token, "released_test", player_solution=[3, 5, 7]) + assert res_member.status_code == 200 + + res_member_invalid = submit_solution(client, member_token, "released_test", player_solution=[3, 5]) + assert res_member_invalid.status_code == 400 + + res_get = get_submission(client, member_token, "released_test") + assert res_get.status_code == 200 + + future_date = (datetime.now(UTC).date() + timedelta(days=2)).isoformat() + create_level(client, admin_token, level_id="unreleased_test", release_date=future_date) + res_unreleased = submit_solution(client, member_token, "unreleased_test", player_solution=[3, 5, 7]) + assert res_unreleased.status_code == 404 + + +def test_submissions_clear(client, member_token, admin_token): + create_level(client, admin_token, level_id="released_test") + submit_solution(client, member_token, "released_test", player_solution=[3, 5, 7]) + + patch_level(client, admin_token, "released_test", name="updated_name") + res_get_unchanged = get_submission(client, member_token, "released_test") + assert res_get_unchanged.status_code == 200 + + patch_level(client, admin_token, "released_test", wall_budget=10) + res_get_changed = get_submission(client, member_token, "released_test") + assert res_get_changed.status_code == 404 + + submit_solution(client, member_token, "released_test", player_solution=[3, 5, 7]) + delete_level(client, admin_token, "released_test") + res_get_deleted = get_submission(client, member_token, "released_test") + assert res_get_deleted.status_code == 404 + + +def test_non_member_cannot_access_member_routes(client, non_member_token, admin_token): + create_level(client, admin_token, level_id="released_test") + + res_get_level_non_member = get_level(client, non_member_token, "released_test") + assert res_get_level_non_member.status_code == 403 + + res_get_all_levels_non_member = get_all_levels(client, non_member_token) + assert res_get_all_levels_non_member.status_code == 403 + + res_submit_non_member = submit_solution(client, non_member_token, "released_test", player_solution=[3, 5, 7]) + assert res_submit_non_member.status_code == 403 + + res_get_submission_non_member = submit_solution( + client, non_member_token, "released_test", player_solution=[3, 5, 7] + ) + assert res_get_submission_non_member.status_code == 403 From 7a98ec872095839e05e4fc177280b1dc9b86837b Mon Sep 17 00:00:00 2001 From: Mikael de Verdier <107776328+MikaeldeVerdier@users.noreply.github.com> Date: Sat, 1 Aug 2026 01:07:45 +0200 Subject: [PATCH 04/12] Added ENCLOSE_MOOSE_TOKEN to test workflow env --- .github/workflows/custom-tests-v2.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/custom-tests-v2.yml b/.github/workflows/custom-tests-v2.yml index 829b4e8f..b9e15637 100644 --- a/.github/workflows/custom-tests-v2.yml +++ b/.github/workflows/custom-tests-v2.yml @@ -81,4 +81,5 @@ jobs: TEST_REDIS_URL: redis://localhost:6379/0 ENVIRONMENT: "testing" USER_MANAGER_SECRET: "test_secret" + ENCLOSE_MOOSE_TOKEN: "happy_secret_key" run: pytest -v From 6435c649028deec3f8bde28edbfa1b9f1a6f98e4 Mon Sep 17 00:00:00 2001 From: Mikael de Verdier Date: Mon, 3 Aug 2026 15:36:28 +0000 Subject: [PATCH 05/12] minor improvement to bfs --- services/enclose_moose_service.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/services/enclose_moose_service.py b/services/enclose_moose_service.py index 0fd6b41c..a30778d7 100644 --- a/services/enclose_moose_service.py +++ b/services/enclose_moose_service.py @@ -217,9 +217,13 @@ def score_solution(self, solution: set[int]): for neighbor_index in self.get_neighbors(current_index): is_visited = neighbor_index in visited - is_invalid = neighbor_index < 0 or neighbor_index > self.N - 1 + if is_visited: + continue + + visited.add(neighbor_index) + is_blocked = self.grid_string[neighbor_index] == "~" or neighbor_index in solution - if is_visited or is_invalid or is_blocked: + if is_blocked: continue is_escaped = self.is_boundary(neighbor_index) @@ -227,7 +231,6 @@ def score_solution(self, solution: set[int]): raise HTTPException(400, detail="The solution does not enclose the moose") queue.append(neighbor_index) - visited.add(neighbor_index) return score # len(visited) + bonus score From f23d8a6f29712f44a5a9d13a96412f08a4194986 Mon Sep 17 00:00:00 2001 From: Mikael de Verdier Date: Mon, 3 Aug 2026 16:14:18 +0000 Subject: [PATCH 06/12] added get_all_submissions and admin_get_all_submissions routes; updated routing structure; minor improvements to tests --- routes/enclose_moose_router.py | 45 +++++++++++++++---- tests/test_enclose_moose.py | 82 ++++++++++++++++++++++++---------- 2 files changed, 94 insertions(+), 33 deletions(-) diff --git a/routes/enclose_moose_router.py b/routes/enclose_moose_router.py index 2569b449..f1182fc9 100644 --- a/routes/enclose_moose_router.py +++ b/routes/enclose_moose_router.py @@ -24,7 +24,7 @@ # Admin routes @enclose_moose_router.post( - "/admin", response_model=EncloseMooseLevelRead, dependencies=[Permission.require("manage", "EncloseMoose")] + "/admin/levels", response_model=EncloseMooseLevelRead, dependencies=[Permission.require("manage", "EncloseMoose")] ) def admin_create_level(data: EncloseMooseLevelCreate, db: DB_dependency): level = level_create(data) @@ -43,7 +43,7 @@ def admin_create_level(data: EncloseMooseLevelCreate, db: DB_dependency): @enclose_moose_router.get( - "/admin/{level_id}", + "/admin/levels/{level_id}", response_model=EncloseMooseLevelRead, dependencies=[Permission.require("manage", "EncloseMoose")], ) @@ -56,7 +56,9 @@ def admin_get_level(level_id: str, db: DB_dependency): @enclose_moose_router.get( - "/admin", response_model=list[EncloseMooseLevelRead], dependencies=[Permission.require("manage", "EncloseMoose")] + "/admin/levels", + response_model=list[EncloseMooseLevelRead], + dependencies=[Permission.require("manage", "EncloseMoose")], ) def admin_get_all_levels(db: DB_dependency): levels = db.query(EncloseMooseLevel_DB).order_by(EncloseMooseLevel_DB.release_date).all() @@ -65,7 +67,7 @@ def admin_get_all_levels(db: DB_dependency): @enclose_moose_router.patch( - "/admin/{level_id}", + "/admin/levels/{level_id}", response_model=EncloseMooseLevelRead, dependencies=[Permission.require("manage", "EncloseMoose")], ) @@ -85,7 +87,7 @@ def admin_update_level(level_id: str, data: EncloseMooseLevelUpdate, db: DB_depe @enclose_moose_router.delete( - "/admin/{level_id}", + "/admin/levels/{level_id}", response_model=EncloseMooseLevelRead, dependencies=[Permission.require("manage", "EncloseMoose")], ) @@ -100,8 +102,23 @@ def admin_delete_level(level_id: str, db: DB_dependency): return level +@enclose_moose_router.get( + "/admin/submissions", + response_model=list[EncloseMooseSubmissionRead], + dependencies=[Permission.require("manage", "EncloseMoose")], +) +def admin_get_all_submissions( + db: DB_dependency, +): + submissions = db.query(EncloseMooseSubmission_DB).all() + + return submissions + + # Non-admin routes -@enclose_moose_router.get("/{level_id}", response_model=EncloseMooseLevelRead, dependencies=[Permission.member()]) +@enclose_moose_router.get( + "/levels/{level_id}", response_model=EncloseMooseLevelRead, dependencies=[Permission.member()] +) def get_level(level_id: str, db: DB_dependency): date_today = datetime.now(ZoneInfo("Europe/Stockholm")).date() level = ( @@ -115,7 +132,7 @@ def get_level(level_id: str, db: DB_dependency): return level -@enclose_moose_router.get("/", response_model=list[EncloseMooseLevelRead], dependencies=[Permission.member()]) +@enclose_moose_router.get("/levels", response_model=list[EncloseMooseLevelRead], dependencies=[Permission.member()]) def get_all_levels(db: DB_dependency): date_today = datetime.now(ZoneInfo("Europe/Stockholm")).date() @@ -129,7 +146,7 @@ def get_all_levels(db: DB_dependency): return levels -@enclose_moose_router.post("/{level_id}/submit", response_model=EncloseMooseSubmissionRead) +@enclose_moose_router.post("/submissions/{level_id}", response_model=EncloseMooseSubmissionRead) def submit_solution( level_id: str, submission: EncloseMooseSubmissionCreate, @@ -164,7 +181,7 @@ def submit_solution( return db_submission -@enclose_moose_router.get("/{level_id}/submit", response_model=EncloseMooseSubmissionRead) +@enclose_moose_router.get("/submissions/{level_id}", response_model=EncloseMooseSubmissionRead) def get_submission( level_id: str, me: Annotated[User_DB, Permission.member()], @@ -175,3 +192,13 @@ def get_submission( raise HTTPException(404, detail="No submission exists for this player and level") return submission + + +@enclose_moose_router.get("/submissions", response_model=list[EncloseMooseSubmissionRead]) +def get_all_submissions( + me: Annotated[User_DB, Permission.member()], + db: DB_dependency, +): + submissions = db.query(EncloseMooseSubmission_DB).filter(EncloseMooseSubmission_DB.player_id == me.id).all() + + return submissions diff --git a/tests/test_enclose_moose.py b/tests/test_enclose_moose.py index f984c52f..fa4d92c2 100644 --- a/tests/test_enclose_moose.py +++ b/tests/test_enclose_moose.py @@ -6,31 +6,31 @@ def create_level(client, token, **kwargs): default_data = {"level_id": "test_level", "name": "test", "encoded_grid": ".~.\n.H.\n~.~", "wall_budget": 4} - return client.post("/enclose-moose/admin", json=default_data | kwargs, headers=auth_headers(token)) + return client.post("/enclose-moose/admin/levels", json=default_data | kwargs, headers=auth_headers(token)) def patch_level(client, token, level_id, **kwargs): - return client.patch(f"/enclose-moose/admin/{level_id}", json=kwargs, headers=auth_headers(token)) + return client.patch(f"/enclose-moose/admin/levels/{level_id}", json=kwargs, headers=auth_headers(token)) def delete_level(client, token, level_id): - return client.delete(f"/enclose-moose/admin/{level_id}", headers=auth_headers(token)) + return client.delete(f"/enclose-moose/admin/levels/{level_id}", headers=auth_headers(token)) def admin_get_level(client, token, level_id): - return client.get(f"/enclose-moose/admin/{level_id}", headers=auth_headers(token)) + return client.get(f"/enclose-moose/admin/levels/{level_id}", headers=auth_headers(token)) def get_level(client, token, level_id): - return client.get(f"/enclose-moose/{level_id}", headers=auth_headers(token)) + return client.get(f"/enclose-moose/levels/{level_id}", headers=auth_headers(token)) def admin_get_all_levels(client, token): - return client.get(f"/enclose-moose/admin", headers=auth_headers(token)) + return client.get("/enclose-moose/admin/levels", headers=auth_headers(token)) def get_all_levels(client, token): - return client.get(f"/enclose-moose", headers=auth_headers(token)) + return client.get("/enclose-moose/levels", headers=auth_headers(token)) def submit_solution(client, token, level_id, player_solution, secret_header="happy_secret_key"): @@ -39,11 +39,19 @@ def submit_solution(client, token, level_id, player_solution, secret_header="hap headers["enclose-moose-token"] = secret_header body = {"player_solution": player_solution} - return client.post(f"/enclose-moose/{level_id}/submit", json=body, headers=headers) + return client.post(f"/enclose-moose/submissions/{level_id}", json=body, headers=headers) def get_submission(client, token, level_id): - return client.get(f"/enclose-moose/{level_id}/submit", headers=auth_headers(token)) + return client.get(f"/enclose-moose/submissions/{level_id}", headers=auth_headers(token)) + + +def admin_get_all_submissions(client, token): + return client.get("/enclose-moose/admin/submissions", headers=auth_headers(token)) + + +def get_all_submissions(client, token): + return client.get("/enclose-moose/submissions", headers=auth_headers(token)) def test_admin_manage_level(client, admin_token): @@ -56,6 +64,12 @@ def test_admin_manage_level(client, admin_token): res_create = create_level(client, admin_token, level_id="released_test") assert res_create.status_code == 200 + res_get = admin_get_level(client, admin_token, "released_test") + assert res_get.status_code == 200 + + res_get_all = admin_get_all_levels(client, admin_token) + assert res_get_all.status_code == 200 + res_patch = patch_level(client, admin_token, "released_test", name="updated_name") assert res_patch.status_code == 200 assert res_patch.json()["name"] == "updated_name" @@ -66,6 +80,9 @@ def test_admin_manage_level(client, admin_token): res_get = admin_get_level(client, admin_token, "released_test") assert res_get.status_code == 404 + res_get_submissions = admin_get_all_submissions(client, admin_token) + assert res_get_submissions.status_code == 200 + def test_admin_duplicate_level( client, admin_token @@ -85,6 +102,9 @@ def test_member_cannot_access_admin_routes(client, member_token, admin_token): res_admin_get = admin_get_level(client, member_token, "released_test") assert res_admin_get.status_code == 403 + res_admin_get_all = admin_get_all_levels(client, member_token) + assert res_admin_get_all.status_code == 403 + res_patch = patch_level(client, member_token, "released_test", name="updated_name") assert res_patch.status_code == 403 res_patch_get = get_level(client, member_token, "released_test") @@ -96,8 +116,11 @@ def test_member_cannot_access_admin_routes(client, member_token, admin_token): res_del_get = get_level(client, member_token, "released_test") assert res_del_get.status_code == 200 + res_submissions = admin_get_all_submissions(client, member_token) + assert res_submissions.status_code == 403 -def test_get_levels(client, member_token, admin_token): + +def test_levels(client, member_token, admin_token): future_date = (datetime.now(UTC).date() + timedelta(days=2)).isoformat() create_level(client, admin_token, level_id="unreleased_test", release_date=future_date) create_level(client, admin_token, level_id="released_test") @@ -124,11 +147,11 @@ def test_get_levels(client, member_token, admin_token): def test_submission(client, member_token, admin_token): - res_non_existant = submit_solution(client, member_token, "released_test", player_solution=[3, 5, 7]) - assert res_non_existant.status_code == 404 + res_non_existent = submit_solution(client, member_token, "released_test", player_solution=[3, 5, 7]) + assert res_non_existent.status_code == 404 - res_get_non_existant = get_submission(client, member_token, "released_test") - assert res_get_non_existant.status_code == 404 + res_get_non_existent = get_submission(client, member_token, "released_test") + assert res_get_non_existent.status_code == 404 create_level(client, admin_token, level_id="released_test") res_member = submit_solution(client, member_token, "released_test", player_solution=[3, 5, 7]) @@ -137,6 +160,11 @@ def test_submission(client, member_token, admin_token): res_member_invalid = submit_solution(client, member_token, "released_test", player_solution=[3, 5]) assert res_member_invalid.status_code == 400 + res_invalid_token = submit_solution( + client, member_token, "released_test", player_solution=[3, 5, 7], secret_header="I love tests!" + ) + assert res_invalid_token.status_code == 401 + res_get = get_submission(client, member_token, "released_test") assert res_get.status_code == 200 @@ -145,6 +173,11 @@ def test_submission(client, member_token, admin_token): res_unreleased = submit_solution(client, member_token, "unreleased_test", player_solution=[3, 5, 7]) assert res_unreleased.status_code == 404 + submit_solution(client, admin_token, "released_test", player_solution=[3, 5, 7]) + res_get_all = get_all_submissions(client, member_token) + assert res_get_all.status_code == 200 + assert len(res_get_all.json()) == 1 + def test_submissions_clear(client, member_token, admin_token): create_level(client, admin_token, level_id="released_test") @@ -167,16 +200,17 @@ def test_submissions_clear(client, member_token, admin_token): def test_non_member_cannot_access_member_routes(client, non_member_token, admin_token): create_level(client, admin_token, level_id="released_test") - res_get_level_non_member = get_level(client, non_member_token, "released_test") - assert res_get_level_non_member.status_code == 403 + res_get = get_level(client, non_member_token, "released_test") + assert res_get.status_code == 403 - res_get_all_levels_non_member = get_all_levels(client, non_member_token) - assert res_get_all_levels_non_member.status_code == 403 + res_get_all = get_all_levels(client, non_member_token) + assert res_get_all.status_code == 403 - res_submit_non_member = submit_solution(client, non_member_token, "released_test", player_solution=[3, 5, 7]) - assert res_submit_non_member.status_code == 403 + res_submit = submit_solution(client, non_member_token, "released_test", player_solution=[3, 5, 7]) + assert res_submit.status_code == 403 - res_get_submission_non_member = submit_solution( - client, non_member_token, "released_test", player_solution=[3, 5, 7] - ) - assert res_get_submission_non_member.status_code == 403 + res_get_submission = submit_solution(client, non_member_token, "released_test", player_solution=[3, 5, 7]) + assert res_get_submission.status_code == 403 + + res_get_all_submissions = get_all_submissions(client, non_member_token) + assert res_get_all_submissions.status_code == 403 From e142618ff492dc3af4ab62b302869aaeb30569d9 Mon Sep 17 00:00:00 2001 From: Mikael de Verdier Date: Tue, 4 Aug 2026 15:01:52 +0000 Subject: [PATCH 07/12] Separated LevelRead into InitialRead and UnlockedRead (before and after submit); added player_submission to InitialRead and score_distribution to UnlockedRead; updated router to match; changed admin_get_all_submissions to admin_get_all_level_submissions; updated and slightly improved tests --- api_schemas/enclose_moose_level_schema.py | 11 ++++- db_models/enclose_moose_level_model.py | 8 ++++ routes/enclose_moose_router.py | 55 +++++++++++++++-------- tests/test_enclose_moose.py | 22 ++++----- 4 files changed, 65 insertions(+), 31 deletions(-) diff --git a/api_schemas/enclose_moose_level_schema.py b/api_schemas/enclose_moose_level_schema.py index 2bf264fe..837318e5 100644 --- a/api_schemas/enclose_moose_level_schema.py +++ b/api_schemas/enclose_moose_level_schema.py @@ -1,9 +1,10 @@ from api_schemas.base_schema import BaseSchema from datetime import datetime, date from zoneinfo import ZoneInfo +from api_schemas.enclose_moose_submission_schema import EncloseMooseSubmissionRead -class EncloseMooseLevelRead(BaseSchema): +class EncloseMooseLevelInitialRead(BaseSchema): level_id: str release_date: date day_index: int | None @@ -12,10 +13,16 @@ class EncloseMooseLevelRead(BaseSchema): encoded_grid: str wall_budget: int + player_submission: EncloseMooseSubmissionRead | None = None + + +class EncloseMooseLevelUnlockedRead(EncloseMooseLevelInitialRead): optimal_score: int - optimal_solution: set[int] # Could consider not showing optimal_solution until player has submitted + optimal_solution: set[int] optimal_is_unique: bool | None + score_distribution: dict[int, int] + class EncloseMooseLevelCreate(BaseSchema): level_id: str diff --git a/db_models/enclose_moose_level_model.py b/db_models/enclose_moose_level_model.py index 4bcb95df..71b74a7d 100644 --- a/db_models/enclose_moose_level_model.py +++ b/db_models/enclose_moose_level_model.py @@ -27,3 +27,11 @@ class EncloseMooseLevel_DB(BaseModel_DB): submissions: Mapped[list["EncloseMooseSubmission_DB"]] = relationship( back_populates="level", cascade="all, delete-orphan", init=False ) + + @property + def score_distribution(self): + score_distribution: dict[int, int] = {} + for submission in self.submissions: + score_distribution[submission.player_score] = score_distribution.get(submission.player_score, 0) + 1 + + return score_distribution diff --git a/routes/enclose_moose_router.py b/routes/enclose_moose_router.py index f1182fc9..54e24bbb 100644 --- a/routes/enclose_moose_router.py +++ b/routes/enclose_moose_router.py @@ -5,7 +5,8 @@ from typing import Annotated from sqlalchemy.exc import DataError, IntegrityError from api_schemas.enclose_moose_level_schema import ( - EncloseMooseLevelRead, + EncloseMooseLevelInitialRead, + EncloseMooseLevelUnlockedRead, EncloseMooseLevelCreate, EncloseMooseLevelUpdate, ) @@ -24,7 +25,9 @@ # Admin routes @enclose_moose_router.post( - "/admin/levels", response_model=EncloseMooseLevelRead, dependencies=[Permission.require("manage", "EncloseMoose")] + "/admin/levels", + response_model=EncloseMooseLevelUnlockedRead, + dependencies=[Permission.require("manage", "EncloseMoose")], ) def admin_create_level(data: EncloseMooseLevelCreate, db: DB_dependency): level = level_create(data) @@ -44,7 +47,7 @@ def admin_create_level(data: EncloseMooseLevelCreate, db: DB_dependency): @enclose_moose_router.get( "/admin/levels/{level_id}", - response_model=EncloseMooseLevelRead, + response_model=EncloseMooseLevelUnlockedRead, dependencies=[Permission.require("manage", "EncloseMoose")], ) def admin_get_level(level_id: str, db: DB_dependency): @@ -57,7 +60,7 @@ def admin_get_level(level_id: str, db: DB_dependency): @enclose_moose_router.get( "/admin/levels", - response_model=list[EncloseMooseLevelRead], + response_model=list[EncloseMooseLevelUnlockedRead], dependencies=[Permission.require("manage", "EncloseMoose")], ) def admin_get_all_levels(db: DB_dependency): @@ -68,7 +71,7 @@ def admin_get_all_levels(db: DB_dependency): @enclose_moose_router.patch( "/admin/levels/{level_id}", - response_model=EncloseMooseLevelRead, + response_model=EncloseMooseLevelUnlockedRead, dependencies=[Permission.require("manage", "EncloseMoose")], ) def admin_update_level(level_id: str, data: EncloseMooseLevelUpdate, db: DB_dependency): @@ -88,7 +91,7 @@ def admin_update_level(level_id: str, data: EncloseMooseLevelUpdate, db: DB_depe @enclose_moose_router.delete( "/admin/levels/{level_id}", - response_model=EncloseMooseLevelRead, + response_model=EncloseMooseLevelUnlockedRead, dependencies=[Permission.require("manage", "EncloseMoose")], ) def admin_delete_level(level_id: str, db: DB_dependency): @@ -103,23 +106,22 @@ def admin_delete_level(level_id: str, db: DB_dependency): @enclose_moose_router.get( - "/admin/submissions", + "/admin/submissions/{level_id}", response_model=list[EncloseMooseSubmissionRead], dependencies=[Permission.require("manage", "EncloseMoose")], ) -def admin_get_all_submissions( +def admin_get_all_level_submissions( + level_id: str, db: DB_dependency, ): - submissions = db.query(EncloseMooseSubmission_DB).all() + submissions = db.query(EncloseMooseSubmission_DB).filter(EncloseMooseSubmission_DB.level_id == level_id).all() return submissions # Non-admin routes -@enclose_moose_router.get( - "/levels/{level_id}", response_model=EncloseMooseLevelRead, dependencies=[Permission.member()] -) -def get_level(level_id: str, db: DB_dependency): +@enclose_moose_router.get("/levels/{level_id}", response_model=EncloseMooseLevelInitialRead) +def get_level(level_id: str, me: Annotated[User_DB, Permission.member()], db: DB_dependency): date_today = datetime.now(ZoneInfo("Europe/Stockholm")).date() level = ( db.query(EncloseMooseLevel_DB) @@ -129,24 +131,37 @@ def get_level(level_id: str, db: DB_dependency): if level is None: raise HTTPException(404, detail=f'No level with level_id "{level_id}" exists') + submission = db.get(EncloseMooseSubmission_DB, (level_id, me.id)) + level.player_submission = submission # pyright: ignore + return level -@enclose_moose_router.get("/levels", response_model=list[EncloseMooseLevelRead], dependencies=[Permission.member()]) -def get_all_levels(db: DB_dependency): +@enclose_moose_router.get("/levels", response_model=list[EncloseMooseLevelInitialRead]) +def get_all_levels(me: Annotated[User_DB, Permission.member()], db: DB_dependency): date_today = datetime.now(ZoneInfo("Europe/Stockholm")).date() - levels = ( - db.query(EncloseMooseLevel_DB) + results = ( + db.query(EncloseMooseLevel_DB, EncloseMooseSubmission_DB) + .outerjoin( + EncloseMooseSubmission_DB, + (EncloseMooseSubmission_DB.level_id == EncloseMooseLevel_DB.level_id) + & (EncloseMooseSubmission_DB.player_id == me.id), + ) .filter(EncloseMooseLevel_DB.release_date <= date_today) .order_by(EncloseMooseLevel_DB.release_date) .all() ) + levels: list[EncloseMooseLevel_DB] = [] + for level, submission in results: + level.player_submission = submission + levels.append(level) + return levels -@enclose_moose_router.post("/submissions/{level_id}", response_model=EncloseMooseSubmissionRead) +@enclose_moose_router.post("/submissions/{level_id}", response_model=EncloseMooseLevelUnlockedRead) def submit_solution( level_id: str, submission: EncloseMooseSubmissionCreate, @@ -178,7 +193,9 @@ def submit_solution( detail="The player has already submitted a solution to this level", ) - return db_submission + level.player_submission = db_submission # pyright: ignore + + return level @enclose_moose_router.get("/submissions/{level_id}", response_model=EncloseMooseSubmissionRead) diff --git a/tests/test_enclose_moose.py b/tests/test_enclose_moose.py index fa4d92c2..93ecf55b 100644 --- a/tests/test_enclose_moose.py +++ b/tests/test_enclose_moose.py @@ -46,15 +46,15 @@ def get_submission(client, token, level_id): return client.get(f"/enclose-moose/submissions/{level_id}", headers=auth_headers(token)) -def admin_get_all_submissions(client, token): - return client.get("/enclose-moose/admin/submissions", headers=auth_headers(token)) +def admin_get_all_level_submissions(client, token, level_id): + return client.get(f"/enclose-moose/admin/submissions/{level_id}", headers=auth_headers(token)) -def get_all_submissions(client, token): +def get_all_my_submissions(client, token): return client.get("/enclose-moose/submissions", headers=auth_headers(token)) -def test_admin_manage_level(client, admin_token): +def test_admin_manage_level(client, member_token, admin_token): res_create_invalid = create_level(client, admin_token, encoded_grid=".~.") assert res_create_invalid.status_code == 400 @@ -70,6 +70,11 @@ def test_admin_manage_level(client, admin_token): res_get_all = admin_get_all_levels(client, admin_token) assert res_get_all.status_code == 200 + submit_solution(client, member_token, "released_test", player_solution=[3, 5, 7]) + res_get_submissions = admin_get_all_level_submissions(client, admin_token, "released_test") + assert res_get_submissions.status_code == 200 + assert len(res_get_submissions.json()) == 1 + res_patch = patch_level(client, admin_token, "released_test", name="updated_name") assert res_patch.status_code == 200 assert res_patch.json()["name"] == "updated_name" @@ -80,9 +85,6 @@ def test_admin_manage_level(client, admin_token): res_get = admin_get_level(client, admin_token, "released_test") assert res_get.status_code == 404 - res_get_submissions = admin_get_all_submissions(client, admin_token) - assert res_get_submissions.status_code == 200 - def test_admin_duplicate_level( client, admin_token @@ -116,7 +118,7 @@ def test_member_cannot_access_admin_routes(client, member_token, admin_token): res_del_get = get_level(client, member_token, "released_test") assert res_del_get.status_code == 200 - res_submissions = admin_get_all_submissions(client, member_token) + res_submissions = admin_get_all_level_submissions(client, member_token, "released_test") assert res_submissions.status_code == 403 @@ -174,7 +176,7 @@ def test_submission(client, member_token, admin_token): assert res_unreleased.status_code == 404 submit_solution(client, admin_token, "released_test", player_solution=[3, 5, 7]) - res_get_all = get_all_submissions(client, member_token) + res_get_all = get_all_my_submissions(client, member_token) assert res_get_all.status_code == 200 assert len(res_get_all.json()) == 1 @@ -212,5 +214,5 @@ def test_non_member_cannot_access_member_routes(client, non_member_token, admin_ res_get_submission = submit_solution(client, non_member_token, "released_test", player_solution=[3, 5, 7]) assert res_get_submission.status_code == 403 - res_get_all_submissions = get_all_submissions(client, non_member_token) + res_get_all_submissions = get_all_my_submissions(client, non_member_token) assert res_get_all_submissions.status_code == 403 From ce37c078526c4c9b50e68f2bffde7ab71eaca1ec Mon Sep 17 00:00:00 2001 From: Mikael de Verdier Date: Tue, 4 Aug 2026 16:35:11 +0000 Subject: [PATCH 08/12] removed initial and unlocked schemas and instead made spoiler fields nullable, only shown if player_submission is not None --- api_schemas/enclose_moose_level_schema.py | 24 +++++++++++++++-------- routes/enclose_moose_router.py | 19 +++++++++--------- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/api_schemas/enclose_moose_level_schema.py b/api_schemas/enclose_moose_level_schema.py index 837318e5..2ff7401a 100644 --- a/api_schemas/enclose_moose_level_schema.py +++ b/api_schemas/enclose_moose_level_schema.py @@ -1,10 +1,11 @@ -from api_schemas.base_schema import BaseSchema +from pydantic import model_validator from datetime import datetime, date from zoneinfo import ZoneInfo +from api_schemas.base_schema import BaseSchema from api_schemas.enclose_moose_submission_schema import EncloseMooseSubmissionRead -class EncloseMooseLevelInitialRead(BaseSchema): +class EncloseMooseLevelRead(BaseSchema): level_id: str release_date: date day_index: int | None @@ -13,15 +14,22 @@ class EncloseMooseLevelInitialRead(BaseSchema): encoded_grid: str wall_budget: int - player_submission: EncloseMooseSubmissionRead | None = None + optimal_score: int | None = None + optimal_solution: set[int] | None = None + optimal_is_unique: bool | None = None + player_submission: EncloseMooseSubmissionRead | None = None + score_distribution: dict[int, int] | None = None -class EncloseMooseLevelUnlockedRead(EncloseMooseLevelInitialRead): - optimal_score: int - optimal_solution: set[int] - optimal_is_unique: bool | None + @model_validator(mode="after") + def redact_spoilers(self): + if self.player_submission is None: + self.optimal_score = None + self.optimal_solution = None + self.optimal_is_unique = None + self.score_distribution = None - score_distribution: dict[int, int] + return self class EncloseMooseLevelCreate(BaseSchema): diff --git a/routes/enclose_moose_router.py b/routes/enclose_moose_router.py index 54e24bbb..598e7ea4 100644 --- a/routes/enclose_moose_router.py +++ b/routes/enclose_moose_router.py @@ -5,8 +5,7 @@ from typing import Annotated from sqlalchemy.exc import DataError, IntegrityError from api_schemas.enclose_moose_level_schema import ( - EncloseMooseLevelInitialRead, - EncloseMooseLevelUnlockedRead, + EncloseMooseLevelRead, EncloseMooseLevelCreate, EncloseMooseLevelUpdate, ) @@ -26,7 +25,7 @@ # Admin routes @enclose_moose_router.post( "/admin/levels", - response_model=EncloseMooseLevelUnlockedRead, + response_model=EncloseMooseLevelRead, dependencies=[Permission.require("manage", "EncloseMoose")], ) def admin_create_level(data: EncloseMooseLevelCreate, db: DB_dependency): @@ -47,7 +46,7 @@ def admin_create_level(data: EncloseMooseLevelCreate, db: DB_dependency): @enclose_moose_router.get( "/admin/levels/{level_id}", - response_model=EncloseMooseLevelUnlockedRead, + response_model=EncloseMooseLevelRead, dependencies=[Permission.require("manage", "EncloseMoose")], ) def admin_get_level(level_id: str, db: DB_dependency): @@ -60,7 +59,7 @@ def admin_get_level(level_id: str, db: DB_dependency): @enclose_moose_router.get( "/admin/levels", - response_model=list[EncloseMooseLevelUnlockedRead], + response_model=list[EncloseMooseLevelRead], dependencies=[Permission.require("manage", "EncloseMoose")], ) def admin_get_all_levels(db: DB_dependency): @@ -71,7 +70,7 @@ def admin_get_all_levels(db: DB_dependency): @enclose_moose_router.patch( "/admin/levels/{level_id}", - response_model=EncloseMooseLevelUnlockedRead, + response_model=EncloseMooseLevelRead, dependencies=[Permission.require("manage", "EncloseMoose")], ) def admin_update_level(level_id: str, data: EncloseMooseLevelUpdate, db: DB_dependency): @@ -91,7 +90,7 @@ def admin_update_level(level_id: str, data: EncloseMooseLevelUpdate, db: DB_depe @enclose_moose_router.delete( "/admin/levels/{level_id}", - response_model=EncloseMooseLevelUnlockedRead, + response_model=EncloseMooseLevelRead, dependencies=[Permission.require("manage", "EncloseMoose")], ) def admin_delete_level(level_id: str, db: DB_dependency): @@ -120,7 +119,7 @@ def admin_get_all_level_submissions( # Non-admin routes -@enclose_moose_router.get("/levels/{level_id}", response_model=EncloseMooseLevelInitialRead) +@enclose_moose_router.get("/levels/{level_id}", response_model=EncloseMooseLevelRead) def get_level(level_id: str, me: Annotated[User_DB, Permission.member()], db: DB_dependency): date_today = datetime.now(ZoneInfo("Europe/Stockholm")).date() level = ( @@ -137,7 +136,7 @@ def get_level(level_id: str, me: Annotated[User_DB, Permission.member()], db: DB return level -@enclose_moose_router.get("/levels", response_model=list[EncloseMooseLevelInitialRead]) +@enclose_moose_router.get("/levels", response_model=list[EncloseMooseLevelRead]) def get_all_levels(me: Annotated[User_DB, Permission.member()], db: DB_dependency): date_today = datetime.now(ZoneInfo("Europe/Stockholm")).date() @@ -161,7 +160,7 @@ def get_all_levels(me: Annotated[User_DB, Permission.member()], db: DB_dependenc return levels -@enclose_moose_router.post("/submissions/{level_id}", response_model=EncloseMooseLevelUnlockedRead) +@enclose_moose_router.post("/submissions/{level_id}", response_model=EncloseMooseLevelRead) def submit_solution( level_id: str, submission: EncloseMooseSubmissionCreate, From 2502eb2c2fd6ac5a5aa3f1acae8b9d6609e5e085 Mon Sep 17 00:00:00 2001 From: Mikael de Verdier Date: Tue, 4 Aug 2026 16:57:16 +0000 Subject: [PATCH 09/12] added a separate flag for show_spoilers, otherwise admins couldn't see it --- api_schemas/enclose_moose_level_schema.py | 6 ++++-- routes/enclose_moose_router.py | 11 +++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/api_schemas/enclose_moose_level_schema.py b/api_schemas/enclose_moose_level_schema.py index 2ff7401a..20a18123 100644 --- a/api_schemas/enclose_moose_level_schema.py +++ b/api_schemas/enclose_moose_level_schema.py @@ -1,4 +1,4 @@ -from pydantic import model_validator +from pydantic import Field, model_validator from datetime import datetime, date from zoneinfo import ZoneInfo from api_schemas.base_schema import BaseSchema @@ -14,6 +14,8 @@ class EncloseMooseLevelRead(BaseSchema): encoded_grid: str wall_budget: int + show_spoilers: bool = Field(default=False, exclude=True) + optimal_score: int | None = None optimal_solution: set[int] | None = None optimal_is_unique: bool | None = None @@ -23,7 +25,7 @@ class EncloseMooseLevelRead(BaseSchema): @model_validator(mode="after") def redact_spoilers(self): - if self.player_submission is None: + if not self.show_spoilers: self.optimal_score = None self.optimal_solution = None self.optimal_is_unique = None diff --git a/routes/enclose_moose_router.py b/routes/enclose_moose_router.py index 598e7ea4..9766d1f3 100644 --- a/routes/enclose_moose_router.py +++ b/routes/enclose_moose_router.py @@ -41,6 +41,8 @@ def admin_create_level(data: EncloseMooseLevelCreate, db: DB_dependency): db.rollback() raise HTTPException(409, detail=f'A level with level_id "{data.level_id}" already exists') + level.show_spoilers = True # pyright: ignore + return level @@ -54,6 +56,8 @@ def admin_get_level(level_id: str, db: DB_dependency): if level is None: raise HTTPException(404, detail=f'No level with level_id "{level_id}" exists') + level.show_spoilers = True # pyright: ignore + return level @@ -65,6 +69,9 @@ def admin_get_level(level_id: str, db: DB_dependency): def admin_get_all_levels(db: DB_dependency): levels = db.query(EncloseMooseLevel_DB).order_by(EncloseMooseLevel_DB.release_date).all() + for level in levels: + level.show_spoilers = True # pyright: ignore + return levels @@ -85,6 +92,8 @@ def admin_update_level(level_id: str, data: EncloseMooseLevelUpdate, db: DB_depe db.rollback() raise HTTPException(400, detail="Some string is too long") + updated_level.show_spoilers = True # pyright: ignore + return updated_level @@ -101,6 +110,8 @@ def admin_delete_level(level_id: str, db: DB_dependency): db.delete(level) db.commit() + level.show_spoilers = True # pyright: ignore + return level From 554e32f37de9e746972df01376d49268e3745802 Mon Sep 17 00:00:00 2001 From: Mikael de Verdier Date: Tue, 4 Aug 2026 20:20:18 +0000 Subject: [PATCH 10/12] fixed show_spoilers after submission --- routes/enclose_moose_router.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/routes/enclose_moose_router.py b/routes/enclose_moose_router.py index 9766d1f3..3555e57c 100644 --- a/routes/enclose_moose_router.py +++ b/routes/enclose_moose_router.py @@ -143,6 +143,7 @@ def get_level(level_id: str, me: Annotated[User_DB, Permission.member()], db: DB submission = db.get(EncloseMooseSubmission_DB, (level_id, me.id)) level.player_submission = submission # pyright: ignore + level.show_spoilers = submission is not None # pyright: ignore return level @@ -166,6 +167,7 @@ def get_all_levels(me: Annotated[User_DB, Permission.member()], db: DB_dependenc levels: list[EncloseMooseLevel_DB] = [] for level, submission in results: level.player_submission = submission + level.show_spoilers = submission is not None # pyright: ignore levels.append(level) return levels @@ -204,6 +206,7 @@ def submit_solution( ) level.player_submission = db_submission # pyright: ignore + level.show_spoilers = True # pyright: ignore return level From 52bd87948921b3d276d92b2f2cb986c3bd845f84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20Bl=C3=A5man?= <29555754+hannesblaman@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:58:09 +0200 Subject: [PATCH 11/12] Added option to specify group types for nollning events (#551) * add required columns for mentor group type validation * Add group type validation on event signup * Add allow other mentors option * Fixed lottery not in event update schema * Also check group allowed on update signup * Added endpoint for getting user data needed for event signup * 403 for event invalid group * Teeny tiny function name change * Fix unrelated route with no permission gate * Guard against changing lottery after signups have been confirmed * Move group check before updating signup, fix bug if group_name ended up as None, allow admins to edit to whatever group they want * Added unit tests of events as a sidetrack task. We (I) want unit tests to cover everything important. * Add unit tests for event signups, checking so that some bugs don't return * Delete helpers/user_util.py --------- Co-authored-by: georgelgeback --- api_schemas/event_schemas.py | 9 +- api_schemas/user_schemas.py | 6 ++ db_models/event_model.py | 10 +- routes/event_router.py | 5 +- routes/event_signup_router.py | 23 ++++- services/event_service.py | 6 ++ services/event_signup_service.py | 55 +++++++++++ tests/basic_factories.py | 51 ++++++++++ tests/basic_fixtures.py | 40 ++++++++ tests/test_event_signup.py | 152 ++++++++++++++++++++++++++++++ tests/test_events.py | 157 +++++++++++++++++++++++++++++++ 11 files changed, 505 insertions(+), 9 deletions(-) create mode 100644 tests/test_event_signup.py create mode 100644 tests/test_events.py diff --git a/api_schemas/event_schemas.py b/api_schemas/event_schemas.py index eaa23a96..f62e644f 100644 --- a/api_schemas/event_schemas.py +++ b/api_schemas/event_schemas.py @@ -2,7 +2,7 @@ from api_schemas.base_schema import BaseSchema from db_models.priority_model import Priority_DB from helpers.constants import MAX_EVENT_DESC, MAX_EVENT_TITLE -from helpers.types import ALCOHOL_EVENT_TYPES, EVENT_DOT_TYPES, datetime_utc +from helpers.types import ALCOHOL_EVENT_TYPES, EVENT_DOT_TYPES, GROUP_TYPE, datetime_utc from pydantic import StringConstraints if TYPE_CHECKING: @@ -31,6 +31,8 @@ class EventRead(BaseSchema): can_signup: bool drink_package: bool is_nollning_event: bool + mentor_group_types: list[GROUP_TYPE] + allow_other_mentors: bool alcohol_event_type: str dress_code: str price: int @@ -64,6 +66,8 @@ class EventCreate(BaseSchema): can_signup: bool drink_package: bool is_nollning_event: bool + mentor_group_types: list[GROUP_TYPE] + allow_other_mentors: bool alcohol_event_type: ALCOHOL_EVENT_TYPES dress_code: str price: int @@ -90,11 +94,14 @@ class EventUpdate(BaseSchema): can_signup: bool | None = None drink_package: bool | None = None is_nollning_event: bool | None = None + mentor_group_types: list[GROUP_TYPE] | None = None + allow_other_mentors: bool | None = None priorities: list[str] | None = None alcohol_event_type: ALCOHOL_EVENT_TYPES | None = None dress_code: str | None = None price: int | None = None dot: EVENT_DOT_TYPES | None = None + lottery: bool | None = None class AddEventTag(BaseSchema): diff --git a/api_schemas/user_schemas.py b/api_schemas/user_schemas.py index c765755c..1758695b 100644 --- a/api_schemas/user_schemas.py +++ b/api_schemas/user_schemas.py @@ -111,6 +111,12 @@ class UserInEventRead(SimpleUserRead): email: str +class UserForEventSignupRead(SimpleUserRead): + groups: list["GroupRead"] + standard_food_preferences: list[str] | None + other_food_preferences: str | None + + class AdminUserReadForCarBookings(SimpleUserRead): email: str telephone_number: PhoneNumber diff --git a/db_models/event_model.py b/db_models/event_model.py index c2ea9381..efd629c9 100644 --- a/db_models/event_model.py +++ b/db_models/event_model.py @@ -1,6 +1,6 @@ -from helpers.types import ALCOHOL_EVENT_TYPES, EVENT_DOT_TYPES, datetime_utc -from typing import TYPE_CHECKING -from sqlalchemy import ForeignKey, String +from helpers.types import ALCOHOL_EVENT_TYPES, EVENT_DOT_TYPES, GROUP_TYPE, datetime_utc +from typing import TYPE_CHECKING, get_args +from sqlalchemy import JSON, ForeignKey, String from sqlalchemy.orm import mapped_column, Mapped, relationship from helpers.constants import MAX_EVENT_DESC, MAX_EVENT_DRESS_CODE, MAX_EVENT_LOCATION, MAX_EVENT_TITLE from .base_model import BaseModel_DB @@ -69,6 +69,10 @@ class Event_DB(BaseModel_DB): ) is_nollning_event: Mapped[bool] = mapped_column(default=False) + mentor_group_types: Mapped[list[GROUP_TYPE]] = mapped_column( + JSON, default_factory=lambda: list(get_args(GROUP_TYPE)) + ) + allow_other_mentors: Mapped[bool] = mapped_column(default=False) dot: Mapped[EVENT_DOT_TYPES] = mapped_column(default="None") diff --git a/routes/event_router.py b/routes/event_router.py index dcca34f9..61cd92ae 100644 --- a/routes/event_router.py +++ b/routes/event_router.py @@ -21,7 +21,6 @@ from helpers.types import ALLOWED_EXT, ALLOWED_IMG_SIZES, ALLOWED_IMG_TYPES, ASSETS_BASE_PATH from pathlib import Path - event_router = APIRouter() @@ -50,7 +49,9 @@ def get_event_priorities(db: DB_dependency): return list(priorities) -@event_router.patch("/confirmed/{event_id}", response_model=EventRead) +@event_router.patch( + "/confirmed/{event_id}", response_model=EventRead, dependencies=[Permission.require("manage", "Event")] +) def confirm_places( db: DB_dependency, event_id: int, diff --git a/routes/event_signup_router.py b/routes/event_signup_router.py index 918d8721..213c6ab3 100644 --- a/routes/event_signup_router.py +++ b/routes/event_signup_router.py @@ -1,13 +1,21 @@ -from typing import Annotated +from typing import Annotated, get_args from fastapi import APIRouter, HTTPException, status +from api_schemas.user_schemas import UserForEventSignupRead from database import DB_dependency from db_models.event_model import Event_DB from db_models.user_model import User_DB -from services.event_signup_service import signup_to_event, signoff_from_event, update_event_signup, check_me_signup +from db_models.group_model import Group_DB +from helpers.types import GROUP_TYPE +from services.event_signup_service import ( + get_allowed_groups, + signup_to_event, + signoff_from_event, + update_event_signup, + check_me_signup, +) from user.permission import Permission from api_schemas.event_signup_schemas import EventSignupCreate, EventSignupRead, EventSignupUpdate - event_signup_router = APIRouter() @@ -88,6 +96,15 @@ def get_me_event_signup(event_id: int, me: Annotated[User_DB, Permission.member( return check_me_signup(event_id, me, db) +@event_signup_router.get("/me/{event_id}", response_model=UserForEventSignupRead) +def get_me_for_event_signup(event_id: int, user: Annotated[User_DB, Permission.member()], db: DB_dependency): # type: ignore + event = db.query(Event_DB).filter_by(id=event_id).one_or_none() + if event is None: + raise HTTPException(404, detail="Event not found") + + return {**user.__dict__, "groups": get_allowed_groups(event, user)} # type: ignore + + # @event_signup_router.get("/{event_id}", response_model=list[EventSignupRead]) # def get_all_signups(event_id: int, db: DB_dependency): # signups = db.query(EventUser_DB).filter(EventUser_DB.event_id == event_id).all() diff --git a/services/event_service.py b/services/event_service.py index 94513d93..8e87cf2d 100644 --- a/services/event_service.py +++ b/services/event_service.py @@ -50,6 +50,8 @@ def create_new_event(data: EventCreate, db: Session): drink_package=data.drink_package, location=data.location, is_nollning_event=data.is_nollning_event, + mentor_group_types=data.mentor_group_types, + allow_other_mentors=data.allow_other_mentors, dress_code=data.dress_code, price=data.price, alcohol_event_type=data.alcohol_event_type, @@ -89,6 +91,10 @@ def update_event(event_id: int, data: EventUpdate, db: Session): if not event: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Event not found") + if data.lottery is not None and data.lottery != event.lottery: + if event.event_users_confirmed or any(eu.confirmed_status for eu in event.event_users): + raise HTTPException(400, detail="Cannot change lottery after signups are confirmed") + if data.price is not None and data.price < 0: raise HTTPException(400, detail="Price cannot be lower than 0") diff --git a/services/event_signup_service.py b/services/event_signup_service.py index 0f3138da..570231fc 100644 --- a/services/event_signup_service.py +++ b/services/event_signup_service.py @@ -1,11 +1,15 @@ from datetime import UTC, datetime +from typing import get_args from fastapi import HTTPException, status from sqlalchemy.orm import Session from db_models.event_model import Event_DB from db_models.event_user_model import EventUser_DB from db_models.user_model import User_DB +from db_models.group_model import Group_DB +from db_models.group_user_model import GroupUser_DB from api_schemas.event_signup_schemas import EventSignupCreate, EventSignupUpdate from helpers.constants import DEFAULT_USER_PRIORITY +from helpers.types import GROUP_TYPE def signup_to_event(event: Event_DB, user: User_DB, data: EventSignupCreate, manage_permission: bool, db: Session): @@ -30,6 +34,13 @@ def signup_to_event(event: Event_DB, user: User_DB, data: EventSignupCreate, man ): raise HTTPException(400, detail="User already signed up to chosen event") + if ( + manage_permission == False + and data.group_name is not None + and not is_group_allowed(event, user, data.group_name) + ): + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="User cannot sign up with this group") + signup = EventUser_DB(user=user, user_id=user.id, event=event, event_id=event.id) for var, value in vars(data).items(): @@ -76,6 +87,13 @@ def update_event_signup(event: Event_DB, data: EventSignupUpdate, user_id: int, if signup is None: raise HTTPException(status.HTTP_404_NOT_FOUND) + if ( + manage_permission == False + and data.group_name is not None + and not is_group_allowed(event, db.query(User_DB).filter(User_DB.id == user_id).one(), data.group_name) + ): + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="User cannot sign up with this group") + for var, value in vars(data).items(): if var == "priority" and not value: setattr(signup, "priority", DEFAULT_USER_PRIORITY) @@ -102,3 +120,40 @@ def check_me_signup(event_id: int, me: User_DB, db: Session): raise HTTPException(404, detail="Signup not found") return signup + + +def get_allowed_groups(event: Event_DB, user: User_DB): + allowed_groups: list[Group_DB] = [] + if event.is_nollning_event: + allowed_group_types = event.mentor_group_types or list(get_args(GROUP_TYPE)) + for gu in user.group_users: + if _is_group_allowed(gu, event, allowed_group_types): + allowed_groups.append(gu.group) + else: + allowed_groups = user.groups + + return allowed_groups + + +def is_group_allowed(event: Event_DB, user: User_DB, group_name: str | None): + if event.is_nollning_event: + allowed_group_types = event.mentor_group_types or list(get_args(GROUP_TYPE)) + is_event_allowed = False + for gu in user.group_users: + if group_name == gu.group.name: + if _is_group_allowed(gu, event, allowed_group_types): + is_event_allowed = True + break + + if not is_event_allowed: + return False + + return True + + +def _is_group_allowed(gu: GroupUser_DB, e: Event_DB, agt: list[GROUP_TYPE]): + if gu.group.group_type in agt: + return True + elif (gu.group_user_type == "Mentor") and e.allow_other_mentors: + return True + return False diff --git a/tests/basic_factories.py b/tests/basic_factories.py index 39a1bed4..d7c8235c 100644 --- a/tests/basic_factories.py +++ b/tests/basic_factories.py @@ -128,3 +128,54 @@ def patch_sub_election(client, sub_election_id, token=None, **kwargs): data = sub_election_data_factory(**kwargs) headers = auth_headers(token) if token else {} return client.patch(f"/sub-election/{sub_election_id}", json=data, headers=headers) + + +def event_data_factory(**kwargs): + """Factory for creating event payloads with sensible default times.""" + now = datetime.datetime.now(timezone.utc) + default_data = { + "starts_at": (now + datetime.timedelta(days=7)).isoformat(), + "ends_at": (now + datetime.timedelta(days=7, hours=3)).isoformat(), + "signup_start": (now - datetime.timedelta(days=1)).isoformat(), + "signup_end": (now + datetime.timedelta(days=6)).isoformat(), + "title_sv": "Testevenemang", + "title_en": "Test Event", + "description_sv": "Svensk beskrivning", + "description_en": "English description", + "location": "Kårhuset", + "max_event_users": 0, + "priorities": [], + "all_day": False, + "recurring": False, + "food": False, + "closed": False, + "can_signup": True, + "drink_package": False, + "is_nollning_event": False, + "mentor_group_types": ["Mentor", "Mission", "Default", "Committee"], + "allow_other_mentors": False, + "alcohol_event_type": "None", + "dress_code": "Ovve", + "price": 0, + "dot": "None", + "lottery": False, + } + return {**default_data, **kwargs} + + +def add_user_to_group(db_session, user, name, group_type, group_user_type="Mentee"): + """Create a group of a given type and put the user in it. Returns the group.""" + from db_models.group_model import Group_DB + from db_models.group_user_model import GroupUser_DB + + group = Group_DB(name=name, group_type=group_type) + db_session.add(group) + db_session.commit() + + group_user = GroupUser_DB( + user=user, user_id=user.id, group=group, group_id=group.id, group_user_type=group_user_type + ) + db_session.add(group_user) + db_session.commit() + + return group diff --git a/tests/basic_fixtures.py b/tests/basic_fixtures.py index 0aa6239e..e4324741 100644 --- a/tests/basic_fixtures.py +++ b/tests/basic_fixtures.py @@ -282,3 +282,43 @@ def open_sub_election(db_session, open_election, admin_post, member_post): db_session.commit() db_session.refresh(sub_election) return sub_election + + +@pytest.fixture() +def event(client, admin_token, admin_council_id): + """Create and return a plain event which is open for signup.""" + + response = client.post( + "/events/", json=event_data_factory(council_id=admin_council_id), headers=auth_headers(admin_token) + ) + assert response.status_code in (200, 201), response.text + + return response.json() + + +@pytest.fixture() +def nollning_event(client, admin_token, admin_council_id): + """Create and return a nollning event which only accepts groups of type "Mentor".""" + + response = client.post( + "/events/", + json=event_data_factory(council_id=admin_council_id, is_nollning_event=True, mentor_group_types=["Mentor"]), + headers=auth_headers(admin_token), + ) + assert response.status_code in (200, 201), response.text + + return response.json() + + +@pytest.fixture() +def mentor_group(db_session, membered_user): + """Create a group of type "Mentor" with the member user as a mentee.""" + + return add_user_to_group(db_session, membered_user, "Fadderiet", "Mentor", "Mentee") + + +@pytest.fixture() +def mission_group(db_session, membered_user): + """Create a group of type "Mission" with the member user as a mentee.""" + + return add_user_to_group(db_session, membered_user, "Uppdraget", "Mission", "Mentee") diff --git a/tests/test_event_signup.py b/tests/test_event_signup.py new file mode 100644 index 00000000..97be0916 --- /dev/null +++ b/tests/test_event_signup.py @@ -0,0 +1,152 @@ +# type: ignore +import pytest +from .basic_factories import add_user_to_group, auth_headers, event_data_factory + + +def test_signup_with_allowed_group_type(client, member_token, membered_user, nollning_event, mentor_group): + """A group whose type is in mentor_group_types is accepted.""" + response = client.post( + f"/event-signup/{nollning_event['id']}", + json={"user_id": membered_user.id, "group_name": mentor_group.name}, + headers=auth_headers(member_token), + ) + + assert response.status_code in (200, 201), response.text + assert response.json()["group_name"] == mentor_group.name + + +def test_signup_with_disallowed_group_type(client, member_token, membered_user, nollning_event, mission_group): + """A group whose type is not in mentor_group_types is rejected.""" + response = client.post( + f"/event-signup/{nollning_event['id']}", + json={"user_id": membered_user.id, "group_name": mission_group.name}, + headers=auth_headers(member_token), + ) + + assert response.status_code == 403 + + +def test_signup_with_group_the_user_is_not_in(client, member_token, membered_user, nollning_event, mentor_group): + """A group name the user isn't a member of is rejected, even if the type is allowed.""" + response = client.post( + f"/event-signup/{nollning_event['id']}", + json={"user_id": membered_user.id, "group_name": "Något helt annat"}, + headers=auth_headers(member_token), + ) + + assert response.status_code == 403 + + +def test_signup_without_group(client, member_token, membered_user, nollning_event, mission_group): + """Signing up without picking a group is not restricted by the group types.""" + response = client.post( + f"/event-signup/{nollning_event['id']}", + json={"user_id": membered_user.id}, + headers=auth_headers(member_token), + ) + + assert response.status_code in (200, 201), response.text + assert response.json()["group_name"] is None + + +def test_admin_can_sign_up_user_with_disallowed_group( + client, admin_token, membered_user, nollning_event, mission_group +): + """Someone who may manage events is not restricted by the group types.""" + response = client.post( + f"/event-signup/{nollning_event['id']}", + json={"user_id": membered_user.id, "group_name": mission_group.name}, + headers=auth_headers(admin_token), + ) + + assert response.status_code in (200, 201), response.text + assert response.json()["group_name"] == mission_group.name + + +@pytest.mark.parametrize("allow_other_mentors", [True, False]) +def test_signup_as_mentor_of_group_with_other_type( + client, + admin_token, + admin_council_id, + member_token, + membered_user, + db_session, + allow_other_mentors, +): + """A mentor may sign up with a group of a disallowed type only if the event allows it.""" + group = add_user_to_group(db_session, membered_user, "Uppdraget", "Mission", "Mentor") + data = event_data_factory( + council_id=admin_council_id, + is_nollning_event=True, + mentor_group_types=["Mentor"], + allow_other_mentors=allow_other_mentors, + ) + event = client.post("/events/", json=data, headers=auth_headers(admin_token)).json() + + response = client.post( + f"/event-signup/{event['id']}", + json={"user_id": membered_user.id, "group_name": group.name}, + headers=auth_headers(member_token), + ) + + if allow_other_mentors: + assert response.status_code in (200, 201), response.text + else: + assert response.status_code == 403 + + +def test_non_nollning_event_ignores_group_types(client, member_token, membered_user, event, mission_group): + """Group types only restrict nollning events.""" + response = client.post( + f"/event-signup/{event['id']}", + json={"user_id": membered_user.id, "group_name": mission_group.name}, + headers=auth_headers(member_token), + ) + + assert response.status_code in (200, 201), response.text + + +def test_update_signup_without_group_name_is_allowed(client, member_token, membered_user, nollning_event, mentor_group): + """ + Regression: patching some other field of a signup on a nollning event used to be + rejected with 403, because an omitted group_name was checked as if it were a group + the user isn't in. + """ + signup = client.post( + f"/event-signup/{nollning_event['id']}", + json={"user_id": membered_user.id, "group_name": mentor_group.name}, + headers=auth_headers(member_token), + ) + assert signup.status_code in (200, 201), signup.text + + response = client.patch( + f"/event-signup/{nollning_event['id']}", json={"priority": "Nolla"}, headers=auth_headers(member_token) + ) + + assert response.status_code == 200, response.text + assert response.json()["priority"] == "Nolla" + + +def test_update_signup_to_disallowed_group( + client, member_token, membered_user, nollning_event, mentor_group, mission_group +): + """Switching to a group of a disallowed type is rejected, and nothing else is changed.""" + signup = client.post( + f"/event-signup/{nollning_event['id']}", + json={"user_id": membered_user.id, "group_name": mentor_group.name}, + headers=auth_headers(member_token), + ) + assert signup.status_code in (200, 201), signup.text + + response = client.patch( + f"/event-signup/{nollning_event['id']}", + json={"group_name": mission_group.name, "priority": "Nolla"}, + headers=auth_headers(member_token), + ) + + assert response.status_code == 403 + signup_after = client.get( + f"/event-signup/me-signup/{nollning_event['id']}", headers=auth_headers(member_token) + ).json() + assert signup_after["group_name"] == mentor_group.name + assert signup_after["priority"] != "Nolla" diff --git a/tests/test_events.py b/tests/test_events.py new file mode 100644 index 00000000..c1523a13 --- /dev/null +++ b/tests/test_events.py @@ -0,0 +1,157 @@ +# type: ignore +import pytest +from .basic_factories import add_user_to_group, auth_headers, event_data_factory + + +class TestCreateEvent: + """Test POST /events/ endpoint""" + + def test_create_event_success(self, client, admin_token, admin_council_id): + """Admin can create an event and gets the stored values back.""" + data = event_data_factory(council_id=admin_council_id) + + response = client.post("/events/", json=data, headers=auth_headers(admin_token)) + + assert response.status_code in (200, 201), response.text + event = response.json() + assert event["title_sv"] == data["title_sv"] + assert event["council_id"] == admin_council_id + assert event["price"] == data["price"] + assert event["signup_count"] == 0 + + def test_create_nollning_event_with_group_types(self, client, admin_token, admin_council_id): + """The nollning group settings are persisted as given.""" + data = event_data_factory( + council_id=admin_council_id, + is_nollning_event=True, + mentor_group_types=["Mentor", "Mission"], + allow_other_mentors=True, + ) + + response = client.post("/events/", json=data, headers=auth_headers(admin_token)) + + assert response.status_code in (200, 201), response.text + event = response.json() + assert event["is_nollning_event"] is True + assert event["mentor_group_types"] == ["Mentor", "Mission"] + assert event["allow_other_mentors"] is True + + def test_create_event_negative_price(self, client, admin_token, admin_council_id): + """Negative prices are rejected.""" + data = event_data_factory(council_id=admin_council_id, price=-1) + + response = client.post("/events/", json=data, headers=auth_headers(admin_token)) + + assert response.status_code == 400 + + def test_create_event_ends_before_starts(self, client, admin_token, admin_council_id): + """An event cannot end before it starts.""" + default_data = event_data_factory() + data = event_data_factory( + council_id=admin_council_id, starts_at=default_data["ends_at"], ends_at=default_data["starts_at"] + ) + + response = client.post("/events/", json=data, headers=auth_headers(admin_token)) + + assert response.status_code == 400 + + @pytest.mark.parametrize("token_fixture", ["member_token", "non_member_token"]) + def test_create_event_forbidden(self, client, request, admin_council_id, token_fixture): + """Members and non-members cannot create events.""" + token = request.getfixturevalue(token_fixture) + data = event_data_factory(council_id=admin_council_id) + + response = client.post("/events/", json=data, headers=auth_headers(token)) + + assert response.status_code == 403 + + def test_create_event_unauthenticated(self, client, admin_council_id): + """Unauthenticated requests get 401.""" + response = client.post("/events/", json=event_data_factory(council_id=admin_council_id)) + + assert response.status_code == 401 + + +class TestGetEvents: + """Test GET /events/ and GET /events/{eventId} endpoints""" + + def test_get_all_events(self, client, event): + """Created events are listed.""" + response = client.get("/events/") + + assert response.status_code == 200 + assert event["id"] in [listed["id"] for listed in response.json()] + + def test_get_single_event(self, client, event): + """A single event can be fetched by id.""" + response = client.get(f"/events/{event['id']}") + + assert response.status_code == 200 + assert response.json()["title_en"] == event["title_en"] + + +class TestUpdateEvent: + """Test PATCH /events/{event_id} endpoint""" + + def test_update_event_success(self, client, admin_token, event): + """Admin can patch a subset of the fields, leaving the rest alone.""" + response = client.patch( + f"/events/{event['id']}", json={"title_sv": "Nytt namn", "price": 100}, headers=auth_headers(admin_token) + ) + + assert response.status_code == 200 + assert response.json()["title_sv"] == "Nytt namn" + assert response.json()["price"] == 100 + assert response.json()["title_en"] == event["title_en"] + + def test_update_event_group_settings(self, client, admin_token, nollning_event): + """The nollning group settings can be changed after creation.""" + response = client.patch( + f"/events/{nollning_event['id']}", + json={"mentor_group_types": ["Mission"], "allow_other_mentors": True}, # Mentor before + headers=auth_headers(admin_token), + ) + + assert response.status_code == 200 + assert response.json()["mentor_group_types"] == ["Mission"] + assert response.json()["allow_other_mentors"] is True + + def test_update_event_lottery(self, client, admin_token, event): + """Lottery can be toggled through the update endpoint.""" + assert event["lottery"] is False + + response = client.patch(f"/events/{event['id']}", json={"lottery": True}, headers=auth_headers(admin_token)) + + assert response.status_code == 200 + assert response.json()["lottery"] is True + + def test_update_event_negative_price(self, client, admin_token, event): + """Negative prices are rejected on update too.""" + response = client.patch(f"/events/{event['id']}", json={"price": -5}, headers=auth_headers(admin_token)) + + assert response.status_code == 400 + + def test_update_event_forbidden(self, client, member_token, event): + """Members cannot update events.""" + response = client.patch( + f"/events/{event['id']}", json={"title_sv": "Kapad"}, headers=auth_headers(member_token) + ) + + assert response.status_code == 403 + + +class TestDeleteEvent: + """Test DELETE /events/{event_id} endpoint""" + + def test_delete_event_success(self, client, admin_token, event): + """Admin can delete an event, after which it is gone.""" + response = client.delete(f"/events/{event['id']}", headers=auth_headers(admin_token)) + + assert response.status_code == 200 + assert client.get(f"/events/{event['id']}").status_code == 404 + + def test_delete_event_forbidden(self, client, member_token, event): + """Members cannot delete events.""" + response = client.delete(f"/events/{event['id']}", headers=auth_headers(member_token)) + + assert response.status_code == 403 From 96b97ff4334a0443712df98518993fee7d944992 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:05:11 +0200 Subject: [PATCH 12/12] Auto-generated Alembic migration ab2b4b6 (#554) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(migration): add autogenerated revision ab2b4b6 [skip ci] * Add default values to new columns in event_table --------- Co-authored-by: hannesblaman <29555754+hannesblaman@users.noreply.github.com> Co-authored-by: Georg Elgebäck <50551748+georgelgeback@users.noreply.github.com> --- ...82f8_chore_migration_auto_generated_on_.py | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 migrations/versions/8479c3bc82f8_chore_migration_auto_generated_on_.py diff --git a/migrations/versions/8479c3bc82f8_chore_migration_auto_generated_on_.py b/migrations/versions/8479c3bc82f8_chore_migration_auto_generated_on_.py new file mode 100644 index 00000000..a04c52df --- /dev/null +++ b/migrations/versions/8479c3bc82f8_chore_migration_auto_generated_on_.py @@ -0,0 +1,32 @@ +"""chore(migration): auto-generated on ab2b4b62fcea6be2f02225d6fadc2f20b69f668c + +Revision ID: 8479c3bc82f8 +Revises: a6a861ab04ae +Create Date: 2026-08-01 12:59:22.711113 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '8479c3bc82f8' +down_revision: Union[str, None] = 'a6a861ab04ae' +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.add_column('event_table', sa.Column('mentor_group_types', sa.JSON(), nullable=False, server_default=sa.text("""'["Mentor", "Mission", "Committee", "Default"]'"""))) + op.add_column('event_table', sa.Column('allow_other_mentors', sa.Boolean(), nullable=False, server_default=sa.false())) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('event_table', 'allow_other_mentors') + op.drop_column('event_table', 'mentor_group_types') + # ### end Alembic commands ###