Initial commit: AARS backend + frontend + holiday/leave phase 1+2

This commit is contained in:
IT Dog
2026-07-19 20:10:15 +08:00
commit bbc0048c24
49 changed files with 13375 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
logs/
+72
View File
@@ -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
+72
View File
@@ -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()
+251
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+98
View File
@@ -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},
)
+96
View File
@@ -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)
+215
View File
@@ -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)
+80
View File
@@ -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)
+11
View File
@@ -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
+187
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
# AARS JWT Secret - CHANGE THIS IN PRODUCTION!
JWT_SECRET = "aars-jwt-secret-2026-prod-do-not-share"
+65
View File
@@ -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")