Skip to content

Commit e493961

Browse files
authored
Merge pull request #95 from cssgunc/student-attendance-route
Student Attendacnce Route
2 parents a5fc142 + 069f9cd commit e493961

File tree

5 files changed

+314
-1
lines changed

5 files changed

+314
-1
lines changed

backend/src/modules/student/student_model.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,4 +66,12 @@ class StudentCreate(BaseModel):
6666
data: StudentDataWithNames
6767

6868

69+
class IsRegisteredUpdate(BaseModel):
70+
"""Request body for updating student registration status (staff/admin)."""
71+
72+
is_registered: bool = Field(
73+
..., description="True to mark as registered, False to unmark"
74+
)
75+
76+
6977
PaginatedStudentsResponse = PaginatedResponse[Student]

backend/src/modules/student/student_router.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from src.modules.party.party_service import PartyService
1010

1111
from .student_model import (
12+
IsRegisteredUpdate,
1213
PaginatedStudentsResponse,
1314
Student,
1415
StudentCreate,
@@ -145,3 +146,17 @@ async def delete_student(
145146
_=Depends(authenticate_admin),
146147
) -> Student:
147148
return await student_service.delete_student(student_id)
149+
150+
151+
@student_router.patch("/{student_id}/is-registered")
152+
async def update_is_registered(
153+
student_id: int,
154+
data: IsRegisteredUpdate,
155+
student_service: StudentService = Depends(),
156+
_=Depends(authenticate_staff_or_admin),
157+
) -> Student:
158+
"""
159+
Update the registration status (attendance) for a student.
160+
Staff can use this to mark students as present/absent.
161+
"""
162+
return await student_service.update_is_registered(student_id, data.is_registered)

backend/src/modules/student/student_service.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from datetime import datetime, timezone
2+
13
from fastapi import Depends
24
from sqlalchemy import func, select
35
from sqlalchemy.exc import IntegrityError
@@ -191,3 +193,21 @@ async def delete_student(self, account_id: int) -> Student:
191193
await self.session.delete(student_entity)
192194
await self.session.commit()
193195
return student_dto
196+
197+
async def update_is_registered(self, account_id: int, is_registered: bool) -> Student:
198+
"""
199+
Update the registration status of a student.
200+
If is_registered is True, sets last_registered to current datetime.
201+
If is_registered is False, sets last_registered to None.
202+
"""
203+
student_entity = await self._get_student_entity_by_account_id(account_id)
204+
205+
if is_registered:
206+
student_entity.last_registered = datetime.now(timezone.utc)
207+
else:
208+
student_entity.last_registered = None
209+
210+
self.session.add(student_entity)
211+
await self.session.commit()
212+
await self.session.refresh(student_entity, ["account"])
213+
return student_entity.to_dto()

backend/test/modules/student/student_router_test.py

Lines changed: 200 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@
66
import pytest_asyncio
77
from httpx import ASGITransport, AsyncClient
88
from sqlalchemy.ext.asyncio import AsyncSession
9-
from src.core.authentication import authenticate_admin, authenticate_student
9+
from src.core.authentication import (
10+
authenticate_admin,
11+
authenticate_staff_or_admin,
12+
authenticate_student,
13+
)
1014
from src.core.database import get_session
1115
from src.main import app
1216
from src.modules.account.account_entity import AccountEntity, AccountRole
@@ -1524,3 +1528,198 @@ async def test_get_me_parties_forbidden_police(
15241528
assert res.status_code == 403
15251529
response_data = res.json()
15261530
assert "message" in response_data
1531+
1532+
1533+
@pytest_asyncio.fixture()
1534+
async def override_dependencies_staff(test_async_session: AsyncSession):
1535+
"""Override dependencies to simulate staff authentication."""
1536+
1537+
async def _get_test_session():
1538+
yield test_async_session
1539+
1540+
async def _fake_staff():
1541+
return Account(
1542+
id=1,
1543+
1544+
first_name="Staff",
1545+
last_name="User",
1546+
pid="333333333",
1547+
role=AccountRole.STAFF,
1548+
)
1549+
1550+
app.dependency_overrides[get_session] = _get_test_session
1551+
app.dependency_overrides[authenticate_staff_or_admin] = _fake_staff
1552+
yield
1553+
app.dependency_overrides.clear()
1554+
1555+
1556+
@pytest.mark.asyncio
1557+
async def test_update_is_registered_mark_as_registered_as_staff(
1558+
override_dependencies_staff: Any, test_async_session: AsyncSession
1559+
):
1560+
"""Test marking a student as registered (staff authentication)."""
1561+
acc = AccountEntity(
1562+
1563+
first_name="Test",
1564+
last_name="Student",
1565+
pid="300000001",
1566+
role=AccountRole.STUDENT,
1567+
)
1568+
test_async_session.add(acc)
1569+
await test_async_session.commit()
1570+
await test_async_session.refresh(acc)
1571+
1572+
student = StudentEntity.from_model(
1573+
StudentData(
1574+
contact_preference=ContactPreference.text,
1575+
phone_number="5559998888",
1576+
last_registered=None,
1577+
),
1578+
acc.id,
1579+
)
1580+
test_async_session.add(student)
1581+
await test_async_session.commit()
1582+
1583+
payload = {"is_registered": True}
1584+
async with AsyncClient(
1585+
transport=ASGITransport(app=app), base_url="http://test"
1586+
) as client:
1587+
res = await client.patch(
1588+
f"/api/students/{acc.id}/is-registered", json=payload, headers=auth_headers()
1589+
)
1590+
assert res.status_code == 200
1591+
body = res.json()
1592+
assert body["id"] == acc.id
1593+
assert body["last_registered"] is not None
1594+
1595+
1596+
@pytest.mark.asyncio
1597+
async def test_update_is_registered_mark_as_not_registered_as_admin(
1598+
override_dependencies_admin: Any, test_async_session: AsyncSession
1599+
):
1600+
"""Test unmarking a student as registered (admin authentication)."""
1601+
acc = AccountEntity(
1602+
1603+
first_name="Test",
1604+
last_name="Student",
1605+
pid="300000002",
1606+
role=AccountRole.STUDENT,
1607+
)
1608+
test_async_session.add(acc)
1609+
await test_async_session.commit()
1610+
await test_async_session.refresh(acc)
1611+
1612+
student = StudentEntity.from_model(
1613+
StudentData(
1614+
contact_preference=ContactPreference.call,
1615+
phone_number="5559997777",
1616+
last_registered=datetime.now(timezone.utc),
1617+
),
1618+
acc.id,
1619+
)
1620+
test_async_session.add(student)
1621+
await test_async_session.commit()
1622+
1623+
payload = {"is_registered": False}
1624+
async with AsyncClient(
1625+
transport=ASGITransport(app=app), base_url="http://test"
1626+
) as client:
1627+
res = await client.patch(
1628+
f"/api/students/{acc.id}/is-registered", json=payload, headers=auth_headers()
1629+
)
1630+
assert res.status_code == 200
1631+
body = res.json()
1632+
assert body["id"] == acc.id
1633+
assert body["last_registered"] is None
1634+
1635+
1636+
@pytest.mark.asyncio
1637+
async def test_update_is_registered_student_not_found(
1638+
override_dependencies_staff: Any
1639+
):
1640+
"""Test updating is_registered for non-existent student."""
1641+
payload = {"is_registered": True}
1642+
async with AsyncClient(
1643+
transport=ASGITransport(app=app), base_url="http://test"
1644+
) as client:
1645+
res = await client.patch(
1646+
"/api/students/99999/is-registered", json=payload, headers=auth_headers()
1647+
)
1648+
assert res.status_code == 404
1649+
1650+
1651+
@pytest.mark.asyncio
1652+
async def test_update_is_registered_unauthenticated(
1653+
override_dependencies_no_auth: Any
1654+
):
1655+
"""Test updating is_registered without authentication."""
1656+
payload = {"is_registered": True}
1657+
async with AsyncClient(
1658+
transport=ASGITransport(app=app), base_url="http://test"
1659+
) as client:
1660+
res = await client.patch("/api/students/1/is-registered", json=payload)
1661+
assert res.status_code in [401, 403]
1662+
1663+
1664+
@pytest.mark.asyncio
1665+
async def test_update_is_registered_toggle(
1666+
override_dependencies_staff: Any, test_async_session: AsyncSession
1667+
):
1668+
"""Test toggling is_registered from False to True and back."""
1669+
acc = AccountEntity(
1670+
1671+
first_name="Toggle",
1672+
last_name="Student",
1673+
pid="300000003",
1674+
role=AccountRole.STUDENT,
1675+
)
1676+
test_async_session.add(acc)
1677+
await test_async_session.commit()
1678+
await test_async_session.refresh(acc)
1679+
1680+
student = StudentEntity.from_model(
1681+
StudentData(
1682+
contact_preference=ContactPreference.text,
1683+
phone_number="5559996666",
1684+
last_registered=None,
1685+
),
1686+
acc.id,
1687+
)
1688+
test_async_session.add(student)
1689+
await test_async_session.commit()
1690+
1691+
async with AsyncClient(
1692+
transport=ASGITransport(app=app), base_url="http://test"
1693+
) as client:
1694+
# Mark as registered
1695+
res = await client.patch(
1696+
f"/api/students/{acc.id}/is-registered",
1697+
json={"is_registered": True},
1698+
headers=auth_headers(),
1699+
)
1700+
assert res.status_code == 200
1701+
body = res.json()
1702+
assert body["last_registered"] is not None
1703+
first_registered_time = body["last_registered"]
1704+
1705+
# Unmark as registered
1706+
res = await client.patch(
1707+
f"/api/students/{acc.id}/is-registered",
1708+
json={"is_registered": False},
1709+
headers=auth_headers(),
1710+
)
1711+
assert res.status_code == 200
1712+
body = res.json()
1713+
assert body["last_registered"] is None
1714+
1715+
# Mark as registered again
1716+
res = await client.patch(
1717+
f"/api/students/{acc.id}/is-registered",
1718+
json={"is_registered": True},
1719+
headers=auth_headers(),
1720+
)
1721+
assert res.status_code == 200
1722+
body = res.json()
1723+
assert body["last_registered"] is not None
1724+
# Second registration time should be different (later)
1725+
assert body["last_registered"] != first_registered_time

backend/test/modules/student/student_service_test.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,3 +418,74 @@ async def test_update_student_with_non_student_role(
418418
)
419419
with pytest.raises(InvalidAccountRoleException):
420420
await student_service.update_student(test_account.id, update_data)
421+
422+
423+
@pytest.mark.asyncio
424+
async def test_update_is_registered_true(
425+
student_service: StudentService,
426+
test_async_session: AsyncSession,
427+
test_account: AccountEntity,
428+
):
429+
"""Test that update_is_registered sets last_registered to current datetime when True."""
430+
data = StudentDataWithNames(
431+
first_name="John",
432+
last_name="Doe",
433+
contact_preference=ContactPreference.text,
434+
phone_number="5551234567",
435+
)
436+
entity = StudentEntity.from_model(data, test_account.id)
437+
test_async_session.add(entity)
438+
await test_async_session.commit()
439+
await test_async_session.refresh(entity)
440+
441+
# Verify initially no last_registered
442+
assert entity.last_registered is None
443+
444+
# Update to registered
445+
before_update = datetime.now(timezone.utc)
446+
updated = await student_service.update_is_registered(test_account.id, is_registered=True)
447+
after_update = datetime.now(timezone.utc)
448+
449+
# Verify last_registered is set to approximately current time
450+
assert updated.last_registered is not None
451+
assert before_update <= updated.last_registered <= after_update
452+
453+
454+
@pytest.mark.asyncio
455+
async def test_update_is_registered_false(
456+
student_service: StudentService,
457+
test_async_session: AsyncSession,
458+
test_account: AccountEntity,
459+
):
460+
"""Test that update_is_registered sets last_registered to None when False."""
461+
# Create student with a last_registered value
462+
last_reg = datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc)
463+
data = StudentDataWithNames(
464+
first_name="John",
465+
last_name="Doe",
466+
contact_preference=ContactPreference.text,
467+
phone_number="5551234567",
468+
last_registered=last_reg,
469+
)
470+
entity = StudentEntity.from_model(data, test_account.id)
471+
test_async_session.add(entity)
472+
await test_async_session.commit()
473+
await test_async_session.refresh(entity)
474+
475+
# Verify initially has last_registered (database may strip timezone)
476+
assert entity.last_registered is not None
477+
478+
# Update to not registered
479+
updated = await student_service.update_is_registered(test_account.id, is_registered=False)
480+
481+
# Verify last_registered is now None
482+
assert updated.last_registered is None
483+
484+
485+
@pytest.mark.asyncio
486+
async def test_update_is_registered_student_not_found(
487+
student_service: StudentService,
488+
):
489+
"""Test that update_is_registered raises StudentNotFoundException for nonexistent student."""
490+
with pytest.raises(StudentNotFoundException):
491+
await student_service.update_is_registered(99999, is_registered=True)

0 commit comments

Comments
 (0)