diff --git a/backend/alembic/versions/0fd2a90a9c2c_add_maintenance_schedules_table.py b/backend/alembic/versions/0fd2a90a9c2c_add_maintenance_schedules_table.py new file mode 100644 index 00000000..14e518b3 --- /dev/null +++ b/backend/alembic/versions/0fd2a90a9c2c_add_maintenance_schedules_table.py @@ -0,0 +1,44 @@ +"""add maintenance_schedules table + +Revision ID: 0fd2a90a9c2c +Revises: 6e8128ef2aa8 +Create Date: 2026-02-17 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '0fd2a90a9c2c' +down_revision = '6e8128ef2aa8' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + 'maintenance_schedules', + sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column('tree_id', postgresql.UUID(as_uuid=True), + sa.ForeignKey('trees.id', ondelete='CASCADE'), nullable=False), + sa.Column('created_by', postgresql.UUID(as_uuid=True), + sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True), + sa.Column('cron_expression', sa.String(100), nullable=False), + sa.Column('timezone', sa.String(100), nullable=False, server_default='UTC'), + sa.Column('target_list_id', postgresql.UUID(as_uuid=True), + sa.ForeignKey('target_lists.id', ondelete='SET NULL'), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'), + sa.Column('next_run_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('last_run_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + ) + op.create_index('ix_maintenance_schedules_tree_id', 'maintenance_schedules', ['tree_id']) + op.create_unique_constraint('uq_maintenance_schedules_tree_id', 'maintenance_schedules', ['tree_id']) + + +def downgrade() -> None: + op.drop_constraint('uq_maintenance_schedules_tree_id', 'maintenance_schedules', type_='unique') + op.drop_index('ix_maintenance_schedules_tree_id', table_name='maintenance_schedules') + op.drop_table('maintenance_schedules') diff --git a/backend/app/api/endpoints/maintenance_schedules.py b/backend/app/api/endpoints/maintenance_schedules.py new file mode 100644 index 00000000..b1703dd9 --- /dev/null +++ b/backend/app/api/endpoints/maintenance_schedules.py @@ -0,0 +1,115 @@ +"""Maintenance schedule CRUD endpoints.""" +from typing import Annotated +from uuid import UUID +from datetime import datetime, timezone +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from croniter import croniter +import pytz + +from app.api.deps import get_current_active_user, get_db +from app.models.maintenance_schedule import MaintenanceSchedule +from app.models.user import User +from app.schemas.maintenance_schedule import ( + MaintenanceScheduleCreate, + MaintenanceScheduleUpdate, + MaintenanceScheduleResponse, +) + +router = APIRouter(prefix="/maintenance-schedules", tags=["maintenance-schedules"]) + + +def _compute_next_run(cron_expression: str, tz_name: str) -> datetime: + """Compute next run time from cron expression, returned as UTC.""" + tz = pytz.timezone(tz_name) + now = datetime.now(tz) + cron = croniter(cron_expression, now) + return cron.get_next(datetime).astimezone(timezone.utc) + + +@router.post("", response_model=MaintenanceScheduleResponse, status_code=201) +async def create_schedule( + data: MaintenanceScheduleCreate, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +): + """Create a cron schedule for a maintenance flow. One per flow.""" + # Check no existing schedule for this tree + existing = await db.execute( + select(MaintenanceSchedule).where(MaintenanceSchedule.tree_id == data.tree_id) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=409, detail="Schedule already exists for this tree") + + try: + next_run = _compute_next_run(data.cron_expression, data.timezone) + except (ValueError, KeyError) as e: + raise HTTPException(status_code=422, detail=f"Invalid cron expression or timezone: {e}") + + schedule = MaintenanceSchedule( + tree_id=data.tree_id, + created_by=current_user.id, + cron_expression=data.cron_expression, + timezone=data.timezone, + target_list_id=data.target_list_id, + is_active=True, + next_run_at=next_run, + ) + db.add(schedule) + await db.commit() + await db.refresh(schedule) + return schedule + + +@router.get("/tree/{tree_id}", response_model=MaintenanceScheduleResponse) +async def get_schedule_for_tree( + tree_id: UUID, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +): + """Get the schedule for a specific maintenance flow.""" + result = await db.execute( + select(MaintenanceSchedule).where(MaintenanceSchedule.tree_id == tree_id) + ) + schedule = result.scalar_one_or_none() + if not schedule: + raise HTTPException(status_code=404, detail="No schedule found for this tree") + return schedule + + +@router.patch("/{schedule_id}", response_model=MaintenanceScheduleResponse) +async def update_schedule( + schedule_id: UUID, + data: MaintenanceScheduleUpdate, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +): + """Update a schedule (disable, change cron, change timezone, change target list).""" + result = await db.execute( + select(MaintenanceSchedule).where(MaintenanceSchedule.id == schedule_id) + ) + schedule = result.scalar_one_or_none() + if not schedule: + raise HTTPException(status_code=404, detail="Schedule not found") + + update_fields = data.model_fields_set + if "cron_expression" in update_fields and data.cron_expression is not None: + schedule.cron_expression = data.cron_expression + if "timezone" in update_fields and data.timezone is not None: + schedule.timezone = data.timezone + if "target_list_id" in update_fields: + schedule.target_list_id = data.target_list_id + if "is_active" in update_fields and data.is_active is not None: + schedule.is_active = data.is_active + + # Recompute next_run_at if schedule timing changed + if "cron_expression" in update_fields or "timezone" in update_fields: + try: + schedule.next_run_at = _compute_next_run(schedule.cron_expression, schedule.timezone) + except (ValueError, KeyError) as e: + raise HTTPException(status_code=422, detail=f"Invalid cron expression or timezone: {e}") + + await db.commit() + await db.refresh(schedule) + return schedule diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 228cce49..08580d80 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -3,6 +3,7 @@ from app.api.endpoints import auth, trees, sessions, invite, categories, tags, f from app.api.endpoints import admin_dashboard, admin_audit, admin_plan_limits, admin_feature_flags, admin_settings, admin_categories from app.api.endpoints import ratings, analytics from app.api.endpoints import target_lists +from app.api.endpoints import maintenance_schedules api_router = APIRouter() @@ -30,3 +31,4 @@ api_router.include_router(tree_markdown.router) api_router.include_router(ratings.router) api_router.include_router(analytics.router) api_router.include_router(target_lists.router) +api_router.include_router(maintenance_schedules.router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 52b28ff3..8638db7c 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -23,6 +23,7 @@ from .feature_flag import FeatureFlag, PlanFeatureDefault, AccountFeatureOverrid from .platform_setting import PlatformSetting from .user_pinned_tree import UserPinnedTree from .target_list import TargetList +from .maintenance_schedule import MaintenanceSchedule __all__ = [ "User", @@ -57,4 +58,5 @@ __all__ = [ "PlatformSetting", "UserPinnedTree", "TargetList", + "MaintenanceSchedule", ] diff --git a/backend/app/models/maintenance_schedule.py b/backend/app/models/maintenance_schedule.py new file mode 100644 index 00000000..91280eb4 --- /dev/null +++ b/backend/app/models/maintenance_schedule.py @@ -0,0 +1,45 @@ +import uuid +from datetime import datetime, timezone +from typing import Optional +from sqlalchemy import String, DateTime, ForeignKey, Boolean, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.postgresql import UUID +from app.core.database import Base + + +class MaintenanceSchedule(Base): + __tablename__ = "maintenance_schedules" + __table_args__ = ( + UniqueConstraint("tree_id", name="uq_maintenance_schedules_tree_id"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + tree_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("trees.id", ondelete="CASCADE"), + nullable=False, index=True + ) + created_by: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + cron_expression: Mapped[str] = mapped_column(String(100), nullable=False) + timezone: Mapped[str] = mapped_column(String(100), nullable=False, default="UTC") + target_list_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("target_lists.id", ondelete="SET NULL"), nullable=True + ) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + next_run_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True + ) + last_run_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) diff --git a/backend/app/schemas/maintenance_schedule.py b/backend/app/schemas/maintenance_schedule.py new file mode 100644 index 00000000..5a191b2e --- /dev/null +++ b/backend/app/schemas/maintenance_schedule.py @@ -0,0 +1,34 @@ +from datetime import datetime +from typing import Optional +from uuid import UUID +from pydantic import BaseModel, Field + + +class MaintenanceScheduleCreate(BaseModel): + tree_id: UUID + cron_expression: str = Field(..., min_length=9, max_length=100) + timezone: str = Field("UTC", max_length=100) + target_list_id: Optional[UUID] = None + + +class MaintenanceScheduleUpdate(BaseModel): + cron_expression: Optional[str] = Field(None, min_length=9, max_length=100) + timezone: Optional[str] = Field(None, max_length=100) + target_list_id: Optional[UUID] = None + is_active: Optional[bool] = None + + +class MaintenanceScheduleResponse(BaseModel): + id: UUID + tree_id: UUID + created_by: Optional[UUID] + cron_expression: str + timezone: str + target_list_id: Optional[UUID] + is_active: bool + next_run_at: Optional[datetime] + last_run_at: Optional[datetime] + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} diff --git a/backend/requirements.txt b/backend/requirements.txt index 28975527..686c481e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -30,3 +30,6 @@ resend==2.21.0 # Utilities python-dotenv==1.0.1 +croniter>=2.0.0 +pytz>=2024.1 +apscheduler>=3.10.4 diff --git a/backend/tests/test_maintenance_schedules.py b/backend/tests/test_maintenance_schedules.py new file mode 100644 index 00000000..954339c4 --- /dev/null +++ b/backend/tests/test_maintenance_schedules.py @@ -0,0 +1,98 @@ +"""Tests for maintenance schedule CRUD.""" +import pytest +from httpx import AsyncClient + + +async def _create_maintenance_tree(client, headers): + resp = await client.post( + "/api/v1/trees", + json={ + "name": "Scheduled Patch", + "tree_type": "maintenance", + "tree_structure": { + "steps": [ + {"id": "s1", "type": "procedure_step", "title": "Step", + "description": "Do it", "content_type": "action"}, + {"id": "end", "type": "procedure_end", "title": "Done"}, + ] + }, + }, + headers=headers, + ) + assert resp.status_code == 201, resp.text + return resp.json()["id"] + + +@pytest.mark.asyncio +async def test_create_schedule(client: AsyncClient, auth_headers: dict): + tree_id = await _create_maintenance_tree(client, auth_headers) + resp = await client.post( + "/api/v1/maintenance-schedules", + json={ + "tree_id": tree_id, + "cron_expression": "0 9 15 * *", + "timezone": "America/New_York", + }, + headers=auth_headers, + ) + assert resp.status_code == 201, resp.text + data = resp.json() + assert data["cron_expression"] == "0 9 15 * *" + assert data["timezone"] == "America/New_York" + assert data["is_active"] is True + assert data["next_run_at"] is not None + + +@pytest.mark.asyncio +async def test_duplicate_schedule_rejected(client: AsyncClient, auth_headers: dict): + """Cannot create two schedules for the same tree.""" + tree_id = await _create_maintenance_tree(client, auth_headers) + await client.post( + "/api/v1/maintenance-schedules", + json={"tree_id": tree_id, "cron_expression": "0 0 1 * *", "timezone": "UTC"}, + headers=auth_headers, + ) + resp = await client.post( + "/api/v1/maintenance-schedules", + json={"tree_id": tree_id, "cron_expression": "0 6 1 * *", "timezone": "UTC"}, + headers=auth_headers, + ) + assert resp.status_code == 409 + + +@pytest.mark.asyncio +async def test_get_schedule_for_tree(client: AsyncClient, auth_headers: dict): + tree_id = await _create_maintenance_tree(client, auth_headers) + await client.post( + "/api/v1/maintenance-schedules", + json={"tree_id": tree_id, "cron_expression": "0 0 1 * *", "timezone": "UTC"}, + headers=auth_headers, + ) + resp = await client.get(f"/api/v1/maintenance-schedules/tree/{tree_id}", headers=auth_headers) + assert resp.status_code == 200 + assert resp.json()["cron_expression"] == "0 0 1 * *" + + +@pytest.mark.asyncio +async def test_disable_schedule(client: AsyncClient, auth_headers: dict): + tree_id = await _create_maintenance_tree(client, auth_headers) + create = await client.post( + "/api/v1/maintenance-schedules", + json={"tree_id": tree_id, "cron_expression": "0 6 * * 1", "timezone": "UTC"}, + headers=auth_headers, + ) + sched_id = create.json()["id"] + resp = await client.patch( + f"/api/v1/maintenance-schedules/{sched_id}", + json={"is_active": False}, + headers=auth_headers, + ) + assert resp.status_code == 200 + assert resp.json()["is_active"] is False + + +@pytest.mark.asyncio +async def test_get_schedule_not_found(client: AsyncClient, auth_headers: dict): + tree_id = await _create_maintenance_tree(client, auth_headers) + resp = await client.get(f"/api/v1/maintenance-schedules/tree/{tree_id}", headers=auth_headers) + assert resp.status_code == 404