feat: LinkedIn profile search engine (Django + React + Elasticsearch + ELK)
Full-stack implementation of the Cyberyan full-stack trial task. Backend (Django 5 / DRF) - Normalised PostgreSQL model (Profile, Company, School, Skill, Experience, Education) as the source of truth, with GIN + trigram indexes. - Self-healing CSV reader: the supplied export carries an injected watermark column, missing columns, rows split by broken quoting and a duplicated header. A signature-scored Needleman-Wunsch alignment reconstructs 284 clean records out of 336 physical rows; only 10 rows parse correctly without it. - Pluggable search layer (Strategy): Elasticsearch by default, PostgreSQL full-text search as a fallback selected by a single environment variable. - Keyword search with per-field boosts, fuzziness and phrase boosting, six faceted filters (OR within a filter, AND across filters), an experience range, four sort orders, highlighting and facet aggregations. - JWT auth with rotation + blacklist, three roles, PII masking, scoped rate limits, a single error envelope, OpenAPI schema and health probes. Frontend (React 18 / TypeScript / Vite) - Debounced search with type-ahead, faceted filter panel, result cards, profile drawer, URL-synced search state, automatic token refresh. - Highlight snippets rendered without dangerouslySetInnerHTML. Infrastructure - Hardened multi-stage images (non-root, read-only rootfs, healthchecks). - docker-compose for the full stack plus dev and production overlays. - ELK: structured JSON logs shipped asynchronously to Logstash, routed per event type, with a composable index template and a Kibana setup script. Quality - 61 backend tests (85% coverage), ruff/black/bandit/mypy, ESLint + tsc, GitHub Actions running lint, tests and both image builds. - Persian documentation: README plus architecture, search, API, data, security and operations guides. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GnFo6fNaHCTAkJbfcdkqtG
This commit is contained in:
@@ -0,0 +1,416 @@
|
||||
# Generated by Django 5.1.15 on 2026-09-03 03:36
|
||||
|
||||
import django.contrib.postgres.fields
|
||||
import django.contrib.postgres.indexes
|
||||
import django.contrib.postgres.search
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Company",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("external_id", models.CharField(blank=True, db_index=True, max_length=128)),
|
||||
("name", models.CharField(max_length=255, unique=True)),
|
||||
("industry", models.CharField(blank=True, db_index=True, max_length=128)),
|
||||
(
|
||||
"size",
|
||||
models.CharField(
|
||||
blank=True, help_text="Head-count bucket, e.g. 51-200", max_length=32
|
||||
),
|
||||
),
|
||||
("founded", models.PositiveIntegerField(blank=True, null=True)),
|
||||
("website", models.CharField(blank=True, max_length=255)),
|
||||
("linkedin_url", models.CharField(blank=True, max_length=255)),
|
||||
("location_name", models.CharField(blank=True, max_length=255)),
|
||||
("country", models.CharField(blank=True, db_index=True, max_length=128)),
|
||||
],
|
||||
options={
|
||||
"verbose_name_plural": "companies",
|
||||
"ordering": ("name",),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="School",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("name", models.CharField(max_length=255, unique=True)),
|
||||
("type", models.CharField(blank=True, max_length=64)),
|
||||
("website", models.CharField(blank=True, max_length=255)),
|
||||
("linkedin_url", models.CharField(blank=True, max_length=255)),
|
||||
("location_name", models.CharField(blank=True, max_length=255)),
|
||||
("country", models.CharField(blank=True, max_length=128)),
|
||||
],
|
||||
options={
|
||||
"ordering": ("name",),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Skill",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("name", models.CharField(max_length=128)),
|
||||
("slug", models.SlugField(max_length=140, unique=True)),
|
||||
(
|
||||
"profile_count",
|
||||
models.PositiveIntegerField(
|
||||
db_index=True,
|
||||
default=0,
|
||||
help_text="Denormalised counter used to rank filter suggestions.",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ("-profile_count", "name"),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Profile",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"external_id",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
db_index=True,
|
||||
help_text="LinkedIn numeric id from the source export.",
|
||||
max_length=64,
|
||||
),
|
||||
),
|
||||
("full_name", models.CharField(db_index=True, max_length=255)),
|
||||
("first_name", models.CharField(blank=True, max_length=128)),
|
||||
("last_name", models.CharField(blank=True, max_length=128)),
|
||||
(
|
||||
"gender",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[("male", "Male"), ("female", "Female"), ("", "Unknown")],
|
||||
max_length=16,
|
||||
),
|
||||
),
|
||||
("linkedin_url", models.CharField(blank=True, max_length=255)),
|
||||
("linkedin_username", models.CharField(blank=True, max_length=128)),
|
||||
("github_url", models.CharField(blank=True, max_length=255)),
|
||||
("twitter_url", models.CharField(blank=True, max_length=255)),
|
||||
("job_title", models.CharField(blank=True, db_index=True, max_length=255)),
|
||||
(
|
||||
"job_title_role",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
db_index=True,
|
||||
help_text="Coarse job family, e.g. engineering.",
|
||||
max_length=64,
|
||||
),
|
||||
),
|
||||
("job_title_sub_role", models.CharField(blank=True, max_length=64)),
|
||||
(
|
||||
"seniority_levels",
|
||||
django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.CharField(max_length=32),
|
||||
blank=True,
|
||||
default=list,
|
||||
help_text="e.g. ['manager'], ['senior'], ['cxo'].",
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
("industry", models.CharField(blank=True, db_index=True, max_length=128)),
|
||||
("job_summary", models.TextField(blank=True)),
|
||||
("job_start_date", models.CharField(blank=True, max_length=10)),
|
||||
("location_name", models.CharField(blank=True, max_length=255)),
|
||||
("location_locality", models.CharField(blank=True, max_length=128)),
|
||||
("location_region", models.CharField(blank=True, db_index=True, max_length=128)),
|
||||
("location_country", models.CharField(blank=True, db_index=True, max_length=128)),
|
||||
("location_continent", models.CharField(blank=True, max_length=64)),
|
||||
("summary", models.TextField(blank=True)),
|
||||
(
|
||||
"years_experience",
|
||||
models.PositiveSmallIntegerField(blank=True, db_index=True, null=True),
|
||||
),
|
||||
("inferred_salary", models.CharField(blank=True, max_length=32)),
|
||||
("connections", models.PositiveIntegerField(blank=True, null=True)),
|
||||
(
|
||||
"interests",
|
||||
django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.CharField(max_length=128),
|
||||
blank=True,
|
||||
default=list,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
(
|
||||
"languages",
|
||||
django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.CharField(max_length=64),
|
||||
blank=True,
|
||||
default=list,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
("emails", models.JSONField(blank=True, default=list)),
|
||||
(
|
||||
"phone_numbers",
|
||||
django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.CharField(max_length=32),
|
||||
blank=True,
|
||||
default=list,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
("skills_text", models.TextField(blank=True, editable=False)),
|
||||
("companies_text", models.TextField(blank=True, editable=False)),
|
||||
("education_text", models.TextField(blank=True, editable=False)),
|
||||
(
|
||||
"search_vector",
|
||||
django.contrib.postgres.search.SearchVectorField(editable=False, null=True),
|
||||
),
|
||||
(
|
||||
"current_company",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="current_employees",
|
||||
to="profiles.company",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ("full_name", "id"),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Education",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("school_name", models.CharField(blank=True, max_length=255)),
|
||||
(
|
||||
"degrees",
|
||||
django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.CharField(max_length=128),
|
||||
blank=True,
|
||||
default=list,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
(
|
||||
"majors",
|
||||
django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.CharField(max_length=128),
|
||||
blank=True,
|
||||
default=list,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
(
|
||||
"minors",
|
||||
django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.CharField(max_length=128),
|
||||
blank=True,
|
||||
default=list,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
("gpa", models.DecimalField(blank=True, decimal_places=2, max_digits=4, null=True)),
|
||||
("start_date", models.CharField(blank=True, max_length=10)),
|
||||
("end_date", models.CharField(blank=True, max_length=10)),
|
||||
(
|
||||
"end_year",
|
||||
models.PositiveSmallIntegerField(blank=True, db_index=True, null=True),
|
||||
),
|
||||
(
|
||||
"profile",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="educations",
|
||||
to="profiles.profile",
|
||||
),
|
||||
),
|
||||
(
|
||||
"school",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="educations",
|
||||
to="profiles.school",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ("-end_year", "id"),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ProfileSkill",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
("position", models.PositiveSmallIntegerField(default=0)),
|
||||
(
|
||||
"profile",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="profile_skills",
|
||||
to="profiles.profile",
|
||||
),
|
||||
),
|
||||
(
|
||||
"skill",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="profile_skills",
|
||||
to="profiles.skill",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ("position",),
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="profile",
|
||||
name="skills",
|
||||
field=models.ManyToManyField(
|
||||
related_name="profiles", through="profiles.ProfileSkill", to="profiles.skill"
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Experience",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("company_name", models.CharField(blank=True, max_length=255)),
|
||||
("title", models.CharField(blank=True, max_length=255)),
|
||||
("title_role", models.CharField(blank=True, max_length=64)),
|
||||
("title_sub_role", models.CharField(blank=True, max_length=64)),
|
||||
(
|
||||
"levels",
|
||||
django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.CharField(max_length=32),
|
||||
blank=True,
|
||||
default=list,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
("start_date", models.CharField(blank=True, max_length=10)),
|
||||
("end_date", models.CharField(blank=True, max_length=10)),
|
||||
(
|
||||
"start_year",
|
||||
models.PositiveSmallIntegerField(blank=True, db_index=True, null=True),
|
||||
),
|
||||
("end_year", models.PositiveSmallIntegerField(blank=True, null=True)),
|
||||
("is_primary", models.BooleanField(db_index=True, default=False)),
|
||||
("location_name", models.CharField(blank=True, max_length=255)),
|
||||
("summary", models.TextField(blank=True)),
|
||||
(
|
||||
"company",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="experiences",
|
||||
to="profiles.company",
|
||||
),
|
||||
),
|
||||
(
|
||||
"profile",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="experiences",
|
||||
to="profiles.profile",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ("-is_primary", "-start_year", "id"),
|
||||
"indexes": [
|
||||
models.Index(fields=["profile", "is_primary"], name="exp_profile_primary")
|
||||
],
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="profileskill",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("profile", "skill"), name="uniq_profile_skill"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="profile",
|
||||
index=django.contrib.postgres.indexes.GinIndex(
|
||||
fields=["search_vector"], name="profile_search_vector_gin"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="profile",
|
||||
index=django.contrib.postgres.indexes.GinIndex(
|
||||
fields=["seniority_levels"], name="profile_seniority_gin"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="profile",
|
||||
index=models.Index(
|
||||
fields=["location_country", "job_title_role"], name="profile_country_role"
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="profile",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("external_id__gt", "")),
|
||||
fields=("external_id",),
|
||||
name="uniq_profile_external_id",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
"""
|
||||
Enable the PostgreSQL extensions the search fallback relies on.
|
||||
|
||||
``pg_trgm`` powers ``TrigramSimilarity`` (typo tolerant name matching) and the
|
||||
GIN trigram index below makes ``ILIKE '%term%'`` on skill names fast enough for
|
||||
the type-ahead endpoint.
|
||||
"""
|
||||
|
||||
from django.contrib.postgres.operations import TrigramExtension, UnaccentExtension
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("profiles", "0001_initial")]
|
||||
|
||||
operations = [
|
||||
TrigramExtension(),
|
||||
UnaccentExtension(),
|
||||
migrations.RunSQL(
|
||||
sql=(
|
||||
"CREATE INDEX IF NOT EXISTS profile_full_name_trgm "
|
||||
"ON profiles_profile USING gin (full_name gin_trgm_ops);"
|
||||
),
|
||||
reverse_sql="DROP INDEX IF EXISTS profile_full_name_trgm;",
|
||||
),
|
||||
migrations.RunSQL(
|
||||
sql=(
|
||||
"CREATE INDEX IF NOT EXISTS skill_name_trgm "
|
||||
"ON profiles_skill USING gin (name gin_trgm_ops);"
|
||||
),
|
||||
reverse_sql="DROP INDEX IF EXISTS skill_name_trgm;",
|
||||
),
|
||||
]
|
||||
Reference in New Issue
Block a user