QR-based event registration, check-in, and contact sharing platform.
Built by Susquehanna Syntax.
# Clone and enter directory
cd turnstil
# Create virtual environment
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# Install dependencies
pip install -r requirements.txt
# Run migrations
python manage.py migrate
# Seed demo data
python manage.py seed_demo
# Or just create an admin user
python manage.py seed_admin
python manage.py seed_admin --username myadmin --password secret123 --email me@example.com
# Run server
python manage.py runserverdocker compose up --build
docker compose exec web python manage.py migrate
docker compose exec web python manage.py seed_demo| Username | Password | Role | Event Limit |
|---|---|---|---|
admin |
admin1234 |
Admin (full access) | Unlimited |
organizer |
organize1234 |
Organizer (create events, assign staff) | 25 |
staff |
staff1234 |
Staff (scan check-ins) | 10 |
alice through eve |
attendee1234 |
Attendees | 10 |
turnstil/
├── config/ # Django project settings
├── core/ # Main app (models, views, API, templates)
│ ├── models.py # User, Person, Event, Ticket, ScanLog, EventPhoto, ScannedContact
│ ├── views.py # REST API views
│ ├── web_views.py # Server-rendered page views
│ ├── serializers.py # DRF serializers
│ ├── api_urls.py # /api/* routes
│ └── urls.py # Web page routes
├── templates/ # Django templates (custom CSS)
│ ├── scanner/ # QR scanner interface
│ ├── admin_portal/ # Dashboard, event management
│ ├── public/ # Home, profile, contact card, QR display
│ └── registration/ # Login, register
└── docs/ # wiki.html — full feature documentation
- Persistent QR Identity — One UUID per person, works across all events
- Two Scanner Modes — Attendance (check-in) and Discovery (networking / contact exchange)
- Scanned Contacts — Discovery scans are saved; view your collected contacts as an avatar stack on your profile
- Profile Pictures — Upload a photo; appears in the sidebar, profile page, and contact cards
- Card Colors — Each user picks a pastel signature color used on their contact card and avatar
- Event Photo Gallery — Organizers upload up to 10 photos per event; shown in a scrollable gallery with drag-and-drop upload, captions, and cover selection
- Visibility Controls — Users choose which contact fields to share
- Scan Confirmation — After a successful check-in, the attendee's device receives a popup asking "Did an event worker just scan your ticket?" to prevent screenshot fraud
- Organizer Self-Promotion — Any attendee can click "Create Event" in the sidebar to auto-promote to organizer
- Per-User Event Limits — Admins can raise or lower each user's active event cap from the dashboard (0 = unlimited)
- Full Audit Trail — Every scan attempt logged with timestamps
- Role-Based Access — Attendee → Staff → Organizer → Admin
- REST API + JWT — Full API alongside server-rendered pages
All responses follow the envelope format:
{ "status": "success" | "error", "data": { ... } }Authenticated endpoints require a JWT bearer token:
Authorization: Bearer <access_token>
Create a new account. Automatically creates a Person record and returns JWT tokens.
Auth: None
Request body:
{
"username": "jsmith",
"email": "j@example.com",
"password": "mypassword",
"name": "Jane Smith",
"organization": "Acme Corp"
}Response 201:
{
"status": "success",
"data": {
"user": { "id": 1, "username": "jsmith", "email": "...", "role": "attendee", "person_uuid": "...", "person_name": "Jane Smith" },
"person_uuid": "<uuid>",
"tokens": { "access": "<jwt>", "refresh": "<jwt>" }
}
}Get JWT tokens for an existing account.
Auth: None
Request body:
{ "username": "jsmith", "password": "mypassword" }Response 200:
{ "access": "<jwt>", "refresh": "<jwt>" }Exchange a refresh token for a new access token.
Auth: None
Request body:
{ "refresh": "<refresh_token>" }Response 200:
{ "access": "<new_jwt>" }Return the current user's profile.
Auth: JWT (any role)
Response 200:
{
"status": "success",
"data": { "id": 1, "username": "jsmith", "email": "...", "role": "attendee", "person_uuid": "<uuid>", "person_name": "Jane Smith" }
}Return a person's contact card. The owner sees all fields; others see only fields the person has marked visible. Also records a ScannedContact entry when an authenticated user views someone else's card (used for the contacts gallery).
Auth: JWT (any role)
Response 200:
{
"status": "success",
"data": { "name": "Jane Smith", "email": "j@example.com", "organization": "Acme", "phone": "...", "links": "https://...", "card_color": "mint", "avatar": "data:image/jpeg;base64,..." }
}Update contact fields. Owner only.
Auth: JWT (owner)
Request body (all fields optional):
{
"name": "Jane Smith",
"email": "new@example.com",
"organization": "New Org",
"phone": "555-1234",
"links": "https://mysite.com",
"visibility": { "email": true, "phone": false }
}Response 200: Full updated person object.
Return the person's QR code as a PNG image. Owner or admin only.
Auth: JWT (owner or admin)
Response 200: Content-Type: image/png
Search attendees by name. Returns up to 10 matches.
Auth: JWT (staff or above)
Response 200:
{
"status": "success",
"data": [
{ "id": "<uuid>", "name": "Jane Smith", "organization": "Acme" }
]
}List all events.
Auth: JWT (any role)
Response 200: Array of event objects with registered_count, checkin_count, is_full.
Create a new event. Creator is automatically added as staff.
Auth: JWT (organizer or above)
Request body:
{
"name": "Tech Meetup",
"description": "...",
"location": "Room 101",
"start_time": "2026-04-01T18:00:00Z",
"end_time": "2026-04-01T21:00:00Z",
"capacity": 100
}Response 201: Full event object.
Get a single event by UUID.
Auth: JWT (any role)
Response 200: Full event object.
Register the currently authenticated user for an event. Returns 409 if already registered or event is full. Returns 403 if registration window is closed.
Auth: JWT (any role)
Request body: None
Response 201:
{
"status": "success",
"data": { "id": "<uuid>", "person": "<uuid>", "event": "<uuid>", "status": "issued", "issued_at": "..." }
}Error codes:
| Code | HTTP | Meaning |
|---|---|---|
REGISTRATION_CLOSED |
403 | Outside reg window |
EVENT_FULL |
409 | At capacity |
ALREADY_REGISTERED |
409 | Duplicate registration |
List staff assigned to the event.
Auth: JWT (organizer or above)
Response 200: Array of user objects.
Assign a user as staff for the event.
Auth: JWT (organizer or above, and must own the event)
Request body:
{ "user_id": 42 }Response 200:
{ "status": "success", "message": "jsmith assigned as staff." }Remove a user from the event's staff list.
Auth: JWT (organizer or above, and must own the event)
Request body:
{ "user_id": 42 }Live event stats and the 20 most recent scan log entries.
Auth: JWT (event staff or above)
Response 200:
{
"status": "success",
"data": {
"event": { ... },
"stats": { "registered": 80, "checked_in": 45, "capacity": 100, "is_full": false },
"recent_scans": [ ... ]
}
}Process a QR scan for attendance check-in. Every attempt is logged. On success, creates a ScanConfirmation record that triggers a popup on the attendee's device.
Auth: JWT (must be staff for the specified event)
Request body:
{
"person_uuid": "<person-uuid>",
"event_uuid": "<event-uuid>"
}Response 200 (success):
{
"status": "success",
"data": { "person_name": "Jane Smith", "checked_in_at": "2026-04-01T18:32:00Z", "event_name": "Tech Meetup" }
}Error codes:
| Code | HTTP | Meaning |
|---|---|---|
INVALID |
404 | Person or event UUID not found |
UNAUTHORIZED |
403 | Scanner is not staff for this event |
NOT_REGISTERED |
404 | Person has no ticket (walk-ins disabled) |
EVENT_FULL |
409 | Walk-in attempted but event is at capacity |
DUPLICATE_CHECKIN |
409 | Person already checked in |
TICKET_CANCELED |
409 | Ticket was canceled |
Returns the most recent pending scan confirmation for the authenticated user's person (within the last 5 minutes). Polled every 5 seconds by the browser to trigger the confirmation popup.
Auth: JWT (any role)
Response 200:
{ "status": "success", "data": { "id": 1, "event_name": "Tech Meetup", "scanned_at": "..." } }Returns "data": null when no pending confirmation exists.
Record the attendee's yes/no response. A "No" response flags the scan log entry.
Auth: JWT (must own the confirmation)
Request body:
{ "confirmed": true }Query the full scan audit log.
Auth: JWT (admin only)
Query params:
| Param | Description |
|---|---|
event |
Filter by event UUID |
result |
Filter by result: success, duplicate, not_registered, invalid |
Response 200:
{
"status": "success",
"data": [
{
"id": 1,
"event": "<uuid>",
"person": "<uuid>",
"person_name": "Jane Smith",
"actor": 3,
"actor_name": "staffuser",
"result": "success",
"scanned_value": "",
"metadata": {},
"timestamp": "2026-04-01T18:32:00Z"
}
]
}The scanner at /scanner/ uses the html5-qrcode library for camera-based scanning. For mobile testing over HTTPS (required for camera), use ngrok:
ngrok http 8000Then add the ngrok domain to ALLOWED_HOSTS in settings.
Django 5 · Django REST Framework · SimpleJWT · PostgreSQL/SQLite · html5-qrcode · Docker