Arcology Engine

The Arcology Reservation System

Contents

For The Chance Encounter and The Chance Encounter Operations I need a system to take event reservations. I went to have a list of event timeslots, let's say hourly or half-hourly, that are listed in an org mode document

I think in line with the design of the Concept Operating System it should flow from the design of the documents themselves, how do I want a reservation system to look when I need to work with it, versus when the friend or customer are trying to register for it?

The data-flow I had come up with last night is:

  • an org-mode document with time spans that the event can be reserved, and times that are already reserved

  • an org-mode document parser that extracts the calendar data and generates a web page which people can click-through to register, fill in information in to a form and submit it

  • an django-admin function which calls the org-protocol stuff to pull in reservations to the org doc

  • then i can email the person right there to confirm the reservation when i do it using org-mime+gnus

  • close the reservation time off

So take Summer 2024 Private Japanese Tea Experiences, it's a list of headings with some property drawers, the events have AVAIL_DATES and AVAIL_TIMES, a matrix of these should be generated and shoved in to a form after being extracted from the Arcology roam.models.HeadingProperty extracted from the headings.

python:tangle reservations/__init__.py:mkdirp yes

Data Models

These parser will only run if an ARCOLOGY_RESERVATIONS keyword on the Page.

python:tangle reservations/models.py
from __future__ import annotations
from typing import List
from django.db import models
from django.conf import settings
from django_prometheus.models import ExportModelOperationsMixin as EMOM

import arroyo.arroyo_rs as native
import roam.models
import arrow

import logging
logger = logging.getLogger(__name__)

reservations.models.Event

each heading on an ARROYO_RESERVATIONS page an event, this is basically just to make it possible to generate a cross-page index of events and to keep every heading from needing to be parsed for the appropriate HeadingProperty. The base event object is pretty simple, it only stores the heading properties in a partially normalized state and then further create_from_arroyo functions will generate all of the EventSlots which can be used to reserve times for Events.

python:tangle reservations/models.py
class Event(EMOM('reservations'), models.Model):
  file_path = models.CharField(max_length=512)
  heading_id = models.CharField(max_length=512)
  name = models.CharField(max_length=512)
  avail_dates = models.TextField()
  avail_times = models.TextField()

Most Arcology models work from the document but it's a bit easier here for us to ask the ORM for some of this data; the headings' properties will have already been extracted from the document at this point, Arcology can just narrow a query for the properties that contain the event metadata, and do some very basic normalization to prepare the EventSlot generator.

python:tangle reservations/models.py
  @classmethod
  def create_from_arroyo(cls, doc: native.Document) -> List[Event]:
    is_reservation_doc = len(doc.collect_keywords("ARCOLOGY_RESERVATIONS")) > 0
    if not is_reservation_doc:
      logger.debug(f"no reservations md for doc {doc.path}")
      return list()

    f = roam.models.File.objects.get(path=doc.path)
    def proc_one_heading(heading):
      dates_qs = heading.headingproperty_set.filter(keyword="AVAIL_DATES")
      times_qs = heading.headingproperty_set.filter(keyword="AVAIL_TIMES")

      if dates_qs.count() == 0 or times_qs.count() == 0:
        logger.debug(f"no dates or times for header {heading.node_id}")
        return None

      dates_hpv = map(lambda v: v.get("value"), dates_qs.values("value"))
      times_hpv = map(lambda v: v.get("value"), times_qs.values("value"))

      return cls.objects.get_or_create(
        file_path=f.path,
        heading_id=heading.node_id,
        name=heading.title,
        avail_dates=', '.join(dates_hpv),
        avail_times=', '.join(times_hpv)
      )[0]

    return [
      e for e in
      [
        proc_one_heading(heading)
        for heading in f.heading_set.all()
      ]
      if e is not None
    ]

reservations.models.EventSlot

EventSlots are created for each hour interval in AVAIL_TIMES, for each of AVAIL_DAYS, except for intervals which a PendingReservation is created.

python:tangle reservations/models.py
from django.utils import timezone

class EventSlot(EMOM('reservations'), models.Model):
  class Meta:
    ordering = ["start_time"]

  event = models.ForeignKey(
    Event,
    on_delete=models.CASCADE
  )
  start_time = models.DateTimeField()
  end_time = models.DateTimeField()

  def __str__(self):
    the_time = timezone.localtime(self.start_time)
    return f"{self.event.name} @ {the_time.strftime('%a %B %-d %-I:%M%p')}"

I'll refactor this expand_to_start_end code at some point soon, but this is the code that calculates the possible time slots of an event. It involves maybe too much string munging, I'd like to make the date.shift code a little bit more reasonable.

python:tangle reservations/models.py
  @classmethod
  def create_from_arroyo(cls, doc: native.Document) -> List[EventSlot]:
    es = Event.objects.filter(file_path=doc.path)

    def slots_for_event(e: Event):
        dates = e.avail_dates
        times = e.avail_times

        def expand_to_start_end(sdate, time_range):
            date = arrow.get(sdate, "YYYY-MM-DD ddd", tzinfo=settings.TIME_ZONE)
            (start, end) = times.split('-')

            (start_h, start_m) = start.split(":")
            start_d = date.shift(hours=int(start_h), minutes=int(start_m))

            (end_h, end_m) = end.split(":")
            end_d = date.shift(hours=int(end_h), minutes=int(end_m))
            return arrow.Arrow.interval("hour", start_d,  end_d,
                                        interval=1, exact=True)

        return [
          cls.objects.get_or_create(
            event=e,
            start_time=time_tup[0].datetime,
            end_time=time_tup[1].datetime,
          )[0]
          for date in dates.split(', ')
          for time_tup in expand_to_start_end(date, times)
        ]

    # flatten each event's slots in to a flat list
    return [
      es
      for e in es
      for es in slots_for_event(e)
    ]
python:tangle reservations/models.py
  @classmethod
  def available_slots(cls, page=None):
      qs = cls.objects \
                .annotate(num_res=models.Count("pendingreservation")) \
                .filter(num_res=0).order_by("start_time")
      if page is not None:
          qs = qs.filter(event__file_path=page.file.path)
      return qs

reservations.models.PendingReservation

When someone fills out the reservation form, a PendingReservation object is created. This prevents double-registration, and captures the information in to a model that is visible in the Django Admin panel. A button will redirect to an org-protocol URI that will capture the form details back in to the Org document for the event as a noexport heading.

python:tangle reservations/models.py
PAYMENT_METHOD_CHOICES = [
   ("venmo", "Venmo @ryan_rix"),
   ("cashapp", "CashApp $rrix"),
   ("cash", "Day-of Cash"),
   ("check", "Day-of Check made payable to Ryan Rix"),
]

class PendingReservation(EMOM('reservations'), models.Model):
  event_slot = models.ForeignKey(
    EventSlot,
    on_delete=models.CASCADE,
    verbose_name="Session Time",
  )
  event = models.ForeignKey(
    Event,
    on_delete=models.CASCADE
  )

  name = models.CharField(max_length=512)
  email = models.EmailField(max_length=512)
  payment_method = models.CharField(max_length=64, choices=PAYMENT_METHOD_CHOICES)
  notes = models.TextField()
python:tangle reservations/models.py
  @classmethod
  def create_from_arroyo(cls, doc: native.Document) -> List[EventSlot]:
    pass

NEXT Need more robust data handling, maybe time to set up routing...

org-protocol capture template

capture the form contents in to the org doc, further add a template email to the page so that I can email them a confirmation

reservations.models.ParsedReservation

Those captured reservations should still be reflected in the available event slots so that people don't double-book. I don't want to have to rely on the sqlite3 DB being long-lasting so these will need to be pulled out of the document and the hidden headings' metadata.

Embeddable Form

Add this HTML to the page you want the form to reside in:

html
<div id="embed-reservation"
     hx-get="/reservations/form"
     hx-trigger="load"
     hx-swap="outerHTML"
> </div>

It'll cause HTMX to load the form partial in on page-load, here is the form:

python:tangle reservations/forms.py
from django import forms
import reservations.models

class ReservationForm(forms.ModelForm):
    class Meta:
        model = reservations.models.PendingReservation
        fields = ["name", "email",
                  "event_slot", "notes",
                  # "payment_method"
                  ]
    event_slot = forms.ModelChoiceField(
        queryset=reservations.models.EventSlot.available_slots(),
        label="Session Time"
    )
# also need to make sure this can filter to show events on the page, rather than all of them as it does
python:tangle reservations/urls.py
from django.urls import path

from reservations import views

urlpatterns = [
    path("form", views.reservation_form),
]
jinja2:tangle reservations/templates/reservations/_form.html:mkdirp yes
{% load django_htmx %}
{% load tz %}
<style type="text/css">
form > label {
  width: 25%;
  display: inline-block;
  text-align: right;
  vertical-align: top;
}

form > input,select,textarea {
  width: 70%;
  display: inline-block;
  background-color: var(--light-gray);
  font-family: "Vulf Sans";
  font-size: medium;
}

form > input[type="submit"] {
  width: 50%;
  display: block;
  margin: auto;
}

ul.errorlist {
  color: var(--alert);
  font-weight: 900;
}

@media all and (max-width: 991px) {
  div.content { padding: 0 };
  main { width: 100%; };
  form > label { width: 20%; };
}
</style>

{% timezone "America/Los_Angeles" %}
<form id="res-form"
  action="/reservations/form" method="post"
>
    {% csrf_token %}
    {{ form }}
    <input type="submit" value="Submit"
           hx-post="/reservations/form" hx-trigger="click"
           hx-target="#res-form">
</form>
{% endtimezone %}
jinja2:tangle reservations/templates/reservations/success.html:mkdirp yes
{% load django_htmx %}
{% load tz %}

{% timezone "America/Los_Angeles" %}
<b>Hey thanks! I'll email you before the event with more details!</b>
{% endtimezone %}
python:tangle reservations/views.py
from django.forms import modelformset_factory
from django.shortcuts import render
import arcology.models
import reservations.models
import reservations.forms

def reservation_form(request):
    if request.method == "POST":
        form = reservations.forms.ReservationForm(request.POST)
        if form.is_valid():
            data = form.cleaned_data
            data["event_id"] = data["event_slot"].event_id
            the_pending_reservation = reservations.models.PendingReservation(**data)
            the_pending_reservation.save()
            return render(request, "reservations/success.html", {"res": the_pending_reservation})
    else:
        form = reservations.forms.ReservationForm()
        if request.htmx:
            # 'http://localhost:29543/teasite/reservations'
            page = arcology.models.Page.find_by_url(request.htmx.current_url)
            form.fields["event_slot"].queryset = reservations.models.EventSlot.available_slots(page)

    return render(request, "reservations/_form.html", {"form": form})

Admin

python:tangle reservations/admin.py
from django.contrib import admin
from django import forms
from django.contrib.admin.widgets import FilteredSelectMultiple
from django.utils.translation import gettext as _

import reservations.models

class SlotInline(admin.TabularInline):
    model = reservations.models.EventSlot
    # fk_name = "dependent"

@admin.register(reservations.models.Event)
class EventAdmin(admin.ModelAdmin):
    list_display = ["name", "avail_dates"]
    inlines = [ SlotInline ]

@admin.register(reservations.models.EventSlot)
class SlotAdmin(admin.ModelAdmin):
    list_display = ["event_name", "start_time"]

    @admin.display(description="Name")
    def event_name(self, obj):
        return obj.event.name

@admin.register(reservations.models.PendingReservation)
class PendingResAdmin(admin.ModelAdmin):
    list_display = ["event_slot", "name", "email"]

File Configuration

# Local Variables: # org-src-preserve-indentation: t # End: