Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions backend/src/modules/student/student_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,12 @@ class StudentCreate(BaseModel):
data: StudentDataWithNames


class IsRegisteredUpdate(BaseModel):
"""Request body for updating student registration status (staff/admin)."""

is_registered: bool = Field(
..., description="True to mark as registered, False to unmark"
)


PaginatedStudentsResponse = PaginatedResponse[Student]
15 changes: 15 additions & 0 deletions backend/src/modules/student/student_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from src.modules.party.party_service import PartyService

from .student_model import (
IsRegisteredUpdate,
PaginatedStudentsResponse,
Student,
StudentCreate,
Expand Down Expand Up @@ -145,3 +146,17 @@ async def delete_student(
_=Depends(authenticate_admin),
) -> Student:
return await student_service.delete_student(student_id)


@student_router.patch("/{student_id}/is-registered")
async def update_is_registered(
student_id: int,
data: IsRegisteredUpdate,
student_service: StudentService = Depends(),
_=Depends(authenticate_staff_or_admin),
) -> Student:
"""
Update the registration status (attendance) for a student.
Staff can use this to mark students as present/absent.
"""
return await student_service.update_is_registered(student_id, data.is_registered)
20 changes: 20 additions & 0 deletions backend/src/modules/student/student_service.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from datetime import datetime, timezone

from fastapi import Depends
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
Expand Down Expand Up @@ -191,3 +193,21 @@ async def delete_student(self, account_id: int) -> Student:
await self.session.delete(student_entity)
await self.session.commit()
return student_dto

async def update_is_registered(self, account_id: int, is_registered: bool) -> Student:
"""
Update the registration status of a student.
If is_registered is True, sets last_registered to current datetime.
If is_registered is False, sets last_registered to None.
"""
student_entity = await self._get_student_entity_by_account_id(account_id)

if is_registered:
student_entity.last_registered = datetime.now(timezone.utc)
else:
student_entity.last_registered = None

self.session.add(student_entity)
await self.session.commit()
await self.session.refresh(student_entity, ["account"])
return student_entity.to_dto()
201 changes: 200 additions & 1 deletion backend/test/modules/student/student_router_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from src.core.authentication import authenticate_admin, authenticate_student
from src.core.authentication import (
authenticate_admin,
authenticate_staff_or_admin,
authenticate_student,
)
from src.core.database import get_session
from src.main import app
from src.modules.account.account_entity import AccountEntity, AccountRole
Expand Down Expand Up @@ -1524,3 +1528,198 @@ async def test_get_me_parties_forbidden_police(
assert res.status_code == 403
response_data = res.json()
assert "message" in response_data


@pytest_asyncio.fixture()
async def override_dependencies_staff(test_async_session: AsyncSession):
"""Override dependencies to simulate staff authentication."""

async def _get_test_session():
yield test_async_session

async def _fake_staff():
return Account(
id=1,
email="[email protected]",
first_name="Staff",
last_name="User",
pid="333333333",
role=AccountRole.STAFF,
)

app.dependency_overrides[get_session] = _get_test_session
app.dependency_overrides[authenticate_staff_or_admin] = _fake_staff
yield
app.dependency_overrides.clear()


@pytest.mark.asyncio
async def test_update_is_registered_mark_as_registered_as_staff(
override_dependencies_staff: Any, test_async_session: AsyncSession
):
"""Test marking a student as registered (staff authentication)."""
acc = AccountEntity(
email="[email protected]",
first_name="Test",
last_name="Student",
pid="300000001",
role=AccountRole.STUDENT,
)
test_async_session.add(acc)
await test_async_session.commit()
await test_async_session.refresh(acc)

student = StudentEntity.from_model(
StudentData(
contact_preference=ContactPreference.text,
phone_number="5559998888",
last_registered=None,
),
acc.id,
)
test_async_session.add(student)
await test_async_session.commit()

payload = {"is_registered": True}
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
res = await client.patch(
f"/api/students/{acc.id}/is-registered", json=payload, headers=auth_headers()
)
assert res.status_code == 200
body = res.json()
assert body["id"] == acc.id
assert body["last_registered"] is not None


@pytest.mark.asyncio
async def test_update_is_registered_mark_as_not_registered_as_admin(
override_dependencies_admin: Any, test_async_session: AsyncSession
):
"""Test unmarking a student as registered (admin authentication)."""
acc = AccountEntity(
email="[email protected]",
first_name="Test",
last_name="Student",
pid="300000002",
role=AccountRole.STUDENT,
)
test_async_session.add(acc)
await test_async_session.commit()
await test_async_session.refresh(acc)

student = StudentEntity.from_model(
StudentData(
contact_preference=ContactPreference.call,
phone_number="5559997777",
last_registered=datetime.now(timezone.utc),
),
acc.id,
)
test_async_session.add(student)
await test_async_session.commit()

payload = {"is_registered": False}
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
res = await client.patch(
f"/api/students/{acc.id}/is-registered", json=payload, headers=auth_headers()
)
assert res.status_code == 200
body = res.json()
assert body["id"] == acc.id
assert body["last_registered"] is None


@pytest.mark.asyncio
async def test_update_is_registered_student_not_found(
override_dependencies_staff: Any
):
"""Test updating is_registered for non-existent student."""
payload = {"is_registered": True}
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
res = await client.patch(
"/api/students/99999/is-registered", json=payload, headers=auth_headers()
)
assert res.status_code == 404


@pytest.mark.asyncio
async def test_update_is_registered_unauthenticated(
override_dependencies_no_auth: Any
):
"""Test updating is_registered without authentication."""
payload = {"is_registered": True}
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
res = await client.patch("/api/students/1/is-registered", json=payload)
assert res.status_code in [401, 403]


@pytest.mark.asyncio
async def test_update_is_registered_toggle(
override_dependencies_staff: Any, test_async_session: AsyncSession
):
"""Test toggling is_registered from False to True and back."""
acc = AccountEntity(
email="[email protected]",
first_name="Toggle",
last_name="Student",
pid="300000003",
role=AccountRole.STUDENT,
)
test_async_session.add(acc)
await test_async_session.commit()
await test_async_session.refresh(acc)

student = StudentEntity.from_model(
StudentData(
contact_preference=ContactPreference.text,
phone_number="5559996666",
last_registered=None,
),
acc.id,
)
test_async_session.add(student)
await test_async_session.commit()

async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
# Mark as registered
res = await client.patch(
f"/api/students/{acc.id}/is-registered",
json={"is_registered": True},
headers=auth_headers(),
)
assert res.status_code == 200
body = res.json()
assert body["last_registered"] is not None
first_registered_time = body["last_registered"]

# Unmark as registered
res = await client.patch(
f"/api/students/{acc.id}/is-registered",
json={"is_registered": False},
headers=auth_headers(),
)
assert res.status_code == 200
body = res.json()
assert body["last_registered"] is None

# Mark as registered again
res = await client.patch(
f"/api/students/{acc.id}/is-registered",
json={"is_registered": True},
headers=auth_headers(),
)
assert res.status_code == 200
body = res.json()
assert body["last_registered"] is not None
# Second registration time should be different (later)
assert body["last_registered"] != first_registered_time
71 changes: 71 additions & 0 deletions backend/test/modules/student/student_service_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -418,3 +418,74 @@ async def test_update_student_with_non_student_role(
)
with pytest.raises(InvalidAccountRoleException):
await student_service.update_student(test_account.id, update_data)


@pytest.mark.asyncio
async def test_update_is_registered_true(
student_service: StudentService,
test_async_session: AsyncSession,
test_account: AccountEntity,
):
"""Test that update_is_registered sets last_registered to current datetime when True."""
data = StudentDataWithNames(
first_name="John",
last_name="Doe",
contact_preference=ContactPreference.text,
phone_number="5551234567",
)
entity = StudentEntity.from_model(data, test_account.id)
test_async_session.add(entity)
await test_async_session.commit()
await test_async_session.refresh(entity)

# Verify initially no last_registered
assert entity.last_registered is None

# Update to registered
before_update = datetime.now(timezone.utc)
updated = await student_service.update_is_registered(test_account.id, is_registered=True)
after_update = datetime.now(timezone.utc)

# Verify last_registered is set to approximately current time
assert updated.last_registered is not None
assert before_update <= updated.last_registered <= after_update


@pytest.mark.asyncio
async def test_update_is_registered_false(
student_service: StudentService,
test_async_session: AsyncSession,
test_account: AccountEntity,
):
"""Test that update_is_registered sets last_registered to None when False."""
# Create student with a last_registered value
last_reg = datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc)
data = StudentDataWithNames(
first_name="John",
last_name="Doe",
contact_preference=ContactPreference.text,
phone_number="5551234567",
last_registered=last_reg,
)
entity = StudentEntity.from_model(data, test_account.id)
test_async_session.add(entity)
await test_async_session.commit()
await test_async_session.refresh(entity)

# Verify initially has last_registered (database may strip timezone)
assert entity.last_registered is not None

# Update to not registered
updated = await student_service.update_is_registered(test_account.id, is_registered=False)

# Verify last_registered is now None
assert updated.last_registered is None


@pytest.mark.asyncio
async def test_update_is_registered_student_not_found(
student_service: StudentService,
):
"""Test that update_is_registered raises StudentNotFoundException for nonexistent student."""
with pytest.raises(StudentNotFoundException):
await student_service.update_is_registered(99999, is_registered=True)