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,87 @@
|
||||
"""``python manage.py import_profiles`` - load the CSV export into PostgreSQL."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
|
||||
from apps.profiles.importers import ProfileImporter
|
||||
from apps.profiles.models import (
|
||||
Company,
|
||||
Education,
|
||||
Experience,
|
||||
Profile,
|
||||
ProfileSkill,
|
||||
School,
|
||||
Skill,
|
||||
)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Import (or refresh) LinkedIn profiles from the raw CSV export."
|
||||
|
||||
def add_arguments(self, parser) -> None: # noqa: ANN001
|
||||
parser.add_argument(
|
||||
"--path",
|
||||
default=settings.DATASET_PATH,
|
||||
help="Path to the CSV export (defaults to DATASET_PATH).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit", type=int, default=None, help="Import at most N profiles (smoke tests)."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--truncate",
|
||||
action="store_true",
|
||||
help="Delete existing profile data before importing.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--reindex",
|
||||
action="store_true",
|
||||
help="Rebuild the Elasticsearch index once the import finishes.",
|
||||
)
|
||||
|
||||
def handle(self, *args: Any, **options: Any) -> None:
|
||||
path = options["path"]
|
||||
started = time.perf_counter()
|
||||
|
||||
if options["truncate"]:
|
||||
self.stdout.write("Truncating existing profile data ...")
|
||||
with transaction.atomic():
|
||||
ProfileSkill.objects.all().delete()
|
||||
Experience.objects.all().delete()
|
||||
Education.objects.all().delete()
|
||||
Profile.objects.all().delete()
|
||||
Skill.objects.all().delete()
|
||||
Company.objects.all().delete()
|
||||
School.objects.all().delete()
|
||||
|
||||
self.stdout.write(f"Reading dataset from {path} ...")
|
||||
try:
|
||||
stats = ProfileImporter(path, limit=options["limit"]).run()
|
||||
except FileNotFoundError as exc:
|
||||
raise CommandError(f"Dataset not found: {path}") from exc
|
||||
|
||||
elapsed = round(time.perf_counter() - started, 2)
|
||||
summary = {**stats.as_dict(), "elapsed_seconds": elapsed}
|
||||
self.stdout.write(json.dumps(summary, indent=2))
|
||||
|
||||
if stats.report.issues:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f"{len(stats.report.issues)} record(s) failed: "
|
||||
f"{', '.join(stats.report.issues[:10])}"
|
||||
)
|
||||
)
|
||||
|
||||
if options["reindex"]:
|
||||
self.stdout.write("Rebuilding the search index ...")
|
||||
from django.core.management import call_command
|
||||
|
||||
call_command("search_index", "--rebuild")
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"Import finished in {elapsed}s."))
|
||||
Reference in New Issue
Block a user