v1.3.0 — pannello admin completo, auth localStorage, Baileys WA, customers, calendario, paginazione, dashboard 7gg
This commit is contained in:
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
100
app/services/availability.py
Normal file
100
app/services/availability.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Calcolo slot disponibili per un servizio in una data.
|
||||
|
||||
Logica: regole orario provider - prenotazioni esistenti - busy Google Calendar.
|
||||
Google Calendar è opzionale (fase 2).
|
||||
"""
|
||||
from datetime import datetime, date, timedelta, time
|
||||
from zoneinfo import ZoneInfo
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models import ProviderService, Booking
|
||||
from app.schemas import TimeSlot
|
||||
|
||||
TZ = ZoneInfo("Europe/Rome")
|
||||
|
||||
|
||||
def get_available_slots(
|
||||
db: Session,
|
||||
service_id: int,
|
||||
target_date: date,
|
||||
duration_min: int,
|
||||
) -> list[TimeSlot]:
|
||||
"""Restituisce tutti gli slot liberi per un servizio in una data."""
|
||||
|
||||
# 1. Trova tutti i provider che erogano questo servizio
|
||||
ps_list = (
|
||||
db.query(ProviderService)
|
||||
.filter(ProviderService.service_id == service_id)
|
||||
.all()
|
||||
)
|
||||
if not ps_list:
|
||||
return []
|
||||
|
||||
weekday = target_date.weekday() # 0=lunedì
|
||||
all_slots = []
|
||||
|
||||
for ps in ps_list:
|
||||
if not ps.provider.active:
|
||||
continue
|
||||
|
||||
# 2. Filtra le regole di disponibilità per il giorno della settimana
|
||||
rules = ps.availability_rules or []
|
||||
day_rules = [r for r in rules if r.get("weekday") == weekday]
|
||||
|
||||
if not day_rules:
|
||||
continue
|
||||
|
||||
# 3. Genera tutti gli slot possibili dalle regole
|
||||
raw_slots = []
|
||||
for rule in day_rules:
|
||||
start_h, start_m = map(int, rule["start"].split(":"))
|
||||
end_h, end_m = map(int, rule["end"].split(":"))
|
||||
|
||||
slot_start = datetime.combine(target_date, time(start_h, start_m), tzinfo=TZ)
|
||||
slot_end_limit = datetime.combine(target_date, time(end_h, end_m), tzinfo=TZ)
|
||||
|
||||
while slot_start + timedelta(minutes=duration_min) <= slot_end_limit:
|
||||
raw_slots.append(slot_start)
|
||||
slot_start += timedelta(minutes=duration_min)
|
||||
|
||||
# 4. Filtra via le prenotazioni esistenti (confirmed)
|
||||
day_start = datetime.combine(target_date, time(0, 0), tzinfo=TZ)
|
||||
day_end = day_start + timedelta(days=1)
|
||||
|
||||
existing = (
|
||||
db.query(Booking)
|
||||
.filter(
|
||||
Booking.provider_id == ps.provider_id,
|
||||
Booking.start_at >= day_start,
|
||||
Booking.start_at < day_end,
|
||||
Booking.status == "confirmed",
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
busy_ranges = [(b.start_at, b.end_at) for b in existing]
|
||||
|
||||
for slot_start in raw_slots:
|
||||
slot_end = slot_start + timedelta(minutes=duration_min)
|
||||
conflict = any(
|
||||
slot_start < busy_end and slot_end > busy_start
|
||||
for busy_start, busy_end in busy_ranges
|
||||
)
|
||||
if not conflict:
|
||||
all_slots.append(
|
||||
TimeSlot(
|
||||
start=slot_start.strftime("%H:%M"),
|
||||
end=slot_end.strftime("%H:%M"),
|
||||
provider_id=ps.provider_id,
|
||||
provider_name=ps.provider.name,
|
||||
)
|
||||
)
|
||||
|
||||
# 5. Ordina per orario
|
||||
all_slots.sort(key=lambda s: s.start)
|
||||
|
||||
# 6. Filtra slot nel passato se target_date è oggi
|
||||
now = datetime.now(TZ)
|
||||
if target_date == now.date():
|
||||
all_slots = [s for s in all_slots if s.start > now.strftime("%H:%M")]
|
||||
|
||||
return all_slots
|
||||
114
app/services/notifications.py
Normal file
114
app/services/notifications.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Notifiche: email SMTP + WhatsApp via gateway XAB."""
|
||||
import smtplib
|
||||
import logging
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from datetime import datetime
|
||||
import httpx
|
||||
from app.config import get_settings
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
async def send_wa_message(phone: str, text: str) -> bool:
|
||||
"""Invia messaggio WhatsApp via gateway XAB su APP:18800."""
|
||||
if not settings.wa_enabled:
|
||||
log.info(f"WA disabled, skip: {phone}")
|
||||
return False
|
||||
try:
|
||||
# Normalizza numero: +39 3xx -> 39 3xx
|
||||
clean = phone.replace("+", "").replace(" ", "").replace("-", "")
|
||||
if not clean.startswith("39"):
|
||||
clean = "39" + clean
|
||||
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.post(
|
||||
settings.wa_gateway_url,
|
||||
json={"to": clean, "text": text},
|
||||
)
|
||||
if r.status_code == 200:
|
||||
log.info(f"WA sent to {clean}")
|
||||
return True
|
||||
else:
|
||||
log.error(f"WA error {r.status_code}: {r.text}")
|
||||
return False
|
||||
except Exception as e:
|
||||
log.error(f"WA exception: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def send_email(to: str, subject: str, html_body: str) -> bool:
|
||||
"""Invia email via SMTP."""
|
||||
if not settings.smtp_user:
|
||||
log.info(f"SMTP not configured, skip: {to}")
|
||||
return False
|
||||
try:
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = settings.smtp_from
|
||||
msg["To"] = to
|
||||
msg.attach(MIMEText(html_body, "html"))
|
||||
|
||||
with smtplib.SMTP(settings.smtp_host, settings.smtp_port) as server:
|
||||
server.starttls()
|
||||
server.login(settings.smtp_user, settings.smtp_pass)
|
||||
server.sendmail(settings.smtp_from, to, msg.as_string())
|
||||
log.info(f"Email sent to {to}")
|
||||
return True
|
||||
except Exception as e:
|
||||
log.error(f"Email exception: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def notify_booking_confirmed(booking, service_name: str, provider_name: str):
|
||||
"""Notifica al cliente: conferma prenotazione."""
|
||||
dt = booking.start_at.strftime("%d/%m/%Y alle %H:%M")
|
||||
# WhatsApp al cliente
|
||||
wa_text = (
|
||||
f"✅ Prenotazione confermata!\n\n"
|
||||
f"Servizio: {service_name}\n"
|
||||
f"Data: {dt}\n"
|
||||
f"Operatore: {provider_name}\n\n"
|
||||
f"Farmacia Ianni - Via Cassia 940, Roma\n"
|
||||
f"Per cancellare, rispondi CANCELLA."
|
||||
)
|
||||
await send_wa_message(booking.customer_phone, wa_text)
|
||||
|
||||
# Email se fornita
|
||||
if booking.customer_email:
|
||||
html = f"""
|
||||
<h2>Prenotazione confermata</h2>
|
||||
<p><strong>Servizio:</strong> {service_name}</p>
|
||||
<p><strong>Data:</strong> {dt}</p>
|
||||
<p><strong>Operatore:</strong> {provider_name}</p>
|
||||
<hr>
|
||||
<p>Farmacia Ianni - Via Cassia 940, Roma</p>
|
||||
"""
|
||||
send_email(booking.customer_email, f"Prenotazione {service_name} - {dt}", html)
|
||||
|
||||
|
||||
async def notify_operator(booking, service_name: str, provider_email: str):
|
||||
"""Notifica all'operatore: nuova prenotazione."""
|
||||
dt = booking.start_at.strftime("%d/%m/%Y alle %H:%M")
|
||||
html = f"""
|
||||
<h2>Nuova prenotazione</h2>
|
||||
<p><strong>Cliente:</strong> {booking.customer_name}</p>
|
||||
<p><strong>Telefono:</strong> {booking.customer_phone}</p>
|
||||
<p><strong>Servizio:</strong> {service_name}</p>
|
||||
<p><strong>Data:</strong> {dt}</p>
|
||||
"""
|
||||
if provider_email:
|
||||
send_email(provider_email, f"Nuova prenotazione: {booking.customer_name} - {dt}", html)
|
||||
|
||||
|
||||
async def send_reminder(booking, service_name: str):
|
||||
"""Reminder 24h prima via WhatsApp."""
|
||||
dt = booking.start_at.strftime("%d/%m/%Y alle %H:%M")
|
||||
text = (
|
||||
f"📅 Promemoria: domani hai un appuntamento\n\n"
|
||||
f"Servizio: {service_name}\n"
|
||||
f"Data: {dt}\n\n"
|
||||
f"Farmacia Ianni - Via Cassia 940, Roma"
|
||||
)
|
||||
await send_wa_message(booking.customer_phone, text)
|
||||
Reference in New Issue
Block a user