Testys commited on
Commit
96b013c
·
1 Parent(s): a09ee49

WIP: Backend Service to go up to write code for streamlit next

Browse files
.gitignore copy ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py,cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ #Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ #uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ #poetry.lock
109
+
110
+ # pdm
111
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
112
+ #pdm.lock
113
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
114
+ # in version control.
115
+ # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
116
+ .pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
121
+ __pypackages__/
122
+
123
+ # Celery stuff
124
+ celerybeat-schedule
125
+ celerybeat.pid
126
+
127
+ # SageMath parsed files
128
+ *.sage.py
129
+
130
+ # Environments
131
+ .env
132
+ .venv
133
+ env/
134
+ venv/
135
+ ENV/
136
+ env.bak/
137
+ venv.bak/
138
+
139
+ # Spyder project settings
140
+ .spyderproject
141
+ .spyproject
142
+
143
+ # Rope project settings
144
+ .ropeproject
145
+
146
+ # mkdocs documentation
147
+ /site
148
+
149
+ # mypy
150
+ .mypy_cache/
151
+ .dmypy.json
152
+ dmypy.json
153
+
154
+ # Pyre type checker
155
+ .pyre/
156
+
157
+ # pytype static type analyzer
158
+ .pytype/
159
+
160
+ # Cython debug symbols
161
+ cython_debug/
162
+
163
+ # PyCharm
164
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
165
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
166
+ # and can be added to the global gitignore or merged into this file.
167
+ .idea/
168
+
169
+ # Ruff stuff:
170
+ .ruff_cache/
171
+
172
+ # PyPI configuration file
173
+ .pypirc
174
+
175
+ # OS-generated files
176
+ .DS_Store
177
+ .DS_Store?
178
+ ._*
179
+ Thumbs.db
180
+ ehthumbs.db
181
+ desktop.ini
182
+
183
+ # Editor backup/swap files
184
+ *~
185
+ *.swp
186
+ *.swo
Dockerfile ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ RUN useradd -m -u 1000 user
4
+ USER user
5
+ ENV PATH="/home/user/.local/bin:$PATH"
6
+
7
+ WORKDIR /app
8
+
9
+ COPY --chown=user ./requirements.txt requirements.txt
10
+ RUN pip install --no-cache-dir --upgrade -r requirements.txt
11
+
12
+ COPY --chown=user . /app
13
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
README copy.md DELETED
@@ -1 +0,0 @@
1
- # clearance_stud
 
 
main copy.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI
2
+ from fastapi.middleware.cors import CORSMiddleware # Keep if CORS is needed
3
+ import uvicorn
4
+ from contextlib import asynccontextmanager
5
+
6
+ from src.database import create_db_tables, get_db
7
+ from src.routers import students, devices, clearance, token, users, admin
8
+
9
+ @asynccontextmanager
10
+ async def lifespan(app_instance: FastAPI):
11
+ """Handles application startup and shutdown events."""
12
+ print("Application startup...")
13
+ create_db_tables()
14
+ print("Database tables checked/created.")
15
+ yield
16
+ print("Application shutdown...")
17
+
18
+
19
+ # FastAPI app instance
20
+ app = FastAPI(
21
+ title="Undergraduate Clearance System API (ORM)",
22
+ description="API for managing student clearance and RFID interactions.",
23
+ version="1.1.0", # Incremented version
24
+ lifespan=lifespan
25
+ )
26
+
27
+ app.add_middleware(
28
+ CORSMiddleware,
29
+ allow_origins= ['*'],
30
+ allow_credentials=True,
31
+ allow_methods=["*"], # Allows all methods
32
+ allow_headers=["*"], # Allows all headers
33
+ )
34
+
35
+ # Include routers
36
+ app.include_router(devices.router)
37
+ app.include_router(students.router)
38
+ app.include_router(clearance.router)
39
+ app.include_router(token.router)
40
+ app.include_router(users.router)
41
+ app.include_router(admin.router)
42
+
43
+ # Root endpoint
44
+ @app.get("/", summary="Root endpoint", tags=["Default"])
45
+ async def read_root():
46
+ """Basic root endpoint to confirm the API is running."""
47
+ return {"message": "Undergraduate Clearance System API is running"}
48
+
49
+ # Run the FastAPI app using Uvicorn
50
+ if __name__ == "__main__":
51
+ uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
src/auth.py CHANGED
@@ -1,30 +1,164 @@
1
- from fastapi import HTTPException, status, Depends
2
- from src.database import database, devices # Import database instance and devices table
 
 
3
 
4
- # Dependency to verify the API key sent by ESP32 devices
5
- async def verify_api_key(api_key: str = Depends(HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key"))):
6
- """
7
- Verifies if the provided API key is valid and corresponds to a registered device
8
- using the 'databases' library.
 
9
 
10
- Args:
11
- api_key: The API key provided in the request header or body.
 
 
12
 
13
- Returns:
14
- The database record of the device if the API key is valid.
 
 
 
 
 
 
 
 
 
 
15
 
16
- Raises:
17
- HTTPException: If the API key is invalid or not found.
18
- """
19
- # Use 'databases' to execute a select query on the 'devices' table
20
- query = devices.select().where(devices.c.api_key == api_key)
21
- device = await database.fetch_one(query) # fetch_one returns a single record or None
22
 
23
- if not device:
24
- # Raise HTTPException if the API key is not found
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  raise HTTPException(
26
  status_code=status.HTTP_401_UNAUTHORIZED,
27
- detail="Invalid API key",
28
- headers={"WWW-Authenticate": "Bearer"}, # Optional: Suggest Bearer token scheme
29
- )
30
- return device
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import HTTPException, status, Header, Depends
2
+ from fastapi.security import OAuth2PasswordBearer
3
+ from fastapi.concurrency import run_in_threadpool # For calling sync crud in async auth
4
+ from sqlalchemy.orm import Session as SQLAlchemySessionType
5
 
6
+ from src import crud, models
7
+ from src.database import get_db
8
+ from typing import Optional, Dict, Any # Added Any
9
+ from datetime import datetime, timedelta
10
+ import jwt
11
+ from typing import Union # For type hinting
12
 
13
+ # JWT Configuration - Loaded from models.py (which loads from .env)
14
+ SECRET_KEY = models.JWT_SECRET_KEY
15
+ ALGORITHM = "HS256"
16
+ ACCESS_TOKEN_EXPIRE_MINUTES = 30
17
 
18
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/token/login") # Path to token endpoint
19
+
20
+ # --- JWT Helper Functions ---
21
+ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
22
+ to_encode = data.copy()
23
+ if expires_delta:
24
+ expire = datetime.utcnow() + expires_delta
25
+ else:
26
+ expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
27
+ to_encode.update({"exp": expire, "iat": datetime.utcnow()}) # Add issued_at time
28
+ encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
29
+ return encoded_jwt
30
 
 
 
 
 
 
 
31
 
32
+ async def get_current_user_from_token(
33
+ token: str = Depends(oauth2_scheme),
34
+ db: SQLAlchemySessionType = Depends(get_db)
35
+ ) -> models.User: # Now aims to return the ORM User model
36
+ credentials_exception = HTTPException(
37
+ status_code=status.HTTP_401_UNAUTHORIZED,
38
+ detail="Could not validate credentials",
39
+ headers={"WWW-Authenticate": "Bearer"},
40
+ )
41
+ try:
42
+ payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
43
+ username: Optional[str] = payload.get("sub")
44
+ if username is None:
45
+ raise credentials_exception
46
+ token_data = models.TokenData(username=username) # Use if TokenData has more fields
47
+ except jwt.ExpiredSignatureError:
48
  raise HTTPException(
49
  status_code=status.HTTP_401_UNAUTHORIZED,
50
+ detail="Token has expired",
51
+ headers={"WWW-Authenticate": "Bearer"},)
52
+ except jwt.PyJWTError:
53
+ raise credentials_exception
54
+
55
+ # User is fetched using sync ORM function, so run in threadpool if this dep is used by async endpoint
56
+ user_orm = await run_in_threadpool(crud.get_user_by_username, db, username)
57
+ if user_orm is None:
58
+ raise credentials_exception
59
+ return user_orm # Return the ORM model instance
60
+
61
+ async def get_current_active_user(
62
+ current_user: models.User = Depends(get_current_user_from_token)
63
+ ) -> models.User: # Expects and returns ORM User
64
+ if not current_user.is_active:
65
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Inactive user")
66
+ return current_user
67
+
68
+ async def get_current_active_staff_user_from_token(
69
+ current_user: models.User = Depends(get_current_active_user)
70
+ ) -> models.User: # Expects and returns ORM User
71
+ if current_user.role not in [models.UserRole.STAFF, models.UserRole.ADMIN]:
72
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Staff or admin access required")
73
+ return current_user
74
+
75
+ async def get_current_active_admin_user_from_token(
76
+ current_user: models.User = Depends(get_current_active_user)
77
+ ) -> models.User: # Expects and returns ORM User
78
+ if current_user.role != models.UserRole.ADMIN:
79
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
80
+ return current_user
81
+
82
+ # Dependency to get and verify API key from header (Device Authentication)
83
+ async def get_verified_device(
84
+ x_api_key: str = Header(..., description="The API Key for the ESP32 device."),
85
+ db: SQLAlchemySessionType = Depends(get_db)
86
+ ) -> models.Device: # Returns the ORM Device model
87
+ """
88
+ Verifies API key and returns the active ORM Device model.
89
+ """
90
+ if not x_api_key:
91
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key")
92
+
93
+ # Use run_in_threadpool as crud.get_device_by_api_key is sync
94
+ device_orm = await run_in_threadpool(crud.get_device_by_api_key, db, x_api_key)
95
+
96
+ if not device_orm:
97
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API Key.")
98
+ if not device_orm.is_active:
99
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Device is not active.")
100
+ return device_orm
101
+
102
+ # Tag-based authentication (User/Student Authentication via RFID tag)
103
+ async def authenticate_tag_user_or_student( # Renamed for clarity
104
+ tag_id: str = Header(..., alias="X-User-Tag-ID", description="RFID Tag ID of the user or student"),
105
+ db: SQLAlchemySessionType = Depends(get_db)
106
+ ) -> Union[models.Student, models.User]: # Returns Student or User ORM model
107
+ """
108
+ Authenticates a tag and returns the corresponding Student or User ORM model.
109
+ Used as a base for tag-based auth dependencies.
110
+ """
111
+ # Run sync ORM calls in threadpool
112
+ student_orm = await run_in_threadpool(crud.get_student_by_tag_id, db, tag_id)
113
+ if student_orm:
114
+ return student_orm # Return Student ORM model
115
+
116
+ user_orm = await run_in_threadpool(crud.get_user_by_tag_id, db, tag_id) # get_user_by_tag_id checks is_active
117
+ if user_orm: # user_orm already checked for is_active in CRUD
118
+ return user_orm # Return User ORM model
119
+
120
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tag not registered or associated user/student is inactive.")
121
+
122
+ # Dependency for current student via Tag ID
123
+ async def get_current_student_via_tag(
124
+ authenticated_entity: Union[models.Student, models.User] = Depends(authenticate_tag_user_or_student)
125
+ ) -> models.Student:
126
+ if not isinstance(authenticated_entity, models.Student):
127
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access restricted to students only.")
128
+ return authenticated_entity
129
+
130
+ # Dependency for current staff or admin via Tag ID
131
+ async def get_current_staff_or_admin_via_tag(
132
+ authenticated_entity: Union[models.Student, models.User] = Depends(authenticate_tag_user_or_student)
133
+ ) -> models.User:
134
+ if not isinstance(authenticated_entity, models.User): # It's a student
135
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Staff or admin access required.")
136
+ # authenticated_entity is User ORM model
137
+ if authenticated_entity.role not in [models.UserRole.STAFF, models.UserRole.ADMIN]:
138
+ # This case should ideally not be hit if authenticate_tag_user_or_student is correct
139
+ # and users fetched by tag are always staff/admin.
140
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User role is not staff or admin.")
141
+ if not authenticated_entity.is_active: # Double check, though crud.get_user_by_tag_id should handle this
142
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User is inactive.")
143
+ return authenticated_entity
144
+
145
+ # Dependency for current admin via Tag ID
146
+ async def get_current_admin_via_tag(
147
+ current_user: models.User = Depends(get_current_staff_or_admin_via_tag) # Leverages the staff_or_admin check
148
+ ) -> models.User:
149
+ if current_user.role != models.UserRole.ADMIN:
150
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required.")
151
+ return current_user
152
+
153
+
154
+ # Department Access Verification (this is a utility, not a dependency itself)
155
+ def verify_department_access( # Made sync as it's pure logic
156
+ user_role: models.UserRole,
157
+ user_department: Optional[models.ClearanceDepartment],
158
+ target_department: models.ClearanceDepartment
159
+ ) -> bool:
160
+ if user_role == models.UserRole.ADMIN:
161
+ return True
162
+ if user_role == models.UserRole.STAFF:
163
+ return user_department == target_department
164
+ return False
src/crud.py CHANGED
@@ -1,151 +1,368 @@
1
- # Import database instance and tables from database.py
2
- from src.database import database, students, clearance_statuses, device_logs, devices
3
- from src.models import StudentCreate, ClearanceStatusCreate, TagScan, DeviceRegister # Import Pydantic models
4
- # Import necessary SQLAlchemy components for building queries
5
- from sqlalchemy import select, insert, update
6
- from datetime import datetime
7
- import secrets # For generating API keys
8
- from fastapi import HTTPException # Import HTTPException to raise errors
9
-
10
- # --- CRUD operations for Students ---
11
-
12
- async def create_student(student: StudentCreate):
13
- """Creates a new student record in the database using 'databases'."""
14
- # Build an insert query using SQLAlchemy
15
- query = students.insert().values(
16
- student_id=student.student_id,
17
- name=student.name,
18
- department=student.department,
19
- tag_id=student.tag_id
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  )
21
- # Execute the query asynchronously using 'databases'
22
- last_record_id = await database.execute(query)
23
- # Return the created student data including the generated ID
24
- return {**student.dict(), "id": last_record_id}
25
-
26
- async def get_all_students():
27
- """Retrieves all student records from the database using 'databases'."""
28
- # Build a select query using SQLAlchemy
29
- query = students.select()
30
- # Execute the query and fetch all results asynchronously
31
- return await database.fetch_all(query)
32
-
33
- async def get_student_by_student_id(student_id: str):
34
- """Retrieves a student record by their student ID using 'databases'."""
35
- # Build a select query with a where clause
36
- query = students.select().where(students.c.student_id == student_id)
37
- # Execute the query and fetch one result asynchronously
38
- return await database.fetch_one(query)
39
-
40
- async def get_student_by_tag_id(tag_id: str):
41
- """Retrieves a student record by their tag ID using 'databases'."""
42
- # Build a select query with a where clause
43
- query = students.select().where(students.c.tag_id == tag_id)
44
- # Execute the query and fetch one result asynchronously
45
- return await database.fetch_one(query)
46
-
47
- # --- CRUD operations for Clearance Statuses ---
48
-
49
- async def create_or_update_clearance_status(status_data: ClearanceStatusCreate):
50
- """Creates a new clearance status or updates an existing one using 'databases'."""
51
- # Check if clearance status already exists for this student and department
52
- query = select(clearance_statuses).where(
53
- (clearance_statuses.c.student_id == status_data.student_id) &
54
- (clearance_statuses.c.department == status_data.department)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  )
56
- existing_status = await database.fetch_one(query)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
 
58
  current_time = datetime.utcnow()
59
 
60
  if existing_status:
61
- # Update existing status
62
- query = update(clearance_statuses).where(
63
- (clearance_statuses.c.student_id == status_data.student_id) &
64
- (clearance_statuses.c.department == status_data.department)
65
- ).values(
66
- status=status_data.status,
67
- remarks=status_data.remarks,
68
- updated_at=current_time
69
- )
70
- await database.execute(query)
71
- # Return the updated record including the existing ID and new timestamp
72
- return {
73
- **status_data.dict(),
74
- "id": existing_status["id"],
75
- "updated_at": current_time
76
- }
77
  else:
78
- # Create new status
79
- query = insert(clearance_statuses).values(
80
  student_id=status_data.student_id,
81
- department=status_data.department,
82
- status=status_data.status,
83
  remarks=status_data.remarks,
 
 
84
  updated_at=current_time
85
  )
86
- last_record_id = await database.execute(query)
87
- # Return the newly created record including the new ID and timestamp
88
- return {
89
- **status_data.dict(),
90
- "id": last_record_id,
91
- "updated_at": current_time
92
- }
93
 
 
 
94
 
95
- async def get_clearance_statuses_by_student_id(student_id: str):
96
- """Retrieves all clearance statuses for a given student ID using 'databases'."""
97
- query = select(clearance_statuses).where(clearance_statuses.c.student_id == student_id)
98
- return await database.fetch_all(query)
 
 
99
 
100
- # --- CRUD operations for Devices ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
 
102
- async def register_device(device_data: DeviceRegister):
103
- """Registers a new device or updates an existing one, generating an API key using 'databases'."""
104
- api_key = secrets.token_hex(16) # Generate a unique API key
105
  current_time = datetime.utcnow()
106
-
107
- # Check if device already exists
108
- query = select(devices).where(devices.c.device_id == device_data.device_id)
109
- existing_device = await database.fetch_one(query)
110
 
111
  if existing_device:
112
- # Update existing device
113
- query = update(devices).where(devices.c.device_id == device_data.device_id).values(
114
- location=device_data.location,
115
- api_key=api_key, # Assign a new API key on re-registration
116
- last_seen=current_time
117
- )
118
- await database.execute(query)
119
  else:
120
- # Create new device
121
- query = insert(devices).values(
122
- device_id=device_data.device_id,
123
  location=device_data.location,
124
  api_key=api_key,
125
- last_seen=current_time
 
 
 
126
  )
127
- await database.execute(query)
 
 
 
 
 
 
 
 
 
 
128
 
129
- # Return the device details including the generated API key
130
- # We need to fetch the newly created/updated device to get the assigned API key
131
- query = select(devices).where(devices.c.device_id == device_data.device_id)
132
- updated_device = await database.fetch_one(query)
133
- return {"device_id": updated_device["device_id"], "location": updated_device["location"], "api_key": updated_device["api_key"]}
134
 
 
 
135
 
136
- async def update_device_last_seen(device_id: str):
137
- """Updates the 'last_seen' timestamp for a given device using 'databases'."""
138
- query = update(devices).where(devices.c.device_id == device_id).values(last_seen=datetime.utcnow())
139
- await database.execute(query)
 
 
140
 
141
- # --- CRUD operations for Device Logs ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
 
143
- async def create_device_log(device_id: str, tag_id: str, action: str):
144
- """Creates a log entry for device activity using 'databases'."""
145
- query = device_logs.insert().values(
146
- device_id=device_id,
147
- tag_id=tag_id,
148
- timestamp=datetime.utcnow(),
149
- action=action
 
150
  )
151
- await database.execute(query)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ORM-based CRUD operations
2
+ # All functions are now synchronous and expect a SQLAlchemy Session
3
+
4
+ from sqlalchemy.orm import Session as SQLAlchemySessionType
5
+ from sqlalchemy import func, and_ # Add other SQLAlchemy functions as needed
6
+ from datetime import datetime, timedelta
7
+ import secrets
8
+ import bcrypt
9
+ from fastapi import HTTPException, status
10
+ from typing import List, Optional, Union # Added Union
11
+
12
+ from src import models # ORM models and Pydantic models
13
+ from src.database import initialize_student_clearance_statuses_orm # ORM based init
14
+
15
+ # --- Password Hashing ---
16
+ def hash_password(password: str) -> str:
17
+ """Hashes a password using bcrypt."""
18
+ return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
19
+
20
+ def verify_password(plain_password: str, hashed_password_str: str) -> bool:
21
+ """Verifies a plain password against a hashed password."""
22
+ if not plain_password or not hashed_password_str:
23
+ return False
24
+ return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password_str.encode('utf-8'))
25
+
26
+ # --- Tag Uniqueness Checks (ORM) ---
27
+ def is_tag_id_unique_for_student(db: SQLAlchemySessionType, tag_id: str, exclude_student_pk: Optional[int] = None) -> bool:
28
+ """Checks if tag_id is unique among students, optionally excluding one by PK."""
29
+ query = db.query(models.Student).filter(models.Student.tag_id == tag_id)
30
+ if exclude_student_pk:
31
+ query = query.filter(models.Student.id != exclude_student_pk)
32
+ return query.first() is None
33
+
34
+ def is_tag_id_unique_for_user(db: SQLAlchemySessionType, tag_id: str, exclude_user_pk: Optional[int] = None) -> bool:
35
+ """Checks if tag_id is unique among users, optionally excluding one by PK."""
36
+ query = db.query(models.User).filter(models.User.tag_id == tag_id)
37
+ if exclude_user_pk:
38
+ query = query.filter(models.User.id != exclude_user_pk)
39
+ return query.first() is None
40
+
41
+ def check_tag_id_globally_unique_for_target(
42
+ db: SQLAlchemySessionType,
43
+ tag_id: str,
44
+ target_type: models.TargetUserType, # Expecting the enum member
45
+ target_pk: Optional[int] = None
46
+ ) -> None:
47
+ """
48
+ Raises HTTPException if tag_id is not globally unique, excluding the target if provided.
49
+ """
50
+ if target_type == models.TargetUserType.STUDENT:
51
+ if not is_tag_id_unique_for_student(db, tag_id, exclude_student_pk=target_pk):
52
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"Tag ID '{tag_id}' is already assigned to another student.")
53
+ if not is_tag_id_unique_for_user(db, tag_id):
54
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"Tag ID '{tag_id}' is already assigned to a user.")
55
+ elif target_type == models.TargetUserType.STAFF_ADMIN:
56
+ if not is_tag_id_unique_for_user(db, tag_id, exclude_user_pk=target_pk):
57
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"Tag ID '{tag_id}' is already assigned to another user.")
58
+ if not is_tag_id_unique_for_student(db, tag_id):
59
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"Tag ID '{tag_id}' is already assigned to a student.")
60
+
61
+
62
+ # --- Student CRUD (ORM) ---
63
+ def create_student(db: SQLAlchemySessionType, student_data: models.StudentCreate) -> models.Student:
64
+ existing_student_by_id = db.query(models.Student).filter(models.Student.student_id == student_data.student_id).first()
65
+ if existing_student_by_id:
66
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Student ID already registered")
67
+
68
+ if student_data.email:
69
+ existing_student_by_email = db.query(models.Student).filter(models.Student.email == student_data.email).first()
70
+ if existing_student_by_email:
71
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered")
72
+
73
+ if student_data.tag_id:
74
+ check_tag_id_globally_unique_for_target(db, student_data.tag_id, models.TargetUserType.STUDENT)
75
+
76
+ db_student = models.Student(
77
+ student_id=student_data.student_id,
78
+ name=student_data.name,
79
+ email=student_data.email,
80
+ department=student_data.department,
81
+ tag_id=student_data.tag_id,
82
+ created_at=datetime.utcnow(),
83
+ updated_at=datetime.utcnow()
84
  )
85
+ db.add(db_student)
86
+ db.commit()
87
+ db.refresh(db_student)
88
+
89
+ initialize_student_clearance_statuses_orm(db, db_student.student_id)
90
+ return db_student
91
+
92
+ def get_all_students(db: SQLAlchemySessionType, skip: int = 0, limit: int = 100) -> List[models.Student]:
93
+ return db.query(models.Student).offset(skip).limit(limit).all()
94
+
95
+ def get_student_by_pk(db: SQLAlchemySessionType, student_pk: int) -> Optional[models.Student]:
96
+ return db.query(models.Student).filter(models.Student.id == student_pk).first()
97
+
98
+ def get_student_by_student_id(db: SQLAlchemySessionType, student_id: str) -> Optional[models.Student]:
99
+ return db.query(models.Student).filter(models.Student.student_id == student_id).first()
100
+
101
+ def get_student_by_tag_id(db: SQLAlchemySessionType, tag_id: str) -> Optional[models.Student]:
102
+ return db.query(models.Student).filter(models.Student.tag_id == tag_id).first()
103
+
104
+ def update_student_tag_id(db: SQLAlchemySessionType, student_id_str: str, new_tag_id: str) -> models.Student:
105
+ student = get_student_by_student_id(db, student_id_str)
106
+ if not student:
107
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Student not found")
108
+
109
+ if student.tag_id == new_tag_id:
110
+ return student
111
+
112
+ check_tag_id_globally_unique_for_target(db, new_tag_id, models.TargetUserType.STUDENT, target_pk=student.id)
113
+
114
+ student.tag_id = new_tag_id
115
+ student.updated_at = datetime.utcnow()
116
+ db.commit()
117
+ db.refresh(student)
118
+ return student
119
+
120
+ # --- User (Staff/Admin) CRUD (ORM) ---
121
+ def create_user(db: SQLAlchemySessionType, user_data: models.UserCreate) -> models.User:
122
+ existing_user = db.query(models.User).filter(models.User.username == user_data.username).first()
123
+ if existing_user:
124
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username already registered")
125
+
126
+ if user_data.tag_id:
127
+ check_tag_id_globally_unique_for_target(db, user_data.tag_id, models.TargetUserType.STAFF_ADMIN)
128
+
129
+ hashed_pass = hash_password(user_data.password)
130
+ db_user = models.User(
131
+ username=user_data.username,
132
+ hashed_password=hashed_pass,
133
+ role=user_data.role, # Pass the enum member directly
134
+ department=user_data.department, # Pass the enum member directly (or None)
135
+ tag_id=user_data.tag_id,
136
+ is_active=user_data.is_active if user_data.is_active is not None else True,
137
+ created_at=datetime.utcnow(),
138
+ updated_at=datetime.utcnow()
139
  )
140
+ db.add(db_user)
141
+ db.commit()
142
+ db.refresh(db_user)
143
+ return db_user
144
+
145
+ def get_users(db: SQLAlchemySessionType, skip: int = 0, limit: int = 100) -> List[models.User]:
146
+ """Retrieves all staff/admin users with pagination."""
147
+ return db.query(models.User).offset(skip).limit(limit).all()
148
+
149
+ def get_user_by_pk(db: SQLAlchemySessionType, user_pk: int) -> Optional[models.User]:
150
+ return db.query(models.User).filter(models.User.id == user_pk).first()
151
+
152
+ def get_user_by_username(db: SQLAlchemySessionType, username: str) -> Optional[models.User]:
153
+ return db.query(models.User).filter(models.User.username == username).first()
154
+
155
+ def get_user_by_tag_id(db: SQLAlchemySessionType, tag_id: str) -> Optional[models.User]:
156
+ return db.query(models.User).filter(models.User.tag_id == tag_id, models.User.is_active == True).first()
157
+
158
+ def update_user_tag_id(db: SQLAlchemySessionType, username_str: str, new_tag_id: str) -> models.User:
159
+ user = get_user_by_username(db, username_str)
160
+ if not user:
161
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
162
+ if not user.is_active:
163
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot update tag for an inactive user.")
164
+
165
+ if user.tag_id == new_tag_id:
166
+ return user
167
+
168
+ check_tag_id_globally_unique_for_target(db, new_tag_id, models.TargetUserType.STAFF_ADMIN, target_pk=user.id)
169
+
170
+ user.tag_id = new_tag_id
171
+ user.updated_at = datetime.utcnow()
172
+ db.commit()
173
+ db.refresh(user)
174
+ return user
175
+
176
+ # --- Clearance Status CRUD (ORM) ---
177
+ def create_or_update_clearance_status(
178
+ db: SQLAlchemySessionType,
179
+ status_data: models.ClearanceStatusCreate, # Pydantic model with enum members
180
+ cleared_by_user_pk: Optional[int] = None
181
+ ) -> models.ClearanceStatus:
182
+
183
+ existing_status = db.query(models.ClearanceStatus).filter(
184
+ models.ClearanceStatus.student_id == status_data.student_id,
185
+ models.ClearanceStatus.department == status_data.department # Comparing enum member from Pydantic to ORM field
186
+ ).first()
187
 
188
  current_time = datetime.utcnow()
189
 
190
  if existing_status:
191
+ existing_status.status = status_data.status # Assigning enum member, SQLAlchemy handles value
192
+ existing_status.remarks = status_data.remarks
193
+ existing_status.cleared_by = cleared_by_user_pk
194
+ existing_status.updated_at = current_time
195
+ db_model = existing_status
 
 
 
 
 
 
 
 
 
 
 
196
  else:
197
+ db_model = models.ClearanceStatus(
 
198
  student_id=status_data.student_id,
199
+ department=status_data.department, # Assign Pydantic enum member
200
+ status=status_data.status, # Assign Pydantic enum member
201
  remarks=status_data.remarks,
202
+ cleared_by=cleared_by_user_pk,
203
+ created_at=current_time,
204
  updated_at=current_time
205
  )
206
+ db.add(db_model)
207
+
208
+ db.commit()
209
+ db.refresh(db_model)
210
+ return db_model
 
 
211
 
212
+ def get_clearance_statuses_by_student_id(db: SQLAlchemySessionType, student_id_str: str) -> List[models.ClearanceStatus]:
213
+ return db.query(models.ClearanceStatus).filter(models.ClearanceStatus.student_id == student_id_str).all()
214
 
215
+ # --- Device CRUD (ORM) ---
216
+ def create_device(db: SQLAlchemySessionType, device_data: models.DeviceCreateAdmin) -> models.Device:
217
+ if device_data.device_id:
218
+ existing_device_hw_id = db.query(models.Device).filter(models.Device.device_id == device_data.device_id).first()
219
+ if existing_device_hw_id:
220
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Device with hardware ID '{device_data.device_id}' already exists.")
221
 
222
+ api_key = secrets.token_urlsafe(32)
223
+ db_device = models.Device(
224
+ name=device_data.name,
225
+ description=device_data.description,
226
+ api_key=api_key,
227
+ device_id=device_data.device_id,
228
+ location=device_data.location,
229
+ is_active=True,
230
+ created_at=datetime.utcnow(),
231
+ updated_at=datetime.utcnow()
232
+ )
233
+ db.add(db_device)
234
+ db.commit()
235
+ db.refresh(db_device)
236
+ return db_device
237
 
238
+ def register_device_esp(db: SQLAlchemySessionType, device_data: models.DeviceRegister) -> models.Device:
239
+ api_key = secrets.token_urlsafe(32)
 
240
  current_time = datetime.utcnow()
241
+ existing_device = db.query(models.Device).filter(models.Device.device_id == device_data.device_id).first()
 
 
 
242
 
243
  if existing_device:
244
+ existing_device.location = device_data.location
245
+ existing_device.api_key = api_key
246
+ existing_device.last_seen = current_time
247
+ existing_device.updated_at = current_time
248
+ existing_device.is_active = True
249
+ db_model = existing_device
 
250
  else:
251
+ db_model = models.Device(
252
+ device_id=device_data.device_id,
 
253
  location=device_data.location,
254
  api_key=api_key,
255
+ is_active=True,
256
+ last_seen=current_time,
257
+ created_at=current_time,
258
+ updated_at=current_time
259
  )
260
+ db.add(db_model)
261
+
262
+ db.commit()
263
+ db.refresh(db_model)
264
+ return db_model
265
+
266
+ def get_device_by_pk(db: SQLAlchemySessionType, device_pk: int) -> Optional[models.Device]:
267
+ return db.query(models.Device).filter(models.Device.id == device_pk).first()
268
+
269
+ def get_device_by_api_key(db: SQLAlchemySessionType, api_key: str) -> Optional[models.Device]:
270
+ return db.query(models.Device).filter(models.Device.api_key == api_key).first()
271
 
272
+ def get_device_by_hardware_id(db: SQLAlchemySessionType, hardware_id: str) -> Optional[models.Device]:
273
+ return db.query(models.Device).filter(models.Device.device_id == hardware_id).first()
 
 
 
274
 
275
+ def get_all_devices(db: SQLAlchemySessionType, skip: int = 0, limit: int = 100) -> List[models.Device]:
276
+ return db.query(models.Device).offset(skip).limit(limit).all()
277
 
278
+ def update_device_last_seen(db: SQLAlchemySessionType, device_pk: int):
279
+ device = get_device_by_pk(db, device_pk)
280
+ if device:
281
+ device.last_seen = datetime.utcnow()
282
+ device.updated_at = datetime.utcnow()
283
+ db.commit()
284
 
285
+ # --- Device Log CRUD (ORM) ---
286
+ def create_device_log(
287
+ db: SQLAlchemySessionType,
288
+ device_pk: Optional[int],
289
+ action: str,
290
+ scanned_tag_id: Optional[str] = None,
291
+ user_type: Optional[str] = "unknown", # This remains a string as UserRole doesn't cover 'unknown'
292
+ actual_device_id_str: Optional[str] = None
293
+ ):
294
+ db_log = models.DeviceLog(
295
+ device_fk_id=device_pk,
296
+ actual_device_id_str=actual_device_id_str,
297
+ tag_id_scanned=scanned_tag_id,
298
+ user_type=user_type,
299
+ action=action,
300
+ timestamp=datetime.utcnow()
301
+ )
302
+ db.add(db_log)
303
+ db.commit()
304
+ return db_log
305
+
306
+ # --- Pending Tag Link CRUD (ORM) ---
307
+ def create_pending_tag_link(
308
+ db: SQLAlchemySessionType,
309
+ device_pk: int,
310
+ target_user_type: models.TargetUserType, # Pydantic validated enum member
311
+ target_identifier: str,
312
+ initiated_by_user_pk: int,
313
+ expires_in_minutes: int = 5
314
+ ) -> models.PendingTagLink:
315
+
316
+ device = get_device_by_pk(db, device_pk)
317
+ if not device:
318
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Device with PK {device_pk} not found.")
319
+ if not device.is_active:
320
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Device '{device.name or device.device_id}' is not active.")
321
+
322
+ if target_user_type == models.TargetUserType.STUDENT:
323
+ student_target = get_student_by_student_id(db, target_identifier)
324
+ if not student_target:
325
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Target student with ID '{target_identifier}' not found.")
326
+ if student_target.tag_id:
327
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"Student '{target_identifier}' already has a tag linked.")
328
+ elif target_user_type == models.TargetUserType.STAFF_ADMIN:
329
+ user_target = get_user_by_username(db, target_identifier)
330
+ if not user_target:
331
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Target user with username '{target_identifier}' not found.")
332
+ if not user_target.is_active:
333
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Target user '{target_identifier}' is not active.")
334
+ if user_target.tag_id:
335
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"User '{target_identifier}' already has a tag linked.")
336
+
337
+ existing_link = db.query(models.PendingTagLink).filter(
338
+ models.PendingTagLink.device_id_fk == device_pk,
339
+ models.PendingTagLink.expires_at > datetime.utcnow()
340
+ ).first()
341
+ if existing_link:
342
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"Device '{device.name or device.device_id}' is already awaiting a tag scan.")
343
 
344
+ expires_at = datetime.utcnow() + timedelta(minutes=expires_in_minutes)
345
+ db_pending_link = models.PendingTagLink(
346
+ device_id_fk=device_pk,
347
+ target_user_type=target_user_type, # Assign Pydantic enum member
348
+ target_identifier=target_identifier,
349
+ initiated_by_user_id=initiated_by_user_pk,
350
+ expires_at=expires_at,
351
+ created_at=datetime.utcnow()
352
  )
353
+ db.add(db_pending_link)
354
+ db.commit()
355
+ db.refresh(db_pending_link)
356
+ return db_pending_link
357
+
358
+ def get_active_pending_tag_link_by_device_pk(db: SQLAlchemySessionType, device_pk: int) -> Optional[models.PendingTagLink]:
359
+ return db.query(models.PendingTagLink).filter(
360
+ models.PendingTagLink.device_id_fk == device_pk,
361
+ models.PendingTagLink.expires_at > datetime.utcnow()
362
+ ).order_by(models.PendingTagLink.created_at.desc()).first()
363
+
364
+ def delete_pending_tag_link(db: SQLAlchemySessionType, pending_link_pk: int):
365
+ link = db.query(models.PendingTagLink).filter(models.PendingTagLink.id == pending_link_pk).first()
366
+ if link:
367
+ db.delete(link)
368
+ db.commit()
src/database.py CHANGED
@@ -1,100 +1,99 @@
1
- import databases
2
  import sqlalchemy
3
  import os
4
  from dotenv import load_dotenv
5
  from datetime import datetime
6
 
 
 
 
 
 
 
 
 
 
 
7
  # Load environment variables from a .env file
8
  load_dotenv()
9
 
10
  # Database setup
11
- # Get the database URL from environment variables
12
- # IMPORTANT: Ensure your .env file has the correct DATABASE_URL for PostgreSQL
13
- # Example: DATABASE_URL="postgresql+asyncpg://user:password@host:port/database_name"
14
  DATABASE_URL = os.getenv("POSTGRES_URI") or os.getenv("DATABASE_URL")
15
 
16
  # Check if DATABASE_URL is set
17
  if not DATABASE_URL:
18
  raise ValueError("DATABASE_URL environment variable not set!")
19
 
20
- # Create a Database instance for asynchronous operations
21
- # The 'databases' library uses the connection string to determine the dialect and driver
22
- database = databases.Database(DATABASE_URL)
23
 
24
- # Create a MetaData object to hold the database schema
25
- metadata = sqlalchemy.MetaData()
26
 
27
- # Define the 'students' table using SQLAlchemy
28
- students = sqlalchemy.Table(
29
- "students",
30
- metadata,
31
- sqlalchemy.Column("id", sqlalchemy.Integer, primary_key=True),
32
- sqlalchemy.Column("student_id", sqlalchemy.String, unique=True, index=True), # Added index for faster lookups
33
- sqlalchemy.Column("name", sqlalchemy.String),
34
- sqlalchemy.Column("department", sqlalchemy.String),
35
- sqlalchemy.Column("tag_id", sqlalchemy.String, unique=True, index=True), # Added index
36
- )
37
 
38
- # Define the 'clearance_statuses' table using SQLAlchemy
39
- clearance_statuses = sqlalchemy.Table(
40
- "clearance_statuses",
41
- metadata,
42
- sqlalchemy.Column("id", sqlalchemy.Integer, primary_key=True),
43
- sqlalchemy.Column("student_id", sqlalchemy.String, index=True), # Added index
44
- sqlalchemy.Column("department", sqlalchemy.String, index=True), # Added index
45
- sqlalchemy.Column("status", sqlalchemy.Boolean, default=False),
46
- sqlalchemy.Column("remarks", sqlalchemy.String, nullable=True),
47
- sqlalchemy.Column("updated_at", sqlalchemy.DateTime, default=datetime.utcnow),
48
- # Add a unique constraint to prevent duplicate entries for the same student and department
49
- sqlalchemy.UniqueConstraint('student_id', 'department', name='uq_student_department')
50
- )
51
 
52
- # Define the 'device_logs' table to log device activity using SQLAlchemy
53
- device_logs = sqlalchemy.Table(
54
- "device_logs",
55
- metadata,
56
- sqlalchemy.Column("id", sqlalchemy.Integer, primary_key=True),
57
- sqlalchemy.Column("device_id", sqlalchemy.String, index=True), # Added index
58
- sqlalchemy.Column("tag_id", sqlalchemy.String, index=True), # Added index
59
- sqlalchemy.Column("timestamp", sqlalchemy.DateTime, default=datetime.utcnow),
60
- sqlalchemy.Column("action", sqlalchemy.String), # e.g., "scan", "register"
61
- )
 
62
 
63
- # Define the 'devices' table to manage registered ESP32 devices using SQLAlchemy (Location Removed)
64
- devices = sqlalchemy.Table(
65
- "devices",
66
- metadata,
67
- sqlalchemy.Column("id", sqlalchemy.Integer, primary_key=True),
68
- sqlalchemy.Column("device_id", sqlalchemy.String, unique=True, index=True), # Added index
69
- # Removed: sqlalchemy.Column("location", sqlalchemy.String),
70
- sqlalchemy.Column("api_key", sqlalchemy.String, unique=True, index=True), # Added index for quick API key lookups
71
- sqlalchemy.Column("last_seen", sqlalchemy.DateTime, nullable=True),
72
- )
73
 
74
- # Create a SQLAlchemy engine (used for creating tables - typically run once)
75
- # Note: For PostgreSQL, check_same_thread=False is not needed.
76
- # This engine is primarily used here for the metadata.create_all call.
77
- engine = sqlalchemy.create_engine(DATABASE_URL)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
 
79
- try:
80
- print("Attempting to create database tables (if they don't exist)...")
81
- metadata.create_all(engine)
82
- print("Database tables creation attempt finished.")
83
- except Exception as e:
84
- print(f"Error during database table creation: {e}")
85
- print("Please ensure your Supabase database is accessible and the connection string is correct.")
86
- print("You might need to manually create tables in Supabase using the provided SQL script.")
87
 
 
88
 
89
- # Database connection lifecycle events (to be called by FastAPI)
90
- async def connect_db():
91
- """Connects to the database on application startup."""
92
- print("Connecting to database...") # Added print statement for debugging
93
- await database.connect()
94
- print("Database connected.") # Added print statement for debugging
95
 
96
- async def disconnect_db():
97
- """Disconnects from the database on application shutdown."""
98
- print("Disconnecting from database...") # Added print statement for debugging
99
- await database.disconnect()
100
- print("Database disconnected.") # Added print statement for debugging
 
 
 
 
 
 
 
1
  import sqlalchemy
2
  import os
3
  from dotenv import load_dotenv
4
  from datetime import datetime
5
 
6
+ # Import Enums, Base, and specific ORM models needed for initialization
7
+ from src.models import (
8
+ UserRole, ClearanceDepartment, ClearanceStatusEnum, Base, TargetUserType,
9
+ Student as StudentORM, # Alias to avoid confusion if Student Pydantic model is also imported
10
+ ClearanceStatus as ClearanceStatusORM, # Alias for ORM model
11
+ User as UserORM,
12
+ Device as DeviceORM
13
+ )
14
+ from sqlalchemy.orm import Session as SQLAlchemySessionType
15
+
16
  # Load environment variables from a .env file
17
  load_dotenv()
18
 
19
  # Database setup
 
 
 
20
  DATABASE_URL = os.getenv("POSTGRES_URI") or os.getenv("DATABASE_URL")
21
 
22
  # Check if DATABASE_URL is set
23
  if not DATABASE_URL:
24
  raise ValueError("DATABASE_URL environment variable not set!")
25
 
26
+ # SQLAlchemy engine
27
+ engine = sqlalchemy.create_engine(DATABASE_URL)
 
28
 
29
+ metadata = Base.metadata
 
30
 
31
+ def create_db_tables():
32
+ """Creates database tables based on SQLAlchemy ORM metadata."""
33
+ try:
34
+ print("Attempting to create database tables (if they don't exist)...")
35
+ metadata.create_all(bind=engine) # Use Base.metadata from models.py
36
+ print("Database tables creation attempt finished.")
37
+ except Exception as e:
38
+ print(f"Error during database table creation: {e}")
39
+ print("Please ensure your database is accessible and the connection string is correct.")
 
40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
+ # Initialize default clearance statuses for new students (Synchronous ORM version)
43
+ def initialize_student_clearance_statuses_orm(db: SQLAlchemySessionType, student_id_str: str):
44
+ """
45
+ Creates default clearance status entries for all departments for a new student.
46
+ Uses SQLAlchemy ORM session.
47
+ """
48
+ created_rows_info = []
49
+ student = db.query(StudentORM).filter(StudentORM.student_id == student_id_str).first()
50
+ if not student:
51
+ print(f"Warning: Student {student_id_str} not found when trying to initialize clearance statuses.")
52
+ return
53
 
54
+ for dept_enum_member in ClearanceDepartment: # Iterate through centralized Enum
55
+ # Check if status already exists for this student and department
56
+ existing_status = db.query(ClearanceStatusORM).filter(
57
+ ClearanceStatusORM.student_id == student_id_str,
58
+ ClearanceStatusORM.department == dept_enum_member
59
+ ).first()
 
 
 
 
60
 
61
+ if not existing_status:
62
+ new_status = ClearanceStatusORM(
63
+ student_id=student_id_str,
64
+ department=dept_enum_member,
65
+ status=ClearanceStatusEnum.NOT_COMPLETED,
66
+ created_at=datetime.utcnow(),
67
+ updated_at=datetime.utcnow()
68
+ )
69
+ db.add(new_status)
70
+ created_rows_info.append({"department": dept_enum_member.value, "status": "initialized"})
71
+ else:
72
+ created_rows_info.append({"department": dept_enum_member.value, "status": "already_exists"})
73
+
74
+ if created_rows_info:
75
+ try:
76
+ db.commit()
77
+ print(f"Committed clearance statuses for student {student_id_str}: {created_rows_info}")
78
+ except Exception as e:
79
+ db.rollback()
80
+ print(f"Error committing clearance statuses for student {student_id_str}: {e}")
81
+ raise
82
+ else:
83
+ print(f"No new clearance statuses to initialize or commit for student {student_id_str}.")
84
 
 
 
 
 
 
 
 
 
85
 
86
+ from sqlalchemy.orm import sessionmaker
87
 
88
+ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
 
 
 
 
 
89
 
90
+ def get_db():
91
+ """
92
+ FastAPI dependency to get a SQLAlchemy database session.
93
+ Ensures the session is closed after the request.
94
+ """
95
+ db = SessionLocal()
96
+ try:
97
+ yield db
98
+ finally:
99
+ db.close()
src/models.py CHANGED
@@ -1,52 +1,235 @@
1
- from pydantic import BaseModel
2
- from typing import List, Optional
3
- from datetime import datetime
 
 
 
 
 
4
 
5
- # Pydantic models for data validation and serialization
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
  class StudentCreate(BaseModel):
8
- """Model for creating a new student."""
9
  student_id: str
10
  name: str
 
11
  department: str
12
- tag_id: str
13
 
14
- class Student(StudentCreate):
15
- """Model for returning student data, including database ID."""
16
  id: int
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
  class ClearanceStatusCreate(BaseModel):
19
- """Model for creating or updating a student's clearance status for a specific department."""
20
  student_id: str
21
- department: str
22
- status: bool
 
 
 
 
 
23
  remarks: Optional[str] = None
 
 
24
 
25
- class ClearanceStatus(ClearanceStatusCreate):
26
- """Model for returning clearance status data, including database ID and update timestamp."""
27
  id: int
 
 
 
 
 
28
  updated_at: datetime
 
 
29
 
30
  class ClearanceDetail(BaseModel):
31
- """Model for returning a student's full clearance details."""
32
  student_id: str
33
  name: str
34
  department: str
35
- clearance_items: List[dict] # List of dictionaries representing each clearance item
36
- overall_status: bool
 
37
 
38
  class DeviceRegister(BaseModel):
39
- """Model for registering a new ESP32 device."""
40
  device_id: str
41
  location: str
42
 
43
- class DeviceResponse(DeviceRegister):
44
- """Model for returning device registration details, including the API key."""
 
 
 
 
 
 
 
 
 
45
  api_key: str
 
 
 
 
 
 
46
 
47
  class TagScan(BaseModel):
48
- """Model for data received from an ESP32 device after scanning a tag."""
49
  device_id: str
50
  tag_id: str
51
- api_key: str
52
- timestamp: datetime
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 must EXACTLY match the labels in the PostgreSQL ENUM types, case-sensitively.
17
+ class UserRole(str, enum.Enum):
18
+ # This one works with lowercase, so we keep it.
19
+ STUDENT = "student"
20
+ STAFF = "staff"
21
+ ADMIN = "admin"
22
+
23
+ class ClearanceStatusEnum(str, enum.Enum):
24
+ # Assuming this enum in the DB uses capitalized labels
25
+ COMPLETED = "COMPLETED"
26
+ NOT_COMPLETED = "NOT_COMPLETED"
27
+ PENDING = "PENDING"
28
+
29
+ class ClearanceDepartment(str, enum.Enum):
30
+ DEPARTMENTAL = "DEPARTMENTAL"
31
+ LIBRARY = "LIBRARY"
32
+ BURSARY = "BURSARY"
33
+ ALUMNI = "ALUMNI"
34
+ class TargetUserType(str, enum.Enum):
35
+ # Assuming this one is lowercase, a common convention
36
+ STUDENT = "student"
37
+ STAFF_ADMIN = "staff_admin"
38
+
39
+ class OverallClearanceStatusEnum(str, enum.Enum):
40
+ # These are used within the API logic and may not correspond to a DB type
41
+ COMPLETED = "COMPLETED"
42
+ PENDING = "PENDING"
43
+
44
+ # Helper for SQLAlchemyEnum to ensure values are used
45
+ def enum_values_callable(obj):
46
+ return [e.value for e in obj]
47
+
48
+ # --- ORM Model Definitions ---
49
+
50
+ class Student(Base):
51
+ __tablename__ = "students"
52
+ id = Column(Integer, primary_key=True, index=True)
53
+ student_id = Column(String, unique=True, index=True, nullable=False)
54
+ name = Column(String, nullable=False)
55
+ email = Column(String, unique=True, index=True, nullable=True)
56
+ department = Column(String, nullable=False)
57
+ tag_id = Column(String, unique=True, index=True, nullable=True)
58
+ created_at = Column(DateTime, default=datetime.utcnow)
59
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
60
+
61
+ class User(Base):
62
+ __tablename__ = "users"
63
+ id = Column(Integer, primary_key=True, index=True)
64
+ username = Column(String, unique=True, index=True, nullable=False)
65
+ hashed_password = Column(String, nullable=False)
66
+ role = Column(SQLAlchemyEnum(UserRole, name="userrole", create_type=False, values_callable=enum_values_callable), default=UserRole.STAFF, nullable=False)
67
+ department = Column(SQLAlchemyEnum(ClearanceDepartment, name="clearancedepartment", create_type=False, values_callable=enum_values_callable), nullable=True)
68
+ tag_id = Column(String, unique=True, index=True, nullable=True)
69
+ is_active = Column(Boolean, default=True)
70
+ created_at = Column(DateTime, default=datetime.utcnow)
71
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
72
+
73
+ class ClearanceStatus(Base):
74
+ __tablename__ = "clearance_statuses"
75
+ id = Column(Integer, primary_key=True, autoincrement=True, index=True)
76
+ student_id = Column(String, ForeignKey("students.student_id"), index=True, nullable=False)
77
+ department = Column(SQLAlchemyEnum(ClearanceDepartment, name="clearancedepartment", create_type=False, values_callable=enum_values_callable), index=True, nullable=False)
78
+ status = Column(SQLAlchemyEnum(ClearanceStatusEnum, name="clearancestatusenum", create_type=False, values_callable=enum_values_callable), default=ClearanceStatusEnum.NOT_COMPLETED, nullable=False)
79
+ remarks = Column(String, nullable=True)
80
+ cleared_by = Column(Integer, ForeignKey("users.id"), nullable=True)
81
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
82
+ created_at = Column(DateTime, default=datetime.utcnow)
83
+
84
+ class Device(Base):
85
+ __tablename__ = "devices"
86
+ id = Column(Integer, primary_key=True, index=True, autoincrement=True)
87
+ name = Column(String, index=True, nullable=True)
88
+ device_id = Column(String, unique=True, index=True, nullable=True)
89
+ location = Column(String, nullable=True)
90
+ api_key = Column(String, unique=True, index=True, nullable=False)
91
+ is_active = Column(Boolean, default=True)
92
+ description = Column(String, nullable=True)
93
+ created_at = Column(DateTime, default=datetime.utcnow)
94
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
95
+ last_seen = Column(DateTime, nullable=True)
96
+
97
+ class PendingTagLink(Base):
98
+ __tablename__ = "pending_tag_links"
99
+ id = Column(Integer, primary_key=True, index=True, autoincrement=True)
100
+ device_id_fk = Column(Integer, ForeignKey("devices.id"), index=True, nullable=False, name="device_id")
101
+ target_user_type = Column(SQLAlchemyEnum(TargetUserType, name="targetusertype", create_type=False, values_callable=enum_values_callable), nullable=False)
102
+ target_identifier = Column(String, nullable=False)
103
+ initiated_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
104
+ created_at = Column(DateTime, default=datetime.utcnow)
105
+ expires_at = Column(DateTime, nullable=False)
106
+
107
+ class DeviceLog(Base):
108
+ __tablename__ = "device_logs"
109
+ id = Column(Integer, primary_key=True, index=True, autoincrement=True)
110
+ device_fk_id = Column(Integer, ForeignKey("devices.id"), index=True, nullable=True, name="device_id")
111
+ actual_device_id_str = Column(String, index=True, nullable=True)
112
+ tag_id_scanned = Column(String, index=True, nullable=True)
113
+ user_type = Column(String, nullable=True)
114
+ action = Column(String, nullable=False)
115
+ timestamp = Column(DateTime, default=datetime.utcnow, nullable=False)
116
+
117
+ # --- Pydantic Models ---
118
 
119
  class StudentCreate(BaseModel):
 
120
  student_id: str
121
  name: str
122
+ email: Optional[EmailStr] = None
123
  department: str
124
+ tag_id: Optional[str] = None
125
 
126
+ class StudentResponse(BaseModel):
 
127
  id: int
128
+ student_id: str
129
+ name: str
130
+ email: Optional[EmailStr] = None
131
+ department: str
132
+ tag_id: Optional[str] = None
133
+ created_at: datetime
134
+ updated_at: datetime
135
+ class Config: from_attributes = True
136
+
137
+ class UserBase(BaseModel):
138
+ username: str
139
+ role: UserRole
140
+ department: Optional[ClearanceDepartment] = None
141
+ tag_id: Optional[str] = None
142
+ is_active: Optional[bool] = True
143
+
144
+ class UserCreate(UserBase):
145
+ password: str
146
+
147
+ class UserResponse(BaseModel):
148
+ id: int
149
+ username: str
150
+ role: UserRole
151
+ department: Optional[ClearanceDepartment] = None
152
+ tag_id: Optional[str] = None
153
+ is_active: bool
154
+ created_at: datetime
155
+ updated_at: datetime
156
+ class Config: from_attributes = True
157
 
158
  class ClearanceStatusCreate(BaseModel):
 
159
  student_id: str
160
+ department: ClearanceDepartment
161
+ status: ClearanceStatusEnum
162
+ remarks: Optional[str] = None
163
+
164
+ class ClearanceStatusItem(BaseModel):
165
+ department: ClearanceDepartment
166
+ status: ClearanceStatusEnum
167
  remarks: Optional[str] = None
168
+ updated_at: datetime
169
+ class Config: from_attributes = True
170
 
171
+ class ClearanceStatusResponse(BaseModel):
 
172
  id: int
173
+ student_id: str
174
+ department: ClearanceDepartment
175
+ status: ClearanceStatusEnum
176
+ remarks: Optional[str] = None
177
+ cleared_by: Optional[int] = None
178
  updated_at: datetime
179
+ created_at: datetime
180
+ class Config: from_attributes = True
181
 
182
  class ClearanceDetail(BaseModel):
 
183
  student_id: str
184
  name: str
185
  department: str
186
+ clearance_items: List[ClearanceStatusItem]
187
+ overall_status: OverallClearanceStatusEnum
188
+ class Config: from_attributes = True
189
 
190
  class DeviceRegister(BaseModel):
 
191
  device_id: str
192
  location: str
193
 
194
+ class DeviceCreateAdmin(BaseModel):
195
+ name: str
196
+ description: Optional[str] = None
197
+ device_id: Optional[str] = None
198
+ location: Optional[str] = None
199
+
200
+ class DeviceResponse(BaseModel):
201
+ id: int
202
+ name: Optional[str] = None
203
+ device_id: Optional[str] = None
204
+ location: Optional[str] = None
205
  api_key: str
206
+ is_active: bool
207
+ description: Optional[str] = None
208
+ created_at: datetime
209
+ updated_at: datetime
210
+ last_seen: Optional[datetime] = None
211
+ class Config: from_attributes = True
212
 
213
  class TagScan(BaseModel):
 
214
  device_id: str
215
  tag_id: str
216
+ timestamp: Optional[datetime] = Field(default_factory=datetime.utcnow)
217
+
218
+ class Token(BaseModel):
219
+ access_token: str
220
+ token_type: str
221
+
222
+ class TokenData(BaseModel):
223
+ username: Optional[str] = None
224
+ role: Optional[UserRole] = None
225
+
226
+ class TagLinkRequest(BaseModel):
227
+ tag_id: str
228
+
229
+ class PrepareTagLinkRequest(BaseModel):
230
+ device_identifier: str
231
+ target_user_type: TargetUserType
232
+ target_identifier: str
233
+
234
+ class ScannedTagSubmit(BaseModel):
235
+ scanned_tag_id: str
src/routers/__init__.py ADDED
File without changes
src/routers/admin.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
src/routers/clearance.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
src/routers/devices.py ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
src/routers/students.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
src/routers/token.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 # 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"}
src/routers/users.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
75
+
76
+ @router.get("/users/me", response_model=models.UserResponse, summary="Get current authenticated user details")
77
+ async def read_users_me(
78
+ current_user_orm: models.User = Depends(get_current_active_user) # Depends on token auth
79
+ ):
80
+ """
81
+ Returns the details of the currently authenticated user (via token).
82
+ The user object is already an ORM model instance.
83
+ Pydantic's UserResponse will convert it using from_attributes=True.
84
+ """
85
+ return current_user_orm