Skip to content

Commit 4e86dd0

Browse files
authored
feat: Improve reservations and built-in documentation (#79)
1 parent c924ca0 commit 4e86dd0

20 files changed

Lines changed: 343 additions & 41 deletions

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ jobs:
1515
with:
1616
python-version: "3.14"
1717
allow-prereleases: true
18-
- run: pip install ruff
18+
- run: pip install ruff==0.9.7
1919
- run: ruff check esb/ tests/
2020

2121
test:

‎.gitignore‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,12 @@ cython_debug/
192192
uploads/
193193
.local/
194194

195+
# Local OpenSpec planning workspace
196+
openspec/
197+
198+
# Local Codex workspace
199+
.codex/
200+
195201
# Ruff stuff:
196202
.ruff_cache/
197203

‎README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
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.
88

9+
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).
10+
911
## Features
1012

1113
- **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:
4042
- [Members Guide](https://decaturmakers.github.io/equipment-status-board/members/) — Checking status, scanning QR codes, reporting problems
4143
- [Technicians Guide](https://decaturmakers.github.io/equipment-status-board/technicians/) — Repair queue, managing repairs, Slack commands
4244
- [Staff Guide](https://decaturmakers.github.io/equipment-status-board/staff/) — Kanban board, equipment management, user administration
45+
- [Reservations Guide](https://decaturmakers.github.io/equipment-status-board/reservations/) — Equipment availability, reservations, administrative holds, and reservation policies
4346
- [Administrators Guide](https://decaturmakers.github.io/equipment-status-board/administrators/) — Deployment, configuration, Slack setup, maintenance
4447

4548
## MAC (Machine Access Control) Integration
57.6 KB
Loading
80.8 KB
Loading

‎docs/index.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ A large-screen display mode designed for wall-mounted monitors or projectors in
2828

2929
A lightweight status page that can be hosted externally (e.g., on S3) for checking equipment status from anywhere, even outside the local network.
3030

31+
### Equipment Reservations
32+
33+
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).
34+
3135
{% if slack_enabled %}
3236
### Slack Integration
3337

@@ -65,6 +69,7 @@ This documentation is organized by role. Find the guide that matches how you use
6569
- **[Members Guide](members.md)** — For anyone using the makerspace. Learn how to check equipment status, scan QR codes, and report problems. No account needed.
6670
- **[Technicians Guide](technicians.md)** — For volunteer repair technicians. Learn how to work the repair queue, manage repair records, and use Slack commands.
6771
- **[Staff Guide](staff.md)** — For makerspace managers. Learn how to use the Kanban board, manage equipment and users, and configure the system.
72+
- **[Reservations Guide](reservations.md)** — For members reserving equipment and the technicians or staff who manage reservations and policies.
6873
- **[Administrators Guide](administrators.md)** — For technical volunteers deploying and maintaining the system. Covers Docker deployment, environment configuration, Slack App setup, and ongoing maintenance.
6974

7075
## Understanding Status Colors

‎docs/reservations.md‎

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# Reservations Guide
2+
3+
Use reservations to check when equipment is available, reserve equipment through Slack, or manage reservations and equipment policies if your role allows it.
4+
5+
## View the Reservation Calendar
6+
7+
{% if reservation_url %}
8+
Select [**Reservations**]({{ reservation_url }}) in the Equipment Status Board navigation.
9+
{% else %}
10+
Select **Reservations** in the Equipment Status Board navigation.
11+
{% endif %}
12+
13+
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.
14+
15+
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.
16+
17+
{% if slack_enabled %}
18+
## Reserve Equipment in Slack
19+
20+
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.
21+
22+
### Create a Reservation
23+
24+
1. Enter `/esb-reserve` in Slack.
25+
2. Select **Reserve** for the equipment you need.
26+
3. Review the listed unavailable times and reservation limits.
27+
4. Enter a start time and an end time.
28+
5. Enter a note if it helps other users understand the reservation.
29+
6. Select **Reserve**.
30+
31+
![Slack reservation form showing existing reservations, policy limits, and requested reservation fields](images/reservations-slack-form.png){ .docs-screenshot-compact }
32+
33+
Slack shows a confirmation with the equipment, time, and note.
34+
35+
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.
36+
37+
### View or Cancel Your Reservations
38+
39+
1. Enter `/esb-reserve` in Slack.
40+
2. Select **My reservations**.
41+
3. To cancel a reservation, select **Cancel reservation**.
42+
4. Review the reservation details.
43+
5. Select **Cancel reservation** to confirm.
44+
45+
![Slack My Reservations window showing an upcoming reservation and its cancel action](images/reservations-cancel-form.png){ .docs-screenshot-compact }
46+
47+
You can cancel only an active reservation that belongs to you. Slack confirms when the reservation is canceled.
48+
{% endif %}
49+
50+
## Manage Reservations
51+
52+
Technicians and staff can manage reservations in the web application. Select **Admin**, then select **Reservations**.
53+
54+
### View Reservations and History
55+
56+
The **Calendar** view shows reservations for one day. Use **Previous**, **Today**, or **Next** to change the displayed day.
57+
58+
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**.
59+
60+
An active reservation can be edited or canceled. A canceled reservation remains in the history.
61+
62+
### Create a Reservation or Administrative Hold
63+
64+
1. Select **New Reservation**.
65+
2. Select **Member reservation** or **Admin hold**.
66+
3. Select the equipment.
67+
4. If you selected **Member reservation**, select the member.
68+
5. Enter the start date, start time, duration, and note.
69+
6. Select **Review reservation**.
70+
7. Review the reservation details and any policy warnings.
71+
8. Confirm the reservation.
72+
73+
An administrative hold blocks a time without assigning the reservation to a member. All administrative reservations and holds require a note.
74+
75+
### Edit or Cancel a Reservation
76+
77+
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.
78+
79+
To cancel an active reservation, select **Cancel**, then confirm the cancellation. You cannot edit or cancel a reservation that is already canceled.
80+
81+
## Configure Reservation Policies
82+
83+
Only staff can configure reservations for an equipment item.
84+
85+
1. Open the equipment detail page.
86+
2. Select **Reservation Settings**.
87+
3. Select **Allow reservations** to make the equipment available for reservations.
88+
4. Enter a **Reservation slug**.
89+
5. Set the advance-notice, duration, and slot-granularity limits.
90+
6. Select **Save Reservation Settings**.
91+
92+
Use a reservation slug that contains lowercase letters, numbers, and single hyphens only. For example, use `laser-cutter-1`.
93+
94+
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.
95+
96+
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.
97+
98+
Clear **Allow reservations** to prevent new reservations for the equipment. You cannot configure reservations for archived equipment.

‎esb/services/docs_service.py‎

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,9 @@
3737
serving stale HTML.
3838
3939
Supported markdown extensions (runtime): ``tables``, ``fenced_code``,
40-
``admonition``, ``toc`` — the only features the five guides use today.
40+
``admonition``, ``toc``, ``attr_list`` — the only features the six guides use today.
4141
``mkdocs.yml`` additionally enables ``pymdownx.details``,
42-
``pymdownx.superfences``, ``attr_list``, ``md_in_html`` which this renderer does
42+
``pymdownx.superfences``, ``md_in_html`` which this renderer does
4343
NOT support: a future doc edit adopting them would build green on GH Pages but
4444
render as literal text here. Align the extension sets (or add
4545
``pymdown-extensions`` at runtime) if/when that happens.
@@ -71,6 +71,7 @@
7171
('members', {'file': 'members.md', 'title': 'Members Guide'}),
7272
('technicians', {'file': 'technicians.md', 'title': 'Technicians Guide'}),
7373
('staff', {'file': 'staff.md', 'title': 'Staff Guide'}),
74+
('reservations', {'file': 'reservations.md', 'title': 'Reservations Guide'}),
7475
('administrators', {'file': 'administrators.md', 'title': 'Administrators Guide'}),
7576
])
7677

@@ -81,10 +82,10 @@
8182
LICENSE_NAME = 'MIT'
8283
LICENSE_URL = GITHUB_URL + '/blob/main/LICENSE'
8384

84-
MARKDOWN_EXTENSIONS = ['tables', 'fenced_code', 'admonition', 'toc']
85+
MARKDOWN_EXTENSIONS = ['tables', 'fenced_code', 'admonition', 'toc', 'attr_list']
8586

8687
# Matches inter-doc markdown links to a served page, e.g. ``(members.md)`` or
87-
# ``(staff.md#anchor)``. Only the five served page names are rewritten — no
88+
# ``(staff.md#anchor)``. Only the six served page names are rewritten — no
8889
# guide links to a non-served .md file (the no-.md-href test guards future
8990
# cases).
9091
_SERVED_PAGE_NAMES = [meta['file'] for meta in DOC_PAGES.values()]
@@ -168,6 +169,10 @@ def get_placeholder_values():
168169
# Human-friendly fallback so an unset base URL never renders a broken
169170
# sentence ("Navigate to in your browser").
170171
'base_url_display': base_url or 'the Equipment Status Board URL provided by your makerspace',
172+
# Same-origin route so links from the built-in help site preserve the
173+
# authenticated session even when local access uses 127.0.0.1 instead
174+
# of the configured localhost base URL.
175+
'reservation_url': '/reservations/',
171176
'static_page_url': static_page_url,
172177
'wifi_ssid': wifi_ssid,
173178
'org_name': cfg.get('ORG_NAME', ''),
@@ -225,7 +230,7 @@ def _link_sub(match):
225230

226231

227232
def render_page(slug):
228-
"""Render a docs page, returning ``(title, html)``.
233+
"""Render a docs page, returning ``(title, html, toc_html)``.
229234
230235
Raises ``KeyError`` for a slug not in ``DOC_PAGES`` (→ 404 in the view). A
231236
slug that IS in ``DOC_PAGES`` but whose file is missing/unreadable lets the
@@ -244,7 +249,10 @@ def render_page(slug):
244249
# Atomic single-dict lookup: invalidate_page_cache() may swap the cache dict
245250
# out concurrently, so a check-then-index would risk a KeyError. dict.get()
246251
# is atomic under the GIL and a miss simply re-renders.
247-
cached = cache.get(cache_key)
252+
# Local documentation changes should appear on refresh without requiring a
253+
# server restart. Production keeps the per-process cache because its docs
254+
# source and configuration are fixed for the lifetime of a deployment.
255+
cached = None if current_app.debug else cache.get(cache_key)
248256
if cached is not None:
249257
return cached
250258

@@ -261,11 +269,18 @@ def render_page(slug):
261269
# (2) rewrite inter-doc links and image refs in the markdown source.
262270
text = _rewrite_links(text)
263271

264-
# (3) convert markdown → HTML.
265-
html = markdown.markdown(text, extensions=MARKDOWN_EXTENSIONS)
266-
267-
result = (title, html)
268-
cache[cache_key] = result
272+
# (3) convert markdown → HTML and retain the TOC generated from the same
273+
# heading pass so the built-in help navigation cannot drift from content.
274+
renderer = markdown.Markdown(
275+
extensions=MARKDOWN_EXTENSIONS,
276+
extension_configs={'toc': {'toc_depth': '2-3'}},
277+
)
278+
html = renderer.convert(text)
279+
toc_html = renderer.toc
280+
281+
result = (title, html, toc_html)
282+
if not current_app.debug:
283+
cache[cache_key] = result
269284
return result
270285

271286

‎esb/services/reservation_read_service.py‎

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -324,9 +324,9 @@ def get_admin_calendar_data(
324324
per_page: int = ADMIN_RESERVATION_PAGE_SIZE,
325325
) -> AdminCalendarData:
326326
"""Build private calendar data and a paginated administrative history list."""
327-
equipment_items = _admin_filtered_equipment(filters)
328-
equipment_ids = [equipment.id for equipment in equipment_items]
329-
if not equipment_ids:
327+
history_equipment = _admin_filtered_equipment(filters)
328+
history_equipment_ids = [equipment.id for equipment in history_equipment]
329+
if not history_equipment_ids:
330330
return {
331331
"startDate": filters.calendar_date.isoformat(),
332332
"startsOn": filters.starts_on.isoformat(),
@@ -338,9 +338,25 @@ def get_admin_calendar_data(
338338
"pagination": {"page": 1, "pages": 0, "total": 0, "has_prev": False, "has_next": False},
339339
}
340340

341+
# History must retain reservations made before equipment was archived or
342+
# reservations were disabled. The default calendar, however, should only
343+
# show tools that can currently be reserved. An explicit equipment filter
344+
# is an investigation workflow, so it continues to show that one tool.
345+
calendar_equipment = [
346+
equipment
347+
for equipment in history_equipment
348+
if filters.equipment_id is not None
349+
or (
350+
not equipment.is_archived
351+
and equipment.reservation_settings is not None
352+
and equipment.reservation_settings.reservations_enabled
353+
)
354+
]
355+
calendar_equipment_ids = [equipment.id for equipment in calendar_equipment]
356+
341357
list_query = _admin_reservation_query(
342358
filters,
343-
equipment_ids,
359+
history_equipment_ids,
344360
filters.starts_on,
345361
filters.ends_on,
346362
)
@@ -357,18 +373,20 @@ def get_admin_calendar_data(
357373
.all()
358374
)
359375

360-
calendar_reservations = list(
361-
db.session.execute(
362-
_admin_reservation_query(
363-
filters,
364-
equipment_ids,
365-
filters.calendar_date,
366-
filters.calendar_date,
367-
).order_by(Reservation.starts_at, Reservation.id)
376+
calendar_reservations = []
377+
if calendar_equipment_ids:
378+
calendar_reservations = list(
379+
db.session.execute(
380+
_admin_reservation_query(
381+
filters,
382+
calendar_equipment_ids,
383+
filters.calendar_date,
384+
filters.calendar_date,
385+
).order_by(Reservation.starts_at, Reservation.id)
386+
)
387+
.scalars()
388+
.all()
368389
)
369-
.scalars()
370-
.all()
371-
)
372390
calendar_rows = [_serialize_admin_reservation(reservation) for reservation in calendar_reservations]
373391
list_rows = [_serialize_admin_reservation(reservation) for reservation in list_reservations]
374392
displayed_ids = {row["id"] for row in calendar_rows + list_rows}
@@ -391,7 +409,7 @@ def get_admin_calendar_data(
391409
"startDate": filters.calendar_date.isoformat(),
392410
"startsOn": filters.starts_on.isoformat(),
393411
"endsOn": filters.ends_on.isoformat(),
394-
"columns": [_serialize_admin_equipment(equipment) for equipment in equipment_items],
412+
"columns": [_serialize_admin_equipment(equipment) for equipment in calendar_equipment],
395413
"events": [_serialize_admin_calendar_event(row) for row in calendar_rows],
396414
"details": details,
397415
"reservations": list_rows,

‎esb/static/css/app.css‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
/* ESB custom styles */
22

3+
/* Make local debug sessions visually distinct from production. */
4+
.navbar-local {
5+
background-color: #14532d;
6+
}
7+
38
/* Kiosk layout. Class is only applied to <body> in base_kiosk.html, so
49
this rule is effectively body-scoped. overflow:hidden prevents any
510
pre-JS scrollbars or post-JS clipping artifacts.
@@ -173,10 +178,54 @@ th.sortable:hover {
173178
/* Built-in docs/help site. python-markdown emits bare HTML (no Bootstrap
174179
classes), so these minimal styles make tables, admonitions, code blocks,
175180
and images render acceptably under Bootstrap. Scoped under .docs-content. */
181+
.docs-page-toc {
182+
border-left: 3px solid var(--bs-border-color);
183+
font-size: 0.9rem;
184+
padding: 0.25rem 0 0.25rem 1rem;
185+
}
186+
.docs-page-toc-title {
187+
font-weight: 700;
188+
margin-bottom: 0.5rem;
189+
}
190+
.docs-page-toc ul {
191+
list-style: none;
192+
margin: 0;
193+
padding-left: 0;
194+
}
195+
.docs-page-toc li + li {
196+
margin-top: 0.35rem;
197+
}
198+
.docs-page-toc li ul {
199+
border-left: 1px solid var(--bs-border-color);
200+
margin-top: 0.35rem;
201+
padding-left: 0.75rem;
202+
}
203+
.docs-page-toc a {
204+
color: var(--bs-secondary-color);
205+
text-decoration: none;
206+
}
207+
.docs-page-toc a:hover,
208+
.docs-page-toc a:focus {
209+
color: var(--bs-link-color);
210+
text-decoration: underline;
211+
}
212+
@media (min-width: 992px) {
213+
.docs-page-toc {
214+
max-height: calc(100vh - 6rem);
215+
overflow-y: auto;
216+
position: sticky;
217+
top: 4.5rem;
218+
}
219+
}
176220
.docs-content img {
177221
max-width: 100%;
178222
height: auto;
179223
}
224+
.docs-content .docs-screenshot-compact {
225+
display: block;
226+
margin: 1rem auto;
227+
width: min(100%, 640px);
228+
}
180229
.docs-content table {
181230
border-collapse: collapse;
182231
margin-bottom: 1rem;

0 commit comments

Comments
 (0)