Spaces:
Sleeping
Sleeping
| """ | |
| 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) | |
| 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 | |
| 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 ────────────────────────────────────────────────────────────── | |
| 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) | |
| 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] | |
| 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) | |
| ], | |
| } | |
| 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 & 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 & 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>""" | |
| async def admin_panel() -> HTMLResponse: | |
| return HTMLResponse(ADMIN_HTML) | |