Rthur2003 commited on
Commit
c1fe190
·
1 Parent(s): 4744538

feat: add reservations API with table management and email notifications

Browse files
app/main.py CHANGED
@@ -15,6 +15,7 @@ from .routes.youtube import router as youtube_router
15
  from .routes.data_processing import router as data_processing_router
16
  from .routes.analyze import router as analyze_router
17
  from .routes.commend import router as commend_router
 
18
  from .services.logging_config import setup_logging, get_logger
19
 
20
 
@@ -96,5 +97,6 @@ app.include_router(youtube_router)
96
  app.include_router(data_processing_router)
97
  app.include_router(analyze_router)
98
  app.include_router(commend_router)
 
99
 
100
  logger.info("CrownCode backend API initialized")
 
15
  from .routes.data_processing import router as data_processing_router
16
  from .routes.analyze import router as analyze_router
17
  from .routes.commend import router as commend_router
18
+ from .routes.reservations import router as reservations_router
19
  from .services.logging_config import setup_logging, get_logger
20
 
21
 
 
97
  app.include_router(data_processing_router)
98
  app.include_router(analyze_router)
99
  app.include_router(commend_router)
100
+ app.include_router(reservations_router)
101
 
102
  logger.info("CrownCode backend API initialized")
app/routes/reservations.py ADDED
@@ -0,0 +1,320 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Noir & Grain rezervasyon demo API'si + admin masa paneli.
3
+
4
+ Şablon vitrini için örnek uçtan uca akış:
5
+ - POST /api/reservations → talep oluştur (masa otomatik atanır) + örnek SMTP bildirimi
6
+ - GET /api/reservations?date= → gün listesi
7
+ - GET /api/reservations/tables → date+time için masa durumu (dolu/boş)
8
+ - PATCH /api/reservations/{id} → durum güncelle (confirmed/cancelled/seated)
9
+ - GET /admin/reservations → tek dosyalık karanlık admin paneli (masa haritası)
10
+
11
+ Depolama: DATA_DIR/reservations.json (HF Spaces'te kalıcı /data'ya işaret edin).
12
+ Mail: SMTP_HOST/USER/PASSWORD tanımlıysa gönderir; değilse sadece loglar (örnek entegrasyon).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import smtplib
20
+ import threading
21
+ import uuid
22
+ from datetime import date as date_type
23
+ from datetime import datetime, timezone
24
+ from email.mime.text import MIMEText
25
+ from pathlib import Path
26
+ from typing import Literal
27
+
28
+ from fastapi import APIRouter, HTTPException, Query
29
+ from fastapi.responses import HTMLResponse
30
+ from pydantic import BaseModel, Field, field_validator
31
+
32
+ from ..services.logging_config import get_logger
33
+
34
+ logger = get_logger(__name__)
35
+
36
+ router = APIRouter(tags=["reservations"])
37
+
38
+ DATA_DIR = Path(os.getenv("DATA_DIR", "data"))
39
+ STORE_PATH = DATA_DIR / "reservations.json"
40
+ TOTAL_TABLES = int(os.getenv("RESTAURANT_TOTAL_TABLES", "12"))
41
+
42
+ _lock = threading.Lock()
43
+
44
+
45
+ # ── Depolama ─────────────────────────────────────────────────────────
46
+
47
+ def _load() -> list[dict]:
48
+ if not STORE_PATH.exists():
49
+ return []
50
+ try:
51
+ return json.loads(STORE_PATH.read_text(encoding="utf-8"))
52
+ except json.JSONDecodeError:
53
+ logger.error("reservations.json bozuk, boş listeyle devam ediliyor")
54
+ return []
55
+
56
+
57
+ def _save(items: list[dict]) -> None:
58
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
59
+ STORE_PATH.write_text(json.dumps(items, ensure_ascii=False, indent=2), encoding="utf-8")
60
+
61
+
62
+ # ── Şemalar ──────────────────────────────────────────────────────────
63
+
64
+ class ReservationIn(BaseModel):
65
+ name: str = Field(min_length=2, max_length=80)
66
+ phone: str = Field(min_length=10, max_length=20)
67
+ date: str # YYYY-MM-DD
68
+ time: str # HH:MM
69
+ guests: int = Field(ge=1, le=12)
70
+ note: str = Field(default="", max_length=500)
71
+
72
+ @field_validator("date")
73
+ @classmethod
74
+ def date_not_past(cls, v: str) -> str:
75
+ try:
76
+ picked = date_type.fromisoformat(v)
77
+ except ValueError as exc:
78
+ raise ValueError("Tarih YYYY-MM-DD formatında olmalı") from exc
79
+ if picked < date_type.today():
80
+ raise ValueError("Geçmiş bir tarih seçilemez")
81
+ return v
82
+
83
+ @field_validator("time")
84
+ @classmethod
85
+ def time_format(cls, v: str) -> str:
86
+ try:
87
+ datetime.strptime(v, "%H:%M")
88
+ except ValueError as exc:
89
+ raise ValueError("Saat HH:MM formatında olmalı") from exc
90
+ return v
91
+
92
+
93
+ class Reservation(ReservationIn):
94
+ id: str
95
+ table: int
96
+ status: Literal["pending", "confirmed", "seated", "cancelled"]
97
+ created_at: str
98
+
99
+
100
+ class StatusUpdate(BaseModel):
101
+ status: Literal["pending", "confirmed", "seated", "cancelled"]
102
+
103
+
104
+ # ── Örnek mail bildirimi ─────────────────────────────────────────────
105
+
106
+ def _notify_by_mail(res: dict) -> None:
107
+ """SMTP env değişkenleri tanımlıysa restorana bildirim maili atar.
108
+
109
+ Tanımlı değilse sessizce loglar — demo ortamında kırılma olmaz.
110
+ """
111
+ host = os.getenv("SMTP_HOST")
112
+ user = os.getenv("SMTP_USER")
113
+ password = os.getenv("SMTP_PASSWORD")
114
+ to_addr = os.getenv("RESERVATION_MAIL_TO", user or "")
115
+ if not (host and user and password and to_addr):
116
+ logger.info("SMTP yapılandırılmamış — mail bildirimi atlandı (demo modu)")
117
+ return
118
+
119
+ body = (
120
+ f"Yeni rezervasyon talebi\n\n"
121
+ f"Ad: {res['name']}\nTelefon: {res['phone']}\n"
122
+ f"Tarih: {res['date']} Saat: {res['time']}\n"
123
+ f"Kişi: {res['guests']} Masa: {res['table']}\n"
124
+ f"Not: {res['note'] or '-'}"
125
+ )
126
+ msg = MIMEText(body, _charset="utf-8")
127
+ msg["Subject"] = f"Rezervasyon: {res['name']} — {res['date']} {res['time']}"
128
+ msg["From"] = os.getenv("SMTP_FROM", user)
129
+ msg["To"] = to_addr
130
+
131
+ try:
132
+ with smtplib.SMTP(host, int(os.getenv("SMTP_PORT", "587")), timeout=10) as smtp:
133
+ smtp.starttls()
134
+ smtp.login(user, password)
135
+ smtp.send_message(msg)
136
+ logger.info("Rezervasyon maili gönderildi: %s", res["id"])
137
+ except Exception as exc: # demo: mail hatası rezervasyonu düşürmesin
138
+ logger.error("Mail gönderilemedi: %s", exc)
139
+
140
+
141
+ # ── Yardımcılar ──────────────────────────────────────────────────────
142
+
143
+ def _occupied_tables(items: list[dict], date: str, time: str) -> dict[int, dict]:
144
+ """Aynı gün + saat dilimindeki iptal edilmemiş rezervasyonların masaları."""
145
+ return {
146
+ r["table"]: r
147
+ for r in items
148
+ if r["date"] == date and r["time"] == time and r["status"] != "cancelled"
149
+ }
150
+
151
+
152
+ # ── API ──────────────────────────────────────────────────────────────
153
+
154
+ @router.post("/api/reservations", response_model=Reservation, status_code=201)
155
+ async def create_reservation(payload: ReservationIn) -> Reservation:
156
+ with _lock:
157
+ items = _load()
158
+ occupied = _occupied_tables(items, payload.date, payload.time)
159
+ free = [n for n in range(1, TOTAL_TABLES + 1) if n not in occupied]
160
+ if not free:
161
+ raise HTTPException(status_code=409, detail="Bu saat için boş masa kalmadı")
162
+
163
+ record = {
164
+ **payload.model_dump(),
165
+ "id": uuid.uuid4().hex[:10],
166
+ "table": free[0],
167
+ "status": "pending",
168
+ "created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
169
+ }
170
+ items.append(record)
171
+ _save(items)
172
+
173
+ _notify_by_mail(record)
174
+ return Reservation(**record)
175
+
176
+
177
+ @router.get("/api/reservations", response_model=list[Reservation])
178
+ async def list_reservations(date: str | None = Query(default=None)) -> list[Reservation]:
179
+ items = _load()
180
+ if date:
181
+ items = [r for r in items if r["date"] == date]
182
+ items.sort(key=lambda r: (r["date"], r["time"], r["table"]))
183
+ return [Reservation(**r) for r in items]
184
+
185
+
186
+ @router.get("/api/reservations/tables")
187
+ async def table_map(date: str, time: str) -> dict:
188
+ items = _load()
189
+ occupied = _occupied_tables(items, date, time)
190
+ return {
191
+ "date": date,
192
+ "time": time,
193
+ "total": TOTAL_TABLES,
194
+ "tables": [
195
+ {
196
+ "table": n,
197
+ "occupied": n in occupied,
198
+ "reservation": (
199
+ {k: occupied[n][k] for k in ("id", "name", "guests", "status")}
200
+ if n in occupied else None
201
+ ),
202
+ }
203
+ for n in range(1, TOTAL_TABLES + 1)
204
+ ],
205
+ }
206
+
207
+
208
+ @router.patch("/api/reservations/{reservation_id}", response_model=Reservation)
209
+ async def update_status(reservation_id: str, payload: StatusUpdate) -> Reservation:
210
+ with _lock:
211
+ items = _load()
212
+ for r in items:
213
+ if r["id"] == reservation_id:
214
+ r["status"] = payload.status
215
+ _save(items)
216
+ return Reservation(**r)
217
+ raise HTTPException(status_code=404, detail="Rezervasyon bulunamadı")
218
+
219
+
220
+ # ── Admin paneli (tek dosyalık, Noir & Grain paletinde) ─────────────
221
+
222
+ ADMIN_HTML = """<!doctype html>
223
+ <html lang="tr">
224
+ <head>
225
+ <meta charset="utf-8">
226
+ <meta name="viewport" content="width=device-width, initial-scale=1">
227
+ <title>Noir &amp; Grain — Servis Paneli</title>
228
+ <style>
229
+ :root { --bg:#0E0C09; --text:#F2E9DA; --accent:#C89B5A; --wine:#5C1F1A; --surface:#2A2620; }
230
+ * { box-sizing:border-box; margin:0; }
231
+ body { background:var(--bg); color:var(--text); font:15px/1.5 Georgia, serif; padding:2rem; }
232
+ h1 { font-weight:normal; letter-spacing:.2em; text-transform:uppercase; font-size:1.1rem; color:var(--accent); }
233
+ h1 span { color:var(--text); }
234
+ .bar { display:flex; gap:1rem; align-items:end; flex-wrap:wrap; margin:1.5rem 0 2rem; }
235
+ label { display:block; font-size:.7rem; letter-spacing:.15em; text-transform:uppercase; opacity:.6; margin-bottom:.3rem; }
236
+ input, select { background:var(--surface); color:var(--text); border:1px solid #3a352d; padding:.5rem .7rem; font:inherit; }
237
+ .grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(110px,1fr)); gap:.8rem; margin-bottom:2.5rem; }
238
+ .table { border:1px solid #3a352d; padding: .9rem .8rem; text-align:center; }
239
+ .table .no { font-size:1.6rem; color:var(--accent); }
240
+ .table.free { border-color:#3a4d35; }
241
+ .table.free .state { color:#8fbf7f; }
242
+ .table.busy { background:var(--wine); border-color:var(--wine); }
243
+ .state { font-size:.7rem; letter-spacing:.15em; text-transform:uppercase; }
244
+ .who { font-size:.8rem; opacity:.85; margin-top:.2rem; min-height:1.2em; }
245
+ ul { list-style:none; display:flex; flex-direction:column; gap:.6rem; }
246
+ li { display:flex; gap:1rem; align-items:center; flex-wrap:wrap; border-bottom:1px solid #2a2620; padding-bottom:.6rem; }
247
+ li b { font-weight:normal; font-style:italic; }
248
+ .st { font-size:.65rem; letter-spacing:.12em; text-transform:uppercase; padding:.2rem .5rem; border:1px solid var(--accent); color:var(--accent); }
249
+ 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; }
250
+ button:hover { background:var(--accent); color:var(--bg); }
251
+ .muted { opacity:.5; }
252
+ </style>
253
+ </head>
254
+ <body>
255
+ <h1>Noir &amp; Grain <span>· Servis Paneli</span></h1>
256
+ <div class="bar">
257
+ <div><label for="d">Tarih</label><input type="date" id="d"></div>
258
+ <div><label for="t">Saat</label><select id="t"></select></div>
259
+ <div class="muted" id="summary"></div>
260
+ </div>
261
+
262
+ <div class="grid" id="tables"></div>
263
+
264
+ <h1 style="margin-bottom:1rem">Günün <span>Rezervasyonları</span></h1>
265
+ <ul id="list"></ul>
266
+
267
+ <script>
268
+ const TIMES = ['18:00','18:30','19:00','19:30','20:00','20:30','21:00','21:30','22:00'];
269
+ const d = document.getElementById('d'), t = document.getElementById('t');
270
+ d.value = new Date().toISOString().slice(0,10);
271
+ TIMES.forEach(x => t.add(new Option(x, x)));
272
+ t.value = '20:00';
273
+
274
+ async function refresh() {
275
+ const [map, list] = await Promise.all([
276
+ fetch(`/api/reservations/tables?date=${d.value}&time=${t.value}`).then(r => r.json()),
277
+ fetch(`/api/reservations?date=${d.value}`).then(r => r.json()),
278
+ ]);
279
+
280
+ const free = map.tables.filter(x => !x.occupied).length;
281
+ document.getElementById('summary').textContent =
282
+ `${map.time} servisi · ${free}/${map.total} masa boş`;
283
+
284
+ document.getElementById('tables').innerHTML = map.tables.map(x => `
285
+ <div class="table ${x.occupied ? 'busy' : 'free'}">
286
+ <div class="no">${x.table}</div>
287
+ <div class="state">${x.occupied ? 'Dolu' : 'Boş'}</div>
288
+ <div class="who">${x.reservation ? x.reservation.name + ' · ' + x.reservation.guests + ' kişi' : ''}</div>
289
+ </div>`).join('');
290
+
291
+ document.getElementById('list').innerHTML = list.length ? list.map(r => `
292
+ <li>
293
+ <b>${r.time}</b> · Masa ${r.table} · ${r.name} · ${r.guests} kişi
294
+ <span class="st">${r.status}</span>
295
+ ${r.note ? `<span class="muted">“${r.note}”</span>` : ''}
296
+ ${r.status === 'pending' ? `<button onclick="setSt('${r.id}','confirmed')">Onayla</button>` : ''}
297
+ ${r.status === 'confirmed' ? `<button onclick="setSt('${r.id}','seated')">Oturt</button>` : ''}
298
+ ${r.status !== 'cancelled' ? `<button onclick="setSt('${r.id}','cancelled')">İptal</button>` : ''}
299
+ </li>`).join('') : '<li class="muted">Bu gün için rezervasyon yok — servis sakin.</li>';
300
+ }
301
+
302
+ async function setSt(id, status) {
303
+ await fetch(`/api/reservations/${id}`, {
304
+ method: 'PATCH',
305
+ headers: { 'Content-Type': 'application/json' },
306
+ body: JSON.stringify({ status }),
307
+ });
308
+ refresh();
309
+ }
310
+
311
+ d.onchange = refresh; t.onchange = refresh;
312
+ refresh();
313
+ </script>
314
+ </body>
315
+ </html>"""
316
+
317
+
318
+ @router.get("/admin/reservations", response_class=HTMLResponse, include_in_schema=False)
319
+ async def admin_panel() -> HTMLResponse:
320
+ return HTMLResponse(ADMIN_HTML)
tests/test_reservations.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rezervasyon API testleri — geçici DATA_DIR ile izole çalışır."""
2
+
3
+ import importlib
4
+ import os
5
+
6
+ import pytest
7
+ from fastapi import FastAPI
8
+ from fastapi.testclient import TestClient
9
+
10
+
11
+ @pytest.fixture()
12
+ def client(tmp_path, monkeypatch):
13
+ monkeypatch.setenv("DATA_DIR", str(tmp_path))
14
+ monkeypatch.setenv("RESTAURANT_TOTAL_TABLES", "2")
15
+ from app.routes import reservations
16
+ importlib.reload(reservations)
17
+
18
+ app = FastAPI()
19
+ app.include_router(reservations.router)
20
+ return TestClient(app)
21
+
22
+
23
+ PAYLOAD = {
24
+ "name": "Arda Yılmaz",
25
+ "phone": "05321112233",
26
+ "date": "2030-08-12",
27
+ "time": "20:00",
28
+ "guests": 4,
29
+ "note": "Pencere kenarı",
30
+ }
31
+
32
+
33
+ def test_create_assigns_table_and_lists(client):
34
+ r = client.post("/api/reservations", json=PAYLOAD)
35
+ assert r.status_code == 201
36
+ body = r.json()
37
+ assert body["table"] == 1
38
+ assert body["status"] == "pending"
39
+
40
+ listed = client.get("/api/reservations", params={"date": PAYLOAD["date"]}).json()
41
+ assert len(listed) == 1
42
+
43
+
44
+ def test_table_map_and_capacity(client):
45
+ client.post("/api/reservations", json=PAYLOAD)
46
+ client.post("/api/reservations", json={**PAYLOAD, "name": "İkinci Misafir"})
47
+
48
+ tables = client.get(
49
+ "/api/reservations/tables",
50
+ params={"date": PAYLOAD["date"], "time": PAYLOAD["time"]},
51
+ ).json()
52
+ assert tables["total"] == 2
53
+ assert all(t["occupied"] for t in tables["tables"])
54
+
55
+ full = client.post("/api/reservations", json={**PAYLOAD, "name": "Üçüncü"})
56
+ assert full.status_code == 409
57
+
58
+
59
+ def test_cancel_frees_table(client):
60
+ created = client.post("/api/reservations", json=PAYLOAD).json()
61
+ r = client.patch(f"/api/reservations/{created['id']}", json={"status": "cancelled"})
62
+ assert r.status_code == 200
63
+
64
+ tables = client.get(
65
+ "/api/reservations/tables",
66
+ params={"date": PAYLOAD["date"], "time": PAYLOAD["time"]},
67
+ ).json()
68
+ assert sum(t["occupied"] for t in tables["tables"]) == 0
69
+
70
+
71
+ def test_past_date_rejected(client):
72
+ r = client.post("/api/reservations", json={**PAYLOAD, "date": "2020-01-01"})
73
+ assert r.status_code == 422