Live: talentmint.org
Stack: Django · PostgreSQL · Bootstrap 5 · HTMX · CKEditor 5 · Select2 · Cloudflare Turnstile · Brevo SMTP · Cloudflare R2 (S3‑compatible) · Heroku
TL;DR
I built TalentMint, a full‑stack recruiting platform where admins publish jobs, assign recruiters, collect applications, and share openings directly to LinkedIn Pages — all with strong security, solid query performance, and a frictionless UX. This post is a deep dive into the design decisions, data model, dashboards, LinkedIn integration, spam/abuse protections, and the production deployment.
1. Project Goals
Recruiting teams often stitch together job boards, spreadsheets, and private messages. I wanted a single place where admins could create jobs, delegate ownership, and see activity at a glance while recruiters worked from a purpose‑built dashboard. Just as important, I wanted applications to arrive clean: no bot submissions, no broken resumes, and no duplicate candidates across the same job. Finally, distribution had to be effortless, so sharing a posting to LinkedIn needed to be one click, reliable, and auditable.
-
Centralise hiring workflows for a small recruiting team (admins + recruiters).
-
Effortless job distribution: post to company LinkedIn Page with a single click.
-
Fast screening: recruiter‑friendly dashboards with per‑job/applicant rollups.
-
Zero‑spam intake: strong bot protection, rate limiting, safe uploads.
-
Production hardening: HTTPS everywhere, HSTS, secure cookies, and a clean deployment story (Heroku + R2).
2. Architecture at a Glance
TalentMint Portal is a conventional Django project split into five apps—accounts, jobs, dashboard, core, and blog—each with a tightly scoped responsibility. The application serves public pages for browsing jobs and submitting applications, authenticated dashboards for admins and recruiters, and a handful of integration endpoints. PostgreSQL stores relational data; WhiteNoise serves fingerprinted static assets; Cloudflare R2 (through django‑storages) holds user‑uploaded media such as resumes and blog images. Emails are delivered via Brevo’s SMTP relay. Heroku hosts the app and Postgres, with TLS enforced end‑to‑end and HSTS enabled in production.
The security story begins at the edge but continues through the stack. Public forms are guarded with Cloudflare Turnstile and a honeypot field; server‑side validation checks the Turnstile token against the client’s real IP (considering proxy headers) and rate limits enforce sensible daily caps. File uploads are sanitized, constrained by size and extension, and routed to predictable paths. Within Django, CSRF protection is standard, cookies are secure, and a safe‑referer helper prevents open‑redirect tricks on error pages.
Browser (Candidates/Admins/Recruiters)
└── Django (talentmint)
├─ Apps: accounts / jobs / blog / core / dashboard
├─ Auth: custom User (email‑only), roles, verified‑email gate
├─ Security: Turnstile, honeypot, ratelimits, CSRF, HSTS
├─ Integrations: LinkedIn (OAuth + Page posting), SMTP email
├─ Storage: WhiteNoise (static), R2 S3 (media)
└─ DB: PostgreSQL (Heroku Postgres)
Key Django apps:
-
accounts: custom
User
, email verification flow, role/permission mixins, LinkedIn OAuth token storage. -
jobs: core ATS features: Job, Skills (taggable), JobApplication, LinkedIn share logs, round‑robin auto‑assignment.
-
dashboard: two role‑specific dashboards (Admin / Recruiter) with annotated counts and prefetching.
-
blog: lightweight CMS (CKEditor + tags) for content marketing / SEO.
-
core: brochure pages, contact form, policies, sitemaps.
3. Data Model (Selected Entities)
The custom User removes usernames entirely; email is the sole credential and is normalized to lowercase. A simple role field (tmadmin or recruiter) backs class‑based mixins that gate views. Users must verify their email before they can reach sensitive pages. Groups mirror roles so the authorization model can grow without rework.
Jobs capture the information candidates expect—title, salary, location, a rich CKEditor description—and connect to two key relationships: a many‑to‑many list of assigned recruiters and a taggable set of Skills (created on the fly via Select2). JobApplication ties a candidate to a specific job, enforces unique applications by email per job, stores a resume with validation and a strict size limit, and records feedback with authorship and timestamps. A pair of small “cursor” models keep round‑robin assignment state resilient across restarts.
For distribution, LinkedInAccount keeps OAuth tokens per admin, and LinkedInJobShare records each successful share with the canonical URN and the raw API response. Persisting that response makes support and auditing straightforward: if LinkedIn behaved oddly, I have the headers and payload to explain or retry.
Users & Roles
-
User: email‑as‑username,
role
in{tadmin, recruiter}
;is_verified
gate. -
Groups mapped from role (e.g.,
TAdmin
,Recruiter
) for future granular perms.
-
**accounts.LinkedInAccount**
: per‑user access/refresh tokens + expiry. -
**jobs.LinkedInJobShare**
: one row per successful share with the canonical LinkedIn entity URN and raw API response for auditability.
Hiring
-
**jobs.Job**
: assigned recruiters (M2M), skills (M2M), rich description, salary, type, active flag, counts & timestamps. -
**jobs.JobApplication**
: per‑job unique by email, resume upload, recruiter assignment, feedback with authorship + audit timestamps. -
**jobs.Skill**
: canonical tags for discoverability.
Content & Contact
-
**blog.Blog**
/**blog.Tag**
: SEO‑friendly slugs, rich content, image media, tag cloud. -
**core.Contact**
: inbound enquiries with anti‑spam protections.
4. Authentication & Authorization
Email‑only sign‑in reduces confusion and prevents the classic “I have two accounts because of case differences” problem by lower‑casing and validating emails on save and in clean(). New users receive a tokenized verification link; until they confirm, the VerifiedLoginMixin quietly redirects them away from privileged pages.
Authorization is intentionally simple: AdminRequiredMixin gates all administrative actions such as creating jobs, assigning recruiters, and bulk transfers; RecruiterRequiredMixin grants access to a dashboard optimized for day‑to‑day candidate work. These mixins also optionally allow superusers, which keeps emergency access clear and predictable.
Custom User with Email Login
-
Removed
username
entirely; email is the only identifier. -
Case‑insensitive uniqueness enforced at the model layer (and lower‑cased on save).
-
Admins provisioned via
createsuperuser
; defaultrole=recruiter
can be overridden.
Verified‑Email Gate
- New users must verify via a signed token link. Until then, sensitive views are gated by
VerifiedLoginMixin
(redirects to a “verify your email” experience).
Role‑based Access
-
RoleRequiredMixin
+ concrete mixins:**AdminRequiredMixin**
and**RecruiterRequiredMixin**
. -
Admins manage jobs, recruiters, assignments, bulk moves, and deletions.
-
Recruiters operate within jobs assigned to them (plus convenience flows like quick‑adding candidates).
5. Job Lifecycle & Recruiter Workflows
An admin creates a job using a familiar rich‑text editor and assigns one or more recruiters. Skills can be selected from existing tags or invented on the spot; the form caps the total to keep the taxonomy practical. The public job page is simple and fast, but the application form does a lot behind the scenes. It verifies the Turnstile token against Cloudflare’s endpoint, checks the honeypot, validates file types and size, and normalizes the filename. If a recruiter is logged in and taking an intake call, a lighter “quick‑add” form skips the anti‑spam ceremony without compromising safety because it’s behind authentication.
When an application is saved for the first time and no recruiter is already set, a post‑save signal selects the next recruiter in a per‑job round‑robin and writes that assigned_recruiters_id in a single update. A tiny cache‑backed cursor avoids race conditions and survives restarts. Feedback is a first‑writer‑wins model: the first recruiter to leave feedback becomes the owner of that text unless an admin needs to intervene. The feedback modal submits over HTMX; the server returns only the updated table cell and a trigger to close the dialog, so the page never has to reload.
Create & Assign
-
Admins create a Job with rich text fields (CKEditor 5) and assign recruiters (M2M).
-
Skills are tagged with Select2, supporting on‑the‑fly creation and a hard cap to prevent tag spam.
Candidate Intake (Two Paths)
-
Public application (
/jobs/apply/<job_id>/
): full form with Turnstile, honeypot, and rate‑limits. -
Recruiter quick‑add: a lean form shown to logged‑in recruiters for speed in intake calls.
Automatic Recruiter Assignment
-
On first creation of a
JobApplication
, a signal assigns the application to the next recruiter in a round‑robin for that job. -
A small cursor model + cache invalidation ensures fairness and resilience across restarts.
-
Admins can also bulk transfer jobs/applicants between recruiters.
Feedback with Ownership
-
Any recruiter can write first feedback. Afterwards, only the author (or an admin) may edit.
-
The modal save path is HTMX‑powered: the server returns just the updated cell and triggers a small JS event to close the modal — fast and delightful.
6. Dashboards that Stay Fast
The recruiter dashboard shows only the jobs assigned to the current user yet counts all applicants on those jobs for an honest view of workload. Query performance relies on a few golden rules: select_related for single‑valued relationships, prefetch_related with to_attr for collections that templates will iterate over, and annotated counts computed once and reused. Optional filters such as search, “recent”, and “most applicants” are applied at the queryset level so pagination remains consistent.
The admin dashboard is a global cockpit. It presents jobs with application totals, LinkedIn share counts, and the timestamp of the latest share obtained via a Subquery rather than a heavy join. Three tabs—all, active, and inactive—share the same base queryset to avoid recomputing annotations. Recruiter cards display counts and prefetch a short list of recent jobs and applicants so the template can render context without extra queries. A separate applications tab lists the newest candidates across the site.
Recruiter Dashboard
-
Shows only jobs assigned to me, but counts all applicants on those jobs for a full picture.
-
Heavy use of
select_related
,prefetch_related(to_attr=...)
, andannotate
keeps queries tight. -
Filters include quick search, “recent” view, and “most applicants” sort.
Admin Dashboard
-
Global view across jobs, applications, LinkedIn share counts, and message inbox.
-
Uses Subquery to surface
last_share
timestamps per job. -
Paginates three job tabs (all/active/inactive) plus a global applications tab — each powered by the same filtered base queryset for consistency.
-
Recruiter cards display per‑recruiter counts and prefetch recent jobs/applicants for context.
Takeaway: the pattern is compute once, paginate many. Build one annotated queryset, then slice it multiple ways for tabs without repeating expensive work.
7. LinkedIn Page Posting — Robust by Design
Getting a valid post onto a LinkedIn Page is surprisingly nuanced. The OAuth flow stores state and an expiry in the session to prevent CSRF and stale callbacks, then exchanges the authorization code for access and (where present) refresh tokens. A lightweight validity probe hits /v2/userinfo using only the openid scope; if that fails or the stored expiry has passed, the UI prompts the admin to reconnect.
The share helper prefers the modern /rest/posts endpoint because it supports proper article cards and CTA buttons. It starts by resolving the Organization URN for which the user is an approved ADMIN (or honoring a pinned URN from settings), then normalizes the destination URL: LinkedIn accepts only public HTTPS links, so the code safely upgrades http:// to https:// when possible and rejects private or loopback hosts. Commentary is constructed to fit the 1,300‑character limit while preserving readability: title, then a trimmed description, then the URL. If a CTA is requested, the helper chooses a landing page that matches the article host where possible to avoid validation errors.
If /rest/posts returns a 422, the code retries without the CTA; if that fails or the URL isn’t public, it falls back to /v2/shares and finally to a text‑only post that inlines the link. The created entity URN is extracted from headers or body and fetched immediately so the code can verify lifecycleState=PUBLISHED and confirm public visibility. Every attempt is logged, and each success creates a LinkedInJobShare row so the dashboard can show share counts and timestamps.
The platform lets admins share a live Job to the company LinkedIn Page with a real article card and an optional CTA button.
OAuth Handshake & Token Hygiene
-
The
linkedin_auth → linkedin_callback
flow exchanges a code for access/refresh tokens. -
State + expiry are stored server‑side to defend against CSRF and stale callbacks.
-
A fast validity probe hits
/v2/userinfo
(needs onlyopenid
) to decide when to refresh/re‑auth.
Building a Valid Post Every Time
LinkedIn’s APIs have multiple surfaces (legacy /v2
and modern /rest
) with finicky headers. The share helper:
-
Resolves the Organization URN where the user is an ADMIN (or uses a pinned URN in settings).
-
Normalises URLs: only public HTTPS links are allowed;
http://
is auto‑upgraded when safe. -
Composes commentary that respects LinkedIn’s 1,300‑char limit:
``` Title
Description (trimmed with ellipsis)
URL (and optional CTA link if different) ```
-
Prefers
**/rest/posts**
with CTA support. On 422, it retries without CTA. -
If that still fails, it falls back to
**/v2/shares**
(link card) and finally text‑only if the URL isn’t public. -
Extracts the created entity URN reliably (header
x-restli-id
,Location
, or body), then fetches the entity to verifylifecycleState=PUBLISHED
and visibility.
All of this is wrapped in clear server logs and a persisted LinkedInJobShare
row so we can audit what was sent/created.
8. Security & Abuse Defences
Public endpoints are the most attractive target, so the contact and application forms are rate‑limited (e.g., ten applications per IP per day, two contact submissions per IP per day) and protected with Turnstile. Server‑side Turnstile verification uses the real client IP derived from proxy headers or REMOTE_ADDR and refuses the submission if the verification fails. File uploads accept only PDF, DOC, or DOCX and cap size at 5 MB. Filenames are normalized to lower case, path traversal is removed, and the final storage path includes the job id and the slugified first name for predictability without leaking sensitive details.
On the platform side, production enables strict transport security, secure cookies, and CSRF‑trusted origins for the public domains. A small utility that sanitizes the HTTP referrer protects the 404 page from becoming an open redirect sink. Admin tools that move or delete data include guardrails—no deleting yourself, no transferring to the same user—and run inside transactions.
-
HTTPS‑only in production with HSTS, secure cookies, and
SECURE_PROXY_SSL_HEADER
for Heroku. -
CSRF‑trusted origins and a safe‑referer helper for 404s to prevent open‑redirect shenanigans.
-
Form bot defences:
-
Cloudflare Turnstile validation (server‑side with real client IP).
-
A subtle honeypot field.
-
Rate limiting sensitive endpoints (e.g.,
10/day
for apply,2/day
for contact) keyed by IP.
-
-
File uploads: extension allow‑list (
pdf/doc/docx
), size cap (5MB), filename sanitisation (lower‑cased, traversal removed), and a stable upload path. -
LinkedIn safety: only post public HTTPS links, auto‑coerce safe hosts, and verify the created entity.
9. Emails That Reach Inboxes
Emails use Django’s EmailMultiAlternatives to send both text and HTML versions, which helps with deliverability and accessibility. New users receive a verification email with a signed token, and public enquiries get a polite, branded acknowledgment. The blog app supports long‑form posts with CKEditor and a simple tag model, and the site publishes sitemaps for both static pages and job listings. Clean slugs, descriptive titles, and predictable URLs make social sharing look good and help search engines crawl efficiently.
-
Brevo (SMTP) delivers multi‑part emails via
EmailMultiAlternatives
. -
Email verification for new users uses signed tokens and a dedicated view.
-
Contact acknowledgements are themed HTML with text fallbacks.
10. Content & SEO
-
Blog: CKEditor 5 for authoring, tag relationships for discovery, and slug‑based routing for clean URLs.
-
Sitemaps for static pages and job listings.
-
Thoughtful titles in templates and structured URLs help search engines and social cards.
11. Performance Notes
A handful of patterns keep pages snappy as data grows. Annotated counts avoid per‑row subqueries in templates. select_related and prefetch_related put the right data in memory before rendering, and to_attr prevents the ORM from repeating work. Subqueries surface “latest share time” without materializing a big join. Pagination is consistent across tabs by computing the filtered, annotated queryset once and slicing it into views. The round‑robin allocator keeps its cursor in the database and caches the next index; a signal writes the assignment in a single, idempotent update to avoid races.
-
Annotated counts (
total_applications
,share_count
) and**distinct=True**
where needed. -
**select_related**
for 1‑to‑1/1‑to‑many foreign keys,**prefetch_related**
withto_attr
to avoid repeat queries in templates. -
Subqueries to expose the latest share timestamp without a join explosion.
-
Paginator helpers keep list pages smooth under load.
12. Deployment: Heroku + R2, Zero‑Fuss Ops
Production and development share a base settings module with environment‑specific overrides. In development, .env feeds secrets; in production, Heroku Config Vars do the same. WhiteNoise serves hashed static assets directly from the dyno, while media is stored on Cloudflare R2 via the S3 API, keeping uploads inexpensive and globally available. Heroku Postgres handles the relational workload. The release flow is intentionally boring: apply migrations, collect static files, smoke‑test the auth and job flow end‑to‑end (including a LinkedIn dry‑run), and confirm that the sitemap and robots files are reachable. Domains are fronted with TLS, HSTS is enabled, and cookies are marked secure.
-
Settings split:
base.py
,dev.py
,prod.py
with overrides for security and storage. -
Static files via WhiteNoise (hashed names), media files on Cloudflare R2 using
django-storages
(S3 API). -
DATABASE_URL powers Postgres on Heroku; connections use long keep‑alive.
-
Environment: secrets from
.env
in dev and Heroku Config Vars in prod. -
Domains:
talentmint.org
+www
with SSL, HSTS, and CSP‑friendly headers.
Release checklist:
-
migrate
-
collectstatic
-
Smoke test: auth → job create → apply → email → LinkedIn share (dry‑run then real)
-
Verify sitemaps and robots.
13. Admin Power Tools
Admin users can mark jobs active or inactive, archive or unarchive inbound messages, and cleanly delete records with contextual feedback. When staffing changes, a pair of bulk transfer views reassign all jobs and/or applicants from one recruiter to another in a transaction, avoiding partial moves and ensuring counters and dashboards remain consistent.
-
Bulk reassign jobs and applicants from one recruiter to another (safe guards for self‑delete, same‑user transfer, etc.).
-
Mark jobs active/inactive in one click.
-
Curate inbound messages (archive/unarchive/delete) with flash messages for clear feedback.
14. What I’d Improve Next
There are a few improvements on the roadmap. Two‑factor authentication for admins would raise the security bar. Background jobs with Celery and Redis would let LinkedIn posting and email bursts retry out of band. Resume parsing and semantic search would help recruiters move faster through large pools. Analytics around time‑to‑first‑feedback and conversion funnels would turn the dashboards into management tools. Finally, an external API would make it easy to syndicate postings to other boards or internal systems.
-
2FA for admin accounts.
-
Background jobs (Celery/Redis) for LinkedIn retry queues and email bursts.
-
Resume parsing + semantic search for candidates.
-
Analytics on recruiter performance (SLA to first feedback, conversion funnels).
-
Fine‑grained RBAC beyond the current role model (per‑permission toggles).
-
API for external job boards (read‑only first; write when stable).
15. Lessons Learned
APIs that look simple on paper often hide edge cases; the LinkedIn client became reliable only after I taught it to switch between /rest and /v2 and to degrade gracefully from article+CTA to link card to text‑only. ORM discipline matters: a few well‑placed prefetch_related calls and annotated counts can eliminate entire classes of performance bugs. And on the public internet, bots arrive long before users—shipping Turnstile, rate limiting, and file‑upload constraints at the start saved a lot of cleanup later.
-
Lean APIs like LinkedIn still have sharp edges — invest in defensive clients that can switch surfaces (
/rest
↔/v2
) and degrade gracefully (with/without CTA, link/text‑only). -
A little prefetching hygiene goes a long way: use
to_attr
+ annotated counts and you can keep templates expressive without N+1s. -
Ship the guard rails first (Turnstile, rate limits, size caps) — every public form will be discovered by bots.
16. Selected Snippets
Essay‑short, code‑long? Here are a few representative patterns pulled from the codebase. They’re shortened for readability.
Round‑robin on application create (signal):
@receiver(post_save, sender=JobApplication)
def auto_assign_application(sender, instance, created, **kwargs):
if created and instance.assigned_recruiters_id is None:
rid = next_recruiter_id_rr(instance.job_id)
if rid is not None:
JobApplication.objects.filter(pk=instance.pk).update(
assigned_recruiters_id=rid
)
HTMX feedback save with OoB swap:
if request.headers.get("HX-Request") == "true":
cell_html = render_to_string("jobs/partials/feedback_preview_cell.html", {"app": app}, request=request)
oob_html = f'<td id="preview-{app.pk}" hx-swap-oob="true">{cell_html}</td>'
resp = HttpResponse(oob_html, status=200)
resp["HX-Trigger"] = "feedbackSaved"
return resp
LinkedIn post creation with graceful fallbacks:
try:
status, urn, created = _create_posts(token, body_with_cta)
except HTTPError as e:
if e.response.status_code == 422:
status, urn, created = _create_posts(token, body_without_cta)
else:
status, urn, created = _create_share_v2(token, fallback_body)
17. Credits & Libraries
Django, django‑allauth (OIDC), widget‑tweaks, django‑ckeditor‑5, django‑select2, django‑storages, django‑contrib‑sitemaps, django‑ratelimit, Cloudflare Turnstile, Brevo SMTP, Cloudflare R2 (S3‑compatible), Heroku, PostgreSQL, and Cloudflare R2 all played a part in delivering a production‑ready experience.
18. Final Thoughts
TalentMint is a pragmatic Django build: small team, real workflows, production‑safe defaults, and a feature that matters — one‑click LinkedIn distribution. If you want to talk about extending this into a fuller ATS (parsing, search, multi‑board syndication), I’m happy to dive in.
Comments
Leave a Comment