Initial commit: AARS backend + frontend + holiday/leave phase 1+2
This commit is contained in:
@@ -0,0 +1 @@
|
||||
logs/
|
||||
@@ -0,0 +1,72 @@
|
||||
from fastapi import Depends, HTTPException, status, Query
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
from jose import JWTError, jwt
|
||||
from database import get_db, SECRET_KEY, ALGORITHM
|
||||
from models import User
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db)
|
||||
) -> User:
|
||||
"""Validate JWT token and return current user."""
|
||||
token = credentials.credentials
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
user_id_str = payload.get("sub")
|
||||
if user_id_str is None:
|
||||
raise credentials_exception
|
||||
try:
|
||||
user_id = int(user_id_str)
|
||||
except (ValueError, TypeError):
|
||||
raise credentials_exception
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
user = db.query(User).filter(User.id == user_id, User.is_active == True).first()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_admin(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""Ensure current user is an admin."""
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin access required"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
async def get_current_user_from_query(
|
||||
token: str = Query(...),
|
||||
db: Session = Depends(get_db)
|
||||
) -> User:
|
||||
"""Validate JWT token from query parameter and return current user."""
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
user_id_str = payload.get("sub")
|
||||
if user_id_str is None:
|
||||
raise credentials_exception
|
||||
user_id = int(user_id_str)
|
||||
except (JWTError, ValueError, TypeError):
|
||||
raise credentials_exception
|
||||
|
||||
user = db.query(User).filter(User.id == user_id, User.is_active == True).first()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
return user
|
||||
@@ -0,0 +1,72 @@
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import create_engine, Column, Integer, String, Text, Float, Boolean, DateTime, Date, Time, JSON
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from passlib.context import CryptContext
|
||||
from jose import jwt
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel
|
||||
import secret
|
||||
|
||||
# Database setup
|
||||
DATABASE_URL = "sqlite:///./data/aars.db"
|
||||
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
Base = declarative_base()
|
||||
|
||||
pwd_context = CryptContext(schemes=["sha256_crypt"], deprecated="auto")
|
||||
|
||||
# JWT Settings
|
||||
SECRET_KEY = secret.JWT_SECRET if hasattr(secret, 'JWT_SECRET') else "aars-dev-secret-change-in-production"
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_HOURS = 24
|
||||
|
||||
|
||||
def create_access_token(data: dict) -> str:
|
||||
expire = datetime.now(timezone.utc).timestamp() + (ACCESS_TOKEN_EXPIRE_HOURS * 3600)
|
||||
to_encode = data.copy()
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def init_db():
|
||||
"""Initialize database with tables and default admin user."""
|
||||
Base.metadata.create_all(bind=engine)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Check if admin exists
|
||||
from models import User
|
||||
admin = db.query(User).filter(User.email == "admin@aars.hk").first()
|
||||
if not admin:
|
||||
# Create default admin
|
||||
admin = User(
|
||||
email="admin@aars.hk",
|
||||
password_hash=get_password_hash("admin123"),
|
||||
name="系統管理員",
|
||||
role="admin",
|
||||
is_active=True
|
||||
)
|
||||
db.add(admin)
|
||||
db.commit()
|
||||
print("✅ Default admin user created: admin@aars.hk / admin123")
|
||||
else:
|
||||
print("✅ Admin user already exists")
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,251 @@
|
||||
"""
|
||||
Structured JSON logging for AARS backend.
|
||||
|
||||
Writes newline-delimited JSON to:
|
||||
/app/logs/app-YYYY-MM-DD.jsonl - INFO+ (general)
|
||||
/app/logs/error-YYYY-MM-DD.jsonl - ERROR only
|
||||
/app/logs/access-YYYY-MM-DD.jsonl - HTTP access log
|
||||
|
||||
Features:
|
||||
- ISO 8601 UTC timestamps
|
||||
- X-Request-ID context propagation via ContextVar
|
||||
- Automatic secret redaction (passwords, tokens, JWTs, etc.)
|
||||
- Daily rotation via TimedRotatingFileHandler
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from contextvars import ContextVar
|
||||
from datetime import datetime, timezone
|
||||
from logging.handlers import TimedRotatingFileHandler
|
||||
from typing import Any
|
||||
|
||||
# ============ Request context ============
|
||||
request_id_var: ContextVar[str] = ContextVar("request_id", default="-")
|
||||
user_id_var: ContextVar[int] = ContextVar("user_id", default=0)
|
||||
|
||||
|
||||
def set_request_id(rid: str) -> None:
|
||||
request_id_var.set(rid)
|
||||
|
||||
|
||||
def get_request_id() -> str:
|
||||
return request_id_var.get()
|
||||
|
||||
|
||||
def set_user_id(uid: int) -> None:
|
||||
user_id_var.set(uid)
|
||||
|
||||
|
||||
# ============ Redactor ============
|
||||
REDACT_KEYS = {
|
||||
"password", "passwd", "pwd", "pass",
|
||||
"token", "access_token", "refresh_token", "id_token",
|
||||
"jwt", "bearer", "authorization",
|
||||
"api_key", "apikey", "secret", "client_secret",
|
||||
"cookie", "set-cookie", "session",
|
||||
"database_url", "db_url", "connection_string",
|
||||
"x-api-key", "x-auth-token",
|
||||
}
|
||||
|
||||
REDACT_PATTERNS = [
|
||||
(re.compile(r"eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+"), "[REDACTED:JWT]"),
|
||||
(re.compile(r"(?i)(bearer\s+)[A-Za-z0-9\-_.]+"), r"\1[REDACTED]"),
|
||||
]
|
||||
|
||||
REDACTED = "[REDACTED]"
|
||||
|
||||
|
||||
def _scrub_str(s: str) -> str:
|
||||
for pat, repl in REDACT_PATTERNS:
|
||||
s = pat.sub(repl, s)
|
||||
return s
|
||||
|
||||
|
||||
def _scrub(obj: Any) -> Any:
|
||||
"""Recursively redact secrets from dict/list/str."""
|
||||
if isinstance(obj, dict):
|
||||
out = {}
|
||||
for k, v in obj.items():
|
||||
if isinstance(k, str) and k.lower() in REDACT_KEYS:
|
||||
out[k] = REDACTED
|
||||
else:
|
||||
out[k] = _scrub(v)
|
||||
return out
|
||||
if isinstance(obj, list):
|
||||
return [_scrub(v) for v in obj]
|
||||
if isinstance(obj, str):
|
||||
return _scrub_str(obj)
|
||||
return obj
|
||||
|
||||
|
||||
# ============ JSON Formatter ============
|
||||
class JSONFormatter(logging.Formatter):
|
||||
SERVICE = "aars-backend"
|
||||
ENV = "production"
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
# Standard fields
|
||||
payload = {
|
||||
"ts": datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z"),
|
||||
"level": record.levelname.lower(),
|
||||
"service": self.SERVICE,
|
||||
"env": self.ENV,
|
||||
"request_id": request_id_var.get(),
|
||||
"msg": record.getMessage(),
|
||||
}
|
||||
|
||||
# Optional fields from extra=
|
||||
for key in ("route", "method", "status", "duration_ms", "user_id",
|
||||
"page_url", "app_version", "browser", "client_ip"):
|
||||
v = getattr(record, key, None)
|
||||
if v is not None:
|
||||
payload[key] = v
|
||||
|
||||
# Exception info
|
||||
if record.exc_info:
|
||||
exc_type, exc_val, exc_tb = record.exc_info
|
||||
payload["error"] = {
|
||||
"name": exc_type.__name__ if exc_type else "Exception",
|
||||
"message": str(exc_val) if exc_val else "",
|
||||
"stack": self.formatException(record.exc_info),
|
||||
}
|
||||
|
||||
# Custom extras (e.g. extra={"context": {...}})
|
||||
if hasattr(record, "context") and record.context:
|
||||
payload["context"] = _scrub(record.context)
|
||||
|
||||
# Scrub msg + all string fields
|
||||
payload = _scrub(payload)
|
||||
|
||||
try:
|
||||
return json.dumps(payload, ensure_ascii=False, default=str)
|
||||
except (TypeError, ValueError):
|
||||
payload["msg"] = str(payload.get("msg", ""))[:500]
|
||||
return json.dumps(payload, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
# ============ Setup ============
|
||||
LOG_DIR = "/app/logs"
|
||||
APP_RETENTION = 7 # days
|
||||
ERROR_RETENTION = 30
|
||||
|
||||
|
||||
class DailyJSONLHandler(logging.Handler):
|
||||
"""Writes one line per record to ``<base>-YYYY-MM-DD.jsonl``.
|
||||
|
||||
Switches file automatically when the local date changes.
|
||||
Honors a maximum retention by deleting old files on rollover.
|
||||
"""
|
||||
|
||||
def __init__(self, base_name: str, retention_days: int, level: int = logging.INFO):
|
||||
super().__init__(level=level)
|
||||
self.base_name = base_name # e.g. "app"
|
||||
self.retention_days = retention_days
|
||||
self._date_str = None
|
||||
self._stream = None
|
||||
self.setFormatter(JSONFormatter())
|
||||
|
||||
def _current_path(self) -> str:
|
||||
import os
|
||||
return os.path.join(LOG_DIR, f"{self.base_name}-{self._date_str}.jsonl")
|
||||
|
||||
def _open(self) -> None:
|
||||
import os
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
path = self._current_path()
|
||||
self._stream = open(path, "a", encoding="utf-8")
|
||||
self._cleanup_old()
|
||||
|
||||
def _cleanup_old(self) -> None:
|
||||
import os
|
||||
import time
|
||||
cutoff = time.time() - (self.retention_days * 86400)
|
||||
prefix = f"{self.base_name}-"
|
||||
try:
|
||||
for fname in os.listdir(LOG_DIR):
|
||||
if not (fname.startswith(prefix) and fname.endswith(".jsonl")):
|
||||
continue
|
||||
fpath = os.path.join(LOG_DIR, fname)
|
||||
if os.path.getmtime(fpath) < cutoff:
|
||||
os.remove(fpath)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _maybe_rollover(self) -> None:
|
||||
import os
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
if self._date_str != today:
|
||||
if self._stream:
|
||||
self._stream.close()
|
||||
self._stream = None
|
||||
self._date_str = today
|
||||
self._open()
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
try:
|
||||
self._maybe_rollover()
|
||||
if self._stream:
|
||||
msg = self.format(record)
|
||||
self._stream.write(msg + "\n")
|
||||
self._stream.flush()
|
||||
except Exception:
|
||||
self.handleError(record)
|
||||
|
||||
def close(self) -> None:
|
||||
if self._stream:
|
||||
self._stream.close()
|
||||
self._stream = None
|
||||
super().close()
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
"""Configure root logger. Call once at app startup."""
|
||||
root = logging.getLogger()
|
||||
root.setLevel(logging.INFO)
|
||||
|
||||
# Clear handlers (uvicorn re-imports)
|
||||
for h in list(root.handlers):
|
||||
root.removeHandler(h)
|
||||
|
||||
# App logger — INFO+
|
||||
app_handler = DailyJSONLHandler("app", APP_RETENTION, level=logging.INFO)
|
||||
root.addHandler(app_handler)
|
||||
|
||||
# Error logger — ERROR only
|
||||
error_handler = DailyJSONLHandler("error", ERROR_RETENTION, level=logging.ERROR)
|
||||
root.addHandler(error_handler)
|
||||
|
||||
# Access logger (separate logger, not root)
|
||||
access_logger = logging.getLogger("access")
|
||||
access_logger.setLevel(logging.INFO)
|
||||
access_logger.propagate = False
|
||||
access_handler = DailyJSONLHandler("access", APP_RETENTION, level=logging.INFO)
|
||||
access_logger.addHandler(access_handler)
|
||||
|
||||
# Console handler — pretty for dev/visible logs
|
||||
# Use a custom filter to inject request_id from ContextVar
|
||||
class _ContextFilter(logging.Filter):
|
||||
def filter(self, record):
|
||||
record.request_id = request_id_var.get()
|
||||
record.user_id = user_id_var.get()
|
||||
return True
|
||||
|
||||
console = logging.StreamHandler(sys.stdout)
|
||||
console.setLevel(logging.INFO)
|
||||
console.setFormatter(logging.Formatter(
|
||||
"%(asctime)s %(levelname)-5s [%(request_id)s] %(name)s: %(message)s"
|
||||
))
|
||||
console.addFilter(_ContextFilter())
|
||||
root.addHandler(console)
|
||||
|
||||
# Silence noisy libs
|
||||
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
||||
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
|
||||
|
||||
logging.getLogger(__name__).info("logging initialized", extra={"context": {"log_dir": LOG_DIR}})
|
||||
|
||||
|
||||
def get_access_logger() -> logging.Logger:
|
||||
return logging.getLogger("access")
|
||||
+3318
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
Middleware for AARS backend:
|
||||
- RequestIDMiddleware: generate/accept X-Request-ID
|
||||
- AccessLogMiddleware: log every HTTP request
|
||||
- error_handler: catch unhandled exceptions, log with stack trace
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
from logging_config import (
|
||||
get_access_logger,
|
||||
set_request_id,
|
||||
set_user_id,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REQUEST_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
|
||||
|
||||
|
||||
def _normalize_request_id(raw: str | None) -> str:
|
||||
if raw and REQUEST_ID_PATTERN.match(raw):
|
||||
return raw
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class RequestIDMiddleware(BaseHTTPMiddleware):
|
||||
"""Generate or accept X-Request-ID and echo in response headers."""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
rid = _normalize_request_id(request.headers.get("X-Request-ID"))
|
||||
set_request_id(rid)
|
||||
request.state.request_id = rid
|
||||
|
||||
response = await call_next(request)
|
||||
response.headers["X-Request-ID"] = rid
|
||||
return response
|
||||
|
||||
|
||||
class AccessLogMiddleware(BaseHTTPMiddleware):
|
||||
"""Log every HTTP request with route, method, status, duration."""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
start = time.perf_counter()
|
||||
rid = getattr(request.state, "request_id", "-")
|
||||
status = 500 # default for exception path
|
||||
try:
|
||||
response = await call_next(request)
|
||||
status = response.status_code
|
||||
return response
|
||||
finally:
|
||||
duration_ms = int((time.perf_counter() - start) * 1000)
|
||||
access = get_access_logger()
|
||||
access.info(
|
||||
"%s %s %d",
|
||||
request.method,
|
||||
request.url.path,
|
||||
status,
|
||||
extra={
|
||||
"route": request.url.path,
|
||||
"method": request.method,
|
||||
"status": status,
|
||||
"duration_ms": duration_ms,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def unhandled_exception_handler(request: Request, exc: Exception):
|
||||
"""Catch-all for unhandled exceptions. Log with stack + return 500 JSON."""
|
||||
rid = getattr(request.state, "request_id", "-")
|
||||
logger.error(
|
||||
"unhandled exception: %s %s",
|
||||
request.method,
|
||||
request.url.path,
|
||||
exc_info=exc,
|
||||
extra={
|
||||
"route": request.url.path,
|
||||
"method": request.method,
|
||||
"status": 500,
|
||||
"context": {
|
||||
"exception_type": type(exc).__name__,
|
||||
"query_params": dict(request.query_params),
|
||||
},
|
||||
},
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "Internal server error", "request_id": rid},
|
||||
headers={"X-Request-ID": rid},
|
||||
)
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Phase 2 Migration: Holidays + Leave Records
|
||||
- Create holidays + leave_records tables
|
||||
- Import HK public holidays 2025-2027 (51 records)
|
||||
- Insert demo leave records
|
||||
|
||||
Run: docker exec aars-aars-1 python3 /app/migrate_phase2.py
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, "/app")
|
||||
|
||||
from database import SessionLocal, engine, Base
|
||||
from models import Holiday, LeaveRecord
|
||||
from parsers import get_all_holidays
|
||||
from datetime import date, datetime
|
||||
|
||||
print("=" * 60)
|
||||
print("Phase 2 Migration: Holidays + Leave Records")
|
||||
print("=" * 60)
|
||||
|
||||
# Step 1: Create tables (idempotent — Base.metadata.create_all skips existing)
|
||||
print("\n[1/3] Creating tables...")
|
||||
Base.metadata.create_all(engine)
|
||||
print(" ✓ holidays + leave_records tables ready")
|
||||
|
||||
# Step 2: Import holidays
|
||||
print("\n[2/3] Importing HK public holidays 2025-2027...")
|
||||
db = SessionLocal()
|
||||
added = 0
|
||||
updated = 0
|
||||
for d, name_zh, name_en, is_mandatory in get_all_holidays():
|
||||
existing = db.query(Holiday).filter(Holiday.date == d).first()
|
||||
if existing:
|
||||
# Update if changed
|
||||
if (existing.name != name_zh or existing.name_en != name_en
|
||||
or existing.is_mandatory != is_mandatory):
|
||||
existing.name = name_zh
|
||||
existing.name_en = name_en
|
||||
existing.is_mandatory = is_mandatory
|
||||
existing.source = "gov_hk"
|
||||
existing.updated_at = datetime.utcnow()
|
||||
updated += 1
|
||||
else:
|
||||
db.add(Holiday(
|
||||
date=d,
|
||||
name=name_zh,
|
||||
name_en=name_en,
|
||||
region="HK",
|
||||
is_mandatory=is_mandatory,
|
||||
source="gov_hk",
|
||||
))
|
||||
added += 1
|
||||
|
||||
db.commit()
|
||||
print(f" ✓ Added {added} new holidays, updated {updated}")
|
||||
print(f" ✓ Total holidays in DB: {db.query(Holiday).count()}")
|
||||
|
||||
# Step 3: Demo leave records
|
||||
print("\n[3/3] Inserting demo leave records...")
|
||||
demo_leaves = [
|
||||
# Jay Lam 2026-07-08: SL 2h (有返工但中間請病假)
|
||||
("Jay Lam", "2026-07-08", "SL", 2.0, "睇醫生"),
|
||||
# Jay Lam 2026-07-10: CL 4h (上午補鐘)
|
||||
("Jay Lam", "2026-07-10", "CL", 4.0, "補鐘"),
|
||||
# Cindy Cheung 2026-07-09: AL 8h (全日年假冇打卡)
|
||||
("Cindy Cheung", "2026-07-09", "AL", 8.0, "Family trip"),
|
||||
# May Chan 2026-07-07: SL 4h (下午走咗)
|
||||
("May Chan", "2026-07-07", "SL", 4.0, "感冒"),
|
||||
]
|
||||
|
||||
added_leave = 0
|
||||
for name, d_str, lt, hrs, reason in demo_leaves:
|
||||
d = datetime.strptime(d_str, "%Y-%m-%d").date()
|
||||
existing = db.query(LeaveRecord).filter(
|
||||
LeaveRecord.employee_name == name,
|
||||
LeaveRecord.date == d,
|
||||
LeaveRecord.leave_type == lt,
|
||||
).first()
|
||||
if not existing:
|
||||
db.add(LeaveRecord(
|
||||
employee_name=name,
|
||||
date=d,
|
||||
leave_type=lt,
|
||||
hours=hrs,
|
||||
reason=reason,
|
||||
source="manual",
|
||||
))
|
||||
added_leave += 1
|
||||
|
||||
db.commit()
|
||||
print(f" ✓ Added {added_leave} demo leave records")
|
||||
print(f" ✓ Total leave records in DB: {db.query(LeaveRecord).count()}")
|
||||
|
||||
db.close()
|
||||
print("\n" + "=" * 60)
|
||||
print("Migration complete ✓")
|
||||
print("=" * 60)
|
||||
@@ -0,0 +1,215 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, Date, JSON, ForeignKey, UniqueConstraint, Numeric
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from database import Base
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def now_hkt():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
# ============ EXISTING MODELS (kept) ============
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
email = Column(String(255), unique=True, index=True, nullable=False)
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
name = Column(String(255), nullable=False)
|
||||
role = Column(String(50), default="user")
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=now_hkt)
|
||||
updated_at = Column(DateTime, default=now_hkt, onupdate=now_hkt)
|
||||
deleted_at = Column(DateTime, nullable=True)
|
||||
|
||||
|
||||
class Accident(Base):
|
||||
__tablename__ = "accidents"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
date = Column(Date, nullable=False)
|
||||
time = Column(String(10), nullable=True)
|
||||
location = Column(String(255), nullable=False)
|
||||
employee_name = Column(String(255), nullable=False)
|
||||
employee_id = Column(String(100))
|
||||
department = Column(String(100))
|
||||
description = Column(Text, nullable=False)
|
||||
severity = Column(String(50), nullable=False)
|
||||
medical_report = Column(Text, nullable=True)
|
||||
action_taken = Column(Text, nullable=True)
|
||||
responsible_person = Column(String(255), nullable=True)
|
||||
|
||||
# Excel-derived (Google Form) extra columns (full map) -- 2026-07-18
|
||||
patient_or_staff = Column(Text, nullable=True) # Excel col 8
|
||||
long_description = Column(Text, nullable=True) # Excel col 9
|
||||
incident_photos = Column(Text, nullable=True) # Excel col 10 (URL)
|
||||
needs_escalation = Column(String(20), nullable=True) # Excel col 12
|
||||
incident_score = Column(Integer, nullable=True) # Excel col 14
|
||||
submitted_at = Column(DateTime, nullable=True) # Excel col 1 (timestamp)
|
||||
excel_row = Column(Integer, nullable=True) # original Excel row (1-based)
|
||||
|
||||
custom_fields = Column(JSON, default={})
|
||||
created_by = Column(Integer, ForeignKey("users.id"))
|
||||
created_at = Column(DateTime, default=now_hkt)
|
||||
updated_at = Column(DateTime, default=now_hkt, onupdate=now_hkt)
|
||||
deleted_at = Column(DateTime, nullable=True)
|
||||
|
||||
|
||||
# ============ NEW MODELS ============
|
||||
|
||||
class Shift(Base):
|
||||
"""班次定義"""
|
||||
__tablename__ = "shifts"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
shift_code = Column(String(20), unique=True, nullable=False, index=True)
|
||||
description = Column(String(100), nullable=True)
|
||||
|
||||
# 每天的上班時間 (HH:MM format as String)
|
||||
mon_start = Column(String(5), nullable=True)
|
||||
mon_end = Column(String(5), nullable=True)
|
||||
tue_start = Column(String(5), nullable=True)
|
||||
tue_end = Column(String(5), nullable=True)
|
||||
wed_start = Column(String(5), nullable=True)
|
||||
wed_end = Column(String(5), nullable=True)
|
||||
thu_start = Column(String(5), nullable=True)
|
||||
thu_end = Column(String(5), nullable=True)
|
||||
fri_start = Column(String(5), nullable=True)
|
||||
fri_end = Column(String(5), nullable=True)
|
||||
sat_start = Column(String(5), nullable=True)
|
||||
sat_end = Column(String(5), nullable=True)
|
||||
sun_start = Column(String(5), nullable=True)
|
||||
sun_end = Column(String(5), nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=now_hkt)
|
||||
updated_at = Column(DateTime, default=now_hkt, onupdate=now_hkt)
|
||||
|
||||
def get_schedule(self, weekday: str):
|
||||
"""取得指定星期幾的上班時間 (start, end)"""
|
||||
day_map = {
|
||||
'monday': ('mon_start', 'mon_end'),
|
||||
'tuesday': ('tue_start', 'tue_end'),
|
||||
'wednesday': ('wed_start', 'wed_end'),
|
||||
'thursday': ('thu_start', 'thu_end'),
|
||||
'friday': ('fri_start', 'fri_end'),
|
||||
'saturday': ('sat_start', 'sat_end'),
|
||||
'sunday': ('sun_start', 'sun_end'),
|
||||
# 中文
|
||||
'星期一': ('mon_start', 'mon_end'),
|
||||
'星期二': ('tue_start', 'tue_end'),
|
||||
'星期三': ('wed_start', 'wed_end'),
|
||||
'星期四': ('thu_start', 'thu_end'),
|
||||
'星期五': ('fri_start', 'fri_end'),
|
||||
'星期六': ('sat_start', 'sat_end'),
|
||||
'星期日': ('sun_start', 'sun_end'),
|
||||
'星期天': ('sun_start', 'sun_end'),
|
||||
}
|
||||
key = weekday.lower().strip()
|
||||
if key not in day_map:
|
||||
return None, None
|
||||
start_attr, end_attr = day_map[key]
|
||||
start = getattr(self, start_attr, None)
|
||||
end = getattr(self, end_attr, None)
|
||||
# 休息標記
|
||||
if start in ('-', '休息', None, ''):
|
||||
return None, None
|
||||
if end in ('-', '休息', None, ''):
|
||||
return None, None
|
||||
return start, end
|
||||
|
||||
|
||||
class AttendanceRecord(Base):
|
||||
"""出勤記錄"""
|
||||
__tablename__ = "attendance_records"
|
||||
__table_args__ = (
|
||||
UniqueConstraint('employee_name', 'date', name='uix_employee_date'),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# 員工信息
|
||||
employee_name = Column(String(255), nullable=False, index=True)
|
||||
company = Column(String(100), nullable=True)
|
||||
department = Column(String(100), nullable=True)
|
||||
|
||||
# 日期
|
||||
date = Column(Date, nullable=False, index=True)
|
||||
weekday = Column(String(20), nullable=True)
|
||||
|
||||
# 打卡時間
|
||||
check_in = Column(DateTime, nullable=True)
|
||||
check_out = Column(DateTime, nullable=True)
|
||||
|
||||
# 班次
|
||||
shift_code = Column(String(20), nullable=True)
|
||||
|
||||
# 計算結果
|
||||
status_code = Column(String(20), nullable=False, default='normal')
|
||||
status_text = Column(String(100), nullable=True)
|
||||
late_minutes = Column(Integer, default=0)
|
||||
early_minutes = Column(Integer, default=0)
|
||||
ot_minutes = Column(Integer, default=0)
|
||||
|
||||
# Expected times
|
||||
expected_in = Column(String(10), nullable=True)
|
||||
expected_out = Column(String(10), nullable=True)
|
||||
actual_in = Column(String(10), nullable=True)
|
||||
actual_out = Column(String(10), nullable=True)
|
||||
|
||||
# 人手修改標記
|
||||
is_manually_edited = Column(Boolean, default=False)
|
||||
|
||||
# 原始 Excel 數據
|
||||
raw_data = Column(JSON, default={})
|
||||
|
||||
created_at = Column(DateTime, default=now_hkt)
|
||||
updated_at = Column(DateTime, default=now_hkt, onupdate=now_hkt)
|
||||
|
||||
# Foreign key
|
||||
shift_id = Column(Integer, ForeignKey("shifts.id"), nullable=True)
|
||||
shift = relationship("Shift", foreign_keys=[shift_id])
|
||||
|
||||
|
||||
class Holiday(Base):
|
||||
"""公眾假期 / Public Holidays
|
||||
|
||||
Source: HK Gov (https://www.gov.hk/en/about/abouthk/holiday/{year}.htm)
|
||||
Auto-imported by /api/holidays/refresh endpoint, manually editable.
|
||||
"""
|
||||
__tablename__ = "holidays"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
date = Column(Date, nullable=False, unique=True, index=True)
|
||||
name = Column(String(100), nullable=False) # 中文 e.g. "元旦"
|
||||
name_en = Column(String(200), nullable=True) # English e.g. "The first day of January"
|
||||
region = Column(String(20), nullable=True, default="HK") # "HK" / "CN"
|
||||
is_mandatory = Column(Boolean, default=True) # 勞工假 vs 公假 (Sunday replacement)
|
||||
source = Column(String(50), nullable=True) # "gov_hk_1823" / "manual"
|
||||
notes = Column(Text, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=now_hkt)
|
||||
updated_at = Column(DateTime, default=now_hkt, onupdate=now_hkt)
|
||||
|
||||
|
||||
class LeaveRecord(Base):
|
||||
"""員工請假 / Employee Leave Records
|
||||
|
||||
Per-employee, per-day, per-type leave. Hours-based granularity (0.5 ~ 8.0).
|
||||
HR uploads via CSV/Excel; manually editable.
|
||||
"""
|
||||
__tablename__ = "leave_records"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
employee_name = Column(String(255), nullable=False, index=True)
|
||||
employee_id = Column(String(100), nullable=True) # future, currently NULL
|
||||
date = Column(Date, nullable=False, index=True)
|
||||
leave_type = Column(String(20), nullable=False) # "SL" / "CL" / "AL"
|
||||
hours = Column(Numeric(4, 2), nullable=False) # 0.5 ~ 8.0
|
||||
reason = Column(Text, nullable=True)
|
||||
approved_by = Column(String(100), nullable=True)
|
||||
source = Column(String(50), nullable=True) # "csv_upload" / "manual"
|
||||
notes = Column(Text, nullable=True)
|
||||
|
||||
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
created_at = Column(DateTime, default=now_hkt)
|
||||
updated_at = Column(DateTime, default=now_hkt, onupdate=now_hkt)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""HK Public Holidays data
|
||||
Source: https://www.gov.hk/en/about/abouthk/holiday/{year}.htm
|
||||
Last verified: 2026-07-19
|
||||
Format: (date ISO, name 中文, name_en English, is_mandatory)
|
||||
"""
|
||||
from datetime import date
|
||||
|
||||
# HK General Holidays 2025-2027 (gov.hk official)
|
||||
# is_mandatory = True means statutory labour holiday (法定假日)
|
||||
# False means general holiday only (銀行/學校等)
|
||||
HK_HOLIDAYS = [
|
||||
# ============ 2025 ============
|
||||
(date(2025, 1, 1), "元旦", "The first day of January", True),
|
||||
(date(2025, 1, 29), "農曆年初一", "Lunar New Year's Day", True),
|
||||
(date(2025, 1, 30), "農曆年初二", "The second day of Lunar New Year", True),
|
||||
(date(2025, 1, 31), "農曆年初三", "The third day of Lunar New Year", True),
|
||||
(date(2025, 4, 4), "清明節", "Ching Ming Festival", True),
|
||||
(date(2025, 4, 18), "耶穌受難日", "Good Friday", False),
|
||||
(date(2025, 4, 19), "耶穌受難日翌日", "The day following Good Friday", False),
|
||||
(date(2025, 4, 21), "復活節星期一", "Easter Monday", False),
|
||||
(date(2025, 5, 1), "勞動節", "Labour Day", True),
|
||||
(date(2025, 5, 5), "佛誕", "The Birthday of the Buddha", True),
|
||||
(date(2025, 5, 31), "端午節", "Tuen Ng Festival", True),
|
||||
(date(2025, 7, 1), "香港特別行政區成立紀念日", "Hong Kong Special Administrative Region Establishment Day", True),
|
||||
(date(2025, 10, 1), "國慶日", "National Day", True),
|
||||
(date(2025, 10, 7), "中秋節翌日", "The day following the Chinese Mid-Autumn Festival", False),
|
||||
(date(2025, 10, 29), "重陽節", "Chung Yeung Festival", True),
|
||||
(date(2025, 12, 25), "聖誕節", "Christmas Day", True),
|
||||
(date(2025, 12, 26), "聖誕節翌日", "The first weekday after Christmas Day", False),
|
||||
|
||||
# ============ 2026 ============
|
||||
(date(2026, 1, 1), "元旦", "The first day of January", True),
|
||||
(date(2026, 2, 17), "農曆年初一", "Lunar New Year's Day", True),
|
||||
(date(2026, 2, 18), "農曆年初二", "The second day of Lunar New Year", True),
|
||||
(date(2026, 2, 19), "農曆年初三", "The third day of Lunar New Year", True),
|
||||
(date(2026, 4, 3), "耶穌受難日", "Good Friday", False),
|
||||
(date(2026, 4, 4), "耶穌受難日翌日", "The day following Good Friday", False),
|
||||
(date(2026, 4, 6), "清明節翌日", "The day following Ching Ming Festival", True),
|
||||
(date(2026, 4, 7), "復活節星期一翌日", "The day following Easter Monday", False),
|
||||
(date(2026, 5, 1), "勞動節", "Labour Day", True),
|
||||
(date(2026, 5, 25), "佛誕翌日", "The day following the Birthday of the Buddha", True),
|
||||
(date(2026, 6, 19), "端午節", "Tuen Ng Festival", True),
|
||||
(date(2026, 7, 1), "香港特別行政區成立紀念日", "Hong Kong Special Administrative Region Establishment Day", True),
|
||||
(date(2026, 9, 26), "中秋節翌日", "The day following the Chinese Mid-Autumn Festival", False),
|
||||
(date(2026, 10, 1), "國慶日", "National Day", True),
|
||||
(date(2026, 10, 19), "重陽節翌日", "The day following Chung Yeung Festival", True),
|
||||
(date(2026, 12, 25), "聖誕節", "Christmas Day", True),
|
||||
(date(2026, 12, 26), "聖誕節翌日", "The first weekday after Christmas Day", False),
|
||||
|
||||
# ============ 2027 ============
|
||||
(date(2027, 1, 1), "元旦", "The first day of January", True),
|
||||
(date(2027, 2, 6), "農曆年初一", "Lunar New Year's Day", True),
|
||||
(date(2027, 2, 8), "農曆年初三", "The third day of Lunar New Year", True),
|
||||
(date(2027, 2, 9), "農曆年初四", "The fourth day of Lunar New Year", True),
|
||||
(date(2027, 3, 26), "耶穌受難日", "Good Friday", False),
|
||||
(date(2027, 3, 27), "耶穌受難日翌日", "The day following Good Friday", False),
|
||||
(date(2027, 3, 29), "復活節星期一", "Easter Monday", False),
|
||||
(date(2027, 4, 5), "清明節", "Ching Ming Festival", True),
|
||||
(date(2027, 5, 1), "勞動節", "Labour Day", True),
|
||||
(date(2027, 5, 13), "佛誕", "The Birthday of the Buddha", True),
|
||||
(date(2027, 6, 9), "端午節", "Tuen Ng Festival", True),
|
||||
(date(2027, 7, 1), "香港特別行政區成立紀念日", "Hong Kong Special Administrative Region Establishment Day", True),
|
||||
(date(2027, 9, 16), "中秋節翌日", "The day following the Chinese Mid-Autumn Festival", False),
|
||||
(date(2027, 10, 1), "國慶日", "National Day", True),
|
||||
(date(2027, 10, 8), "重陽節", "Chung Yeung Festival", True),
|
||||
(date(2027, 12, 25), "聖誕節", "Christmas Day", True),
|
||||
(date(2027, 12, 27), "聖誕節翌日", "The first weekday after Christmas Day", False),
|
||||
]
|
||||
|
||||
GOV_HK_URL_TEMPLATE = "https://www.gov.hk/en/about/abouthk/holiday/{year}.htm"
|
||||
|
||||
|
||||
def get_holidays_for_year(year: int):
|
||||
"""Return list of (date, name_zh, name_en, is_mandatory) for given year."""
|
||||
return [h for h in HK_HOLIDAYS if h[0].year == year]
|
||||
|
||||
|
||||
def get_all_holidays():
|
||||
"""Return full HK_HOLIDAYS list."""
|
||||
return list(HK_HOLIDAYS)
|
||||
@@ -0,0 +1,11 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.6
|
||||
sqlalchemy==2.0.35
|
||||
pydantic==2.9.2
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib==1.7.4
|
||||
python-multipart==0.0.12
|
||||
openpyxl==3.1.5
|
||||
reportlab==4.2.5
|
||||
xlsxwriter==3.2.0
|
||||
aiofiles==24.1.0
|
||||
@@ -0,0 +1,187 @@
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import date, time, datetime
|
||||
|
||||
|
||||
# ============ Auth ============
|
||||
class LoginRequest(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: int
|
||||
email: str
|
||||
name: str
|
||||
role: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============ Attendance ============
|
||||
class AttendanceBase(BaseModel):
|
||||
employee_id: str
|
||||
employee_name: str
|
||||
department: Optional[str] = None
|
||||
date: date
|
||||
check_in: Optional[time] = None
|
||||
check_out: Optional[time] = None
|
||||
overtime_hours: float = 0
|
||||
leave_type: Optional[str] = None
|
||||
leave_hours: float = 0
|
||||
status: str
|
||||
remarks: Optional[str] = None
|
||||
custom_fields: Dict[str, Any] = {}
|
||||
|
||||
|
||||
class AttendanceCreate(AttendanceBase):
|
||||
pass
|
||||
|
||||
|
||||
class AttendanceUpdate(BaseModel):
|
||||
employee_id: Optional[str] = None
|
||||
employee_name: Optional[str] = None
|
||||
department: Optional[str] = None
|
||||
date: Optional[date] = None
|
||||
check_in: Optional[time] = None
|
||||
check_out: Optional[time] = None
|
||||
overtime_hours: Optional[float] = None
|
||||
leave_type: Optional[str] = None
|
||||
leave_hours: Optional[float] = None
|
||||
status: Optional[str] = None
|
||||
remarks: Optional[str] = None
|
||||
custom_fields: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class AttendanceResponse(AttendanceBase):
|
||||
id: int
|
||||
created_by: Optional[int] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============ Accident ============
|
||||
class AccidentBase(BaseModel):
|
||||
date: date
|
||||
time: Optional[time] = None
|
||||
location: str
|
||||
employee_name: str
|
||||
employee_id: Optional[str] = None
|
||||
department: Optional[str] = None
|
||||
description: str
|
||||
severity: str
|
||||
medical_report: Optional[str] = None
|
||||
action_taken: Optional[str] = None
|
||||
responsible_person: Optional[str] = None
|
||||
custom_fields: Dict[str, Any] = {}
|
||||
|
||||
|
||||
class AccidentCreate(AccidentBase):
|
||||
pass
|
||||
|
||||
|
||||
class AccidentUpdate(BaseModel):
|
||||
date: Optional[date] = None
|
||||
time: Optional[time] = None
|
||||
location: Optional[str] = None
|
||||
employee_name: Optional[str] = None
|
||||
employee_id: Optional[str] = None
|
||||
department: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
severity: Optional[str] = None
|
||||
medical_report: Optional[str] = None
|
||||
action_taken: Optional[str] = None
|
||||
responsible_person: Optional[str] = None
|
||||
custom_fields: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class AccidentResponse(AccidentBase):
|
||||
id: int
|
||||
created_by: Optional[int] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============ Custom Fields ============
|
||||
class CustomFieldBase(BaseModel):
|
||||
field_name: str
|
||||
field_type: str
|
||||
field_key: str
|
||||
required: bool = False
|
||||
options: List[str] = []
|
||||
order: int = 0
|
||||
|
||||
|
||||
class CustomFieldCreate(CustomFieldBase):
|
||||
pass
|
||||
|
||||
|
||||
class CustomFieldResponse(CustomFieldBase):
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============ Dashboard ============
|
||||
class AttendanceStats(BaseModel):
|
||||
total: int
|
||||
today: int
|
||||
this_month: int
|
||||
by_status: Dict[str, int]
|
||||
by_department: Dict[str, int]
|
||||
trend: List[Dict[str, Any]]
|
||||
|
||||
|
||||
class AccidentStats(BaseModel):
|
||||
total: int
|
||||
this_month: int
|
||||
unresolved: int
|
||||
by_severity: Dict[str, int]
|
||||
recent: List[AccidentResponse]
|
||||
|
||||
|
||||
# ============ Import ============
|
||||
class ImportRequest(BaseModel):
|
||||
section: str # "attendance" or "accident"
|
||||
update_existing: bool = True
|
||||
|
||||
|
||||
class ImportResult(BaseModel):
|
||||
created: int
|
||||
updated: int
|
||||
skipped: int
|
||||
errors: List[str]
|
||||
|
||||
|
||||
# ============ Pagination / Filter ============
|
||||
class FilterParams(BaseModel):
|
||||
page: int = 1
|
||||
per_page: int = 20
|
||||
sort_by: Optional[str] = None
|
||||
sort_order: Optional[str] = "asc" # asc or desc
|
||||
search: Optional[str] = None
|
||||
date_from: Optional[date] = None
|
||||
date_to: Optional[date] = None
|
||||
department: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
|
||||
|
||||
class PaginatedResponse(BaseModel):
|
||||
items: List[Any]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
total_pages: int
|
||||
@@ -0,0 +1,2 @@
|
||||
# AARS JWT Secret - CHANGE THIS IN PRODUCTION!
|
||||
JWT_SECRET = "aars-jwt-secret-2026-prod-do-not-share"
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Quick test script for AARS backend - run locally without Docker"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add backend to path
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
# Generate secret if missing
|
||||
if not os.path.exists("secret.py"):
|
||||
with open("secret.py", "w") as f:
|
||||
f.write(f"JWT_SECRET = 'dev-secret-{os.urandom(16).hex()}'\n")
|
||||
|
||||
# Import and run
|
||||
from database import init_db, SessionLocal
|
||||
from models import User
|
||||
|
||||
print("🧪 Testing AARS Backend...")
|
||||
print()
|
||||
|
||||
# Test 1: Database init
|
||||
print("1️⃣ Testing database initialization...")
|
||||
try:
|
||||
init_db()
|
||||
print(" ✅ Database initialized")
|
||||
except Exception as e:
|
||||
print(f" ❌ Database init failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Test 2: Check admin user
|
||||
print("2️⃣ Testing admin user...")
|
||||
try:
|
||||
db = SessionLocal()
|
||||
admin = db.query(User).filter(User.email == "admin@aars.hk").first()
|
||||
if admin:
|
||||
print(f" ✅ Admin exists: {admin.email}")
|
||||
else:
|
||||
print(" ⚠️ Admin not found")
|
||||
db.close()
|
||||
except Exception as e:
|
||||
print(f" ❌ Query failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Test 3: Test password hashing
|
||||
print("3️⃣ Testing password hashing...")
|
||||
try:
|
||||
from database import get_password_hash, verify_password
|
||||
pw_hash = get_password_hash("test123")
|
||||
assert verify_password("test123", pw_hash)
|
||||
print(" ✅ Password hashing works")
|
||||
except Exception as e:
|
||||
print(f" ❌ Password hashing failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
print()
|
||||
print("🎉 All basic tests passed!")
|
||||
print()
|
||||
print("To run the backend:")
|
||||
print(" cd backend")
|
||||
print(" pip install -r requirements.txt")
|
||||
print(" uvicorn main:app --reload --port 8000")
|
||||
print()
|
||||
print("Then visit: http://localhost:8000")
|
||||
print("API docs: http://localhost:8000/docs")
|
||||
Reference in New Issue
Block a user