Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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)."""

isRegistered: 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.isRegistered)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stick to convention for isRegistered, make it snake case instead (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 = {"isRegistered": 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 = {"isRegistered": 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 = {"isRegistered": 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 = {"isRegistered": 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={"isRegistered": 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={"isRegistered": 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={"isRegistered": 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