Browse Source

Add wifi_ssid to ReservationData and reservation.default config

Adds wifi_ssid alongside wifi_key in ReservationData and x_data storage.
Resolution priority matches other codes: incoming data → stored value →
reservation.default config → generated (wifi_ssid has no generator).

The description renders as "WiFi: <ssid> / <key>" when ssid is set.
patch_reservation_codes.py gains --ssid flag. config.json gains a
reservation.default section with wifi_key, wifi_ssid, door_code.
ZiB 4 months ago
parent
commit
39d46c72b9

+ 26 - 0
room_cal/_utils.py

@@ -374,6 +374,30 @@ class StoreConfig:
     )
     )
 
 
 
 
+@dataclass
+class ReservationDefaultsConfig:
+  wifi_key: str | None = None
+  wifi_ssid: str | None = None
+  door_code: str | None = None
+
+  @classmethod
+  def from_dict(cls, d: dict) -> 'ReservationDefaultsConfig':
+    return cls(
+      wifi_key=d.get('wifi_key') or None,
+      wifi_ssid=d.get('wifi_ssid') or None,
+      door_code=d.get('door_code') or None,
+    )
+
+
+@dataclass
+class ReservationConfig:
+  default: ReservationDefaultsConfig = field(default_factory=ReservationDefaultsConfig)
+
+  @classmethod
+  def from_dict(cls, d: dict) -> 'ReservationConfig':
+    return cls(default=ReservationDefaultsConfig.from_dict(d.get('default', {})))
+
+
 @dataclass
 @dataclass
 class Config:
 class Config:
   global_: GlobalConfig = field(default_factory=GlobalConfig)
   global_: GlobalConfig = field(default_factory=GlobalConfig)
@@ -382,6 +406,7 @@ class Config:
   calendars: list[CalendarEntry] = field(default_factory=list)
   calendars: list[CalendarEntry] = field(default_factory=list)
   api_url: str | None = None
   api_url: str | None = None
   store: StoreConfig = field(default_factory=StoreConfig)
   store: StoreConfig = field(default_factory=StoreConfig)
+  reservation: ReservationConfig = field(default_factory=ReservationConfig)
 
 
   @classmethod
   @classmethod
   def from_dict(cls, d: dict) -> 'Config':
   def from_dict(cls, d: dict) -> 'Config':
@@ -393,6 +418,7 @@ class Config:
       calendars=[CalendarEntry.from_dict(e) for e in d.get('calendars', [])],
       calendars=[CalendarEntry.from_dict(e) for e in d.get('calendars', [])],
       api_url=d.get('api_url'),
       api_url=d.get('api_url'),
       store=StoreConfig.from_dict(store_d),
       store=StoreConfig.from_dict(store_d),
+      reservation=ReservationConfig.from_dict(d.get('reservation', {})),
     )
     )
 
 
 
 

+ 7 - 0
room_cal/config-all.json

@@ -53,6 +53,13 @@
       "db_path": "store.db"
       "db_path": "store.db"
     }
     }
   },
   },
+  "reservation": {
+    "default": {
+      "wifi_key": "",
+      "wifi_ssid": "",
+      "door_code": ""
+    }
+  },
   "global": {
   "global": {
     "taxes": {
     "taxes": {
       "sales": 0.157
       "sales": 0.157

+ 8 - 1
room_cal/config.json

@@ -50,5 +50,12 @@
       "url": "webcal://cal.4114.us/fire.ics"
       "url": "webcal://cal.4114.us/fire.ics"
     }
     }
   ],
   ],
-  "api_url": "http://localhost:8000/"
+  "api_url": "http://localhost:8000/",
+  "reservation": {
+    "default": {
+      "wifi_key": "",
+      "wifi_ssid": "",
+      "door_code": ""
+    }
+  }
 }
 }

+ 15 - 5
room_cal/reservations/__init__.py

@@ -17,6 +17,7 @@ class ReservationData:
   service_data: dict[str, str] = field(default_factory=dict)
   service_data: dict[str, str] = field(default_factory=dict)
   chat_id: str | None = None
   chat_id: str | None = None
   wifi_key: str | None = None
   wifi_key: str | None = None
+  wifi_ssid: str | None = None
   door_code: str | None = None
   door_code: str | None = None
 
 
 
 
@@ -24,6 +25,10 @@ def _property_cfg():
   return get_config().global_.property
   return get_config().global_.property
 
 
 
 
+def _reservation_defaults():
+  return get_config().reservation.default
+
+
 class Reservation(StoreBacked, EventSource):
 class Reservation(StoreBacked, EventSource):
   def __init__(self, calendar_name: str = DEFAULT_CALENDAR_NAME, *, backend=None):
   def __init__(self, calendar_name: str = DEFAULT_CALENDAR_NAME, *, backend=None):
     StoreBacked.__init__(self, calendar_name, backend=backend)
     StoreBacked.__init__(self, calendar_name, backend=backend)
@@ -41,19 +46,22 @@ class Reservation(StoreBacked, EventSource):
       existing = None
       existing = None
 
 
     existing_xd = safe_json_loads(existing.x_data) if existing else None
     existing_xd = safe_json_loads(existing.x_data) if existing else None
-    wk = data.get('wifi_key') or (existing_xd or {}).get('wifi_key') or _gen_wifi_key()
-    dc = data.get('door_code') or (existing_xd or {}).get('door_code') or _gen_door_code()
+    defaults = _reservation_defaults()
+    wk = data.get('wifi_key') or (existing_xd or {}).get('wifi_key') or defaults.wifi_key or _gen_wifi_key()
+    ws = data.get('wifi_ssid') or (existing_xd or {}).get('wifi_ssid') or defaults.wifi_ssid
+    dc = data.get('door_code') or (existing_xd or {}).get('door_code') or defaults.door_code or _gen_door_code()
 
 
     x_data_dict = {k: data.get(k) for k in ['guest_name', 'service', 'reservation_id']}
     x_data_dict = {k: data.get(k) for k in ['guest_name', 'service', 'reservation_id']}
     x_data_dict['service_data'] = data.get('service_data') or {}
     x_data_dict['service_data'] = data.get('service_data') or {}
     x_data_dict['chat_id'] = data.get('chat_id')
     x_data_dict['chat_id'] = data.get('chat_id')
     x_data_dict['wifi_key'] = wk
     x_data_dict['wifi_key'] = wk
+    x_data_dict['wifi_ssid'] = ws
     x_data_dict['door_code'] = dc
     x_data_dict['door_code'] = dc
     x_data = json.dumps(x_data_dict)
     x_data = json.dumps(x_data_dict)
 
 
     first_name = (data.get('guest_name') or '').split()[0] or 'Guest'
     first_name = (data.get('guest_name') or '').split()[0] or 'Guest'
     title = f"{_PREFIX}{first_name}"
     title = f"{_PREFIX}{first_name}"
-    description = _build_description(data, wk, dc)
+    description = _build_description(data, wk, ws, dc)
 
 
     if existing:
     if existing:
       if title != existing.title or description != existing.description or x_data != existing.x_data:
       if title != existing.title or description != existing.description or x_data != existing.x_data:
@@ -79,6 +87,7 @@ class Reservation(StoreBacked, EventSource):
       service_data=d.get('service_data') or {},
       service_data=d.get('service_data') or {},
       chat_id=d.get('chat_id'),
       chat_id=d.get('chat_id'),
       wifi_key=d.get('wifi_key'),
       wifi_key=d.get('wifi_key'),
+      wifi_ssid=d.get('wifi_ssid'),
       door_code=d.get('door_code'),
       door_code=d.get('door_code'),
     )
     )
 
 
@@ -99,12 +108,13 @@ class Reservation(StoreBacked, EventSource):
     return None
     return None
 
 
 
 
-def _build_description(data: dict, wk: str, dc: str) -> str:
+def _build_description(data: dict, wk: str, ws: str | None, dc: str) -> str:
   svc = data['service']
   svc = data['service']
+  wifi_line = f"WiFi: {ws} / {wk}" if ws else f"WiFi: {wk}"
   return (
   return (
     f"Name: {data.get('guest_name', '')}\n"
     f"Name: {data.get('guest_name', '')}\n"
     f"Service: {service_name(svc)}\n"
     f"Service: {service_name(svc)}\n"
     f"Reservation ID: {data['reservation_id']}\n"
     f"Reservation ID: {data['reservation_id']}\n"
     f"Door: {dc}\n"
     f"Door: {dc}\n"
-    f"WiFi: {wk}"
+    f"{wifi_line}"
   )
   )

+ 16 - 9
scripts/patch_reservation_codes.py

@@ -1,9 +1,9 @@
 #!/usr/bin/env python3
 #!/usr/bin/env python3
-"""Patch wifi_key and/or door_code on a reservation that contains a given date.
+"""Patch wifi_key, wifi_ssid, and/or door_code on a reservation that contains a given date.
 
 
 Usage:
 Usage:
   uv run python3 scripts/patch_reservation_codes.py 2026-07-05 --wifi stone-angel-market --door 0472
   uv run python3 scripts/patch_reservation_codes.py 2026-07-05 --wifi stone-angel-market --door 0472
-  uv run python3 scripts/patch_reservation_codes.py 2026-07-05 --wifi new-word-combo
+  uv run python3 scripts/patch_reservation_codes.py 2026-07-05 --wifi new-word-combo --ssid MyNetwork
   uv run python3 scripts/patch_reservation_codes.py 2026-07-05 --door 1234
   uv run python3 scripts/patch_reservation_codes.py 2026-07-05 --door 1234
 """
 """
 import argparse
 import argparse
@@ -18,18 +18,19 @@ from room_cal._utils import safe_json_loads, filter_events, service_name
 _PREFIX = 'Guest: '
 _PREFIX = 'Guest: '
 
 
 
 
-def _build_description(xd: dict, wk: str, dc: str) -> str:
+def _build_description(xd: dict, wk: str, ws: str | None, dc: str) -> str:
   svc = xd.get('service', '')
   svc = xd.get('service', '')
+  wifi_line = f"WiFi: {ws} / {wk}" if ws else f"WiFi: {wk}"
   return (
   return (
     f"Name: {xd.get('guest_name', '')}\n"
     f"Name: {xd.get('guest_name', '')}\n"
     f"Service: {service_name(svc)}\n"
     f"Service: {service_name(svc)}\n"
     f"Reservation ID: {xd.get('reservation_id', '')}\n"
     f"Reservation ID: {xd.get('reservation_id', '')}\n"
     f"Door: {dc}\n"
     f"Door: {dc}\n"
-    f"WiFi: {wk}"
+    f"{wifi_line}"
   )
   )
 
 
 
 
-async def patch(target: date, new_wifi: str | None, new_door: str | None, calendar_name: str) -> None:
+async def patch(target: date, new_wifi: str | None, new_ssid: str | None, new_door: str | None, calendar_name: str) -> None:
   cal = Calendar(calendar_name)
   cal = Calendar(calendar_name)
   events = await cal.fetch_date_range(target, target)
   events = await cal.fetch_date_range(target, target)
   reservations = filter_events(events, _PREFIX, all_day=False)
   reservations = filter_events(events, _PREFIX, all_day=False)
@@ -47,14 +48,18 @@ async def patch(target: date, new_wifi: str | None, new_door: str | None, calend
   xd = safe_json_loads(event['x_data']) or {}
   xd = safe_json_loads(event['x_data']) or {}
 
 
   old_wifi = xd.get('wifi_key', '(none)')
   old_wifi = xd.get('wifi_key', '(none)')
+  old_ssid = xd.get('wifi_ssid')
   old_door = xd.get('door_code', '(none)')
   old_door = xd.get('door_code', '(none)')
 
 
   wk = new_wifi if new_wifi is not None else old_wifi
   wk = new_wifi if new_wifi is not None else old_wifi
+  ws = new_ssid if new_ssid is not None else old_ssid
   dc = new_door if new_door is not None else old_door
   dc = new_door if new_door is not None else old_door
 
 
   print(f"Reservation: {event['title']}  ({event['start'].date()} – {event['end'].date()})")
   print(f"Reservation: {event['title']}  ({event['start'].date()} – {event['end'].date()})")
   if new_wifi is not None:
   if new_wifi is not None:
     print(f"  wifi_key:  {old_wifi!r}  →  {wk!r}")
     print(f"  wifi_key:  {old_wifi!r}  →  {wk!r}")
+  if new_ssid is not None:
+    print(f"  wifi_ssid: {old_ssid!r}  →  {ws!r}")
   if new_door is not None:
   if new_door is not None:
     print(f"  door_code: {old_door!r}  →  {dc!r}")
     print(f"  door_code: {old_door!r}  →  {dc!r}")
 
 
@@ -64,9 +69,10 @@ async def patch(target: date, new_wifi: str | None, new_door: str | None, calend
     return
     return
 
 
   xd['wifi_key'] = wk
   xd['wifi_key'] = wk
+  xd['wifi_ssid'] = ws
   xd['door_code'] = dc
   xd['door_code'] = dc
   new_x_data = json.dumps(xd)
   new_x_data = json.dumps(xd)
-  new_description = _build_description(xd, wk, dc)
+  new_description = _build_description(xd, wk, ws, dc)
 
 
   await cal.update_event(event, description=new_description, x_data=new_x_data)
   await cal.update_event(event, description=new_description, x_data=new_x_data)
   print("Updated.")
   print("Updated.")
@@ -76,14 +82,15 @@ def main():
   parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
   parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
   parser.add_argument('date', type=date.fromisoformat, help='Any date within the reservation (YYYY-MM-DD)')
   parser.add_argument('date', type=date.fromisoformat, help='Any date within the reservation (YYYY-MM-DD)')
   parser.add_argument('--wifi', metavar='WIFI_KEY', help='New wifi_key string')
   parser.add_argument('--wifi', metavar='WIFI_KEY', help='New wifi_key string')
+  parser.add_argument('--ssid', metavar='WIFI_SSID', help='New wifi_ssid string')
   parser.add_argument('--door', metavar='DOOR_CODE', help='New door_code string')
   parser.add_argument('--door', metavar='DOOR_CODE', help='New door_code string')
   parser.add_argument('--calendar', default=DEFAULT_CALENDAR_NAME, help=f'Calendar name (default: {DEFAULT_CALENDAR_NAME})')
   parser.add_argument('--calendar', default=DEFAULT_CALENDAR_NAME, help=f'Calendar name (default: {DEFAULT_CALENDAR_NAME})')
   args = parser.parse_args()
   args = parser.parse_args()
 
 
-  if args.wifi is None and args.door is None:
-    parser.error('Provide at least one of --wifi or --door')
+  if args.wifi is None and args.ssid is None and args.door is None:
+    parser.error('Provide at least one of --wifi, --ssid, or --door')
 
 
-  asyncio.run(patch(args.date, args.wifi, args.door, args.calendar))
+  asyncio.run(patch(args.date, args.wifi, args.ssid, args.door, args.calendar))
 
 
 
 
 if __name__ == '__main__':
 if __name__ == '__main__':

+ 34 - 2
tests/test_store_sqlite.py

@@ -354,11 +354,12 @@ async def test_reservation_generates_wifi_and_door(sqlite_backend):
   stored = await res.get('RES-002', checkin, checkout)
   stored = await res.get('RES-002', checkin, checkout)
   assert stored.wifi_key is not None and stored.wifi_key != ''
   assert stored.wifi_key is not None and stored.wifi_key != ''
   assert stored.door_code is not None and stored.door_code != ''
   assert stored.door_code is not None and stored.door_code != ''
+  assert stored.wifi_ssid is None  # no config default set
 
 
 
 
 @pytest.mark.asyncio
 @pytest.mark.asyncio
 async def test_reservation_preserves_wifi_on_update(sqlite_backend):
 async def test_reservation_preserves_wifi_on_update(sqlite_backend):
-  """Re-setting a reservation preserves wifi_key/door_code from the first write."""
+  """Re-setting a reservation preserves wifi_key/wifi_ssid/door_code from the first write."""
   res = Reservation(backend=sqlite_backend)
   res = Reservation(backend=sqlite_backend)
   checkin = date(2024, 6, 5)
   checkin = date(2024, 6, 5)
   checkout = checkin + timedelta(days=2)
   checkout = checkin + timedelta(days=2)
@@ -368,18 +369,49 @@ async def test_reservation_preserves_wifi_on_update(sqlite_backend):
     'reservation_id': 'RES-003',
     'reservation_id': 'RES-003',
     'checkin_date': checkin,
     'checkin_date': checkin,
     'checkout_date': checkout,
     'checkout_date': checkout,
+    'wifi_ssid': 'TestNet',
   }
   }
 
 
   await res.set(data)
   await res.set(data)
   first = await res.get('RES-003', checkin, checkout)
   first = await res.get('RES-003', checkin, checkout)
 
 
-  await res.set(data)
+  await res.set({**data, 'wifi_ssid': None})  # omit ssid on second write
   second = await res.get('RES-003', checkin, checkout)
   second = await res.get('RES-003', checkin, checkout)
 
 
   assert first.wifi_key == second.wifi_key
   assert first.wifi_key == second.wifi_key
+  assert first.wifi_ssid == second.wifi_ssid  # preserved from stored value
   assert first.door_code == second.door_code
   assert first.door_code == second.door_code
 
 
 
 
+@pytest.mark.asyncio
+async def test_reservation_config_default_wifi(sqlite_backend, monkeypatch):
+  """Config defaults for wifi_key, wifi_ssid, door_code are used when not otherwise provided."""
+  from room_cal._utils import ReservationDefaultsConfig
+  import room_cal.reservations as _res_mod
+  monkeypatch.setattr(_res_mod, '_reservation_defaults', lambda: ReservationDefaultsConfig(
+    wifi_key='default-pass',
+    wifi_ssid='HomeNet',
+    door_code='9999',
+  ))
+
+  res = Reservation(backend=sqlite_backend)
+  checkin = date(2024, 7, 1)
+  checkout = checkin + timedelta(days=1)
+
+  await res.set({
+    'guest_name': 'Dave Test',
+    'service': 'airbnb',
+    'reservation_id': 'RES-CFG',
+    'checkin_date': checkin,
+    'checkout_date': checkout,
+  })
+
+  stored = await res.get('RES-CFG', checkin, checkout)
+  assert stored.wifi_key == 'default-pass'
+  assert stored.wifi_ssid == 'HomeNet'
+  assert stored.door_code == '9999'
+
+
 @pytest.mark.asyncio
 @pytest.mark.asyncio
 async def test_reservation_list(sqlite_backend):
 async def test_reservation_list(sqlite_backend):
   res = Reservation(backend=sqlite_backend)
   res = Reservation(backend=sqlite_backend)