Spaces:
Runtime error
Runtime error
All changes commit
Browse files- src/config.py +15 -0
- src/crud/__init__.py +42 -0
- src/crud/clearance.py +77 -0
- src/crud/devices.py +52 -0
- src/crud/students.py +79 -0
- src/crud/tag_linking.py +43 -0
- src/crud/users.py +93 -0
- src/models.py +120 -189
- src/routers/admin.py +61 -87
- src/routers/clearance.py +49 -81
- src/routers/devices.py +59 -197
- src/routers/rfid.py +83 -0
- src/routers/students.py +26 -70
- src/routers/token.py +25 -43
- src/routers/users.py +21 -55
- src/utils.py +51 -0
src/config.py
ADDED
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from pydantic_settings import BaseSettings
|
2 |
+
import os
|
3 |
+
from dotenv import load_dotenv
|
4 |
+
|
5 |
+
load_dotenv()
|
6 |
+
|
7 |
+
class Settings(BaseSettings):
|
8 |
+
POSTGRES_URI: str
|
9 |
+
JWT_SECRET_KEY: str = os.getenv("JWT_SECRET_KEY", "default_secret_key")
|
10 |
+
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
11 |
+
|
12 |
+
class Config:
|
13 |
+
env_file = ".env"
|
14 |
+
|
15 |
+
settings = Settings()
|
src/crud/__init__.py
ADDED
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
CRUD Package Initializer
|
3 |
+
|
4 |
+
This file makes the 'crud' directory a Python package and imports all the
|
5 |
+
public CRUD functions from the submodules. This allows you to import any
|
6 |
+
CRUD function directly from `src.crud` instead of the specific submodule,
|
7 |
+
keeping the router imports clean.
|
8 |
+
"""
|
9 |
+
|
10 |
+
from .students import (
|
11 |
+
create_student,
|
12 |
+
get_all_students,
|
13 |
+
get_student_by_student_id,
|
14 |
+
get_student_by_tag_id,
|
15 |
+
update_student_tag_id,
|
16 |
+
delete_student,
|
17 |
+
)
|
18 |
+
from .users import (
|
19 |
+
create_user,
|
20 |
+
get_user_by_username,
|
21 |
+
get_user_by_tag_id,
|
22 |
+
update_user_tag_id,
|
23 |
+
get_user_by_id,
|
24 |
+
delete_user,
|
25 |
+
)
|
26 |
+
from .devices import (
|
27 |
+
get_device_by_id_str,
|
28 |
+
get_device_by_api_key,
|
29 |
+
create_device_log,
|
30 |
+
update_device_last_seen,
|
31 |
+
delete_device,
|
32 |
+
)
|
33 |
+
from .clearance import (
|
34 |
+
get_clearance_statuses_by_student_id,
|
35 |
+
update_clearance_status,
|
36 |
+
delete_clearance_status, # <-- Added the new delete function
|
37 |
+
)
|
38 |
+
from .tag_linking import (
|
39 |
+
create_pending_tag_link,
|
40 |
+
get_pending_link_by_device,
|
41 |
+
delete_pending_link,
|
42 |
+
)
|
src/crud/clearance.py
ADDED
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
CRUD operations for student Clearance Statuses.
|
3 |
+
"""
|
4 |
+
from sqlalchemy.orm import Session
|
5 |
+
from fastapi import HTTPException, status
|
6 |
+
|
7 |
+
from src import models
|
8 |
+
|
9 |
+
def get_clearance_statuses_by_student_id(db: Session, student_id: str) -> list[models.ClearanceStatus]:
|
10 |
+
"""
|
11 |
+
Fetches all existing clearance status records for a given student.
|
12 |
+
"""
|
13 |
+
return db.query(models.ClearanceStatus).filter(models.ClearanceStatus.student_id == student_id).all()
|
14 |
+
|
15 |
+
def update_clearance_status(
|
16 |
+
db: Session,
|
17 |
+
student_id: str,
|
18 |
+
department: models.ClearanceDepartment,
|
19 |
+
new_status: models.ClearanceStatusEnum,
|
20 |
+
remarks: str,
|
21 |
+
cleared_by_user_id: int
|
22 |
+
) -> models.ClearanceStatus:
|
23 |
+
"""
|
24 |
+
Updates or creates a clearance status for a student in a specific department.
|
25 |
+
"""
|
26 |
+
student = db.query(models.Student).filter(models.Student.student_id == student_id).first()
|
27 |
+
if not student:
|
28 |
+
raise HTTPException(
|
29 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
30 |
+
detail=f"Student with ID '{student_id}' not found."
|
31 |
+
)
|
32 |
+
|
33 |
+
existing_status = db.query(models.ClearanceStatus).filter(
|
34 |
+
models.ClearanceStatus.student_id == student_id,
|
35 |
+
models.ClearanceStatus.department == department
|
36 |
+
).first()
|
37 |
+
|
38 |
+
if existing_status:
|
39 |
+
existing_status.status = new_status
|
40 |
+
existing_status.remarks = remarks
|
41 |
+
existing_status.cleared_by = cleared_by_user_id
|
42 |
+
db_status = existing_status
|
43 |
+
else:
|
44 |
+
db_status = models.ClearanceStatus(
|
45 |
+
student_id=student_id,
|
46 |
+
department=department,
|
47 |
+
status=new_status,
|
48 |
+
remarks=remarks,
|
49 |
+
cleared_by=cleared_by_user_id
|
50 |
+
)
|
51 |
+
db.add(db_status)
|
52 |
+
|
53 |
+
db.commit()
|
54 |
+
db.refresh(db_status)
|
55 |
+
return db_status
|
56 |
+
|
57 |
+
def delete_clearance_status(
|
58 |
+
db: Session,
|
59 |
+
student_id: str,
|
60 |
+
department: models.ClearanceDepartment
|
61 |
+
) -> models.ClearanceStatus | None:
|
62 |
+
"""
|
63 |
+
Deletes a specific clearance status record for a student.
|
64 |
+
|
65 |
+
This effectively resets the status for that department to the default state.
|
66 |
+
Returns the deleted object if found, otherwise returns None.
|
67 |
+
"""
|
68 |
+
status_to_delete = db.query(models.ClearanceStatus).filter(
|
69 |
+
models.ClearanceStatus.student_id == student_id,
|
70 |
+
models.ClearanceStatus.department == department
|
71 |
+
).first()
|
72 |
+
|
73 |
+
if status_to_delete:
|
74 |
+
db.delete(status_to_delete)
|
75 |
+
db.commit()
|
76 |
+
|
77 |
+
return status_to_delete
|
src/crud/devices.py
ADDED
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
CRUD operations for Devices and Device Logs.
|
3 |
+
"""
|
4 |
+
from sqlalchemy.orm import Session
|
5 |
+
from datetime import datetime
|
6 |
+
from fastapi import HTTPException, status
|
7 |
+
|
8 |
+
from src import models
|
9 |
+
|
10 |
+
def get_device_by_id_str(db: Session, device_id: str) -> models.Device | None:
|
11 |
+
"""Fetches a device by its public device_id string."""
|
12 |
+
return db.query(models.Device).filter(models.Device.device_id == device_id).first()
|
13 |
+
|
14 |
+
def get_device_by_api_key(db: Session, api_key: str) -> models.Device | None:
|
15 |
+
"""Fetches a device by its unique API key for authentication."""
|
16 |
+
return db.query(models.Device).filter(models.Device.api_key == api_key).first()
|
17 |
+
|
18 |
+
def update_device_last_seen(db: Session, device_id: int):
|
19 |
+
"""Updates the last_seen timestamp for a device."""
|
20 |
+
db.query(models.Device).filter(models.Device.id == device_id).update({"last_seen": datetime.utcnow()})
|
21 |
+
db.commit()
|
22 |
+
|
23 |
+
def create_device_log(db: Session, log_data: dict):
|
24 |
+
"""Creates a new log entry for a device action."""
|
25 |
+
new_log = models.DeviceLog(**log_data)
|
26 |
+
db.add(new_log)
|
27 |
+
db.commit()
|
28 |
+
db.refresh(new_log)
|
29 |
+
return new_log
|
30 |
+
|
31 |
+
def delete_device(db: Session, device_id_str: str) -> models.Device:
|
32 |
+
"""
|
33 |
+
Deletes a device and all of its associated records (logs, pending links).
|
34 |
+
"""
|
35 |
+
device_to_delete = get_device_by_id_str(db, device_id_str)
|
36 |
+
if not device_to_delete:
|
37 |
+
raise HTTPException(
|
38 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
39 |
+
detail=f"Device with ID '{device_id_str}' not found."
|
40 |
+
)
|
41 |
+
|
42 |
+
device_pk_id = device_to_delete.id
|
43 |
+
|
44 |
+
# Delete all dependent records first to maintain foreign key integrity
|
45 |
+
db.query(models.DeviceLog).filter(models.DeviceLog.device_fk_id == device_pk_id).delete(synchronize_session=False)
|
46 |
+
db.query(models.PendingTagLink).filter(models.PendingTagLink.device_id_fk == device_pk_id).delete(synchronize_session=False)
|
47 |
+
|
48 |
+
# Now delete the device itself
|
49 |
+
db.delete(device_to_delete)
|
50 |
+
db.commit()
|
51 |
+
|
52 |
+
return device_to_delete
|
src/crud/students.py
ADDED
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
CRUD operations for Students.
|
3 |
+
"""
|
4 |
+
from sqlalchemy.orm import Session
|
5 |
+
from fastapi import HTTPException, status
|
6 |
+
|
7 |
+
from src import models
|
8 |
+
|
9 |
+
def get_student_by_student_id(db: Session, student_id: str) -> models.Student | None:
|
10 |
+
"""Fetches a student by their unique student ID."""
|
11 |
+
return db.query(models.Student).filter(models.Student.student_id == student_id).first()
|
12 |
+
|
13 |
+
def get_student_by_tag_id(db: Session, tag_id: str) -> models.Student | None:
|
14 |
+
"""Fetches a student by their RFID tag ID."""
|
15 |
+
return db.query(models.Student).filter(models.Student.tag_id == tag_id).first()
|
16 |
+
|
17 |
+
def get_all_students(db: Session, skip: int = 0, limit: int = 100) -> list[models.Student]:
|
18 |
+
"""Fetches all students with pagination."""
|
19 |
+
return db.query(models.Student).offset(skip).limit(limit).all()
|
20 |
+
|
21 |
+
def create_student(db: Session, student: models.StudentCreate) -> models.Student:
|
22 |
+
"""Creates a new student in the database."""
|
23 |
+
db_student = get_student_by_student_id(db, student.student_id)
|
24 |
+
if db_student:
|
25 |
+
raise HTTPException(
|
26 |
+
status_code=status.HTTP_409_CONFLICT,
|
27 |
+
detail=f"Student with ID '{student.student_id}' already exists."
|
28 |
+
)
|
29 |
+
|
30 |
+
new_student = models.Student(**student.model_dump())
|
31 |
+
db.add(new_student)
|
32 |
+
db.commit()
|
33 |
+
db.refresh(new_student)
|
34 |
+
return new_student
|
35 |
+
|
36 |
+
def update_student_tag_id(db: Session, student_id: str, tag_id: str) -> models.Student:
|
37 |
+
"""Updates the RFID tag ID for a specific student."""
|
38 |
+
db_student = get_student_by_student_id(db, student_id)
|
39 |
+
if not db_student:
|
40 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Student not found")
|
41 |
+
|
42 |
+
existing_tag_user = get_student_by_tag_id(db, tag_id)
|
43 |
+
if existing_tag_user and existing_tag_user.student_id != student_id:
|
44 |
+
raise HTTPException(
|
45 |
+
status_code=status.HTTP_409_CONFLICT,
|
46 |
+
detail=f"Tag ID '{tag_id}' is already assigned to another student."
|
47 |
+
)
|
48 |
+
|
49 |
+
db_student.tag_id = tag_id
|
50 |
+
db.commit()
|
51 |
+
db.refresh(db_student)
|
52 |
+
return db_student
|
53 |
+
|
54 |
+
def delete_student(db: Session, student_id: str) -> models.Student:
|
55 |
+
"""
|
56 |
+
Deletes a student and all of their associated clearance records.
|
57 |
+
|
58 |
+
This function first finds the student, then deletes all related rows in the
|
59 |
+
'clearance_statuses' table before finally deleting the student record
|
60 |
+
to maintain database integrity.
|
61 |
+
"""
|
62 |
+
student_to_delete = get_student_by_student_id(db, student_id)
|
63 |
+
if not student_to_delete:
|
64 |
+
raise HTTPException(
|
65 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
66 |
+
detail=f"Student with ID '{student_id}' not found."
|
67 |
+
)
|
68 |
+
|
69 |
+
# First, delete all associated clearance statuses for this student.
|
70 |
+
# This is crucial to prevent foreign key constraint violations.
|
71 |
+
db.query(models.ClearanceStatus).filter(
|
72 |
+
models.ClearanceStatus.student_id == student_id
|
73 |
+
).delete(synchronize_session=False)
|
74 |
+
|
75 |
+
# Now, delete the student record itself.
|
76 |
+
db.delete(student_to_delete)
|
77 |
+
db.commit()
|
78 |
+
|
79 |
+
return student_to_delete
|
src/crud/tag_linking.py
ADDED
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
CRUD operations for the PendingTagLink model.
|
3 |
+
"""
|
4 |
+
from sqlalchemy.orm import Session
|
5 |
+
from datetime import datetime
|
6 |
+
|
7 |
+
from src import models
|
8 |
+
|
9 |
+
def create_pending_tag_link(db: Session, link_details: models.PrepareTagLinkRequest, initiated_by_id: int, expires_at: datetime) -> models.PendingTagLink:
|
10 |
+
"""Creates a new pending tag link request in the database."""
|
11 |
+
|
12 |
+
device = db.query(models.Device).filter(models.Device.device_id == link_details.device_identifier).first()
|
13 |
+
if not device:
|
14 |
+
# This check should ideally be in the router, but adding here as a safeguard
|
15 |
+
return None
|
16 |
+
|
17 |
+
new_link = models.PendingTagLink(
|
18 |
+
device_id_fk=device.id,
|
19 |
+
target_user_type=link_details.target_user_type,
|
20 |
+
target_identifier=link_details.target_identifier,
|
21 |
+
initiated_by_user_id=initiated_by_id,
|
22 |
+
expires_at=expires_at,
|
23 |
+
)
|
24 |
+
db.add(new_link)
|
25 |
+
db.commit()
|
26 |
+
db.refresh(new_link)
|
27 |
+
return new_link
|
28 |
+
|
29 |
+
def get_pending_link_by_device(db: Session, device_id: int) -> models.PendingTagLink | None:
|
30 |
+
"""
|
31 |
+
Fetches the active (non-expired) pending tag link for a specific device.
|
32 |
+
"""
|
33 |
+
return db.query(models.PendingTagLink).filter(
|
34 |
+
models.PendingTagLink.device_id_fk == device_id,
|
35 |
+
models.PendingTagLink.expires_at > datetime.utcnow()
|
36 |
+
).first()
|
37 |
+
|
38 |
+
def delete_pending_link(db: Session, link_id: int):
|
39 |
+
"""Deletes a pending link, typically after it has been used."""
|
40 |
+
link_to_delete = db.query(models.PendingTagLink).filter(models.PendingTagLink.id == link_id).first()
|
41 |
+
if link_to_delete:
|
42 |
+
db.delete(link_to_delete)
|
43 |
+
db.commit()
|
src/crud/users.py
ADDED
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
CRUD operations for Users (Admins, Staff).
|
3 |
+
"""
|
4 |
+
from sqlalchemy.orm import Session
|
5 |
+
from fastapi import HTTPException, status
|
6 |
+
|
7 |
+
from src import models
|
8 |
+
from src.auth import get_password_hash # Assuming this is in your auth file
|
9 |
+
|
10 |
+
def get_user_by_id(db: Session, user_id: int) -> models.User | None:
|
11 |
+
"""Fetches a user by their primary key ID."""
|
12 |
+
return db.query(models.User).filter(models.User.id == user_id).first()
|
13 |
+
|
14 |
+
def get_user_by_username(db: Session, username: str) -> models.User | None:
|
15 |
+
"""Fetches a user by their unique username."""
|
16 |
+
return db.query(models.User).filter(models.User.username == username).first()
|
17 |
+
|
18 |
+
def get_user_by_tag_id(db: Session, tag_id: str) -> models.User | None:
|
19 |
+
"""Fetches a user by their RFID tag ID."""
|
20 |
+
return db.query(models.User).filter(models.User.tag_id == tag_id).first()
|
21 |
+
|
22 |
+
def create_user(db: Session, user: models.UserCreate) -> models.User:
|
23 |
+
"""Creates a new user in the database."""
|
24 |
+
if get_user_by_username(db, user.username):
|
25 |
+
raise HTTPException(
|
26 |
+
status_code=status.HTTP_409_CONFLICT,
|
27 |
+
detail=f"Username '{user.username}' is already registered."
|
28 |
+
)
|
29 |
+
hashed_password = get_password_hash(user.password)
|
30 |
+
new_user = models.User(
|
31 |
+
username=user.username,
|
32 |
+
hashed_password=hashed_password,
|
33 |
+
role=user.role,
|
34 |
+
department=user.department,
|
35 |
+
tag_id=user.tag_id
|
36 |
+
)
|
37 |
+
db.add(new_user)
|
38 |
+
db.commit()
|
39 |
+
db.refresh(new_user)
|
40 |
+
return new_user
|
41 |
+
|
42 |
+
def update_user_tag_id(db: Session, username: str, tag_id: str) -> models.User:
|
43 |
+
"""Updates the RFID tag ID for a specific user."""
|
44 |
+
db_user = get_user_by_username(db, username)
|
45 |
+
if not db_user:
|
46 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
47 |
+
|
48 |
+
existing_tag_user = get_user_by_tag_id(db, tag_id)
|
49 |
+
if existing_tag_user and existing_tag_user.username != username:
|
50 |
+
raise HTTPException(
|
51 |
+
status_code=status.HTTP_409_CONFLICT,
|
52 |
+
detail=f"Tag ID '{tag_id}' is already assigned to another user."
|
53 |
+
)
|
54 |
+
|
55 |
+
db_user.tag_id = tag_id
|
56 |
+
db.commit()
|
57 |
+
db.refresh(db_user)
|
58 |
+
return db_user
|
59 |
+
|
60 |
+
def delete_user(db: Session, username_to_delete: str, current_admin: models.User) -> models.User:
|
61 |
+
"""
|
62 |
+
Deletes a user, ensuring an admin cannot delete themselves or the last admin.
|
63 |
+
"""
|
64 |
+
if username_to_delete == current_admin.username:
|
65 |
+
raise HTTPException(
|
66 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
67 |
+
detail="Admins cannot delete their own account."
|
68 |
+
)
|
69 |
+
|
70 |
+
user_to_delete = get_user_by_username(db, username_to_delete)
|
71 |
+
if not user_to_delete:
|
72 |
+
raise HTTPException(
|
73 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
74 |
+
detail=f"User '{username_to_delete}' not found."
|
75 |
+
)
|
76 |
+
|
77 |
+
# Prevent deleting the last admin account
|
78 |
+
if user_to_delete.role == models.UserRole.ADMIN:
|
79 |
+
admin_count = db.query(models.User).filter(models.User.role == models.UserRole.ADMIN).count()
|
80 |
+
if admin_count <= 1:
|
81 |
+
raise HTTPException(
|
82 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
83 |
+
detail="Cannot delete the last remaining admin account."
|
84 |
+
)
|
85 |
+
|
86 |
+
# Handle dependencies: set foreign keys to NULL where a user is referenced
|
87 |
+
db.query(models.ClearanceStatus).filter(models.ClearanceStatus.cleared_by == user_to_delete.id).update({"cleared_by": None})
|
88 |
+
db.query(models.PendingTagLink).filter(models.PendingTagLink.initiated_by_user_id == user_to_delete.id).delete()
|
89 |
+
|
90 |
+
db.delete(user_to_delete)
|
91 |
+
db.commit()
|
92 |
+
|
93 |
+
return user_to_delete
|
src/models.py
CHANGED
@@ -1,234 +1,165 @@
|
|
1 |
-
|
2 |
-
|
3 |
-
|
4 |
-
|
5 |
-
from
|
6 |
-
from
|
7 |
-
import
|
8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
9 |
|
10 |
Base = declarative_base()
|
11 |
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
class ClearanceStatusEnum(str, enum.Enum):
|
23 |
-
COMPLETED = "COMPLETED"
|
24 |
-
NOT_COMPLETED = "NOT_COMPLETED"
|
25 |
-
PENDING = "PENDING"
|
26 |
-
|
27 |
-
class ClearanceDepartment(str, enum.Enum):
|
28 |
-
DEPARTMENTAL = "DEPARTMENTAL"
|
29 |
-
LIBRARY = "LIBRARY"
|
30 |
-
BURSARY = "BURSARY"
|
31 |
-
ALUMNI = "ALUMNI"
|
32 |
-
|
33 |
-
class TargetUserType(str, enum.Enum):
|
34 |
-
# Corrected to ALL CAPS to match other enums and likely DB schema
|
35 |
-
STUDENT = "STUDENT"
|
36 |
-
STAFF_ADMIN = "STAFF_ADMIN"
|
37 |
-
|
38 |
-
class OverallClearanceStatusEnum(str, enum.Enum):
|
39 |
-
COMPLETED = "COMPLETED"
|
40 |
-
PENDING = "PENDING"
|
41 |
-
|
42 |
-
# Helper for SQLAlchemyEnum to ensure values are used
|
43 |
-
def enum_values_callable(obj):
|
44 |
-
return [e.value for e in obj]
|
45 |
|
46 |
-
# --- ORM Model Definitions ---
|
47 |
|
48 |
class Student(Base):
|
|
|
49 |
__tablename__ = "students"
|
50 |
id = Column(Integer, primary_key=True, index=True)
|
51 |
student_id = Column(String, unique=True, index=True, nullable=False)
|
52 |
name = Column(String, nullable=False)
|
53 |
-
email = Column(String, unique=True, index=True, nullable=True)
|
54 |
department = Column(String, nullable=False)
|
55 |
tag_id = Column(String, unique=True, index=True, nullable=True)
|
56 |
-
created_at = Column(DateTime, default=datetime.utcnow)
|
57 |
-
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
58 |
|
59 |
-
|
60 |
-
|
61 |
-
id = Column(Integer, primary_key=True, index=True)
|
62 |
-
username = Column(String, unique=True, index=True, nullable=False)
|
63 |
-
hashed_password = Column(String, nullable=False)
|
64 |
-
role = Column(SQLAlchemyEnum(UserRole, name="userrole", create_type=False, values_callable=enum_values_callable), default=UserRole.STAFF, nullable=False)
|
65 |
-
department = Column(SQLAlchemyEnum(ClearanceDepartment, name="clearancedepartment", create_type=False, values_callable=enum_values_callable), nullable=True)
|
66 |
-
tag_id = Column(String, unique=True, index=True, nullable=True)
|
67 |
-
is_active = Column(Boolean, default=True)
|
68 |
-
created_at = Column(DateTime, default=datetime.utcnow)
|
69 |
-
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
70 |
|
71 |
class ClearanceStatus(Base):
|
|
|
72 |
__tablename__ = "clearance_statuses"
|
73 |
-
id = Column(Integer, primary_key=True,
|
74 |
-
student_id = Column(String, ForeignKey("students.student_id"),
|
75 |
-
department = Column(
|
76 |
-
status = Column(
|
77 |
-
|
78 |
-
|
79 |
-
|
80 |
-
|
|
|
|
|
|
|
|
|
|
|
81 |
|
82 |
class Device(Base):
|
|
|
83 |
__tablename__ = "devices"
|
84 |
-
id = Column(Integer, primary_key=True, index=True
|
85 |
-
|
86 |
-
device_id = Column(String, unique=True, index=True, nullable=True)
|
87 |
location = Column(String, nullable=True)
|
88 |
-
|
89 |
-
|
90 |
-
|
91 |
-
created_at = Column(DateTime, default=datetime.utcnow)
|
92 |
-
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
93 |
-
last_seen = Column(DateTime, nullable=True)
|
94 |
-
|
95 |
-
class PendingTagLink(Base):
|
96 |
-
__tablename__ = "pending_tag_links"
|
97 |
-
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
98 |
-
device_id_fk = Column(Integer, ForeignKey("devices.id"), index=True, nullable=False, name="device_id")
|
99 |
-
# ** FIX: Correctly define the column using SQLAlchemyEnum **
|
100 |
-
target_user_type = Column(SQLAlchemyEnum(TargetUserType, name="targetusertype", create_type=False, values_callable=enum_values_callable), nullable=False)
|
101 |
-
target_identifier = Column(String, nullable=False)
|
102 |
-
initiated_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
103 |
-
created_at = Column(DateTime, default=datetime.utcnow)
|
104 |
-
expires_at = Column(DateTime, nullable=False)
|
105 |
-
|
106 |
-
class DeviceLog(Base):
|
107 |
-
__tablename__ = "device_logs"
|
108 |
-
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
109 |
-
device_fk_id = Column(Integer, ForeignKey("devices.id"), index=True, nullable=True, name="device_id")
|
110 |
-
actual_device_id_str = Column(String, index=True, nullable=True)
|
111 |
-
tag_id_scanned = Column(String, index=True, nullable=True)
|
112 |
-
user_type = Column(String, nullable=True)
|
113 |
-
action = Column(String, nullable=False)
|
114 |
-
timestamp = Column(DateTime, default=datetime.utcnow, nullable=False)
|
115 |
-
|
116 |
-
# --- Pydantic Models ---
|
117 |
-
|
118 |
-
class StudentCreate(BaseModel):
|
119 |
-
student_id: str
|
120 |
-
name: str
|
121 |
-
email: Optional[EmailStr] = None
|
122 |
-
department: str
|
123 |
-
tag_id: Optional[str] = None
|
124 |
|
125 |
-
|
126 |
-
|
127 |
-
|
128 |
-
name: str
|
129 |
-
email: Optional[EmailStr] = None
|
130 |
-
department: str
|
131 |
-
tag_id: Optional[str] = None
|
132 |
-
created_at: datetime
|
133 |
-
updated_at: datetime
|
134 |
-
class Config: from_attributes = True
|
135 |
|
|
|
136 |
class UserBase(BaseModel):
|
137 |
username: str
|
138 |
-
|
139 |
-
department: Optional[ClearanceDepartment] = None
|
140 |
-
tag_id: Optional[str] = None
|
141 |
-
is_active: Optional[bool] = True
|
142 |
|
143 |
class UserCreate(UserBase):
|
144 |
password: str
|
|
|
145 |
|
146 |
-
class UserResponse(
|
147 |
id: int
|
148 |
-
username: str
|
149 |
-
role: UserRole
|
150 |
-
department: Optional[ClearanceDepartment] = None
|
151 |
-
tag_id: Optional[str] = None
|
152 |
is_active: bool
|
153 |
-
|
154 |
-
|
155 |
-
|
|
|
|
|
156 |
|
157 |
-
class
|
158 |
-
|
159 |
-
|
160 |
-
status: ClearanceStatusEnum
|
161 |
-
remarks: Optional[str] = None
|
162 |
|
163 |
-
class
|
164 |
-
|
165 |
-
|
166 |
-
|
167 |
-
|
168 |
-
|
|
|
|
|
|
|
169 |
|
170 |
-
class
|
|
|
|
|
|
|
171 |
id: int
|
172 |
-
|
173 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
174 |
status: ClearanceStatusEnum
|
175 |
-
|
176 |
-
|
177 |
-
|
178 |
-
created_at: datetime
|
179 |
-
class Config: from_attributes = True
|
180 |
|
181 |
class ClearanceDetail(BaseModel):
|
182 |
student_id: str
|
183 |
name: str
|
184 |
department: str
|
185 |
-
clearance_items: List[ClearanceStatusItem]
|
186 |
overall_status: OverallClearanceStatusEnum
|
187 |
-
|
188 |
-
|
189 |
-
class DeviceRegister(BaseModel):
|
190 |
-
device_id: str
|
191 |
-
location: str
|
192 |
-
|
193 |
-
class DeviceCreateAdmin(BaseModel):
|
194 |
-
name: str
|
195 |
-
description: Optional[str] = None
|
196 |
-
device_id: Optional[str] = None
|
197 |
-
location: Optional[str] = None
|
198 |
-
|
199 |
-
class DeviceResponse(BaseModel):
|
200 |
-
id: int
|
201 |
-
name: Optional[str] = None
|
202 |
-
device_id: Optional[str] = None
|
203 |
-
location: Optional[str] = None
|
204 |
-
api_key: str
|
205 |
-
is_active: bool
|
206 |
-
description: Optional[str] = None
|
207 |
-
created_at: datetime
|
208 |
-
updated_at: datetime
|
209 |
-
last_seen: Optional[datetime] = None
|
210 |
-
class Config: from_attributes = True
|
211 |
-
|
212 |
-
class TagScan(BaseModel):
|
213 |
-
device_id: str
|
214 |
-
tag_id: str
|
215 |
-
timestamp: Optional[datetime] = Field(default_factory=datetime.utcnow)
|
216 |
|
217 |
-
class
|
218 |
-
|
219 |
-
|
220 |
|
221 |
-
class TokenData(BaseModel):
|
222 |
-
username: Optional[str] = None
|
223 |
-
role: Optional[UserRole] = None
|
224 |
|
|
|
225 |
class TagLinkRequest(BaseModel):
|
226 |
-
tag_id: str
|
227 |
-
|
228 |
-
class
|
229 |
-
|
230 |
-
|
231 |
-
|
232 |
-
|
233 |
-
|
234 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
Pydantic Models for data validation and serialization.
|
3 |
+
SQLAlchemy Models for database table definitions.
|
4 |
+
"""
|
5 |
+
from pydantic import BaseModel, Field
|
6 |
+
from typing import List, Optional, Union
|
7 |
+
from enum import Enum
|
8 |
+
|
9 |
+
from sqlalchemy import (
|
10 |
+
Boolean, Column, ForeignKey, Integer, String,
|
11 |
+
create_engine, Enum as SQLAlchemyEnum
|
12 |
+
)
|
13 |
+
from sqlalchemy.orm import relationship, sessionmaker, declarative_base
|
14 |
+
|
15 |
+
# ==============================================================================
|
16 |
+
# Database (SQLAlchemy) Models
|
17 |
+
# ==============================================================================
|
18 |
|
19 |
Base = declarative_base()
|
20 |
|
21 |
+
class User(Base):
|
22 |
+
"""Database model for Users (Admins, Staff)."""
|
23 |
+
__tablename__ = "users"
|
24 |
+
id = Column(Integer, primary_key=True, index=True)
|
25 |
+
username = Column(String, unique=True, index=True, nullable=False)
|
26 |
+
hashed_password = Column(String, nullable=False)
|
27 |
+
name = Column(String, nullable=False)
|
28 |
+
role = Column(String, default="staff")
|
29 |
+
is_active = Column(Boolean, default=True)
|
30 |
+
tag_id = Column(String, unique=True, index=True, nullable=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
31 |
|
|
|
32 |
|
33 |
class Student(Base):
|
34 |
+
"""Database model for Students."""
|
35 |
__tablename__ = "students"
|
36 |
id = Column(Integer, primary_key=True, index=True)
|
37 |
student_id = Column(String, unique=True, index=True, nullable=False)
|
38 |
name = Column(String, nullable=False)
|
|
|
39 |
department = Column(String, nullable=False)
|
40 |
tag_id = Column(String, unique=True, index=True, nullable=True)
|
|
|
|
|
41 |
|
42 |
+
clearance_statuses = relationship("ClearanceStatus", back_populates="student")
|
43 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
44 |
|
45 |
class ClearanceStatus(Base):
|
46 |
+
"""Database model for individual clearance items for a student."""
|
47 |
__tablename__ = "clearance_statuses"
|
48 |
+
id = Column(Integer, primary_key=True, index=True)
|
49 |
+
student_id = Column(String, ForeignKey("students.student_id"), nullable=False)
|
50 |
+
department = Column(String, nullable=False)
|
51 |
+
status = Column(String, default="PENDING")
|
52 |
+
|
53 |
+
student = relationship("Student", back_populates="clearance_statuses")
|
54 |
+
|
55 |
+
|
56 |
+
class UserTypeEnum(str, Enum):
|
57 |
+
"""Enum for user types."""
|
58 |
+
STUDENT = "student"
|
59 |
+
USER = "user"
|
60 |
+
|
61 |
|
62 |
class Device(Base):
|
63 |
+
"""Database model for RFID reader devices."""
|
64 |
__tablename__ = "devices"
|
65 |
+
id = Column(Integer, primary_key=True, index=True)
|
66 |
+
device_id_str = Column(String, unique=True, index=True, nullable=False)
|
|
|
67 |
location = Column(String, nullable=True)
|
68 |
+
# Fields to temporarily link a device to a user for tag registration
|
69 |
+
link_for_user_id = Column(String, nullable=True)
|
70 |
+
link_for_user_type = Column(SQLAlchemyEnum(UserTypeEnum), nullable=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
71 |
|
72 |
+
# ==============================================================================
|
73 |
+
# API (Pydantic) Models
|
74 |
+
# ==============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
75 |
|
76 |
+
# --- User and Auth Models ---
|
77 |
class UserBase(BaseModel):
|
78 |
username: str
|
79 |
+
name: str
|
|
|
|
|
|
|
80 |
|
81 |
class UserCreate(UserBase):
|
82 |
password: str
|
83 |
+
role: str = "staff"
|
84 |
|
85 |
+
class UserResponse(UserBase):
|
86 |
id: int
|
|
|
|
|
|
|
|
|
87 |
is_active: bool
|
88 |
+
role: str
|
89 |
+
tag_id: Optional[str] = None
|
90 |
+
|
91 |
+
class Config:
|
92 |
+
from_attributes = True
|
93 |
|
94 |
+
class Token(BaseModel):
|
95 |
+
access_token: str
|
96 |
+
token_type: str
|
|
|
|
|
97 |
|
98 |
+
class TokenData(BaseModel):
|
99 |
+
username: Optional[str] = None
|
100 |
+
|
101 |
+
|
102 |
+
# --- Student and Clearance Models ---
|
103 |
+
class StudentBase(BaseModel):
|
104 |
+
student_id: str = Field(..., example="CST/18/123")
|
105 |
+
name: str = Field(..., example="John Doe")
|
106 |
+
department: str = Field(..., example="Computer Science")
|
107 |
|
108 |
+
class StudentCreate(StudentBase):
|
109 |
+
pass
|
110 |
+
|
111 |
+
class StudentResponse(StudentBase):
|
112 |
id: int
|
113 |
+
tag_id: Optional[str] = None
|
114 |
+
|
115 |
+
class Config:
|
116 |
+
from_attributes = True
|
117 |
+
|
118 |
+
class ClearanceStatusEnum(str, Enum):
|
119 |
+
PENDING = "PENDING"
|
120 |
+
COMPLETED = "COMPLETED"
|
121 |
+
REJECTED = "REJECTED"
|
122 |
+
|
123 |
+
class OverallClearanceStatusEnum(str, Enum):
|
124 |
+
PENDING = "PENDING"
|
125 |
+
COMPLETED = "COMPLETED"
|
126 |
+
|
127 |
+
class ClearanceStatusItem(BaseModel):
|
128 |
+
department: str
|
129 |
status: ClearanceStatusEnum
|
130 |
+
|
131 |
+
class Config:
|
132 |
+
from_attributes = True
|
|
|
|
|
133 |
|
134 |
class ClearanceDetail(BaseModel):
|
135 |
student_id: str
|
136 |
name: str
|
137 |
department: str
|
|
|
138 |
overall_status: OverallClearanceStatusEnum
|
139 |
+
clearance_items: List[ClearanceStatusItem]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
140 |
|
141 |
+
class ClearanceStatusUpdate(BaseModel):
|
142 |
+
department: str
|
143 |
+
status: ClearanceStatusEnum
|
144 |
|
|
|
|
|
|
|
145 |
|
146 |
+
# --- Tag and Device Models ---
|
147 |
class TagLinkRequest(BaseModel):
|
148 |
+
tag_id: str = Field(..., example="A1B2C3D4")
|
149 |
+
|
150 |
+
class PrepareDeviceRequest(BaseModel):
|
151 |
+
device_id_str: str = Field(..., example="RFID-READER-01")
|
152 |
+
user_id_str: str # Can be student_id or username
|
153 |
+
user_type: UserTypeEnum
|
154 |
+
|
155 |
+
# --- New RFID Models ---
|
156 |
+
class RfidScanRequest(BaseModel):
|
157 |
+
"""Request body for the unified RFID scan endpoint."""
|
158 |
+
tag_id: str = Field(..., description="The ID scanned from the RFID tag.", example="A1B2C3D4")
|
159 |
+
device_id: str = Field(..., description="The unique identifier of the RFID reader device.", example="RFID-READER-01")
|
160 |
+
|
161 |
+
class RfidLinkSuccessResponse(BaseModel):
|
162 |
+
"""Success response when a tag is linked."""
|
163 |
+
message: str = "Tag linked successfully."
|
164 |
+
user_id: str
|
165 |
+
user_type: UserTypeEnum
|
src/routers/admin.py
CHANGED
@@ -1,109 +1,83 @@
|
|
|
|
|
|
|
|
|
|
1 |
from fastapi import APIRouter, Depends, HTTPException, status
|
2 |
-
from
|
3 |
-
from
|
|
|
4 |
|
5 |
-
from src import crud, models
|
|
|
6 |
from src.database import get_db
|
7 |
-
from src.auth import (
|
8 |
-
get_current_active_admin_user_from_token, # Returns ORM User model (Token-based)
|
9 |
-
)
|
10 |
-
from fastapi.concurrency import run_in_threadpool
|
11 |
|
12 |
router = APIRouter(
|
13 |
prefix="/api/admin",
|
14 |
-
tags=["
|
15 |
-
dependencies=[Depends(
|
16 |
)
|
17 |
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
db:
|
23 |
-
current_admin_orm: models.User = Depends(get_current_active_admin_user_from_token) # For logging who did it
|
24 |
):
|
25 |
"""
|
26 |
-
Admin
|
|
|
27 |
"""
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
34 |
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
return devices_orm_list
|
46 |
|
47 |
-
@router.
|
48 |
-
def
|
49 |
-
|
50 |
-
db:
|
|
|
51 |
):
|
52 |
"""
|
53 |
-
Admin
|
54 |
"""
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
db: SQLAlchemySessionType = Depends(get_db),
|
66 |
-
current_admin_orm: models.User = Depends(get_current_active_admin_user_from_token)
|
67 |
):
|
68 |
"""
|
69 |
-
Admin
|
70 |
"""
|
71 |
-
device_identifier_val = request_payload.device_identifier
|
72 |
-
device_orm_instance: Optional[models.Device] = None
|
73 |
-
|
74 |
-
try:
|
75 |
-
device_pk = int(device_identifier_val)
|
76 |
-
device_orm_instance = crud.get_device_by_pk(db, device_pk)
|
77 |
-
except ValueError: # If not an int, assume it's the hardware string ID
|
78 |
-
device_orm_instance = crud.get_device_by_hardware_id(db, device_identifier_val)
|
79 |
-
|
80 |
-
if not device_orm_instance:
|
81 |
-
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Device with identifier '{device_identifier_val}' not found.")
|
82 |
-
if not device_orm_instance.is_active: # Check if device is active
|
83 |
-
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Device '{device_orm_instance.name or device_orm_instance.device_id}' is not active.")
|
84 |
-
|
85 |
-
device_pk_for_link = device_orm_instance.id
|
86 |
-
admin_user_pk = current_admin_orm.id # User's primary key
|
87 |
-
|
88 |
try:
|
89 |
-
|
90 |
-
|
91 |
-
|
92 |
-
target_user_type=request_payload.target_user_type,
|
93 |
-
target_identifier=request_payload.target_identifier,
|
94 |
-
initiated_by_user_pk=admin_user_pk, # Pass admin's PK
|
95 |
-
expires_in_minutes=5
|
96 |
-
)
|
97 |
-
|
98 |
-
device_display_name = device_orm_instance.name or device_orm_instance.device_id or f"PK:{device_pk_for_link}"
|
99 |
-
return {
|
100 |
-
"message": f"Device '{device_display_name}' is now ready to scan a tag for {request_payload.target_user_type.value} '{request_payload.target_identifier}'. Tag scan must occur within 5 minutes.",
|
101 |
-
"pending_link_id": pending_link_orm.id,
|
102 |
-
"expires_at": pending_link_orm.expires_at.isoformat()
|
103 |
-
}
|
104 |
-
except HTTPException as e: # Catch known exceptions from CRUD (e.g., 409 if device busy)
|
105 |
raise e
|
106 |
-
except Exception as e:
|
107 |
-
print(f"Unexpected error in prepare_device_for_tag_linking_admin: {e}")
|
108 |
-
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"An unexpected error occurred: {str(e)}")
|
109 |
-
|
|
|
1 |
+
"""
|
2 |
+
Admin-only endpoints for managing system-wide operations like tag linking
|
3 |
+
and deleting core resources like users and devices.
|
4 |
+
"""
|
5 |
from fastapi import APIRouter, Depends, HTTPException, status
|
6 |
+
from fastapi.concurrency import run_in_threadpool
|
7 |
+
from sqlalchemy.orm import Session
|
8 |
+
from datetime import datetime, timedelta
|
9 |
|
10 |
+
from src import crud, models
|
11 |
+
from src.auth import get_current_active_admin_user, get_current_active_user
|
12 |
from src.database import get_db
|
|
|
|
|
|
|
|
|
13 |
|
14 |
router = APIRouter(
|
15 |
prefix="/api/admin",
|
16 |
+
tags=["Admin"],
|
17 |
+
dependencies=[Depends(get_current_active_admin_user)]
|
18 |
)
|
19 |
|
20 |
+
@router.post("/prepare-tag-link", status_code=status.HTTP_202_ACCEPTED, response_model=dict)
|
21 |
+
async def prepare_device_for_tag_linking(
|
22 |
+
request: models.PrepareTagLinkRequest,
|
23 |
+
current_user: models.User = Depends(get_current_active_user),
|
24 |
+
db: Session = Depends(get_db)
|
|
|
25 |
):
|
26 |
"""
|
27 |
+
Admin: Initiates a request to link a tag. This creates a temporary,
|
28 |
+
expiring 'PendingTagLink' record for a device.
|
29 |
"""
|
30 |
+
device = await run_in_threadpool(crud.get_device_by_id_str, db, request.device_identifier)
|
31 |
+
if not device or not device.is_active:
|
32 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Device not found or is inactive.")
|
33 |
+
|
34 |
+
if request.target_user_type == models.TargetUserType.STUDENT:
|
35 |
+
target = await run_in_threadpool(crud.get_student_by_student_id, db, request.target_identifier)
|
36 |
+
else:
|
37 |
+
target = await run_in_threadpool(crud.get_user_by_username, db, request.target_identifier)
|
38 |
+
|
39 |
+
if not target:
|
40 |
+
raise HTTPException(
|
41 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
42 |
+
detail=f"{request.target_user_type.value} '{request.target_identifier}' not found."
|
43 |
+
)
|
44 |
|
45 |
+
expiry_time = datetime.utcnow() + timedelta(minutes=2)
|
46 |
+
await run_in_threadpool(
|
47 |
+
crud.create_pending_tag_link, db, request, current_user.id, expiry_time
|
48 |
+
)
|
49 |
+
return {
|
50 |
+
"message": "Device is ready to link tag.",
|
51 |
+
"device_id": device.device_id,
|
52 |
+
"target": request.target_identifier,
|
53 |
+
"expires_at": expiry_time.isoformat()
|
54 |
+
}
|
|
|
55 |
|
56 |
+
@router.delete("/users/{username}", status_code=status.HTTP_200_OK, response_model=dict)
|
57 |
+
async def delete_user_endpoint(
|
58 |
+
username: str,
|
59 |
+
db: Session = Depends(get_db),
|
60 |
+
current_admin: models.User = Depends(get_current_active_admin_user)
|
61 |
):
|
62 |
"""
|
63 |
+
Admin: Permanently deletes a user (staff or other admin).
|
64 |
"""
|
65 |
+
try:
|
66 |
+
deleted_user = await run_in_threadpool(crud.delete_user, db, username, current_admin)
|
67 |
+
return {"message": "User deleted successfully", "username": deleted_user.username}
|
68 |
+
except HTTPException as e:
|
69 |
+
raise e
|
70 |
|
71 |
+
@router.delete("/devices/{device_id_str}", status_code=status.HTTP_200_OK, response_model=dict)
|
72 |
+
async def delete_device_endpoint(
|
73 |
+
device_id_str: str,
|
74 |
+
db: Session = Depends(get_db)
|
|
|
|
|
75 |
):
|
76 |
"""
|
77 |
+
Admin: Permanently deletes a registered RFID device and all its logs.
|
78 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
79 |
try:
|
80 |
+
deleted_device = await run_in_threadpool(crud.delete_device, db, device_id_str)
|
81 |
+
return {"message": "Device deleted successfully", "device_id": deleted_device.device_id}
|
82 |
+
except HTTPException as e:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
83 |
raise e
|
|
|
|
|
|
|
|
src/routers/clearance.py
CHANGED
@@ -1,100 +1,68 @@
|
|
1 |
-
|
|
|
|
|
|
|
|
|
2 |
from fastapi.concurrency import run_in_threadpool
|
3 |
-
from sqlalchemy.orm import Session as SQLAlchemySessionType
|
4 |
-
from typing import List, Optional # For type hinting
|
5 |
|
6 |
from src import crud, models
|
7 |
-
from src.auth import (
|
8 |
-
get_current_staff_or_admin_via_tag, # Returns ORM User model (Tag-based)
|
9 |
-
get_current_student_via_tag, # Returns ORM Student model (Tag-based)
|
10 |
-
verify_department_access # Sync utility function
|
11 |
-
)
|
12 |
from src.database import get_db
|
|
|
|
|
13 |
|
14 |
router = APIRouter(
|
15 |
prefix="/api/clearance",
|
16 |
-
tags=["
|
|
|
17 |
)
|
18 |
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
|
|
|
|
|
|
|
|
|
25 |
):
|
26 |
"""
|
27 |
-
Staff/Admin
|
28 |
"""
|
29 |
-
|
30 |
-
|
31 |
-
if not student_orm:
|
32 |
-
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Student not found")
|
33 |
-
|
34 |
-
# verify_department_access is sync
|
35 |
-
# status_data.department is ClearanceDepartment enum from Pydantic
|
36 |
-
if not verify_department_access(current_user_orm.role, current_user_orm.department, status_data.department):
|
37 |
-
raise HTTPException(
|
38 |
-
status_code=status.HTTP_403_FORBIDDEN,
|
39 |
-
detail=f"User '{current_user_orm.username}' does not have permission to update clearance for the {status_data.department.value} department."
|
40 |
-
)
|
41 |
-
|
42 |
-
cleared_by_user_pk = current_user_orm.id # User's primary key
|
43 |
-
|
44 |
-
try:
|
45 |
-
# crud.create_or_update_clearance_status is sync, returns ORM model
|
46 |
-
updated_status_orm = await run_in_threadpool(
|
47 |
-
crud.create_or_update_clearance_status, db, status_data, cleared_by_user_pk
|
48 |
-
)
|
49 |
-
except HTTPException as e: # Catch known errors from CRUD
|
50 |
-
raise e
|
51 |
-
return updated_status_orm # Pydantic converts from ORM model
|
52 |
-
|
53 |
-
|
54 |
-
# Helper for student's own clearance view (similar to one in students.py/devices.py)
|
55 |
-
async def _format_my_clearance_response(
|
56 |
-
db: SQLAlchemySessionType,
|
57 |
-
student_orm: models.Student # Expect Student ORM model
|
58 |
-
) -> models.ClearanceDetail:
|
59 |
-
|
60 |
-
statuses_orm_list = await run_in_threadpool(crud.get_clearance_statuses_by_student_id, db, student_orm.student_id)
|
61 |
-
|
62 |
-
clearance_items_models: List[models.ClearanceStatusItem] = []
|
63 |
-
overall_status_val = models.OverallClearanceStatusEnum.COMPLETED
|
64 |
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
for status_orm in statuses_orm_list:
|
69 |
-
item = models.ClearanceStatusItem(
|
70 |
-
department=status_orm.department,
|
71 |
-
status=status_orm.status,
|
72 |
-
remarks=status_orm.remarks,
|
73 |
-
updated_at=status_orm.updated_at
|
74 |
-
)
|
75 |
-
clearance_items_models.append(item)
|
76 |
-
if item.status != models.ClearanceStatusEnum.COMPLETED:
|
77 |
-
overall_status_val = models.OverallClearanceStatusEnum.PENDING
|
78 |
-
|
79 |
-
if not statuses_orm_list and overall_status_val == models.OverallClearanceStatusEnum.COMPLETED:
|
80 |
-
overall_status_val = models.OverallClearanceStatusEnum.PENDING
|
81 |
-
|
82 |
-
return models.ClearanceDetail(
|
83 |
-
student_id=student_orm.student_id,
|
84 |
-
name=student_orm.name,
|
85 |
-
department=student_orm.department,
|
86 |
-
clearance_items=clearance_items_models,
|
87 |
-
overall_status=overall_status_val
|
88 |
)
|
|
|
|
|
|
|
89 |
|
90 |
-
@router.
|
91 |
-
async def
|
92 |
-
|
93 |
-
|
94 |
-
db:
|
|
|
95 |
):
|
96 |
"""
|
97 |
-
|
|
|
98 |
"""
|
99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
100 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
Router for staff and admin clearance operations.
|
3 |
+
"""
|
4 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
5 |
+
from sqlalchemy.orm import Session
|
6 |
from fastapi.concurrency import run_in_threadpool
|
|
|
|
|
7 |
|
8 |
from src import crud, models
|
|
|
|
|
|
|
|
|
|
|
9 |
from src.database import get_db
|
10 |
+
from src.auth import get_current_active_staff_user
|
11 |
+
from src.utils import format_student_clearance_details
|
12 |
|
13 |
router = APIRouter(
|
14 |
prefix="/api/clearance",
|
15 |
+
tags=["Clearance"],
|
16 |
+
dependencies=[Depends(get_current_active_staff_user)]
|
17 |
)
|
18 |
|
19 |
+
class ClearanceUpdatePayload(models.BaseModel):
|
20 |
+
status: models.ClearanceStatusEnum
|
21 |
+
remarks: str | None = None
|
22 |
+
|
23 |
+
@router.put("/{student_id_str}", response_model=models.ClearanceDetail)
|
24 |
+
async def update_student_clearance(
|
25 |
+
student_id_str: str,
|
26 |
+
payload: ClearanceUpdatePayload,
|
27 |
+
db: Session = Depends(get_db),
|
28 |
+
current_user: models.User = Depends(get_current_active_staff_user)
|
29 |
):
|
30 |
"""
|
31 |
+
Staff/Admin: Update a student's clearance status for their department.
|
32 |
"""
|
33 |
+
if not current_user.department:
|
34 |
+
raise HTTPException(status_code=403, detail="Your user account is not assigned to a clearable department.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
35 |
|
36 |
+
await run_in_threadpool(
|
37 |
+
crud.update_clearance_status, db, student_id_str, current_user.department, payload.status, payload.remarks, current_user.id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
38 |
)
|
39 |
+
|
40 |
+
student_orm = await run_in_threadpool(crud.get_student_by_student_id, db, student_id_str)
|
41 |
+
return await format_student_clearance_details(db, student_orm)
|
42 |
|
43 |
+
@router.delete("/{student_id_str}/{department_str}", response_model=models.ClearanceDetail)
|
44 |
+
async def reset_student_clearance(
|
45 |
+
student_id_str: str,
|
46 |
+
department_str: str,
|
47 |
+
db: Session = Depends(get_db),
|
48 |
+
current_user: models.User = Depends(get_current_active_staff_user)
|
49 |
):
|
50 |
"""
|
51 |
+
Staff/Admin: Reset a student's clearance status for a department.
|
52 |
+
Admins can reset for any department; staff only for their own.
|
53 |
"""
|
54 |
+
try:
|
55 |
+
target_department = models.ClearanceDepartment(department_str.upper())
|
56 |
+
except ValueError:
|
57 |
+
raise HTTPException(status_code=400, detail=f"'{department_str}' is not a valid department.")
|
58 |
+
|
59 |
+
if current_user.role != models.UserRole.ADMIN and current_user.department != target_department:
|
60 |
+
raise HTTPException(status_code=403, detail=f"You can only reset clearance for your own department.")
|
61 |
|
62 |
+
await run_in_threadpool(crud.delete_clearance_status, db, student_id_str, target_department)
|
63 |
+
|
64 |
+
student_orm = await run_in_threadpool(crud.get_student_by_student_id, db, student_id_str)
|
65 |
+
if not student_orm:
|
66 |
+
raise HTTPException(status_code=404, detail="Student not found.")
|
67 |
+
|
68 |
+
return await format_student_clearance_details(db, student_orm)
|
src/routers/devices.py
CHANGED
@@ -1,211 +1,73 @@
|
|
1 |
-
|
|
|
|
|
|
|
|
|
2 |
from fastapi.concurrency import run_in_threadpool
|
3 |
-
from sqlalchemy.orm import Session
|
4 |
-
from typing import
|
5 |
|
6 |
from src import crud, models
|
7 |
from src.database import get_db
|
8 |
-
from src.
|
9 |
|
10 |
-
|
11 |
-
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
@router.post("/devices/register", response_model=models.DeviceResponse)
|
16 |
-
async def register_device_endpoint( # Async endpoint
|
17 |
-
device_data: models.DeviceRegister, # Pydantic model from ESP32
|
18 |
-
db: SQLAlchemySessionType = Depends(get_db)
|
19 |
-
):
|
20 |
-
"""
|
21 |
-
ESP32 devices self-register or re-register. Uses ORM.
|
22 |
-
"""
|
23 |
-
# crud.register_device_esp is sync, call with run_in_threadpool
|
24 |
-
try:
|
25 |
-
registered_device_orm = await run_in_threadpool(crud.register_device_esp, db, device_data)
|
26 |
-
except HTTPException as e: # Catch HTTPExceptions raised by CRUD (e.g., device already exists)
|
27 |
-
raise e
|
28 |
-
return registered_device_orm # Pydantic DeviceResponse converts from ORM model
|
29 |
-
|
30 |
-
|
31 |
-
# Helper for formatting scan response (moved to be more specific to this router's needs)
|
32 |
-
async def _format_clearance_for_device_scan(
|
33 |
-
db: SQLAlchemySessionType, # Pass db session for sync crud calls
|
34 |
-
student_orm: models.Student # Expect Student ORM model
|
35 |
-
) -> models.ClearanceDetail:
|
36 |
-
"""Helper to format clearance details for the /scan endpoint response using ORM."""
|
37 |
-
|
38 |
-
# crud.get_clearance_statuses_by_student_id is sync, needs run_in_threadpool
|
39 |
-
statuses_orm_list = await run_in_threadpool(crud.get_clearance_statuses_by_student_id, db, student_orm.student_id)
|
40 |
-
|
41 |
-
clearance_items_models: List[models.ClearanceStatusItem] = []
|
42 |
-
overall_status_val = models.OverallClearanceStatusEnum.COMPLETED
|
43 |
-
|
44 |
-
if not statuses_orm_list:
|
45 |
-
overall_status_val = models.OverallClearanceStatusEnum.PENDING
|
46 |
-
|
47 |
-
for status_orm in statuses_orm_list:
|
48 |
-
item = models.ClearanceStatusItem(
|
49 |
-
department=status_orm.department, # Already enum from ORM
|
50 |
-
status=status_orm.status, # Already enum from ORM
|
51 |
-
remarks=status_orm.remarks,
|
52 |
-
updated_at=status_orm.updated_at
|
53 |
-
)
|
54 |
-
clearance_items_models.append(item)
|
55 |
-
if item.status != models.ClearanceStatusEnum.COMPLETED:
|
56 |
-
overall_status_val = models.OverallClearanceStatusEnum.PENDING
|
57 |
-
|
58 |
-
if not statuses_orm_list and overall_status_val == models.OverallClearanceStatusEnum.COMPLETED:
|
59 |
-
overall_status_val = models.OverallClearanceStatusEnum.PENDING
|
60 |
-
|
61 |
-
return models.ClearanceDetail(
|
62 |
-
student_id=student_orm.student_id,
|
63 |
-
name=student_orm.name,
|
64 |
-
department=student_orm.department,
|
65 |
-
clearance_items=clearance_items_models,
|
66 |
-
overall_status=overall_status_val
|
67 |
-
)
|
68 |
-
|
69 |
-
@router.post("/scan", response_model=models.ClearanceDetail)
|
70 |
-
async def scan_tag_endpoint( # Async endpoint
|
71 |
-
scan_data: models.TagScan, # Contains device_id (hardware_id) and tag_id
|
72 |
-
verified_device_orm: models.Device = Depends(get_verified_device), # Returns ORM Device model
|
73 |
-
db: SQLAlchemySessionType = Depends(get_db)
|
74 |
-
):
|
75 |
-
"""
|
76 |
-
Device scans a tag. Verifies device, logs scan, gets student clearance. ORM-based.
|
77 |
-
"""
|
78 |
-
if verified_device_orm.device_id != scan_data.device_id: # Compare hardware IDs
|
79 |
-
# This might indicate a misconfiguration or an attempt to spoof.
|
80 |
-
# Log this with verified_device_orm.id and scan_data.device_id
|
81 |
-
await run_in_threadpool(
|
82 |
-
crud.create_device_log, db, verified_device_orm.id, "scan_error_device_id_mismatch",
|
83 |
-
scanned_tag_id=scan_data.tag_id, actual_device_id_str=scan_data.device_id
|
84 |
-
)
|
85 |
raise HTTPException(
|
86 |
-
status_code=status.
|
87 |
-
detail="
|
88 |
)
|
|
|
89 |
|
90 |
-
|
91 |
-
|
92 |
-
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
-
# Check if tag belongs to a student
|
97 |
-
student_orm = await run_in_threadpool(crud.get_student_by_tag_id, db, scan_data.tag_id)
|
98 |
-
|
99 |
-
if student_orm:
|
100 |
-
await run_in_threadpool(
|
101 |
-
crud.create_device_log, db, device_pk, "scan_student_clearance",
|
102 |
-
scanned_tag_id=scan_data.tag_id, user_type=models.UserRole.STUDENT.value, actual_device_id_str=device_hw_id
|
103 |
-
)
|
104 |
-
return await _format_clearance_for_device_scan(db, student_orm)
|
105 |
-
else:
|
106 |
-
# Check if tag belongs to staff/admin (not for clearance check, but for logging)
|
107 |
-
user_orm = await run_in_threadpool(crud.get_user_by_tag_id, db, scan_data.tag_id)
|
108 |
-
user_type_log = models.UserRole.ADMIN.value if user_orm and user_orm.role == models.UserRole.ADMIN else \
|
109 |
-
models.UserRole.STAFF.value if user_orm and user_orm.role == models.UserRole.STAFF else \
|
110 |
-
"unknown_user_tag"
|
111 |
-
|
112 |
-
await run_in_threadpool(
|
113 |
-
crud.create_device_log, db, device_pk, f"scan_failed_not_student_tag ({user_type_log})",
|
114 |
-
scanned_tag_id=scan_data.tag_id, user_type=user_type_log, actual_device_id_str=device_hw_id
|
115 |
-
)
|
116 |
-
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tag does not belong to a registered student for clearance check.")
|
117 |
-
|
118 |
|
119 |
-
@router.post(
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
|
|
|
|
|
|
|
|
124 |
):
|
125 |
"""
|
126 |
-
|
|
|
127 |
"""
|
128 |
-
|
129 |
-
|
130 |
-
|
131 |
-
|
132 |
-
|
133 |
-
|
134 |
-
|
135 |
-
|
136 |
-
|
137 |
-
|
138 |
-
|
139 |
-
|
140 |
-
|
141 |
-
# Check tag uniqueness (crud.check_tag_id_globally_unique_for_target is sync and raises HTTPException)
|
142 |
-
try:
|
143 |
-
await run_in_threadpool(
|
144 |
-
crud.check_tag_id_globally_unique_for_target,
|
145 |
-
db,
|
146 |
-
payload.scanned_tag_id,
|
147 |
-
pending_link_orm.target_user_type, # Pass the TargetUserType enum
|
148 |
-
# PK of the target entity (student.id or user.id) is NOT known here yet.
|
149 |
-
# The check_tag_id_globally_unique_for_target needs to be careful if target_pk is None
|
150 |
-
# or fetch the target's PK if the identifier is unique (student_id/username).
|
151 |
-
# For now, pass None for target_pk, meaning it checks against ALL existing.
|
152 |
-
# This is safer. If target was already assigned this tag, it should have been caught
|
153 |
-
# during prepare_tag_link.
|
154 |
-
None
|
155 |
-
)
|
156 |
-
except HTTPException as e_tag_conflict: # Specifically catch tag conflict from the check
|
157 |
-
await run_in_threadpool(crud.delete_pending_tag_link, db, pending_link_orm.id) # Cancel link
|
158 |
-
await run_in_threadpool(
|
159 |
-
crud.create_device_log, db, device_pk, "submit_tag_failed_tag_conflict",
|
160 |
-
scanned_tag_id=payload.scanned_tag_id, actual_device_id_str=device_hw_id
|
161 |
-
)
|
162 |
-
raise e_tag_conflict # Re-raise the 409
|
163 |
-
|
164 |
-
linked_identifier_val: Optional[str] = None
|
165 |
-
try:
|
166 |
-
if pending_link_orm.target_user_type == models.TargetUserType.STUDENT:
|
167 |
-
# crud.update_student_tag_id is sync
|
168 |
-
updated_student_orm = await run_in_threadpool(
|
169 |
-
crud.update_student_tag_id, db, pending_link_orm.target_identifier, payload.scanned_tag_id
|
170 |
-
)
|
171 |
-
linked_identifier_val = updated_student_orm.student_id
|
172 |
-
elif pending_link_orm.target_user_type == models.TargetUserType.STAFF_ADMIN:
|
173 |
-
# crud.update_user_tag_id is sync
|
174 |
-
updated_user_orm = await run_in_threadpool(
|
175 |
-
crud.update_user_tag_id, db, pending_link_orm.target_identifier, payload.scanned_tag_id
|
176 |
-
)
|
177 |
-
linked_identifier_val = updated_user_orm.username
|
178 |
|
179 |
-
|
180 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
181 |
|
182 |
-
|
183 |
-
|
184 |
-
await run_in_threadpool(
|
185 |
-
crud.create_device_log, db, device_pk,
|
186 |
-
f"tag_linked_to_{pending_link_orm.target_user_type.value}:{linked_identifier_val}",
|
187 |
-
scanned_tag_id=payload.scanned_tag_id, actual_device_id_str=device_hw_id
|
188 |
-
)
|
189 |
-
|
190 |
-
return {
|
191 |
-
"message": f"Tag ID '{payload.scanned_tag_id}' successfully linked to {pending_link_orm.target_user_type.value} '{linked_identifier_val}'.",
|
192 |
-
"device_id": device_hw_id,
|
193 |
-
"tag_id": payload.scanned_tag_id,
|
194 |
-
}
|
195 |
-
except HTTPException as e_update: # Catch errors from update_..._tag_id
|
196 |
-
# If update fails (e.g. target not found, though checked in prepare), log and raise
|
197 |
-
await run_in_threadpool(
|
198 |
-
crud.create_device_log, db, device_pk, f"submit_tag_failed_update_error: {e_update.detail}",
|
199 |
-
scanned_tag_id=payload.scanned_tag_id, actual_device_id_str=device_hw_id
|
200 |
-
)
|
201 |
-
# Consider if pending link should be deleted on update failure.
|
202 |
-
# It might be better to leave it for investigation if it wasn't a tag conflict.
|
203 |
-
raise e_update
|
204 |
-
except Exception as e_generic:
|
205 |
-
print(f"Unexpected error during submit_scanned_tag: {e_generic}")
|
206 |
-
await run_in_threadpool(
|
207 |
-
crud.create_device_log, db, device_pk, "submit_tag_failed_unexpected_error",
|
208 |
-
scanned_tag_id=payload.scanned_tag_id, actual_device_id_str=device_hw_id
|
209 |
-
)
|
210 |
-
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred linking the tag.")
|
211 |
-
|
|
|
1 |
+
"""
|
2 |
+
Router for device interactions, specifically for submitting a scanned tag.
|
3 |
+
This endpoint is intended to be called by the physical RFID reader device.
|
4 |
+
"""
|
5 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Header
|
6 |
from fastapi.concurrency import run_in_threadpool
|
7 |
+
from sqlalchemy.orm import Session
|
8 |
+
from typing import Union
|
9 |
|
10 |
from src import crud, models
|
11 |
from src.database import get_db
|
12 |
+
from src.utils import format_student_clearance_details
|
13 |
|
14 |
+
async def get_authenticated_device(x_api_key: str = Header(...), db: Session = Depends(get_db)) -> models.Device:
|
15 |
+
"""Dependency to authenticate a device by its API key."""
|
16 |
+
device = await run_in_threadpool(crud.get_device_by_api_key, db, x_api_key)
|
17 |
+
if not device or not device.is_active:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
18 |
raise HTTPException(
|
19 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
20 |
+
detail="Invalid API Key or Inactive Device",
|
21 |
)
|
22 |
+
return device
|
23 |
|
24 |
+
router = APIRouter(
|
25 |
+
prefix="/api/devices",
|
26 |
+
tags=["Devices"],
|
27 |
+
dependencies=[Depends(get_authenticated_device)]
|
28 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
29 |
|
30 |
+
@router.post(
|
31 |
+
"/submit-tag",
|
32 |
+
response_model=Union[models.ClearanceDetail, models.UserResponse, dict],
|
33 |
+
summary="Endpoint for RFID devices to submit a scanned tag ID."
|
34 |
+
)
|
35 |
+
async def device_submit_scanned_tag(
|
36 |
+
scanned_tag: models.ScannedTagSubmit,
|
37 |
+
device: models.Device = Depends(get_authenticated_device),
|
38 |
+
db: Session = Depends(get_db)
|
39 |
):
|
40 |
"""
|
41 |
+
Handles a tag submission from an authenticated RFID device.
|
42 |
+
It can either link a tag if a pending link exists or fetch user details.
|
43 |
"""
|
44 |
+
tag_id = scanned_tag.scanned_tag_id
|
45 |
+
pending_link = await run_in_threadpool(crud.get_pending_link_by_device, db, device.id)
|
46 |
+
|
47 |
+
if pending_link:
|
48 |
+
# Registration Mode
|
49 |
+
target_type, target_id = pending_link.target_user_type, pending_link.target_identifier
|
50 |
+
try:
|
51 |
+
if target_type == models.TargetUserType.STUDENT:
|
52 |
+
await run_in_threadpool(crud.update_student_tag_id, db, target_id, tag_id)
|
53 |
+
else:
|
54 |
+
await run_in_threadpool(crud.update_user_tag_id, db, target_id, tag_id)
|
55 |
+
finally:
|
56 |
+
await run_in_threadpool(crud.delete_pending_link, db, pending_link.id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
57 |
|
58 |
+
await run_in_threadpool(crud.create_device_log, db, {"device_fk_id": device.id, "tag_id_scanned": tag_id, "action": f"TAG_LINK_SUCCESS: {target_type.value} {target_id}"})
|
59 |
+
return {"message": "Tag linked successfully", "user_id": target_id, "user_type": target_type}
|
60 |
+
else:
|
61 |
+
# Fetching Mode
|
62 |
+
student = await run_in_threadpool(crud.get_student_by_tag_id, db, tag_id)
|
63 |
+
if student:
|
64 |
+
await run_in_threadpool(crud.create_device_log, db, {"device_fk_id": device.id, "tag_id_scanned": tag_id, "action": f"FETCH_SUCCESS: Student {student.student_id}"})
|
65 |
+
return await format_student_clearance_details(db, student)
|
66 |
+
|
67 |
+
user = await run_in_threadpool(crud.get_user_by_tag_id, db, tag_id)
|
68 |
+
if user:
|
69 |
+
await run_in_threadpool(crud.create_device_log, db, {"device_fk_id": device.id, "tag_id_scanned": tag_id, "action": f"FETCH_SUCCESS: User {user.username}"})
|
70 |
+
return models.UserResponse.from_orm(user)
|
71 |
|
72 |
+
await run_in_threadpool(crud.create_device_log, db, {"device_fk_id": device.id, "tag_id_scanned": tag_id, "action": "FETCH_FAIL: Tag not found"})
|
73 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Tag ID '{tag_id}' not found.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/routers/rfid.py
ADDED
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
Router for handling all RFID interactions.
|
3 |
+
"""
|
4 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
5 |
+
from fastapi.concurrency import run_in_threadpool
|
6 |
+
from sqlalchemy.orm import Session
|
7 |
+
from typing import Union
|
8 |
+
|
9 |
+
from src import crud, models
|
10 |
+
from src.database import get_db
|
11 |
+
from src.utils import format_student_clearance_details # Using a utility for DRY code
|
12 |
+
|
13 |
+
router = APIRouter(
|
14 |
+
prefix="/api/rfid",
|
15 |
+
tags=["RFID"],
|
16 |
+
)
|
17 |
+
|
18 |
+
@router.post(
|
19 |
+
"/scan",
|
20 |
+
response_model=Union[models.ClearanceDetail, models.RfidLinkSuccessResponse, models.UserResponse],
|
21 |
+
summary="Unified endpoint for all RFID tag scans."
|
22 |
+
)
|
23 |
+
async def handle_rfid_scan(
|
24 |
+
scan_data: models.RfidScanRequest,
|
25 |
+
db: Session = Depends(get_db),
|
26 |
+
):
|
27 |
+
"""
|
28 |
+
This endpoint intelligently handles an RFID tag scan.
|
29 |
+
|
30 |
+
- **Registration Mode**: If an admin has prepared the device to link a tag
|
31 |
+
to a user, this endpoint will perform the link and return a success message.
|
32 |
+
|
33 |
+
- **Fetching Mode**: If the device is not in registration mode, this endpoint
|
34 |
+
will look up the user/student by the tag ID and return their details
|
35 |
+
(e.g., clearance status for a student).
|
36 |
+
"""
|
37 |
+
# 1. Get the device that sent the scan
|
38 |
+
device = await run_in_threadpool(crud.get_device_by_id, db, scan_data.device_id)
|
39 |
+
if not device:
|
40 |
+
raise HTTPException(
|
41 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
42 |
+
detail=f"Device with ID '{scan_data.device_id}' not found."
|
43 |
+
)
|
44 |
+
|
45 |
+
# 2. Check if the device is in "Registration Mode"
|
46 |
+
if device.link_for_user_id and device.link_for_user_type:
|
47 |
+
user_id_to_link = device.link_for_user_id
|
48 |
+
user_type_to_link = device.link_for_user_type
|
49 |
+
|
50 |
+
# Link the tag based on the user type
|
51 |
+
if user_type_to_link == models.UserTypeEnum.STUDENT:
|
52 |
+
await run_in_threadpool(crud.update_student_tag_id, db, user_id_to_link, scan_data.tag_id)
|
53 |
+
elif user_type_to_link == models.UserTypeEnum.USER:
|
54 |
+
await run_in_threadpool(crud.update_user_tag_id, db, user_id_to_link, scan_data.tag_id)
|
55 |
+
|
56 |
+
# Clear the registration mode from the device
|
57 |
+
await run_in_threadpool(crud.clear_device_link_for_user, db, device.device_id_str)
|
58 |
+
|
59 |
+
return models.RfidLinkSuccessResponse(
|
60 |
+
user_id=user_id_to_link,
|
61 |
+
user_type=user_type_to_link
|
62 |
+
)
|
63 |
+
|
64 |
+
# 3. If not in registration mode, it's "Fetching Mode"
|
65 |
+
else:
|
66 |
+
# First, check if the tag belongs to a student
|
67 |
+
student = await run_in_threadpool(crud.get_student_by_tag_id, db, scan_data.tag_id)
|
68 |
+
if student:
|
69 |
+
# If a student is found, format and return their full clearance details
|
70 |
+
return await format_student_clearance_details(db, student)
|
71 |
+
|
72 |
+
# If not a student, check if it belongs to other users (staff/admin)
|
73 |
+
user = await run_in_threadpool(crud.get_user_by_tag_id, db, scan_data.tag_id)
|
74 |
+
if user:
|
75 |
+
# For a staff/admin, just return their basic profile info
|
76 |
+
return models.UserResponse.from_orm(user)
|
77 |
+
|
78 |
+
# If the tag is not found anywhere, raise an error
|
79 |
+
raise HTTPException(
|
80 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
81 |
+
detail=f"Tag ID '{scan_data.tag_id}' is not associated with any user."
|
82 |
+
)
|
83 |
+
|
src/routers/students.py
CHANGED
@@ -1,109 +1,65 @@
|
|
|
|
|
|
|
|
1 |
from fastapi import APIRouter, HTTPException, status, Depends
|
2 |
from fastapi.concurrency import run_in_threadpool
|
3 |
from sqlalchemy.orm import Session as SQLAlchemySessionType
|
4 |
-
from typing import List
|
5 |
|
6 |
from src import crud, models
|
7 |
-
from src.auth import
|
8 |
from src.database import get_db
|
|
|
9 |
|
10 |
router = APIRouter(
|
11 |
prefix="/api/students",
|
12 |
-
tags=["
|
13 |
-
|
14 |
-
dependencies=[Depends(get_current_active_admin_user_from_token)]
|
15 |
)
|
16 |
|
17 |
-
# Helper function to format clearance details for student endpoints
|
18 |
-
# This is very similar to the one in devices.py, consider consolidating to a utils.py
|
19 |
-
async def _format_student_clearance_details_response(
|
20 |
-
db: SQLAlchemySessionType,
|
21 |
-
student_orm: models.Student
|
22 |
-
) -> models.ClearanceDetail:
|
23 |
-
|
24 |
-
statuses_orm_list = await run_in_threadpool(crud.get_clearance_statuses_by_student_id, db, student_orm.student_id)
|
25 |
-
|
26 |
-
clearance_items_models: List[models.ClearanceStatusItem] = []
|
27 |
-
overall_status_val = models.OverallClearanceStatusEnum.COMPLETED
|
28 |
-
|
29 |
-
if not statuses_orm_list:
|
30 |
-
overall_status_val = models.OverallClearanceStatusEnum.PENDING
|
31 |
-
|
32 |
-
for status_orm in statuses_orm_list:
|
33 |
-
item = models.ClearanceStatusItem(
|
34 |
-
department=status_orm.department,
|
35 |
-
status=status_orm.status,
|
36 |
-
remarks=status_orm.remarks,
|
37 |
-
updated_at=status_orm.updated_at
|
38 |
-
)
|
39 |
-
clearance_items_models.append(item)
|
40 |
-
if item.status != models.ClearanceStatusEnum.COMPLETED:
|
41 |
-
overall_status_val = models.OverallClearanceStatusEnum.PENDING
|
42 |
-
|
43 |
-
if not statuses_orm_list and overall_status_val == models.OverallClearanceStatusEnum.COMPLETED:
|
44 |
-
overall_status_val = models.OverallClearanceStatusEnum.PENDING
|
45 |
-
|
46 |
-
return models.ClearanceDetail(
|
47 |
-
student_id=student_orm.student_id,
|
48 |
-
name=student_orm.name,
|
49 |
-
department=student_orm.department,
|
50 |
-
clearance_items=clearance_items_models,
|
51 |
-
overall_status=overall_status_val
|
52 |
-
)
|
53 |
-
|
54 |
@router.post("/", response_model=models.StudentResponse, status_code=status.HTTP_201_CREATED)
|
55 |
-
async def create_student_endpoint(
|
56 |
student_data: models.StudentCreate,
|
57 |
db: SQLAlchemySessionType = Depends(get_db),
|
58 |
-
# current_admin_orm: models.User = Depends(get_current_active_admin_user_from_token) # For logging
|
59 |
):
|
60 |
-
"""Admin
|
61 |
try:
|
62 |
-
# crud.create_student is sync, handles checks and returns ORM model
|
63 |
created_student_orm = await run_in_threadpool(crud.create_student, db, student_data)
|
64 |
-
|
|
|
65 |
raise e
|
66 |
-
return created_student_orm # Pydantic converts from ORM model
|
67 |
|
68 |
@router.get("/", response_model=List[models.StudentResponse])
|
69 |
-
async def get_students_endpoint(
|
70 |
skip: int = 0,
|
71 |
limit: int = 100,
|
72 |
db: SQLAlchemySessionType = Depends(get_db),
|
73 |
-
# current_admin_orm: models.User = Depends(get_current_active_admin_user_from_token)
|
74 |
):
|
75 |
-
"""Admin
|
76 |
-
# crud.get_all_students is sync
|
77 |
students_orm_list = await run_in_threadpool(crud.get_all_students, db, skip, limit)
|
78 |
-
return students_orm_list
|
79 |
|
80 |
@router.get("/{student_id_str}", response_model=models.ClearanceDetail)
|
81 |
-
async def get_student_clearance_endpoint(
|
82 |
student_id_str: str,
|
83 |
db: SQLAlchemySessionType = Depends(get_db),
|
84 |
-
# current_admin_orm: models.User = Depends(get_current_active_admin_user_from_token)
|
85 |
):
|
86 |
-
"""Admin
|
87 |
-
# crud.get_student_by_student_id is sync
|
88 |
student_orm = await run_in_threadpool(crud.get_student_by_student_id, db, student_id_str)
|
89 |
if not student_orm:
|
90 |
raise HTTPException(status_code=404, detail="Student not found")
|
91 |
-
return await
|
92 |
|
93 |
-
@router.
|
94 |
-
async def
|
95 |
student_id_str: str,
|
96 |
-
tag_link_request: models.TagLinkRequest,
|
97 |
db: SQLAlchemySessionType = Depends(get_db),
|
98 |
-
# current_admin_orm: models.User = Depends(get_current_active_admin_user_from_token)
|
99 |
):
|
100 |
-
"""
|
|
|
|
|
101 |
try:
|
102 |
-
|
103 |
-
|
104 |
-
except HTTPException as e:
|
105 |
raise e
|
106 |
-
except Exception as e_generic:
|
107 |
-
print(f"Unexpected error in link_student_tag_endpoint: {e_generic}")
|
108 |
-
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred.")
|
109 |
-
return updated_student_orm
|
|
|
1 |
+
"""
|
2 |
+
Router for all CRUD operations related to students.
|
3 |
+
"""
|
4 |
from fastapi import APIRouter, HTTPException, status, Depends
|
5 |
from fastapi.concurrency import run_in_threadpool
|
6 |
from sqlalchemy.orm import Session as SQLAlchemySessionType
|
7 |
+
from typing import List
|
8 |
|
9 |
from src import crud, models
|
10 |
+
from src.auth import get_current_active_admin_user
|
11 |
from src.database import get_db
|
12 |
+
from src.utils import format_student_clearance_details
|
13 |
|
14 |
router = APIRouter(
|
15 |
prefix="/api/students",
|
16 |
+
tags=["Students"],
|
17 |
+
dependencies=[Depends(get_current_active_admin_user)]
|
|
|
18 |
)
|
19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
20 |
@router.post("/", response_model=models.StudentResponse, status_code=status.HTTP_201_CREATED)
|
21 |
+
async def create_student_endpoint(
|
22 |
student_data: models.StudentCreate,
|
23 |
db: SQLAlchemySessionType = Depends(get_db),
|
|
|
24 |
):
|
25 |
+
"""Admin: Create a new student."""
|
26 |
try:
|
|
|
27 |
created_student_orm = await run_in_threadpool(crud.create_student, db, student_data)
|
28 |
+
return created_student_orm
|
29 |
+
except HTTPException as e:
|
30 |
raise e
|
|
|
31 |
|
32 |
@router.get("/", response_model=List[models.StudentResponse])
|
33 |
+
async def get_students_endpoint(
|
34 |
skip: int = 0,
|
35 |
limit: int = 100,
|
36 |
db: SQLAlchemySessionType = Depends(get_db),
|
|
|
37 |
):
|
38 |
+
"""Admin: Get a list of all students."""
|
|
|
39 |
students_orm_list = await run_in_threadpool(crud.get_all_students, db, skip, limit)
|
40 |
+
return students_orm_list
|
41 |
|
42 |
@router.get("/{student_id_str}", response_model=models.ClearanceDetail)
|
43 |
+
async def get_student_clearance_endpoint(
|
44 |
student_id_str: str,
|
45 |
db: SQLAlchemySessionType = Depends(get_db),
|
|
|
46 |
):
|
47 |
+
"""Admin: Get detailed clearance status for a specific student."""
|
|
|
48 |
student_orm = await run_in_threadpool(crud.get_student_by_student_id, db, student_id_str)
|
49 |
if not student_orm:
|
50 |
raise HTTPException(status_code=404, detail="Student not found")
|
51 |
+
return await format_student_clearance_details(db, student_orm)
|
52 |
|
53 |
+
@router.delete("/{student_id_str}", status_code=status.HTTP_200_OK, response_model=dict)
|
54 |
+
async def delete_student_endpoint(
|
55 |
student_id_str: str,
|
|
|
56 |
db: SQLAlchemySessionType = Depends(get_db),
|
|
|
57 |
):
|
58 |
+
"""
|
59 |
+
Admin: Permanently deletes a student and all associated records.
|
60 |
+
"""
|
61 |
try:
|
62 |
+
deleted_student = await run_in_threadpool(crud.delete_student, db, student_id_str)
|
63 |
+
return {"message": "Student deleted successfully", "student_id": deleted_student.student_id}
|
64 |
+
except HTTPException as e:
|
65 |
raise e
|
|
|
|
|
|
|
|
src/routers/token.py
CHANGED
@@ -1,62 +1,44 @@
|
|
|
|
|
|
|
|
1 |
from fastapi import APIRouter, Depends, HTTPException, status
|
2 |
from fastapi.security import OAuth2PasswordRequestForm
|
3 |
-
from
|
4 |
-
from
|
5 |
|
6 |
-
from src import
|
7 |
-
from src.
|
8 |
-
from src.
|
|
|
9 |
|
10 |
router = APIRouter(
|
11 |
-
prefix="/api
|
12 |
-
tags=["
|
13 |
)
|
14 |
|
15 |
-
@router.post("/
|
16 |
-
async def login_for_access_token(
|
17 |
form_data: OAuth2PasswordRequestForm = Depends(),
|
18 |
-
db:
|
19 |
):
|
20 |
"""
|
21 |
-
Provides
|
22 |
-
Requires username and password. Uses ORM.
|
23 |
-
"""
|
24 |
-
# crud.get_user_by_username is now sync, call with run_in_threadpool
|
25 |
-
user = await run_in_threadpool(crud.get_user_by_username, db, form_data.username)
|
26 |
|
|
|
|
|
|
|
|
|
27 |
if not user:
|
28 |
raise HTTPException(
|
29 |
status_code=status.HTTP_401_UNAUTHORIZED,
|
30 |
detail="Incorrect username or password",
|
31 |
headers={"WWW-Authenticate": "Bearer"},
|
32 |
)
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
39 |
-
detail="Incorrect username or password",
|
40 |
-
headers={"WWW-Authenticate": "Bearer"},
|
41 |
-
)
|
42 |
-
|
43 |
-
if not user.is_active:
|
44 |
-
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Inactive user")
|
45 |
-
|
46 |
-
access_token = create_access_token( # create_access_token is sync
|
47 |
-
data={"sub": user.username, "role": user.role.value} # user.role is UserRole enum
|
48 |
)
|
|
|
49 |
return {"access_token": access_token, "token_type": "bearer"}
|
50 |
-
|
51 |
-
|
52 |
-
@router.get("/users/me", response_model=models.UserResponse, summary="Get current authenticated user details")
|
53 |
-
async def read_users_me(
|
54 |
-
current_user_orm: models.User = Depends(get_current_active_user) # Depends on token auth
|
55 |
-
):
|
56 |
-
"""
|
57 |
-
Returns the details of the currently authenticated user (via token).
|
58 |
-
The user object is already an ORM model instance.
|
59 |
-
Pydantic's UserResponse will convert it using from_attributes=True.
|
60 |
-
"""
|
61 |
-
return current_user_orm
|
62 |
-
|
|
|
1 |
+
"""
|
2 |
+
Router for handling user authentication and issuing JWT tokens.
|
3 |
+
"""
|
4 |
from fastapi import APIRouter, Depends, HTTPException, status
|
5 |
from fastapi.security import OAuth2PasswordRequestForm
|
6 |
+
from sqlalchemy.orm import Session
|
7 |
+
from datetime import timedelta
|
8 |
|
9 |
+
from src import models
|
10 |
+
from src.database import get_db
|
11 |
+
from src.auth import authenticate_user, create_access_token
|
12 |
+
from src.config import settings
|
13 |
|
14 |
router = APIRouter(
|
15 |
+
prefix="/api",
|
16 |
+
tags=["Authentication"]
|
17 |
)
|
18 |
|
19 |
+
@router.post("/token", response_model=models.Token)
|
20 |
+
async def login_for_access_token(
|
21 |
form_data: OAuth2PasswordRequestForm = Depends(),
|
22 |
+
db: Session = Depends(get_db)
|
23 |
):
|
24 |
"""
|
25 |
+
Provides a JWT token for authenticated users.
|
|
|
|
|
|
|
|
|
26 |
|
27 |
+
This is the primary login endpoint. It takes a username and password
|
28 |
+
and returns an access token if the credentials are valid.
|
29 |
+
"""
|
30 |
+
user = await authenticate_user(db, form_data.username, form_data.password)
|
31 |
if not user:
|
32 |
raise HTTPException(
|
33 |
status_code=status.HTTP_401_UNAUTHORIZED,
|
34 |
detail="Incorrect username or password",
|
35 |
headers={"WWW-Authenticate": "Bearer"},
|
36 |
)
|
37 |
+
|
38 |
+
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
39 |
+
access_token = create_access_token(
|
40 |
+
data={"sub": user.username, "role": user.role.value},
|
41 |
+
expires_delta=access_token_expires
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
42 |
)
|
43 |
+
|
44 |
return {"access_token": access_token, "token_type": "bearer"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/routers/users.py
CHANGED
@@ -1,74 +1,40 @@
|
|
|
|
|
|
|
|
1 |
from fastapi import APIRouter, Depends, HTTPException, status
|
2 |
-
from
|
3 |
-
from
|
4 |
-
from typing import List
|
5 |
|
6 |
from src import crud, models
|
7 |
-
from src.auth import get_current_active_admin_user_from_token # Returns ORM User
|
8 |
from src.database import get_db
|
9 |
-
from src.auth import get_current_active_user
|
10 |
-
|
11 |
|
12 |
router = APIRouter(
|
13 |
prefix="/api/users",
|
14 |
-
tags=["
|
15 |
)
|
16 |
|
17 |
-
@router.post("/
|
18 |
-
async def
|
19 |
-
user_data: models.UserCreate,
|
20 |
-
db: SQLAlchemySessionType = Depends(get_db),
|
21 |
-
):
|
22 |
"""
|
23 |
-
Admin
|
24 |
"""
|
25 |
-
# Role check (user_data.role is already UserRole enum from Pydantic)
|
26 |
-
if user_data.role not in [models.UserRole.STAFF, models.UserRole.ADMIN]:
|
27 |
-
raise HTTPException(
|
28 |
-
status_code=status.HTTP_400_BAD_REQUEST,
|
29 |
-
detail=f"User role must be '{models.UserRole.STAFF.value}' or '{models.UserRole.ADMIN.value}'"
|
30 |
-
)
|
31 |
-
|
32 |
-
# Check if username already exists
|
33 |
try:
|
34 |
-
|
35 |
-
except HTTPException as e:
|
36 |
raise e
|
37 |
-
|
38 |
-
return created_user_orm # Pydantic UserResponse will convert from ORM model
|
39 |
|
40 |
-
@router.
|
41 |
-
async def
|
42 |
-
username_str: str,
|
43 |
-
tag_link_request: models.TagLinkRequest,
|
44 |
-
db: SQLAlchemySessionType = Depends(get_db),
|
45 |
-
current_admin_orm: models.User = Depends(get_current_active_admin_user_from_token) # For logging, if needed
|
46 |
-
):
|
47 |
"""
|
48 |
-
Admin
|
49 |
"""
|
50 |
-
|
51 |
-
|
52 |
-
# It handles tag uniqueness and user existence checks.
|
53 |
-
updated_user_orm = await run_in_threadpool(crud.update_user_tag_id, db, username_str, tag_link_request.tag_id)
|
54 |
-
except HTTPException as e: # Catch HTTPExceptions from CRUD
|
55 |
-
raise e
|
56 |
-
except Exception as e:
|
57 |
-
# Generic error logging
|
58 |
-
print(f"Unexpected error in link_user_tag_endpoint: {e}")
|
59 |
-
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred.")
|
60 |
-
|
61 |
-
return updated_user_orm # Pydantic UserResponse will convert
|
62 |
|
63 |
-
@router.get("/", response_model=
|
64 |
-
async def
|
65 |
-
skip: int = 0,
|
66 |
-
limit: int = 100,
|
67 |
-
db: SQLAlchemySessionType = Depends(get_db),
|
68 |
-
):
|
69 |
"""
|
70 |
-
|
71 |
"""
|
72 |
-
|
73 |
-
users_orm_list = await run_in_threadpool(crud.get_users, db, skip, limit)
|
74 |
-
return users_orm_list # Pydantic will convert the list of ORM User models
|
|
|
1 |
+
"""
|
2 |
+
Router for managing users (staff, admins).
|
3 |
+
"""
|
4 |
from fastapi import APIRouter, Depends, HTTPException, status
|
5 |
+
from sqlalchemy.orm import Session
|
6 |
+
from typing import List
|
|
|
7 |
|
8 |
from src import crud, models
|
|
|
9 |
from src.database import get_db
|
10 |
+
from src.auth import get_current_active_user, get_current_active_admin_user
|
|
|
11 |
|
12 |
router = APIRouter(
|
13 |
prefix="/api/users",
|
14 |
+
tags=["Users"],
|
15 |
)
|
16 |
|
17 |
+
@router.post("/", response_model=models.UserResponse, status_code=status.HTTP_201_CREATED, dependencies=[Depends(get_current_active_admin_user)])
|
18 |
+
async def create_new_user(user: models.UserCreate, db: Session = Depends(get_db)):
|
|
|
|
|
|
|
19 |
"""
|
20 |
+
Admin: Create a new user (staff or admin).
|
21 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
22 |
try:
|
23 |
+
return await crud.create_user(db=db, user=user)
|
24 |
+
except HTTPException as e:
|
25 |
raise e
|
|
|
|
|
26 |
|
27 |
+
@router.get("/", response_model=List[models.UserResponse], dependencies=[Depends(get_current_active_admin_user)])
|
28 |
+
async def read_users(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
|
|
|
|
|
|
|
|
|
|
29 |
"""
|
30 |
+
Admin: Retrieve a list of all users.
|
31 |
"""
|
32 |
+
users = await crud.get_all_users(db, skip=skip, limit=limit) # Assumes get_all_users exists in crud.users
|
33 |
+
return users
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
34 |
|
35 |
+
@router.get("/me", response_model=models.UserResponse)
|
36 |
+
async def read_users_me(current_user: models.User = Depends(get_current_active_user)):
|
|
|
|
|
|
|
|
|
37 |
"""
|
38 |
+
Get profile information for the currently authenticated user.
|
39 |
"""
|
40 |
+
return current_user
|
|
|
|
src/utils.py
ADDED
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from typing import List, Dict, Any
|
2 |
+
from sqlalchemy.orm import Session as SQLAlchemySessionType
|
3 |
+
from fastapi.concurrency import run_in_threadpool
|
4 |
+
|
5 |
+
from src import crud, models
|
6 |
+
|
7 |
+
async def format_student_clearance_details(
|
8 |
+
db: SQLAlchemySessionType,
|
9 |
+
student_orm: models.Student
|
10 |
+
) -> models.ClearanceDetail:
|
11 |
+
"""
|
12 |
+
Formats clearance details for a student.
|
13 |
+
|
14 |
+
Args:
|
15 |
+
db (SQLAlchemySessionType): Database session.
|
16 |
+
student_orm (models.Student): ORM model of the student.
|
17 |
+
|
18 |
+
Returns:
|
19 |
+
models.ClearanceDetail: Formatted clearance details.
|
20 |
+
"""
|
21 |
+
statuses_orm_list = await run_in_threadpool(crud.get_clearance_statuses_by_student_id, db, student_orm.student_id)
|
22 |
+
|
23 |
+
clearance_items_models: List[models.ClearanceStatusItem] = []
|
24 |
+
overall_status_val = models.OverallClearanceStatusEnum.COMPLETED
|
25 |
+
|
26 |
+
|
27 |
+
if not statuses_orm_list:
|
28 |
+
overall_status_val = models.OverallClearanceStatusEnum.PENDING
|
29 |
+
|
30 |
+
for status_orm in statuses_orm_list:
|
31 |
+
item = models.ClearanceStatusItem(
|
32 |
+
department=status_orm.department,
|
33 |
+
status=status_orm.status,
|
34 |
+
remarks=status_orm.remarks,
|
35 |
+
updated_at=status_orm.updated_at
|
36 |
+
)
|
37 |
+
clearance_items_models.append(item)
|
38 |
+
if item.status != models.ClearanceStatusEnum.COMPLETED:
|
39 |
+
overall_status_val = models.OverallClearanceStatusEnum.PENDING
|
40 |
+
|
41 |
+
if not statuses_orm_list and overall_status_val == models.OverallClearanceStatusEnum.COMPLETED:
|
42 |
+
overall_status_val = models.OverallClearanceStatusEnum.PENDING
|
43 |
+
|
44 |
+
return models.ClearanceDetail(
|
45 |
+
student_id=student_orm.student_id,
|
46 |
+
name=student_orm.name,
|
47 |
+
department=student_orm.department,
|
48 |
+
clearance_items=clearance_items_models,
|
49 |
+
overall_status=overall_status_val
|
50 |
+
)
|
51 |
+
|