Files
HermesMessages/backend/app/modules/calendar/service.py

136 lines
4.4 KiB
Python
Raw Normal View History

import json
from datetime import date, datetime, time, timedelta
import redis.asyncio as aioredis
from fastapi import HTTPException, status
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.modules.business.models import BusinessConfig
from app.modules.business.service import get_business_config
from app.modules.calendar.schemas import DayAvailability, SlotRead
from app.modules.reservations.models import Reservation
SLOTS_CACHE_TTL = 300 # 5 minutos
def _cache_key(business_id: int, target_date: date) -> str:
return f"slots:{business_id}:{target_date.isoformat()}"
def _generate_slots(open_time: time, close_time: time, slot_duration: int) -> list[tuple[time, time]]:
slots = []
base = date.today()
current = datetime.combine(base, open_time)
end = datetime.combine(base, close_time)
delta = timedelta(minutes=slot_duration)
while current + delta <= end:
slots.append((current.time(), (current + delta).time()))
current += delta
return slots
async def _count_reservations_per_slot(
db: AsyncSession,
business_id: int,
target_date: date,
slots: list[tuple[time, time]],
) -> dict[tuple[time, time], int]:
result = await db.execute(
select(Reservation.time_start, func.count(Reservation.id))
.where(
and_(
Reservation.business_id == business_id,
Reservation.date == target_date,
Reservation.status.in_(["pending", "confirmed"]),
)
)
.group_by(Reservation.time_start)
)
counts = {row[0]: row[1] for row in result.all()}
return {slot: counts.get(slot[0], 0) for slot in slots}
async def get_available_slots(
db: AsyncSession,
redis: aioredis.Redis,
business_id: int,
target_date: date,
) -> DayAvailability:
cache_key = _cache_key(business_id, target_date)
cached = await redis.get(cache_key)
if cached:
return DayAvailability.model_validate_json(cached)
config = await get_business_config(db, business_id)
is_open = (
target_date.weekday() in (config.open_days or [])
and target_date not in (config.blocked_dates or [])
)
if not is_open:
result = DayAvailability(date=target_date, is_open=False, slots=[])
await redis.setex(cache_key, SLOTS_CACHE_TTL, result.model_dump_json())
return result
raw_slots = _generate_slots(config.open_time, config.close_time, config.slot_duration)
counts = await _count_reservations_per_slot(db, business_id, target_date, raw_slots)
slots = [
SlotRead(
time_start=s[0],
time_end=s[1],
available=max(0, config.max_per_slot - counts[s]),
max_per_slot=config.max_per_slot,
)
for s in raw_slots
]
result = DayAvailability(date=target_date, is_open=True, slots=slots)
await redis.setex(cache_key, SLOTS_CACHE_TTL, result.model_dump_json())
return result
async def get_availability_range(
db: AsyncSession,
redis: aioredis.Redis,
business_id: int,
start: date,
end: date,
) -> list[DayAvailability]:
if (end - start).days > 31:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="El rango máximo es 31 días",
)
days = []
current = start
while current <= end:
days.append(await get_available_slots(db, redis, business_id, current))
current += timedelta(days=1)
return days
async def invalidate_slots_cache(redis: aioredis.Redis, business_id: int, target_date: date) -> None:
await redis.delete(_cache_key(business_id, target_date))
async def add_blocked_date(db: AsyncSession, business_id: int, target_date: date) -> None:
config = await get_business_config(db, business_id)
blocked = list(config.blocked_dates or [])
if target_date not in blocked:
blocked.append(target_date)
config.blocked_dates = blocked
await db.commit()
async def remove_blocked_date(db: AsyncSession, business_id: int, target_date: date) -> None:
config = await get_business_config(db, business_id)
blocked = list(config.blocked_dates or [])
if target_date not in blocked:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Fecha no bloqueada")
blocked.remove(target_date)
config.blocked_dates = blocked
await db.commit()