diff --git a/.env b/.env new file mode 100644 index 0000000000000000000000000000000000000000..74060b26850d1f8d22bd27191a203023ad419e41 --- /dev/null +++ b/.env @@ -0,0 +1,4 @@ +APP_NAME=QueryRoute Dashboard +ENVIRONMENT=development +DEBUG=true +DATABASE_URL=postgresql://neondb_owner:npg_ltTPcqu1Q6oS@ep-damp-glade-ab350rcx.eu-west-2.aws.neon.tech/neondb?sslmode=require&channel_binding=require diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/__pycache__/__init__.cpython-313.pyc b/app/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..900b894645e5a82f2a3cdf1a5cbdf54276167875 Binary files /dev/null and b/app/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/__pycache__/config.cpython-313.pyc b/app/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..74c7141258bc00d201542f86db85874fdcc3fee5 Binary files /dev/null and b/app/__pycache__/config.cpython-313.pyc differ diff --git a/app/__pycache__/dependencies.cpython-313.pyc b/app/__pycache__/dependencies.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7f11cf9b359f87fd5c8975e013e8acefbbf16b53 Binary files /dev/null and b/app/__pycache__/dependencies.cpython-313.pyc differ diff --git a/app/__pycache__/main.cpython-313.pyc b/app/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8e22c461889529b92ce62b386add30d0d378caab Binary files /dev/null and b/app/__pycache__/main.cpython-313.pyc differ diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000000000000000000000000000000000000..c4809e895c2454bdfd26380e0ebf0996a6708b11 --- /dev/null +++ b/app/config.py @@ -0,0 +1,47 @@ +from dataclasses import dataclass +from pathlib import Path +import os + + +def _load_env_file() -> None: + env_path = Path(__file__).resolve().parents[1] / ".env" + if not env_path.exists(): + return + + for raw_line in env_path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + + if key and key not in os.environ: + os.environ[key] = value + + +_load_env_file() + + +def _as_bool(value: str | None, default: bool = False) -> bool: + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + +@dataclass(frozen=True) +class Settings: + app_name: str = os.getenv("APP_NAME", "QueryRoute Dashboard") + environment: str = os.getenv("ENVIRONMENT", "development") + debug: bool = _as_bool(os.getenv("DEBUG"), False) + database_url: str = os.getenv("DATABASE_URL", "") + database_url_direct: str = os.getenv("DATABASE_URL_DIRECT", "") + jwt_secret_key: str = os.getenv("JWT_SECRET_KEY", "change-me-in-production") + jwt_algorithm: str = os.getenv("JWT_ALGORITHM", "HS256") + access_token_expire_minutes: int = int( + os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60") + ) + + +settings = Settings() diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/core/__pycache__/__init__.cpython-313.pyc b/app/core/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..05950c965ea6b695c86422b69ea6e4ebdeec5dd1 Binary files /dev/null and b/app/core/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/core/__pycache__/database.cpython-313.pyc b/app/core/__pycache__/database.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2c12501ef8b3c054af7776b0166fb9ec42531e4a Binary files /dev/null and b/app/core/__pycache__/database.cpython-313.pyc differ diff --git a/app/core/__pycache__/exceptions.cpython-313.pyc b/app/core/__pycache__/exceptions.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1d5003e7997d247654abd97aae0fb443800a8acf Binary files /dev/null and b/app/core/__pycache__/exceptions.cpython-313.pyc differ diff --git a/app/core/__pycache__/logging.cpython-313.pyc b/app/core/__pycache__/logging.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f0ce056e68345f24dcf90a5f561d96f81ab63b58 Binary files /dev/null and b/app/core/__pycache__/logging.cpython-313.pyc differ diff --git a/app/core/__pycache__/permissions.cpython-313.pyc b/app/core/__pycache__/permissions.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e998df9716ff05533da65ce1ff72f5290944fdd4 Binary files /dev/null and b/app/core/__pycache__/permissions.cpython-313.pyc differ diff --git a/app/core/__pycache__/security.cpython-313.pyc b/app/core/__pycache__/security.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8841abf9a631f650dae7bc075b1f6f2637b6dcab Binary files /dev/null and b/app/core/__pycache__/security.cpython-313.pyc differ diff --git a/app/core/database.py b/app/core/database.py new file mode 100644 index 0000000000000000000000000000000000000000..be7d1cab56478a9a251e7982f13fc58501b40ce3 --- /dev/null +++ b/app/core/database.py @@ -0,0 +1,31 @@ +from collections.abc import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import declarative_base, sessionmaker + +from app.config import settings + + +if not settings.database_url: + raise RuntimeError("DATABASE_URL is not set") + +engine = create_engine( + settings.database_url, + pool_pre_ping=True, +) + +SessionLocal = sessionmaker( + autocommit=False, + autoflush=False, + bind=engine, +) + +Base = declarative_base() + + +def get_db() -> Generator: + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/core/exceptions.py b/app/core/exceptions.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/core/logging.py b/app/core/logging.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/core/permissions.py b/app/core/permissions.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000000000000000000000000000000000000..20574ab5f36613d8d2998ab1eac4d59f04977cb6 --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +import bcrypt +import jwt + +from app.config import settings + + +def hash_password(password: str) -> str: + salt = bcrypt.gensalt() + return bcrypt.hashpw(password.encode("utf-8"), salt).decode("utf-8") + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + try: + return bcrypt.checkpw( + plain_password.encode("utf-8"), + hashed_password.encode("utf-8"), + ) + except ValueError: + return False + + +def create_access_token(subject: str, expires_delta: timedelta | None = None) -> str: + expire = datetime.now(UTC) + ( + expires_delta + if expires_delta is not None + else timedelta(minutes=settings.access_token_expire_minutes) + ) + payload = {"sub": subject, "exp": expire} + return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm) + + +def decode_access_token(token: str) -> dict: + return jwt.decode( + token, + settings.jwt_secret_key, + algorithms=[settings.jwt_algorithm], + ) diff --git a/app/dependencies.py b/app/dependencies.py new file mode 100644 index 0000000000000000000000000000000000000000..d5995d457674a3c49a569abe0386de9009ad7139 --- /dev/null +++ b/app/dependencies.py @@ -0,0 +1,60 @@ +from collections.abc import Generator + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy.orm import Session +import jwt + +from app.core.database import SessionLocal +from app.core.security import decode_access_token +from app.models.user import User + + +bearer_scheme = HTTPBearer(auto_error=False) + + +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def get_current_user( + credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), + db: Session = Depends(get_db), +) -> User: + if credentials is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + ) + + try: + payload = decode_access_token(credentials.credentials) + user_id = int(payload["sub"]) + except (KeyError, ValueError, TypeError, jwt.PyJWTError): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token", + ) from None + + user = db.get(User, user_id) + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found", + ) + return user + + +def get_current_admin_user( + current_user: User = Depends(get_current_user), +) -> User: + if not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin access required", + ) + return current_user diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000000000000000000000000000000000000..5f5df6793bb34f1685c178b8fe481ab5bd8be2a3 --- /dev/null +++ b/app/main.py @@ -0,0 +1,35 @@ +from fastapi import FastAPI +from sqlalchemy import text + + +from app.config import settings +from app.core.database import engine +from app.models import User # noqa: F401 +from app.core.database import Base +from app.routes.auth import router as auth_router +from app.routes.admin import router as admin_router +from app.routes.departments import router as departments_router + + +app = FastAPI(title=settings.app_name) + +app.include_router(auth_router) +app.include_router(admin_router) +app.include_router(departments_router) + + +@app.on_event("startup") +def startup() -> None: + Base.metadata.create_all(bind=engine) + + +@app.get("/") +def read_root() -> dict[str, str]: + return {"status": "ok", "app": settings.app_name} + + +@app.get("/health/db") +def health_db() -> dict[str, str]: + with engine.connect() as connection: + connection.execute(text("SELECT 1")) + return {"status": "ok", "database": "connected"} diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..0738f7d401bd3f032c0bef7b2f42d112f4a7894c --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,4 @@ +from app.models.user import User +from app.models.department import Department + +__all__ = ["User", "Department"] diff --git a/app/models/__pycache__/__init__.cpython-313.pyc b/app/models/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..aac03c09167a14f6f271ed384b2c82b40a165088 Binary files /dev/null and b/app/models/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/models/__pycache__/announcement.cpython-313.pyc b/app/models/__pycache__/announcement.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..42bd66296292aa84b2b381adc38988cdf5bd6acb Binary files /dev/null and b/app/models/__pycache__/announcement.cpython-313.pyc differ diff --git a/app/models/__pycache__/audit_log.cpython-313.pyc b/app/models/__pycache__/audit_log.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8186c9c4d34210bd26e60b4cbfe0db6ede8c8781 Binary files /dev/null and b/app/models/__pycache__/audit_log.cpython-313.pyc differ diff --git a/app/models/__pycache__/department.cpython-313.pyc b/app/models/__pycache__/department.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8d5862f50b5f69a4dcb3c7fd379cdf727f317d4d Binary files /dev/null and b/app/models/__pycache__/department.cpython-313.pyc differ diff --git a/app/models/__pycache__/escalation.cpython-313.pyc b/app/models/__pycache__/escalation.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..83c6837469789c2c89709a5728cc1fbb7254f83f Binary files /dev/null and b/app/models/__pycache__/escalation.cpython-313.pyc differ diff --git a/app/models/__pycache__/instructor_leave.cpython-313.pyc b/app/models/__pycache__/instructor_leave.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0983bbbd2c96a0fd603ba59d5799c550520cd8f9 Binary files /dev/null and b/app/models/__pycache__/instructor_leave.cpython-313.pyc differ diff --git a/app/models/__pycache__/notification.cpython-313.pyc b/app/models/__pycache__/notification.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..755e7f04cbfa2e5bc628a2d3437087709f51f320 Binary files /dev/null and b/app/models/__pycache__/notification.cpython-313.pyc differ diff --git a/app/models/__pycache__/query.cpython-313.pyc b/app/models/__pycache__/query.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f14b5a037391cc642d166d6e6b5604bcc6dcec18 Binary files /dev/null and b/app/models/__pycache__/query.cpython-313.pyc differ diff --git a/app/models/__pycache__/query_message.cpython-313.pyc b/app/models/__pycache__/query_message.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..654bcd6698723340cd27925be857b0f775a94bc7 Binary files /dev/null and b/app/models/__pycache__/query_message.cpython-313.pyc differ diff --git a/app/models/__pycache__/routing_rule.cpython-313.pyc b/app/models/__pycache__/routing_rule.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0e0745a925d30f2d93c6f0fa83fa265dbc47f7b0 Binary files /dev/null and b/app/models/__pycache__/routing_rule.cpython-313.pyc differ diff --git a/app/models/__pycache__/user.cpython-313.pyc b/app/models/__pycache__/user.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ef8bf289c6536ca3b3e77c51caeab82aadf96fb6 Binary files /dev/null and b/app/models/__pycache__/user.cpython-313.pyc differ diff --git a/app/models/announcement.py b/app/models/announcement.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/models/audit_log.py b/app/models/audit_log.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/models/department.py b/app/models/department.py new file mode 100644 index 0000000000000000000000000000000000000000..89a2df61463e97a309a50be10ee78c6cb3416927 --- /dev/null +++ b/app/models/department.py @@ -0,0 +1,16 @@ +from sqlalchemy import Boolean, Column, Integer, String, Text +from sqlalchemy.orm import relationship + +from app.core.database import Base + + +class Department(Base): + __tablename__ = "departments" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(100), unique=True, nullable=False) + code = Column(String(20), unique=True, nullable=False) + description = Column(Text, nullable=True) + keywords = Column(Text, nullable=True) + is_active = Column(Boolean, default=True, nullable=False) + users = relationship("User", back_populates="department") diff --git a/app/models/escalation.py b/app/models/escalation.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/models/instructor_leave.py b/app/models/instructor_leave.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/models/notification.py b/app/models/notification.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/models/query.py b/app/models/query.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/models/query_message.py b/app/models/query_message.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/models/routing_rule.py b/app/models/routing_rule.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000000000000000000000000000000000000..149a8303c60909785c1c90aec80ff50751b7d783 --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,30 @@ +from datetime import UTC, datetime +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.core.database import Base + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False) + full_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) + role: Mapped[str] = mapped_column(String(255), nullable=False) + is_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + department_id: Mapped[int | None] = mapped_column( + ForeignKey("departments.id", ondelete="SET NULL"), + nullable=True, + ) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime, default=lambda: datetime.now(UTC), nullable=False + ) + department = relationship("Department", back_populates="users") + + @property + def department_name(self) -> str | None: + if self.department is None: + return None + return self.department.name diff --git a/app/routes/__pycache__/admin.cpython-313.pyc b/app/routes/__pycache__/admin.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5ba41c163ec5c06b994fe26d33d74da026228f4e Binary files /dev/null and b/app/routes/__pycache__/admin.cpython-313.pyc differ diff --git a/app/routes/__pycache__/auth.cpython-313.pyc b/app/routes/__pycache__/auth.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e6f144448f46674822657e644ae7f1da59efe1f0 Binary files /dev/null and b/app/routes/__pycache__/auth.cpython-313.pyc differ diff --git a/app/routes/__pycache__/departments.cpython-313.pyc b/app/routes/__pycache__/departments.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1e67cd0d60f23e62f2b391129d07cb9f646ff671 Binary files /dev/null and b/app/routes/__pycache__/departments.cpython-313.pyc differ diff --git a/app/routes/admin.py b/app/routes/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..9e7bfe90a37f50d93c0c8509dcda3a49cac9f094 --- /dev/null +++ b/app/routes/admin.py @@ -0,0 +1,135 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.dependencies import get_current_admin_user, get_db +from app.models.user import User +from app.schemas.admin import AdminDepartmentUpdate, AdminUserUpdate +from app.schemas.department import DepartmentCreate, DepartmentResponse +from app.schemas.user import UserRead +from app.services.admin_service import ( + create_department, + deactivate_department, + deactivate_user, + get_department, + get_user, + list_departments, + list_users, + update_department, + update_user, +) + + +router = APIRouter(prefix="/admin", tags=["admin"]) + + +def _raise_not_found(entity: str) -> None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"{entity} not found", + ) + + +@router.get("/departments", response_model=list[DepartmentResponse]) +def admin_list_departments( + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_user), +): + _ = current_admin + return list_departments(db) + + +@router.post("/departments", response_model=DepartmentResponse, status_code=status.HTTP_201_CREATED) +def admin_create_department( + payload: DepartmentCreate, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_user), +): + _ = current_admin + try: + return create_department(db, **payload.model_dump()) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + +@router.put("/departments/{department_id}", response_model=DepartmentResponse) +def admin_update_department( + department_id: int, + payload: AdminDepartmentUpdate, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_user), +): + _ = current_admin + try: + department = update_department(db, department_id=department_id, payload=payload) + except ValueError as exc: + message = str(exc) + status_code = status.HTTP_404_NOT_FOUND if message == "Department not found" else status.HTTP_400_BAD_REQUEST + raise HTTPException(status_code=status_code, detail=message) from exc + return department + + +@router.delete("/departments/{department_id}", response_model=DepartmentResponse) +def admin_deactivate_department( + department_id: int, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_user), +): + _ = current_admin + try: + department = deactivate_department(db, department_id=department_id) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + return department + + +@router.get("/users", response_model=list[UserRead]) +def admin_list_users( + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_user), +): + _ = current_admin + return list_users(db) + + +@router.get("/users/{user_id}", response_model=UserRead) +def admin_get_user( + user_id: int, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_user), +): + _ = current_admin + user = get_user(db, user_id=user_id) + if user is None: + _raise_not_found("User") + return user + + +@router.put("/users/{user_id}", response_model=UserRead) +def admin_update_user( + user_id: int, + payload: AdminUserUpdate, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_user), +): + _ = current_admin + try: + user = update_user(db, user_id=user_id, payload=payload) + except ValueError as exc: + message = str(exc) + status_code = status.HTTP_404_NOT_FOUND if message == "User not found" else status.HTTP_400_BAD_REQUEST + raise HTTPException(status_code=status_code, detail=message) from exc + return user + + +@router.delete("/users/{user_id}", response_model=UserRead) +def admin_deactivate_user( + user_id: int, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_user), +): + _ = current_admin + try: + user = deactivate_user(db, user_id=user_id) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + return user diff --git a/app/routes/auth.py b/app/routes/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..648b0d48a3d1d288d2cfd4cd7eb9681328de52b5 --- /dev/null +++ b/app/routes/auth.py @@ -0,0 +1,40 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.dependencies import get_current_user, get_db +from app.models.user import User +from app.schemas.auth import LoginRequest, SignupRequest, TokenResponse +from app.schemas.user import UserRead +from app.services.auth_service import ( + authenticate_user, + build_token_response, + create_user, +) + + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.post("/signup", response_model=TokenResponse, status_code=status.HTTP_201_CREATED) +def signup(payload: SignupRequest, db: Session = Depends(get_db)) -> TokenResponse: + try: + user = create_user(db, payload) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + return build_token_response(user) + + +@router.post("/login", response_model=TokenResponse) +def login(payload: LoginRequest, db: Session = Depends(get_db)) -> TokenResponse: + try: + user = authenticate_user(db, payload) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc + + return build_token_response(user) + + +@router.get("/me", response_model=UserRead) +def read_me(current_user: User = Depends(get_current_user)) -> User: + return current_user diff --git a/app/routes/departments.py b/app/routes/departments.py new file mode 100644 index 0000000000000000000000000000000000000000..25b9fb8cb2d03d1dbff207494a728d32bb63b1d7 --- /dev/null +++ b/app/routes/departments.py @@ -0,0 +1,114 @@ +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.models.department import Department +from app.schemas.department import DepartmentCreate, DepartmentResponse + +router = APIRouter(prefix="/departments", tags=["departments"]) + + +def _normalize_keywords(value: str | None) -> set[str]: + if not value: + return set() + + return { + keyword.strip().lower() + for keyword in value.split(",") + if keyword.strip() + } + + +def _score_department(department: Department, query: str) -> int: + query_value = query.lower().strip() + if not query_value: + return 0 + + score = 0 + searchable_fields = [ + department.name, + department.code, + department.description or "", + department.keywords or "", + ] + + for field in searchable_fields: + field_value = field.lower() + if query_value in field_value: + score += 10 + + query_words = {word for word in query_value.split() if word} + department_keywords = _normalize_keywords(department.keywords) + + score += len(query_words & department_keywords) * 5 + + return score + + +@router.get("/", response_model=list[DepartmentResponse]) +def get_departments(db: Session = Depends(get_db)): + return ( + db.query(Department) + .filter(Department.is_active.is_(True)) + .order_by(Department.name) + .all() + ) + + +@router.post("/", response_model=DepartmentResponse) +def create_department( + department_data: DepartmentCreate, + db: Session = Depends(get_db), +): + department = Department(**department_data.model_dump()) + db.add(department) + db.commit() + db.refresh(department) + return department + + +@router.get("/search", response_model=list[DepartmentResponse]) +def search_departments( + q: str = Query(..., min_length=1, description="Text to match against departments"), + db: Session = Depends(get_db), +): + departments = ( + db.query(Department) + .filter(Department.is_active.is_(True)) + .all() + ) + + ranked_departments = [ + (department, _score_department(department, q)) + for department in departments + ] + + ranked_departments = [ + item for item in ranked_departments if item[1] > 0 + ] + ranked_departments.sort(key=lambda item: (-item[1], item[0].name)) + + return [department for department, _ in ranked_departments] + + +@router.get("/match", response_model=DepartmentResponse | None) +def match_department( + q: str = Query(..., min_length=1, description="Text to classify into a department"), + db: Session = Depends(get_db), +): + departments = ( + db.query(Department) + .filter(Department.is_active.is_(True)) + .all() + ) + + best_department = None + best_score = 0 + + for department in departments: + score = _score_department(department, q) + if score > best_score: + best_department = department + best_score = score + + return best_department diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/schemas/__pycache__/__init__.cpython-313.pyc b/app/schemas/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f452bbafa1470c2e8be589d6c591843067517781 Binary files /dev/null and b/app/schemas/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/schemas/__pycache__/admin.cpython-313.pyc b/app/schemas/__pycache__/admin.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6f41c323dcc508d3f3a38c09135000cf9197a05b Binary files /dev/null and b/app/schemas/__pycache__/admin.cpython-313.pyc differ diff --git a/app/schemas/__pycache__/analytics.cpython-313.pyc b/app/schemas/__pycache__/analytics.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f63ea6d037e2ddc623e2c8a147d380ab60856b42 Binary files /dev/null and b/app/schemas/__pycache__/analytics.cpython-313.pyc differ diff --git a/app/schemas/__pycache__/announcement.cpython-313.pyc b/app/schemas/__pycache__/announcement.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dea051f00a79c880c29a38861da35d6c2854b209 Binary files /dev/null and b/app/schemas/__pycache__/announcement.cpython-313.pyc differ diff --git a/app/schemas/__pycache__/audit.cpython-313.pyc b/app/schemas/__pycache__/audit.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..72ac0b45dffea78e9754f8e528c7c2e82610c94e Binary files /dev/null and b/app/schemas/__pycache__/audit.cpython-313.pyc differ diff --git a/app/schemas/__pycache__/auth.cpython-313.pyc b/app/schemas/__pycache__/auth.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0d14f882dd55633e27b016c09288ccbdf2bd81cf Binary files /dev/null and b/app/schemas/__pycache__/auth.cpython-313.pyc differ diff --git a/app/schemas/__pycache__/department.cpython-313.pyc b/app/schemas/__pycache__/department.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b233f5ed2e6b94aa5d0a95f20ed150b5cae3ca09 Binary files /dev/null and b/app/schemas/__pycache__/department.cpython-313.pyc differ diff --git a/app/schemas/__pycache__/escalation.cpython-313.pyc b/app/schemas/__pycache__/escalation.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1ce7d37227be07e6dafc73eaec1d0ea54763c77e Binary files /dev/null and b/app/schemas/__pycache__/escalation.cpython-313.pyc differ diff --git a/app/schemas/__pycache__/leave.cpython-313.pyc b/app/schemas/__pycache__/leave.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0fb02648e52a497c031e6aee8bc17bcf8caab13d Binary files /dev/null and b/app/schemas/__pycache__/leave.cpython-313.pyc differ diff --git a/app/schemas/__pycache__/notification.cpython-313.pyc b/app/schemas/__pycache__/notification.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..82374864987c58a43c7ac9acfbcf0fc107561ab6 Binary files /dev/null and b/app/schemas/__pycache__/notification.cpython-313.pyc differ diff --git a/app/schemas/__pycache__/query.cpython-313.pyc b/app/schemas/__pycache__/query.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bcb6b6901edd9d9ecc58461b0ec4a05ad1fdb6ca Binary files /dev/null and b/app/schemas/__pycache__/query.cpython-313.pyc differ diff --git a/app/schemas/__pycache__/reply.cpython-313.pyc b/app/schemas/__pycache__/reply.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..590aff6b8524a5a976fc4dfebe278f63b6ca786d Binary files /dev/null and b/app/schemas/__pycache__/reply.cpython-313.pyc differ diff --git a/app/schemas/__pycache__/routing.cpython-313.pyc b/app/schemas/__pycache__/routing.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1126e8f30423128b5d33c5efc6971fe283572e5b Binary files /dev/null and b/app/schemas/__pycache__/routing.cpython-313.pyc differ diff --git a/app/schemas/__pycache__/user.cpython-313.pyc b/app/schemas/__pycache__/user.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3307bd3bd8d4625c71210cb62d19da328f0d68a0 Binary files /dev/null and b/app/schemas/__pycache__/user.cpython-313.pyc differ diff --git a/app/schemas/admin.py b/app/schemas/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..528578b633ae3410bfa80713ec769a334df1fedc --- /dev/null +++ b/app/schemas/admin.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel, Field + + +class AdminDepartmentUpdate(BaseModel): + name: str | None = Field(default=None, max_length=100) + code: str | None = Field(default=None, max_length=20) + description: str | None = None + keywords: str | None = None + is_active: bool | None = None + + +class AdminUserUpdate(BaseModel): + email: str | None = Field(default=None, max_length=255) + full_name: str | None = Field(default=None, max_length=255) + department_name: str | None = Field(default=None, max_length=255) + is_active: bool | None = None diff --git a/app/schemas/analytics.py b/app/schemas/analytics.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/schemas/announcement.py b/app/schemas/announcement.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/schemas/audit.py b/app/schemas/audit.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/schemas/auth.py b/app/schemas/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..fb7ee1b9374925f2743e89e599b5f14622c2fb24 --- /dev/null +++ b/app/schemas/auth.py @@ -0,0 +1,21 @@ +from pydantic import BaseModel, Field + +from app.schemas.user import UserRead + + +class SignupRequest(BaseModel): + email: str = Field(min_length=3, max_length=255) + password: str = Field(min_length=8, max_length=128) + full_name: str | None = Field(default=None, max_length=255) + department_name: str = Field(min_length=1, max_length=255) + + +class LoginRequest(BaseModel): + email: str = Field(min_length=3, max_length=255) + password: str = Field(min_length=1, max_length=128) + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + user: UserRead diff --git a/app/schemas/department.py b/app/schemas/department.py new file mode 100644 index 0000000000000000000000000000000000000000..2242b0eab62b117c2bbb4043d85ad4002399594c --- /dev/null +++ b/app/schemas/department.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel, ConfigDict + + +class DepartmentCreate(BaseModel): + name: str + code: str + description: str | None = None + keywords: str | None = None + + +class DepartmentResponse(DepartmentCreate): + id: int + is_active: bool + + model_config = ConfigDict(from_attributes=True) diff --git a/app/schemas/escalation.py b/app/schemas/escalation.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/schemas/leave.py b/app/schemas/leave.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/schemas/notification.py b/app/schemas/notification.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/schemas/query.py b/app/schemas/query.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/schemas/reply.py b/app/schemas/reply.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/schemas/routing.py b/app/schemas/routing.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000000000000000000000000000000000000..95ac6c292f3909b0da76f2cd06439c7f7369a571 --- /dev/null +++ b/app/schemas/user.py @@ -0,0 +1,36 @@ +from datetime import datetime +import re + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +EMAIL_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") + + +class UserBase(BaseModel): + email: str = Field(min_length=3, max_length=255) + full_name: str | None = None + + @field_validator("email") + @classmethod + def validate_email(cls, value: str) -> str: + normalized = value.strip().lower() + if not EMAIL_PATTERN.match(normalized): + raise ValueError("Invalid email address") + return normalized + + +class UserCreate(UserBase): + password: str + + +class UserRead(UserBase): + id: int + role: str + is_admin: bool + department_id: int | None + department_name: str | None + is_active: bool + created_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/services/__pycache__/__init__.cpython-313.pyc b/app/services/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..acd5d2cd9110668a70b0ae961c92c0d077011e9b Binary files /dev/null and b/app/services/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/services/__pycache__/admin_service.cpython-313.pyc b/app/services/__pycache__/admin_service.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..777d39044cd9bca1e0935007e42d39a225a1d0b7 Binary files /dev/null and b/app/services/__pycache__/admin_service.cpython-313.pyc differ diff --git a/app/services/__pycache__/analytics_service.cpython-313.pyc b/app/services/__pycache__/analytics_service.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..85f078fbfde322360420a1803bcc74ad324dbf53 Binary files /dev/null and b/app/services/__pycache__/analytics_service.cpython-313.pyc differ diff --git a/app/services/__pycache__/announcement_service.cpython-313.pyc b/app/services/__pycache__/announcement_service.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c60f5cbc0d774a85bc5e389e679c5680a89e4680 Binary files /dev/null and b/app/services/__pycache__/announcement_service.cpython-313.pyc differ diff --git a/app/services/__pycache__/audit_service.cpython-313.pyc b/app/services/__pycache__/audit_service.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6e93f881a62faadb14bef0bc25879af25484db5f Binary files /dev/null and b/app/services/__pycache__/audit_service.cpython-313.pyc differ diff --git a/app/services/__pycache__/auth_service.cpython-313.pyc b/app/services/__pycache__/auth_service.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..646605608058ebef7520663faf12b3d30d8d0500 Binary files /dev/null and b/app/services/__pycache__/auth_service.cpython-313.pyc differ diff --git a/app/services/__pycache__/classification_service.cpython-313.pyc b/app/services/__pycache__/classification_service.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7c7be29d6c28ee06101e2c841456f3389b144209 Binary files /dev/null and b/app/services/__pycache__/classification_service.cpython-313.pyc differ diff --git a/app/services/__pycache__/email_service.cpython-313.pyc b/app/services/__pycache__/email_service.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e0919ce5d4b705894715acd4b8b71e09880dd488 Binary files /dev/null and b/app/services/__pycache__/email_service.cpython-313.pyc differ diff --git a/app/services/__pycache__/escalation_service.cpython-313.pyc b/app/services/__pycache__/escalation_service.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ed36de30326c4e47d6b1671dbdb36b960af64836 Binary files /dev/null and b/app/services/__pycache__/escalation_service.cpython-313.pyc differ diff --git a/app/services/__pycache__/leave_service.cpython-313.pyc b/app/services/__pycache__/leave_service.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0fc5aba55d4d4f464b3815e6459af50dceba7b68 Binary files /dev/null and b/app/services/__pycache__/leave_service.cpython-313.pyc differ diff --git a/app/services/__pycache__/notification_service.cpython-313.pyc b/app/services/__pycache__/notification_service.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9abfcaebc6700480f73e55b3fa5cf47c71fc22d1 Binary files /dev/null and b/app/services/__pycache__/notification_service.cpython-313.pyc differ diff --git a/app/services/__pycache__/query_service.cpython-313.pyc b/app/services/__pycache__/query_service.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..223f337e7183d697a973cc8ab5559b06cae6f95f Binary files /dev/null and b/app/services/__pycache__/query_service.cpython-313.pyc differ diff --git a/app/services/__pycache__/reply_service.cpython-313.pyc b/app/services/__pycache__/reply_service.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..71abcb27574484ff5605515035ef41eac77f2179 Binary files /dev/null and b/app/services/__pycache__/reply_service.cpython-313.pyc differ diff --git a/app/services/__pycache__/report_service.cpython-313.pyc b/app/services/__pycache__/report_service.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..18baabd9dd31b39430dc0878445259bd7b9ba598 Binary files /dev/null and b/app/services/__pycache__/report_service.cpython-313.pyc differ diff --git a/app/services/__pycache__/routing_service.cpython-313.pyc b/app/services/__pycache__/routing_service.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..819da15ae59f71285a395e592dc2ea405289b9b2 Binary files /dev/null and b/app/services/__pycache__/routing_service.cpython-313.pyc differ diff --git a/app/services/admin_service.py b/app/services/admin_service.py new file mode 100644 index 0000000000000000000000000000000000000000..a5d4344415999cde60dc20a840fd031c3680ead3 --- /dev/null +++ b/app/services/admin_service.py @@ -0,0 +1,176 @@ +from sqlalchemy import func +from sqlalchemy.orm import Session, selectinload + +from app.models.department import Department +from app.models.user import User +from app.schemas.admin import AdminDepartmentUpdate, AdminUserUpdate +from app.services.auth_service import get_department_by_name, resolve_signup_target + + +def _department_exists_with_name_or_code( + db: Session, + *, + name: str | None = None, + code: str | None = None, + exclude_id: int | None = None, +) -> bool: + query = db.query(Department) + if exclude_id is not None: + query = query.filter(Department.id != exclude_id) + if name is not None: + query = query.filter(func.lower(Department.name) == name.lower()) + if code is not None: + query = query.filter(func.lower(Department.code) == code.lower()) + return query.first() is not None + + +def list_departments(db: Session) -> list[Department]: + return ( + db.query(Department) + .filter(Department.is_active.is_(True)) + .order_by(Department.name) + .all() + ) + + +def create_department( + db: Session, + *, + name: str, + code: str, + description: str | None = None, + keywords: str | None = None, +) -> Department: + if _department_exists_with_name_or_code(db, name=name): + raise ValueError("Department name is already registered") + if _department_exists_with_name_or_code(db, code=code): + raise ValueError("Department code is already registered") + + department = Department( + name=name, + code=code, + description=description, + keywords=keywords, + ) + db.add(department) + db.commit() + db.refresh(department) + return department + + +def update_department( + db: Session, + *, + department_id: int, + payload: AdminDepartmentUpdate, +) -> Department: + department = db.get(Department, department_id) + if department is None: + raise ValueError("Department not found") + + if payload.name is not None and _department_exists_with_name_or_code( + db, + name=payload.name, + exclude_id=department_id, + ): + raise ValueError("Department name is already registered") + if payload.code is not None and _department_exists_with_name_or_code( + db, + code=payload.code, + exclude_id=department_id, + ): + raise ValueError("Department code is already registered") + + if payload.name is not None: + department.name = payload.name + if payload.code is not None: + department.code = payload.code + if payload.description is not None: + department.description = payload.description + if payload.keywords is not None: + department.keywords = payload.keywords + if payload.is_active is not None: + department.is_active = payload.is_active + + db.commit() + db.refresh(department) + return department + + +def deactivate_department(db: Session, *, department_id: int) -> Department: + return update_department( + db, + department_id=department_id, + payload=AdminDepartmentUpdate(is_active=False), + ) + + +def get_department(db: Session, *, department_id: int) -> Department | None: + return db.get(Department, department_id) + + +def list_users(db: Session) -> list[User]: + return ( + db.query(User) + .options(selectinload(User.department)) + .order_by(User.id) + .all() + ) + + +def get_user(db: Session, *, user_id: int) -> User | None: + return ( + db.query(User) + .options(selectinload(User.department)) + .filter(User.id == user_id) + .first() + ) + + +def update_user( + db: Session, + *, + user_id: int, + payload: AdminUserUpdate, +) -> User: + user = ( + db.query(User) + .options(selectinload(User.department)) + .filter(User.id == user_id) + .first() + ) + if user is None: + raise ValueError("User not found") + + if payload.email is not None: + existing_user = ( + db.query(User) + .filter(func.lower(User.email) == payload.email.lower(), User.id != user_id) + .first() + ) + if existing_user is not None: + raise ValueError("Email is already registered") + user.email = payload.email.lower() + + if payload.full_name is not None: + user.full_name = payload.full_name + + if payload.department_name is not None: + role, department_id = resolve_signup_target(db, payload.department_name) + user.role = role + user.department_id = department_id + + if payload.is_active is not None: + user.is_active = payload.is_active + + db.commit() + db.refresh(user) + return user + + +def deactivate_user(db: Session, *, user_id: int) -> User: + return update_user( + db, + user_id=user_id, + payload=AdminUserUpdate(is_active=False), + ) diff --git a/app/services/analytics_service.py b/app/services/analytics_service.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/services/announcement_service.py b/app/services/announcement_service.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/services/audit_service.py b/app/services/audit_service.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/services/auth_service.py b/app/services/auth_service.py new file mode 100644 index 0000000000000000000000000000000000000000..9a9000483553cb3f0da3ef926338fdccf7986d76 --- /dev/null +++ b/app/services/auth_service.py @@ -0,0 +1,71 @@ +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.core.security import create_access_token, hash_password, verify_password +from app.models.department import Department +from app.models.user import User +from app.schemas.auth import LoginRequest, SignupRequest, TokenResponse + + +def get_user_by_email(db: Session, email: str) -> User | None: + return db.query(User).filter(User.email == email.lower()).first() + + +def get_department_by_name(db: Session, department_name: str) -> Department | None: + normalized_name = department_name.strip().lower() + return ( + db.query(Department) + .filter(func.lower(Department.name) == normalized_name) + .first() + ) + + +def resolve_signup_target( + db: Session, + department_name: str, +) -> tuple[str, int | None]: + normalized_name = department_name.strip() + if normalized_name.lower() == "student": + return "STUDENT", None + + department = get_department_by_name(db, normalized_name) + if department is None: + raise ValueError("Selected department was not found") + + return department.name, department.id + + +def create_user(db: Session, payload: SignupRequest) -> User: + existing_user = get_user_by_email(db, payload.email) + if existing_user is not None: + raise ValueError("Email is already registered") + + role, department_id = resolve_signup_target(db, payload.department_name) + + user = User( + email=payload.email.lower(), + full_name=payload.full_name, + hashed_password=hash_password(payload.password), + role=role, + is_admin=False, + department_id=department_id, + ) + db.add(user) + db.commit() + db.refresh(user) + return user + + +def authenticate_user(db: Session, payload: LoginRequest) -> User: + user = get_user_by_email(db, payload.email) + if user is None or not verify_password(payload.password, user.hashed_password): + raise ValueError("Incorrect email or password") + return user + + +def build_token_response(user: User) -> TokenResponse: + access_token = create_access_token(subject=str(user.id)) + return TokenResponse( + access_token=access_token, + user=user, + ) diff --git a/app/services/classification_service.py b/app/services/classification_service.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/services/email_service.py b/app/services/email_service.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/services/escalation_service.py b/app/services/escalation_service.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/services/leave_service.py b/app/services/leave_service.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/services/notification_service.py b/app/services/notification_service.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/services/query_service.py b/app/services/query_service.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/services/reply_service.py b/app/services/reply_service.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/services/report_service.py b/app/services/report_service.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/services/routing_service.py b/app/services/routing_service.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/utils/__pycache__/__init__.cpython-313.pyc b/app/utils/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9f940d7e6eb37511ff78d7c4c66b18fbb8566330 Binary files /dev/null and b/app/utils/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/utils/__pycache__/date_utils.cpython-313.pyc b/app/utils/__pycache__/date_utils.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fd83f8be42658bb5eade64c83e6e730dc971dce2 Binary files /dev/null and b/app/utils/__pycache__/date_utils.cpython-313.pyc differ diff --git a/app/utils/__pycache__/enums.cpython-313.pyc b/app/utils/__pycache__/enums.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..85d1969dcf4c51ef693ccf0deabcf43c657d3688 Binary files /dev/null and b/app/utils/__pycache__/enums.cpython-313.pyc differ diff --git a/app/utils/__pycache__/file_export.cpython-313.pyc b/app/utils/__pycache__/file_export.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..494bd85d2e472529b4079bf708e571a869c3abb9 Binary files /dev/null and b/app/utils/__pycache__/file_export.cpython-313.pyc differ diff --git a/app/utils/__pycache__/pagination.cpython-313.pyc b/app/utils/__pycache__/pagination.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6adbdede4e3ef5aa33e431c69f580fc7271ab7e4 Binary files /dev/null and b/app/utils/__pycache__/pagination.cpython-313.pyc differ diff --git a/app/utils/__pycache__/validators.cpython-313.pyc b/app/utils/__pycache__/validators.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fe12a2a23149a96993972628514e41a316aa35e1 Binary files /dev/null and b/app/utils/__pycache__/validators.cpython-313.pyc differ diff --git a/app/utils/date_utils.py b/app/utils/date_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/utils/enums.py b/app/utils/enums.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/utils/file_export.py b/app/utils/file_export.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/utils/pagination.py b/app/utils/pagination.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/utils/validators.py b/app/utils/validators.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..b6381eee8df5297f39edd3b6233a09941a1e69ed --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +fastapi +uvicorn[standard] +sqlalchemy +psycopg2-binary +bcrypt +PyJWT diff --git a/scripts/create_admin.py b/scripts/create_admin.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/scripts/seed_database.py b/scripts/seed_database.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/__pycache__/conftest.cpython-313-pytest-8.4.2.pyc b/tests/__pycache__/conftest.cpython-313-pytest-8.4.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..914fe27b42ac78808a82c90c8eda3c38e36957ff Binary files /dev/null and b/tests/__pycache__/conftest.cpython-313-pytest-8.4.2.pyc differ diff --git a/tests/__pycache__/conftest.cpython-313.pyc b/tests/__pycache__/conftest.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e1a3735cc4d2aecd9c53c7de59307eedbfda877d Binary files /dev/null and b/tests/__pycache__/conftest.cpython-313.pyc differ diff --git a/tests/__pycache__/test_admin.cpython-313-pytest-8.4.2.pyc b/tests/__pycache__/test_admin.cpython-313-pytest-8.4.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..62aa8c181eaf0d96e8b2aec668300433c06b7e07 Binary files /dev/null and b/tests/__pycache__/test_admin.cpython-313-pytest-8.4.2.pyc differ diff --git a/tests/__pycache__/test_admin.cpython-313.pyc b/tests/__pycache__/test_admin.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..36a563b2f813062404b913e5c8ea5b7776edf823 Binary files /dev/null and b/tests/__pycache__/test_admin.cpython-313.pyc differ diff --git a/tests/__pycache__/test_auth.cpython-313-pytest-8.4.2.pyc b/tests/__pycache__/test_auth.cpython-313-pytest-8.4.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7c1ea438e691243c7a54367fc9c63bf124f7e1a3 Binary files /dev/null and b/tests/__pycache__/test_auth.cpython-313-pytest-8.4.2.pyc differ diff --git a/tests/__pycache__/test_auth.cpython-313.pyc b/tests/__pycache__/test_auth.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a37390491683da8336e9fd76eb29252152e8847f Binary files /dev/null and b/tests/__pycache__/test_auth.cpython-313.pyc differ diff --git a/tests/__pycache__/test_classification.cpython-313.pyc b/tests/__pycache__/test_classification.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c31b9bd33c296c1f63b7bcc09e94ef31d731fd08 Binary files /dev/null and b/tests/__pycache__/test_classification.cpython-313.pyc differ diff --git a/tests/__pycache__/test_escalation.cpython-313.pyc b/tests/__pycache__/test_escalation.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..91e0279a448a982727cde0ca6b0bb6583e6c58e1 Binary files /dev/null and b/tests/__pycache__/test_escalation.cpython-313.pyc differ diff --git a/tests/__pycache__/test_notifications.cpython-313.pyc b/tests/__pycache__/test_notifications.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..333fb30309c1d584c964c6c1fdd406c3b33026c2 Binary files /dev/null and b/tests/__pycache__/test_notifications.cpython-313.pyc differ diff --git a/tests/__pycache__/test_permissions.cpython-313.pyc b/tests/__pycache__/test_permissions.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9d204dfcd88b605f23bfa02bc252408ff30dae4f Binary files /dev/null and b/tests/__pycache__/test_permissions.cpython-313.pyc differ diff --git a/tests/__pycache__/test_queries.cpython-313.pyc b/tests/__pycache__/test_queries.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..aa3ae809d9fe32e950b2a866072651308080ed84 Binary files /dev/null and b/tests/__pycache__/test_queries.cpython-313.pyc differ diff --git a/tests/__pycache__/test_reports.cpython-313.pyc b/tests/__pycache__/test_reports.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b00a4852ffa9429a4a3a07b9d461a7f2272a6e82 Binary files /dev/null and b/tests/__pycache__/test_reports.cpython-313.pyc differ diff --git a/tests/__pycache__/test_routing.cpython-313.pyc b/tests/__pycache__/test_routing.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b9c91172cbeade4877622dacb266a229cd44f436 Binary files /dev/null and b/tests/__pycache__/test_routing.cpython-313.pyc differ diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/test_admin.py b/tests/test_admin.py new file mode 100644 index 0000000000000000000000000000000000000000..41d31ba3462287de16cc7dc6ecd3c3a92ed3bf85 --- /dev/null +++ b/tests/test_admin.py @@ -0,0 +1,126 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from app.core.database import Base +from app.models.department import Department +from app.models.user import User +from app.schemas.admin import AdminDepartmentUpdate, AdminUserUpdate +from app.schemas.auth import SignupRequest +from app.services.admin_service import ( + create_department, + deactivate_department, + deactivate_user, + list_departments, + list_users, + update_department, + update_user, +) +from app.services.auth_service import create_user + + +def _make_session(): + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + Base.metadata.create_all(bind=engine) + return engine, TestingSessionLocal() + + +def test_admin_can_create_update_and_deactivate_department(): + engine, db = _make_session() + try: + created = create_department( + db, + name="Library", + code="LIB", + description="Library department", + keywords="books,study", + ) + + assert created.name == "Library" + assert created.is_active is True + + updated = update_department( + db, + department_id=created.id, + payload=AdminDepartmentUpdate( + name="Library Services", + code="LIB-S", + description="Updated library department", + keywords="books, study, catalog", + is_active=True, + ), + ) + + assert updated.name == "Library Services" + assert updated.code == "LIB-S" + assert updated.keywords == "books, study, catalog" + + deactivated = deactivate_department(db, department_id=created.id) + assert deactivated.is_active is False + + departments = list_departments(db) + assert departments == [] + finally: + db.close() + Base.metadata.drop_all(bind=engine) + + +def test_admin_can_list_and_update_user_department(): + engine, db = _make_session() + try: + finance = Department( + name="Finance Department", + code="FIN", + description="Finance work", + keywords="finance, budget", + ) + exam = Department( + name="Examination Department", + code="EXAM", + description="Exam work", + keywords="exam, timetable", + ) + db.add_all([finance, exam]) + db.commit() + db.refresh(finance) + db.refresh(exam) + + user = create_user( + db, + SignupRequest( + email="staff@example.com", + password="password123", + full_name="Finance Staff", + department_name="Finance Department", + ), + ) + + users = list_users(db) + assert len(users) == 1 + assert users[0].department_name == "Finance Department" + + updated = update_user( + db, + user_id=user.id, + payload=AdminUserUpdate( + full_name="Exam Staff", + department_name="Examination Department", + is_active=False, + ), + ) + + assert updated.full_name == "Exam Staff" + assert updated.role == "Examination Department" + assert updated.department_id == exam.id + assert updated.is_active is False + + deactivated = deactivate_user(db, user_id=user.id) + assert deactivated.is_active is False + finally: + db.close() + Base.metadata.drop_all(bind=engine) diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000000000000000000000000000000000000..5ff67e4c889330f50a47bb32297a34e00f4a0895 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,260 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from fastapi import HTTPException + +from app import dependencies +from app.core.database import Base +from app.core.security import decode_access_token, verify_password +from app.models.department import Department +from app.models.user import User +from app.schemas.auth import LoginRequest, SignupRequest +from app.services.auth_service import ( + authenticate_user, + build_token_response, + create_user, +) + + +def _make_session(): + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + Base.metadata.create_all(bind=engine) + return engine, TestingSessionLocal() + + +def test_signup_creates_user_with_hashed_password(): + engine, db = _make_session() + try: + user = create_user( + db, + SignupRequest( + email="student@example.com", + password="password123", + full_name="Student User", + department_name="Student", + ), + ) + + assert user.id is not None + assert user.email == "student@example.com" + assert user.full_name == "Student User" + assert user.role == "STUDENT" + assert user.department_id is None + assert user.hashed_password != "password123" + assert verify_password("password123", user.hashed_password) + finally: + db.close() + Base.metadata.drop_all(bind=engine) + + +def test_signup_student_sets_student_role_without_department(): + engine, db = _make_session() + try: + user = create_user( + db, + SignupRequest( + email="student2@example.com", + password="password123", + full_name="Student User", + department_name="Student", + ), + ) + + assert user.role == "STUDENT" + assert user.department_id is None + finally: + db.close() + Base.metadata.drop_all(bind=engine) + + +def test_login_returns_access_token(): + engine, db = _make_session() + try: + created_user = create_user( + db, + SignupRequest( + email="teacher@example.com", + password="password123", + full_name="Teacher User", + department_name="Student", + ), + ) + + user = authenticate_user( + db, + LoginRequest(email="teacher@example.com", password="password123"), + ) + token_response = build_token_response(user) + claims = decode_access_token(token_response.access_token) + + assert user.id == created_user.id + assert token_response.token_type == "bearer" + assert claims["sub"] == str(created_user.id) + finally: + db.close() + Base.metadata.drop_all(bind=engine) + + +def test_signup_rejects_duplicate_email(): + engine, db = _make_session() + try: + create_user( + db, + SignupRequest( + email="duplicate@example.com", + password="password123", + department_name="Student", + ), + ) + + try: + create_user( + db, + SignupRequest( + email="duplicate@example.com", + password="password123", + department_name="Student", + ), + ) + raise AssertionError("Expected duplicate email to fail") + except ValueError as exc: + assert "already registered" in str(exc) + finally: + db.close() + Base.metadata.drop_all(bind=engine) + + +def test_signup_staff_sets_staff_role_and_department_id(): + engine, db = _make_session() + try: + department = Department( + name="Examination Department", + code="EXAM", + description="Handles examinations", + keywords="exam, timetable, seating", + ) + db.add(department) + db.commit() + db.refresh(department) + + user = create_user( + db, + SignupRequest( + email="staff@example.com", + password="password123", + full_name="Exam Staff", + department_name="Examination Department", + ), + ) + + assert user.role == "Examination Department" + assert user.department_id == department.id + finally: + db.close() + Base.metadata.drop_all(bind=engine) + + +def test_signup_response_includes_department_name_for_staff(): + engine, db = _make_session() + try: + department = Department( + name="Finance Department", + code="FIN", + description="Handles finance", + keywords="finance, budget, fees", + ) + db.add(department) + db.commit() + db.refresh(department) + + user = create_user( + db, + SignupRequest( + email="finance.staff@example.com", + password="password123", + full_name="Finance Staff", + department_name="Finance Department", + ), + ) + + token_response = build_token_response(user) + + assert token_response.user.department_name == "Finance Department" + finally: + db.close() + Base.metadata.drop_all(bind=engine) + + +def test_signup_defaults_new_users_to_non_admin(): + engine, db = _make_session() + try: + user = create_user( + db, + SignupRequest( + email="normal.user@example.com", + password="password123", + full_name="Normal User", + department_name="Student", + ), + ) + + assert user.is_admin is False + finally: + db.close() + Base.metadata.drop_all(bind=engine) + + +def test_get_current_admin_user_returns_admin_user(): + engine, db = _make_session() + try: + admin_user = User( + email="admin@example.com", + full_name="Admin User", + hashed_password="hashed", + role="ADMIN", + is_admin=True, + department_id=None, + ) + db.add(admin_user) + db.commit() + db.refresh(admin_user) + + current_admin = dependencies.get_current_admin_user(current_user=admin_user) + + assert current_admin.id == admin_user.id + assert current_admin.is_admin is True + finally: + db.close() + Base.metadata.drop_all(bind=engine) + + +def test_get_current_admin_user_rejects_non_admin_user(): + engine, db = _make_session() + try: + normal_user = User( + email="user@example.com", + full_name="Normal User", + hashed_password="hashed", + role="STUDENT", + is_admin=False, + department_id=None, + ) + db.add(normal_user) + db.commit() + db.refresh(normal_user) + + try: + dependencies.get_current_admin_user(current_user=normal_user) + raise AssertionError("Expected admin check to fail") + except HTTPException as exc: + assert exc.status_code == 403 + assert "Admin access required" in str(exc.detail) + finally: + db.close() + Base.metadata.drop_all(bind=engine) diff --git a/tests/test_classification.py b/tests/test_classification.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/test_escalation.py b/tests/test_escalation.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/test_notifications.py b/tests/test_notifications.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/test_permissions.py b/tests/test_permissions.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/test_queries.py b/tests/test_queries.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/test_reports.py b/tests/test_reports.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/test_routing.py b/tests/test_routing.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391