from datetime import date, timedelta, datetime, time import asyncpg import pytz from app.config import get_settings from app.exceptions import NotFoundError, AppError from app.models.bookings import WeeklyScheduleCreate, SlotCreate def _fmt_time(t) -> str: if t is None: return "" s = str(t) return s[:5] async def get_schedule(db: asyncpg.Connection) -> list[dict]: rows = await db.fetch("SELECT * FROM weekly_schedule WHERE is_active = true ORDER BY day_of_week, start_time") return [dict(r) for r in rows] async def create_schedule_entry(db: asyncpg.Connection, data: WeeklyScheduleCreate) -> dict: row = await db.fetchrow( """ INSERT INTO weekly_schedule (day_of_week, start_time, end_time, slot_duration_minutes) VALUES ($1, $2, $3, $4) RETURNING * """, data.day_of_week, data.start_time, data.end_time, data.slot_duration_minutes, ) return dict(row) async def delete_schedule_entry(db: asyncpg.Connection, schedule_id: str): result = await db.execute("DELETE FROM weekly_schedule WHERE id = $1", schedule_id) if result == "DELETE 0": raise NotFoundError("schedule entry") async def generate_slots(db: asyncpg.Connection, from_date: date, to_date: date) -> int: if (to_date - from_date).days > 90: raise AppError("RANGE_TOO_LARGE", "Date range cannot exceed 90 days", 400) schedule = await db.fetch("SELECT * FROM weekly_schedule WHERE is_active = true") if not schedule: raise AppError("NO_SCHEDULE", "No active weekly schedule configured", 400) blocked = await db.fetch( "SELECT date FROM blocked_dates WHERE date BETWEEN $1 AND $2", from_date, to_date, ) blocked_set = {r["date"] for r in blocked} tz = pytz.timezone(get_settings().BUSINESS_TIMEZONE) created = 0 current = from_date async with db.transaction(): while current <= to_date: if current in blocked_set: current += timedelta(days=1) continue day_schedules = [s for s in schedule if s["day_of_week"] == current.weekday()] for sched in day_schedules: cursor = datetime.combine(current, sched["start_time"]) end_limit = datetime.combine(current, sched["end_time"]) duration = timedelta(minutes=sched["slot_duration_minutes"]) while cursor + duration <= end_limit: slot_end = cursor + duration exists = await db.fetchval( "SELECT 1 FROM time_slots WHERE date = $1 AND start_time = $2", current, cursor.time(), ) if not exists: await db.execute( "INSERT INTO time_slots (date, start_time, end_time) VALUES ($1, $2, $3)", current, cursor.time(), slot_end.time(), ) created += 1 cursor += duration current += timedelta(days=1) return created async def create_slot(db: asyncpg.Connection, data: SlotCreate) -> dict: if data.start_time >= data.end_time: raise AppError("INVALID_SLOT", "start_time must be before end_time", 400) row = await db.fetchrow( "INSERT INTO time_slots (date, start_time, end_time) VALUES ($1, $2, $3) RETURNING *", data.date, data.start_time, data.end_time, ) return dict(row) async def delete_slot(db: asyncpg.Connection, slot_id: str): booked = await db.fetchval( "SELECT 1 FROM bookings WHERE slot_id = $1 AND status IN ('pending', 'confirmed')", slot_id, ) if booked: raise AppError("SLOT_BOOKED", "Cannot delete a slot with active bookings", 409) result = await db.execute("DELETE FROM time_slots WHERE id = $1", slot_id) if result == "DELETE 0": raise NotFoundError("slot") async def update_slot(db: asyncpg.Connection, slot_id: str, is_blocked: bool, block_reason: str | None) -> dict: row = await db.fetchrow( "UPDATE time_slots SET is_blocked = $2, block_reason = $3 WHERE id = $1 RETURNING *", slot_id, is_blocked, block_reason, ) if not row: raise NotFoundError("slot") return dict(row) async def list_slots( db: asyncpg.Connection, from_date: date, to_date: date, available_only: bool = False, ) -> list[dict]: query = """ SELECT ts.id, ts.date, ts.start_time, ts.end_time, ts.is_blocked, ts.block_reason, EXISTS( SELECT 1 FROM bookings b WHERE b.slot_id = ts.id AND b.status IN ('pending', 'confirmed') ) AS is_booked FROM time_slots ts WHERE ts.date BETWEEN $1 AND $2 """ params: list = [from_date, to_date] if available_only: query += " AND ts.is_blocked = false" query += """ AND NOT EXISTS( SELECT 1 FROM blocked_dates bd WHERE bd.date = ts.date ) AND NOT EXISTS( SELECT 1 FROM bookings b WHERE b.slot_id = ts.id AND b.status IN ('pending', 'confirmed') ) AND ts.date >= CURRENT_DATE """ query += " ORDER BY ts.date, ts.start_time" rows = await db.fetch(query, *params) return [dict(r) for r in rows] async def get_blocked_dates(db: asyncpg.Connection) -> list[dict]: rows = await db.fetch("SELECT * FROM blocked_dates ORDER BY date") return [dict(r) for r in rows] async def add_blocked_date(db: asyncpg.Connection, date: date, reason: str | None) -> dict: row = await db.fetchrow( "INSERT INTO blocked_dates (date, reason) VALUES ($1, $2) ON CONFLICT (date) DO UPDATE SET reason = $2 RETURNING *", date, reason, ) return dict(row) async def remove_blocked_date(db: asyncpg.Connection, blocked_date_id: str): result = await db.execute("DELETE FROM blocked_dates WHERE id = $1", blocked_date_id) if result == "DELETE 0": raise NotFoundError("blocked date")