Spaces:
Sleeping
Sleeping
initial commit
Browse files- .gitignore +12 -0
- Dockerfile +16 -0
- app/__init__.py +0 -0
- app/auth.py +57 -0
- app/crud.py +41 -0
- app/database.py +26 -0
- app/models.py +46 -0
- app/routers/__init__.py +0 -0
- app/routers/auth.py +39 -0
- app/routers/student.py +56 -0
- app/routers/teacher.py +180 -0
- app/schemas.py +56 -0
- app/settings.py +14 -0
- main.py +35 -0
- requirements.txt +11 -0
.gitignore
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
*.pyo
|
| 4 |
+
*.pyd
|
| 5 |
+
*.db
|
| 6 |
+
*.sqlite3
|
| 7 |
+
*.log
|
| 8 |
+
*.env
|
| 9 |
+
*.venv
|
| 10 |
+
*.egg-info/
|
| 11 |
+
dist/
|
| 12 |
+
build/
|
Dockerfile
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12.7
|
| 2 |
+
WORKDIR /code
|
| 3 |
+
COPY ./requirements.txt /code/requirements.txt
|
| 4 |
+
RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
|
| 5 |
+
|
| 6 |
+
RUN useradd user
|
| 7 |
+
USER user
|
| 8 |
+
|
| 9 |
+
ENV HOME=/home/user \
|
| 10 |
+
PATH=/home/user/.local/bin:$PATH
|
| 11 |
+
|
| 12 |
+
WORKDIR $HOME/app
|
| 13 |
+
|
| 14 |
+
COPY --chown=user . $HOME/app
|
| 15 |
+
|
| 16 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
app/__init__.py
ADDED
|
File without changes
|
app/auth.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime, timedelta, timezone
|
| 2 |
+
from typing import Optional
|
| 3 |
+
from fastapi import Depends, HTTPException, status
|
| 4 |
+
from fastapi.security import OAuth2PasswordBearer
|
| 5 |
+
from jose import JWTError, jwt
|
| 6 |
+
from passlib.context import CryptContext
|
| 7 |
+
from sqlalchemy.orm import Session
|
| 8 |
+
|
| 9 |
+
from . import crud, models, schemas
|
| 10 |
+
from .database import get_db
|
| 11 |
+
from .settings import settings
|
| 12 |
+
|
| 13 |
+
# Password Hashing
|
| 14 |
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 15 |
+
|
| 16 |
+
# OAuth2 Scheme
|
| 17 |
+
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")
|
| 18 |
+
|
| 19 |
+
def verify_password(plain_password, hashed_password):
|
| 20 |
+
return pwd_context.verify(plain_password, hashed_password)
|
| 21 |
+
|
| 22 |
+
def get_password_hash(password):
|
| 23 |
+
return pwd_context.hash(password)
|
| 24 |
+
|
| 25 |
+
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
| 26 |
+
to_encode = data.copy()
|
| 27 |
+
if expires_delta:
|
| 28 |
+
expire = datetime.now(timezone.utc) + expires_delta
|
| 29 |
+
else:
|
| 30 |
+
expire = datetime.now(timezone.utc) + timedelta(minutes=15)
|
| 31 |
+
to_encode.update({"exp": expire})
|
| 32 |
+
encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
|
| 33 |
+
return encoded_jwt
|
| 34 |
+
|
| 35 |
+
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
|
| 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, settings.secret_key, algorithms=[settings.algorithm])
|
| 43 |
+
username: str = payload.get("sub")
|
| 44 |
+
if username is None:
|
| 45 |
+
raise credentials_exception
|
| 46 |
+
token_data = schemas.TokenData(username=username)
|
| 47 |
+
except JWTError:
|
| 48 |
+
raise credentials_exception
|
| 49 |
+
user = crud.get_user_by_username(db, username=token_data.username)
|
| 50 |
+
if user is None:
|
| 51 |
+
raise credentials_exception
|
| 52 |
+
return user
|
| 53 |
+
|
| 54 |
+
def get_current_active_teacher(current_user: models.User = Depends(get_current_user)):
|
| 55 |
+
if current_user.role != models.UserRole.teacher:
|
| 56 |
+
raise HTTPException(status_code=403, detail="Not authorized")
|
| 57 |
+
return current_user
|
app/crud.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy.orm import Session
|
| 2 |
+
from . import models, schemas, auth
|
| 3 |
+
|
| 4 |
+
def get_user_by_username(db: Session, username: str):
|
| 5 |
+
return db.query(models.User).filter(models.User.username == username).first()
|
| 6 |
+
|
| 7 |
+
def create_user(db: Session, user: schemas.UserCreate):
|
| 8 |
+
hashed_password = auth.get_password_hash(user.password)
|
| 9 |
+
db_user = models.User(
|
| 10 |
+
username=user.username,
|
| 11 |
+
hashed_password=hashed_password,
|
| 12 |
+
full_name=user.full_name,
|
| 13 |
+
role=user.role
|
| 14 |
+
)
|
| 15 |
+
db.add(db_user)
|
| 16 |
+
db.commit()
|
| 17 |
+
db.refresh(db_user)
|
| 18 |
+
return db_user
|
| 19 |
+
|
| 20 |
+
def get_lectures_by_day(db: Session, day_of_week: int, teacher_id: int):
|
| 21 |
+
return db.query(models.TimetableSlot).filter(
|
| 22 |
+
models.TimetableSlot.day_of_week == day_of_week,
|
| 23 |
+
models.TimetableSlot.teacher_id == teacher_id
|
| 24 |
+
).all()
|
| 25 |
+
|
| 26 |
+
def check_if_already_marked(db: Session, student_id: int, session_id: str):
|
| 27 |
+
return db.query(models.AttendanceRecord).filter(
|
| 28 |
+
models.AttendanceRecord.student_id == student_id,
|
| 29 |
+
models.AttendanceRecord.session_id == session_id
|
| 30 |
+
).first() is not None
|
| 31 |
+
|
| 32 |
+
def create_attendance_record(db: Session, student_id: int, slot_id: int, session_id: str):
|
| 33 |
+
db_record = models.AttendanceRecord(
|
| 34 |
+
student_id=student_id,
|
| 35 |
+
slot_id=slot_id,
|
| 36 |
+
session_id=session_id
|
| 37 |
+
)
|
| 38 |
+
db.add(db_record)
|
| 39 |
+
db.commit()
|
| 40 |
+
db.refresh(db_record)
|
| 41 |
+
return db_record
|
app/database.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import create_engine
|
| 2 |
+
from sqlalchemy.ext.declarative import declarative_base
|
| 3 |
+
from sqlalchemy.orm import sessionmaker
|
| 4 |
+
import redis
|
| 5 |
+
from .settings import settings
|
| 6 |
+
|
| 7 |
+
# PostgreSQL Setup
|
| 8 |
+
engine = create_engine(settings.database_url)
|
| 9 |
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 10 |
+
Base = declarative_base()
|
| 11 |
+
|
| 12 |
+
# Redis Setup
|
| 13 |
+
redis_client = redis.from_url(settings.redis_url, decode_responses=True)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
# Dependency to get a DB session
|
| 17 |
+
def get_db():
|
| 18 |
+
db = SessionLocal()
|
| 19 |
+
try:
|
| 20 |
+
yield db
|
| 21 |
+
finally:
|
| 22 |
+
db.close()
|
| 23 |
+
|
| 24 |
+
# Dependency to get a Redis client
|
| 25 |
+
def get_redis():
|
| 26 |
+
return redis_client
|
app/models.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import Column, Integer, String, ForeignKey, Time, DateTime, Enum as SQLAlchemyEnum
|
| 2 |
+
from sqlalchemy.orm import relationship
|
| 3 |
+
from sqlalchemy.sql import func
|
| 4 |
+
import enum
|
| 5 |
+
from .database import Base
|
| 6 |
+
|
| 7 |
+
class UserRole(enum.Enum):
|
| 8 |
+
teacher = "teacher"
|
| 9 |
+
student = "student"
|
| 10 |
+
|
| 11 |
+
class User(Base):
|
| 12 |
+
__tablename__ = "users"
|
| 13 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 14 |
+
username = Column(String, unique=True, index=True, nullable=False)
|
| 15 |
+
hashed_password = Column(String, nullable=False)
|
| 16 |
+
full_name = Column(String, nullable=False)
|
| 17 |
+
role = Column(SQLAlchemyEnum(UserRole), nullable=False)
|
| 18 |
+
|
| 19 |
+
class Subject(Base):
|
| 20 |
+
__tablename__ = "subjects"
|
| 21 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 22 |
+
name = Column(String, nullable=False)
|
| 23 |
+
code = Column(String, unique=True, nullable=False)
|
| 24 |
+
|
| 25 |
+
class TimetableSlot(Base):
|
| 26 |
+
__tablename__ = "timetable_slots"
|
| 27 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 28 |
+
subject_id = Column(Integer, ForeignKey("subjects.id"), nullable=False)
|
| 29 |
+
teacher_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
| 30 |
+
day_of_week = Column(Integer, nullable=False) # 0=Monday, 1=Tuesday...
|
| 31 |
+
start_time = Column(Time, nullable=False)
|
| 32 |
+
end_time = Column(Time, nullable=False)
|
| 33 |
+
|
| 34 |
+
subject = relationship("Subject")
|
| 35 |
+
teacher = relationship("User")
|
| 36 |
+
|
| 37 |
+
class AttendanceRecord(Base):
|
| 38 |
+
__tablename__ = "attendance_records"
|
| 39 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 40 |
+
student_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
| 41 |
+
slot_id = Column(Integer, ForeignKey("timetable_slots.id"), nullable=False)
|
| 42 |
+
session_id = Column(String, nullable=False, index=True)
|
| 43 |
+
timestamp = Column(DateTime(timezone=True), server_default=func.now())
|
| 44 |
+
|
| 45 |
+
student = relationship("User")
|
| 46 |
+
slot = relationship("TimetableSlot")
|
app/routers/__init__.py
ADDED
|
File without changes
|
app/routers/auth.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import timedelta
|
| 2 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 3 |
+
from fastapi.security import OAuth2PasswordRequestForm
|
| 4 |
+
from sqlalchemy.orm import Session
|
| 5 |
+
|
| 6 |
+
from .. import auth, crud, schemas, models
|
| 7 |
+
from ..database import get_db
|
| 8 |
+
from ..settings import settings
|
| 9 |
+
|
| 10 |
+
router = APIRouter(
|
| 11 |
+
prefix="/auth",
|
| 12 |
+
tags=["Authentication"],
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
@router.post("/token", response_model=schemas.Token)
|
| 16 |
+
def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
|
| 17 |
+
user = crud.get_user_by_username(db, username=form_data.username)
|
| 18 |
+
if not user or not auth.verify_password(form_data.password, user.hashed_password):
|
| 19 |
+
raise HTTPException(
|
| 20 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 21 |
+
detail="Incorrect username or password",
|
| 22 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 23 |
+
)
|
| 24 |
+
access_token_expires = timedelta(minutes=settings.access_token_expire_minutes)
|
| 25 |
+
access_token = auth.create_access_token(
|
| 26 |
+
data={"sub": user.username}, expires_delta=access_token_expires
|
| 27 |
+
)
|
| 28 |
+
return {"access_token": access_token, "token_type": "bearer"}
|
| 29 |
+
|
| 30 |
+
@router.get("/me", response_model=schemas.User)
|
| 31 |
+
def read_users_me(current_user: models.User = Depends(auth.get_current_user)):
|
| 32 |
+
"""
|
| 33 |
+
Get the current logged-in user's details.
|
| 34 |
+
|
| 35 |
+
This endpoint is protected. It uses the `get_current_user` dependency
|
| 36 |
+
to validate the JWT token from the Authorization header and return
|
| 37 |
+
the corresponding user's data from the database.
|
| 38 |
+
"""
|
| 39 |
+
return current_user
|
app/routers/student.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 2 |
+
from redis import Redis
|
| 3 |
+
from sqlalchemy.orm import Session
|
| 4 |
+
import json
|
| 5 |
+
|
| 6 |
+
from .. import auth, crud, models, schemas
|
| 7 |
+
from ..database import get_db, get_redis
|
| 8 |
+
from .teacher import manager # Import the connection manager
|
| 9 |
+
|
| 10 |
+
router = APIRouter(
|
| 11 |
+
prefix="/student",
|
| 12 |
+
tags=["Student"],
|
| 13 |
+
dependencies=[Depends(auth.get_current_user)]
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
@router.post("/attendance/scan")
|
| 17 |
+
async def scan_attendance(
|
| 18 |
+
payload: schemas.AttendanceScanPayload,
|
| 19 |
+
db: Session = Depends(get_db),
|
| 20 |
+
redis_client: Redis = Depends(get_redis),
|
| 21 |
+
current_user: models.User = Depends(auth.get_current_user)
|
| 22 |
+
):
|
| 23 |
+
if current_user.role != models.UserRole.student:
|
| 24 |
+
raise HTTPException(status_code=403, detail="Only students can scan for attendance")
|
| 25 |
+
|
| 26 |
+
# 1. Verify Token
|
| 27 |
+
valid_token = redis_client.get(f"qr_token:{payload.session_id}")
|
| 28 |
+
if not valid_token or valid_token != payload.token:
|
| 29 |
+
raise HTTPException(status_code=400, detail="Invalid or expired QR code")
|
| 30 |
+
|
| 31 |
+
# 2. Get lecture ID for this session
|
| 32 |
+
slot_id = redis_client.get(f"session_slot:{payload.session_id}")
|
| 33 |
+
if not slot_id:
|
| 34 |
+
raise HTTPException(status_code=404, detail="Attendance session not found")
|
| 35 |
+
|
| 36 |
+
# 3. Check for duplicates
|
| 37 |
+
if crud.check_if_already_marked(db, student_id=current_user.id, session_id=payload.session_id):
|
| 38 |
+
raise HTTPException(status_code=409, detail="Attendance already marked for this session")
|
| 39 |
+
|
| 40 |
+
# 4. Mark attendance
|
| 41 |
+
record = crud.create_attendance_record(
|
| 42 |
+
db,
|
| 43 |
+
student_id=current_user.id,
|
| 44 |
+
slot_id=int(slot_id),
|
| 45 |
+
session_id=payload.session_id
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
# 5. Notify teacher via WebSocket
|
| 49 |
+
student_data = {
|
| 50 |
+
"id": current_user.id,
|
| 51 |
+
"name": current_user.full_name,
|
| 52 |
+
"email": current_user.username
|
| 53 |
+
}
|
| 54 |
+
await manager.broadcast_to_session(json.dumps(student_data), payload.session_id)
|
| 55 |
+
|
| 56 |
+
return {"status": "success", "message": "Attendance marked successfully"}
|
app/routers/teacher.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# import uuid
|
| 2 |
+
# import asyncio
|
| 3 |
+
# from datetime import date
|
| 4 |
+
# from typing import List, Dict
|
| 5 |
+
|
| 6 |
+
# from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect, HTTPException
|
| 7 |
+
# from redis import Redis
|
| 8 |
+
# from sqlalchemy.orm import Session
|
| 9 |
+
|
| 10 |
+
# from .. import auth, crud, models, schemas
|
| 11 |
+
# from ..database import get_db, get_redis
|
| 12 |
+
|
| 13 |
+
# router = APIRouter(
|
| 14 |
+
# prefix="/teacher",
|
| 15 |
+
# tags=["Teacher"],
|
| 16 |
+
# dependencies=[Depends(auth.get_current_active_teacher)]
|
| 17 |
+
# )
|
| 18 |
+
|
| 19 |
+
# class ConnectionManager:
|
| 20 |
+
# def __init__(self):
|
| 21 |
+
# self.active_connections: Dict[str, WebSocket] = {}
|
| 22 |
+
|
| 23 |
+
# async def connect(self, websocket: WebSocket, session_id: str):
|
| 24 |
+
# await websocket.accept()
|
| 25 |
+
# self.active_connections[session_id] = websocket
|
| 26 |
+
|
| 27 |
+
# def disconnect(self, session_id: str):
|
| 28 |
+
# if session_id in self.active_connections:
|
| 29 |
+
# del self.active_connections[session_id]
|
| 30 |
+
|
| 31 |
+
# async def send_personal_message(self, message: str, websocket: WebSocket):
|
| 32 |
+
# await websocket.send_text(message)
|
| 33 |
+
|
| 34 |
+
# async def broadcast_to_session(self, message: str, session_id: str):
|
| 35 |
+
# if session_id in self.active_connections:
|
| 36 |
+
# await self.active_connections[session_id].send_text(message)
|
| 37 |
+
|
| 38 |
+
# manager = ConnectionManager()
|
| 39 |
+
|
| 40 |
+
# @router.get("/lectures", response_model=List[schemas.TimetableSlot])
|
| 41 |
+
# def get_teacher_lectures_for_date(
|
| 42 |
+
# selected_date: date,
|
| 43 |
+
# db: Session = Depends(get_db),
|
| 44 |
+
# current_user: models.User = Depends(auth.get_current_active_teacher)
|
| 45 |
+
# ):
|
| 46 |
+
# day_of_week = selected_date.weekday()
|
| 47 |
+
# return crud.get_lectures_by_day(db, day_of_week=day_of_week, teacher_id=current_user.id)
|
| 48 |
+
|
| 49 |
+
# @router.post("/attendance/start")
|
| 50 |
+
# def start_attendance_session(
|
| 51 |
+
# slot_id: int,
|
| 52 |
+
# redis_client: Redis = Depends(get_redis)
|
| 53 |
+
# ):
|
| 54 |
+
# session_id = str(uuid.uuid4())
|
| 55 |
+
# token = str(uuid.uuid4())
|
| 56 |
+
|
| 57 |
+
# # Store token and associated lecture (slot_id) in Redis
|
| 58 |
+
# redis_client.set(f"qr_token:{session_id}", token, ex=20) # ex=20 -> expires in 20 seconds
|
| 59 |
+
# redis_client.set(f"session_slot:{session_id}", slot_id)
|
| 60 |
+
|
| 61 |
+
# return {"session_id": session_id, "initial_token": token}
|
| 62 |
+
|
| 63 |
+
# @router.get("/attendance/qr/{session_id}")
|
| 64 |
+
# def refresh_qr_token(session_id: str, redis_client: Redis = Depends(get_redis)):
|
| 65 |
+
# if not redis_client.exists(f"session_slot:{session_id}"):
|
| 66 |
+
# raise HTTPException(status_code=404, detail="Attendance session not found or expired")
|
| 67 |
+
|
| 68 |
+
# new_token = str(uuid.uuid4())
|
| 69 |
+
# redis_client.set(f"qr_token:{session_id}", new_token, ex=20)
|
| 70 |
+
# return {"token": new_token}
|
| 71 |
+
|
| 72 |
+
# @router.websocket("/ws/attendance/{session_id}")
|
| 73 |
+
# async def websocket_endpoint(websocket: WebSocket, session_id: str):
|
| 74 |
+
# await manager.connect(websocket, session_id)
|
| 75 |
+
# try:
|
| 76 |
+
# while True:
|
| 77 |
+
# # Keep connection alive
|
| 78 |
+
# await asyncio.sleep(1)
|
| 79 |
+
# except WebSocketDisconnect:
|
| 80 |
+
# manager.disconnect(session_id)
|
| 81 |
+
|
| 82 |
+
import uuid
|
| 83 |
+
import asyncio
|
| 84 |
+
from datetime import date
|
| 85 |
+
from typing import List, Dict
|
| 86 |
+
|
| 87 |
+
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect, HTTPException, Query, status
|
| 88 |
+
from redis import Redis
|
| 89 |
+
from sqlalchemy.orm import Session
|
| 90 |
+
|
| 91 |
+
from .. import auth, crud, models, schemas
|
| 92 |
+
from ..database import get_db, get_redis
|
| 93 |
+
|
| 94 |
+
# --- NEW: WebSocket-specific authenticator ---
|
| 95 |
+
# This function will be used ONLY by the websocket endpoint.
|
| 96 |
+
# It looks for the token in a query parameter instead of a header.
|
| 97 |
+
async def get_current_user_from_query(
|
| 98 |
+
token: str = Query(...),
|
| 99 |
+
db: Session = Depends(get_db)
|
| 100 |
+
):
|
| 101 |
+
user = auth.get_current_user(token=token, db=db)
|
| 102 |
+
if user.role != models.UserRole.teacher:
|
| 103 |
+
raise WebSocketDisconnect(code=status.WS_1008_POLICY_VIOLATION, reason="Not authorized")
|
| 104 |
+
return user
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
# --- MODIFIED: Router definition ---
|
| 108 |
+
# We REMOVE the global dependency from the router itself.
|
| 109 |
+
router = APIRouter(
|
| 110 |
+
prefix="/teacher",
|
| 111 |
+
tags=["Teacher"]
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
class ConnectionManager:
|
| 115 |
+
def __init__(self):
|
| 116 |
+
self.active_connections: Dict[str, WebSocket] = {}
|
| 117 |
+
|
| 118 |
+
async def connect(self, websocket: WebSocket, session_id: str):
|
| 119 |
+
await websocket.accept()
|
| 120 |
+
self.active_connections[session_id] = websocket
|
| 121 |
+
|
| 122 |
+
def disconnect(self, session_id: str):
|
| 123 |
+
if session_id in self.active_connections:
|
| 124 |
+
del self.active_connections[session_id]
|
| 125 |
+
|
| 126 |
+
async def broadcast_to_session(self, message: str, session_id: str):
|
| 127 |
+
if session_id in self.active_connections:
|
| 128 |
+
await self.active_connections[session_id].send_text(message)
|
| 129 |
+
|
| 130 |
+
manager = ConnectionManager()
|
| 131 |
+
|
| 132 |
+
# --- MODIFIED: Add the dependency directly to the HTTP routes ---
|
| 133 |
+
@router.get("/lectures", response_model=List[schemas.TimetableSlot], dependencies=[Depends(auth.get_current_active_teacher)])
|
| 134 |
+
def get_teacher_lectures_for_date(
|
| 135 |
+
selected_date: date,
|
| 136 |
+
db: Session = Depends(get_db),
|
| 137 |
+
current_user: models.User = Depends(auth.get_current_active_teacher)
|
| 138 |
+
):
|
| 139 |
+
day_of_week = selected_date.weekday()
|
| 140 |
+
return crud.get_lectures_by_day(db, day_of_week=day_of_week, teacher_id=current_user.id)
|
| 141 |
+
|
| 142 |
+
@router.post("/attendance/start", dependencies=[Depends(auth.get_current_active_teacher)])
|
| 143 |
+
def start_attendance_session(
|
| 144 |
+
slot_id: int,
|
| 145 |
+
redis_client: Redis = Depends(get_redis)
|
| 146 |
+
):
|
| 147 |
+
session_id = str(uuid.uuid4())
|
| 148 |
+
token = str(uuid.uuid4())
|
| 149 |
+
|
| 150 |
+
redis_client.set(f"qr_token:{session_id}", token, ex=20)
|
| 151 |
+
redis_client.set(f"session_slot:{session_id}", slot_id)
|
| 152 |
+
|
| 153 |
+
return {"session_id": session_id, "initial_token": token}
|
| 154 |
+
|
| 155 |
+
@router.get("/attendance/qr/{session_id}", dependencies=[Depends(auth.get_current_active_teacher)])
|
| 156 |
+
def refresh_qr_token(session_id: str, redis_client: Redis = Depends(get_redis)):
|
| 157 |
+
if not redis_client.exists(f"session_slot:{session_id}"):
|
| 158 |
+
raise HTTPException(status_code=404, detail="Attendance session not found or expired")
|
| 159 |
+
|
| 160 |
+
new_token = str(uuid.uuid4())
|
| 161 |
+
redis_client.set(f"qr_token:{session_id}", new_token, ex=20)
|
| 162 |
+
print( f"Refreshed token for session {session_id}: {new_token}" )
|
| 163 |
+
return {"token": new_token}
|
| 164 |
+
|
| 165 |
+
# --- MODIFIED: WebSocket endpoint now uses its own authenticator ---
|
| 166 |
+
@router.websocket("/ws/attendance/{session_id}")
|
| 167 |
+
async def websocket_endpoint(
|
| 168 |
+
websocket: WebSocket,
|
| 169 |
+
session_id: str,
|
| 170 |
+
# This dependency will now be resolved from the query parameter
|
| 171 |
+
user: models.User = Depends(get_current_user_from_query)
|
| 172 |
+
):
|
| 173 |
+
await manager.connect(websocket, session_id)
|
| 174 |
+
try:
|
| 175 |
+
while True:
|
| 176 |
+
# Keep connection alive
|
| 177 |
+
await asyncio.sleep(1)
|
| 178 |
+
except WebSocketDisconnect:
|
| 179 |
+
manager.disconnect(session_id)
|
| 180 |
+
|
app/schemas.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
from typing import List
|
| 3 |
+
import datetime
|
| 4 |
+
from .models import UserRole
|
| 5 |
+
|
| 6 |
+
# --- User Schemas ---
|
| 7 |
+
class UserBase(BaseModel):
|
| 8 |
+
username: str
|
| 9 |
+
full_name: str
|
| 10 |
+
|
| 11 |
+
class UserCreate(UserBase):
|
| 12 |
+
password: str
|
| 13 |
+
role: UserRole
|
| 14 |
+
|
| 15 |
+
class User(UserBase):
|
| 16 |
+
id: int
|
| 17 |
+
role: UserRole
|
| 18 |
+
|
| 19 |
+
class Config:
|
| 20 |
+
from_attributes = True
|
| 21 |
+
|
| 22 |
+
# --- Auth Schemas ---
|
| 23 |
+
class Token(BaseModel):
|
| 24 |
+
access_token: str
|
| 25 |
+
token_type: str
|
| 26 |
+
|
| 27 |
+
class TokenData(BaseModel):
|
| 28 |
+
username: str | None = None
|
| 29 |
+
|
| 30 |
+
# --- Timetable Schemas ---
|
| 31 |
+
class Subject(BaseModel):
|
| 32 |
+
id: int
|
| 33 |
+
name: str
|
| 34 |
+
code: str
|
| 35 |
+
|
| 36 |
+
class Config:
|
| 37 |
+
from_attributes = True
|
| 38 |
+
|
| 39 |
+
class TimetableSlot(BaseModel):
|
| 40 |
+
id: int
|
| 41 |
+
day_of_week: int
|
| 42 |
+
start_time: datetime.time
|
| 43 |
+
end_time: datetime.time
|
| 44 |
+
subject: Subject
|
| 45 |
+
|
| 46 |
+
class Config:
|
| 47 |
+
from_attributes = True
|
| 48 |
+
|
| 49 |
+
# --- Attendance Schemas ---
|
| 50 |
+
class AttendanceScanPayload(BaseModel):
|
| 51 |
+
token: str
|
| 52 |
+
session_id: str
|
| 53 |
+
|
| 54 |
+
class LiveAttendanceUpdate(BaseModel):
|
| 55 |
+
student_name: str
|
| 56 |
+
timestamp: datetime.datetime
|
app/settings.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic_settings import BaseSettings
|
| 2 |
+
|
| 3 |
+
class Settings(BaseSettings):
|
| 4 |
+
"""Loads environment variables from the .env file."""
|
| 5 |
+
database_url: str
|
| 6 |
+
redis_url: str
|
| 7 |
+
secret_key: str
|
| 8 |
+
algorithm: str
|
| 9 |
+
access_token_expire_minutes: int
|
| 10 |
+
|
| 11 |
+
class Config:
|
| 12 |
+
env_file = ".\.env"
|
| 13 |
+
|
| 14 |
+
settings = Settings()
|
main.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from app import models
|
| 4 |
+
from app.database import engine
|
| 5 |
+
from app.routers import auth, teacher, student
|
| 6 |
+
|
| 7 |
+
# Create all database tables
|
| 8 |
+
models.Base.metadata.create_all(bind=engine)
|
| 9 |
+
|
| 10 |
+
app = FastAPI()
|
| 11 |
+
|
| 12 |
+
# CORS Middleware Setup
|
| 13 |
+
# Replace "http://localhost:3000" with your Next.js frontend URL
|
| 14 |
+
origins = [
|
| 15 |
+
"http://localhost:3000",
|
| 16 |
+
"http://127.0.0.1:63442",
|
| 17 |
+
"http://localhost:9002",
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
app.add_middleware(
|
| 21 |
+
CORSMiddleware,
|
| 22 |
+
allow_origins=origins,
|
| 23 |
+
allow_credentials=True,
|
| 24 |
+
allow_methods=["*"],
|
| 25 |
+
allow_headers=["*"],
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
# Include Routers
|
| 29 |
+
app.include_router(auth.router)
|
| 30 |
+
app.include_router(teacher.router)
|
| 31 |
+
app.include_router(student.router)
|
| 32 |
+
|
| 33 |
+
@app.get("/")
|
| 34 |
+
def read_root():
|
| 35 |
+
return {"message": "Welcome to the Smart Attendance System API"}
|
requirements.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.111.0
|
| 2 |
+
uvicorn[standard]==0.29.0
|
| 3 |
+
sqlalchemy==2.0.30
|
| 4 |
+
psycopg2-binary==2.9.9
|
| 5 |
+
redis==5.0.4
|
| 6 |
+
pydantic==2.7.1
|
| 7 |
+
pydantic-settings==2.2.1
|
| 8 |
+
python-jose[cryptography]==3.3.0
|
| 9 |
+
passlib[bcrypt]
|
| 10 |
+
python-multipart==0.0.9
|
| 11 |
+
websockets==12.0
|