diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c19339..08fa070 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: with: python-version: "3.14" allow-prereleases: true - - run: pip install ruff + - run: pip install ruff==0.9.7 - run: ruff check esb/ tests/ test: diff --git a/.gitignore b/.gitignore index 2cdb999..bbe170c 100644 --- a/.gitignore +++ b/.gitignore @@ -192,6 +192,12 @@ cython_debug/ uploads/ .local/ +# Local OpenSpec planning workspace +openspec/ + +# Local Codex workspace +.codex/ + # Ruff stuff: .ruff_cache/ diff --git a/README.md b/README.md index 3e65246..7150ef5 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ A web application for tracking equipment status and coordinating repairs at community makerspaces, originally developed for [Decatur Makers](https://decaturmakers.org). Provides a single source of truth so members know what's working, technicians know what needs fixing, and staff can coordinate it all. +At Decatur Makers, ESB is available from the space or while connected to the VPN at [http://esb.decaturmakers.org:8000](http://esb.decaturmakers.org:8000). + ## Features - **Equipment Registry & Status Tracking** — Maintain a registry of all equipment organized by area, with live green/yellow/red status derived from open repair records @@ -40,6 +42,7 @@ Full user guides and administrator documentation are available at: - [Members Guide](https://decaturmakers.github.io/equipment-status-board/members/) — Checking status, scanning QR codes, reporting problems - [Technicians Guide](https://decaturmakers.github.io/equipment-status-board/technicians/) — Repair queue, managing repairs, Slack commands - [Staff Guide](https://decaturmakers.github.io/equipment-status-board/staff/) — Kanban board, equipment management, user administration +- [Reservations Guide](https://decaturmakers.github.io/equipment-status-board/reservations/) — Equipment availability, reservations, administrative holds, and reservation policies - [Administrators Guide](https://decaturmakers.github.io/equipment-status-board/administrators/) — Deployment, configuration, Slack setup, maintenance ## MAC (Machine Access Control) Integration diff --git a/docs/images/reservations-cancel-form.png b/docs/images/reservations-cancel-form.png new file mode 100644 index 0000000..f10dc98 Binary files /dev/null and b/docs/images/reservations-cancel-form.png differ diff --git a/docs/images/reservations-slack-form.png b/docs/images/reservations-slack-form.png new file mode 100644 index 0000000..252d309 Binary files /dev/null and b/docs/images/reservations-slack-form.png differ diff --git a/docs/index.md b/docs/index.md index 3641b4e..665e1eb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -28,6 +28,10 @@ A large-screen display mode designed for wall-mounted monitors or projectors in A lightweight status page that can be hosted externally (e.g., on S3) for checking equipment status from anywhere, even outside the local network. +### Equipment Reservations + +View reservable equipment on the reservation calendar. Members can create and manage their own reservations from Slack, technicians can manage reservations, and staff can configure equipment policies. See the [Reservations Guide](reservations.md). + {% if slack_enabled %} ### Slack Integration @@ -65,6 +69,7 @@ This documentation is organized by role. Find the guide that matches how you use - **[Members Guide](members.md)** — For anyone using the makerspace. Learn how to check equipment status, scan QR codes, and report problems. No account needed. - **[Technicians Guide](technicians.md)** — For volunteer repair technicians. Learn how to work the repair queue, manage repair records, and use Slack commands. - **[Staff Guide](staff.md)** — For makerspace managers. Learn how to use the Kanban board, manage equipment and users, and configure the system. +- **[Reservations Guide](reservations.md)** — For members reserving equipment and the technicians or staff who manage reservations and policies. - **[Administrators Guide](administrators.md)** — For technical volunteers deploying and maintaining the system. Covers Docker deployment, environment configuration, Slack App setup, and ongoing maintenance. ## Understanding Status Colors diff --git a/docs/reservations.md b/docs/reservations.md new file mode 100644 index 0000000..41d38a9 --- /dev/null +++ b/docs/reservations.md @@ -0,0 +1,98 @@ +# Reservations Guide + +Use reservations to check when equipment is available, reserve equipment through Slack, or manage reservations and equipment policies if your role allows it. + +## View the Reservation Calendar + +{% if reservation_url %} +Select [**Reservations**]({{ reservation_url }}) in the Equipment Status Board navigation. +{% else %} +Select **Reservations** in the Equipment Status Board navigation. +{% endif %} + +The calendar shows each reservable equipment item in a separate column. A block labeled **Reserved** shows an unavailable time. The calendar does not show the name of the member who made the reservation. + +Use **Previous**, the date field, or **Next** to view another day. If the page says that no reservable tools are configured, ask a staff member whether reservations are available for the equipment you need. + +{% if slack_enabled %} +## Reserve Equipment in Slack + +Before you start, make sure that your Slack account is linked to an active ESB user. Ask a staff member for help if Slack says that your account is not linked. + +### Create a Reservation + +1. Enter `/esb-reserve` in Slack. +2. Select **Reserve** for the equipment you need. +3. Review the listed unavailable times and reservation limits. +4. Enter a start time and an end time. +5. Enter a note if it helps other users understand the reservation. +6. Select **Reserve**. + +![Slack reservation form showing existing reservations, policy limits, and requested reservation fields](images/reservations-slack-form.png){ .docs-screenshot-compact } + +Slack shows a confirmation with the equipment, time, and note. + +If your requested time is unavailable or does not meet the equipment reservation policy, Slack shows the reason. Select **Choose another time** and enter a valid time. + +### View or Cancel Your Reservations + +1. Enter `/esb-reserve` in Slack. +2. Select **My reservations**. +3. To cancel a reservation, select **Cancel reservation**. +4. Review the reservation details. +5. Select **Cancel reservation** to confirm. + +![Slack My Reservations window showing an upcoming reservation and its cancel action](images/reservations-cancel-form.png){ .docs-screenshot-compact } + +You can cancel only an active reservation that belongs to you. Slack confirms when the reservation is canceled. +{% endif %} + +## Manage Reservations + +Technicians and staff can manage reservations in the web application. Select **Admin**, then select **Reservations**. + +### View Reservations and History + +The **Calendar** view shows reservations for one day. Use **Previous**, **Today**, or **Next** to change the displayed day. + +Select **History** to view reservation records. Use the filters to limit the results by date range, area, equipment, member, status, or source. The source is either **Slack** or **Admin**. + +An active reservation can be edited or canceled. A canceled reservation remains in the history. + +### Create a Reservation or Administrative Hold + +1. Select **New Reservation**. +2. Select **Member reservation** or **Admin hold**. +3. Select the equipment. +4. If you selected **Member reservation**, select the member. +5. Enter the start date, start time, duration, and note. +6. Select **Review reservation**. +7. Review the reservation details and any policy warnings. +8. Confirm the reservation. + +An administrative hold blocks a time without assigning the reservation to a member. All administrative reservations and holds require a note. + +### Edit or Cancel a Reservation + +To edit an active reservation, select **Edit**, update the fields, review the changes, and confirm the replacement reservation. The system cancels the previous reservation and preserves it in the history. + +To cancel an active reservation, select **Cancel**, then confirm the cancellation. You cannot edit or cancel a reservation that is already canceled. + +## Configure Reservation Policies + +Only staff can configure reservations for an equipment item. + +1. Open the equipment detail page. +2. Select **Reservation Settings**. +3. Select **Allow reservations** to make the equipment available for reservations. +4. Enter a **Reservation slug**. +5. Set the advance-notice, duration, and slot-granularity limits. +6. Select **Save Reservation Settings**. + +Use a reservation slug that contains lowercase letters, numbers, and single hyphens only. For example, use `laser-cutter-1`. + +Set **Minimum advance notice** and **Maximum advance notice** to control how soon and how far ahead members can reserve the equipment. Set **Minimum duration** and **Maximum duration** to control reservation length. Set **Slot granularity** to control the allowed start-time and duration increments. + +The maximum advance notice must be at least the minimum advance notice. The maximum duration must be at least the minimum duration. Both duration limits must be exact multiples of the slot granularity. + +Clear **Allow reservations** to prevent new reservations for the equipment. You cannot configure reservations for archived equipment. diff --git a/esb/services/docs_service.py b/esb/services/docs_service.py index 734c6f3..3a259a9 100644 --- a/esb/services/docs_service.py +++ b/esb/services/docs_service.py @@ -37,9 +37,9 @@ serving stale HTML. Supported markdown extensions (runtime): ``tables``, ``fenced_code``, -``admonition``, ``toc`` — the only features the five guides use today. +``admonition``, ``toc``, ``attr_list`` — the only features the six guides use today. ``mkdocs.yml`` additionally enables ``pymdownx.details``, -``pymdownx.superfences``, ``attr_list``, ``md_in_html`` which this renderer does +``pymdownx.superfences``, ``md_in_html`` which this renderer does NOT support: a future doc edit adopting them would build green on GH Pages but render as literal text here. Align the extension sets (or add ``pymdown-extensions`` at runtime) if/when that happens. @@ -71,6 +71,7 @@ ('members', {'file': 'members.md', 'title': 'Members Guide'}), ('technicians', {'file': 'technicians.md', 'title': 'Technicians Guide'}), ('staff', {'file': 'staff.md', 'title': 'Staff Guide'}), + ('reservations', {'file': 'reservations.md', 'title': 'Reservations Guide'}), ('administrators', {'file': 'administrators.md', 'title': 'Administrators Guide'}), ]) @@ -81,10 +82,10 @@ LICENSE_NAME = 'MIT' LICENSE_URL = GITHUB_URL + '/blob/main/LICENSE' -MARKDOWN_EXTENSIONS = ['tables', 'fenced_code', 'admonition', 'toc'] +MARKDOWN_EXTENSIONS = ['tables', 'fenced_code', 'admonition', 'toc', 'attr_list'] # Matches inter-doc markdown links to a served page, e.g. ``(members.md)`` or -# ``(staff.md#anchor)``. Only the five served page names are rewritten — no +# ``(staff.md#anchor)``. Only the six served page names are rewritten — no # guide links to a non-served .md file (the no-.md-href test guards future # cases). _SERVED_PAGE_NAMES = [meta['file'] for meta in DOC_PAGES.values()] @@ -168,6 +169,10 @@ 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/', 'static_page_url': static_page_url, 'wifi_ssid': wifi_ssid, 'org_name': cfg.get('ORG_NAME', ''), @@ -225,7 +230,7 @@ def _link_sub(match): def render_page(slug): - """Render a docs page, returning ``(title, html)``. + """Render a docs page, returning ``(title, html, toc_html)``. Raises ``KeyError`` for a slug not in ``DOC_PAGES`` (→ 404 in the view). A slug that IS in ``DOC_PAGES`` but whose file is missing/unreadable lets the @@ -244,7 +249,10 @@ def render_page(slug): # Atomic single-dict lookup: invalidate_page_cache() may swap the cache dict # out concurrently, so a check-then-index would risk a KeyError. dict.get() # is atomic under the GIL and a miss simply re-renders. - cached = cache.get(cache_key) + # Local documentation changes should appear on refresh without requiring a + # server restart. Production keeps the per-process cache because its docs + # source and configuration are fixed for the lifetime of a deployment. + cached = None if current_app.debug else cache.get(cache_key) if cached is not None: return cached @@ -261,11 +269,18 @@ def render_page(slug): # (2) rewrite inter-doc links and image refs in the markdown source. text = _rewrite_links(text) - # (3) convert markdown → HTML. - html = markdown.markdown(text, extensions=MARKDOWN_EXTENSIONS) - - result = (title, html) - cache[cache_key] = result + # (3) convert markdown → HTML and retain the TOC generated from the same + # heading pass so the built-in help navigation cannot drift from content. + renderer = markdown.Markdown( + extensions=MARKDOWN_EXTENSIONS, + extension_configs={'toc': {'toc_depth': '2-3'}}, + ) + html = renderer.convert(text) + toc_html = renderer.toc + + result = (title, html, toc_html) + if not current_app.debug: + cache[cache_key] = result return result diff --git a/esb/services/reservation_read_service.py b/esb/services/reservation_read_service.py index d6c7e3e..69173e7 100644 --- a/esb/services/reservation_read_service.py +++ b/esb/services/reservation_read_service.py @@ -324,9 +324,9 @@ def get_admin_calendar_data( per_page: int = ADMIN_RESERVATION_PAGE_SIZE, ) -> AdminCalendarData: """Build private calendar data and a paginated administrative history list.""" - equipment_items = _admin_filtered_equipment(filters) - equipment_ids = [equipment.id for equipment in equipment_items] - if not equipment_ids: + history_equipment = _admin_filtered_equipment(filters) + history_equipment_ids = [equipment.id for equipment in history_equipment] + if not history_equipment_ids: return { "startDate": filters.calendar_date.isoformat(), "startsOn": filters.starts_on.isoformat(), @@ -338,9 +338,25 @@ def get_admin_calendar_data( "pagination": {"page": 1, "pages": 0, "total": 0, "has_prev": False, "has_next": False}, } + # History must retain reservations made before equipment was archived or + # reservations were disabled. The default calendar, however, should only + # show tools that can currently be reserved. An explicit equipment filter + # is an investigation workflow, so it continues to show that one tool. + calendar_equipment = [ + equipment + for equipment in history_equipment + if filters.equipment_id is not None + or ( + not equipment.is_archived + and equipment.reservation_settings is not None + and equipment.reservation_settings.reservations_enabled + ) + ] + calendar_equipment_ids = [equipment.id for equipment in calendar_equipment] + list_query = _admin_reservation_query( filters, - equipment_ids, + history_equipment_ids, filters.starts_on, filters.ends_on, ) @@ -357,18 +373,20 @@ def get_admin_calendar_data( .all() ) - calendar_reservations = list( - db.session.execute( - _admin_reservation_query( - filters, - equipment_ids, - filters.calendar_date, - filters.calendar_date, - ).order_by(Reservation.starts_at, Reservation.id) + calendar_reservations = [] + if calendar_equipment_ids: + calendar_reservations = list( + db.session.execute( + _admin_reservation_query( + filters, + calendar_equipment_ids, + filters.calendar_date, + filters.calendar_date, + ).order_by(Reservation.starts_at, Reservation.id) + ) + .scalars() + .all() ) - .scalars() - .all() - ) calendar_rows = [_serialize_admin_reservation(reservation) for reservation in calendar_reservations] list_rows = [_serialize_admin_reservation(reservation) for reservation in list_reservations] displayed_ids = {row["id"] for row in calendar_rows + list_rows} @@ -391,7 +409,7 @@ def get_admin_calendar_data( "startDate": filters.calendar_date.isoformat(), "startsOn": filters.starts_on.isoformat(), "endsOn": filters.ends_on.isoformat(), - "columns": [_serialize_admin_equipment(equipment) for equipment in equipment_items], + "columns": [_serialize_admin_equipment(equipment) for equipment in calendar_equipment], "events": [_serialize_admin_calendar_event(row) for row in calendar_rows], "details": details, "reservations": list_rows, diff --git a/esb/static/css/app.css b/esb/static/css/app.css index 32fca4e..5696b59 100644 --- a/esb/static/css/app.css +++ b/esb/static/css/app.css @@ -1,5 +1,10 @@ /* ESB custom styles */ +/* Make local debug sessions visually distinct from production. */ +.navbar-local { + background-color: #14532d; +} + /* Kiosk layout. Class is only applied to in base_kiosk.html, so this rule is effectively body-scoped. overflow:hidden prevents any pre-JS scrollbars or post-JS clipping artifacts. @@ -173,10 +178,54 @@ th.sortable:hover { /* Built-in docs/help site. python-markdown emits bare HTML (no Bootstrap classes), so these minimal styles make tables, admonitions, code blocks, and images render acceptably under Bootstrap. Scoped under .docs-content. */ +.docs-page-toc { + border-left: 3px solid var(--bs-border-color); + font-size: 0.9rem; + padding: 0.25rem 0 0.25rem 1rem; +} +.docs-page-toc-title { + font-weight: 700; + margin-bottom: 0.5rem; +} +.docs-page-toc ul { + list-style: none; + margin: 0; + padding-left: 0; +} +.docs-page-toc li + li { + margin-top: 0.35rem; +} +.docs-page-toc li ul { + border-left: 1px solid var(--bs-border-color); + margin-top: 0.35rem; + padding-left: 0.75rem; +} +.docs-page-toc a { + color: var(--bs-secondary-color); + text-decoration: none; +} +.docs-page-toc a:hover, +.docs-page-toc a:focus { + color: var(--bs-link-color); + text-decoration: underline; +} +@media (min-width: 992px) { + .docs-page-toc { + max-height: calc(100vh - 6rem); + overflow-y: auto; + position: sticky; + top: 4.5rem; + } +} .docs-content img { max-width: 100%; height: auto; } +.docs-content .docs-screenshot-compact { + display: block; + margin: 1rem auto; + width: min(100%, 640px); +} .docs-content table { border-collapse: collapse; margin-bottom: 1rem; diff --git a/esb/templates/base.html b/esb/templates/base.html index 079d037..04f772e 100644 --- a/esb/templates/base.html +++ b/esb/templates/base.html @@ -10,7 +10,7 @@ {% if newrelic_browser_header is defined %}{{ newrelic_browser_header|safe }}{% endif %} -