Răsfoiți Sursa

Add check-in/check-out time editing to reservation panel UI

Wires the existing PATCH /reservation time-adjustment support into
calendar.html with editable time inputs, completing the feature.
ZiB 3 luni în urmă
părinte
comite
df09dd504f

+ 12 - 8
room_cal/api/_routes/reservations.py

@@ -1,4 +1,3 @@
-from dataclasses import asdict
 from datetime import date
 from fastapi import APIRouter, Body, HTTPException
 from room_cal.reservations import Reservation
@@ -29,15 +28,20 @@ async def get_reservation(target_date: date):
 @router.patch('/reservation/{uid}')
 async def patch_reservation(uid: str, body: dict = Body(...)):
   checkin_date = date.fromisoformat(body['checkin_date'])
-  result = await Reservation().patch(
-    uid, checkin_date,
-    wifi_ssid=body.get('wifi_ssid'),
-    wifi_key=body.get('wifi_key'),
-    door_code=body.get('door_code'),
-  )
+  reserved_keys = {'checkin_date', 'checkin_time', 'checkout_time'}
+  fields = {k: v for k, v in body.items() if k not in reserved_keys}
+  try:
+    result = await Reservation().patch(
+      uid, checkin_date,
+      fields=fields,
+      checkin_time=body.get('checkin_time'),
+      checkout_time=body.get('checkout_time'),
+    )
+  except ValueError as e:
+    raise HTTPException(status_code=400, detail=str(e))
   if result is None:
     raise HTTPException(status_code=404, detail='reservation not found')
-  return asdict(result)
+  return result
 
 
 @router.get('/reservation/{start_date}/{end_date}')

+ 12 - 5
room_cal/mcp/__init__.py

@@ -69,16 +69,23 @@ async def set_price(target_date: str, value: float) -> None:
 
 
 @mcp.tool()
-async def patch_reservation(uid: str, checkin_date: str, wifi_ssid: str | None = None, wifi_key: str | None = None, door_code: str | None = None) -> dict:
-  """Update guest access fields (wifi_ssid, wifi_key, door_code) on a stored reservation. checkin_date (YYYY-MM-DD) is required to locate the event. Only supplied fields are written. Returns updated ReservationData as a dict."""
-  from dataclasses import asdict
+async def patch_reservation(uid: str, checkin_date: str, fields: dict[str, str] | None = None,
+                            checkin_time: str | None = None, checkout_time: str | None = None) -> dict:
+  """Patch a stored reservation. checkin_date (YYYY-MM-DD) is required to locate the event.
+
+  `fields` is merged directly into the reservation's stored data — wifi_key, wifi_ssid,
+  door_code, or any other custom field — except the sync-owned identity fields
+  (reservation_id, service, service_data, chat_id, guest_name), which are rejected.
+  `checkin_time`/`checkout_time` are 'HH:MM' strings that adjust only the time-of-day
+  of checkin/checkout; the date is never changed. Returns the updated reservation dict.
+  """
   result = await Reservation().patch(
     uid, date.fromisoformat(checkin_date),
-    wifi_ssid=wifi_ssid, wifi_key=wifi_key, door_code=door_code,
+    fields=fields, checkin_time=checkin_time, checkout_time=checkout_time,
   )
   if result is None:
     raise ValueError(f'reservation {uid!r} not found on {checkin_date}')
-  return asdict(result)
+  return result
 
 
 @mcp.tool()

+ 36 - 20
room_cal/reservations/__init__.py

@@ -10,6 +10,15 @@ from room_cal._mnemonic import wifi_key as _gen_wifi_key, door_code as _gen_door
 _PREFIX = 'Guest: '
 _log = logging.getLogger(__name__)
 
+# Identity fields owned by the sync path (Reservation.set()) — patch() must not let a caller
+# clobber them, since they're re-derived from upstream platform data on every sync.
+_PROTECTED_XDATA_KEYS = {'reservation_id', 'service', 'service_data', 'chat_id', 'guest_name'}
+
+
+def _replace_time(dt: datetime, hhmm: str) -> datetime:
+  hour, minute = (int(p) for p in hhmm.split(':'))
+  return dt.replace(hour=hour, minute=minute, second=0, microsecond=0)
+
 
 @dataclass
 class ReservationData:
@@ -107,35 +116,42 @@ class Reservation(StoreBacked, EventSource):
     return filter_events(events, _PREFIX, all_day=False)
 
   async def patch(self, uid: str, checkin_date: date, *,
-                  wifi_ssid: str | None = None,
-                  wifi_key: str | None = None,
-                  door_code: str | None = None) -> 'ReservationData | None':
+                  fields: dict | None = None,
+                  checkin_time: str | None = None,
+                  checkout_time: str | None = None) -> 'dict | None':
+    """Patch arbitrary x_data fields and/or the time-of-day (not date) of checkin/checkout.
+
+    `fields` is merged directly into the stored x_data dict — any key can be added or
+    overwritten except the sync-owned identity fields in _PROTECTED_XDATA_KEYS.
+    `checkin_time`/`checkout_time` are 'HH:MM' strings that replace only the time
+    component of the existing start/end datetime; the date is never changed here.
+    Returns the full merged x_data dict (plus checkin/checkout date/time) — not the
+    fixed-field ReservationData — so newly patched keys are reflected back too.
+    """
     events = await self._fetch_date_range(checkin_date, checkin_date + timedelta(days=1))
     event = next((e for e in filter_events(events, _PREFIX, all_day=False) if e.uid == uid), None)
     if event is None:
       return None
+    fields = fields or {}
+    bad_keys = _PROTECTED_XDATA_KEYS & fields.keys()
+    if bad_keys:
+      raise ValueError(f"cannot patch protected reservation field(s): {', '.join(sorted(bad_keys))}")
     xd = safe_json_loads(event.x_data) or {}
-    if wifi_ssid is not None:
-      xd['wifi_ssid'] = wifi_ssid
-    if wifi_key is not None:
-      xd['wifi_key'] = wifi_key
-    if door_code is not None:
-      xd['door_code'] = door_code
+    xd.update(fields)
     wk = xd.get('wifi_key', '')
     ws = xd.get('wifi_ssid')
     dc = xd.get('door_code', '')
     description = _build_description(xd, wk, ws, dc)
-    await self._backend.update(uid, description=description, x_data=json.dumps(xd))
-    return ReservationData(
-      guest_name=xd.get('guest_name'),
-      service=xd.get('service', ''),
-      reservation_id=xd.get('reservation_id', ''),
-      service_data=xd.get('service_data') or {},
-      chat_id=xd.get('chat_id'),
-      wifi_key=xd.get('wifi_key'),
-      wifi_ssid=xd.get('wifi_ssid'),
-      door_code=xd.get('door_code'),
-    )
+    start = _replace_time(event.start, checkin_time) if checkin_time is not None else None
+    end = _replace_time(event.end, checkout_time) if checkout_time is not None else None
+    updated = await self._backend.update(uid, description=description, x_data=json.dumps(xd), start=start, end=end)
+    result = dict(xd)
+    result['uid'] = uid
+    result['checkin_date'] = updated.start.date().isoformat()
+    result['checkout_date'] = updated.end.date().isoformat()
+    result['checkin_time'] = updated.start.strftime('%H:%M')
+    result['checkout_time'] = updated.end.strftime('%H:%M')
+    return result
 
   async def delete(self, event) -> None:
     await self._backend.delete(event.uid)

+ 2 - 1
room_cal/store/_backend.py

@@ -20,7 +20,8 @@ class EventBackend(Protocol):
   async def fetch_range(self, start: datetime, end: datetime) -> list[EventRecord]: ...
   async def create(self, record: EventRecord) -> EventRecord: ...
   async def update(self, uid: str, *, title: str | None = None,
-                   description: str | None = None, x_data: str | None = None) -> EventRecord: ...
+                   description: str | None = None, x_data: str | None = None,
+                   start: datetime | None = None, end: datetime | None = None) -> EventRecord: ...
   async def delete(self, uid: str) -> None: ...
 
 

+ 10 - 1
room_cal/store/_caldav.py

@@ -83,7 +83,8 @@ class CalDAVBackend:
     return _dict_to_record(_parse_event(saved))
 
   async def update(self, uid: str, *, title: str | None = None,
-                   description: str | None = None, x_data: str | None = None) -> EventRecord:
+                   description: str | None = None, x_data: str | None = None,
+                   start: datetime | None = None, end: datetime | None = None) -> EventRecord:
     async with await self._cal._client() as client:
       cal = await self._cal._get_calendar(client)
       raw = await cal.event_by_uid(uid)
@@ -103,6 +104,14 @@ class CalDAVBackend:
           if _X_DATA_PROP in component:
             del component[_X_DATA_PROP]
           component.add(_X_DATA_PROP, x_data)
+        if start is not None:
+          if 'DTSTART' in component:
+            del component['DTSTART']
+          component.add('dtstart', start)
+        if end is not None:
+          if 'DTEND' in component:
+            del component['DTEND']
+          component.add('dtend', end)
       raw.data = ical.to_ical().decode('utf-8')
       await raw.save()
     self._cal._event_cache.clear()

+ 6 - 1
room_cal/store/_sqlite.py

@@ -117,7 +117,8 @@ class SQLiteBackend:
     return record
 
   async def update(self, uid: str, *, title: str | None = None,
-                   description: str | None = None, x_data: str | None = None) -> EventRecord:
+                   description: str | None = None, x_data: str | None = None,
+                   start: datetime | None = None, end: datetime | None = None) -> EventRecord:
     async with self._connect() as db:
       await db.execute(_CREATE_TABLE)
       if title is not None:
@@ -126,6 +127,10 @@ class SQLiteBackend:
         await db.execute('UPDATE events SET description = ? WHERE uid = ?', (description, uid))
       if x_data is not None:
         await db.execute('UPDATE events SET x_data = ? WHERE uid = ?', (x_data, uid))
+      if start is not None:
+        await db.execute('UPDATE events SET start = ? WHERE uid = ?', (start.isoformat(), uid))
+      if end is not None:
+        await db.execute('UPDATE events SET end = ? WHERE uid = ?', (end.isoformat(), uid))
       await db.commit()
       cursor = await db.execute(f'SELECT {_COLS} FROM events WHERE uid = ?', (uid,))
       row = await cursor.fetchone()

+ 58 - 12
room_cal/vue/calendar.html

@@ -260,6 +260,7 @@
     .panel-input:focus { border-bottom-color: var(--accent, #6366f1); }
     .panel-input.is-sm { width: 2.5ch; }
     .panel-input.is-door { width: 4ch; }
+    .panel-input.is-time { width: 9ch; }
     .panel-value.is-grow { flex: 1; min-width: 0; }
     .panel-value.is-grow .panel-input { width: 100%; }
     .type-switch { display: inline-flex; align-items: baseline; gap: 2px; margin-left: 4px; font-size: 10.5px; }
@@ -1161,13 +1162,37 @@
             <div class="panel-subtitle">{{ selection.nights }} night{{ selection.nights !== 1 ? 's' : '' }}</div>
           </div>
           <div class="panel-rows">
-            <div class="panel-row">
+            <div class="panel-row" :class="{ 'is-saving': resCheckinTimeState==='saving', 'is-error': resCheckinTimeState==='error' }">
               <span class="panel-label">Check-in</span>
-              <span class="panel-value">{{ fmtLong(selection.checkin_date) }}</span>
+              <span class="panel-value">
+                {{ fmtDateNoYear(selection.checkin_date) }} @
+                <input class="panel-input is-time" type="time"
+                  :value="editingCheckinTime ?? (selection.checkin_time || '')"
+                  @focus="editingCheckinTime = selection.checkin_time || ''"
+                  @input="editingCheckinTime = $event.target.value"
+                  @blur="commitResField('checkin_time')"
+                  @keydown.enter="$event.target.blur()"
+                  @keydown.escape="editingCheckinTime = null; $event.target.blur()"
+                />
+                <span v-if="resCheckinTimeState==='saving'" class="row-spinner"></span>
+                <span v-else-if="resCheckinTimeState==='error'" class="row-err">Failed</span>
+              </span>
             </div>
-            <div class="panel-row">
+            <div class="panel-row" :class="{ 'is-saving': resCheckoutTimeState==='saving', 'is-error': resCheckoutTimeState==='error' }">
               <span class="panel-label">Check-out</span>
-              <span class="panel-value">{{ fmtLong(selection.checkout_date) }}</span>
+              <span class="panel-value">
+                {{ fmtDateNoYear(selection.checkout_date) }} @
+                <input class="panel-input is-time" type="time"
+                  :value="editingCheckoutTime ?? (selection.checkout_time || '')"
+                  @focus="editingCheckoutTime = selection.checkout_time || ''"
+                  @input="editingCheckoutTime = $event.target.value"
+                  @blur="commitResField('checkout_time')"
+                  @keydown.enter="$event.target.blur()"
+                  @keydown.escape="editingCheckoutTime = null; $event.target.blur()"
+                />
+                <span v-if="resCheckoutTimeState==='saving'" class="row-spinner"></span>
+                <span v-else-if="resCheckoutTimeState==='error'" class="row-err">Failed</span>
+              </span>
             </div>
             <div class="panel-row">
               <span class="panel-label">Service</span>
@@ -1366,9 +1391,13 @@ createApp({
     const editingWifiSsid     = ref(null);
     const editingWifiKey      = ref(null);
     const editingDoorCode     = ref(null);
+    const editingCheckinTime  = ref(null);
+    const editingCheckoutTime = ref(null);
     const resSsidState        = ref(null);
     const resKeyState         = ref(null);
     const resDoorState        = ref(null);
+    const resCheckinTimeState  = ref(null);
+    const resCheckoutTimeState = ref(null);
     const pendingDiscountType = ref(null);
     const pendingClosed       = ref(null);
     const discountInputRef    = ref(null);
@@ -1580,9 +1609,13 @@ createApp({
       editingDoorCode.value = null;
       editingWifiKey.value  = null;
       editingWifiSsid.value = null;
+      editingCheckinTime.value  = null;
+      editingCheckoutTime.value = null;
       resDoorState.value    = null;
       resKeyState.value     = null;
       resSsidState.value    = null;
+      resCheckinTimeState.value  = null;
+      resCheckoutTimeState.value = null;
       if (sel?.type === 'res' && sel.chat_id && selectionSupportsChat.value) {
         chatThread.value    = null;
         chatError.value     = null;
@@ -1690,8 +1723,14 @@ createApp({
     }
 
     async function commitResField(field) {
-      const editingMap = { wifi_ssid: editingWifiSsid, wifi_key: editingWifiKey, door_code: editingDoorCode };
-      const stateMap   = { wifi_ssid: resSsidState,    wifi_key: resKeyState,    door_code: resDoorState };
+      const editingMap = {
+        wifi_ssid: editingWifiSsid, wifi_key: editingWifiKey, door_code: editingDoorCode,
+        checkin_time: editingCheckinTime, checkout_time: editingCheckoutTime,
+      };
+      const stateMap   = {
+        wifi_ssid: resSsidState,    wifi_key: resKeyState,    door_code: resDoorState,
+        checkin_time: resCheckinTimeState, checkout_time: resCheckoutTimeState,
+      };
       const editRef  = editingMap[field];
       const stateRef = stateMap[field];
       const newVal   = editRef.value;
@@ -1710,12 +1749,15 @@ createApp({
         if (!res.ok) throw new Error(await res.text());
         const updated = await res.json();
         if (selection.value?.uid !== targetUid) return;
-        selection.value.wifi_key  = updated.wifi_key  ?? selection.value.wifi_key;
-        selection.value.wifi_ssid = updated.wifi_ssid ?? selection.value.wifi_ssid;
-        selection.value.door_code = updated.door_code ?? selection.value.door_code;
+        selection.value.wifi_key      = updated.wifi_key      ?? selection.value.wifi_key;
+        selection.value.wifi_ssid     = updated.wifi_ssid     ?? selection.value.wifi_ssid;
+        selection.value.door_code     = updated.door_code     ?? selection.value.door_code;
+        selection.value.checkin_time  = updated.checkin_time  ?? selection.value.checkin_time;
+        selection.value.checkout_time = updated.checkout_time ?? selection.value.checkout_time;
         if (reservations.value[targetCheckin]) {
           Object.assign(reservations.value[targetCheckin], {
             wifi_key: updated.wifi_key, wifi_ssid: updated.wifi_ssid, door_code: updated.door_code,
+            checkin_time: updated.checkin_time, checkout_time: updated.checkout_time,
           });
         }
         stateRef.value = null;
@@ -1742,6 +1784,10 @@ createApp({
       return parseLocal(dateStr).toLocaleString('default', { month: 'short', day: 'numeric', year: 'numeric' });
     }
 
+    function fmtDateNoYear(dateStr) {
+      return parseLocal(dateStr).toLocaleString('default', { month: 'short', day: 'numeric' });
+    }
+
     function fmtShort(ds) {
       if (!ds) return '—';
       const [y, m, d] = ds.split('-').map(Number);
@@ -2466,12 +2512,12 @@ createApp({
       runHealthCheck, toggleSync, toggleBulk, fetchDiff, fixDay, fixResChanges, fixBusy, fixDayBusy, fixMonthBusy, FIX,
       monthCats, fixMonth, fixMonthFeeds, fixMonthScripts, reloadCaches,
       toggleClosed, commitPrice, switchDiscountType, commitDiscount, commitResField,
-      editingDoorCode, editingWifiKey, editingWifiSsid,
-      resDoorState, resKeyState, resSsidState,
+      editingDoorCode, editingWifiKey, editingWifiSsid, editingCheckinTime, editingCheckoutTime,
+      resDoorState, resKeyState, resSsidState, resCheckinTimeState, resCheckoutTimeState,
       monthName, monthStatsLabel,
       todayKey, inMonth, isClosed,
       priceFor, discountFor, priceDataFor, dayServiceKeys, dayStatus,
-      fmtDate, fmtLong, fmtShort, fmtDiscount, fmtField, capitalize, serviceName,
+      fmtDate, fmtLong, fmtDateNoYear, fmtShort, fmtDiscount, fmtField, capitalize, serviceName,
       selectDay, selectReservation, fmtBulkDates,
       reservationSpans, closureSpans,
       prevMonth, nextMonth,

+ 80 - 0
tests/test_reservation.py

@@ -1,3 +1,4 @@
+import pytest
 from datetime import date, timedelta
 from room_cal.reservations import Reservation
 
@@ -26,3 +27,82 @@ async def test_add_reservation_round_trip():
     event = await res._find('A1-B2-C3', checkin, checkout)
     if event:
       await res.delete(event)
+
+
+async def _make_reservation(res: Reservation, reservation_id: str, checkin: date, checkout: date):
+  await res.set({
+    'guest_name':      'Patch Test',
+    'service':        'Test Service',
+    'reservation_id': reservation_id,
+    'checkin_date':   checkin,
+    'checkout_date':  checkout,
+  })
+  return await res._find(reservation_id, checkin, checkout)
+
+
+async def test_patch_generic_fields():
+  res = Reservation()
+  checkin = date.today() - timedelta(days=31)
+  checkout = checkin + timedelta(days=1)
+  reservation_id = 'PATCH-FIELDS-1'
+
+  try:
+    event = await _make_reservation(res, reservation_id, checkin, checkout)
+
+    result = await res.patch(event.uid, checkin, fields={'door_code': '4242', 'note': 'left a spare key'})
+    assert result['door_code'] == '4242'
+    assert result['note'] == 'left a spare key'
+    assert result['reservation_id'] == reservation_id  # untouched identity field survives
+
+    stored = await res._find(reservation_id, checkin, checkout)
+    from room_cal._utils import safe_json_loads
+    xd = safe_json_loads(stored.x_data)
+    assert xd['door_code'] == '4242'
+    assert xd['note'] == 'left a spare key'
+  finally:
+    event = await res._find(reservation_id, checkin, checkout)
+    if event:
+      await res.delete(event)
+
+
+async def test_patch_rejects_protected_fields():
+  res = Reservation()
+  checkin = date.today() - timedelta(days=32)
+  checkout = checkin + timedelta(days=1)
+  reservation_id = 'PATCH-PROTECTED-1'
+
+  try:
+    event = await _make_reservation(res, reservation_id, checkin, checkout)
+
+    with pytest.raises(ValueError):
+      await res.patch(event.uid, checkin, fields={'reservation_id': 'HIJACKED'})
+  finally:
+    event = await res._find(reservation_id, checkin, checkout)
+    if event:
+      await res.delete(event)
+
+
+async def test_patch_checkin_checkout_time():
+  res = Reservation()
+  checkin = date.today() - timedelta(days=33)
+  checkout = checkin + timedelta(days=1)
+  reservation_id = 'PATCH-TIME-1'
+
+  try:
+    event = await _make_reservation(res, reservation_id, checkin, checkout)
+
+    result = await res.patch(event.uid, checkin, checkin_time='17:00', checkout_time='09:30')
+    assert result['checkin_time'] == '17:00'
+    assert result['checkout_time'] == '09:30'
+    assert result['checkin_date'] == checkin.isoformat()  # date untouched
+    assert result['checkout_date'] == checkout.isoformat()
+
+    stored = await res._find(reservation_id, checkin, checkout)
+    assert stored.start.date() == checkin
+    assert stored.start.hour == 17 and stored.start.minute == 0
+    assert stored.end.date() == checkout
+    assert stored.end.hour == 9 and stored.end.minute == 30
+  finally:
+    event = await res._find(reservation_id, checkin, checkout)
+    if event:
+      await res.delete(event)