Spaces:
Runtime error
Runtime error
FEAT: Backend code completed done
Browse files- src/auth.py +85 -0
- src/config.py +15 -0
- src/crud/__init__.py +83 -0
- src/crud/clearance.py +58 -0
- src/crud/devices.py +74 -0
- src/crud/students.py +98 -0
- src/crud/tag_linking.py +65 -0
- src/crud/users.py +85 -0
- src/crud/utils.py +14 -0
- src/database.py +35 -0
- src/models.py +130 -0
- src/routers/__init__.py +0 -0
- src/routers/admin.py +225 -0
- src/routers/clearance.py +38 -0
- src/routers/devices.py +68 -0
- src/routers/rfid.py +60 -0
- src/routers/students.py +29 -0
- src/routers/token.py +41 -0
- src/routers/users.py +25 -0
- src/utils.py +51 -0
src/auth.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import Depends, HTTPException, status
|
| 2 |
+
from fastapi.security import OAuth2PasswordBearer, APIKeyHeader
|
| 3 |
+
from jose import JWTError, jwt
|
| 4 |
+
from passlib.context import CryptContext
|
| 5 |
+
from datetime import datetime, timedelta, timezone
|
| 6 |
+
from typing import Optional
|
| 7 |
+
from sqlmodel import Session, select
|
| 8 |
+
|
| 9 |
+
from src.database import get_session
|
| 10 |
+
from src.models import User
|
| 11 |
+
from src.config import settings
|
| 12 |
+
|
| 13 |
+
# --- Security Configuration ---
|
| 14 |
+
|
| 15 |
+
# Password hashing context using bcrypt
|
| 16 |
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 17 |
+
|
| 18 |
+
# OAuth2 scheme for token-based authentication
|
| 19 |
+
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/token")
|
| 20 |
+
|
| 21 |
+
# API key header for device authentication
|
| 22 |
+
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=True)
|
| 23 |
+
|
| 24 |
+
# --- Password Utilities ---
|
| 25 |
+
|
| 26 |
+
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
| 27 |
+
"""Verifies a plain password against a hashed password."""
|
| 28 |
+
return pwd_context.verify(plain_password, hashed_password)
|
| 29 |
+
|
| 30 |
+
def hash_password(password: str) -> str:
|
| 31 |
+
"""Hashes a plain password."""
|
| 32 |
+
return pwd_context.hash(password)
|
| 33 |
+
|
| 34 |
+
# --- JWT Token Utilities ---
|
| 35 |
+
|
| 36 |
+
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
| 37 |
+
"""
|
| 38 |
+
Generates a new JWT access token.
|
| 39 |
+
"""
|
| 40 |
+
to_encode = data.copy()
|
| 41 |
+
if expires_delta:
|
| 42 |
+
expire = datetime.now(timezone.utc) + expires_delta
|
| 43 |
+
else:
|
| 44 |
+
# Default expiration time: 15 minutes
|
| 45 |
+
expire = datetime.now(timezone.utc) + timedelta(minutes=15)
|
| 46 |
+
|
| 47 |
+
to_encode.update({"exp": expire})
|
| 48 |
+
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
| 49 |
+
return encoded_jwt
|
| 50 |
+
|
| 51 |
+
# --- User Authentication and Authorization ---
|
| 52 |
+
|
| 53 |
+
async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_session)) -> User:
|
| 54 |
+
"""
|
| 55 |
+
Decodes the JWT token to get the current user.
|
| 56 |
+
Raises an exception if the token is invalid or the user doesn't exist.
|
| 57 |
+
"""
|
| 58 |
+
credentials_exception = HTTPException(
|
| 59 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 60 |
+
detail="Could not validate credentials",
|
| 61 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
try:
|
| 65 |
+
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
| 66 |
+
username: Optional[str] = payload.get("sub")
|
| 67 |
+
if username is None:
|
| 68 |
+
raise credentials_exception
|
| 69 |
+
except JWTError:
|
| 70 |
+
raise credentials_exception
|
| 71 |
+
|
| 72 |
+
user = db.exec(select(User).where(User.username == username)).first()
|
| 73 |
+
if user is None:
|
| 74 |
+
raise credentials_exception
|
| 75 |
+
|
| 76 |
+
return user
|
| 77 |
+
|
| 78 |
+
async def get_current_active_user(current_user: User = Depends(get_current_user)) -> User:
|
| 79 |
+
"""
|
| 80 |
+
Ensures the user retrieved from the token is active.
|
| 81 |
+
"""
|
| 82 |
+
if not current_user.is_active:
|
| 83 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Inactive user")
|
| 84 |
+
return current_user
|
| 85 |
+
|
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,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
hash_password, # Import hash_password from users module
|
| 26 |
+
get_all_users
|
| 27 |
+
)
|
| 28 |
+
from .devices import (
|
| 29 |
+
get_device_by_id_str,
|
| 30 |
+
get_device_by_api_key,
|
| 31 |
+
create_device_log,
|
| 32 |
+
update_device_last_seen,
|
| 33 |
+
delete_device,
|
| 34 |
+
)
|
| 35 |
+
from .clearance import (
|
| 36 |
+
get_clearance_statuses_by_student_id,
|
| 37 |
+
update_clearance_status,
|
| 38 |
+
delete_clearance_status,
|
| 39 |
+
get_all_clearance_status,
|
| 40 |
+
get_student_clearance_status
|
| 41 |
+
)
|
| 42 |
+
from .tag_linking import (
|
| 43 |
+
create_pending_tag_link,
|
| 44 |
+
get_pending_link_by_id,
|
| 45 |
+
delete_pending_link_by_device_id,
|
| 46 |
+
get_pending_links,
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
# Export all functions
|
| 50 |
+
__all__ = [
|
| 51 |
+
# Users
|
| 52 |
+
'create_user',
|
| 53 |
+
'get_user_by_username',
|
| 54 |
+
'get_user_by_tag_id',
|
| 55 |
+
'update_user_tag_id',
|
| 56 |
+
'get_user_by_id',
|
| 57 |
+
'delete_user',
|
| 58 |
+
'hash_password',
|
| 59 |
+
'get_all_users',
|
| 60 |
+
# Students
|
| 61 |
+
'create_student',
|
| 62 |
+
'get_all_students',
|
| 63 |
+
'get_student_by_student_id',
|
| 64 |
+
'get_student_by_tag_id',
|
| 65 |
+
'update_student_tag_id',
|
| 66 |
+
'delete_student',
|
| 67 |
+
# Devices
|
| 68 |
+
'get_device_by_id_str',
|
| 69 |
+
'get_device_by_api_key',
|
| 70 |
+
'create_device_log',
|
| 71 |
+
'update_device_last_seen',
|
| 72 |
+
'delete_device',
|
| 73 |
+
# Clearance
|
| 74 |
+
'get_clearance_statuses_by_student_id',
|
| 75 |
+
'update_clearance_status',
|
| 76 |
+
'delete_clearance_status',
|
| 77 |
+
# Tag Linking
|
| 78 |
+
'create_pending_tag_link',
|
| 79 |
+
'get_pending_link_by_device_id',
|
| 80 |
+
'get_pending_link_by_token',
|
| 81 |
+
'delete_pending_link_by_id',
|
| 82 |
+
'get_all_pending_links',
|
| 83 |
+
]
|
src/crud/clearance.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlmodel import Session, select
|
| 2 |
+
from typing import List, Optional
|
| 3 |
+
|
| 4 |
+
from src.models import Student, ClearanceStatus, ClearanceUpdate, Department, ClearanceProcess
|
| 5 |
+
|
| 6 |
+
def get_clearance_status_for_student(db: Session, student: Student) -> List[ClearanceStatus]:
|
| 7 |
+
"""
|
| 8 |
+
Retrieves all clearance statuses for a given student object.
|
| 9 |
+
"""
|
| 10 |
+
return student.clearance_statuses
|
| 11 |
+
|
| 12 |
+
def update_clearance_status(db: Session, clearance_update: ClearanceUpdate) -> Optional[ClearanceStatus]:
|
| 13 |
+
"""
|
| 14 |
+
Updates the clearance status for a student in a specific department.
|
| 15 |
+
|
| 16 |
+
This function performs a direct lookup on the ClearanceStatus table, which is
|
| 17 |
+
more efficient than fetching the student and iterating through their statuses.
|
| 18 |
+
"""
|
| 19 |
+
# First, find the student to ensure they exist.
|
| 20 |
+
student = db.exec(select(Student).where(Student.matric_no == clearance_update.matric_no)).first()
|
| 21 |
+
if not student:
|
| 22 |
+
return None # Student not found
|
| 23 |
+
|
| 24 |
+
# Directly query for the specific clearance status record.
|
| 25 |
+
statement = select(ClearanceStatus).where(
|
| 26 |
+
ClearanceStatus.student_id == student.id,
|
| 27 |
+
ClearanceStatus.department == clearance_update.department
|
| 28 |
+
)
|
| 29 |
+
status_to_update = db.exec(statement).first()
|
| 30 |
+
|
| 31 |
+
if not status_to_update:
|
| 32 |
+
# This case should theoretically not happen if students are created correctly,
|
| 33 |
+
# but it's a good safeguard.
|
| 34 |
+
return None
|
| 35 |
+
|
| 36 |
+
# Update the status and commit the change.
|
| 37 |
+
status_to_update.status = clearance_update.status
|
| 38 |
+
db.add(status_to_update)
|
| 39 |
+
db.commit()
|
| 40 |
+
db.refresh(status_to_update)
|
| 41 |
+
|
| 42 |
+
return status_to_update
|
| 43 |
+
|
| 44 |
+
def is_student_fully_cleared(db: Session, matric_no: str) -> bool:
|
| 45 |
+
"""
|
| 46 |
+
Checks if a student has been approved by all required departments.
|
| 47 |
+
"""
|
| 48 |
+
student = db.exec(select(Student).where(Student.matric_no == matric_no)).first()
|
| 49 |
+
if not student:
|
| 50 |
+
return False # Or raise an error, depending on desired behavior
|
| 51 |
+
|
| 52 |
+
# Check if any of the student's clearance statuses are NOT 'approved'.
|
| 53 |
+
for status in student.clearance_statuses:
|
| 54 |
+
if status.status != ClearanceProcess.APPROVED:
|
| 55 |
+
return False
|
| 56 |
+
|
| 57 |
+
# If the loop completes without returning, all statuses are approved.
|
| 58 |
+
return True
|
src/crud/devices.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlmodel import Session, select
|
| 2 |
+
import secrets
|
| 3 |
+
from typing import List, Optional
|
| 4 |
+
|
| 5 |
+
from src.models import Device, DeviceCreate, Department
|
| 6 |
+
|
| 7 |
+
def create_device(db: Session, device: DeviceCreate) -> Optional[Device]:
|
| 8 |
+
"""
|
| 9 |
+
Creates a new device for a department.
|
| 10 |
+
Generates a unique API key for authentication.
|
| 11 |
+
Returns None if a device with the same name already exists.
|
| 12 |
+
"""
|
| 13 |
+
# Check for existing device with the same name to prevent duplicates
|
| 14 |
+
existing_device = db.exec(select(Device).where(Device.device_name == device.device_name)).first()
|
| 15 |
+
if existing_device:
|
| 16 |
+
return None
|
| 17 |
+
|
| 18 |
+
# Generate a secure, URL-safe API key
|
| 19 |
+
api_key = secrets.token_urlsafe(32)
|
| 20 |
+
|
| 21 |
+
db_device = Device(
|
| 22 |
+
device_name=device.device_name,
|
| 23 |
+
department=device.department,
|
| 24 |
+
api_key=api_key,
|
| 25 |
+
is_active=True
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
db.add(db_device)
|
| 29 |
+
db.commit()
|
| 30 |
+
db.refresh(db_device)
|
| 31 |
+
return db_device
|
| 32 |
+
|
| 33 |
+
def get_device_by_api_key(db: Session, api_key: str) -> Optional[Device]:
|
| 34 |
+
"""Retrieves an active device by its API key."""
|
| 35 |
+
statement = select(Device).where(Device.api_key == api_key, Device.is_active == True)
|
| 36 |
+
return db.exec(statement).first()
|
| 37 |
+
|
| 38 |
+
def get_device_by_name(db: Session, device_name: str) -> Optional[Device]:
|
| 39 |
+
"""Retrieves a device by its unique name."""
|
| 40 |
+
return db.exec(select(Device).where(Device.device_name == device_name)).first()
|
| 41 |
+
|
| 42 |
+
def get_all_devices(db: Session, skip: int = 0, limit: int = 100) -> List[Device]:
|
| 43 |
+
"""Retrieves a list of all devices."""
|
| 44 |
+
return db.exec(select(Device).offset(skip).limit(limit)).all()
|
| 45 |
+
|
| 46 |
+
def update_device(db: Session, device_id: int, device_update: dict) -> Optional[Device]:
|
| 47 |
+
"""
|
| 48 |
+
Updates a device's mutable properties (e.g., name, active status).
|
| 49 |
+
The API key is immutable and cannot be changed here.
|
| 50 |
+
"""
|
| 51 |
+
db_device = db.get(Device, device_id)
|
| 52 |
+
if not db_device:
|
| 53 |
+
return None
|
| 54 |
+
|
| 55 |
+
# Exclude API key from updates for security
|
| 56 |
+
device_update.pop("api_key", None)
|
| 57 |
+
|
| 58 |
+
for key, value in device_update.items():
|
| 59 |
+
setattr(db_device, key, value)
|
| 60 |
+
|
| 61 |
+
db.add(db_device)
|
| 62 |
+
db.commit()
|
| 63 |
+
db.refresh(db_device)
|
| 64 |
+
return db_device
|
| 65 |
+
|
| 66 |
+
def delete_device(db: Session, device_id: int) -> Optional[Device]:
|
| 67 |
+
"""Deletes a device from the database."""
|
| 68 |
+
db_device = db.get(Device, device_id)
|
| 69 |
+
if not db_device:
|
| 70 |
+
return None
|
| 71 |
+
|
| 72 |
+
db.delete(db_device)
|
| 73 |
+
db.commit()
|
| 74 |
+
return db_device
|
src/crud/students.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlmodel import Session, select
|
| 2 |
+
from typing import List, Optional
|
| 3 |
+
|
| 4 |
+
from src.models import Student, StudentCreate, StudentUpdate, RFIDTag, ClearanceStatus, Department, ClearanceProcess
|
| 5 |
+
from src.auth import hash_password
|
| 6 |
+
|
| 7 |
+
# --- Read Operations ---
|
| 8 |
+
|
| 9 |
+
def get_student_by_id(db: Session, student_id: int) -> Optional[Student]:
|
| 10 |
+
"""Retrieves a student by their primary key ID."""
|
| 11 |
+
return db.get(Student, student_id)
|
| 12 |
+
|
| 13 |
+
def get_student_by_matric_no(db: Session, matric_no: str) -> Optional[Student]:
|
| 14 |
+
"""Retrieves a student by their unique matriculation number."""
|
| 15 |
+
return db.exec(select(Student).where(Student.matric_no == matric_no)).first()
|
| 16 |
+
|
| 17 |
+
def get_student_by_tag_id(db: Session, tag_id: str) -> Optional[Student]:
|
| 18 |
+
"""Retrieves a student by their linked RFID tag ID."""
|
| 19 |
+
statement = select(Student).join(RFIDTag).where(RFIDTag.tag_id == tag_id)
|
| 20 |
+
return db.exec(statement).first()
|
| 21 |
+
|
| 22 |
+
def get_all_students(db: Session, skip: int = 0, limit: int = 100) -> List[Student]:
|
| 23 |
+
"""Retrieves a paginated list of all students."""
|
| 24 |
+
return db.exec(select(Student).offset(skip).limit(limit)).all()
|
| 25 |
+
|
| 26 |
+
# --- Write Operations ---
|
| 27 |
+
|
| 28 |
+
def create_student(db: Session, student: StudentCreate) -> Student:
|
| 29 |
+
"""
|
| 30 |
+
Creates a new student and initializes their clearance statuses.
|
| 31 |
+
|
| 32 |
+
This is a critical business logic function. When a student is created,
|
| 33 |
+
this function automatically creates a 'pending' clearance record for every
|
| 34 |
+
department defined in the `Department` enum.
|
| 35 |
+
"""
|
| 36 |
+
hashed_pass = hash_password(student.password)
|
| 37 |
+
db_student = Student(
|
| 38 |
+
matric_no=student.matric_no,
|
| 39 |
+
full_name=student.full_name,
|
| 40 |
+
email=student.email,
|
| 41 |
+
hashed_password=hashed_pass,
|
| 42 |
+
# Department will be set from the StudentCreate model
|
| 43 |
+
department=student.department
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
# --- Auto-populate clearance statuses ---
|
| 47 |
+
initial_statuses = []
|
| 48 |
+
for dept in Department:
|
| 49 |
+
status = ClearanceStatus(
|
| 50 |
+
department=dept,
|
| 51 |
+
status=ClearanceProcess.PENDING
|
| 52 |
+
)
|
| 53 |
+
initial_statuses.append(status)
|
| 54 |
+
|
| 55 |
+
db_student.clearance_statuses = initial_statuses
|
| 56 |
+
# --- End of auto-population ---
|
| 57 |
+
|
| 58 |
+
db.add(db_student)
|
| 59 |
+
db.commit()
|
| 60 |
+
db.refresh(db_student)
|
| 61 |
+
return db_student
|
| 62 |
+
|
| 63 |
+
def update_student(db: Session, student_id: int, student_update: StudentUpdate) -> Optional[Student]:
|
| 64 |
+
"""
|
| 65 |
+
Updates a student's information.
|
| 66 |
+
If a new password is provided, it will be hashed.
|
| 67 |
+
"""
|
| 68 |
+
db_student = db.get(Student, student_id)
|
| 69 |
+
if not db_student:
|
| 70 |
+
return None
|
| 71 |
+
|
| 72 |
+
update_data = student_update.model_dump(exclude_unset=True)
|
| 73 |
+
|
| 74 |
+
if "password" in update_data:
|
| 75 |
+
update_data["hashed_password"] = hash_password(update_data.pop("password"))
|
| 76 |
+
|
| 77 |
+
for key, value in update_data.items():
|
| 78 |
+
setattr(db_student, key, value)
|
| 79 |
+
|
| 80 |
+
db.add(db_student)
|
| 81 |
+
db.commit()
|
| 82 |
+
db.refresh(db_student)
|
| 83 |
+
return db_student
|
| 84 |
+
|
| 85 |
+
def delete_student(db: Session, student_id: int) -> Optional[Student]:
|
| 86 |
+
"""
|
| 87 |
+
|
| 88 |
+
Deletes a student from the database.
|
| 89 |
+
All associated clearance statuses and the linked RFID tag will also be
|
| 90 |
+
deleted automatically due to the cascade settings in the data models.
|
| 91 |
+
"""
|
| 92 |
+
db_student = db.get(Student, student_id)
|
| 93 |
+
if not db_student:
|
| 94 |
+
return None
|
| 95 |
+
|
| 96 |
+
db.delete(db_student)
|
| 97 |
+
db.commit()
|
| 98 |
+
return db_student
|
src/crud/tag_linking.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlmodel import Session, select
|
| 2 |
+
from typing import Optional, Union
|
| 3 |
+
|
| 4 |
+
from src.models import RFIDTag, User, Student, TagLink
|
| 5 |
+
|
| 6 |
+
def link_tag(db: Session, link_data: TagLink) -> Optional[RFIDTag]:
|
| 7 |
+
"""
|
| 8 |
+
Links an RFID tag to a user or student.
|
| 9 |
+
|
| 10 |
+
This function performs several crucial validation checks:
|
| 11 |
+
1. Ensures the tag is not already linked to another person.
|
| 12 |
+
2. Ensures the target user/student does not already have a tag.
|
| 13 |
+
3. Ensures either a matric_no or username is provided.
|
| 14 |
+
|
| 15 |
+
Returns the new RFIDTag object on success, None on failure.
|
| 16 |
+
The calling router is responsible for raising the appropriate HTTP exception.
|
| 17 |
+
"""
|
| 18 |
+
# 1. Check if the tag is already in use
|
| 19 |
+
existing_tag = db.exec(select(RFIDTag).where(RFIDTag.tag_id == link_data.tag_id)).first()
|
| 20 |
+
if existing_tag:
|
| 21 |
+
return None # Failure: Tag already exists
|
| 22 |
+
|
| 23 |
+
target_person: Optional[Union[User, Student]] = None
|
| 24 |
+
|
| 25 |
+
if link_data.matric_no:
|
| 26 |
+
target_person = db.exec(select(Student).where(Student.matric_no == link_data.matric_no)).first()
|
| 27 |
+
elif link_data.username:
|
| 28 |
+
target_person = db.exec(select(User).where(User.username == link_data.username)).first()
|
| 29 |
+
else:
|
| 30 |
+
return None # Failure: No identifier provided
|
| 31 |
+
|
| 32 |
+
if not target_person:
|
| 33 |
+
return None # Failure: Target person not found
|
| 34 |
+
|
| 35 |
+
# 2. Check if the person already has a tag linked
|
| 36 |
+
if target_person.rfid_tag:
|
| 37 |
+
return None # Failure: Person already has a tag
|
| 38 |
+
|
| 39 |
+
# Create and link the new tag
|
| 40 |
+
new_tag = RFIDTag(tag_id=link_data.tag_id)
|
| 41 |
+
if isinstance(target_person, Student):
|
| 42 |
+
new_tag.student_id = target_person.id
|
| 43 |
+
else:
|
| 44 |
+
new_tag.user_id = target_person.id
|
| 45 |
+
|
| 46 |
+
db.add(new_tag)
|
| 47 |
+
db.commit()
|
| 48 |
+
db.refresh(new_tag)
|
| 49 |
+
|
| 50 |
+
return new_tag
|
| 51 |
+
|
| 52 |
+
def unlink_tag(db: Session, tag_id: str) -> Optional[RFIDTag]:
|
| 53 |
+
"""
|
| 54 |
+
Unlinks an RFID tag, making it available for re-assignment.
|
| 55 |
+
Returns the deleted tag object on success, None if the tag doesn't exist.
|
| 56 |
+
"""
|
| 57 |
+
tag_to_delete = db.exec(select(RFIDTag).where(RFIDTag.tag_id == tag_id)).first()
|
| 58 |
+
|
| 59 |
+
if not tag_to_delete:
|
| 60 |
+
return None # Tag not found
|
| 61 |
+
|
| 62 |
+
db.delete(tag_to_delete)
|
| 63 |
+
db.commit()
|
| 64 |
+
|
| 65 |
+
return tag_to_delete
|
src/crud/users.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlmodel import Session, select
|
| 2 |
+
from typing import List, Optional
|
| 3 |
+
|
| 4 |
+
from src.models import User, UserCreate, UserUpdate, RFIDTag
|
| 5 |
+
from src.auth import hash_password
|
| 6 |
+
|
| 7 |
+
# --- Read Operations ---
|
| 8 |
+
|
| 9 |
+
def get_user_by_id(db: Session, user_id: int) -> Optional[User]:
|
| 10 |
+
"""Retrieves a user by their primary key ID."""
|
| 11 |
+
return db.get(User, user_id)
|
| 12 |
+
|
| 13 |
+
def get_user_by_username(db: Session, username: str) -> Optional[User]:
|
| 14 |
+
"""Retrieves a user by their unique username."""
|
| 15 |
+
return db.exec(select(User).where(User.username == username)).first()
|
| 16 |
+
|
| 17 |
+
def get_user_by_email(db: Session, email: str) -> Optional[User]:
|
| 18 |
+
"""Retrieves a user by their unique email."""
|
| 19 |
+
return db.exec(select(User).where(User.email == email)).first()
|
| 20 |
+
|
| 21 |
+
def get_user_by_tag_id(db: Session, tag_id: str) -> Optional[User]:
|
| 22 |
+
"""Retrieves a user by their linked RFID tag ID."""
|
| 23 |
+
statement = select(User).join(RFIDTag).where(RFIDTag.tag_id == tag_id)
|
| 24 |
+
return db.exec(statement).first()
|
| 25 |
+
|
| 26 |
+
def get_all_users(db: Session, skip: int = 0, limit: int = 100) -> List[User]:
|
| 27 |
+
"""Retrieves a paginated list of all users."""
|
| 28 |
+
return db.exec(select(User).offset(skip).limit(limit)).all()
|
| 29 |
+
|
| 30 |
+
# --- Write Operations ---
|
| 31 |
+
|
| 32 |
+
def create_user(db: Session, user: UserCreate) -> User:
|
| 33 |
+
"""
|
| 34 |
+
Creates a new user.
|
| 35 |
+
- Hashes the password before saving.
|
| 36 |
+
- The router should handle checks for existing username/email to provide clean HTTP errors.
|
| 37 |
+
"""
|
| 38 |
+
hashed_pass = hash_password(user.password)
|
| 39 |
+
db_user = User(
|
| 40 |
+
username=user.username,
|
| 41 |
+
email=user.email,
|
| 42 |
+
full_name=user.full_name,
|
| 43 |
+
hashed_password=hashed_pass,
|
| 44 |
+
role=user.role
|
| 45 |
+
)
|
| 46 |
+
db.add(db_user)
|
| 47 |
+
db.commit()
|
| 48 |
+
db.refresh(db_user)
|
| 49 |
+
return db_user
|
| 50 |
+
|
| 51 |
+
def update_user(db: Session, user_id: int, user_update: UserUpdate) -> Optional[User]:
|
| 52 |
+
"""
|
| 53 |
+
Updates a user's information.
|
| 54 |
+
- If a new password is provided, it will be hashed.
|
| 55 |
+
"""
|
| 56 |
+
db_user = db.get(User, user_id)
|
| 57 |
+
if not db_user:
|
| 58 |
+
return None
|
| 59 |
+
|
| 60 |
+
update_data = user_update.model_dump(exclude_unset=True)
|
| 61 |
+
|
| 62 |
+
# Hash password if it's being updated
|
| 63 |
+
if "password" in update_data:
|
| 64 |
+
update_data["hashed_password"] = hash_password(update_data.pop("password"))
|
| 65 |
+
|
| 66 |
+
for key, value in update_data.items():
|
| 67 |
+
setattr(db_user, key, value)
|
| 68 |
+
|
| 69 |
+
db.add(db_user)
|
| 70 |
+
db.commit()
|
| 71 |
+
db.refresh(db_user)
|
| 72 |
+
return db_user
|
| 73 |
+
|
| 74 |
+
def delete_user(db: Session, user_id: int) -> Optional[User]:
|
| 75 |
+
"""
|
| 76 |
+
Deletes a user from the database.
|
| 77 |
+
The linked RFID tag will also be deleted due to cascade settings.
|
| 78 |
+
"""
|
| 79 |
+
db_user = db.get(User, user_id)
|
| 80 |
+
if not db_user:
|
| 81 |
+
return None
|
| 82 |
+
|
| 83 |
+
db.delete(db_user)
|
| 84 |
+
db.commit()
|
| 85 |
+
return db_user
|
src/crud/utils.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Utility functions for CRUD operations.
|
| 3 |
+
"""
|
| 4 |
+
import bcrypt
|
| 5 |
+
|
| 6 |
+
def hash_password(password: str) -> str:
|
| 7 |
+
"""Hashes a password using bcrypt."""
|
| 8 |
+
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
| 9 |
+
|
| 10 |
+
def verify_password(plain_password: str, hashed_password_str: str) -> bool:
|
| 11 |
+
"""Verifies a plain password against a hashed password."""
|
| 12 |
+
if not plain_password or not hashed_password_str:
|
| 13 |
+
return False
|
| 14 |
+
return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password_str.encode('utf-8'))
|
src/database.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlmodel import create_engine, Session, SQLModel
|
| 2 |
+
from src.config import settings
|
| 3 |
+
|
| 4 |
+
# --- Database Engine Setup ---
|
| 5 |
+
|
| 6 |
+
# The database URL is constructed from the application settings.
|
| 7 |
+
# This makes it easy to switch between different database environments (e.g., dev, test, prod).
|
| 8 |
+
DATABASE_URL = settings.POSTGRES_URI
|
| 9 |
+
engine = create_engine(DATABASE_URL, echo=True) # echo=True logs SQL queries, useful for debugging
|
| 10 |
+
|
| 11 |
+
# --- Database Initialization ---
|
| 12 |
+
|
| 13 |
+
def create_db_and_tables():
|
| 14 |
+
"""
|
| 15 |
+
Creates all database tables defined by SQLModel metadata.
|
| 16 |
+
This function is called once at application startup.
|
| 17 |
+
"""
|
| 18 |
+
print("Initializing database...")
|
| 19 |
+
SQLModel.metadata.create_all(engine)
|
| 20 |
+
print("Database tables created successfully (if they didn't exist).")
|
| 21 |
+
|
| 22 |
+
# --- Database Session Management ---
|
| 23 |
+
|
| 24 |
+
def get_session():
|
| 25 |
+
"""
|
| 26 |
+
A FastAPI dependency that provides a database session for each request.
|
| 27 |
+
It ensures that the session is always closed after the request is finished,
|
| 28 |
+
even if an error occurs.
|
| 29 |
+
"""
|
| 30 |
+
with Session(engine) as session:
|
| 31 |
+
try:
|
| 32 |
+
yield session
|
| 33 |
+
finally:
|
| 34 |
+
session.close()
|
| 35 |
+
|
src/models.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlmodel import Field, Relationship, SQLModel
|
| 2 |
+
from typing import List, Optional
|
| 3 |
+
from enum import Enum as PyEnum
|
| 4 |
+
|
| 5 |
+
# --- Enums for Controlled Vocabularies ---
|
| 6 |
+
# Using enums ensures data consistency for categorical fields.
|
| 7 |
+
|
| 8 |
+
class UserRole(str, PyEnum):
|
| 9 |
+
STUDENT = "student"
|
| 10 |
+
STAFF = "staff"
|
| 11 |
+
ADMIN = "admin"
|
| 12 |
+
|
| 13 |
+
class Department(str, PyEnum):
|
| 14 |
+
LIBRARY = "library"
|
| 15 |
+
BURSARY = "bursary"
|
| 16 |
+
ALUMNI = "alumni"
|
| 17 |
+
DEPARTMENTAL = "departmental"
|
| 18 |
+
|
| 19 |
+
class ClearanceProcess(str, PyEnum):
|
| 20 |
+
PENDING = "pending"
|
| 21 |
+
APPROVED = "approved"
|
| 22 |
+
REJECTED = "rejected"
|
| 23 |
+
|
| 24 |
+
# --- Database Table Models ---
|
| 25 |
+
|
| 26 |
+
# Represents a User (Staff or Admin)
|
| 27 |
+
class User(SQLModel, table=True):
|
| 28 |
+
id: Optional[int] = Field(default=None, primary_key=True)
|
| 29 |
+
username: str = Field(index=True, unique=True)
|
| 30 |
+
email: str = Field(unique=True)
|
| 31 |
+
full_name: Optional[str] = None
|
| 32 |
+
hashed_password: str
|
| 33 |
+
role: UserRole = Field(default=UserRole.STAFF)
|
| 34 |
+
is_active: bool = Field(default=True)
|
| 35 |
+
|
| 36 |
+
# One-to-one relationship with an RFID tag
|
| 37 |
+
rfid_tag: Optional["RFIDTag"] = Relationship(back_populates="user")
|
| 38 |
+
|
| 39 |
+
# Represents a Student
|
| 40 |
+
class Student(SQLModel, table=True):
|
| 41 |
+
id: Optional[int] = Field(default=None, primary_key=True)
|
| 42 |
+
matric_no: str = Field(index=True, unique=True)
|
| 43 |
+
full_name: str
|
| 44 |
+
email: str = Field(unique=True)
|
| 45 |
+
department: Department
|
| 46 |
+
hashed_password: str
|
| 47 |
+
|
| 48 |
+
# One-to-many relationship with clearance statuses
|
| 49 |
+
clearance_statuses: List["ClearanceStatus"] = Relationship(
|
| 50 |
+
back_populates="student", sa_relationship_kwargs={"cascade": "all, delete-orphan"}
|
| 51 |
+
)
|
| 52 |
+
# One-to-one relationship with an RFID tag
|
| 53 |
+
rfid_tag: Optional["RFIDTag"] = Relationship(back_populates="student")
|
| 54 |
+
|
| 55 |
+
# Represents an RFID tag, linking it to either a User or a Student
|
| 56 |
+
class RFIDTag(SQLModel, table=True):
|
| 57 |
+
id: Optional[int] = Field(default=None, primary_key=True)
|
| 58 |
+
tag_id: str = Field(index=True, unique=True, description="The unique ID from the RFID chip")
|
| 59 |
+
|
| 60 |
+
# Foreign keys to link to User or Student (only one should be set)
|
| 61 |
+
user_id: Optional[int] = Field(default=None, foreign_key="user.id")
|
| 62 |
+
student_id: Optional[int] = Field(default=None, foreign_key="student.id")
|
| 63 |
+
|
| 64 |
+
# Relationships back to the owner of the tag
|
| 65 |
+
user: Optional[User] = Relationship(back_populates="rfid_tag")
|
| 66 |
+
student: Optional[Student] = Relationship(back_populates="rfid_tag")
|
| 67 |
+
|
| 68 |
+
# Represents a single clearance status for a student in a specific department
|
| 69 |
+
class ClearanceStatus(SQLModel, table=True):
|
| 70 |
+
id: Optional[int] = Field(default=None, primary_key=True)
|
| 71 |
+
department: Department
|
| 72 |
+
status: ClearanceProcess = Field(default=ClearanceProcess.PENDING)
|
| 73 |
+
|
| 74 |
+
student_id: int = Field(foreign_key="student.id")
|
| 75 |
+
student: Student = Relationship(back_populates="clearance_statuses")
|
| 76 |
+
|
| 77 |
+
# Represents a physical ESP32 device
|
| 78 |
+
class Device(SQLModel, table=True):
|
| 79 |
+
id: Optional[int] = Field(default=None, primary_key=True)
|
| 80 |
+
device_name: str = Field(index=True, unique=True)
|
| 81 |
+
api_key: str = Field(unique=True)
|
| 82 |
+
is_active: bool = Field(default=True)
|
| 83 |
+
department: Department
|
| 84 |
+
|
| 85 |
+
# --- Pydantic Models for API Operations ---
|
| 86 |
+
# These models define the shape of data for creating and updating records via the API.
|
| 87 |
+
|
| 88 |
+
# For Users
|
| 89 |
+
class UserCreate(SQLModel):
|
| 90 |
+
username: str
|
| 91 |
+
email: str
|
| 92 |
+
password: str
|
| 93 |
+
full_name: Optional[str] = None
|
| 94 |
+
role: UserRole = UserRole.STAFF
|
| 95 |
+
|
| 96 |
+
class UserUpdate(SQLModel):
|
| 97 |
+
email: Optional[str] = None
|
| 98 |
+
full_name: Optional[str] = None
|
| 99 |
+
password: Optional[str] = None
|
| 100 |
+
is_active: Optional[bool] = None
|
| 101 |
+
|
| 102 |
+
# For Students
|
| 103 |
+
class StudentCreate(SQLModel):
|
| 104 |
+
matric_no: str
|
| 105 |
+
full_name: str
|
| 106 |
+
email: str
|
| 107 |
+
password: str
|
| 108 |
+
|
| 109 |
+
class StudentUpdate(SQLModel):
|
| 110 |
+
full_name: Optional[str] = None
|
| 111 |
+
email: Optional[str] = None
|
| 112 |
+
password: Optional[str] = None
|
| 113 |
+
|
| 114 |
+
# For Devices
|
| 115 |
+
class DeviceCreate(SQLModel):
|
| 116 |
+
device_name: str
|
| 117 |
+
department: Department
|
| 118 |
+
|
| 119 |
+
# For Tag Linking
|
| 120 |
+
class TagLink(SQLModel):
|
| 121 |
+
tag_id: str
|
| 122 |
+
matric_no: Optional[str] = None
|
| 123 |
+
username: Optional[str] = None
|
| 124 |
+
|
| 125 |
+
# For Clearance Updates
|
| 126 |
+
class ClearanceUpdate(SQLModel):
|
| 127 |
+
matric_no: str
|
| 128 |
+
department: Department
|
| 129 |
+
status: ClearanceProcess
|
| 130 |
+
|
src/routers/__init__.py
ADDED
|
File without changes
|
src/routers/admin.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
| 2 |
+
from sqlmodel import Session
|
| 3 |
+
from typing import List, Optional, Dict
|
| 4 |
+
|
| 5 |
+
from src.database import get_session
|
| 6 |
+
from src.auth import get_current_active_user
|
| 7 |
+
from src.models import (
|
| 8 |
+
User, UserCreate, UserRead, UserUpdate, Role,
|
| 9 |
+
Student, StudentCreate, StudentRead, StudentUpdate, StudentReadWithClearance,
|
| 10 |
+
TagLink, RFIDTagRead, TagScan,
|
| 11 |
+
Device, DeviceCreate, DeviceRead
|
| 12 |
+
)
|
| 13 |
+
from src.crud import users as user_crud
|
| 14 |
+
from src.crud import students as student_crud
|
| 15 |
+
from src.crud import tag_linking as tag_crud
|
| 16 |
+
from src.crud import devices as device_crud
|
| 17 |
+
|
| 18 |
+
# In-memory storage for last scanned tags by an admin.
|
| 19 |
+
# Key: admin user ID, Value: last scanned tag ID.
|
| 20 |
+
# This is simple and effective for a non-distributed system.
|
| 21 |
+
admin_scanned_tags: Dict[int, str] = {}
|
| 22 |
+
|
| 23 |
+
# Define the router.
|
| 24 |
+
# It's accessible to both Admins and Staff, providing a unified management panel.
|
| 25 |
+
router = APIRouter(
|
| 26 |
+
prefix="/admin",
|
| 27 |
+
tags=["Administration"],
|
| 28 |
+
dependencies=[Depends(get_current_active_user(required_roles=[Role.ADMIN, Role.STAFF]))],
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
# --- Admin Tag Scanning for Web UI ---
|
| 32 |
+
|
| 33 |
+
@router.post("/tags/scan", status_code=status.HTTP_204_NO_CONTENT)
|
| 34 |
+
def report_scanned_tag(
|
| 35 |
+
scan_data: TagScan,
|
| 36 |
+
current_user: User = Depends(get_current_active_user(required_roles=[Role.ADMIN, Role.STAFF]))
|
| 37 |
+
):
|
| 38 |
+
"""
|
| 39 |
+
(Admin & Staff) Endpoint for the admin's desk RFID reader to report a scanned tag.
|
| 40 |
+
The backend stores this tag ID temporarily against the admin's user ID.
|
| 41 |
+
"""
|
| 42 |
+
admin_scanned_tags[current_user.id] = scan_data.tag_id
|
| 43 |
+
return
|
| 44 |
+
|
| 45 |
+
@router.get("/tags/scan", response_model=TagScan)
|
| 46 |
+
def retrieve_scanned_tag(
|
| 47 |
+
current_user: User = Depends(get_current_active_user(required_roles=[Role.ADMIN, Role.STAFF]))
|
| 48 |
+
):
|
| 49 |
+
"""
|
| 50 |
+
(Admin & Staff) Endpoint for the admin's web portal to retrieve the last scanned tag.
|
| 51 |
+
The tag is removed after being retrieved to prevent re-use.
|
| 52 |
+
"""
|
| 53 |
+
tag_id = admin_scanned_tags.pop(current_user.id, None)
|
| 54 |
+
if not tag_id:
|
| 55 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No tag has been scanned recently.")
|
| 56 |
+
return TagScan(tag_id=tag_id)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
# --- Student Management (Admin + Staff) ---
|
| 60 |
+
|
| 61 |
+
@router.post("/students/", response_model=StudentReadWithClearance, status_code=status.HTTP_201_CREATED)
|
| 62 |
+
def create_student(student: StudentCreate, db: Session = Depends(get_session)):
|
| 63 |
+
"""(Admin & Staff) Creates a new student and initializes their clearance status."""
|
| 64 |
+
db_student = student_crud.get_student_by_matric_no(db, matric_no=student.matric_no)
|
| 65 |
+
if db_student:
|
| 66 |
+
raise HTTPException(status_code=400, detail="Matriculation number already registered")
|
| 67 |
+
return student_crud.create_student(db=db, student=student)
|
| 68 |
+
|
| 69 |
+
@router.get("/students/", response_model=List[StudentReadWithClearance])
|
| 70 |
+
def read_all_students(skip: int = 0, limit: int = 100, db: Session = Depends(get_session)):
|
| 71 |
+
"""(Admin & Staff) Retrieves a list of all student records."""
|
| 72 |
+
return student_crud.get_all_students(db, skip=skip, limit=limit)
|
| 73 |
+
|
| 74 |
+
@router.get("/students/lookup", response_model=StudentReadWithClearance)
|
| 75 |
+
def lookup_student(
|
| 76 |
+
matric_no: Optional[str] = Query(None, description="Matriculation number of the student."),
|
| 77 |
+
tag_id: Optional[str] = Query(None, description="RFID tag ID linked to the student."),
|
| 78 |
+
db: Session = Depends(get_session)
|
| 79 |
+
):
|
| 80 |
+
"""(Admin & Staff) Looks up a single student by Matric Number OR Tag ID."""
|
| 81 |
+
if not matric_no and not tag_id:
|
| 82 |
+
raise HTTPException(status_code=400, detail="A matric_no or tag_id must be provided.")
|
| 83 |
+
if matric_no and tag_id:
|
| 84 |
+
raise HTTPException(status_code=400, detail="Provide either matric_no or tag_id, not both.")
|
| 85 |
+
|
| 86 |
+
db_student = None
|
| 87 |
+
if matric_no:
|
| 88 |
+
db_student = student_crud.get_student_by_matric_no(db, matric_no=matric_no)
|
| 89 |
+
elif tag_id:
|
| 90 |
+
db_student = student_crud.get_student_by_tag_id(db, tag_id=tag_id)
|
| 91 |
+
|
| 92 |
+
if not db_student:
|
| 93 |
+
raise HTTPException(status_code=404, detail="Student not found with the provided identifier.")
|
| 94 |
+
return db_student
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
@router.get("/students/{student_id}", response_model=StudentReadWithClearance)
|
| 98 |
+
def read_single_student(student_id: int, db: Session = Depends(get_session)):
|
| 99 |
+
"""(Admin & Staff) Retrieves a single student's complete record by their internal ID."""
|
| 100 |
+
db_student = student_crud.get_student_by_id(db, student_id=student_id)
|
| 101 |
+
if not db_student:
|
| 102 |
+
raise HTTPException(status_code=404, detail="Student not found")
|
| 103 |
+
return db_student
|
| 104 |
+
|
| 105 |
+
@router.put("/students/{student_id}", response_model=StudentReadWithClearance)
|
| 106 |
+
def update_student_details(student_id: int, student: StudentUpdate, db: Session = Depends(get_session)):
|
| 107 |
+
"""(Admin & Staff) Updates a student's information."""
|
| 108 |
+
updated_student = student_crud.update_student(db, student_id=student_id, student_update=student)
|
| 109 |
+
if not updated_student:
|
| 110 |
+
raise HTTPException(status_code=404, detail="Student not found")
|
| 111 |
+
return updated_student
|
| 112 |
+
|
| 113 |
+
# --- Tag Management (Admin + Staff) ---
|
| 114 |
+
|
| 115 |
+
@router.post("/tags/link", response_model=RFIDTagRead)
|
| 116 |
+
def link_rfid_tag(link_data: TagLink, db: Session = Depends(get_session)):
|
| 117 |
+
"""(Admin & Staff) Links an RFID tag to a student or user."""
|
| 118 |
+
new_tag = tag_crud.link_tag(db, link_data)
|
| 119 |
+
if not new_tag:
|
| 120 |
+
raise HTTPException(
|
| 121 |
+
status_code=status.HTTP_409_CONFLICT,
|
| 122 |
+
detail="Could not link tag. The tag may already be in use, or the user/student already has a tag."
|
| 123 |
+
)
|
| 124 |
+
return new_tag
|
| 125 |
+
|
| 126 |
+
@router.delete("/tags/{tag_id}/unlink", response_model=RFIDTagRead)
|
| 127 |
+
def unlink_rfid_tag(tag_id: str, db: Session = Depends(get_session)):
|
| 128 |
+
"""(Admin & Staff) Unlinks an RFID tag, making it available again."""
|
| 129 |
+
deleted_tag = tag_crud.unlink_tag(db, tag_id)
|
| 130 |
+
if not deleted_tag:
|
| 131 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="RFID Tag not found.")
|
| 132 |
+
return deleted_tag
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
# --- Super Admin Only Functions ---
|
| 136 |
+
|
| 137 |
+
def require_super_admin(current_user: User = Depends(get_current_active_user())):
|
| 138 |
+
"""Dependency to ensure a user has the ADMIN role."""
|
| 139 |
+
if current_user.role != Role.ADMIN:
|
| 140 |
+
raise HTTPException(
|
| 141 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 142 |
+
detail="This action requires Super Admin privileges."
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
@router.post("/users/", response_model=UserRead, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_super_admin)])
|
| 146 |
+
def create_user(user: UserCreate, db: Session = Depends(get_session)):
|
| 147 |
+
"""(Super Admin Only) Creates a new user (Staff or Admin)."""
|
| 148 |
+
db_user = user_crud.get_user_by_username(db, username=user.username)
|
| 149 |
+
if db_user:
|
| 150 |
+
raise HTTPException(status_code=400, detail="Username already registered")
|
| 151 |
+
return user_crud.create_user(db=db, user=user)
|
| 152 |
+
|
| 153 |
+
@router.get("/users/", response_model=List[UserRead], dependencies=[Depends(require_super_admin)])
|
| 154 |
+
def read_all_users(db: Session = Depends(get_session)):
|
| 155 |
+
"""(Super Admin Only) Retrieves a list of all users."""
|
| 156 |
+
return user_crud.get_all_users(db)
|
| 157 |
+
|
| 158 |
+
@router.get("/users/lookup", response_model=UserRead, dependencies=[Depends(require_super_admin)])
|
| 159 |
+
def lookup_user(
|
| 160 |
+
username: Optional[str] = Query(None, description="Username of the user."),
|
| 161 |
+
tag_id: Optional[str] = Query(None, description="RFID tag ID linked to the user."),
|
| 162 |
+
db: Session = Depends(get_session)
|
| 163 |
+
):
|
| 164 |
+
"""(Super Admin Only) Looks up a single user by Username OR Tag ID."""
|
| 165 |
+
if not username and not tag_id:
|
| 166 |
+
raise HTTPException(status_code=400, detail="A username or tag_id must be provided.")
|
| 167 |
+
if username and tag_id:
|
| 168 |
+
raise HTTPException(status_code=400, detail="Provide either username or tag_id, not both.")
|
| 169 |
+
|
| 170 |
+
db_user = None
|
| 171 |
+
if username:
|
| 172 |
+
db_user = user_crud.get_user_by_username(db, username=username)
|
| 173 |
+
elif tag_id:
|
| 174 |
+
db_user = user_crud.get_user_by_tag_id(db, tag_id=tag_id)
|
| 175 |
+
|
| 176 |
+
if not db_user:
|
| 177 |
+
raise HTTPException(status_code=404, detail="User not found with the provided identifier.")
|
| 178 |
+
return db_user
|
| 179 |
+
|
| 180 |
+
@router.put("/users/{user_id}", response_model=UserRead, dependencies=[Depends(require_super_admin)])
|
| 181 |
+
def update_user_details(user_id: int, user: UserUpdate, db: Session = Depends(get_session)):
|
| 182 |
+
"""(Super Admin Only) Updates a user's details (e.g., role)."""
|
| 183 |
+
updated_user = user_crud.update_user(db, user_id=user_id, user_update=user)
|
| 184 |
+
if not updated_user:
|
| 185 |
+
raise HTTPException(status_code=404, detail="User not found")
|
| 186 |
+
return updated_user
|
| 187 |
+
|
| 188 |
+
@router.delete("/users/{user_id}", response_model=UserRead, dependencies=[Depends(require_super_admin)])
|
| 189 |
+
def delete_user_account(user_id: int, db: Session = Depends(get_session), current_user: User = Depends(get_current_active_user())):
|
| 190 |
+
"""(Super Admin Only) Deletes a user account."""
|
| 191 |
+
if current_user.id == user_id:
|
| 192 |
+
raise HTTPException(status_code=400, detail="Cannot delete your own account.")
|
| 193 |
+
deleted_user = user_crud.delete_user(db, user_id=user_id)
|
| 194 |
+
if not deleted_user:
|
| 195 |
+
raise HTTPException(status_code=404, detail="User not found")
|
| 196 |
+
return deleted_user
|
| 197 |
+
|
| 198 |
+
@router.delete("/students/{student_id}", response_model=StudentRead, dependencies=[Depends(require_super_admin)])
|
| 199 |
+
def delete_student_record(student_id: int, db: Session = Depends(get_session)):
|
| 200 |
+
"""(Super Admin Only) Deletes a student record and all associated data."""
|
| 201 |
+
deleted_student = student_crud.delete_student(db, student_id=student_id)
|
| 202 |
+
if not deleted_student:
|
| 203 |
+
raise HTTPException(status_code=404, detail="Student not found")
|
| 204 |
+
return deleted_student
|
| 205 |
+
|
| 206 |
+
@router.post("/devices/", response_model=DeviceRead, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_super_admin)])
|
| 207 |
+
def create_device(device: DeviceCreate, db: Session = Depends(get_session)):
|
| 208 |
+
"""(Super Admin Only) Registers a new RFID hardware device."""
|
| 209 |
+
db_device = device_crud.get_device_by_location(db, location=device.location)
|
| 210 |
+
if db_device:
|
| 211 |
+
raise HTTPException(status_code=400, detail=f"A device at location '{device.location}' already exists.")
|
| 212 |
+
return device_crud.create_device(db=db, device=device)
|
| 213 |
+
|
| 214 |
+
@router.get("/devices/", response_model=List[DeviceRead], dependencies=[Depends(require_super_admin)])
|
| 215 |
+
def read_all_devices(db: Session = Depends(get_session)):
|
| 216 |
+
"""(Super Admin Only) Retrieves a list of all registered devices."""
|
| 217 |
+
return device_crud.get_all_devices(db)
|
| 218 |
+
|
| 219 |
+
@router.delete("/devices/{device_id}", response_model=DeviceRead, dependencies=[Depends(require_super_admin)])
|
| 220 |
+
def delete_device_registration(device_id: int, db: Session = Depends(get_session)):
|
| 221 |
+
"""(Super Admin Only) De-authorizes a hardware device."""
|
| 222 |
+
deleted_device = device_crud.delete_device(db, device_id=device_id)
|
| 223 |
+
if not deleted_device:
|
| 224 |
+
raise HTTPException(status_code=404, detail="Device not found")
|
| 225 |
+
return deleted_device
|
src/routers/clearance.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 2 |
+
from sqlmodel import Session
|
| 3 |
+
|
| 4 |
+
from src.database import get_session
|
| 5 |
+
from src.auth import get_current_active_user
|
| 6 |
+
from src.models import User, Role, ClearanceStatus, ClearanceUpdate, ClearanceStatusRead
|
| 7 |
+
from src.crud import clearance as clearance_crud
|
| 8 |
+
|
| 9 |
+
router = APIRouter(
|
| 10 |
+
prefix="/clearance",
|
| 11 |
+
tags=["Clearance"],
|
| 12 |
+
dependencies=[Depends(get_current_active_user(required_roles=[Role.STAFF, Role.ADMIN]))],
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
@router.put("/update", response_model=ClearanceStatusRead)
|
| 16 |
+
def update_student_clearance_status(
|
| 17 |
+
clearance_update: ClearanceUpdate,
|
| 18 |
+
db: Session = Depends(get_session),
|
| 19 |
+
# The current_user object is injected by the dependency
|
| 20 |
+
current_user: User = Depends(get_current_active_user(required_roles=[Role.STAFF, Role.ADMIN]))
|
| 21 |
+
):
|
| 22 |
+
"""
|
| 23 |
+
Endpoint for staff to update a student's clearance status.
|
| 24 |
+
A staff member can only approve for their own department.
|
| 25 |
+
(Future enhancement could enforce this rule more strictly).
|
| 26 |
+
"""
|
| 27 |
+
# A potential security check: ensure staff's department matches clearance_update.department
|
| 28 |
+
# For now, we trust the role.
|
| 29 |
+
|
| 30 |
+
updated_status = clearance_crud.update_clearance_status(db, clearance_update)
|
| 31 |
+
|
| 32 |
+
if not updated_status:
|
| 33 |
+
raise HTTPException(
|
| 34 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 35 |
+
detail=f"No clearance record found for student {clearance_update.matric_no} in department {clearance_update.department}"
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
return updated_status
|
src/routers/devices.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 2 |
+
from sqlmodel import Session
|
| 3 |
+
from typing import List
|
| 4 |
+
|
| 5 |
+
from src.database import get_session
|
| 6 |
+
from src.auth import get_current_active_user
|
| 7 |
+
from src.models import Role, Device, DeviceCreate, DeviceRead
|
| 8 |
+
from src.crud import devices as device_crud
|
| 9 |
+
|
| 10 |
+
# Define the router with admin-only access
|
| 11 |
+
router = APIRouter(
|
| 12 |
+
prefix="/devices",
|
| 13 |
+
tags=["Devices"],
|
| 14 |
+
dependencies=[Depends(get_current_active_user(required_roles=[Role.ADMIN]))],
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
@router.post("/", response_model=DeviceRead, status_code=status.HTTP_201_CREATED)
|
| 18 |
+
def create_device(
|
| 19 |
+
device: DeviceCreate,
|
| 20 |
+
db: Session = Depends(get_session)
|
| 21 |
+
):
|
| 22 |
+
"""
|
| 23 |
+
Admin endpoint to register a new RFID hardware device.
|
| 24 |
+
|
| 25 |
+
This generates a unique API key that the device must use to authenticate.
|
| 26 |
+
A device's location must be unique.
|
| 27 |
+
"""
|
| 28 |
+
# Check if a device with the same location already exists
|
| 29 |
+
db_device = device_crud.get_device_by_location(db, location=device.location)
|
| 30 |
+
if db_device:
|
| 31 |
+
raise HTTPException(
|
| 32 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 33 |
+
detail=f"A device at location '{device.location}' already exists."
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
# Create the new device and its API key
|
| 37 |
+
return device_crud.create_device(db=db, device=device)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@router.get("/", response_model=List[DeviceRead])
|
| 41 |
+
def read_all_devices(
|
| 42 |
+
skip: int = 0,
|
| 43 |
+
limit: int = 100,
|
| 44 |
+
db: Session = Depends(get_session)
|
| 45 |
+
):
|
| 46 |
+
"""
|
| 47 |
+
Admin endpoint to retrieve a list of all registered hardware devices.
|
| 48 |
+
"""
|
| 49 |
+
return device_crud.get_all_devices(db, skip=skip, limit=limit)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@router.delete("/{device_id}", response_model=DeviceRead)
|
| 53 |
+
def delete_device(
|
| 54 |
+
device_id: int,
|
| 55 |
+
db: Session = Depends(get_session)
|
| 56 |
+
):
|
| 57 |
+
"""
|
| 58 |
+
Admin endpoint to delete/de-authorize a hardware device.
|
| 59 |
+
|
| 60 |
+
This will render the device's API key invalid.
|
| 61 |
+
"""
|
| 62 |
+
db_device = device_crud.delete_device(db, device_id=device_id)
|
| 63 |
+
if not db_device:
|
| 64 |
+
raise HTTPException(
|
| 65 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 66 |
+
detail="Device not found."
|
| 67 |
+
)
|
| 68 |
+
return db_device
|
src/routers/rfid.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Security
|
| 2 |
+
from fastapi.security import APIKeyHeader
|
| 3 |
+
from sqlmodel import Session
|
| 4 |
+
|
| 5 |
+
from src.database import get_session
|
| 6 |
+
from src.auth import get_api_key
|
| 7 |
+
from src.models import RFIDStatusResponse, RFIDScanRequest
|
| 8 |
+
from src.crud import students as student_crud
|
| 9 |
+
from src.crud import users as user_crud
|
| 10 |
+
|
| 11 |
+
# Define the router and the API key security scheme
|
| 12 |
+
router = APIRouter(prefix="/rfid", tags=["RFID"])
|
| 13 |
+
api_key_header = APIKeyHeader(name="x-api-key", auto_error=False)
|
| 14 |
+
|
| 15 |
+
@router.post("/check-status", response_model=RFIDStatusResponse)
|
| 16 |
+
def check_rfid_status(
|
| 17 |
+
scan_data: RFIDScanRequest,
|
| 18 |
+
db: Session = Depends(get_session),
|
| 19 |
+
# This dependency ensures the request comes from a valid, registered device
|
| 20 |
+
api_key: str = Security(get_api_key),
|
| 21 |
+
):
|
| 22 |
+
"""
|
| 23 |
+
Public endpoint for hardware devices to check the status of a scanned RFID tag.
|
| 24 |
+
The device must provide a valid API key in the 'x-api-key' header.
|
| 25 |
+
"""
|
| 26 |
+
tag_id = scan_data.tag_id
|
| 27 |
+
|
| 28 |
+
# 1. Check if the tag belongs to a student
|
| 29 |
+
student = student_crud.get_student_by_tag_id(db, tag_id=tag_id)
|
| 30 |
+
if student:
|
| 31 |
+
# Check overall clearance status
|
| 32 |
+
is_cleared = all(
|
| 33 |
+
status.status == "approved" for status in student.clearance_statuses
|
| 34 |
+
)
|
| 35 |
+
clearance_status_str = "Fully Cleared" if is_cleared else "Pending Clearance"
|
| 36 |
+
|
| 37 |
+
return RFIDStatusResponse(
|
| 38 |
+
status="found",
|
| 39 |
+
full_name=student.full_name,
|
| 40 |
+
entity_type="Student",
|
| 41 |
+
clearance_status=clearance_status_str,
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
# 2. If not a student, check if it belongs to a user (staff/admin)
|
| 45 |
+
user = user_crud.get_user_by_tag_id(db, tag_id=tag_id)
|
| 46 |
+
if user:
|
| 47 |
+
return RFIDStatusResponse(
|
| 48 |
+
status="found",
|
| 49 |
+
full_name=user.full_name,
|
| 50 |
+
entity_type=user.role.value, # e.g. "Admin" or "Staff"
|
| 51 |
+
clearance_status="N/A",
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
# 3. If the tag is not linked to anyone
|
| 55 |
+
return RFIDStatusResponse(
|
| 56 |
+
status="unregistered",
|
| 57 |
+
full_name=None,
|
| 58 |
+
entity_type=None,
|
| 59 |
+
clearance_status=None,
|
| 60 |
+
)
|
src/routers/students.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 2 |
+
from sqlmodel import Session
|
| 3 |
+
|
| 4 |
+
from src.database import get_session
|
| 5 |
+
from src.auth import get_current_active_student
|
| 6 |
+
from src.models import Student, StudentReadWithClearance
|
| 7 |
+
|
| 8 |
+
router = APIRouter(
|
| 9 |
+
prefix="/students",
|
| 10 |
+
tags=["Students"],
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
@router.get("/me", response_model=StudentReadWithClearance)
|
| 14 |
+
def read_student_me(
|
| 15 |
+
# This dependency ensures the user is an authenticated student
|
| 16 |
+
# and injects their database object into the 'current_student' parameter.
|
| 17 |
+
current_student: Student = Depends(get_current_active_student)
|
| 18 |
+
):
|
| 19 |
+
"""
|
| 20 |
+
Endpoint for a logged-in student to retrieve their own profile
|
| 21 |
+
and clearance information. The user is identified via their JWT token.
|
| 22 |
+
"""
|
| 23 |
+
# Because the dependency returns the full student object, we can just return it.
|
| 24 |
+
# No need for another database call.
|
| 25 |
+
if not current_student:
|
| 26 |
+
# This should not happen if the dependency is set up correctly
|
| 27 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Student not found")
|
| 28 |
+
return current_student
|
| 29 |
+
|
src/routers/token.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 2 |
+
from fastapi.security import OAuth2PasswordRequestForm
|
| 3 |
+
from sqlmodel import Session
|
| 4 |
+
from datetime import timedelta
|
| 5 |
+
|
| 6 |
+
from src.database import get_session
|
| 7 |
+
from src.auth import authenticate_user, create_access_token
|
| 8 |
+
from src.models import Token
|
| 9 |
+
from src.config import settings
|
| 10 |
+
|
| 11 |
+
router = APIRouter(tags=["Authentication"])
|
| 12 |
+
|
| 13 |
+
@router.post("/token", response_model=Token)
|
| 14 |
+
async def login_for_access_token(
|
| 15 |
+
form_data: OAuth2PasswordRequestForm = Depends(),
|
| 16 |
+
db: Session = Depends(get_session)
|
| 17 |
+
):
|
| 18 |
+
"""
|
| 19 |
+
Provides a JWT access token for a valid user (student or staff).
|
| 20 |
+
|
| 21 |
+
This is the primary login endpoint. It uses the standard OAuth2
|
| 22 |
+
password flow. The client sends 'username' and 'password' in a
|
| 23 |
+
form-data body.
|
| 24 |
+
"""
|
| 25 |
+
# The authenticate_user function will check both Student and User tables
|
| 26 |
+
user = authenticate_user(db, form_data.username, form_data.password)
|
| 27 |
+
|
| 28 |
+
if not user:
|
| 29 |
+
raise HTTPException(
|
| 30 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 31 |
+
detail="Incorrect username or password",
|
| 32 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
# Create the JWT token
|
| 36 |
+
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
| 37 |
+
access_token = create_access_token(
|
| 38 |
+
data={"sub": user.email}, expires_delta=access_token_expires
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
return {"access_token": access_token, "token_type": "bearer"}
|
src/routers/users.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends
|
| 2 |
+
from sqlmodel import Session
|
| 3 |
+
|
| 4 |
+
from src.database import get_session
|
| 5 |
+
from src.auth import get_current_active_user
|
| 6 |
+
from src.models import User, UserRead, Role
|
| 7 |
+
|
| 8 |
+
# Define the router
|
| 9 |
+
router = APIRouter(
|
| 10 |
+
prefix="/users",
|
| 11 |
+
tags=["Users"],
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
@router.get("/me", response_model=UserRead)
|
| 15 |
+
def read_user_me(
|
| 16 |
+
# This dependency ensures the user is an authenticated Admin or Staff
|
| 17 |
+
# and injects their database object into the 'current_user' parameter.
|
| 18 |
+
current_user: User = Depends(get_current_active_user(required_roles=[Role.ADMIN, Role.STAFF]))
|
| 19 |
+
):
|
| 20 |
+
"""
|
| 21 |
+
Endpoint for a logged-in user (Admin or Staff) to retrieve their own profile.
|
| 22 |
+
The user is identified via their JWT token.
|
| 23 |
+
"""
|
| 24 |
+
# The dependency handles fetching the user, so we just return it.
|
| 25 |
+
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 |
+
|