Forecasting app: hybrid port to HNF stack
Python FastAPI ML backend kept intact; auth replaced with central hnf_session cookie verification. Frontend rebuilt on React 18 + TS + Vite with stack design system, Plotly charts retained. Shared Postgres via DATABASE_URL; schema applied on startup. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
75d2c1fa9d
103 changed files with 70316 additions and 0 deletions
571
backend/api/special_dates.py
Normal file
571
backend/api/special_dates.py
Normal file
|
|
@ -0,0 +1,571 @@
|
|||
"""
|
||||
Special Dates API - Configure custom holidays/events for Prophet forecasting
|
||||
"""
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Optional, List
|
||||
from enum import Enum
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from pydantic import BaseModel
|
||||
|
||||
from database import get_db
|
||||
from auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============================================
|
||||
# MODELS
|
||||
# ============================================
|
||||
|
||||
class DatePatternType(str, Enum):
|
||||
FIXED = "fixed" # Fixed date each year (e.g., Feb 14)
|
||||
NTH_WEEKDAY = "nth_weekday" # Nth weekday of month (e.g., 2nd Monday of Feb)
|
||||
RELATIVE_TO_DATE = "relative_to_date" # Weekday before/after a fixed date
|
||||
|
||||
|
||||
class SpecialDateBase(BaseModel):
|
||||
name: str
|
||||
pattern_type: DatePatternType
|
||||
# For FIXED pattern
|
||||
fixed_month: Optional[int] = None # 1-12
|
||||
fixed_day: Optional[int] = None # 1-31
|
||||
# For NTH_WEEKDAY pattern
|
||||
nth_week: Optional[int] = None # 1-5 or -1 for last
|
||||
weekday: Optional[int] = None # 0=Mon, 6=Sun
|
||||
month: Optional[int] = None # 1-12
|
||||
# For RELATIVE_TO_DATE pattern
|
||||
relative_to_month: Optional[int] = None
|
||||
relative_to_day: Optional[int] = None
|
||||
relative_weekday: Optional[int] = None # Which weekday to find
|
||||
relative_direction: Optional[str] = None # 'before' or 'after'
|
||||
# Common fields
|
||||
duration_days: int = 1
|
||||
is_recurring: bool = True
|
||||
one_off_year: Optional[int] = None # Only if is_recurring = False
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class SpecialDateCreate(SpecialDateBase):
|
||||
pass
|
||||
|
||||
|
||||
class SpecialDateUpdate(SpecialDateBase):
|
||||
pass
|
||||
|
||||
|
||||
class SpecialDateResponse(SpecialDateBase):
|
||||
id: int
|
||||
created_at: str
|
||||
|
||||
|
||||
class ResolvedDate(BaseModel):
|
||||
name: str
|
||||
date: str
|
||||
day_of_week: str
|
||||
|
||||
|
||||
# ============================================
|
||||
# TABLE CREATION
|
||||
# ============================================
|
||||
|
||||
async def ensure_table_exists(db: AsyncSession):
|
||||
"""Create the special_dates table if it doesn't exist"""
|
||||
await db.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS special_dates (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
pattern_type VARCHAR(20) NOT NULL,
|
||||
fixed_month INTEGER,
|
||||
fixed_day INTEGER,
|
||||
nth_week INTEGER,
|
||||
weekday INTEGER,
|
||||
month INTEGER,
|
||||
relative_to_month INTEGER,
|
||||
relative_to_day INTEGER,
|
||||
relative_weekday INTEGER,
|
||||
relative_direction VARCHAR(10),
|
||||
duration_days INTEGER DEFAULT 1,
|
||||
is_recurring BOOLEAN DEFAULT TRUE,
|
||||
one_off_year INTEGER,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ============================================
|
||||
# DATE RESOLUTION HELPERS
|
||||
# ============================================
|
||||
|
||||
def get_nth_weekday_of_month(year: int, month: int, weekday: int, nth: int) -> Optional[date]:
|
||||
"""
|
||||
Get the nth occurrence of a weekday in a month.
|
||||
nth: 1-5 for first through fifth, -1 for last
|
||||
weekday: 0=Monday, 6=Sunday
|
||||
"""
|
||||
if nth == -1:
|
||||
# Last occurrence - start from end of month
|
||||
if month == 12:
|
||||
next_month = date(year + 1, 1, 1)
|
||||
else:
|
||||
next_month = date(year, month + 1, 1)
|
||||
last_day = next_month - timedelta(days=1)
|
||||
|
||||
# Find the last occurrence of the weekday
|
||||
days_back = (last_day.weekday() - weekday) % 7
|
||||
return last_day - timedelta(days=days_back)
|
||||
else:
|
||||
# Nth occurrence from start
|
||||
first_of_month = date(year, month, 1)
|
||||
# Find the first occurrence of the weekday
|
||||
days_ahead = (weekday - first_of_month.weekday()) % 7
|
||||
first_occurrence = first_of_month + timedelta(days=days_ahead)
|
||||
# Add weeks to get to nth occurrence
|
||||
result = first_occurrence + timedelta(weeks=nth - 1)
|
||||
# Verify it's still in the same month
|
||||
if result.month != month:
|
||||
return None
|
||||
return result
|
||||
|
||||
|
||||
def get_weekday_relative_to_date(year: int, month: int, day: int,
|
||||
target_weekday: int, direction: str) -> Optional[date]:
|
||||
"""
|
||||
Get a specific weekday before or after a fixed date.
|
||||
target_weekday: 0=Monday, 6=Sunday
|
||||
direction: 'before' or 'after'
|
||||
"""
|
||||
try:
|
||||
base_date = date(year, month, day)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
if direction == 'before':
|
||||
# Find the weekday before (or on) the base date
|
||||
days_back = (base_date.weekday() - target_weekday) % 7
|
||||
if days_back == 0:
|
||||
days_back = 7 # If same weekday, go back a week
|
||||
return base_date - timedelta(days=days_back)
|
||||
else: # after
|
||||
# Find the weekday after the base date
|
||||
days_ahead = (target_weekday - base_date.weekday()) % 7
|
||||
if days_ahead == 0:
|
||||
days_ahead = 7 # If same weekday, go forward a week
|
||||
return base_date + timedelta(days=days_ahead)
|
||||
|
||||
|
||||
def resolve_special_date(sd: dict, year: int) -> List[date]:
|
||||
"""Resolve a special date pattern to actual dates for a given year"""
|
||||
if not sd['is_recurring'] and sd.get('one_off_year') and sd['one_off_year'] != year:
|
||||
return []
|
||||
|
||||
base_date = None
|
||||
pattern_type = sd['pattern_type']
|
||||
|
||||
if pattern_type == 'fixed':
|
||||
try:
|
||||
base_date = date(year, sd['fixed_month'], sd['fixed_day'])
|
||||
except (ValueError, TypeError):
|
||||
return []
|
||||
|
||||
elif pattern_type == 'nth_weekday':
|
||||
base_date = get_nth_weekday_of_month(
|
||||
year, sd['month'], sd['weekday'], sd['nth_week']
|
||||
)
|
||||
|
||||
elif pattern_type == 'relative_to_date':
|
||||
base_date = get_weekday_relative_to_date(
|
||||
year, sd['relative_to_month'], sd['relative_to_day'],
|
||||
sd['relative_weekday'], sd['relative_direction']
|
||||
)
|
||||
|
||||
if base_date is None:
|
||||
return []
|
||||
|
||||
# Generate dates for duration
|
||||
duration = sd.get('duration_days', 1) or 1
|
||||
return [base_date + timedelta(days=i) for i in range(duration)]
|
||||
|
||||
|
||||
# ============================================
|
||||
# CRUD ENDPOINTS
|
||||
# ============================================
|
||||
|
||||
@router.get("/special-dates", response_model=List[SpecialDateResponse])
|
||||
async def list_special_dates(
|
||||
active_only: bool = Query(False, description="Only return active dates"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""List all special date configurations"""
|
||||
await ensure_table_exists(db)
|
||||
|
||||
query = "SELECT * FROM special_dates"
|
||||
if active_only:
|
||||
query += " WHERE is_active = TRUE"
|
||||
query += " ORDER BY name"
|
||||
|
||||
result = await db.execute(text(query))
|
||||
rows = result.fetchall()
|
||||
|
||||
return [
|
||||
SpecialDateResponse(
|
||||
id=row.id,
|
||||
name=row.name,
|
||||
pattern_type=row.pattern_type,
|
||||
fixed_month=row.fixed_month,
|
||||
fixed_day=row.fixed_day,
|
||||
nth_week=row.nth_week,
|
||||
weekday=row.weekday,
|
||||
month=row.month,
|
||||
relative_to_month=row.relative_to_month,
|
||||
relative_to_day=row.relative_to_day,
|
||||
relative_weekday=row.relative_weekday,
|
||||
relative_direction=row.relative_direction,
|
||||
duration_days=row.duration_days or 1,
|
||||
is_recurring=row.is_recurring,
|
||||
one_off_year=row.one_off_year,
|
||||
is_active=row.is_active,
|
||||
created_at=str(row.created_at)
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post("/special-dates", response_model=SpecialDateResponse)
|
||||
async def create_special_date(
|
||||
data: SpecialDateCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Create a new special date configuration"""
|
||||
await ensure_table_exists(db)
|
||||
|
||||
result = await db.execute(text("""
|
||||
INSERT INTO special_dates (
|
||||
name, pattern_type, fixed_month, fixed_day, nth_week, weekday, month,
|
||||
relative_to_month, relative_to_day, relative_weekday, relative_direction,
|
||||
duration_days, is_recurring, one_off_year, is_active
|
||||
) VALUES (
|
||||
:name, :pattern_type, :fixed_month, :fixed_day, :nth_week, :weekday, :month,
|
||||
:relative_to_month, :relative_to_day, :relative_weekday, :relative_direction,
|
||||
:duration_days, :is_recurring, :one_off_year, :is_active
|
||||
) RETURNING id, created_at
|
||||
"""), {
|
||||
"name": data.name,
|
||||
"pattern_type": data.pattern_type.value,
|
||||
"fixed_month": data.fixed_month,
|
||||
"fixed_day": data.fixed_day,
|
||||
"nth_week": data.nth_week,
|
||||
"weekday": data.weekday,
|
||||
"month": data.month,
|
||||
"relative_to_month": data.relative_to_month,
|
||||
"relative_to_day": data.relative_to_day,
|
||||
"relative_weekday": data.relative_weekday,
|
||||
"relative_direction": data.relative_direction,
|
||||
"duration_days": data.duration_days,
|
||||
"is_recurring": data.is_recurring,
|
||||
"one_off_year": data.one_off_year,
|
||||
"is_active": data.is_active
|
||||
})
|
||||
|
||||
row = result.fetchone()
|
||||
await db.commit()
|
||||
|
||||
return SpecialDateResponse(
|
||||
id=row.id,
|
||||
created_at=str(row.created_at),
|
||||
**data.dict()
|
||||
)
|
||||
|
||||
|
||||
@router.put("/special-dates/{date_id}", response_model=SpecialDateResponse)
|
||||
async def update_special_date(
|
||||
date_id: int,
|
||||
data: SpecialDateUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Update a special date configuration"""
|
||||
result = await db.execute(text("""
|
||||
UPDATE special_dates SET
|
||||
name = :name,
|
||||
pattern_type = :pattern_type,
|
||||
fixed_month = :fixed_month,
|
||||
fixed_day = :fixed_day,
|
||||
nth_week = :nth_week,
|
||||
weekday = :weekday,
|
||||
month = :month,
|
||||
relative_to_month = :relative_to_month,
|
||||
relative_to_day = :relative_to_day,
|
||||
relative_weekday = :relative_weekday,
|
||||
relative_direction = :relative_direction,
|
||||
duration_days = :duration_days,
|
||||
is_recurring = :is_recurring,
|
||||
one_off_year = :one_off_year,
|
||||
is_active = :is_active,
|
||||
updated_at = NOW()
|
||||
WHERE id = :id
|
||||
RETURNING id, created_at
|
||||
"""), {
|
||||
"id": date_id,
|
||||
"name": data.name,
|
||||
"pattern_type": data.pattern_type.value,
|
||||
"fixed_month": data.fixed_month,
|
||||
"fixed_day": data.fixed_day,
|
||||
"nth_week": data.nth_week,
|
||||
"weekday": data.weekday,
|
||||
"month": data.month,
|
||||
"relative_to_month": data.relative_to_month,
|
||||
"relative_to_day": data.relative_to_day,
|
||||
"relative_weekday": data.relative_weekday,
|
||||
"relative_direction": data.relative_direction,
|
||||
"duration_days": data.duration_days,
|
||||
"is_recurring": data.is_recurring,
|
||||
"one_off_year": data.one_off_year,
|
||||
"is_active": data.is_active
|
||||
})
|
||||
|
||||
row = result.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Special date not found")
|
||||
|
||||
await db.commit()
|
||||
|
||||
return SpecialDateResponse(
|
||||
id=row.id,
|
||||
created_at=str(row.created_at),
|
||||
**data.dict()
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/special-dates/{date_id}")
|
||||
async def delete_special_date(
|
||||
date_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Delete a special date configuration"""
|
||||
result = await db.execute(
|
||||
text("DELETE FROM special_dates WHERE id = :id RETURNING id"),
|
||||
{"id": date_id}
|
||||
)
|
||||
|
||||
if not result.fetchone():
|
||||
raise HTTPException(status_code=404, detail="Special date not found")
|
||||
|
||||
await db.commit()
|
||||
return {"message": "Special date deleted successfully"}
|
||||
|
||||
|
||||
@router.get("/special-dates/preview", response_model=List[ResolvedDate])
|
||||
async def preview_special_dates(
|
||||
year: int = Query(..., description="Year to preview dates for"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Preview resolved dates for a given year"""
|
||||
await ensure_table_exists(db)
|
||||
|
||||
result = await db.execute(text(
|
||||
"SELECT * FROM special_dates WHERE is_active = TRUE ORDER BY name"
|
||||
))
|
||||
rows = result.fetchall()
|
||||
|
||||
resolved = []
|
||||
for row in rows:
|
||||
sd = {
|
||||
'pattern_type': row.pattern_type,
|
||||
'fixed_month': row.fixed_month,
|
||||
'fixed_day': row.fixed_day,
|
||||
'nth_week': row.nth_week,
|
||||
'weekday': row.weekday,
|
||||
'month': row.month,
|
||||
'relative_to_month': row.relative_to_month,
|
||||
'relative_to_day': row.relative_to_day,
|
||||
'relative_weekday': row.relative_weekday,
|
||||
'relative_direction': row.relative_direction,
|
||||
'duration_days': row.duration_days,
|
||||
'is_recurring': row.is_recurring,
|
||||
'one_off_year': row.one_off_year
|
||||
}
|
||||
|
||||
dates = resolve_special_date(sd, year)
|
||||
for d in dates:
|
||||
resolved.append(ResolvedDate(
|
||||
name=row.name,
|
||||
date=str(d),
|
||||
day_of_week=d.strftime("%a")
|
||||
))
|
||||
|
||||
# Sort by date
|
||||
resolved.sort(key=lambda x: x.date)
|
||||
return resolved
|
||||
|
||||
|
||||
@router.post("/special-dates/seed-defaults")
|
||||
async def seed_default_dates(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Seed default special dates (Valentine's, Christmas Eve, NYE, Bank Holiday weekends)"""
|
||||
await ensure_table_exists(db)
|
||||
|
||||
# Check if already seeded
|
||||
result = await db.execute(text("SELECT COUNT(*) as count FROM special_dates"))
|
||||
if result.fetchone().count > 0:
|
||||
return {"message": "Special dates already exist. Delete existing to re-seed."}
|
||||
|
||||
defaults = [
|
||||
# Valentine's Day
|
||||
{
|
||||
"name": "Valentine's Day",
|
||||
"pattern_type": "fixed",
|
||||
"fixed_month": 2,
|
||||
"fixed_day": 14,
|
||||
"duration_days": 1
|
||||
},
|
||||
# Christmas Eve
|
||||
{
|
||||
"name": "Christmas Eve",
|
||||
"pattern_type": "fixed",
|
||||
"fixed_month": 12,
|
||||
"fixed_day": 24,
|
||||
"duration_days": 1
|
||||
},
|
||||
# New Year's Eve
|
||||
{
|
||||
"name": "New Year's Eve",
|
||||
"pattern_type": "fixed",
|
||||
"fixed_month": 12,
|
||||
"fixed_day": 31,
|
||||
"duration_days": 1
|
||||
},
|
||||
# Friday before Christmas (if Christmas is Sat-Tue)
|
||||
{
|
||||
"name": "Friday Before Christmas",
|
||||
"pattern_type": "relative_to_date",
|
||||
"relative_to_month": 12,
|
||||
"relative_to_day": 25,
|
||||
"relative_weekday": 4, # Friday
|
||||
"relative_direction": "before",
|
||||
"duration_days": 1
|
||||
},
|
||||
# Saturday before Christmas
|
||||
{
|
||||
"name": "Saturday Before Christmas",
|
||||
"pattern_type": "relative_to_date",
|
||||
"relative_to_month": 12,
|
||||
"relative_to_day": 25,
|
||||
"relative_weekday": 5, # Saturday
|
||||
"relative_direction": "before",
|
||||
"duration_days": 1
|
||||
},
|
||||
# Early May Bank Holiday Weekend (Fri-Sat of first Monday in May)
|
||||
{
|
||||
"name": "Early May BH Friday",
|
||||
"pattern_type": "nth_weekday",
|
||||
"month": 5,
|
||||
"nth_week": 1,
|
||||
"weekday": 4, # Friday (before the Monday)
|
||||
"duration_days": 1
|
||||
},
|
||||
# Spring Bank Holiday Weekend (last Monday of May)
|
||||
{
|
||||
"name": "Spring BH Weekend",
|
||||
"pattern_type": "nth_weekday",
|
||||
"month": 5,
|
||||
"nth_week": -1, # Last week
|
||||
"weekday": 5, # Saturday
|
||||
"duration_days": 2 # Sat + Sun (Mon is the bank holiday itself)
|
||||
},
|
||||
# August Bank Holiday Weekend (last Monday of August)
|
||||
{
|
||||
"name": "August BH Weekend",
|
||||
"pattern_type": "nth_weekday",
|
||||
"month": 8,
|
||||
"nth_week": -1,
|
||||
"weekday": 5, # Saturday
|
||||
"duration_days": 2
|
||||
},
|
||||
]
|
||||
|
||||
for d in defaults:
|
||||
await db.execute(text("""
|
||||
INSERT INTO special_dates (
|
||||
name, pattern_type, fixed_month, fixed_day, nth_week, weekday, month,
|
||||
relative_to_month, relative_to_day, relative_weekday, relative_direction,
|
||||
duration_days, is_recurring, is_active
|
||||
) VALUES (
|
||||
:name, :pattern_type, :fixed_month, :fixed_day, :nth_week, :weekday, :month,
|
||||
:relative_to_month, :relative_to_day, :relative_weekday, :relative_direction,
|
||||
:duration_days, TRUE, TRUE
|
||||
)
|
||||
"""), {
|
||||
"name": d["name"],
|
||||
"pattern_type": d["pattern_type"],
|
||||
"fixed_month": d.get("fixed_month"),
|
||||
"fixed_day": d.get("fixed_day"),
|
||||
"nth_week": d.get("nth_week"),
|
||||
"weekday": d.get("weekday"),
|
||||
"month": d.get("month"),
|
||||
"relative_to_month": d.get("relative_to_month"),
|
||||
"relative_to_day": d.get("relative_to_day"),
|
||||
"relative_weekday": d.get("relative_weekday"),
|
||||
"relative_direction": d.get("relative_direction"),
|
||||
"duration_days": d.get("duration_days", 1)
|
||||
})
|
||||
|
||||
await db.commit()
|
||||
return {"message": f"Seeded {len(defaults)} default special dates"}
|
||||
|
||||
|
||||
# ============================================
|
||||
# HELPER FOR PROPHET INTEGRATION
|
||||
# ============================================
|
||||
|
||||
async def get_special_dates_for_prophet(db: AsyncSession, start_year: int, end_year: int) -> List[dict]:
|
||||
"""
|
||||
Get all special dates resolved for a range of years, formatted for Prophet.
|
||||
Returns list of dicts with 'ds' (date) and 'holiday' (name) columns.
|
||||
"""
|
||||
await ensure_table_exists(db)
|
||||
|
||||
result = await db.execute(text(
|
||||
"SELECT * FROM special_dates WHERE is_active = TRUE"
|
||||
))
|
||||
rows = result.fetchall()
|
||||
|
||||
prophet_holidays = []
|
||||
for year in range(start_year, end_year + 1):
|
||||
for row in rows:
|
||||
sd = {
|
||||
'pattern_type': row.pattern_type,
|
||||
'fixed_month': row.fixed_month,
|
||||
'fixed_day': row.fixed_day,
|
||||
'nth_week': row.nth_week,
|
||||
'weekday': row.weekday,
|
||||
'month': row.month,
|
||||
'relative_to_month': row.relative_to_month,
|
||||
'relative_to_day': row.relative_to_day,
|
||||
'relative_weekday': row.relative_weekday,
|
||||
'relative_direction': row.relative_direction,
|
||||
'duration_days': row.duration_days,
|
||||
'is_recurring': row.is_recurring,
|
||||
'one_off_year': row.one_off_year
|
||||
}
|
||||
|
||||
dates = resolve_special_date(sd, year)
|
||||
for d in dates:
|
||||
# Prophet expects datetime objects, not date objects
|
||||
prophet_holidays.append({
|
||||
'ds': datetime.combine(d, datetime.min.time()),
|
||||
'holiday': row.name
|
||||
})
|
||||
|
||||
return prophet_holidays
|
||||
Loading…
Add table
Add a link
Reference in a new issue