From eb57197020e8d8d1ae51278e13c13515f07617c1 Mon Sep 17 00:00:00 2001 From: zhi Date: Mon, 30 Mar 2026 22:27:05 +0000 Subject: [PATCH] BE-CAL-004: implement MinimumWorkload storage - New model: minimum_workloads table with JSON config column (per-user) - Schemas: MinimumWorkloadConfig, MinimumWorkloadUpdate, MinimumWorkloadResponse - Service: CRUD operations + check_workload_warnings() entry point for BE-CAL-007 - API: GET/PUT/PATCH /calendar/workload-config (self + admin routes) - Migration: auto-create minimum_workloads table on startup - Registered calendar router in main.py --- app/api/routers/calendar.py | 147 +++++++++++++++++++++++++++++++ app/main.py | 19 +++- app/models/minimum_workload.py | 66 ++++++++++++++ app/schemas/calendar.py | 63 +++++++++++++ app/services/minimum_workload.py | 144 ++++++++++++++++++++++++++++++ 5 files changed, 438 insertions(+), 1 deletion(-) create mode 100644 app/api/routers/calendar.py create mode 100644 app/models/minimum_workload.py create mode 100644 app/schemas/calendar.py create mode 100644 app/services/minimum_workload.py diff --git a/app/api/routers/calendar.py b/app/api/routers/calendar.py new file mode 100644 index 0000000..a6d8790 --- /dev/null +++ b/app/api/routers/calendar.py @@ -0,0 +1,147 @@ +"""Calendar API router. + +BE-CAL-004: MinimumWorkload CRUD endpoints. +Future tasks (BE-CAL-API-*) will add slot/plan endpoints here. +""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.api.deps import get_current_user +from app.core.config import get_db +from app.models.models import User +from app.schemas.calendar import ( + MinimumWorkloadConfig, + MinimumWorkloadResponse, + MinimumWorkloadUpdate, +) +from app.services.minimum_workload import ( + get_workload_config, + replace_workload_config, + upsert_workload_config, +) + +router = APIRouter(prefix="/calendar", tags=["Calendar"]) + + +# --------------------------------------------------------------------------- +# MinimumWorkload +# --------------------------------------------------------------------------- + +@router.get( + "/workload-config", + response_model=MinimumWorkloadResponse, + summary="Get current user's minimum workload configuration", +) +def get_my_workload_config( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Return the workload thresholds for the authenticated user. + + If no configuration has been saved yet, returns default (all-zero) + thresholds. + """ + cfg = get_workload_config(db, current_user.id) + return MinimumWorkloadResponse(user_id=current_user.id, config=cfg) + + +@router.put( + "/workload-config", + response_model=MinimumWorkloadResponse, + summary="Replace the current user's minimum workload configuration", +) +def put_my_workload_config( + payload: MinimumWorkloadConfig, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Full replacement of the workload configuration.""" + row = replace_workload_config(db, current_user.id, payload) + db.commit() + db.refresh(row) + return MinimumWorkloadResponse(user_id=current_user.id, config=row.config) + + +@router.patch( + "/workload-config", + response_model=MinimumWorkloadResponse, + summary="Partially update the current user's minimum workload configuration", +) +def patch_my_workload_config( + payload: MinimumWorkloadUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Partial update — only the provided periods are overwritten.""" + row = upsert_workload_config(db, current_user.id, payload) + db.commit() + db.refresh(row) + return MinimumWorkloadResponse(user_id=current_user.id, config=row.config) + + +# --------------------------------------------------------------------------- +# Admin: manage another user's workload config +# --------------------------------------------------------------------------- + +def _require_admin(current_user: User = Depends(get_current_user)): + if not current_user.is_admin: + raise HTTPException(status_code=403, detail="Admin required") + return current_user + + +@router.get( + "/workload-config/{user_id}", + response_model=MinimumWorkloadResponse, + summary="[Admin] Get a specific user's minimum workload configuration", +) +def get_user_workload_config( + user_id: int, + db: Session = Depends(get_db), + _admin: User = Depends(_require_admin), +): + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + cfg = get_workload_config(db, user_id) + return MinimumWorkloadResponse(user_id=user_id, config=cfg) + + +@router.put( + "/workload-config/{user_id}", + response_model=MinimumWorkloadResponse, + summary="[Admin] Replace a specific user's minimum workload configuration", +) +def put_user_workload_config( + user_id: int, + payload: MinimumWorkloadConfig, + db: Session = Depends(get_db), + _admin: User = Depends(_require_admin), +): + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + row = replace_workload_config(db, user_id, payload) + db.commit() + db.refresh(row) + return MinimumWorkloadResponse(user_id=user_id, config=row.config) + + +@router.patch( + "/workload-config/{user_id}", + response_model=MinimumWorkloadResponse, + summary="[Admin] Partially update a specific user's minimum workload configuration", +) +def patch_user_workload_config( + user_id: int, + payload: MinimumWorkloadUpdate, + db: Session = Depends(get_db), + _admin: User = Depends(_require_admin), +): + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + row = upsert_workload_config(db, user_id, payload) + db.commit() + db.refresh(row) + return MinimumWorkloadResponse(user_id=user_id, config=row.config) diff --git a/app/main.py b/app/main.py index 9627064..3dd9b9d 100644 --- a/app/main.py +++ b/app/main.py @@ -62,6 +62,7 @@ from app.api.routers.proposes import router as proposes_router # legacy compat from app.api.routers.milestone_actions import router as milestone_actions_router from app.api.routers.meetings import router as meetings_router from app.api.routers.essentials import router as essentials_router +from app.api.routers.calendar import router as calendar_router app.include_router(auth_router) app.include_router(tasks_router) @@ -78,6 +79,7 @@ app.include_router(proposes_router) # legacy compat app.include_router(milestone_actions_router) app.include_router(meetings_router) app.include_router(essentials_router) +app.include_router(calendar_router) # Auto schema migration for lightweight deployments @@ -303,6 +305,21 @@ def _migrate_schema(): ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 """)) + # --- minimum_workloads table (BE-CAL-004) --- + if not _has_table(db, "minimum_workloads"): + db.execute(text(""" + CREATE TABLE minimum_workloads ( + id INTEGER NOT NULL AUTO_INCREMENT, + user_id INTEGER NOT NULL, + config JSON NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE INDEX idx_minimum_workloads_user_id (user_id), + CONSTRAINT fk_minimum_workloads_user_id FOREIGN KEY (user_id) REFERENCES users(id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """)) + db.commit() except Exception as e: db.rollback() @@ -337,7 +354,7 @@ def _sync_default_user_roles(db): @app.on_event("startup") def startup(): from app.core.config import Base, engine, SessionLocal - from app.models import models, webhook, apikey, activity, milestone, notification, worklog, monitor, role_permission, task, support, meeting, proposal, propose, essential, agent, calendar + from app.models import models, webhook, apikey, activity, milestone, notification, worklog, monitor, role_permission, task, support, meeting, proposal, propose, essential, agent, calendar, minimum_workload Base.metadata.create_all(bind=engine) _migrate_schema() diff --git a/app/models/minimum_workload.py b/app/models/minimum_workload.py new file mode 100644 index 0000000..d29bcb0 --- /dev/null +++ b/app/models/minimum_workload.py @@ -0,0 +1,66 @@ +"""MinimumWorkload model — per-user workload threshold configuration. + +Stores the minimum expected workload (in minutes) across four periods +(daily / weekly / monthly / yearly) and three slot categories +(work / on_call / entertainment). Values are advisory: when a +calendar submission would leave the user below these thresholds, the +system returns a *warning* but does not block the operation. + +Storage decision (BE-CAL-004): independent table with a JSON column. +This keeps the User model clean while giving each user exactly one +configuration row. The JSON structure matches the design document: + + { + "daily": {"work": 0, "on_call": 0, "entertainment": 0}, + "weekly": {"work": 0, "on_call": 0, "entertainment": 0}, + "monthly": {"work": 0, "on_call": 0, "entertainment": 0}, + "yearly": {"work": 0, "on_call": 0, "entertainment": 0} + } + +All values are minutes in range [0, 65535]. +""" + +from sqlalchemy import Column, Integer, ForeignKey, JSON, DateTime +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from app.core.config import Base + + +# Default configuration — all thresholds zeroed out (no warnings). +DEFAULT_WORKLOAD_CONFIG: dict = { + "daily": {"work": 0, "on_call": 0, "entertainment": 0}, + "weekly": {"work": 0, "on_call": 0, "entertainment": 0}, + "monthly": {"work": 0, "on_call": 0, "entertainment": 0}, + "yearly": {"work": 0, "on_call": 0, "entertainment": 0}, +} + +PERIODS = ("daily", "weekly", "monthly", "yearly") +CATEGORIES = ("work", "on_call", "entertainment") + + +class MinimumWorkload(Base): + """Per-user minimum workload configuration.""" + + __tablename__ = "minimum_workloads" + + id = Column(Integer, primary_key=True, index=True) + + user_id = Column( + Integer, + ForeignKey("users.id"), + nullable=False, + unique=True, + index=True, + comment="One config row per user", + ) + + config = Column( + JSON, + nullable=False, + default=lambda: dict(DEFAULT_WORKLOAD_CONFIG), + comment="Workload thresholds JSON — see module docstring for schema", + ) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) diff --git a/app/schemas/calendar.py b/app/schemas/calendar.py new file mode 100644 index 0000000..1ad97c8 --- /dev/null +++ b/app/schemas/calendar.py @@ -0,0 +1,63 @@ +"""Calendar-related Pydantic schemas. + +BE-CAL-004: MinimumWorkload read/write schemas. +""" + +from __future__ import annotations + +from pydantic import BaseModel, Field, model_validator +from typing import Optional + + +# --------------------------------------------------------------------------- +# MinimumWorkload +# --------------------------------------------------------------------------- + +class WorkloadCategoryThresholds(BaseModel): + """Minutes thresholds per slot category within a single period.""" + work: int = Field(0, ge=0, le=65535, description="Minutes of work-type slots") + on_call: int = Field(0, ge=0, le=65535, description="Minutes of on-call-type slots") + entertainment: int = Field(0, ge=0, le=65535, description="Minutes of entertainment-type slots") + + +class MinimumWorkloadConfig(BaseModel): + """Full workload configuration across all four periods.""" + daily: WorkloadCategoryThresholds = Field(default_factory=WorkloadCategoryThresholds) + weekly: WorkloadCategoryThresholds = Field(default_factory=WorkloadCategoryThresholds) + monthly: WorkloadCategoryThresholds = Field(default_factory=WorkloadCategoryThresholds) + yearly: WorkloadCategoryThresholds = Field(default_factory=WorkloadCategoryThresholds) + + +class MinimumWorkloadUpdate(BaseModel): + """Partial update — only provided periods/categories are overwritten. + + Accepts the same shape as ``MinimumWorkloadConfig`` but every field + is optional so callers can PATCH individual periods. + """ + daily: Optional[WorkloadCategoryThresholds] = None + weekly: Optional[WorkloadCategoryThresholds] = None + monthly: Optional[WorkloadCategoryThresholds] = None + yearly: Optional[WorkloadCategoryThresholds] = None + + +class MinimumWorkloadResponse(BaseModel): + """API response for workload configuration.""" + user_id: int + config: MinimumWorkloadConfig + + class Config: + from_attributes = True + + +# --------------------------------------------------------------------------- +# Workload warning (used by future calendar validation endpoints) +# --------------------------------------------------------------------------- + +class WorkloadWarningItem(BaseModel): + """A single workload warning returned alongside a calendar mutation.""" + period: str = Field(..., description="daily | weekly | monthly | yearly") + category: str = Field(..., description="work | on_call | entertainment") + current_minutes: int = Field(..., ge=0, description="Current scheduled minutes in the period") + minimum_minutes: int = Field(..., ge=0, description="Configured minimum threshold") + shortfall_minutes: int = Field(..., ge=0, description="How many minutes below threshold") + message: str = Field(..., description="Human-readable warning") diff --git a/app/services/minimum_workload.py b/app/services/minimum_workload.py new file mode 100644 index 0000000..2fa3b75 --- /dev/null +++ b/app/services/minimum_workload.py @@ -0,0 +1,144 @@ +"""MinimumWorkload service — CRUD and validation helpers. + +BE-CAL-004: user-level workload config read/write + future validation entry point. +""" + +from __future__ import annotations + +import copy +from typing import Optional + +from sqlalchemy.orm import Session + +from app.models.minimum_workload import ( + DEFAULT_WORKLOAD_CONFIG, + CATEGORIES, + PERIODS, + MinimumWorkload, +) +from app.schemas.calendar import ( + MinimumWorkloadConfig, + MinimumWorkloadUpdate, + WorkloadWarningItem, +) + + +# --------------------------------------------------------------------------- +# Read +# --------------------------------------------------------------------------- + +def get_workload_config(db: Session, user_id: int) -> dict: + """Return the raw config dict for *user_id*, falling back to defaults.""" + row = db.query(MinimumWorkload).filter(MinimumWorkload.user_id == user_id).first() + if row is None: + return copy.deepcopy(DEFAULT_WORKLOAD_CONFIG) + return row.config + + +def get_workload_row(db: Session, user_id: int) -> Optional[MinimumWorkload]: + """Return the ORM row or None.""" + return db.query(MinimumWorkload).filter(MinimumWorkload.user_id == user_id).first() + + +# --------------------------------------------------------------------------- +# Write (upsert) +# --------------------------------------------------------------------------- + +def upsert_workload_config( + db: Session, + user_id: int, + update: MinimumWorkloadUpdate, +) -> MinimumWorkload: + """Create or update the workload config for *user_id*. + + Only the periods present in *update* are overwritten; the rest keep + their current (or default) values. + """ + row = db.query(MinimumWorkload).filter(MinimumWorkload.user_id == user_id).first() + + if row is None: + row = MinimumWorkload( + user_id=user_id, + config=copy.deepcopy(DEFAULT_WORKLOAD_CONFIG), + ) + db.add(row) + + # Merge provided periods into existing config + current = copy.deepcopy(row.config) if row.config else copy.deepcopy(DEFAULT_WORKLOAD_CONFIG) + + for period in PERIODS: + period_data = getattr(update, period, None) + if period_data is not None: + current[period] = period_data.model_dump() + + # Ensure JSON column is flagged as dirty for SQLAlchemy + row.config = current + db.flush() + return row + + +def replace_workload_config( + db: Session, + user_id: int, + config: MinimumWorkloadConfig, +) -> MinimumWorkload: + """Full replace of the workload config for *user_id*.""" + row = db.query(MinimumWorkload).filter(MinimumWorkload.user_id == user_id).first() + + if row is None: + row = MinimumWorkload(user_id=user_id, config=config.model_dump()) + db.add(row) + else: + row.config = config.model_dump() + + db.flush() + return row + + +# --------------------------------------------------------------------------- +# Validation entry point (BE-CAL-007 will flesh this out) +# --------------------------------------------------------------------------- + +def check_workload_warnings( + db: Session, + user_id: int, + scheduled_minutes: dict[str, dict[str, int]], +) -> list[WorkloadWarningItem]: + """Compare *scheduled_minutes* against the user's configured thresholds. + + ``scheduled_minutes`` has the same shape as the config: + {"daily": {"work": N, ...}, "weekly": {...}, ...} + + Returns a list of warnings for every (period, category) where the + scheduled total is below the minimum. An empty list means no warnings. + + This is the entry point that BE-CAL-007 and the calendar API endpoints + will call. + """ + config = get_workload_config(db, user_id) + warnings: list[WorkloadWarningItem] = [] + + for period in PERIODS: + cfg_period = config.get(period, {}) + sch_period = scheduled_minutes.get(period, {}) + for cat in CATEGORIES: + minimum = cfg_period.get(cat, 0) + if minimum <= 0: + continue + current = sch_period.get(cat, 0) + if current < minimum: + shortfall = minimum - current + warnings.append(WorkloadWarningItem( + period=period, + category=cat, + current_minutes=current, + minimum_minutes=minimum, + shortfall_minutes=shortfall, + message=( + f"{period.capitalize()} {cat.replace('_', '-')} workload " + f"is {current} min, below minimum of {minimum} min " + f"(shortfall: {shortfall} min)" + ), + )) + + return warnings