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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,12 @@ cython_debug/
uploads/
.local/

# Local OpenSpec planning workspace
openspec/

# Local Codex workspace
.codex/

# Ruff stuff:
.ruff_cache/

Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Binary file added docs/images/reservations-cancel-form.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/reservations-slack-form.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
98 changes: 98 additions & 0 deletions docs/reservations.md
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 26 additions & 11 deletions esb/services/docs_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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'}),
])

Expand All @@ -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()]
Expand Down Expand Up @@ -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', ''),
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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


Expand Down
50 changes: 34 additions & 16 deletions esb/services/reservation_read_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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,
)
Expand All @@ -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}
Expand All @@ -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,
Expand Down
49 changes: 49 additions & 0 deletions esb/static/css/app.css
Original file line number Diff line number Diff line change
@@ -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 <body> in base_kiosk.html, so
this rule is effectively body-scoped. overflow:hidden prevents any
pre-JS scrollbars or post-JS clipping artifacts.
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading