crowncode-backend / app /routes /reservations.py
Rthur2003's picture
feat: add reservations API with table management and email notifications
c1fe190
Raw
History Blame Contribute Delete
12.9 kB
"""
Noir & Grain rezervasyon demo API'si + admin masa paneli.
Şablon vitrini için örnek uçtan uca akış:
- POST /api/reservations → talep oluştur (masa otomatik atanır) + örnek SMTP bildirimi
- GET /api/reservations?date= → gün listesi
- GET /api/reservations/tables → date+time için masa durumu (dolu/boş)
- PATCH /api/reservations/{id} → durum güncelle (confirmed/cancelled/seated)
- GET /admin/reservations → tek dosyalık karanlık admin paneli (masa haritası)
Depolama: DATA_DIR/reservations.json (HF Spaces'te kalıcı /data'ya işaret edin).
Mail: SMTP_HOST/USER/PASSWORD tanımlıysa gönderir; değilse sadece loglar (örnek entegrasyon).
"""
from __future__ import annotations
import json
import os
import smtplib
import threading
import uuid
from datetime import date as date_type
from datetime import datetime, timezone
from email.mime.text import MIMEText
from pathlib import Path
from typing import Literal
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import HTMLResponse
from pydantic import BaseModel, Field, field_validator
from ..services.logging_config import get_logger
logger = get_logger(__name__)
router = APIRouter(tags=["reservations"])
DATA_DIR = Path(os.getenv("DATA_DIR", "data"))
STORE_PATH = DATA_DIR / "reservations.json"
TOTAL_TABLES = int(os.getenv("RESTAURANT_TOTAL_TABLES", "12"))
_lock = threading.Lock()
# ── Depolama ─────────────────────────────────────────────────────────
def _load() -> list[dict]:
if not STORE_PATH.exists():
return []
try:
return json.loads(STORE_PATH.read_text(encoding="utf-8"))
except json.JSONDecodeError:
logger.error("reservations.json bozuk, boş listeyle devam ediliyor")
return []
def _save(items: list[dict]) -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
STORE_PATH.write_text(json.dumps(items, ensure_ascii=False, indent=2), encoding="utf-8")
# ── Şemalar ──────────────────────────────────────────────────────────
class ReservationIn(BaseModel):
name: str = Field(min_length=2, max_length=80)
phone: str = Field(min_length=10, max_length=20)
date: str # YYYY-MM-DD
time: str # HH:MM
guests: int = Field(ge=1, le=12)
note: str = Field(default="", max_length=500)
@field_validator("date")
@classmethod
def date_not_past(cls, v: str) -> str:
try:
picked = date_type.fromisoformat(v)
except ValueError as exc:
raise ValueError("Tarih YYYY-MM-DD formatında olmalı") from exc
if picked < date_type.today():
raise ValueError("Geçmiş bir tarih seçilemez")
return v
@field_validator("time")
@classmethod
def time_format(cls, v: str) -> str:
try:
datetime.strptime(v, "%H:%M")
except ValueError as exc:
raise ValueError("Saat HH:MM formatında olmalı") from exc
return v
class Reservation(ReservationIn):
id: str
table: int
status: Literal["pending", "confirmed", "seated", "cancelled"]
created_at: str
class StatusUpdate(BaseModel):
status: Literal["pending", "confirmed", "seated", "cancelled"]
# ── Örnek mail bildirimi ─────────────────────────────────────────────
def _notify_by_mail(res: dict) -> None:
"""SMTP env değişkenleri tanımlıysa restorana bildirim maili atar.
Tanımlı değilse sessizce loglar — demo ortamında kırılma olmaz.
"""
host = os.getenv("SMTP_HOST")
user = os.getenv("SMTP_USER")
password = os.getenv("SMTP_PASSWORD")
to_addr = os.getenv("RESERVATION_MAIL_TO", user or "")
if not (host and user and password and to_addr):
logger.info("SMTP yapılandırılmamış — mail bildirimi atlandı (demo modu)")
return
body = (
f"Yeni rezervasyon talebi\n\n"
f"Ad: {res['name']}\nTelefon: {res['phone']}\n"
f"Tarih: {res['date']} Saat: {res['time']}\n"
f"Kişi: {res['guests']} Masa: {res['table']}\n"
f"Not: {res['note'] or '-'}"
)
msg = MIMEText(body, _charset="utf-8")
msg["Subject"] = f"Rezervasyon: {res['name']}{res['date']} {res['time']}"
msg["From"] = os.getenv("SMTP_FROM", user)
msg["To"] = to_addr
try:
with smtplib.SMTP(host, int(os.getenv("SMTP_PORT", "587")), timeout=10) as smtp:
smtp.starttls()
smtp.login(user, password)
smtp.send_message(msg)
logger.info("Rezervasyon maili gönderildi: %s", res["id"])
except Exception as exc: # demo: mail hatası rezervasyonu düşürmesin
logger.error("Mail gönderilemedi: %s", exc)
# ── Yardımcılar ──────────────────────────────────────────────────────
def _occupied_tables(items: list[dict], date: str, time: str) -> dict[int, dict]:
"""Aynı gün + saat dilimindeki iptal edilmemiş rezervasyonların masaları."""
return {
r["table"]: r
for r in items
if r["date"] == date and r["time"] == time and r["status"] != "cancelled"
}
# ── API ──────────────────────────────────────────────────────────────
@router.post("/api/reservations", response_model=Reservation, status_code=201)
async def create_reservation(payload: ReservationIn) -> Reservation:
with _lock:
items = _load()
occupied = _occupied_tables(items, payload.date, payload.time)
free = [n for n in range(1, TOTAL_TABLES + 1) if n not in occupied]
if not free:
raise HTTPException(status_code=409, detail="Bu saat için boş masa kalmadı")
record = {
**payload.model_dump(),
"id": uuid.uuid4().hex[:10],
"table": free[0],
"status": "pending",
"created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
}
items.append(record)
_save(items)
_notify_by_mail(record)
return Reservation(**record)
@router.get("/api/reservations", response_model=list[Reservation])
async def list_reservations(date: str | None = Query(default=None)) -> list[Reservation]:
items = _load()
if date:
items = [r for r in items if r["date"] == date]
items.sort(key=lambda r: (r["date"], r["time"], r["table"]))
return [Reservation(**r) for r in items]
@router.get("/api/reservations/tables")
async def table_map(date: str, time: str) -> dict:
items = _load()
occupied = _occupied_tables(items, date, time)
return {
"date": date,
"time": time,
"total": TOTAL_TABLES,
"tables": [
{
"table": n,
"occupied": n in occupied,
"reservation": (
{k: occupied[n][k] for k in ("id", "name", "guests", "status")}
if n in occupied else None
),
}
for n in range(1, TOTAL_TABLES + 1)
],
}
@router.patch("/api/reservations/{reservation_id}", response_model=Reservation)
async def update_status(reservation_id: str, payload: StatusUpdate) -> Reservation:
with _lock:
items = _load()
for r in items:
if r["id"] == reservation_id:
r["status"] = payload.status
_save(items)
return Reservation(**r)
raise HTTPException(status_code=404, detail="Rezervasyon bulunamadı")
# ── Admin paneli (tek dosyalık, Noir & Grain paletinde) ─────────────
ADMIN_HTML = """<!doctype html>
<html lang="tr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Noir &amp; Grain — Servis Paneli</title>
<style>
:root { --bg:#0E0C09; --text:#F2E9DA; --accent:#C89B5A; --wine:#5C1F1A; --surface:#2A2620; }
* { box-sizing:border-box; margin:0; }
body { background:var(--bg); color:var(--text); font:15px/1.5 Georgia, serif; padding:2rem; }
h1 { font-weight:normal; letter-spacing:.2em; text-transform:uppercase; font-size:1.1rem; color:var(--accent); }
h1 span { color:var(--text); }
.bar { display:flex; gap:1rem; align-items:end; flex-wrap:wrap; margin:1.5rem 0 2rem; }
label { display:block; font-size:.7rem; letter-spacing:.15em; text-transform:uppercase; opacity:.6; margin-bottom:.3rem; }
input, select { background:var(--surface); color:var(--text); border:1px solid #3a352d; padding:.5rem .7rem; font:inherit; }
.grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(110px,1fr)); gap:.8rem; margin-bottom:2.5rem; }
.table { border:1px solid #3a352d; padding: .9rem .8rem; text-align:center; }
.table .no { font-size:1.6rem; color:var(--accent); }
.table.free { border-color:#3a4d35; }
.table.free .state { color:#8fbf7f; }
.table.busy { background:var(--wine); border-color:var(--wine); }
.state { font-size:.7rem; letter-spacing:.15em; text-transform:uppercase; }
.who { font-size:.8rem; opacity:.85; margin-top:.2rem; min-height:1.2em; }
ul { list-style:none; display:flex; flex-direction:column; gap:.6rem; }
li { display:flex; gap:1rem; align-items:center; flex-wrap:wrap; border-bottom:1px solid #2a2620; padding-bottom:.6rem; }
li b { font-weight:normal; font-style:italic; }
.st { font-size:.65rem; letter-spacing:.12em; text-transform:uppercase; padding:.2rem .5rem; border:1px solid var(--accent); color:var(--accent); }
button { background:none; border:1px solid var(--accent); color:var(--accent); padding:.3rem .8rem; cursor:pointer; font:inherit; font-size:.75rem; letter-spacing:.1em; text-transform:uppercase; }
button:hover { background:var(--accent); color:var(--bg); }
.muted { opacity:.5; }
</style>
</head>
<body>
<h1>Noir &amp; Grain <span>· Servis Paneli</span></h1>
<div class="bar">
<div><label for="d">Tarih</label><input type="date" id="d"></div>
<div><label for="t">Saat</label><select id="t"></select></div>
<div class="muted" id="summary"></div>
</div>
<div class="grid" id="tables"></div>
<h1 style="margin-bottom:1rem">Günün <span>Rezervasyonları</span></h1>
<ul id="list"></ul>
<script>
const TIMES = ['18:00','18:30','19:00','19:30','20:00','20:30','21:00','21:30','22:00'];
const d = document.getElementById('d'), t = document.getElementById('t');
d.value = new Date().toISOString().slice(0,10);
TIMES.forEach(x => t.add(new Option(x, x)));
t.value = '20:00';
async function refresh() {
const [map, list] = await Promise.all([
fetch(`/api/reservations/tables?date=${d.value}&time=${t.value}`).then(r => r.json()),
fetch(`/api/reservations?date=${d.value}`).then(r => r.json()),
]);
const free = map.tables.filter(x => !x.occupied).length;
document.getElementById('summary').textContent =
`${map.time} servisi · ${free}/${map.total} masa boş`;
document.getElementById('tables').innerHTML = map.tables.map(x => `
<div class="table ${x.occupied ? 'busy' : 'free'}">
<div class="no">${x.table}</div>
<div class="state">${x.occupied ? 'Dolu' : 'Boş'}</div>
<div class="who">${x.reservation ? x.reservation.name + ' · ' + x.reservation.guests + ' kişi' : ''}</div>
</div>`).join('');
document.getElementById('list').innerHTML = list.length ? list.map(r => `
<li>
<b>${r.time}</b> · Masa ${r.table} · ${r.name} · ${r.guests} kişi
<span class="st">${r.status}</span>
${r.note ? `<span class="muted">“${r.note}”</span>` : ''}
${r.status === 'pending' ? `<button onclick="setSt('${r.id}','confirmed')">Onayla</button>` : ''}
${r.status === 'confirmed' ? `<button onclick="setSt('${r.id}','seated')">Oturt</button>` : ''}
${r.status !== 'cancelled' ? `<button onclick="setSt('${r.id}','cancelled')">İptal</button>` : ''}
</li>`).join('') : '<li class="muted">Bu gün için rezervasyon yok — servis sakin.</li>';
}
async function setSt(id, status) {
await fetch(`/api/reservations/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }),
});
refresh();
}
d.onchange = refresh; t.onchange = refresh;
refresh();
</script>
</body>
</html>"""
@router.get("/admin/reservations", response_class=HTMLResponse, include_in_schema=False)
async def admin_panel() -> HTMLResponse:
return HTMLResponse(ADMIN_HTML)