diff --git a/app/__init__.py b/app/__init__.py index 1ce316b..21f7a26 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -5,7 +5,7 @@ from app.api import kiosk, stats from .config import config -from .api import members, auth, events, admin, mail, jobs +from .api import members, auth, events, admin, mail, jobs, committees from .db import setup_db @@ -40,6 +40,7 @@ def create_app(): app.include_router(mail.router, prefix="/api/mail", tags=["mail"]) app.include_router(jobs.router, prefix="/api/jobs", tags=["job"]) app.include_router(kiosk.router, prefix="/api/kiosk", tags=["kiosk"]) + app.include_router(committees.router, prefix="/api/committee", tags=["committee"]) # only visible in development app.include_router( stats.router, diff --git a/app/api/committees.py b/app/api/committees.py new file mode 100644 index 0000000..760db14 --- /dev/null +++ b/app/api/committees.py @@ -0,0 +1,361 @@ +import re +import logging +from datetime import datetime +from typing import Optional +from uuid import UUID, uuid4 + +from fastapi import APIRouter, Depends, HTTPException, Request, Response, Query + +from app.auth_helpers import authorize, authorize_admin +from app.db import get_database +from app.models import ( + AccessTokenPayload, + Committee, + CommitteeDB, + CommitteeInput, + CommitteeApplicationInput, + CommitteeMemberInput, + CommitteeMemberListItem, + CommitteeUpdate, + Status, +) +from app.utils.validation import validate_uuid + + +router = APIRouter() +logger = logging.getLogger(__name__) + + +def slugify(value: str) -> str: + value = value.lower().strip() + value = re.sub(r"[^a-z0-9]+", "-", value) + value = re.sub(r"-+", "-", value) + return value.strip("-") + + +def committee_member_count(db, committee_id: UUID) -> int: + return db.committeeMembers.count_documents({ + "committeeId": committee_id, + "active": True, + }) + + +def get_committee_or_404(db, id: str): + c = db.committees.find_one({"id": UUID(id)}) + if not c: + raise HTTPException(404, "Committee not found") + return c + + +@router.get("/") +def list_committees( + request: Request, + status: Optional[Status] = None, + hasOpenSpots: Optional[bool] = None, + q: Optional[str] = None, + sort: Optional[str] = Query("name", description="Sort by field, prefix with - for desc"), + page: int = Query(1, ge=1), + limit: int = Query(20, ge=1, le=100), +): + db = get_database(request) + + query = {} + if status is not None: + query["status"] = f"{status}" + if hasOpenSpots is not None: + query["hasOpenSpots"] = hasOpenSpots + if q: + query["$or"] = [ + {"name": {"$regex": q, "$options": "i"}}, + {"description": {"$regex": q, "$options": "i"}}, + ] + + sort_field = "name" + sort_dir = 1 + if sort: + if sort.startswith("-"): + sort_field = sort[1:] + sort_dir = -1 + else: + sort_field = sort + if sort_field not in ["name", "createdAt", "updatedAt"]: + sort_field = "name" + + total = db.committees.count_documents(query) + cur = ( + db.committees.find(query) + .sort(sort_field, sort_dir) + .skip((page - 1) * limit) + .limit(limit) + ) + + items = [] + for c in cur: + count = committee_member_count(db, c["id"]) + payload = Committee.model_validate({**c, "memberCount": count}) + items.append(payload) + + return { + "items": [i.model_dump() for i in items], + "total": total, + "page": page, + "limit": limit, + } + + +@router.get("/{id}", dependencies=[Depends(validate_uuid)]) +def get_committee(request: Request, id: str): + db = get_database(request) + c = get_committee_or_404(db, id) + count = committee_member_count(db, c["id"]) + return Committee.model_validate({**c, "memberCount": count}) + + +@router.post("/") +def create_committee( + request: Request, + payload: CommitteeInput, + token: AccessTokenPayload = Depends(authorize_admin), +): + db = get_database(request) + + # Derive slug if not provided + slug = payload.slug or slugify(payload.name) + # Ensure uniqueness + exists = db.committees.find_one({"slug": slug}) + if exists: + raise HTTPException(409, "Slug already in use") + + # CreatedBy is email; fetch current user email + creator = db.members.find_one({"id": UUID(token.user_id)}) + if not creator: + raise HTTPException(400, "Creator not found") + + now = datetime.utcnow() + doc = CommitteeDB( + id=uuid4(), + name=payload.name, + slug=slug, + description=payload.description, + status=payload.status, + hasOpenSpots=payload.hasOpenSpots, + createdAt=now, + updatedAt=now, + createdBy=creator["email"], + email=payload.email, + ) + + db.committees.insert_one(doc.model_dump()) + return Response(status_code=201) + + +@router.put("/{id}", dependencies=[Depends(validate_uuid)]) +def update_committee( + request: Request, + id: str, + payload: CommitteeUpdate, + token: AccessTokenPayload = Depends(authorize_admin), +): + db = get_database(request) + c = get_committee_or_404(db, id) + + values = payload.model_dump(exclude_unset=True) + if len(values) == 0: + raise HTTPException(400, "Update values cannot be empty") + + if "slug" in values and values["slug"]: + # Normalize and ensure unique + new_slug = slugify(values["slug"]) if values["slug"] else slugify(c["name"]) + if new_slug != c["slug"]: + exists = db.committees.find_one({"slug": new_slug}) + if exists: + raise HTTPException(409, "Slug already in use") + values["slug"] = new_slug + + values["updatedAt"] = datetime.utcnow() + + res = db.committees.find_one_and_update({"id": c["id"]}, {"$set": values}) + if not res: + raise HTTPException(500, "Unexpected error when updating committee") + return Response(status_code=200) + + +@router.post("/{id}/apply", dependencies=[Depends(validate_uuid)]) +def apply_for_committee( + request: Request, + id: str, + payload: CommitteeApplicationInput, + token: AccessTokenPayload = Depends(authorize), +): + """Allow a logged-in member to apply to a committee. + + Sends an email to the committee's associated email with applicant details. + """ + db = get_database(request) + c = get_committee_or_404(db, id) + + if not c.get("hasOpenSpots", False): + raise HTTPException(400, "Committee is not open for new members") + + committee_email = c.get("email") + if not committee_email: + raise HTTPException(400, "Committee does not have an associated email") + + member = db.members.find_one({"id": UUID(token.user_id)}) + if not member: + raise HTTPException(404, "User could not be found") + + # Build email content + message = payload.message or "" + if message and len(message) > 2000: + raise HTTPException(400, "Message is too long") + + content_lines = [ + f"New application to {c['name']} committee", + "", + f"Applicant: {member['realName']}", + f"Email: {member['email']}", + ] + if message: + content_lines.extend(["", "Content:", message]) + content = "\n".join(content_lines) + + # Only send mail in production (keeps parity with reset password/confirm flows) + if request.app.config.ENV == "production": + from app.api.mail import send_mail + from app.models import MailPayload + + mail = MailPayload( + to=[committee_email], + subject=f"New committee application: {c['name']}", + content=content, + ) + send_mail(mail) + + return Response(status_code=202) + + +@router.delete("/{id}", dependencies=[Depends(validate_uuid)]) +def delete_committee( + request: Request, id: str, token: AccessTokenPayload = Depends(authorize_admin) +): + db = get_database(request) + c = get_committee_or_404(db, id) + # Delete memberships associated (current and historical) + db.committeeMembers.delete_many({"committeeId": c["id"]}) + res = db.committees.find_one_and_delete({"id": c["id"]}) + if not res: + raise HTTPException(500, "Unexpected error when deleting committee") + return Response(status_code=200) + + +@router.post("/{id}/members", dependencies=[Depends(validate_uuid)]) +def add_committee_member( + request: Request, + id: str, + body: CommitteeMemberInput, + token: AccessTokenPayload = Depends(authorize_admin), +): + db = get_database(request) + c = get_committee_or_404(db, id) + + if not c["hasOpenSpots"]: + raise HTTPException(400, "Committee is not open for new members") + + member = db.members.find_one({"id": UUID(str(body.userId))}) + if not member: + raise HTTPException(404, "Member not found") + + creator = db.members.find_one({"id": UUID(token.user_id)}) + if not creator: + raise HTTPException(400, "Creator not found") + + doc = { + "committeeId": c["id"], + "userId": member["id"], + "addedBy": creator["email"], + "addedAt": datetime.utcnow(), + "active": True, + "leftAt": None, + "leftBy": None, + } + + # Try insert; if exists, try re-activate if inactive + try: + db.committeeMembers.insert_one(doc) + except Exception: + # Check if there is an existing inactive record + existing = db.committeeMembers.find_one( + {"committeeId": c["id"], "userId": member["id"]} + ) + if existing and existing.get("active") is False: + db.committeeMembers.find_one_and_update( + {"committeeId": c["id"], "userId": member["id"]}, + {"$set": {"active": True, "leftAt": None, "leftBy": None}}, + ) + else: + raise HTTPException(409, "Member already assigned to committee") + + return Response(status_code=201) + + +@router.delete("/{id}/members/{userId}", dependencies=[Depends(validate_uuid)]) +def remove_committee_member( + request: Request, + id: str, + userId: str, + token: AccessTokenPayload = Depends(authorize_admin), +): + db = get_database(request) + c = get_committee_or_404(db, id) + admin = db.members.find_one({"id": UUID(token.user_id)}) + + res = db.committeeMembers.find_one_and_update( + {"committeeId": c["id"], "userId": UUID(userId), "active": True}, + {"$set": {"active": False, "leftAt": datetime.utcnow(), "leftBy": admin["email"]}}, + ) + if not res: + raise HTTPException(404, "Active membership not found") + return Response(status_code=200) + + +@router.get("/{id}/members", dependencies=[Depends(validate_uuid)]) +def list_committee_members( + request: Request, + id: str, + page: int = Query(1, ge=1), + limit: int = Query(20, ge=1, le=100), + token: AccessTokenPayload = Depends(authorize_admin), +): + db = get_database(request) + c = get_committee_or_404(db, id) + + match = {"committeeId": c["id"], "active": True} + total = db.committeeMembers.count_documents(match) + + pipeline = [ + {"$match": match}, + {"$lookup": {"from": "members", "localField": "userId", "foreignField": "id", "as": "user"}}, + {"$unwind": "$user"}, + {"$sort": {"user.realName": 1}}, + {"$skip": (page - 1) * limit}, + {"$limit": limit}, + {"$project": { + "_id": 0, + "id": "$user.id", + "realName": "$user.realName", + "email": "$user.email", + "classOf": "$user.classof", + "phone": "$user.phone", + "role": "$user.role", + }}, + ] + + rows = list(db.committeeMembers.aggregate(pipeline)) + items = [CommitteeMemberListItem.model_validate(r) for r in rows] + return { + "items": [i.model_dump() for i in items], + "total": total, + "page": page, + "limit": limit, + } diff --git a/app/db.py b/app/db.py index b12a211..0172979 100644 --- a/app/db.py +++ b/app/db.py @@ -50,6 +50,10 @@ def setup_db(app): # Expire reset password codes after 10 minutes app.db.passwordResets.create_index("createdAt", expireAfterSeconds=60 * 10) + + # Ensure unique committee memberships (one user per committee) + app.db.committeeMembers.create_index([("committeeId", 1), ("userId", 1)], unique=True) + app.qr_path = f'{file_storage_path}/qr' if app.config.MONGO_DBNAME == 'test': app.image_path = 'db/test_event_images' diff --git a/app/models.py b/app/models.py index a59a205..5c32c3c 100644 --- a/app/models.py +++ b/app/models.py @@ -248,6 +248,83 @@ class PenaltyInput(BaseModel): penalty: int = Field(ge=0, description="Penalty must be larger or equal to 0") +# -------------------- Committees -------------------- + +class CommitteeInput(BaseModel): + name: str + description: Optional[str] = None + hasOpenSpots: bool + status: Status + # Optional to allow custom slugs; otherwise derived from name + slug: Optional[str] = None + # Email associated with the committee (receives applications) + email: EmailStr + + +class CommitteeUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + hasOpenSpots: Optional[bool] = None + status: Optional[Status] = None + slug: Optional[str] = None + email: Optional[EmailStr] = None + + +class Committee(BaseModel): + id: UUID4 + name: str + slug: str + description: Optional[str] = None + status: Status + hasOpenSpots: bool + createdAt: datetime + updatedAt: datetime + createdBy: EmailStr + email: EmailStr + memberCount: int + + +class CommitteeDB(BaseModel): + id: UUID4 + name: str + slug: str + description: Optional[str] = None + status: Status + hasOpenSpots: bool + createdAt: datetime + updatedAt: datetime + createdBy: EmailStr + email: EmailStr + + +class CommitteeApplicationInput(BaseModel): + # Optional message provided by the applicant + message: Optional[str] = None + + +class CommitteeMemberInput(BaseModel): + userId: UUID4 + + +class CommitteeMemberDB(BaseModel): + committeeId: UUID4 + userId: UUID4 + addedBy: EmailStr + addedAt: datetime + active: bool = True + leftAt: Optional[datetime] = None + leftBy: Optional[EmailStr] = None + + +class CommitteeMemberListItem(BaseModel): + id: UUID4 + realName: str + email: EmailStr + classOf: str + phone: Optional[str] = None + role: Role + + class SetAttendancePayload(BaseModel): member_id: Optional[str] = None attendance: bool diff --git a/db/seeds/test_seeds/test_committees.json b/db/seeds/test_seeds/test_committees.json new file mode 100644 index 0000000..559ba85 --- /dev/null +++ b/db/seeds/test_seeds/test_committees.json @@ -0,0 +1,34 @@ +[ + { + "name": "Board", + "slug": "board", + "description": "TD Board - Leadership committee", + "status": "active", + "hasOpenSpots": false, + "email": "board@td-uit.no" + }, + { + "name": "Tech Committee", + "slug": "tech", + "description": "Technology and development committee", + "status": "active", + "hasOpenSpots": true, + "email": "tech@td-uit.no" + }, + { + "name": "Social Committee", + "slug": "social", + "description": "Social events and activities", + "status": "active", + "hasOpenSpots": true, + "email": "social@td-uit.no" + }, + { + "name": "Inactive Committee", + "slug": "inactive", + "description": "This committee is inactive", + "status": "inactive", + "hasOpenSpots": false, + "email": "inactive@td-uit.no" + } +] diff --git a/tests/conftest.py b/tests/conftest.py index 500c98b..232e361 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,7 @@ from app import config from pymongo import MongoClient from fastapi.testclient import TestClient -from utils.seeding import seed_events, seed_members +from utils.seeding import seed_events, seed_members, seed_committees import sys import os @@ -39,6 +39,12 @@ def client(app): # important that test_members.json always has a penalized member seed_members(app.db, f"{test_seed_path}/test_members.json") seed_events(app.db, f"{test_seed_path}/test_events.json") + # Don't seed committee members in tests - let tests control membership + seed_committees(app.db, f"{test_seed_path}/test_committees.json", seed_members=False) + + # Create indexes after seeding (since drop_database removed them) + app.db.committeeMembers.create_index([("committeeId", 1), ("userId", 1)], unique=True) + yield client diff --git a/tests/test_endpoints/test_committees.py b/tests/test_endpoints/test_committees.py new file mode 100644 index 0000000..1268d18 --- /dev/null +++ b/tests/test_endpoints/test_committees.py @@ -0,0 +1,455 @@ +from app.db import get_test_db +from app.models import Status +from tests.conftest import client_login +from tests.users import regular_member, admin_member, second_member, second_admin +from tests.utils.authentication import admin_required, authentication_required +from uuid import UUID + +db = get_test_db() + + +# -------------------- List Committees -------------------- + +def test_list_committees_public(client): + """Test that anyone can list committees without authentication""" + response = client.get("/api/committee/") + assert response.status_code == 200 + res_json = response.json() + + assert "items" in res_json + assert "total" in res_json + assert "page" in res_json + assert "limit" in res_json + + # Should have 4 committees from seed data + assert res_json["total"] == 4 + assert len(res_json["items"]) == 4 + + # Verify structure of committee items + for item in res_json["items"]: + assert "id" in item + assert "name" in item + assert "slug" in item + assert "status" in item + assert "hasOpenSpots" in item + assert "memberCount" in item + assert "email" in item + + +def test_list_committees_pagination(client): + """Test pagination for committee listing""" + response = client.get("/api/committee/?page=1&limit=2") + assert response.status_code == 200 + res_json = response.json() + + assert res_json["total"] == 4 + assert len(res_json["items"]) == 2 + assert res_json["page"] == 1 + assert res_json["limit"] == 2 + + +def test_list_committees_filter_by_status(client): + """Test filtering committees by status""" + response = client.get("/api/committee/?status=active") + assert response.status_code == 200 + res_json = response.json() + + assert res_json["total"] == 3 # Board, Tech, Social are active + for item in res_json["items"]: + assert item["status"] == "active" + + response = client.get("/api/committee/?status=inactive") + assert response.status_code == 200 + res_json = response.json() + + assert res_json["total"] == 1 # Only "Inactive Committee" + assert res_json["items"][0]["status"] == "inactive" + + +def test_list_committees_filter_by_open_spots(client): + """Test filtering committees by hasOpenSpots""" + response = client.get("/api/committee/?hasOpenSpots=true") + assert response.status_code == 200 + res_json = response.json() + + # Tech and Social have open spots + assert res_json["total"] >= 2 + for item in res_json["items"]: + assert item["hasOpenSpots"] is True + + +def test_list_committees_sorting(client): + """Test sorting committees by name""" + response = client.get("/api/committee/?sortBy=name&sortOrder=asc") + assert response.status_code == 200 + res_json = response.json() + + names = [item["name"] for item in res_json["items"]] + assert names == sorted(names) + + +# -------------------- Get Committee by ID -------------------- + +def test_get_committee_by_id(client): + """Test getting a specific committee by ID""" + committee = db.committees.find_one({"slug": "board"}) + assert committee is not None + + response = client.get(f"/api/committee/{committee['id']}") + assert response.status_code == 200 + res_json = response.json() + + assert res_json["id"] == str(committee["id"]) + assert res_json["name"] == "Board" + assert res_json["slug"] == "board" + assert "memberCount" in res_json + + +def test_get_committee_not_found(client): + """Test getting a non-existent committee""" + from uuid import uuid4 + fake_id = uuid4() + + response = client.get(f"/api/committee/{fake_id}") + assert response.status_code == 404 + + +def test_get_committee_invalid_uuid(client): + """Test getting committee with invalid UUID""" + response = client.get("/api/committee/not-a-uuid") + assert response.status_code == 400 # validate_uuid returns 400 + + +# -------------------- Create Committee (Admin) -------------------- + + +def test_create_committee(client): + """Test creating a new committee as admin""" + client_login(client, admin_member["email"], admin_member["password"]) + + payload = { + "name": "New Committee", + "description": "A brand new committee", + "hasOpenSpots": True, + "status": "active", + "email": "new@td-uit.no" + } + + response = client.post("/api/committee/", json=payload) + assert response.status_code == 201 + + # Verify committee was created by fetching it + created_committee = db.committees.find_one({"name": payload["name"]}) + assert created_committee is not None + assert created_committee["slug"] == "new-committee" # Auto-generated slug + assert created_committee["description"] == payload["description"] + assert created_committee["hasOpenSpots"] == payload["hasOpenSpots"] + assert created_committee["status"] == payload["status"] + assert created_committee["email"] == payload["email"] + assert created_committee["createdBy"] == admin_member["email"] + + +def test_create_committee_with_custom_slug(client): + """Test creating a committee with a custom slug""" + client_login(client, admin_member["email"], admin_member["password"]) + + payload = { + "name": "Custom Committee", + "slug": "custom-slug", + "hasOpenSpots": False, + "status": "active", + "email": "custom@td-uit.no" + } + + response = client.post("/api/committee/", json=payload) + assert response.status_code == 201 + + # Verify custom slug was used + created_committee = db.committees.find_one({"slug": "custom-slug"}) + assert created_committee is not None + assert created_committee["name"] == payload["name"] + + +def test_create_committee_duplicate_slug(client): + """Test that creating a committee with duplicate slug fails""" + client_login(client, admin_member["email"], admin_member["password"]) + + payload = { + "name": "Duplicate", + "slug": "board", # Already exists in seed data + "hasOpenSpots": True, + "status": "active", + "email": "dup@td-uit.no" + } + + response = client.post("/api/committee/", json=payload) + assert response.status_code == 409 + + +# -------------------- Update Committee (Admin) -------------------- + + +def test_update_committee(client): + """Test updating a committee""" + client_login(client, admin_member["email"], admin_member["password"]) + + committee = db.committees.find_one({"slug": "tech"}) + assert committee is not None + + payload = { + "name": "Updated Tech Committee", + "description": "Updated description", + "hasOpenSpots": False + } + + response = client.put(f"/api/committee/{committee['id']}", json=payload) + assert response.status_code == 200 + + # Verify committee was updated + updated_committee = db.committees.find_one({"id": committee["id"]}) + assert updated_committee["name"] == payload["name"] + assert updated_committee["description"] == payload["description"] + assert updated_committee["hasOpenSpots"] == payload["hasOpenSpots"] + assert updated_committee["slug"] == "tech" # Slug unchanged + + +def test_update_committee_slug_conflict(client): + """Test that updating slug to existing one fails""" + client_login(client, admin_member["email"], admin_member["password"]) + + committee = db.committees.find_one({"slug": "tech"}) + payload = {"slug": "board"} # Already exists + + response = client.put(f"/api/committee/{committee['id']}", json=payload) + assert response.status_code == 409 + + +# -------------------- Delete Committee (Admin) -------------------- + + +def test_delete_committee(client): + """Test deleting a committee""" + client_login(client, admin_member["email"], admin_member["password"]) + + # Create a committee to delete + payload = { + "name": "To Delete", + "hasOpenSpots": True, + "status": "active", + "email": "delete@td-uit.no" + } + create_response = client.post("/api/committee/", json=payload) + assert create_response.status_code == 201 + + # Get the created committee ID from database + created_committee = db.committees.find_one({"name": "To Delete"}) + assert created_committee is not None + committee_id = created_committee["id"] + + # Delete it + response = client.delete(f"/api/committee/{committee_id}") + assert response.status_code == 200 + + # Verify it's gone + get_response = client.get(f"/api/committee/{committee_id}") + assert get_response.status_code == 404 + + +# -------------------- Add Committee Member (Admin) -------------------- + + +def test_add_committee_member(client): + """Test adding a member to a committee""" + client_login(client, admin_member["email"], admin_member["password"]) + + # Get a committee with open spots + committee = db.committees.find_one({"slug": "tech"}) + member = db.members.find_one({"email": regular_member["email"]}) + + payload = {"userId": str(member["id"])} + + response = client.post(f"/api/committee/{committee['id']}/members", json=payload) + assert response.status_code == 201 + + # Verify member was added + membership = db.committeeMembers.find_one({ + "committeeId": committee["id"], + "userId": member["id"], + "active": True + }) + assert membership is not None + assert membership["addedBy"] == admin_member["email"] + + +def test_add_committee_member_no_open_spots(client): + """Test adding member to committee without open spots fails""" + client_login(client, admin_member["email"], admin_member["password"]) + + committee = db.committees.find_one({"slug": "board"}) # hasOpenSpots: false + member = db.members.find_one({"email": regular_member["email"]}) + + payload = {"userId": str(member["id"])} + + response = client.post(f"/api/committee/{committee['id']}/members", json=payload) + assert response.status_code == 400 + + +def test_add_committee_member_duplicate(client): + """Test adding the same member twice fails""" + client_login(client, admin_member["email"], admin_member["password"]) + + committee = db.committees.find_one({"slug": "tech"}) + member = db.members.find_one({"email": second_member["email"]}) + + payload = {"userId": str(member["id"])} + + # Add first time + response = client.post(f"/api/committee/{committee['id']}/members", json=payload) + assert response.status_code == 201 + + # Verify member was added and is active + membership = db.committeeMembers.find_one({ + "committeeId": committee["id"], + "userId": member["id"] + }) + assert membership is not None + assert membership["active"] is True + + response = client.post(f"/api/committee/{committee['id']}/members", json=payload) + assert response.status_code == 409 + + +def test_reactivate_inactive_member(client): + """Test that adding a previously removed member reactivates them""" + client_login(client, admin_member["email"], admin_member["password"]) + + committee = db.committees.find_one({"slug": "tech"}) + member = db.members.find_one({"email": second_admin["email"]}) + + payload = {"userId": str(member["id"])} + + # Add member + response = client.post(f"/api/committee/{committee['id']}/members", json=payload) + assert response.status_code == 201 + + # Remove member + response = client.delete(f"/api/committee/{committee['id']}/members/{member['id']}") + assert response.status_code == 200 + + # Verify member is inactive + membership = db.committeeMembers.find_one({ + "committeeId": committee["id"], + "userId": member["id"] + }) + assert membership is not None + assert membership.get("active") is False + + # Add again (should reactivate and return 201) + response = client.post(f"/api/committee/{committee['id']}/members", json=payload) + assert response.status_code == 201 + + # Note: The actual reactivation logic sets active=True in the database + # We've verified the API accepts the request successfully + + +# -------------------- List Committee Members (Admin) -------------------- + + +def test_list_committee_members(client): + """Test listing members of a committee""" + client_login(client, admin_member["email"], admin_member["password"]) + + # Add some members first + committee = db.committees.find_one({"slug": "social"}) + member1 = db.members.find_one({"email": regular_member["email"]}) + member2 = db.members.find_one({"email": second_member["email"]}) + + for member in [member1, member2]: + payload = {"userId": str(member["id"])} + client.post(f"/api/committee/{committee['id']}/members", json=payload) + + # List members + response = client.get(f"/api/committee/{committee['id']}/members") + assert response.status_code == 200 + res_json = response.json() + + assert "items" in res_json + assert "total" in res_json + assert res_json["total"] == 2 + + # Verify member structure + for item in res_json["items"]: + assert "id" in item + assert "realName" in item + assert "email" in item + assert "classOf" in item + assert "role" in item + + + +# -------------------- Remove Committee Member (Admin) -------------------- + + +def test_remove_committee_member(client): + """Test removing a member from a committee""" + client_login(client, admin_member["email"], admin_member["password"]) + + committee = db.committees.find_one({"slug": "tech"}) + member = db.members.find_one({"email": second_admin["email"]}) + + # Add member first + payload = {"userId": str(member["id"])} + client.post(f"/api/committee/{committee['id']}/members", json=payload) + + # Remove member + response = client.delete(f"/api/committee/{committee['id']}/members/{member['id']}") + assert response.status_code == 200 + + # Verify member is inactive + membership = db.committeeMembers.find_one({ + "committeeId": committee["id"], + "userId": member["id"] + }) + assert membership["active"] is False + assert membership["leftBy"] == admin_member["email"] + assert membership["leftAt"] is not None + + +def test_remove_nonexistent_member(client): + """Test removing a member that doesn't exist""" + client_login(client, admin_member["email"], admin_member["password"]) + + from uuid import uuid4 + committee = db.committees.find_one({"slug": "tech"}) + fake_member_id = uuid4() + + response = client.delete(f"/api/committee/{committee['id']}/members/{fake_member_id}") + assert response.status_code == 404 + +# -------------------- Member Count Validation -------------------- + +def test_member_count_accuracy(client): + """Test that memberCount reflects active members only""" + client_login(client, admin_member["email"], admin_member["password"]) + + committee = db.committees.find_one({"slug": "social"}) + + # Get initial count + response = client.get(f"/api/committee/{committee['id']}") + initial_count = response.json()["memberCount"] + + # Add a member + member = db.members.find_one({"email": regular_member["email"]}) + payload = {"userId": str(member["id"])} + client.post(f"/api/committee/{committee['id']}/members", json=payload) + + # Check count increased + response = client.get(f"/api/committee/{committee['id']}") + assert response.json()["memberCount"] == initial_count + 1 + + # Remove the member + client.delete(f"/api/committee/{committee['id']}/members/{member['id']}") + + # Check count decreased + response = client.get(f"/api/committee/{committee['id']}") + assert response.json()["memberCount"] == initial_count diff --git a/utils/seeding.py b/utils/seeding.py index fb6877a..6780b83 100644 --- a/utils/seeding.py +++ b/utils/seeding.py @@ -4,6 +4,7 @@ from werkzeug.security import generate_password_hash from app import config from app.models import EventDB +from app.models import CommitteeDB import json import os import shutil @@ -180,6 +181,80 @@ def seed_jobs(db, seed_path): db.jobs.insert_many(list_of_jobs) +def seed_committees(db, seed_path=None, seed_members=True): + """Create sample committees and optionally assign random members. + + Args: + db: Database connection + seed_path: Path to JSON file with committee data + seed_members: If False, only create committees without assigning members (useful for tests) + """ + if seed_path: + with open(seed_path, "r") as f: + committees = json.load(f) + else: + committees = [ + {"name": "Board", "slug": "board", "description": "TD Board", "status": "active", "hasOpenSpots": False, "email": "board@td-uit.no"}, + {"name": "Tech", "slug": "tech", "description": "Tech committee", "status": "active", "hasOpenSpots": True, "email": "tech@td-uit.no"}, + {"name": "Social", "slug": "social", "description": "Social & events", "status": "active", "hasOpenSpots": True, "email": "social@td-uit.no"}, + ] + + # Use admin email as creator if present, else fallback + creator = db.members.find_one({"role": "admin"}) or db.members.find_one({}) + creator_email = creator and creator.get("email") or "seed@td-uit.no" + now = datetime.now() + + for c in committees: + if db.committees.find_one({"slug": c["slug"]}): + continue + doc = CommitteeDB( + id=uuid4(), + name=c["name"], + slug=c["slug"], + description=c.get("description"), + status=c["status"], + hasOpenSpots=c["hasOpenSpots"], + createdAt=now, + updatedAt=now, + createdBy=creator_email, + email=c["email"], + ) + db.committees.insert_one(doc.model_dump()) + + # Skip member assignment if seed_members is False (for test environments) + if not seed_members: + return + + # Assign some members to the committees + # Gather some member ids + members = list(db.members.find({}, {"id": 1, "email": 1})) + for c in db.committees.find({}, {"id": 1, "slug": 1, "hasOpenSpots": 1}): + # Assign 3-6 members + count = random.randint(3, min(6, len(members))) + random.shuffle(members) + chosen = members[:count] + for m in chosen: + # Check if membership already exists before inserting + existing = db.committeeMembers.find_one({ + "committeeId": c["id"], + "userId": m["id"] + }) + if existing: + continue + try: + db.committeeMembers.insert_one({ + "committeeId": c["id"], + "userId": m["id"], + "addedBy": creator_email, + "addedAt": now, + "active": True, + "leftAt": None, + "leftBy": None, + }) + except Exception: + pass + + if __name__ == "__main__": db = get_db() events_seed_path = f"{base_dir}/events.json" @@ -190,3 +265,4 @@ def seed_jobs(db, seed_path): seed_events(db, events_seed_path) seed_jobs(db, jobs_seed_path) seed_stats(db) + seed_committees(db)