Testys commited on
Commit
b7ed26f
·
1 Parent(s): 99f4045

All changes commit

Browse files
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
- from pydantic import BaseModel, Field, EmailStr
2
- from typing import List, Optional, Dict
3
- from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Enum as SQLAlchemyEnum
4
- from sqlalchemy.orm import relationship
5
- from sqlalchemy.ext.declarative import declarative_base
6
- from datetime import datetime, timedelta
7
- import enum
8
- import os
 
 
 
 
 
 
 
 
 
9
 
10
  Base = declarative_base()
11
 
12
- JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "your-default-secret-key-for-dev-only-CHANGE-ME")
13
- if JWT_SECRET_KEY == "your-default-secret-key-for-dev-only-CHANGE-ME":
14
- print("WARNING: Using default JWT_SECRET_KEY. Please set a strong JWT_SECRET_KEY environment variable for production.")
15
-
16
- # Enums - Values are now ALL CAPS to match the PostgreSQL ENUM type labels, based on testing.
17
- class UserRole(str, enum.Enum):
18
- STUDENT = "student"
19
- STAFF = "staff"
20
- ADMIN = "admin"
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
- class User(Base):
60
- __tablename__ = "users"
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, autoincrement=True, index=True)
74
- student_id = Column(String, ForeignKey("students.student_id"), index=True, nullable=False)
75
- department = Column(SQLAlchemyEnum(ClearanceDepartment, name="clearancedepartment", create_type=False, values_callable=enum_values_callable), index=True, nullable=False)
76
- status = Column(SQLAlchemyEnum(ClearanceStatusEnum, name="clearancestatusenum", create_type=False, values_callable=enum_values_callable), default=ClearanceStatusEnum.NOT_COMPLETED, nullable=False)
77
- remarks = Column(String, nullable=True)
78
- cleared_by = Column(Integer, ForeignKey("users.id"), nullable=True)
79
- updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
80
- created_at = Column(DateTime, default=datetime.utcnow)
 
 
 
 
 
81
 
82
  class Device(Base):
 
83
  __tablename__ = "devices"
84
- id = Column(Integer, primary_key=True, index=True, autoincrement=True)
85
- name = Column(String, index=True, nullable=True)
86
- device_id = Column(String, unique=True, index=True, nullable=True)
87
  location = Column(String, nullable=True)
88
- api_key = Column(String, unique=True, index=True, nullable=False)
89
- is_active = Column(Boolean, default=True)
90
- description = Column(String, nullable=True)
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
- class StudentResponse(BaseModel):
126
- id: int
127
- student_id: str
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
- role: UserRole
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(BaseModel):
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
- created_at: datetime
154
- updated_at: datetime
155
- class Config: from_attributes = True
 
 
156
 
157
- class ClearanceStatusCreate(BaseModel):
158
- student_id: str
159
- department: ClearanceDepartment
160
- status: ClearanceStatusEnum
161
- remarks: Optional[str] = None
162
 
163
- class ClearanceStatusItem(BaseModel):
164
- department: ClearanceDepartment
165
- status: ClearanceStatusEnum
166
- remarks: Optional[str] = None
167
- updated_at: datetime
168
- class Config: from_attributes = True
 
 
 
169
 
170
- class ClearanceStatusResponse(BaseModel):
 
 
 
171
  id: int
172
- student_id: str
173
- department: ClearanceDepartment
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  status: ClearanceStatusEnum
175
- remarks: Optional[str] = None
176
- cleared_by: Optional[int] = None
177
- updated_at: datetime
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
- class Config: from_attributes = True
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 Token(BaseModel):
218
- access_token: str
219
- token_type: str
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 PrepareTagLinkRequest(BaseModel):
229
- device_identifier: str
230
- target_user_type: TargetUserType
231
- target_identifier: str
232
-
233
- class ScannedTagSubmit(BaseModel):
234
- scanned_tag_id: str
 
 
 
 
 
 
 
 
 
 
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 sqlalchemy.orm import Session as SQLAlchemySessionType
3
- from typing import List, Optional, Dict
 
4
 
5
- from src import crud, models # crud functions are now sync ORM
 
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=["admin"],
15
- dependencies=[Depends(get_current_active_admin_user_from_token)]
16
  )
17
 
18
- # --- Device Management Endpoints (Synchronous ORM) ---
19
- @router.post("/devices/", response_model=models.DeviceResponse, status_code=status.HTTP_201_CREATED)
20
- def register_new_device_admin( # Endpoint is synchronous
21
- device_data: models.DeviceCreateAdmin,
22
- db: SQLAlchemySessionType = Depends(get_db),
23
- current_admin_orm: models.User = Depends(get_current_active_admin_user_from_token) # For logging who did it
24
  ):
25
  """
26
- Admin registers a new ESP32/RFID reader device. Uses ORM.
 
27
  """
28
- print(f"Admin '{current_admin_orm.username}' creating device: {device_data.name}")
29
- try:
30
- created_device_orm = crud.create_device(db=db, device_data=device_data)
31
- except HTTPException as e:
32
- raise e
33
- return created_device_orm # Pydantic converts from ORM model
 
 
 
 
 
 
 
 
34
 
35
- @router.get("/devices/", response_model=List[models.DeviceResponse])
36
- def list_all_devices_admin( # Endpoint is synchronous
37
- skip: int = 0,
38
- limit: int = 100,
39
- db: SQLAlchemySessionType = Depends(get_db)
40
- ):
41
- """
42
- Admin lists all registered devices. Uses ORM.
43
- """
44
- devices_orm_list = crud.get_all_devices(db, skip=skip, limit=limit)
45
- return devices_orm_list
46
 
47
- @router.get("/devices/{device_pk_id}", response_model=models.DeviceResponse)
48
- def get_device_details_admin( # Endpoint is synchronous
49
- device_pk_id: int,
50
- db: SQLAlchemySessionType = Depends(get_db)
 
51
  ):
52
  """
53
- Admin gets details of a specific device by its Primary Key. Uses ORM.
54
  """
55
- # crud.get_device_by_pk is now sync ORM
56
- db_device_orm = crud.get_device_by_pk(db, device_pk=device_pk_id)
57
- if db_device_orm is None:
58
- raise HTTPException(status_code=404, detail="Device not found")
59
- return db_device_orm
60
 
61
- # --- Tag Linking Preparation Endpoint (Synchronous ORM) ---
62
- @router.post("/prepare-device-tag-link", status_code=status.HTTP_202_ACCEPTED, response_model=Dict)
63
- def prepare_device_for_tag_linking_admin( # Endpoint is synchronous
64
- request_payload: models.PrepareTagLinkRequest,
65
- db: SQLAlchemySessionType = Depends(get_db),
66
- current_admin_orm: models.User = Depends(get_current_active_admin_user_from_token)
67
  ):
68
  """
69
- Admin prepares a device for tag linking. Uses synchronous ORM.
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
- pending_link_orm = crud.create_pending_tag_link(
90
- db=db,
91
- device_pk=device_pk_for_link,
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
- 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, 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=["clearance"],
 
17
  )
18
 
19
- @router.post("/", response_model=models.ClearanceStatusResponse)
20
- async def update_clearance_status_endpoint( # Async endpoint
21
- status_data: models.ClearanceStatusCreate,
22
- db: SQLAlchemySessionType = Depends(get_db),
23
- # current_user_orm is User ORM model (staff/admin) via Tag-based auth
24
- current_user_orm: models.User = Depends(get_current_staff_or_admin_via_tag)
 
 
 
 
25
  ):
26
  """
27
- Staff/Admin creates or updates a student's clearance status. Uses ORM.
28
  """
29
- # crud.get_student_by_student_id is sync
30
- student_orm = await run_in_threadpool(crud.get_student_by_student_id, db, status_data.student_id)
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
- if not statuses_orm_list:
66
- overall_status_val = models.OverallClearanceStatusEnum.PENDING
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.get("/me", response_model=models.ClearanceDetail)
91
- async def get_my_clearance_status( # Async endpoint
92
- # current_student_orm is Student ORM model via Tag-based auth
93
- current_student_orm: models.Student = Depends(get_current_student_via_tag),
94
- db: SQLAlchemySessionType = Depends(get_db)
 
95
  ):
96
  """
97
- Student retrieves their own complete clearance status via RFID Tag. Uses ORM.
 
98
  """
99
- return await _format_my_clearance_response(db, current_student_orm)
 
 
 
 
 
 
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
- from fastapi import APIRouter, HTTPException, Depends, status, Header
 
 
 
 
2
  from fastapi.concurrency import run_in_threadpool
3
- from sqlalchemy.orm import Session as SQLAlchemySessionType
4
- from typing import Dict, Any, List, Optional # Added List for _format_clearance_for_scan_response
5
 
6
  from src import crud, models
7
  from src.database import get_db
8
- from src.auth import get_verified_device # Returns ORM Device model
9
 
10
- router = APIRouter(
11
- prefix="/api", # Keep common prefix or change to /api/devices if preferred
12
- tags=["devices"],
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.HTTP_403_FORBIDDEN,
87
- detail="Device identity mismatch. API key valid, but payload device_id differs."
88
  )
 
89
 
90
- device_pk = verified_device_orm.id
91
- device_hw_id = verified_device_orm.device_id # ESP32's hardware ID (string)
92
-
93
- # Update last seen (sync crud)
94
- await run_in_threadpool(crud.update_device_last_seen, db, device_pk)
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("/devices/submit-scanned-tag", response_model=Dict) # Return a success message dict
120
- async def submit_scanned_tag_endpoint( # Async endpoint
121
- payload: models.ScannedTagSubmit, # Contains scanned_tag_id
122
- verified_device_orm: models.Device = Depends(get_verified_device), # Returns ORM Device model
123
- db: SQLAlchemySessionType = Depends(get_db)
 
 
 
 
124
  ):
125
  """
126
- Device submits a scanned tag to complete a pending link. ORM-based.
 
127
  """
128
- device_pk = verified_device_orm.id
129
- device_hw_id = verified_device_orm.device_id # ESP32's hardware ID (string)
130
-
131
- # crud.get_active_pending_tag_link_by_device_pk is sync
132
- pending_link_orm = await run_in_threadpool(crud.get_active_pending_tag_link_by_device_pk, db, device_pk)
133
-
134
- if not pending_link_orm:
135
- await run_in_threadpool(
136
- crud.create_device_log, db, device_pk, "submit_tag_failed_no_pending",
137
- scanned_tag_id=payload.scanned_tag_id, actual_device_id_str=device_hw_id
138
- )
139
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No active tag linking process for this device.")
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
- # Delete the processed pending link (sync crud)
180
- await run_in_threadpool(crud.delete_pending_tag_link, db, pending_link_orm.id)
 
 
 
 
 
 
 
 
 
 
 
181
 
182
- # Log success (sync crud)
183
- await run_in_threadpool(crud.update_device_last_seen, db, device_pk)
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, Dict, Any # For type hinting
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
 
10
  router = APIRouter(
11
  prefix="/api/students",
12
- tags=["students"],
13
- # All student management routes require an active admin (token-based)
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( # Async 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 creates a new student. Uses ORM."""
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
- except HTTPException as e: # Catch known exceptions from CRUD (e.g., student_id exists)
 
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( # Async 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 gets all students. Uses ORM."""
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 # Pydantic converts list of ORM models
79
 
80
  @router.get("/{student_id_str}", response_model=models.ClearanceDetail)
81
- async def get_student_clearance_endpoint( # Async 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 gets clearance details for a specific student. Uses ORM."""
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 _format_student_clearance_details_response(db, student_orm)
92
 
93
- @router.put("/{student_id_str}/link-tag", response_model=models.StudentResponse)
94
- async def link_student_tag_endpoint( # Async endpoint
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
- """Admin links or updates RFID tag for a student. Uses ORM."""
 
 
101
  try:
102
- # crud.update_student_tag_id is sync, handles checks
103
- updated_student_orm = await run_in_threadpool(crud.update_student_tag_id, db, student_id_str, tag_link_request.tag_id)
104
- except HTTPException as e: # Catch known errors from CRUD
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 fastapi.concurrency import run_in_threadpool # To call sync CRUD in async endpoint
4
- from sqlalchemy.orm import Session as SQLAlchemySessionType
5
 
6
- from src import crud, models # crud now contains sync ORM functions
7
- from src.auth import create_access_token, get_current_active_user # JWT creation is sync
8
- from src.database import get_db # Dependency for SQLAlchemy session
 
9
 
10
  router = APIRouter(
11
- prefix="/api/token",
12
- tags=["authentication"],
13
  )
14
 
15
- @router.post("/login", response_model=models.Token)
16
- async def login_for_access_token( # Endpoint remains async
17
  form_data: OAuth2PasswordRequestForm = Depends(),
18
- db: SQLAlchemySessionType = Depends(get_db)
19
  ):
20
  """
21
- Provides an access token for an authenticated staff or admin user.
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
- # crud.verify_password is sync
35
- is_password_valid = await run_in_threadpool(crud.verify_password, form_data.password, user.hashed_password)
36
- if not is_password_valid:
37
- raise HTTPException(
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 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 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 # Returns ORM User model
10
-
11
 
12
  router = APIRouter(
13
  prefix="/api/users",
14
- tags=["users"],
15
  )
16
 
17
- @router.post("/register", response_model=models.UserResponse, status_code=status.HTTP_201_CREATED)
18
- async def register_user( # Endpoint remains async
19
- user_data: models.UserCreate,
20
- db: SQLAlchemySessionType = Depends(get_db),
21
- ):
22
  """
23
- Admin registers a new staff or admin user. Uses ORM.
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
- created_user_orm = await run_in_threadpool(crud.create_user, db, user_data)
35
- except HTTPException as e: # Catch HTTPExceptions raised by CRUD (e.g., username exists)
36
  raise e
37
-
38
- return created_user_orm # Pydantic UserResponse will convert from ORM model
39
 
40
- @router.put("/{username_str}/link-tag", response_model=models.UserResponse)
41
- async def link_user_tag_endpoint( # Endpoint remains async
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 links or updates the RFID tag_id for a specific staff/admin user. Uses ORM.
49
  """
50
- try:
51
- # crud.update_user_tag_id is sync, call with run_in_threadpool
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=List[models.UserResponse])
64
- async def list_all_users(
65
- skip: int = 0,
66
- limit: int = 100,
67
- db: SQLAlchemySessionType = Depends(get_db),
68
- ):
69
  """
70
- Admin lists all staff/admin users with pagination. Uses ORM.
71
  """
72
- # crud.get_users is sync, call with run_in_threadpool
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
+