Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions docs/administrators.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions esb/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions esb/services/docs_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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', ''),
Expand Down
19 changes: 19 additions & 0 deletions esb/services/equipment_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,13 +191,19 @@ 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()

log_mutation('equipment.archived', archived_by, {
'id': equipment.id,
'name': equipment.name,
})
if was_reservable:
_queue_static_reservation_refresh('reservable_equipment_archived')

return equipment

Expand Down Expand Up @@ -320,6 +326,7 @@ def update_equipment_reservation_settings(
"changes": changes,
},
)
_queue_static_reservation_refresh('reservation_settings_changed')
return settings


Expand Down Expand Up @@ -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 ---


Expand Down
55 changes: 54 additions & 1 deletion esb/services/notification_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions esb/services/reservation_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -443,6 +445,7 @@ def cancel_reservation(
"new_status": CANCELED_STATUS,
},
)
_queue_static_refresh("reservation_canceled")
else:
db.session.flush()
return reservation
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading