A Django web application that helps a group of friends find a common meeting date for recording a podcast.
100% vibe-coded by Claude Opus 4.5
- Keycloak OAuth2 Authentication: Secure login via Keycloak identity provider
- Interactive Calendar: Full-size calendar view for selecting availability
- Three-state Availability:
- Click once: Mark as Available (green)
- Click twice: Mark as Tentatively Available (yellow)
- Click again: Remove marker
- Real-time Updates: Changes are instantly visible to all users via WebSockets
- Group Visibility: See other users' availability with their names
- Dark green labels for available users
- Orange labels for tentatively available users
- Star Indicator: Dates with 3+ people available are marked with a ⭐
- Configurable Registration: User registration can be enabled/disabled via environment variable (disabled by default)
- Date Confirmation: Confirm dates with 1+ availabilities as official podcast recording dates
- Notifications: Send notifications via Apprise when dates are confirmed/unconfirmed
- iCal Export: Public iCal feed for subscribing to confirmed podcast dates
- Python 3.10+ (or NixOS/Nix for declarative deployment)
- Redis (optional, for production WebSocket support)
- Keycloak server (for OAuth2 authentication)
If you have Nix installed, you can run datefinder directly without any setup:
nix run github:Binaergewitter/datefinderThis starts the server on http://localhost:8000 using SQLite. For a declarative NixOS deployment, see NixOS Deployment below.
-
Create a virtual environment:
python -m venv venv source venv/bin/activate # On Windows: venv\Scripts\activate
-
Install dependencies:
pip install -r requirements.txt
-
Copy the environment example file and configure:
cp .env.example .env
-
Edit
.envwith your Keycloak configuration:KEYCLOAK_SERVER_URL=https://your-keycloak-server.com/auth/ KEYCLOAK_REALM=your-realm KEYCLOAK_CLIENT_ID=your-client-id KEYCLOAK_CLIENT_SECRET=your-client-secret SECRET_KEY=your-django-secret-key DEBUG=false -
Run migrations:
python manage.py migrate
-
Create a superuser (optional, for admin access):
python manage.py createsuperuser
-
Configure the Sites framework:
python manage.py shell
from django.contrib.sites.models import Site site = Site.objects.get(id=1) site.domain = 'localhost:8000' # or your domain site.name = 'Podcast Date Finder' site.save()
- Create a new client in your Keycloak realm
- Set the client to "Confidential" access type
- Add valid redirect URIs:
http://localhost:8000/accounts/openid_connect/keycloak/login/callback/https://yourdomain.com/accounts/openid_connect/keycloak/login/callback/
- Copy the client ID and client secret to your
.envfile
python manage.py runserverThe in-memory channel layer works for single-server development but doesn't support real-time updates across multiple browser sessions.
# Start Redis
docker run -p 6379:6379 redis:alpine
# Update .env
REDIS_URL=redis://localhost:6379/0
# Run with Daphne
daphne -b 0.0.0.0 -p 8000 datefinder.asgi:application# Collect static files
python manage.py collectstatic
# Run with Daphne behind a reverse proxy (nginx, etc.)
daphne -b 127.0.0.1 -p 8000 datefinder.asgi:applicationWhen running behind a reverse proxy (e.g., nginx) with HTTPS, configure these environment variables:
# The external URL where the app is accessible
SITE_URL=https://plan.binaergewitter.de
# Add the domain to allowed hosts
ALLOWED_HOSTS=plan.binaergewitter.de,localhost,127.0.0.1
# Trust proxy headers for proper HTTPS detection
USE_X_FORWARDED_HOST=true
TRUST_PROXY_HEADERS=true
# Additional CSRF trusted origins (SITE_URL is added automatically)
# CSRF_TRUSTED_ORIGINS=https://other-domain.com
# Enable user registration (disabled by default)
REGISTRATION_ENABLED=true
# Show local username/password login form (enabled by default)
# Set to false to only allow social login (Keycloak)
LOCAL_LOGIN_ENABLED=true# Directory for storing state files (database, iCal export)
# Default: /tmp
STATEDIR=/var/lib/datefinder
# Path to SQLite database file
# Default: <STATEDIR>/db.sqlite3
DATABASE_PATH=/var/lib/datefinder/db.sqlite3
# PostgreSQL via unix socket (used by NixOS module)
DATABASE_URL=postgres:///datefinder
# PostgreSQL via TCP
DATABASE_URL=postgres://user:password@localhost:5432/datefinder
# Unix socket directory (default: /run/postgresql)
DATABASE_SOCKET_DIR=/run/postgresql
# Path where the iCal export file will be written
# Default: <STATEDIR>/calendar.ics
ICAL_EXPORT_PATH=/var/lib/datefinder/calendar.ics
# Timezone for iCal events (times are converted to UTC)
# Default: Europe/Berlin
ICAL_TIMEZONE=Europe/Berlin# Enable Django debug mode (for development only!)
# Default: false
DEBUG=false
# Django secret key (required for production)
# Generate with: python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())'
SECRET_KEY=your-random-secret-keyThe application can send notifications when podcast dates are confirmed or unconfirmed using the Apprise library. Apprise supports 90+ notification services including Slack, Discord, Telegram, Email, and more.
# Comma-separated list of Apprise notification URLs
# See https://github.com/caronc/apprise/wiki for all supported services
APPRISE_URLS=slack://tokenA/tokenB/tokenC,discord://webhook_id/webhook_token
# Optional: Jinja2 template for confirm notification message
# Available variables: date, date_formatted, description, confirmed_by, site_url
APPRISE_CONFIRM_TEMPLATE={{ description }}
# Optional: Jinja2 template for unconfirm notification message
# Available variables: date, date_formatted
APPRISE_UNCONFIRM_TEMPLATE=Date {{ date_formatted }} has been unconfirmed.| Service | URL Format |
|---|---|
| Slack | slack://tokenA/tokenB/tokenC |
| Discord | discord://webhook_id/webhook_token |
| Telegram | tgram://bot_token/chat_id |
| Email (SMTP) | mailto://user:[email protected] |
| Gotify | gotify://hostname/token |
| Ntfy | ntfy://topic |
| Matrix | matrix://user:pass@hostname/#room |
For the complete list of supported services, see the Apprise Wiki.
You can customize the notification message using Jinja2 templates. Available variables:
| Variable | Description |
|---|---|
date |
ISO format date (e.g., 2026-01-25) |
date_formatted |
Human-readable date (e.g., Sunday, January 25, 2026) |
description |
The description entered when confirming |
confirmed_by |
Username of the person who confirmed |
site_url |
The configured SITE_URL |
Example template:
APPRISE_CONFIRM_TEMPLATE=🎙️ Podcast scheduled: {{ description }} on {{ date_formatted }} (confirmed by {{ confirmed_by }})Example nginx configuration:
server {
listen 443 ssl;
server_name plan.binaergewitter.de;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
# WebSocket support
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}The datefinder flake provides a NixOS module at services.datefinder for declarative deployment. It handles systemd service setup, PostgreSQL integration, database migrations, and static file serving out of the box.
Add the flake input to your NixOS configuration:
# flake.nix
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
datefinder.url = "github:Binaergewitter/datefinder";
};
outputs = { nixpkgs, datefinder, ... }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
datefinder.nixosModules.datefinder
{
services.datefinder = {
enable = true;
settings.allowedHosts = [ "plan.example.com" "localhost" ];
database = {
type = "postgres";
createLocally = true;
};
environmentFile = "/run/secrets/datefinder";
};
}
];
};
};
}When database.type = "postgres" and database.createLocally = true (the default), the module automatically:
- Enables and configures PostgreSQL
- Creates the
datefinderdatabase and user - Grants database ownership to the service user
- Uses unix socket (peer) authentication — no password needed
- Ensures the datefinder service starts after PostgreSQL
The generated DATABASE_URL is postgres:///datefinder with the socket directory set to /run/postgresql.
To connect to an existing PostgreSQL server, disable local creation and provide the connection details:
services.datefinder = {
enable = true;
database = {
type = "postgres";
createLocally = false;
host = "db.example.com";
port = 5432;
name = "datefinder";
user = "datefinder";
};
# Put the password in DATABASE_URL inside the environmentFile:
# DATABASE_URL=postgres://datefinder:[email protected]:5432/datefinder
environmentFile = "/run/secrets/datefinder";
};When host is null (the default), the module uses unix socket authentication via socketDir instead of TCP.
Never put secrets directly in Nix configuration — they end up in the world-readable Nix store. Instead, use environmentFile to point to a file containing secrets:
# /run/secrets/datefinder (or managed by sops-nix/agenix)
SECRET_KEY=your-random-django-secret-key
KEYCLOAK_CLIENT_SECRET=your-keycloak-client-secret
APPRISE_URLS=slack://tokenA/tokenB/tokenC,ntfy://topicThe environmentFile is loaded by systemd as an EnvironmentFile, so it uses KEY=VALUE format (no export, no quotes needed).
| Option | Type | Default | Description |
|---|---|---|---|
enable |
bool | false |
Enable the datefinder service |
package |
package | self.packages.*.default |
The datefinder package to use |
port |
port | 8000 |
Port to listen on |
host |
string | "127.0.0.1" |
Bind address for the server |
stateDir |
string | "/var/lib/datefinder" |
Directory for persistent state |
user |
string | "datefinder" |
System user to run as |
group |
string | "datefinder" |
System group to run as |
environmentFile |
null or path | null |
Path to systemd EnvironmentFile with secrets |
| settings | |||
settings.secretKey |
null or string | null |
Django secret key (prefer environmentFile) |
settings.debug |
bool | false |
Enable Django debug mode |
settings.allowedHosts |
list of string | ["localhost" "127.0.0.1"] |
Django ALLOWED_HOSTS |
settings.siteUrl |
null or string | null |
External URL for reverse proxy setups |
settings.useXForwardedHost |
bool | false |
Trust X-Forwarded-Host header |
settings.trustProxyHeaders |
bool | false |
Trust X-Forwarded-Proto for HTTPS detection |
settings.csrfTrustedOrigins |
list of string | [] |
CSRF trusted origins |
settings.registrationEnabled |
bool | false |
Allow new user registration |
settings.localLoginEnabled |
bool | true |
Allow local username/password login |
settings.redisUrl |
null or string | null |
Redis URL for Django Channels |
settings.icalTimezone |
string | "Europe/Berlin" |
Timezone for iCal exports |
| database | |||
database.type |
enum: sqlite, postgres | "sqlite" |
Database backend |
database.name |
string | "datefinder" |
Database name |
database.user |
string | "datefinder" |
PostgreSQL user |
database.host |
null or string | null |
Database host (null = unix socket) |
database.port |
port | 5432 |
Database port for TCP connections |
database.socketDir |
string | "/run/postgresql" |
PostgreSQL unix socket directory |
database.createLocally |
bool | true |
Auto-configure local PostgreSQL |
| keycloak | |||
keycloak.serverUrl |
null or string | null |
Keycloak server URL |
keycloak.realm |
null or string | null |
Keycloak realm name |
keycloak.clientId |
null or string | null |
Keycloak OIDC client ID |
For real-time updates across multiple browser sessions, configure a local Redis instance as the Django Channels backend:
services.redis.servers.datefinder = {
enable = true;
port = 6379;
};
services.datefinder = {
enable = true;
settings.redisUrl = "redis://localhost:6379";
database = {
type = "postgres";
createLocally = true;
};
};Without Redis, datefinder uses an in-memory channel layer that only works within a single server process — WebSocket updates won't propagate between browser tabs or users. The health endpoint at /.health includes a redis check when redisUrl is configured.
A full example with PostgreSQL, Keycloak OIDC, reverse proxy settings, and nginx:
# flake.nix
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
datefinder.url = "github:Binaergewitter/datefinder";
};
outputs = { nixpkgs, datefinder, ... }: {
nixosConfigurations.podcast-server = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
datefinder.nixosModules.datefinder
({ pkgs, ... }: {
# Datefinder service
services.datefinder = {
enable = true;
host = "127.0.0.1";
port = 8000;
settings = {
allowedHosts = [ "plan.binaergewitter.de" "localhost" ];
siteUrl = "https://plan.binaergewitter.de";
useXForwardedHost = true;
trustProxyHeaders = true;
csrfTrustedOrigins = [ "https://plan.binaergewitter.de" ];
registrationEnabled = false;
localLoginEnabled = false; # Keycloak only
icalTimezone = "Europe/Berlin";
};
database = {
type = "postgres";
createLocally = true;
};
keycloak = {
serverUrl = "https://keycloak.binaergewitter.de/";
realm = "binaergewitter";
clientId = "datefinder";
};
# Secrets: SECRET_KEY, KEYCLOAK_CLIENT_SECRET, APPRISE_URLS
environmentFile = "/run/secrets/datefinder";
};
# nginx reverse proxy with HTTPS
services.nginx = {
enable = true;
recommendedProxySettings = true;
recommendedTlsSettings = true;
virtualHosts."plan.binaergewitter.de" = {
enableACME = true;
forceSSL = true;
locations."/" = {
proxyPass = "http://127.0.0.1:8000";
proxyWebsockets = true;
};
};
};
# ACME (Let's Encrypt) for TLS certificates
security.acme = {
acceptTerms = true;
defaults.email = "[email protected]";
};
networking.firewall.allowedTCPPorts = [ 80 443 ];
})
];
};
};
}The flake includes a NixOS VM test that validates the full stack (PostgreSQL, Redis, migrations, HTTP endpoints, user registration):
nix build .#checks.x86_64-linux.nixos-testThe test verifies:
- PostgreSQL, Redis, and datefinder services start successfully
- Web interface responds (login page, redirects)
- Database migrations create the expected tables
- Static files are served correctly
- iCal export endpoint works
- Health endpoint returns healthy status with database and Redis checks
- User registration and login flow completes
If you started with the default SQLite backend and want to switch to PostgreSQL, use the included management command to transfer all data (users, availability, confirmed dates, reminders):
# Set DATABASE_URL to point to the new PostgreSQL database
export DATABASE_URL=postgres:///datefinder
# Run Django migrations on the new database first
datefinder-manage migrate
# Import data from the old SQLite file
datefinder-manage migrate_from_sqlite --sqlite-path /var/lib/datefinder/db.sqlite3The command is idempotent — re-running it skips rows that already exist in the target database. User passwords and timestamps are preserved.
On NixOS, run the command as the datefinder user with the service environment:
sudo -u datefinder env \
DATABASE_URL=postgres:///datefinder \
DATABASE_SOCKET_DIR=/run/postgresql \
STATEDIR=/var/lib/datefinder \
SECRET_KEY=... \
datefinder-manage migrate_from_sqlite --sqlite-path /path/to/old/db.sqlite3- Navigate to
http://localhost:8000 - You'll be redirected to the login page
- Click "Login with Keycloak" (or use the social login button)
- After authentication, you'll see the calendar
- Click on future dates to toggle your availability:
- First click → Available (green border)
- Second click → Tentatively available (yellow border)
- Third click → Remove marker
- See other users' availability displayed on each date
- Look for the ⭐ indicator on dates where 3+ people are available
- Visit the Confirm page to officially confirm dates with 2+ availabilities
- Confirmed dates appear in blue on the calendar
- Subscribe to the iCal feed at
/calendar/export/calendar.ics
date-finder/
├── datefinder/ # Main Django project
│ ├── settings.py # Django settings with Keycloak config
│ ├── urls.py # Main URL routing
│ ├── asgi.py # ASGI config for WebSockets
│ └── wsgi.py # WSGI config
├── calendar_app/ # Calendar application
│ ├── models.py # Availability model
│ ├── views.py # HTTP views and API endpoints
│ ├── urls.py # App URL routing
│ ├── consumers.py # WebSocket consumer
│ ├── routing.py # WebSocket routing
│ └── admin.py # Admin configuration
├── templates/ # HTML templates
│ └── calendar_app/
│ └── calendar.html # Main calendar template
├── static/ # Static files
├── requirements.txt # Python dependencies
├── manage.py # Django management script
└── .env.example # Environment variables template
MIT