From aae8cdfc08bea91d74641282003882f206363cbe Mon Sep 17 00:00:00 2001 From: georgelgeback Date: Sat, 20 Jun 2026 15:37:34 +0000 Subject: [PATCH 1/3] Add stil-id support when registering --- api_schemas/user_schemas.py | 24 +++++++++++++++++++----- helpers/check_stil_id.py | 8 ++++++++ services/user.py | 9 +-------- 3 files changed, 28 insertions(+), 13 deletions(-) create mode 100644 helpers/check_stil_id.py diff --git a/api_schemas/user_schemas.py b/api_schemas/user_schemas.py index 04b8d3d..c765755 100644 --- a/api_schemas/user_schemas.py +++ b/api_schemas/user_schemas.py @@ -1,5 +1,6 @@ from typing import TYPE_CHECKING, Annotated, Literal -from pydantic import StringConstraints +from pydantic import StringConstraints, field_validator +from helpers.check_stil_id import check_stil_id from fastapi_users_pelicanq import schemas as fastapi_users_schemas from api_schemas.post_schemas import PostRead from helpers.constants import MAX_FIRST_NAME_LEN, MAX_LAST_NAME_LEN @@ -11,6 +12,19 @@ from api_schemas.group_schema import GroupRead +class StilIdValidationMixin: + stil_id: str | None = None + + @field_validator("stil_id", mode="after") + @classmethod + def validate_stil_id(cls, value: str | None) -> str | None: + if value is None: + return None + if not check_stil_id(value): + raise ValueError("Invalid stil-id") + return value + + class _UserEventRead(BaseSchema): id: int @@ -118,25 +132,25 @@ class UserInGroupRead(BaseSchema): # fastapi-users will take all fields on this model and feed into the user constructor User_DB(...) when /auth/register route is called -class UserCreate(fastapi_users_schemas.BaseUserCreate, BaseSchema): +class UserCreate(StilIdValidationMixin, fastapi_users_schemas.BaseUserCreate, BaseSchema): first_name: Annotated[str, StringConstraints(max_length=MAX_FIRST_NAME_LEN)] last_name: Annotated[str, StringConstraints(max_length=MAX_LAST_NAME_LEN)] telephone_number: PhoneNumber start_year: int | None = None - pass + # stil_id is here, but inherited from StilIdValidationMixin -class UserUpdate(BaseSchema): +class UserUpdate(StilIdValidationMixin, BaseSchema): first_name: str | None = None last_name: str | None = None start_year: int | None = None program: PROGRAM_TYPE | None = None notifications: bool | None = None - stil_id: str | None = None standard_food_preferences: list[str] | None = None other_food_preferences: str | None = None telephone_number: PhoneNumber | None = None moose_game_name: str | None = None + # stil_id is here, but inherited from StilIdValidationMixin class UpdateUserMember(BaseSchema): diff --git a/helpers/check_stil_id.py b/helpers/check_stil_id.py new file mode 100644 index 0000000..3d71214 --- /dev/null +++ b/helpers/check_stil_id.py @@ -0,0 +1,8 @@ +import re + + +def check_stil_id(s: str) -> bool: + if not len(s) == 10: + return False + pattern = r"^[a-z]{2}\d{4}[a-z]{2}-s$" + return bool(re.fullmatch(pattern, s)) diff --git a/services/user.py b/services/user.py index 21759b0..ea7911b 100644 --- a/services/user.py +++ b/services/user.py @@ -4,16 +4,9 @@ from db_models.user_model import User_DB from fastapi import HTTPException, status from sqlalchemy.exc import DataError, NoResultFound, MultipleResultsFound -import re from helpers.types import FOOD_PREFERENCES from db_models.post_model import Post_DB - - -def check_stil_id(s: str) -> bool: - if not len(s) == 10: - return False - pattern = r"^[a-z]{2}\d{4}[a-z]{2}-s$" - return bool(re.fullmatch(pattern, s)) +from helpers.check_stil_id import check_stil_id def condition(model, asset): From 701f8a4942c5f643936e852605bbd5f6650a4a84 Mon Sep 17 00:00:00 2001 From: georgelgeback Date: Sun, 21 Jun 2026 08:56:22 +0000 Subject: [PATCH 2/3] Add simple test case to prevent inadvertent validation rollbacks --- tests/test_users.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_users.py b/tests/test_users.py index 4f6e02a..9118a6b 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -15,6 +15,19 @@ def test_register_user(client, user1_data): assert "id" in data +def test_register_user_invalid_stil_id(client): + """Test user registration with valid data except for an invalid stil_id""" + user_data = user_data_factory( + email="test1@example.com", + last_name="User1", + program="F", + telephone_number="+46701234567", + stil_id="invalid_stil_id", + ) + response = client.post("/auth/register", json=user_data) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + def test_register_duplicate_user(client, user1_data): """Test registration with duplicate email fails""" # Register first user From b39ff76091087bc4713cce80c8b7cc848968304f Mon Sep 17 00:00:00 2001 From: georgelgeback Date: Tue, 30 Jun 2026 16:46:12 +0000 Subject: [PATCH 3/3] Add prereg member system and tests for it. --- api_schemas/prereg_member_schema.py | 21 +++ db_models/prereg_member_model.py | 24 ++++ routes/__init__.py | 3 + routes/prereg_member_router.py | 196 ++++++++++++++++++++++++++++ tests/test_prereg_members.py | 124 ++++++++++++++++++ 5 files changed, 368 insertions(+) create mode 100644 api_schemas/prereg_member_schema.py create mode 100644 db_models/prereg_member_model.py create mode 100644 routes/prereg_member_router.py create mode 100644 tests/test_prereg_members.py diff --git a/api_schemas/prereg_member_schema.py b/api_schemas/prereg_member_schema.py new file mode 100644 index 0000000..8a8faf3 --- /dev/null +++ b/api_schemas/prereg_member_schema.py @@ -0,0 +1,21 @@ +from api_schemas.base_schema import BaseSchema +from pydantic_extra_types.phone_numbers import PhoneNumber + + +class PreregMemberCreate(BaseSchema): + telephone_number: PhoneNumber | None = None + stil_id: str | None = None + email: str | None = None + + +class PreregMemberUpdate(BaseSchema): + telephone_number: PhoneNumber | None = None + stil_id: str | None = None + email: str | None = None + + +class PreregMemberRead(BaseSchema): + prereg_member_id: int + telephone_number: PhoneNumber | None = None + stil_id: str | None = None + email: str | None = None diff --git a/db_models/prereg_member_model.py b/db_models/prereg_member_model.py new file mode 100644 index 0000000..72083fd --- /dev/null +++ b/db_models/prereg_member_model.py @@ -0,0 +1,24 @@ +from typing import Optional +from sqlalchemy import CheckConstraint, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from .base_model import BaseModel_DB + +from helpers.constants import MAX_TELEPHONE_LEN + + +class PreregMember_DB(BaseModel_DB): + __tablename__ = "prereg_member_table" + __table_args__ = ( + CheckConstraint( + "telephone_number IS NOT NULL OR stil_id IS NOT NULL OR email IS NOT NULL", + name="at_least_one_identifier_required", + ), + UniqueConstraint("telephone_number", "stil_id", "email", name="unique_prereg_member_identifiers"), + ) + prereg_member_id: Mapped[int] = mapped_column( + primary_key=True, init=False + ) # Not the id the user will have when confirmed as member! + + telephone_number: Mapped[Optional[str]] = mapped_column(String(MAX_TELEPHONE_LEN), default=None) + stil_id: Mapped[Optional[str]] = mapped_column(default=None) + email: Mapped[Optional[str]] = mapped_column(default=None) diff --git a/routes/__init__.py b/routes/__init__.py index f7bef5f..5d02dc3 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -32,6 +32,7 @@ from .nomination_router import nomination_router from .guild_meeting_router import guild_meeting_router from .keyval_router import keyval_router +from .prereg_member_router import prereg_member_router # here comes the big momma router main_router = APIRouter() @@ -97,3 +98,5 @@ main_router.include_router(guild_meeting_router, prefix="/guild-meeting", tags=["guild meeting"]) main_router.include_router(keyval_router, prefix="/keyvals", tags=["keyvals"]) + +main_router.include_router(prereg_member_router, prefix="/prereg-members", tags=["prereg members"]) diff --git a/routes/prereg_member_router.py b/routes/prereg_member_router.py new file mode 100644 index 0000000..2b31a88 --- /dev/null +++ b/routes/prereg_member_router.py @@ -0,0 +1,196 @@ +from fastapi import APIRouter, Body, HTTPException +from database import DB_dependency +from db_models.prereg_member_model import PreregMember_DB +from api_schemas.prereg_member_schema import ( + PreregMemberCreate, + PreregMemberRead, + PreregMemberUpdate, +) +from user.permission import Permission +from helpers.check_stil_id import check_stil_id +import phonenumbers + +prereg_member_router = APIRouter() + + +def normalize_telephone_number(telephone_number: str | None) -> str | None: + if telephone_number is None: + return None + + return phonenumbers.format_number( + phonenumbers.parse(str(telephone_number), None), phonenumbers.PhoneNumberFormat.E164 + ) + + +def prereg_member_matches(member: PreregMember_DB, data: PreregMemberCreate | PreregMemberUpdate) -> bool: + return ( + normalize_telephone_number(member.telephone_number) == normalize_telephone_number(data.telephone_number) + and member.stil_id == data.stil_id + and member.email == data.email + ) + + +def validate_identifiers(data: PreregMemberCreate | PreregMemberUpdate) -> bool: + if data.stil_id: + if not check_stil_id(data.stil_id): + return False + + if data.email: + if "@" not in data.email or "." not in data.email: + return False + + return True + + +@prereg_member_router.get("/", response_model=list[PreregMemberRead], dependencies=[Permission.require("view", "User")]) +def get_all_prereg_member_info(db: DB_dependency): + all_prereg_members = db.query(PreregMember_DB).all() + return all_prereg_members + + +@prereg_member_router.get( + "/{prereg_member_id}", response_model=PreregMemberRead, dependencies=[Permission.require("view", "User")] +) +def get_prereg_member_info(prereg_member_id: int, db: DB_dependency): + prereg_member = db.query(PreregMember_DB).filter(PreregMember_DB.prereg_member_id == prereg_member_id).one_or_none() + if not prereg_member: + raise HTTPException(404, detail="Prereg member not found") + return prereg_member + + +@prereg_member_router.post("/", response_model=PreregMemberRead, dependencies=[Permission.require("manage", "User")]) +def create_prereg_member( + data: PreregMemberCreate, + db: DB_dependency, +): + if not (data.telephone_number or data.stil_id or data.email): + raise HTTPException( + 400, detail="At least one identifier (telephone number, stil_id, or email) must be provided" + ) + + if not validate_identifiers(data): + raise HTTPException(400, detail="Invalid identifier(s) provided") + + existing_prereg_member = next( + (member for member in db.query(PreregMember_DB).all() if prereg_member_matches(member, data)), + None, + ) + if existing_prereg_member: + raise HTTPException(400, detail="Prereg member with the exact same details already exists") + prereg_member = PreregMember_DB( + telephone_number=data.telephone_number, + stil_id=data.stil_id, + email=data.email, + ) + db.add(prereg_member) + db.commit() + return prereg_member + + +@prereg_member_router.post( + "/multiple", + response_model=list[PreregMemberRead], + dependencies=[Permission.require("manage", "User")], +) +def create_multiple_prereg_members( + data: list[PreregMemberCreate], + db: DB_dependency, +): + prereg_members: list[PreregMember_DB] = [] + existing_prereg_members: list[PreregMember_DB] = [] + for member_data in data: + if not (member_data.telephone_number or member_data.stil_id or member_data.email): + raise HTTPException( + 400, + detail="At least one identifier (telephone number, stil_id, or email) must be provided for all entries you want to add", + ) + + if not validate_identifiers(member_data): + raise HTTPException( + 400, + detail=f"One of the entries had an invalid identifier. At least one of the telephone number: {member_data.telephone_number}, stil_id: {member_data.stil_id}, or email: {member_data.email} is invalid", + ) + + existing_prereg_member = next( + (member for member in db.query(PreregMember_DB).all() if prereg_member_matches(member, member_data)), + None, + ) + + if existing_prereg_member: + # There is already an entry with the exact same combination of identifiers. + # Erroring here would be too strict + existing_prereg_members.append(existing_prereg_member) + continue + + prereg_member = PreregMember_DB( + telephone_number=member_data.telephone_number, + stil_id=member_data.stil_id, + email=member_data.email, + ) + db.add(prereg_member) + prereg_members.append(prereg_member) + + if len(existing_prereg_members) >= 0 and len(existing_prereg_members) == len(data): + raise HTTPException(400, detail="All prereg members already exist") + + db.commit() + + # Note: returns all existing prereg members that would collide unless they were skipped, not the newly created prereg members + return existing_prereg_members + + +@prereg_member_router.patch( + "/{prereg_member_id}", response_model=PreregMemberRead, dependencies=[Permission.require("manage", "User")] +) +def update_prereg_member(prereg_member_id: int, data: PreregMemberUpdate, db: DB_dependency): + prereg_member = db.query(PreregMember_DB).filter(PreregMember_DB.prereg_member_id == prereg_member_id).one_or_none() + if not prereg_member: + raise HTTPException(404, detail="Prereg member not found") + + if not validate_identifiers(data): + raise HTTPException(400, detail="Invalid identifier(s) provided") + + for var, value in vars(data).items(): + # Update the fields even if they are being set to None so they can be cleared + setattr(prereg_member, var, value) + + if not (prereg_member.telephone_number or prereg_member.stil_id or prereg_member.email): + db.rollback() + raise HTTPException( + 400, detail="At least one identifier (telephone number, stil_id, or email) must be provided" + ) + + db.commit() + return prereg_member + + +@prereg_member_router.delete( + "/multiple", + response_model=list[PreregMemberRead], + dependencies=[Permission.require("manage", "User")], +) +def delete_multiple_prereg_members( + db: DB_dependency, + prereg_member_ids: list[int] = Body(...), +): + prereg_members = db.query(PreregMember_DB).filter(PreregMember_DB.prereg_member_id.in_(prereg_member_ids)).all() + if not prereg_members: + raise HTTPException(404, detail="No prereg members found for the provided ids") + if len(prereg_members) != len(prereg_member_ids): + raise HTTPException(404, detail="Some prereg members not found for the provided ids") + for member in prereg_members: + db.delete(member) + db.commit() + return prereg_members + + +@prereg_member_router.delete( + "/{prereg_member_id}", response_model=PreregMemberRead, dependencies=[Permission.require("manage", "User")] +) +def delete_prereg_member(prereg_member_id: int, db: DB_dependency): + prereg_member = db.query(PreregMember_DB).filter(PreregMember_DB.prereg_member_id == prereg_member_id).one_or_none() + if not prereg_member: + raise HTTPException(404, detail="Prereg member not found") + db.delete(prereg_member) + db.commit() + return prereg_member diff --git a/tests/test_prereg_members.py b/tests/test_prereg_members.py new file mode 100644 index 0000000..63d367a --- /dev/null +++ b/tests/test_prereg_members.py @@ -0,0 +1,124 @@ +# type: ignore +from db_models.prereg_member_model import PreregMember_DB +from tests.basic_factories import auth_headers + + +def create_db_prereg_member(db_session, telephone_number=None, stil_id=None, email=None): + member = PreregMember_DB(telephone_number=telephone_number, stil_id=stil_id, email=email) + db_session.add(member) + db_session.commit() + return member + + +def test_get_all_prereg_member_info(client, admin_token, db_session): + create_db_prereg_member(db_session, email="test1@example.com") + create_db_prereg_member(db_session, stil_id="xy9876zw-s") + + resp = client.get("/prereg-members/", headers=auth_headers(admin_token)) + assert resp.status_code == 200 + data = resp.json() + assert len(data) >= 2 + + +def test_get_prereg_member_info(client, admin_token, db_session): + member = create_db_prereg_member(db_session, email="wow@cool.com") + + resp = client.get(f"/prereg-members/{member.prereg_member_id}", headers=auth_headers(admin_token)) + assert resp.status_code == 200 + assert resp.json()["email"] == "wow@cool.com" + + +def test_create_prereg_member(client, admin_token): + payload = {"telephone_number": "+46234567890", "stil_id": "ab1234cd-s", "email": "user@test.com"} + resp = client.post("/prereg-members/", json=payload, headers=auth_headers(admin_token)) + assert resp.status_code in [200, 201] + data = resp.json() + assert data["email"] == "user@test.com" + assert "prereg_member_id" in data + + +def test_create_prereg_member_missing_identifiers(client, admin_token): + payload = {"telephone_number": None, "stil_id": None, "email": None} + resp = client.post("/prereg-members/", json=payload, headers=auth_headers(admin_token)) + assert resp.status_code == 400 + + +def test_create_prereg_member_invalid_identifiers(client, admin_token): + payload = {"telephone_number": "number", "stil_id": None, "email": None} + resp1 = client.post("/prereg-members/", json=payload, headers=auth_headers(admin_token)) + assert resp1.status_code in [400, 422] + payload = {"telephone_number": None, "stil_id": "asdsaddasads", "email": None} + resp2 = client.post("/prereg-members/", json=payload, headers=auth_headers(admin_token)) + assert resp2.status_code in [400, 422] + payload = {"telephone_number": None, "stil_id": None, "email": "wow"} + resp3 = client.post("/prereg-members/", json=payload, headers=auth_headers(admin_token)) + assert resp3.status_code in [400, 422] + + +def test_create_duplicate_prereg_member_fails(client, admin_token, db_session): + create_db_prereg_member(db_session, telephone_number="+46234567890", stil_id="ef5678gh-s", email="555@test.com") + db_session.commit() + payload = {"telephone_number": "+46234567890", "stil_id": "ef5678gh-s", "email": "555@test.com"} + resp = client.post("/prereg-members/", json=payload, headers=auth_headers(admin_token)) + assert resp.status_code in [400, 409] + + +def test_create_multiple_prereg_members(client, admin_token, db_session): + existing = create_db_prereg_member( + db_session, telephone_number="+46234567890", stil_id="ij1234kl-s", email="999@test.com" + ) + db_session.commit() + payload = [ + { + "telephone_number": "+46234567890", + "stil_id": "ij1234kl-s", + "email": "999@test.com", + }, # duplicate (will be skipped) + {"telephone_number": "+46234567890", "stil_id": "mn5678op-s", "email": "888@test.com"}, # new + {"telephone_number": "+46234567890", "stil_id": "qr9012st-s", "email": "777@test.com"}, # new + ] + resp = client.post("/prereg-members/multiple", json=payload, headers=auth_headers(admin_token)) + assert resp.status_code in [200, 201] + data = resp.json() + # one collision returned (existing), others created — ensure collision present + assert any(d["prereg_member_id"] == existing.prereg_member_id for d in data) + + +def test_create_multiple_prereg_members_all_exist_fails(client, admin_token, db_session): + create_db_prereg_member(db_session, telephone_number="+46234567890", stil_id="uv3456wx-s", email="999@test.com") + db_session.commit() + payload = [{"telephone_number": "+46234567890", "stil_id": "uv3456wx-s", "email": "999@test.com"}] + resp = client.post("/prereg-members/multiple", json=payload, headers=auth_headers(admin_token)) + assert resp.status_code == 400 + + +def test_update_prereg_member(client, admin_token, db_session): + member = create_db_prereg_member(db_session, email="old@test.com") + payload = {"email": "new@test.com"} + resp = client.patch(f"/prereg-members/{member.prereg_member_id}", json=payload, headers=auth_headers(admin_token)) + assert resp.status_code == 200 + assert resp.json()["email"] == "new@test.com" + + +def test_update_prereg_member_remove_all_identifiers_fails(client, admin_token, db_session): + member = create_db_prereg_member(db_session, email="old@test.com") + payload = {"telephone_number": None, "email": None} + resp = client.patch(f"/prereg-members/{member.prereg_member_id}", json=payload, headers=auth_headers(admin_token)) + assert resp.status_code == 400 + + +def test_delete_prereg_member(client, admin_token, db_session): + member = create_db_prereg_member(db_session, email="delete@test.com") + resp = client.delete(f"/prereg-members/{member.prereg_member_id}", headers=auth_headers(admin_token)) + assert resp.status_code == 200 + assert resp.json()["prereg_member_id"] == member.prereg_member_id + + +def test_delete_multiple_prereg_members(client, admin_token, db_session): + m1 = create_db_prereg_member(db_session, email="m1@test.com") + m2 = create_db_prereg_member(db_session, email="m2@test.com") + + payload = [m1.prereg_member_id, m2.prereg_member_id] + resp = client.request("DELETE", "/prereg-members/multiple", json=payload, headers=auth_headers(admin_token)) + assert resp.status_code == 200 + assert len(resp.json()) == 2