This is a full-stack Django and React application for collaborative job lead collection, structured job evaluation, manual ChatGPT-based analysis, and application prioritization.
Hosted on Azure Container Apps: https://dachapply.livelysea-3461ad21.westeurope.azurecontainerapps.io
Access may require an account.
Add a job link → copy the ChatGPT prompt → import structured JSON → review fit → generate application documents in local mode.
The hosted analysis workflow requires no paid LLM API. The document-generation step shown at the end is explicitly labeled Local mode.
DACHApply is a private job intelligence dashboard for a Software Engineer job search in Austria/Germany. Friends can submit relevant job links using invite codes. The owner reviews leads, generates reusable ChatGPT prompts without paid LLM APIs, imports strict JSON evaluations, and tracks applications, notes, and follow-ups.
backend/: Django, Django REST Framework, SQLite, Django auth/admin, pytest testsfrontend/: React + TypeScript + Tailwind CSS (Vite)- Production: PostgreSQL via
DATABASE_URLis supported; SQLite remains suitable only for local/MVP testing - React production build is served by Django/WhiteNoise
- Friend opens
/public-submit, enters invite code once, and may submit either full job details or just a job URL. After a successful submission, the browser remembers the invite code for next time. - Owner logs in at
/loginand views dashboard. - Owner can paste a list of job links in
/promptsand generate a Bulk Links Prompt for ChatGPT. The returned JSON can create new jobs with details and nested evaluations. - Owner can also select URL-only/incomplete existing jobs in
/promptsand generate a Missing Details Prompt for ChatGPT. - Owner pastes ChatGPT's
job_updatesorjobsJSON into/import; the app updates or creates the correct job records. - Owner selects completed jobs in
/promptsand generates an Evaluation Prompt with embedded candidate profile. - Owner pastes strict evaluation JSON into
/import. - Backend validates required fields and stores
JobEvaluationrecords. - Dashboard/job detail pages show score, priority, recommendation, gaps, status, and next action with clear badges.
This reduces repeated LLM token usage by centralizing reusable prompts and structured imports while avoiding paid LLM API calls.
Django, Django REST Framework, built-in auth/session auth, admin, SQLite, WhiteNoise, pytest.
React, TypeScript, React Router, Tailwind CSS, fetch-based API client with session cookies.
From the project root:
Python dependencies are managed with uv. uv run creates and
updates .venv from uv.lock on demand, so there is no venv to create or activate by hand — and
if .venv is ever lost, the next uv run rebuilds it in seconds.
Before running migrate or any other manage.py command: if the repo-root .env has
DATABASE_URL set to a remote database (production or otherwise), manage.py refuses to start
rather than risk a local command reaching it silently — you'll see
DATABASE_URL came from a .env file.... This is deliberate, not a bug:
- Serving the app locally (TASK-111, owner decision):
runserverandcheck_mailboxare exempt from the guard and use the.envDATABASE_URLdirectly, so the local app and the deployed site always show the same remote data. No flag needed. - Everything else with an empty
DATABASE_URL:manage.pyusesbackend/db.sqlite3; setDB_NAME=<path>for a different sqlite file (see.env.local.example). - Deliberately reaching a remote database (e.g. the
.env.local-neon.exampleworkflow): opt in for that one command, e.g.DACHAPPLY_ALLOW_PROD_DB=1 uv run manage.py migrate. The guard only ever looks at values sourced from a.envfile — aDATABASE_URLyou export in your own shell for a single command is never blocked.
uv sync
cd frontend
npm install
cd ../backend
uv run manage.py migrate
uv run manage.py createcachetable
python manage.py createsuperuser
python manage.py seed_demo
python manage.py runserver 127.0.0.1:8000In a second terminal:
cd frontend
npm run devOpen http://localhost:5173.
Useful pages:
- Owner login:
http://localhost:5173/login - Dashboard:
http://localhost:5173/ - Friend submission:
http://localhost:5173/public-submit - Prompt generator:
http://localhost:5173/prompts - Import evaluation JSON:
http://localhost:5173/import - Data export/import:
http://localhost:5173/export - Django admin:
http://127.0.0.1:8000/admin/
Seed invite code:
FRIEND-DEMO
For a quick non-interactive local demo user, run the seed command below and log in with:
demo@dachapply.com / DemoApply2026!
For same-origin production-style local serving through Django:
cd frontend
npm install
npm run build
cd ../backend
python manage.py collectstatic --noinput
python manage.py migrate
python manage.py createcachetable
python manage.py runserver 127.0.0.1:8000Open http://127.0.0.1:8000.
Off by default: with nothing set, the backend binds 127.0.0.1:8000 and only this machine can
reach it. To let a phone or a second laptop on the same home network open the board, put one line
in the repo-root .env (it survives a reboot, and both the launcher and Django read it):
DACHAPPLY_LAN_ACCESS=1
That single flag moves the three things that have to move together:
scripts/dachapply-local-runtime.cmdbinds0.0.0.0:8000instead of127.0.0.1:8000;- it also runs
npm run build, so Django serves the built app itself athttp://<this-host>:8000/. Without the build,/redirects toFRONTEND_URL(http://localhost:5173) and a phone resolves thatlocalhostto itself — the one failure that survives a correct bind and a correctALLOWED_HOSTS. Serving from:8000also keeps the login POST same-origin, so no CSRF or CORS rule is relaxed; ALLOWED_HOSTSandCSRF_TRUSTED_ORIGINSgain this host's own private IPv4 addresses (detected at startup withsocket.gethostbyname_ex) and the names this host answers to (hostname, its FQDN, and<hostname>.local), plus thehttp://<address-or-name>:8000origin of each. Never*, never a public address, never a.localwildcard — a name that is not this host's is answered with400 Bad Request. If detection is wrong (VPN, second NIC, a name only your router knows), list them yourself; addresses and names share the one variable:DACHAPPLY_LAN_HOSTS=192.168.8.130,caren.local.
Then open either form on the other device:
http://<this-host-lan-ip>:8000/—ipconfigprints the address;http://<this-host-name>:8000/—hostnameprints the name.
Three are trusted, and which one a given device can resolve is decided on the device, not by
anything in this repository. On the machine this was built on (hostname → Caren) they are:
| Form | Example | Usually resolved by |
|---|---|---|
| bare hostname | http://caren:8000/ |
Windows, over NetBIOS/LLMNR. Not iOS, and not reliably Android |
.local (mDNS) |
http://caren.local:8000/ |
iPhone/iPad and macOS, Windows 10+, Linux with Avahi. Android has historically been uneven |
| FQDN | http://caren.lan:8000/ |
only devices your router hands that DNS suffix to |
Case does not matter (http://CAREN.local:8000/ works), and a trailing dot is fine.
If a phone cannot open any of them, no server change will fix it. Name resolution happens on
the device: if it cannot turn caren.local into 192.168.8.130, the request never reaches this
machine, so there is nothing here to widen. Android's mDNS support has historically been uneven,
and some networks (guest Wi-Fi, "client isolation", a mesh extender) block mDNS outright. What
works instead, in order of effort:
- use the IP address —
http://192.168.8.130:8000/still works and is unaffected; - give the router a static DHCP lease for this machine plus a DNS/hosts entry for its name, so the name resolves for every device on the LAN without mDNS;
- on a second laptop, add the line
192.168.8.130 carento its ownhostsfile (C:\Windows\System32\drivers\etc\hosts, or/etc/hosts).
If you reach the app under a name this repository did not derive (a router-supplied alias, for
instance), add it to DACHAPPLY_LAN_HOSTS as above — otherwise Django refuses the request with
400 Bad Request and says which host header it saw.
Inbound 8000 is blocked by default, so this is required and is a one-time step. Run it in an
elevated cmd (Run as administrator). The rule is persistent — it survives a reboot:
netsh advfirewall firewall add rule name="DACHApply LAN 8000" dir=in action=allow protocol=TCP localport=8000 profile=private remoteip=localsubnet
profile=private keeps it off public Wi-Fi networks and remoteip=localsubnet limits it to the
local subnet. To remove it again (also elevated):
netsh advfirewall firewall delete rule name="DACHApply LAN 8000"
The local runtime runs with DEBUG=True against the production database, so while LAN access is
on, the real board, the real mailbox data and the real CV workspace are reachable by anything on the
home network. The only thing in front of them is the Django login. That is why it is opt-in, why the
firewall rule is scoped to the private profile and the local subnet, and why it is worth turning
back off (remove the .env line, delete the rule) when you no longer need it.
Logged-in users can open /export and export jobs/application data, dashboard preferences, or both. Each export option supports JSON, CSV, and XLSX. Preferences include theme, column visibility, skill overrides, and work-mode badge colors. Exports do not include passwords, sessions, tokens, permissions, admin logs, invite codes, or secrets.
To restore data, open /export and drop or click-to-select a .json, .csv, or .xlsx file. Import automatically detects whether the file contains jobs, preferences, or both. Job imports run server-side in a database transaction and imported records are assigned to the currently logged-in user; uploaded files are processed immediately and are not stored permanently.
API endpoints are also available for authenticated users:
GET /api/export/POST /api/import/
cd backend
python manage.py seed_demoCreates one active invite code (FRIEND-DEMO) plus the public demo user (demo@dachapply.com / DemoApply2026!) with rich sample data: multiple active interviews, friend referrals, evaluations, notes, follow-ups, archived/skipped jobs, and a pending friend request. Admin setup instruction: python manage.py createsuperuser.
Backend tests:
cd backend
uv run pytest -qTests run against an in-memory SQLite database via config/settings_test.py; they never touch the
production Neon instance or the real CV workspace.
Frontend production build check:
cd frontend
npm install
npm run buildManual MVP verification checklist:
- Start backend and frontend using the local setup commands above.
- Go to
/public-submit, use invite codeFRIEND-DEMO, and submit a job with just a URL if desired. - Invalid invite code should show a validation error.
- Log in at
/loginas the owner. - Confirm the dashboard loads jobs from the backend.
- Go to
/prompts, select the URL-only job, and clickGenerate Missing Details Prompt. - Paste ChatGPT
job_updatesJSON into/import; confirm company/title/details update. - Open a job detail page from the dashboard.
- Go to
/prompts, select the submitted job, generate and copy the evaluation prompt. - Paste valid ChatGPT-style evaluation JSON into
/importand import it. - Confirm the dashboard/job detail show fit score, priority, recommendation, gaps, and next action.
- Confirm
/exportdownloads JSON, CSV, and Markdown exports.
POST /api/public/submit/GET/POST /api/jobs/GET/PATCH/DELETE /api/jobs/{id}/POST /api/prompts/generate/for evaluation promptsPOST /api/prompts/enrich/for missing-detail promptsPOST /api/prompts/bulk-links/for a list of links that should become jobs plus evaluationsPOST /api/evaluations/import/for evaluation JSON,job_updatesJSON, or bulkjobsJSONGET /api/stats/GET /api/export/jobs.json,.csv,/api/export/chatgpt-brief.mdDELETE /api/auth/account/to delete the current account and owned dashboard data
DACHApply stores the data needed to provide a private job-search dashboard:
- Account username/email and Django password hash.
- Candidate profile, scoring rules, optional structured profile fields, and prompt templates.
- Job leads, URLs, descriptions, salary/language/work-mode fields, application statuses, status dates, follow-up dates, and source metadata.
- Imported/generated evaluations, fit scores, recommendation fields, skills, gaps, notes, and follow-ups.
- Friend-submission relationships and submitted-by metadata when enabled.
Exports are available from the Data page and include the current user's profile/jobs/evaluations/notes/follow-ups plus optional frontend preferences. Invite codes, passwords, sessions, permissions, and other credentials are intentionally excluded from exports.
Users can delete their account from the Data page. Account deletion removes the user, profile, and owned dashboard jobs; evaluations, notes, and follow-ups attached to those jobs are deleted by cascade. Any remaining references created by Django relations are anonymized via nullable user fields.
DACHApply is configured through environment variables. In production (DEBUG=False) the app refuses to start if critical values are missing.
Required production variables:
SECRET_KEY=strong-unique-secret
DEBUG=False
ALLOWED_HOSTS=your-domain.example.com
FRONTEND_URL=https://your-domain.example.com
CSRF_TRUSTED_ORIGINS=https://your-domain.example.com
DATABASE_URL=postgresql://USER:PASSWORD@HOST:5432/DBNAME
DEFAULT_FROM_EMAIL=DACHApply <noreply@your-domain.example.com>
EMAIL_HOST=smtp.example.com
Recommended production variables:
CORS_ALLOWED_ORIGINS=https://your-domain.example.com
SECURE_SSL_REDIRECT=True
USE_X_FORWARDED_PROTO=True
SESSION_COOKIE_SECURE=True
CSRF_COOKIE_SECURE=True
DB_CONN_MAX_AGE=600
DB_SSL_REQUIRE=True
EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
EMAIL_PORT=587
EMAIL_USE_TLS=True
EMAIL_HOST_USER=smtp-user
EMAIL_HOST_PASSWORD=smtp-password
Abuse protection is enabled with Django REST Framework cache-backed throttles on login, registration, password reset requests, public submissions, and import endpoints. Optional rate-limit overrides:
RATE_LIMIT_LOGIN_IP=10/minute
RATE_LIMIT_LOGIN_ACCOUNT=5/minute
RATE_LIMIT_REGISTER_IP=5/hour
RATE_LIMIT_PASSWORD_RESET_IP=5/hour
RATE_LIMIT_PASSWORD_RESET_EMAIL=5/hour
RATE_LIMIT_PUBLIC_SUBMIT_IP=20/hour
RATE_LIMIT_IMPORT_USER=60/hour
Optional hardening after HTTPS is verified:
SECURE_HSTS_SECONDS=31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS=True
SECURE_HSTS_PRELOAD=True
See .env.example for a full template. For email environment separation, see .env.local.example, .env.azure.example, and docs/email-setup.md. Additional beta launch checklists are in docs/production-readiness.md and docs/backup-restore.md.
Before public deployment:
- Set
DEBUG=False. - Generate and set a strong
SECRET_KEY; never usedev-only-change-mepublicly. - Set
ALLOWED_HOSTSto the exact production hostnames. - Set
FRONTEND_URLandCSRF_TRUSTED_ORIGINSwithhttps://origins. - Use PostgreSQL via
DATABASE_URL; do not rely on SQLite for public multi-user production. - Set SMTP email variables and test password reset delivery.
- Keep
SESSION_COOKIE_SECURE=TrueandCSRF_COOKIE_SECURE=True. - Enable
SECURE_SSL_REDIRECT=TrueandUSE_X_FORWARDED_PROTO=Truebehind a TLS-terminating proxy/load balancer. - Run migrations and collect static files.
- Create a non-shared admin account and use strong passwords.
- Verify login, CSRF-protected POSTs, export/import, prompt generation, and password reset on the deployed domain.
- After HTTPS is stable, enable HSTS.
- Configure database/backups outside the app.
- Review logs for
DisallowedHost, CSRF, email, and database connection errors.
The Docker image is built by GitHub Actions and pushed to the private GitHub Container Registry package ghcr.io/ermischo/dachapply with both latest and commit-SHA tags. Keep this GHCR package private; do not make the image public.
Azure Container Apps should pull ghcr.io/ermischo/dachapply:latest from GHCR using registry credentials backed by a GitHub PAT that has read:packages permission. Application secrets such as DATABASE_URL and SECRET_KEY must remain Azure Container Apps secrets/environment references and must not be committed to the repository.
Delete the old Azure Container Registry only after Azure Container Apps has successfully pulled and is running the GHCR image.
The App Service path and its deploy-azure.yml workflow were retired on 2026-08-15, along with
requirements.txt, which existed only to feed them. Its last run was 2026-06-05; Azure Container
Apps has been the deploy path since. Both files remain in git history if the path is ever revived —
note that reviving it needs a pip-installable dependency list, since App Service's Oryx builder
looks for requirements.txt and this repo now resolves solely from uv.lock:
uv export --no-dev --no-hashes --no-emit-project --format requirements-txt -o requirements.txtUse PostgreSQL for public deployment. SQLite can still be used for local testing or throwaway demos only.
Minimum Azure environment variables:
SECRET_KEYDEBUG=FalseALLOWED_HOSTS=your-app.azurewebsites.netFRONTEND_URL=https://your-app.azurewebsites.netCSRF_TRUSTED_ORIGINS=https://your-app.azurewebsites.netDATABASE_URL=postgresql://USER:PASSWORD@HOST:5432/DBNAMESECURE_SSL_REDIRECT=TrueUSE_X_FORWARDED_PROTO=True- SMTP email variables listed above
- Use Free F1 App Service for testing.
- For private throwaway demos only, SQLite can reduce cost; for any public deployment use PostgreSQL via
DATABASE_URL. - Disable always-on features not available/free.
- Avoid Application Insights paid ingestion until needed.
- Export backups manually from the app/admin.
Add screenshots here after running locally:
- Dashboard: searchable/filterable job table with status, priority, and recommendation badges.
- Friend submission form: simple invite-code + URL-first submission page.
- Prompt generator: bulk links area, existing-job selector, selected preview, generated prompt, copy button.
- Import evaluation page: JSON textarea, clear validation errors, success summary listing created/updated jobs.
- Job detail: clean metadata header, badges, fit score card, match/gap cards, next action, raw description, notes.
- Django CSRF/session authentication enabled.
- Owner APIs require authentication by default.
- Public submission requires active invite code and includes a honeypot spam field. The frontend stores the invite code in that browser's localStorage after a successful submission, so trusted friends do not need to retype it every time.
- URLs are validated by serializers/model fields. Common pasted forms such as
https-www.karriere.at-jobs-7794074are normalized tohttps://www.karriere.at/jobs/7794074. - Secrets are read from environment variables; use
.env.exampleas a template.
- Azure PostgreSQL
- Azure Blob Storage for backups/exports
- GitHub Actions deployment
- Application Insights logging
- Browser extension for saving jobs
- Optional LLM API mode
- Email reminders
- CV version management
- Recruiter message generator
- Interview preparation module
