Skip to content
116 changes: 116 additions & 0 deletions src/api/v1/endpoints/plans.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""Training plan (calendar) endpoints — member only."""

from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session

from src.api.v1.endpoints.auth import get_current_user
from src.db.database import get_db
from src.db.models import SavedPlan, User
from src.models.plan_schema import CompleteDayRequest, SavedPlanResponse, SavePlanRequest
from src.models.response_schema import SuccessResponse

router = APIRouter()


@router.post("/", response_model=SuccessResponse[SavedPlanResponse])
def save_plan(
req: SavePlanRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> SuccessResponse[SavedPlanResponse]:
plan = SavedPlan(
user_id=current_user.id,
plan_type=req.plan_type,
title=req.title,
data=req.data,
start_date=req.start_date,
total_days=req.total_days,
completed_days=[],
)
db.add(plan)
db.commit()
db.refresh(plan)
return SuccessResponse(data=SavedPlanResponse.model_validate(plan))


@router.get("/", response_model=SuccessResponse[list[SavedPlanResponse]])
def get_plans(
year: int = Query(..., ge=1, le=9999),
month: int = Query(..., ge=1, le=12),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> SuccessResponse[list[SavedPlanResponse]]:
from calendar import monthrange
from datetime import date

first_day = date(year, month, 1)
last_day = date(year, month, monthrange(year, month)[1])

plans = (
db.query(SavedPlan)
.filter(
SavedPlan.user_id == current_user.id,
SavedPlan.start_date <= last_day,
)
.order_by(SavedPlan.start_date)
.all()
)

# Filter: plans whose date range overlaps with the requested month
result = []
for plan in plans:
from datetime import timedelta

plan_end = plan.start_date + timedelta(days=plan.total_days - 1)
if plan_end >= first_day:
result.append(SavedPlanResponse.model_validate(plan))

return SuccessResponse(data=result)


@router.patch("/{plan_id}/complete", response_model=SuccessResponse[SavedPlanResponse])
def complete_plan_day(
plan_id: int,
req: CompleteDayRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> SuccessResponse[SavedPlanResponse]:
plan = db.query(SavedPlan).filter(
SavedPlan.id == plan_id,
SavedPlan.user_id == current_user.id,
).first()
if not plan:
raise HTTPException(status_code=404, detail="Plan not found")

if req.day_number < 1 or req.day_number > plan.total_days:
raise HTTPException(status_code=422, detail="day_number out of range")

completed: list[int] = list(plan.completed_days or [])
if req.completed:
if req.day_number not in completed:
completed.append(req.day_number)
else:
completed = [d for d in completed if d != req.day_number]
Comment thread
zweadfx marked this conversation as resolved.

plan.completed_days = completed
db.commit()
db.refresh(plan)
return SuccessResponse(data=SavedPlanResponse.model_validate(plan))
Comment thread
zweadfx marked this conversation as resolved.


@router.delete("/{plan_id}", response_model=SuccessResponse[None])
def delete_plan(
plan_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> SuccessResponse[None]:
plan = db.query(SavedPlan).filter(
SavedPlan.id == plan_id,
SavedPlan.user_id == current_user.id,
).first()
if not plan:
raise HTTPException(status_code=404, detail="Plan not found")

db.delete(plan)
db.commit()
return SuccessResponse(message="Plan deleted successfully.")
3 changes: 2 additions & 1 deletion src/api/v1/router.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
from fastapi import APIRouter

from src.api.v1.endpoints import auth, gear, skill, whistle
from src.api.v1.endpoints import auth, gear, plans, skill, whistle

api_router = APIRouter()

api_router.include_router(auth.router, prefix="/auth", tags=["Auth"])
api_router.include_router(skill.router, prefix="/skill", tags=["Skill Lab"])
api_router.include_router(gear.router, prefix="/gear", tags=["Gear Advisor"])
api_router.include_router(whistle.router, prefix="/whistle", tags=["The Whistle"])
api_router.include_router(plans.router, prefix="/plans", tags=["Plans"])
24 changes: 22 additions & 2 deletions src/db/models.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"""SQLAlchemy ORM models."""

from datetime import datetime, timezone
from datetime import date, datetime, timezone
from typing import Any

from sqlalchemy import DateTime, Integer, String
from sqlalchemy import Date, DateTime, ForeignKey, Integer, JSON, String
from sqlalchemy.orm import Mapped, mapped_column

from src.db.database import Base
Expand All @@ -19,3 +20,22 @@ class User(Base):
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
)


class SavedPlan(Base):
__tablename__ = "saved_plans"

id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
plan_type: Mapped[str] = mapped_column(String(10), nullable=False) # "weekly" | "skill"
title: Mapped[str] = mapped_column(String(200), nullable=False)
data: Mapped[Any] = mapped_column(JSON, nullable=False)
start_date: Mapped[date] = mapped_column(Date, nullable=False)
total_days: Mapped[int] = mapped_column(Integer, nullable=False)
completed_days: Mapped[Any] = mapped_column(JSON, nullable=False, default=list)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
)
32 changes: 32 additions & 0 deletions src/models/plan_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Pydantic schemas for training plan (calendar) endpoints."""

from datetime import date, datetime
from typing import Any, Literal

from pydantic import BaseModel, Field


class SavePlanRequest(BaseModel):
plan_type: Literal["weekly", "skill"]
title: str = Field(..., min_length=1, max_length=200)
data: dict[str, Any]
start_date: date
total_days: int = Field(..., ge=1)


class SavedPlanResponse(BaseModel):
id: int
plan_type: str
title: str
data: dict[str, Any]
start_date: date
total_days: int
completed_days: list[int]
created_at: datetime

model_config = {"from_attributes": True}


class CompleteDayRequest(BaseModel):
day_number: int = Field(..., ge=1)
completed: bool
Comment thread
zweadfx marked this conversation as resolved.
Loading