From a84d6e5b953181b61c8c3f6d994ca5913e61310d Mon Sep 17 00:00:00 2001 From: "alexander.higgins" Date: Tue, 25 Aug 2026 08:22:19 -0400 Subject: [PATCH 1/3] feat: publish static reservation calendar --- .env.example | 3 + docs/administrators.md | 3 + esb/config.py | 1 + esb/services/docs_service.py | 8 +- esb/services/equipment_service.py | 19 ++ esb/services/notification_service.py | 55 +++- esb/services/reservation_service.py | 9 + esb/services/static_page_service.py | 166 +++++++++-- esb/slack/reservation_handlers.py | 6 +- esb/templates/public/static_reservations.html | 116 ++++++++ .../test_services/test_static_reservations.py | 276 ++++++++++++++++++ tests/test_slack/test_handlers.py | 8 +- tests/test_views/test_docs_views.py | 7 + 13 files changed, 635 insertions(+), 42 deletions(-) create mode 100644 esb/templates/public/static_reservations.html create mode 100644 tests/test_services/test_static_reservations.py diff --git a/.env.example b/.env.example index 927ec24..10cdb25 100644 --- a/.env.example +++ b/.env.example @@ -60,6 +60,9 @@ STATIC_PAGE_PUSH_TARGET= # the static-page references from the docs. # STATIC_PAGE_PUBLIC_URL= +# Public URL for the separately published read-only reservation calendar. +# STATIC_RESERVATIONS_PUBLIC_URL=https://esb-static.example.org/reservations.html + # Organization branding shown in the built-in /docs/ site. Defaults match the # upstream Decatur Makers deployment; set these to re-brand the docs. # ORG_NAME=Decatur Makers diff --git a/docs/administrators.md b/docs/administrators.md index 1c990ba..6521490 100644 --- a/docs/administrators.md +++ b/docs/administrators.md @@ -89,6 +89,7 @@ Open `http://localhost:5000` in a browser (or the server's IP/hostname on port 5 | `STATIC_PAGE_PUSH_METHOD` | How to publish the static status page. Options: `local` (write to directory), `s3` (upload to S3 bucket via boto3), or `gcs` (upload to Google Cloud Storage bucket). | No | `local` | `s3` | | `STATIC_PAGE_PUSH_TARGET` | Target for static page push. For `local`: a directory path. For `s3` and `gcs`: `bucket-name/optional/key/path` (key defaults to `index.html`). | No | _(empty)_ | `my-status-bucket/index.html` | | `STATIC_PAGE_PUBLIC_URL` | Public URL where the pushed static status page is *served* to members (distinct from `STATIC_PAGE_PUSH_TARGET`, which is where it is uploaded). Used only by the built-in `/docs/` site: when set, the Members and Staff guides link the real URL; when empty, the static-page references are omitted from those guides. | No | _(empty)_ | `https://status.example.com/` | +| `STATIC_RESERVATIONS_PUBLIC_URL` | Public URL for the read-only static reservation calendar. Slack availability buttons and the built-in Reservations Guide use this URL when set; the guide falls back to the internal `/reservations/` route when empty. | No | _(empty)_ | `https://status.example.com/reservations.html` | | `CLOUDFRONT_DISTRIBUTION_ID` | CloudFront distribution ID. Only meaningful when `STATIC_PAGE_PUSH_METHOD=s3`. When set, a CloudFront invalidation is issued for the uploaded key after every successful S3 upload, so the CDN serves the just-uploaded content immediately. Requires the IAM principal to have `cloudfront:CreateInvalidation` on the distribution. The AWS Free Tier covers 1000 invalidation paths per month; pushes more frequently than that will incur per-invalidation charges. | No | _(empty)_ | `EDFDVBD6EXAMPLE` | | `FLASK_APP` | Flask application entry point. Do not change. | No | `esb:create_app` | `esb:create_app` | | `FLASK_DEBUG` | Enable Flask debug mode. Set to `0` in production. | No | `1` | `0` | @@ -370,6 +371,8 @@ Set the push method via the `STATIC_PAGE_PUSH_METHOD` environment variable: The static page is pushed by the background worker whenever it detects a status change during its polling cycle. +The same target also receives `reservations.html` and `reservations.json` beside the configured status-page object. The reservation page contains the read-only calendar; the JSON contains only public equipment names, reservation slugs, and anonymous blocked times. Reservation changes trigger a push, and the worker queues a refresh at startup and hourly as a backstop. Set `STATIC_RESERVATIONS_PUBLIC_URL` to the public `reservations.html` URL. + The static page's generation timestamp reflects the `worker` container's `TZ` environment variable. The variable resolves against the OS tzdata database (`/usr/share/zoneinfo`), which is provided by the `tzdata` system package. Both the `python:3.14-slim` base image and this image's Dockerfile install list include `tzdata`; do not remove it. To use a non-default zone, set `TZ` in `.env` before running `docker compose up`. ## MAC (Machine Access Control) Integration diff --git a/esb/config.py b/esb/config.py index 59ff171..73ae5ed 100644 --- a/esb/config.py +++ b/esb/config.py @@ -60,6 +60,7 @@ class Config: # only by the built-in /docs/ site: when set, the member/staff guides link # the real URL; when empty, the static-page references are omitted. STATIC_PAGE_PUBLIC_URL = os.environ.get('STATIC_PAGE_PUBLIC_URL', '') + STATIC_RESERVATIONS_PUBLIC_URL = os.environ.get('STATIC_RESERVATIONS_PUBLIC_URL', '') CLOUDFRONT_DISTRIBUTION_ID = os.environ.get('CLOUDFRONT_DISTRIBUTION_ID', '') # Organization branding surfaced in the built-in /docs/ site. Defaults match # the upstream Decatur Makers deployment so an unconfigured instance renders diff --git a/esb/services/docs_service.py b/esb/services/docs_service.py index 3a259a9..c492b36 100644 --- a/esb/services/docs_service.py +++ b/esb/services/docs_service.py @@ -135,6 +135,7 @@ def get_placeholder_values(): cfg = current_app.config base_url = cfg.get('ESB_BASE_URL', '') static_page_url = cfg.get('STATIC_PAGE_PUBLIC_URL', '') + reservations_public_url = cfg.get('STATIC_RESERVATIONS_PUBLIC_URL', '') # The docs site is public and unauthenticated; it must stay up even on a # fresh deployment that has not yet run `flask db upgrade` (no app_config # table) or during a transient DB outage. Treat any DB failure as "WiFi not @@ -169,10 +170,9 @@ def get_placeholder_values(): # Human-friendly fallback so an unset base URL never renders a broken # sentence ("Navigate to in your browser"). 'base_url_display': base_url or 'the Equipment Status Board URL provided by your makerspace', - # Same-origin route so links from the built-in help site preserve the - # authenticated session even when local access uses 127.0.0.1 instead - # of the configured localhost base URL. - 'reservation_url': '/reservations/', + # Prefer the externally reachable read-only calendar when configured; + # otherwise keep the same-origin application route. + 'reservation_url': reservations_public_url or '/reservations/', 'static_page_url': static_page_url, 'wifi_ssid': wifi_ssid, 'org_name': cfg.get('ORG_NAME', ''), diff --git a/esb/services/equipment_service.py b/esb/services/equipment_service.py index 61264c6..b7cf60a 100644 --- a/esb/services/equipment_service.py +++ b/esb/services/equipment_service.py @@ -191,6 +191,10 @@ def archive_equipment(equipment_id: int, archived_by: str) -> Equipment: if equipment.is_archived: raise ValidationError(f'Equipment {equipment.name!r} is already archived') + was_reservable = bool( + equipment.reservation_settings + and equipment.reservation_settings.reservations_enabled + ) equipment.is_archived = True db.session.commit() @@ -198,6 +202,8 @@ def archive_equipment(equipment_id: int, archived_by: str) -> Equipment: 'id': equipment.id, 'name': equipment.name, }) + if was_reservable: + _queue_static_reservation_refresh('reservable_equipment_archived') return equipment @@ -320,6 +326,7 @@ def update_equipment_reservation_settings( "changes": changes, }, ) + _queue_static_reservation_refresh('reservation_settings_changed') return settings @@ -510,10 +517,22 @@ def update_equipment( 'name': equipment.name, 'changes': serialized_changes, }) + if ( + 'name' in changes + and equipment.reservation_settings + and equipment.reservation_settings.reservations_enabled + ): + _queue_static_reservation_refresh('reservable_equipment_renamed') return equipment +def _queue_static_reservation_refresh(trigger: str) -> None: + from esb.services import notification_service + + notification_service.queue_static_reservation_refresh(trigger) + + # --- External Links --- diff --git a/esb/services/notification_service.py b/esb/services/notification_service.py index 59ced54..f0c9828 100644 --- a/esb/services/notification_service.py +++ b/esb/services/notification_service.py @@ -48,11 +48,13 @@ # Minimum seconds between periodic MAC status refreshes. A fast --poll-interval # must not hammer MAC, so the refresh is throttled independently of the poll. MAC_REFRESH_INTERVAL = 60 +RESERVATION_REFRESH_INTERVAL = 3600 # Throttle state for the periodic MAC status refresh. ``None`` => the first call # always runs (bootstrap at worker startup). A single worker process owns # run_worker_loop(), so a module-level global is safe. Tests reset this. _last_mac_refresh = None +_last_reservation_refresh = None def _write_heartbeat(path: Path) -> None: @@ -168,6 +170,53 @@ def queue_notification( return notification +def queue_static_reservation_refresh(trigger: str) -> PendingNotification | None: + """Queue a post-commit reservation-page refresh without failing the user action.""" + if not current_app.config.get('STATIC_PAGE_PUSH_TARGET', '').strip(): + return None + try: + return queue_notification( + notification_type='static_page_push', + target='reservations', + payload={'trigger': trigger}, + ) + except Exception: + db.session.rollback() + logger.exception('Failed to queue static reservation refresh (trigger=%s)', trigger) + return None + + +def _refresh_static_reservations() -> None: + """Ensure a reservation publish is queued at startup and at most hourly.""" + global _last_reservation_refresh + if not current_app.config.get('STATIC_PAGE_PUSH_TARGET', '').strip(): + return + try: + now = time.monotonic() + if ( + _last_reservation_refresh is not None + and now - _last_reservation_refresh < RESERVATION_REFRESH_INTERVAL + ): + return + pending = db.session.execute( + db.select(PendingNotification).filter_by( + notification_type='static_page_push', + target='reservations', + status='pending', + ) + ).scalars().first() + if pending is None: + queue_notification( + notification_type='static_page_push', + target='reservations', + payload={'trigger': 'hourly_refresh'}, + ) + _last_reservation_refresh = now + except Exception: + db.session.rollback() + logger.warning('Periodic static reservation refresh failed', exc_info=True) + + def queue_member_reservation_notification(reservation: Reservation, event_type: str) -> str | None: """Queue a member reservation DM; return a non-fatal warning on failure.""" if reservation.reservation_type == RESERVATION_TYPE_ADMIN_HOLD: @@ -549,7 +598,10 @@ def _deliver_static_page_push(notification: PendingNotification) -> None: 'Static page push triggered (notification=%d, payload=%s)', notification.id, notification.payload, ) - static_page_service.generate_and_push() + if notification.target == 'reservations': + static_page_service.generate_and_push_reservations() + else: + static_page_service.generate_and_push() def _deliver_mac_clear(notification: PendingNotification) -> None: @@ -686,6 +738,7 @@ def _handle_signal(signum, frame): # throttled and self-contained (its own try/except), so a MAC outage # never feeds the outer poll-failure backoff. _refresh_mac_status() + _refresh_static_reservations() except Exception: consecutive_poll_failures += 1 diff --git a/esb/services/reservation_service.py b/esb/services/reservation_service.py index 1f41130..7a34d03 100644 --- a/esb/services/reservation_service.py +++ b/esb/services/reservation_service.py @@ -252,6 +252,7 @@ def _persist_admin_reservation( original.canceled_at = _utc_now() original.canceled_by_user_id = actor.id db.session.commit() + _queue_static_refresh("reservation_replaced" if original is not None else "reservation_created") if original is None: _log_reservation_created(reservation, actor) @@ -396,6 +397,7 @@ def persist_reservation( db.session.add(reservation) if commit: db.session.commit() + _queue_static_refresh("reservation_created") else: db.session.flush() return reservation @@ -443,6 +445,7 @@ def cancel_reservation( "new_status": CANCELED_STATUS, }, ) + _queue_static_refresh("reservation_canceled") else: db.session.flush() return reservation @@ -532,6 +535,12 @@ def _log_reservation_created(reservation: Reservation, actor: User | None) -> No ) +def _queue_static_refresh(trigger: str) -> None: + from esb.services import notification_service + + notification_service.queue_static_reservation_refresh(trigger) + + def _validate_reservation_shape( *, reservation_type: str, diff --git a/esb/services/static_page_service.py b/esb/services/static_page_service.py index d7a060a..0eaf1d1 100644 --- a/esb/services/static_page_service.py +++ b/esb/services/static_page_service.py @@ -2,7 +2,9 @@ import logging import os -from datetime import datetime +import posixpath +from datetime import UTC, datetime +from pathlib import Path from flask import current_app, render_template @@ -10,6 +12,10 @@ logger = logging.getLogger(__name__) +HTML_CONTENT_TYPE = 'text/html; charset=utf-8' +JSON_CONTENT_TYPE = 'application/json; charset=utf-8' +CACHE_CONTROL = 'no-cache, no-store, must-revalidate' + def _compute_generated_at() -> tuple[str, int]: """Compute the generation timestamp string and year in the system's local timezone. @@ -58,6 +64,51 @@ def generate() -> str: ) +def generate_reservations() -> tuple[str, str]: + """Render the standalone reservation calendar and its anonymous JSON data.""" + import json + + from esb.services import reservation_read_service + from esb.utils.timezones import MAKERSPACE_TIMEZONE, utc_naive_to_local + + now = datetime.now(UTC) + availability = reservation_read_service.get_public_availability(now=now) + columns = [] + events = [] + for equipment in availability['equipment']: + slug = equipment['reservation_slug'] + columns.append({'id': slug, 'name': equipment['name']}) + for reservation in equipment['reservations']: + starts_at = datetime.fromisoformat(reservation['starts_at']).replace(tzinfo=None) + ends_at = datetime.fromisoformat(reservation['ends_at']).replace(tzinfo=None) + events.append({ + 'resource': slug, + 'start': utc_naive_to_local(starts_at).replace(tzinfo=None).isoformat(timespec='seconds'), + 'end': utc_naive_to_local(ends_at).replace(tzinfo=None).isoformat(timespec='seconds'), + 'text': 'Reserved', + 'backColor': '#2f6f73', + 'barColor': '#164e52', + 'fontColor': '#ffffff', + }) + + data = { + 'generatedAt': now.isoformat(), + 'timeZone': str(MAKERSPACE_TIMEZONE), + 'startDate': now.astimezone(MAKERSPACE_TIMEZONE).date().isoformat(), + 'columns': columns, + 'events': events, + } + daypilot_source = (Path(current_app.static_folder) / 'js' / 'daypilot-javascript.min.js').read_text( + encoding='utf-8' + ) + html = render_template( + 'public/static_reservations.html', + daypilot_source=daypilot_source, + generated_year=now.astimezone(MAKERSPACE_TIMEZONE).year, + ) + return html, json.dumps(data, ensure_ascii=False, separators=(',', ':')) + + def push(html_content: str) -> None: """Push the rendered static page to the configured destination. @@ -80,7 +131,7 @@ def push(html_content: str) -> None: invalidation_id: str | None = None if method == 'local': - _push_local(html_content, target) + _push_local(html_content, target, 'index.html') elif method == 's3': invalidation_id = _push_s3(html_content, target) elif method == 'gcs': @@ -96,7 +147,7 @@ def push(html_content: str) -> None: logger.info('Static page pushed via %s to %s', method, target) -def _push_local(html_content: str, target_path: str) -> None: +def _push_local(content: str, target_path: str, filename: str) -> None: """Write the static page HTML to a local directory. Writes to {target_path}/index.html, creating the directory if needed. @@ -110,15 +161,36 @@ def _push_local(html_content: str, target_path: str) -> None: """ try: os.makedirs(target_path, exist_ok=True) - output_path = os.path.join(target_path, 'index.html') + output_path = os.path.join(target_path, filename) with open(output_path, 'w', encoding='utf-8') as f: - f.write(html_content) + f.write(content) logger.info('Static page written to %s', output_path) except OSError as e: raise RuntimeError(f'Failed to write static page to {target_path}: {e}') from e -def _push_s3(html_content: str, target: str) -> str | None: +def _object_target(target: str, filename: str | None = None) -> tuple[str, str]: + """Return bucket and key, placing named artifacts beside the configured key.""" + parts = target.split('/', 1) + bucket = parts[0] + if not bucket: + raise RuntimeError(f'Invalid object target {target!r}: bucket name is empty') + configured_key = parts[1] if len(parts) > 1 and parts[1] else 'index.html' + if filename: + key = posixpath.join(posixpath.dirname(configured_key), filename) + else: + key = configured_key + return bucket, key + + +def _push_s3( + content: str, + target: str, + *, + filename: str | None = None, + content_type: str = HTML_CONTENT_TYPE, + invalidate: bool = True, +) -> str | None: """Upload the static page HTML to an S3 bucket. Target format: "bucket-name/optional/key/path" (key defaults to index.html @@ -145,21 +217,19 @@ def _push_s3(html_content: str, target: str) -> str | None: except ImportError as e: raise RuntimeError('boto3 is required for S3 push method. Install it with: pip install boto3') from e - # Parse target: "bucket-name/optional/key/path" - parts = target.split('/', 1) - bucket = parts[0] - if not bucket: - raise RuntimeError(f'Invalid S3 target {target!r}: bucket name is empty') - key = parts[1] if len(parts) > 1 and parts[1] else 'index.html' + try: + bucket, key = _object_target(target, filename) + except RuntimeError as e: + raise RuntimeError(str(e).replace('object target', 'S3 target')) from e try: s3 = boto3.client('s3') s3.put_object( Bucket=bucket, Key=key, - Body=html_content.encode('utf-8'), - ContentType='text/html; charset=utf-8', - CacheControl='no-cache, no-store, must-revalidate', + Body=content.encode('utf-8'), + ContentType=content_type, + CacheControl=CACHE_CONTROL, ) logger.info('Static page uploaded to s3://%s/%s', bucket, key) except NoCredentialsError as e: @@ -170,12 +240,12 @@ def _push_s3(html_content: str, target: str) -> str | None: raise RuntimeError(f'S3 upload failed ({error_code}): {error_msg}') from e distribution_id = current_app.config.get('CLOUDFRONT_DISTRIBUTION_ID', '') - if distribution_id: - return _create_cloudfront_invalidation(distribution_id, key) + if distribution_id and invalidate: + return _create_cloudfront_invalidation(distribution_id, [key]) return None -def _create_cloudfront_invalidation(distribution_id: str, key: str) -> str: +def _create_cloudfront_invalidation(distribution_id: str, keys: list[str]) -> str: """Create a CloudFront invalidation for the given object key. Args: @@ -197,20 +267,20 @@ def _create_cloudfront_invalidation(distribution_id: str, key: str) -> str: import boto3 from botocore.exceptions import ClientError, NoCredentialsError - path = '/' + quote(key.lstrip('/'), safe='/') + paths = ['/' + quote(key.lstrip('/'), safe='/') for key in keys] try: cf = boto3.client('cloudfront') response = cf.create_invalidation( DistributionId=distribution_id, InvalidationBatch={ - 'Paths': {'Quantity': 1, 'Items': [path]}, + 'Paths': {'Quantity': len(paths), 'Items': paths}, 'CallerReference': f'esb-{uuid.uuid4()}', }, ) invalidation_id = response['Invalidation']['Id'] logger.info( 'Created CloudFront invalidation %s for distribution %s path %s', - invalidation_id, distribution_id, path, + invalidation_id, distribution_id, ', '.join(paths), ) return invalidation_id except NoCredentialsError as e: @@ -221,7 +291,13 @@ def _create_cloudfront_invalidation(distribution_id: str, key: str) -> str: raise RuntimeError(f'CloudFront invalidation failed ({error_code}): {error_msg}') from e -def _push_gcs(html_content: str, target: str) -> None: +def _push_gcs( + content: str, + target: str, + *, + filename: str | None = None, + content_type: str = HTML_CONTENT_TYPE, +) -> None: """Upload the static page HTML to a Google Cloud Storage bucket. Target format: "bucket-name/optional/key/path" (key defaults to index.html @@ -243,19 +319,17 @@ def _push_gcs(html_content: str, target: str) -> None: 'google-cloud-storage is required for GCS push method. Install it with: pip install google-cloud-storage' ) from e - # Parse target: "bucket-name/optional/key/path" - parts = target.split('/', 1) - bucket = parts[0] - if not bucket: - raise RuntimeError(f'Invalid GCS target {target!r}: bucket name is empty') - key = parts[1] if len(parts) > 1 and parts[1] else 'index.html' + try: + bucket, key = _object_target(target, filename) + except RuntimeError as e: + raise RuntimeError(str(e).replace('object target', 'GCS target')) from e try: client = storage.Client() bucket_obj = client.bucket(bucket) blob = bucket_obj.blob(key) - blob.cache_control = 'no-cache, no-store, must-revalidate' - blob.upload_from_string(html_content, content_type='text/html; charset=utf-8') + blob.cache_control = CACHE_CONTROL + blob.upload_from_string(content, content_type=content_type) logger.info('Static page uploaded to gs://%s/%s', bucket, key) except DefaultCredentialsError as e: raise RuntimeError('Google Cloud credentials not configured for GCS push') from e @@ -270,3 +344,35 @@ def generate_and_push() -> None: """ html = generate() push(html) + + +def generate_and_push_reservations() -> None: + """Generate and publish the standalone reservation HTML and JSON files.""" + html, json_content = generate_reservations() + method = current_app.config.get('STATIC_PAGE_PUSH_METHOD', 'local') + target = current_app.config.get('STATIC_PAGE_PUSH_TARGET', '') + if not target: + raise RuntimeError('STATIC_PAGE_PUSH_TARGET is not configured') + + invalidation_id = None + if method == 'local': + _push_local(json_content, target, 'reservations.json') + _push_local(html, target, 'reservations.html') + elif method == 's3': + _push_s3(json_content, target, filename='reservations.json', content_type=JSON_CONTENT_TYPE, invalidate=False) + _push_s3(html, target, filename='reservations.html', invalidate=False) + distribution_id = current_app.config.get('CLOUDFRONT_DISTRIBUTION_ID', '') + if distribution_id: + _, json_key = _object_target(target, 'reservations.json') + _, html_key = _object_target(target, 'reservations.html') + invalidation_id = _create_cloudfront_invalidation(distribution_id, [json_key, html_key]) + elif method == 'gcs': + _push_gcs(json_content, target, filename='reservations.json', content_type=JSON_CONTENT_TYPE) + _push_gcs(html, target, filename='reservations.html') + else: + raise RuntimeError(f'Unknown STATIC_PAGE_PUSH_METHOD: {method!r}') + + mutation_data = {'method': method, 'target': target, 'artifacts': ['reservations.json', 'reservations.html']} + if invalidation_id: + mutation_data['cloudfront_invalidation_id'] = invalidation_id + log_mutation('static_page.pushed', 'system', mutation_data) diff --git a/esb/slack/reservation_handlers.py b/esb/slack/reservation_handlers.py index 0cbdff9..7f39680 100644 --- a/esb/slack/reservation_handlers.py +++ b/esb/slack/reservation_handlers.py @@ -61,7 +61,7 @@ def handle_esb_reserve(ack, body, client): trigger_id=body['trigger_id'], view=build_reservation_landing_modal( availability, - availability_url=app.config.get('STATIC_PAGE_PUBLIC_URL', ''), + availability_url=app.config.get('STATIC_RESERVATIONS_PUBLIC_URL', ''), now=now, ), ) @@ -261,7 +261,7 @@ def handle_reservation_reserve_another(ack, body, client): view_id=body['view']['id'], view=build_reservation_landing_modal( availability, - availability_url=app.config.get('STATIC_PAGE_PUBLIC_URL', ''), + availability_url=app.config.get('STATIC_RESERVATIONS_PUBLIC_URL', ''), now=now, ), ) @@ -372,6 +372,6 @@ def handle_reservation_cancel_confirm(ack, body, client): view_id=body['view']['id'], view=build_reservation_canceled_modal( canceled, - availability_url=app.config.get('STATIC_PAGE_PUBLIC_URL', ''), + availability_url=app.config.get('STATIC_RESERVATIONS_PUBLIC_URL', ''), ), ) diff --git a/esb/templates/public/static_reservations.html b/esb/templates/public/static_reservations.html new file mode 100644 index 0000000..c4c7d58 --- /dev/null +++ b/esb/templates/public/static_reservations.html @@ -0,0 +1,116 @@ + + + + + + + Equipment Reservations + + + +
+

Equipment Reservations

+

This calendar is read-only. Create or change a reservation through Slack or from inside the makerspace.

+
+ + + + +
+
+
+
+

Loading reservation availability…

+ +
+ + + + + diff --git a/tests/test_services/test_static_reservations.py b/tests/test_services/test_static_reservations.py new file mode 100644 index 0000000..f0aa54b --- /dev/null +++ b/tests/test_services/test_static_reservations.py @@ -0,0 +1,276 @@ +"""Focused tests for the published static reservation calendar.""" + +import json +from datetime import UTC, datetime, timedelta +from unittest.mock import MagicMock, patch + +import pytest + +from esb.extensions import db +from esb.models.pending_notification import PendingNotification +from esb.services import ( + equipment_service, + notification_service, + reservation_service, + static_page_service, +) +from tests.reservation_helpers import create_reservation_row, create_reservation_settings + + +def _reservation_fixture(make_area, make_equipment, staff_user): + equipment = make_equipment(name="Laser Cutter", area=make_area(name="Fab Lab")) + create_reservation_settings(equipment, slug="laser-cutter") + reservation = create_reservation_row( + equipment, + staff_user, + starts_at=datetime.now(UTC).replace(tzinfo=None) + timedelta(hours=1), + notes="member secret", + ) + return equipment, reservation + + +class TestReservationArtifacts: + def test_generation_is_standalone_and_anonymous( + self, app, make_area, make_equipment, staff_user + ): + _reservation_fixture(make_area, make_equipment, staff_user) + + html, raw_json = static_page_service.generate_reservations() + data = json.loads(raw_json) + + assert "daypilot-javascript.min.js" not in html + assert "DayPilot" in html + assert "Create or change a reservation through Slack or from inside the makerspace" in html + assert "reservations.json" in html + assert data["timeZone"] == "America/New_York" + assert data["columns"] == [{"id": "laser-cutter", "name": "Laser Cutter"}] + assert data["events"][0]["resource"] == "laser-cutter" + assert data["events"][0]["text"] == "Reserved" + assert "id" not in data["events"][0] + assert isinstance(data["columns"][0]["id"], str) + assert staff_user.username not in raw_json + assert "member secret" not in raw_json + + def test_local_publish_writes_both_sibling_files(self, app, tmp_path): + app.config.update(STATIC_PAGE_PUSH_METHOD="local", STATIC_PAGE_PUSH_TARGET=str(tmp_path)) + with patch.object(static_page_service, "generate_reservations", return_value=("x", "{}")): + static_page_service.generate_and_push_reservations() + + assert (tmp_path / "reservations.json").read_text() == "{}" + assert (tmp_path / "reservations.html").read_text() == "x" + + def test_s3_publish_uses_sibling_keys_and_one_invalidation(self, app): + app.config.update( + STATIC_PAGE_PUSH_METHOD="s3", + STATIC_PAGE_PUSH_TARGET="bucket/public/index.html", + CLOUDFRONT_DISTRIBUTION_ID="DIST", + ) + s3 = MagicMock() + cloudfront = MagicMock() + cloudfront.create_invalidation.return_value = {"Invalidation": {"Id": "INV"}} + + def client(service): + return s3 if service == "s3" else cloudfront + + with ( + patch.object(static_page_service, "generate_reservations", return_value=("x", "{}")), + patch("boto3.client", side_effect=client), + ): + static_page_service.generate_and_push_reservations() + + assert [call.kwargs["Key"] for call in s3.put_object.call_args_list] == [ + "public/reservations.json", + "public/reservations.html", + ] + assert s3.put_object.call_args_list[0].kwargs["ContentType"] == static_page_service.JSON_CONTENT_TYPE + paths = cloudfront.create_invalidation.call_args.kwargs["InvalidationBatch"]["Paths"] + assert paths == { + "Quantity": 2, + "Items": ["/public/reservations.json", "/public/reservations.html"], + } + + def test_gcs_publish_uses_sibling_objects(self, app): + app.config.update( + STATIC_PAGE_PUSH_METHOD="gcs", + STATIC_PAGE_PUSH_TARGET="bucket/public/index.html", + ) + from google.cloud import storage + + client = MagicMock() + bucket = client.bucket.return_value + json_blob = MagicMock() + html_blob = MagicMock() + bucket.blob.side_effect = [json_blob, html_blob] + with ( + patch.object(static_page_service, "generate_reservations", return_value=("x", "{}")), + patch.object(storage, "Client", return_value=client), + ): + static_page_service.generate_and_push_reservations() + + assert [call.args[0] for call in bucket.blob.call_args_list] == [ + "public/reservations.json", + "public/reservations.html", + ] + json_blob.upload_from_string.assert_called_once_with( + "{}", content_type=static_page_service.JSON_CONTENT_TYPE + ) + + +class TestReservationRefreshQueue: + @pytest.fixture(autouse=True) + def _configure_static_page_push(self, app, tmp_path): + app.config["STATIC_PAGE_PUSH_TARGET"] = str(tmp_path) + + def test_delivery_routes_reservation_target(self, app): + notification = PendingNotification( + notification_type="static_page_push", + target="reservations", + status="pending", + ) + db.session.add(notification) + db.session.commit() + with ( + patch.object(static_page_service, "generate_and_push_reservations") as reservations, + patch.object(static_page_service, "generate_and_push") as status, + ): + notification_service._deliver_static_page_push(notification) + reservations.assert_called_once_with() + status.assert_not_called() + + def test_periodic_refresh_starts_immediately_and_is_hourly(self, app): + notification_service._last_reservation_refresh = None + with patch.object(notification_service.time, "monotonic", side_effect=[100, 200, 3800, 7500]): + notification_service._refresh_static_reservations() + notification_service._refresh_static_reservations() + notification_service._refresh_static_reservations() + first = db.session.execute( + db.select(PendingNotification).filter_by(target="reservations") + ).scalar_one() + first.status = "delivered" + db.session.commit() + notification_service._refresh_static_reservations() + + rows = db.session.execute( + db.select(PendingNotification).filter_by(target="reservations") + ).scalars().all() + assert len(rows) == 2 + + def test_post_commit_queue_failure_is_nonfatal(self, app): + with patch.object(notification_service, "queue_notification", side_effect=RuntimeError("db down")): + assert notification_service.queue_static_reservation_refresh("test") is None + + def test_mutation_refresh_is_skipped_without_push_target(self, app): + app.config["STATIC_PAGE_PUSH_TARGET"] = "" + + assert notification_service.queue_static_reservation_refresh("test") is None + assert db.session.execute(db.select(PendingNotification)).scalar_one_or_none() is None + + def test_periodic_refresh_is_skipped_without_push_target(self, app): + app.config["STATIC_PAGE_PUSH_TARGET"] = "" + notification_service._last_reservation_refresh = None + + notification_service._refresh_static_reservations() + + assert db.session.execute(db.select(PendingNotification)).scalar_one_or_none() is None + + def test_reservation_create_and_cancel_queue_refreshes( + self, app, make_equipment, staff_user + ): + equipment = make_equipment(name="Queue Tool") + create_reservation_settings(equipment, slug="queue-tool") + starts_at = datetime.now(UTC).replace(minute=0, second=0, microsecond=0) + timedelta(hours=3) + reservation = reservation_service.create_reservation( + equipment_id=equipment.id, + owner_user_id=staff_user.id, + starts_at_utc=starts_at, + duration_minutes=30, + notes=None, + created_via="slack", + ) + reservation_service.cancel_reservation(reservation.id, staff_user.id) + + triggers = [ + row.payload["trigger"] + for row in db.session.execute( + db.select(PendingNotification) + .filter_by(target="reservations") + .order_by(PendingNotification.id) + ).scalars() + ] + assert triggers == ["reservation_created", "reservation_canceled"] + + def test_settings_and_reservable_rename_queue_refreshes( + self, app, make_equipment + ): + equipment = make_equipment(name="Original Name") + equipment_service.update_equipment_reservation_settings( + equipment_id=equipment.id, + updated_by="staff", + reservations_enabled=True, + reservation_slug="original-name", + min_advance_notice_minutes=0, + max_advance_notice_minutes=1440, + min_duration_minutes=30, + max_duration_minutes=120, + slot_granularity_minutes=30, + ) + equipment_service.update_equipment(equipment.id, "staff", name="New Name") + + triggers = [ + row.payload["trigger"] + for row in db.session.execute( + db.select(PendingNotification) + .filter_by(target="reservations") + .order_by(PendingNotification.id) + ).scalars() + ] + assert triggers == ["reservation_settings_changed", "reservable_equipment_renamed"] + + def test_reservable_archive_queues_refresh(self, app, make_equipment): + equipment = make_equipment(name="Archive Tool") + create_reservation_settings(equipment, slug="archive-tool") + + equipment_service.archive_equipment(equipment.id, "staff") + + row = db.session.execute( + db.select(PendingNotification).filter_by(target="reservations") + ).scalar_one() + assert row.payload["trigger"] == "reservable_equipment_archived" + + def test_admin_create_and_replace_each_queue_one_refresh( + self, app, make_equipment, staff_user + ): + equipment = make_equipment(name="Admin Queue Tool") + create_reservation_settings(equipment, slug="admin-queue-tool") + starts_at = datetime.now(UTC).replace(minute=0, second=0, microsecond=0) + timedelta(hours=3) + original = reservation_service.create_admin_reservation( + equipment_id=equipment.id, + owner_user_id=staff_user.id, + starts_at_utc=starts_at, + duration_minutes=30, + notes="Admin reservation", + actor_user_id=staff_user.id, + reservation_type="member", + overridden_policy_codes=[], + ) + reservation_service.replace_admin_reservation( + reservation_id=original.id, + equipment_id=equipment.id, + owner_user_id=staff_user.id, + starts_at_utc=starts_at + timedelta(hours=1), + duration_minutes=30, + notes="Updated reservation", + actor_user_id=staff_user.id, + reservation_type="member", + overridden_policy_codes=[], + ) + + triggers = [ + row.payload["trigger"] + for row in db.session.execute( + db.select(PendingNotification) + .filter_by(target="reservations") + .order_by(PendingNotification.id) + ).scalars() + ] + assert triggers == ["reservation_created", "reservation_replaced"] diff --git a/tests/test_slack/test_handlers.py b/tests/test_slack/test_handlers.py index bfd4d75..7f1483a 100644 --- a/tests/test_slack/test_handlers.py +++ b/tests/test_slack/test_handlers.py @@ -119,7 +119,7 @@ def setup(self, app, db): ) self.db.session.add(reservation) self.db.session.commit() - self.app.config['STATIC_PAGE_PUBLIC_URL'] = 'https://status.example.com/reservations/' + self.app.config['STATIC_RESERVATIONS_PUBLIC_URL'] = 'https://status.example.com/reservations/' self.handlers = _register_and_capture(app) def _settings(self, equipment, *, slug, enabled=True): @@ -203,7 +203,7 @@ def test_reserve_command_calls_ack_and_opens_landing_modal(self): def test_reserve_landing_hides_availability_buttons_without_public_url(self): """/esb-reserve omits inert Availability buttons when no URL is configured.""" - self.app.config['STATIC_PAGE_PUBLIC_URL'] = '' + self.app.config['STATIC_RESERVATIONS_PUBLIC_URL'] = '' ack = MagicMock() client = MagicMock() client.users_info.return_value = { @@ -861,7 +861,7 @@ def test_keep_reservation_returns_to_my_reservations(self): def test_cancel_confirmation_cancels_reservation_and_shows_result(self): """Flow 5: confirming cancellation marks the reservation canceled.""" - self.app.config['STATIC_PAGE_PUBLIC_URL'] = 'http://example.test/status' + self.app.config['STATIC_RESERVATIONS_PUBLIC_URL'] = 'http://example.test/reservations.html' start_timestamp, end_timestamp = self._future_aligned_window(hours_from_now=5) future = Reservation( equipment_id=self.laser.id, @@ -902,7 +902,7 @@ def test_cancel_confirmation_cancels_reservation_and_shows_result(self): assert actions[0]['text']['text'] == 'Reserve another tool' assert actions[0]['action_id'] == 'reservation_reserve_another' assert actions[1]['text']['text'] == 'View availability' - assert actions[1]['url'] == 'http://example.test/status' + assert actions[1]['url'] == 'http://example.test/reservations.html' def test_unlinked_slack_owner_can_cancel(self): start_timestamp, end_timestamp = self._future_aligned_window(hours_from_now=5) diff --git a/tests/test_views/test_docs_views.py b/tests/test_views/test_docs_views.py index 441582e..55a8708 100644 --- a/tests/test_views/test_docs_views.py +++ b/tests/test_views/test_docs_views.py @@ -268,6 +268,13 @@ def test_reservations_guide_links_to_same_origin_calendar(self): html = _render('reservations', {}) assert 'href="/reservations/"' in html + def test_reservations_guide_uses_static_calendar_when_configured(self): + html = _render( + 'reservations', + {'STATIC_RESERVATIONS_PUBLIC_URL': 'https://status.example.com/reservations.html'}, + ) + assert 'href="https://status.example.com/reservations.html"' in html + def test_static_page_url_rendered(self): assert 'https://status.example.com/' in _render('members', STATIC_ON) From d038aaf66a41242bea03650e0498713584022e2e Mon Sep 17 00:00:00 2001 From: "alexander.higgins" Date: Mon, 31 Aug 2026 08:55:58 -0400 Subject: [PATCH 2/3] fix: invalidate reservation files after upload --- esb/services/static_page_service.py | 9 ++------- tests/test_services/test_static_reservations.py | 14 ++++++++------ 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/esb/services/static_page_service.py b/esb/services/static_page_service.py index 0eaf1d1..e3a8f61 100644 --- a/esb/services/static_page_service.py +++ b/esb/services/static_page_service.py @@ -359,13 +359,8 @@ def generate_and_push_reservations() -> None: _push_local(json_content, target, 'reservations.json') _push_local(html, target, 'reservations.html') elif method == 's3': - _push_s3(json_content, target, filename='reservations.json', content_type=JSON_CONTENT_TYPE, invalidate=False) - _push_s3(html, target, filename='reservations.html', invalidate=False) - distribution_id = current_app.config.get('CLOUDFRONT_DISTRIBUTION_ID', '') - if distribution_id: - _, json_key = _object_target(target, 'reservations.json') - _, html_key = _object_target(target, 'reservations.html') - invalidation_id = _create_cloudfront_invalidation(distribution_id, [json_key, html_key]) + _push_s3(json_content, target, filename='reservations.json', content_type=JSON_CONTENT_TYPE) + invalidation_id = _push_s3(html, target, filename='reservations.html') elif method == 'gcs': _push_gcs(json_content, target, filename='reservations.json', content_type=JSON_CONTENT_TYPE) _push_gcs(html, target, filename='reservations.html') diff --git a/tests/test_services/test_static_reservations.py b/tests/test_services/test_static_reservations.py index f0aa54b..75d005d 100644 --- a/tests/test_services/test_static_reservations.py +++ b/tests/test_services/test_static_reservations.py @@ -59,7 +59,7 @@ def test_local_publish_writes_both_sibling_files(self, app, tmp_path): assert (tmp_path / "reservations.json").read_text() == "{}" assert (tmp_path / "reservations.html").read_text() == "x" - def test_s3_publish_uses_sibling_keys_and_one_invalidation(self, app): + def test_s3_publish_uses_sibling_keys_and_invalidates_each(self, app): app.config.update( STATIC_PAGE_PUSH_METHOD="s3", STATIC_PAGE_PUSH_TARGET="bucket/public/index.html", @@ -83,11 +83,13 @@ def client(service): "public/reservations.html", ] assert s3.put_object.call_args_list[0].kwargs["ContentType"] == static_page_service.JSON_CONTENT_TYPE - paths = cloudfront.create_invalidation.call_args.kwargs["InvalidationBatch"]["Paths"] - assert paths == { - "Quantity": 2, - "Items": ["/public/reservations.json", "/public/reservations.html"], - } + assert [ + call.kwargs["InvalidationBatch"]["Paths"] + for call in cloudfront.create_invalidation.call_args_list + ] == [ + {"Quantity": 1, "Items": ["/public/reservations.json"]}, + {"Quantity": 1, "Items": ["/public/reservations.html"]}, + ] def test_gcs_publish_uses_sibling_objects(self, app): app.config.update( From 56198e6e1839e526aa646e5f24097bfa9432696d Mon Sep 17 00:00:00 2001 From: "alexander.higgins" Date: Mon, 31 Aug 2026 09:05:58 -0400 Subject: [PATCH 3/3] chore: bump version to 0.22.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b5a358d..40773eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "equipment-status-board" -version = "0.21.0" +version = "0.22.0" description = "Equipment status tracking and repair management for Decatur Makers makerspace" requires-python = ">=3.14" dependencies = [