Note
GitHub users - this project is maintained over on GitLab.
Internal tool for tracking client retainer hours, time entries, and overage billing.
Clients are put on a support retainer with a monthly hour allocation. Employees log time against the client's current term; the app tracks usage in real time, flags clients running low or over their allocation, and handles term renewal (converting unused hours to development time, or migrating them forward) and overage billing.
Looking to work on the codebase itself rather than just run the app? See CONTRIBUTING.md.
- Django 6: server-rendered templates, class-based views
- SQLite: single-file database, no server needed
- mozilla-django-oidc: OIDC + PKCE authentication (IdP)
- Tailwind CSS v4 via
pnpm: one package, no framework - Whitenoise: static file serving in production
- uv - Python dependency management
Opening this repo in the provided devcontainer (VS Code / any
devcontainers-compatible tool) runs the full bootstrap automatically:
installs uv, syncs Python dependencies, installs pnpm packages, installs
the pre-commit hooks, and generates .env / settings.ini from their
.example templates.
The devcontainer bootstrap already runs manage.py migrate, which - on
first run - also generates a random SECRET_KEY and writes it into .env
for you (see core/utils/env.py). Nothing to do there manually.
After the container finishes building:
# Build the CSS (one-time; use `pnpm dev` in a separate terminal while developing)
pnpm build
# Seed core data (an admin account)
python manage.py seed
# Run
python manage.py runserver# 1. Python environment
pipx install uv
uv sync --all-extras
source .venv/bin/activate
# 2. Secrets and business config (both gitignored except the .example files)
./scripts/setup_settings.sh
# Copies settings.example.ini -> settings.ini and .env.example -> .env,
# installs the pre-commit hooks, and runs migrations - which, on first run,
# also generates a random SECRET_KEY and writes it into .env for you.
# 3. CSS (requires Node + pnpm)
corepack enable # ships with Node 16.9+
pnpm install
pnpm build # compiles static/build/css/final.css
# During development, watch mode in a separate terminal:
pnpm dev
# 4. Seed core data (an admin account) - add --full for fake demo data
python manage.py seed
# 5. Run
python manage.py runserverDefault admin login (Django admin only): admin@example.com / changeme123
- see Authentication below for why this is admin-only.
| Variable | Required | Description |
|---|---|---|
SECRET_KEY |
yes | Django secret key - auto-generated into .env on first run if still set to the placeholder your-secret-key-here |
DEBUG |
True in dev, False in prod (default: False) |
|
ALLOWED_HOSTS |
Comma-separated hostnames (default: localhost,127.0.0.1) |
|
STATIC_URL |
Static file URL prefix (default: static/) |
|
DEFAULT_FROM_EMAIL |
From-address for outgoing email (default: local@localhost) |
DB_NAME also ships in .env.example but isn't currently read anywhere -
the SQLite filename is fixed. Don't rely on it.
[branding]
APP_NAME = RetainerTracker # shown in UI and browser title
[hours]
TERM_MONTHS = 12 # contract term length
DEV_CONVERSION_RATIO = 2.0 # 1 dev hour costs this many support hours
MAX_MIGRATE_HOURS = 6 # max support hours migratable without conversion
LOW_HOURS_THRESHOLD = 75 # % used at which "low" warning appears
[auth]
OIDC_ALLOWED_DOMAINS = # comma-separated; blank = any authenticated userAll [hours] values feed directly into tracker/hours.py via the
HoursConfig dataclass (resolved through core.app_settings.AppConfig) - no
other files need touching if you change these.
-
In IdP, create a Web application:
- Authentication method: PKCE (no client secret needed for a public client)
- Allowed redirect URI:
https://yourdomain.com/oidc/callback/(exact match required - scheme, host, port, and trailing slash all matter; this is the single most common setup mistake)
"Sign out" in this app only ends the local Django session - it does not currently perform an RP-initiated logout at IdP, so a post-logout redirect URI isn't needed yet. (
mozilla-django-oidcdoes expose its own/oidc/logout/view that would use one, but nothing in the app links to it today.) -
Fill in the
OIDC_*variables under[auth]insettings.ini:[auth] OIDC_ALLOWED_DOMAINS = yourcompany.io, contractor.com OIDC_CLIENT_ID = <from IdP> OIDC_CLIENT_SECRET = ; leave blank for a PKCE-only public client OIDC_ISSUER = https://your-org.IdP.cloud OIDC_LABEL = IdP Name ; shown on the "Sign in with ..." button
OIDC is enabled automatically as soon as
OIDC_ISSUERis non-blank - no separate feature flag. -
Restart - the login page will show the "Sign in with
{OIDC_LABEL}" button.
Users are auto-provisioned on first login if their email domain is listed in
OIDC_ALLOWED_DOMAINS (blank = any authenticated domain is accepted, not
recommended for production). The provisioned account has no Django password
and can only authenticate via OIDC.
The very first employee ever created in the system - counting however
it happened, not just OIDC - is automatically promoted to admin
(role=ADMIN, is_staff, is_superuser) the moment they log in via OIDC.
This means:
- If you go straight to OIDC without ever running
python manage.py seed, the first person to sign in becomes an admin automatically - a fresh deployment never gets stuck with zero admins. - If you already ran
seed(which creates theadmin@example.comaccount), that account is the first employee, so this auto-promotion won't re-trigger for the first OIDC login - you already have an admin.
python manage.py elevate_admin # lists employees, prompts for a choice
python manage.py elevate_admin --email someone@yourcompany.io # skip the promptSets role=ADMIN, is_staff=True, and is_superuser=True on the chosen
employee - the same flags the first-user auto-promotion above sets. Useful
any time you need to promote someone who wasn't first through the door.
Django admin (/admin/) uses standard username/password, but only
superusers can log in this way - see
core/backends/SuperuserOnlyModelBackend.py. Everyone else must use OIDC.
The seeded admin@example.com account is intended for initial setup only -
change its password immediately or restrict access to the admin URL at the
web server level.
HoursConfig is a plain dataclass. You can exercise the hours logic without
a Django process, given an explicit config:
from tracker.hours import HoursConfig, calculate_term_hours
cfg = HoursConfig(term_months=12, dev_conversion_ratio=2.0)
summary = calculate_term_hours(term, entries, config=cfg)- Client accrues
monthly_hourssupport hours per calendar month (current partial month included). - Migrated support hours from a previous term are an opening balance on top.
- Development hours (from a previous term's conversion) are a separate pool.
| Option | What happens |
|---|---|
| Convert to dev | remaining support ÷ dev_conversion_ratio = development hours |
| Migrate support | up to max_migrate_hours carry forward; excess is forfeited |
Overages are computed in real time on the client detail page. Use the "Record Billing" form to mark hours as invoiced. Unbilled = computed overage
- total billed.
python manage.py seed # core data only - creates the admin account
python manage.py seed --full # + a fake dataset for development/demos
python manage.py seed --full --employees 5 --clients 10 --entries 50The --full seeder refuses to run when DEBUG=False - it's dev/demo only.
Runs with gunicorn behind nginx via Docker Compose - see docs/DEPLOYMENT.md for the full guide (build/run, config, TLS, database backups).
Without Docker, the same idea applies manually: pnpm build +
python manage.py collectstatic, run gunicorn core.wsgi:application
behind a reverse proxy, and set DEBUG=False / a real ALLOWED_HOSTS /
SECRET_KEY in .env.