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
This commit is contained in:
147
app/api/routers/calendar.py
Normal file
147
app/api/routers/calendar.py
Normal file
@@ -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)
|
||||
19
app/main.py
19
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()
|
||||
|
||||
|
||||
66
app/models/minimum_workload.py
Normal file
66
app/models/minimum_workload.py
Normal file
@@ -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())
|
||||
63
app/schemas/calendar.py
Normal file
63
app/schemas/calendar.py
Normal file
@@ -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")
|
||||
144
app/services/minimum_workload.py
Normal file
144
app/services/minimum_workload.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user