info@altius-group.ch
Froideville, Vaud
IT

Two people, one row

/ 13 min di lettura / aggiornato 04.09.2026

A site inspection is filled in by two people. One is up a ladder with a tape measure, the other is on the ground photographing the façade. They are looking at one form and producing one submission.

Neither of them has an account. They are subcontractors, on site for a morning, using their own phones.

That is the collaborative session in PaperPoint, and once you take the two constraints together — one shared row and no user accounts — most of the familiar answers stop being available. There is no request.user to key anything on. There is no login to gate anything behind. And there is a single JSON document that two phones are writing to at the same time over a connection that drops behind buildings.

Who you are, without an account

Identity is a per-participant token, presented two ways:

def resolve_participant(request, access_code: str) -> SessionParticipant | None:
    """Return the active participant for ``access_code`` or ``None``.

    Tries ``Authorization: Bearer <token>`` first (mobile/REST), then the
    ``collab_participant_<code>`` cookie (web/PWA). Enforces the inactivity
    timeout — a stale token is treated as missing AND its slot is released so
    a fresh joiner can take it.
    """

Two transports for one credential: a Bearer header for the native app, a cookie for the browser. The cookie is named per session — collab_participant_<code> — so one phone can be in two sessions at once, which happens the moment a foreman is running two inspections in one morning.

The permission classes are thin and do one useful extra thing:

class IsCollaborativeParticipant(permissions.BasePermission):
    """Allow access if the request carries a valid participant token."""

    def has_permission(self, request, view):
        access_code = view.kwargs.get("access_code")
        if not access_code:
            return False
        participant = resolve_participant(request, access_code)
        if not participant:
            return False
        request.collab_participant = participant
        request.collab_session = participant.session
        return True

The resolved participant is attached to the request, so the view body does not re-query. That is a small ergonomic win and a real correctness one: two resolutions in one request could disagree if the inactivity cutoff falls between them.

There is something to flag honestly, though. resolve_participant has a side effect — it releases the participant’s seat when the token is stale — and it is called from a permission class. A permission check that writes to the database is not the shape anybody would design on purpose. It is defensible here because the write is idempotent and the alternative is a periodic job to reclaim seats, which is more machinery for a session that lives 48 hours. But it is the kind of thing that deserves the sentence rather than the silence.

Six characters people have to read aloud

Joining is done by typing a code that somebody reads out on a building site:

# Characters that avoid ambiguity (no O/0/I/1/L)
SAFE_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"


def generate_access_code():
    # secrets, not random: these codes gate access to a session that collects
    # data. random.choices (Mersenne Twister) is predictable from prior outputs.
    return "".join(secrets.choice(SAFE_CHARS) for _ in range(6))

Both comments are load-bearing and neither is obvious.

Dropping O, 0, I, 1 and L costs you five characters of alphabet — 31 instead of 36, so about 0.9 bits over six characters — and removes the entire category of failure where the code is correct and the transcription is not. On a site, over a phone, that trade is not close.

And secrets rather than random: Mersenne Twister’s state is recoverable from a few hundred outputs, so a random.choices code space is enumerable by anyone who has seen enough codes. These codes gate write access to a session collecting real data. The comment says why, which is the only way that choice survives the next refactor.

Uniqueness is handled with a bounded retry and an explicit acknowledgement of what it does not cover:

def _ensure_unique_code(instance, model_cls, field):
    """Regenerate ``field`` until it is free in ``model_cls`` (bounded).

    Only runs on insert. Closes the practical collision window on the 6-char
    code space; the residual insert-race is caught as an IntegrityError backstop
    by the caller's retry. Returns nothing — mutates ``instance``.
    """
    # Not `for _ in …`: this module imports gettext as `_`, and the loop variable
    # would shadow it for the rest of the function.
    for _attempt in range(10):
        code = getattr(instance, field)
        if not model_cls.objects.filter(**{field: code}).exists():
            return
        setattr(instance, field, generate_access_code())

Check-then-insert is a race, and the docstring says so instead of pretending otherwise: the unique=True on the column is the actual guarantee, this loop just makes the retry rare. Ten attempts, then let the database decide.

The for _attempt comment is my favourite line in the module. for _ in range(10) would rebind _ — which this module imported as gettext_lazy — for the rest of the function, and the failure would surface as a TypeError on a translation call several lines later. Two extra words in the loop variable, one comment, and a bug that would have taken an afternoon never happens.

Seats, and why they are not just a count

A session has a participant cap, but it also has named seats:

class ParticipantSlot(models.Model):
    """Numbered slot for a participant in a collaborative session."""

    session = models.ForeignKey(CollaborativeSession, related_name="slots", ...)
    slot_number = models.PositiveIntegerField()  # 1-based
    label = models.CharField(max_length=100, blank=True)
    slot_code = models.CharField(max_length=6, unique=True,
                                 default=generate_access_code, db_index=True)

    class Meta:
        unique_together = [("session", "slot_number")]
class SlotGroupAssignment(models.Model):
    """Assigns a field group to a participant slot."""

A seat carries a label and its own join code, and field groups are assigned to seats. So the electrician’s code opens the electrical section is expressible without any of the people involved having accounts. The generic session code still exists for the free-for-all case; the API distinguishes them at join time:

"the session creator) or a slot code (assigned to a specific seat). "
if forced_slot is not None and session.participants.filter(
        participant_number=forced_slot).exists():
    return Response({"error": {"slot_code": "Slot already occupied."}}, ...)

Occupancy is checked before the join, and the check runs after stale seats are reclaimed:

session.release_inactive_slots()

Order matters there. Reclaiming after the occupancy test would refuse a legit joiner because of a seat held by a phone that died twenty minutes ago.

Presence, when nothing tells you someone left

This is the hardest part of the whole feature, and it has no clean solution.

def release_inactive_slots(self, minutes=5):
    """Free participant_number for users inactive longer than ``minutes``.

    Only releases slots for participants that are disconnected AND
    haven't been seen recently.  This prevents stale slot occupation
    when a browser crashes before the WebSocket ``disconnect`` fires.
    """
    cutoff = timezone.now() - timedelta(minutes=minutes)
    self.participants.filter(
        participant_number__isnull=False,
        is_connected=False,
        date_last_seen__lt=cutoff,
    ).update(participant_number=None)

There are two presence signals and neither is trustworthy alone. is_connected is set by the socket lifecycle and is precise but unreliable — a phone that loses signal in a stairwell never sends a disconnect, and the flag stays true forever. date_last_seen is reliable but coarse — it only tells you the last time something happened, so a participant who is reading rather than typing looks idle.

So the code uses each one where it is strong. Reclaiming a seat requires both: explicitly disconnected and not seen for five minutes. Joinability accepts either:

# Count only active participants (connected OR seen in last 5 min)
cutoff = timezone.now() - timedelta(minutes=5)
active_count = self.participants.filter(
    Q(is_connected=True) | Q(date_last_seen__gte=cutoff)
).count()
return not active_count >= self.max_participants

AND to take something away, OR to count someone as present. That asymmetry is the design: be reluctant to evict, be generous about who counts as here. Both errors are possible and they are not equally bad — wrongly freeing a seat throws someone out of a form they are filling in; wrongly holding one makes a colleague wait five minutes.

The heartbeat is what keeps date_last_seen meaningful:

class CollaborativeHeartbeatAPIView(APIView):
    """POST /api/v1/collab/{access_code}/heartbeat/ — Mobile keepalive.

    Refreshes ``date_last_seen`` on the participant (extends the inactivity
    window without re-issuing a token) and returns a lightweight snapshot
    of who's connected right now. Mobile clients call this every 15-30s
    instead of refetching the full state endpoint.
    """

    def post(self, request, access_code):
        participant = request.collab_participant
        session = request.collab_session

        SessionParticipant.objects.filter(pk=participant.pk).update(
            date_last_seen=timezone.now(),
            is_connected=True,
        )
        ...

Without re-issuing a token is the important clause: staying alive must not mint credentials. And .filter(pk=...).update(...) rather than participant.save() — a targeted UPDATE that cannot clobber a field another request just wrote, on an endpoint called every fifteen seconds by every phone in the session.

The response carries server_time, which is the detail that makes the client side sane: phone clocks on shared site devices are wrong often enough that “seen 3 minutes ago” computed against local time is misleading.

The lost update, and the row lock that prevents it

The submission is one row with one JSON column. Two people typing into different fields of the same form are doing a read-modify-write on the same document.

def save_field_atomic(submission, field_name, processed_value, participant) -> tuple[str, str]:
    """Persist ``processed_value`` under ``field_name`` with a row lock.

    Returns ``(saved_at_iso, old_value)``. Wraps the JSON read-modify-write
    in ``SELECT … FOR UPDATE`` so simultaneous saves from multiple
    participants never lose updates.
    """
    saved_at = timezone.now().isoformat()
    with transaction.atomic():
        locked = FormSubmission.objects.select_for_update().get(pk=submission.pk)
        data = locked.data or {}
        old_value = data.get(field_name, "")
        data[field_name] = processed_value
        meta = data.get("_meta", {})
        meta[field_name] = {
            "saved_at": saved_at,
            "by": participant.display_name,
            "by_id": participant.pk,
        }
        data["_meta"] = meta
        locked.data = data
        locked.save(update_fields=["data"])
    return saved_at, old_value

Without the lock, the failure is the textbook one and it is silent: A reads the document, B reads the document, A writes their field, B writes theirs — and B’s document, which never contained A’s field, overwrites it. The person up the ladder watches their measurement disappear and blames the app, correctly.

select_for_update() serialises the read-modify-write. It is the right tool here for a reason worth naming: the contention is genuinely low. Two or three people, debounced writes, a lock held for microseconds. A lock is the wrong answer at high contention and the simplest correct answer at this one — and the alternative, a JSONB path update in SQL, would move the merge into the database at the cost of expressing it in a form the next reader has to decode.

Per-field provenance rides along in _meta:

meta[field_name] = {"saved_at": saved_at, "by": participant.display_name, "by_id": participant.pk}

Both the name and the id, deliberately. The id survives a rename; the name survives the participant row being deleted when the session is cleaned up. A shared document where nobody can say who wrote a number is a document nobody signs off on.

File attachments take the same lock and add one branch:

if field_obj.field_type in MULTIVALUE_FILE_FIELD_TYPES:
    existing = data.get(field_name)
    if not isinstance(existing, list):
        existing = [existing] if existing else []
    existing.append(str(form_file.id))
    data[field_name] = existing
else:
    data[field_name] = str(form_file.id)

if not isinstance(existing, list) is defensive against the document’s own history: a field that was single-valued when the form was designed and became multi-value afterwards has a bare string sitting in old submissions. Coercing rather than crashing is right, and [existing] if existing else [] avoids turning an empty string into a list containing an empty string.

Creating the shared submission has the same shape, for the same reason:

with transaction.atomic():
    locked = CollaborativeSession.objects.select_for_update().get(pk=self.pk)
    if locked.submission_id:
        self.submission = locked.submission
        return self.submission
    sub = FormSubmission.objects.create(form_template=self.form_template, data={})

Two participants joining in the same second must not create two submissions. The lock, then a re-check of the locked row — not the stale one in memory.

Throttling something that has no user

"""Rate limiting for collaborative session endpoints.

The collaborative tier exposes write paths to anonymous participants
(authenticated by a per-session bearer token, NOT a Django user). DRF's
built-in ``UserRateThrottle`` keys off ``request.user`` so it would group
every participant under "AnonymousUser" — useless.
"""

This is the concrete cost of the “no accounts” decision, and it is the kind of thing that is discovered rather than planned. Every stock throttle in DRF keys on the user or the IP. Here the user is always anonymous, and the IP is shared — two phones on one site’s mobile hotspot are one address.

So the bucket is keyed on the credential that actually identifies a participant:

def get_cache_key(self, request, view):
    token = _token_from_request(request)
    if token:
        ident = hashlib.sha1(token.encode("utf-8"), usedforsecurity=False).hexdigest()
    else:
        ident = self.get_ident(request)
    return self.cache_format % {"scope": self.scope, "ident": ident}

The token is hashed before it becomes a cache key, which matters more than it looks: cache keys turn up in logs, in Redis MONITOR output, and in whatever observability tooling is attached. A raw credential in a cache key is a credential in your log aggregator. usedforsecurity=False on SHA-1 is the correct FIPS-mode annotation — this is a keying digest, not a security claim.

And the rates carry their justification:

class CollabSaveFieldThrottle(_CollabParticipantThrottle):
    scope = "collab_save_field"
    rate = "120/min"  # ~2/s — typing-debounced writes


class CollabUploadThrottle(_CollabParticipantThrottle):
    scope = "collab_upload"
    rate = "20/min"   # 1 upload every 3s — already gated by file size

Already gated by file size is the reasoning that makes 20/min defensible rather than arbitrary: uploads are bounded by another mechanism, so this throttle only needs to stop a loop, not to shape bandwidth.

Logging enough, and not more

class SessionActivityLog(models.Model):
    """Lightweight audit trail for collaborative sessions.

    Logs significant events (field saves, file uploads, finalization)
    but NOT every keystroke — the debounced save endpoint already
    batches rapid edits.
    """

The old_value / new_value pair means the log answers who changed the depth measurement from 2.4 to 2.7, which is the question that gets asked when two people disagree about a reading. Logging keystrokes would answer the same question and bury it.

There is a small trap here worth mentioning: old_value and new_value are TextFields holding the content of form fields, which on this platform can include personal data. A session log kept forever is a copy of the submission’s history with a longer retention than anybody intended. That is not solved in the code I read, and it is the sort of thing that should be, before the first audit rather than after.

The small fix worth stealing

def assign_color_balanced(session) -> str:
    """Pick the least-used color in this session (cycle, not collide).

    Replaces the old "fall back to the first colour if all five are taken"
    behaviour which produced two identical pills for 5+ participants.
    """
    used = list(session.participants.values_list("color", flat=True))
    if not used:
        return PARTICIPANT_COLORS[0]
    usage = Counter(used)
    return min(PARTICIPANT_COLORS, key=lambda c: usage.get(c, 0))

Five colours, and the sixth participant used to collide with the first. Counting usage and taking the minimum cycles instead: participants six through ten repeat the palette in order, and two people with the same colour are at least as far apart as possible.

min over a Counter is stable in Python, so the palette order breaks ties — the sixth participant reliably gets colour one, not an arbitrary one. Small thing; it means the colour a person gets is reproducible when you replay a session for support.

What carries over

  • When there are no accounts, pick the identifier deliberately — and then go find everything in your stack that assumed request.user. Throttling is the one that bites.
  • Two presence signals, used asymmetrically. Require both to evict, accept either to count as present. Wrongly evicting is much worse than wrongly waiting.
  • SELECT … FOR UPDATE around any JSON read-modify-write two clients can reach. The lost update is silent and the user blames the app.
  • Hash credentials before they become cache keys. Keys leak into logs.
  • Choose the alphabet for the humans reading it aloud. Dropping O/0/I/1/L costs under a bit and removes a whole class of support call.
  • Write down the race you are not closing. “The unique index is the guarantee; this loop makes the retry rare” is a better comment than a loop that looks like it works.
Pronto a cominciare?

Parliamo del suo progetto

Ci racconti le sue esigenze in IoT, GIS o sviluppo su misura — le rispondiamo entro 24 ore.