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,119 @@
|
||||
"""Read-only serializers for profile payloads."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.profiles.models import Company, Education, Experience, Profile, School, Skill
|
||||
from apps.profiles.privacy import mask_emails, mask_phones
|
||||
|
||||
|
||||
class SkillSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Skill
|
||||
fields = ("id", "name", "slug", "profile_count")
|
||||
|
||||
|
||||
class CompanySerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Company
|
||||
fields = ("id", "name", "industry", "size", "founded", "website", "location_name")
|
||||
|
||||
|
||||
class SchoolSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = School
|
||||
fields = ("id", "name", "type", "location_name")
|
||||
|
||||
|
||||
class ExperienceSerializer(serializers.ModelSerializer):
|
||||
company = CompanySerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Experience
|
||||
fields = (
|
||||
"id", "title", "title_role", "title_sub_role", "levels", "company", "company_name",
|
||||
"start_date", "end_date", "start_year", "end_year", "is_primary", "location_name",
|
||||
"summary",
|
||||
) # fmt: skip
|
||||
|
||||
|
||||
class EducationSerializer(serializers.ModelSerializer):
|
||||
school = SchoolSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Education
|
||||
fields = (
|
||||
"id", "school", "school_name", "degrees", "majors", "minors", "gpa",
|
||||
"start_date", "end_date", "end_year",
|
||||
) # fmt: skip
|
||||
|
||||
|
||||
class PIIMixin:
|
||||
"""Masks contact details unless the caller is allowed to see them."""
|
||||
|
||||
def _can_view_pii(self) -> bool:
|
||||
request = self.context.get("request") # type: ignore[attr-defined]
|
||||
user = getattr(request, "user", None)
|
||||
return bool(user and user.is_authenticated and user.can_view_pii)
|
||||
|
||||
def get_emails(self, obj: Profile) -> list[dict[str, str]]:
|
||||
return obj.emails if self._can_view_pii() else mask_emails(obj.emails)
|
||||
|
||||
def get_phone_numbers(self, obj: Profile) -> list[str]:
|
||||
return obj.phone_numbers if self._can_view_pii() else mask_phones(obj.phone_numbers)
|
||||
|
||||
|
||||
class ProfileListSerializer(PIIMixin, serializers.ModelSerializer):
|
||||
"""Compact representation used for result cards."""
|
||||
|
||||
headline = serializers.CharField(read_only=True)
|
||||
company = serializers.CharField(source="current_company.name", default="", read_only=True)
|
||||
skills = serializers.SerializerMethodField()
|
||||
emails = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Profile
|
||||
fields = (
|
||||
"id", "full_name", "headline", "job_title", "job_title_role", "seniority_levels",
|
||||
"company", "industry", "location_name", "location_country", "years_experience",
|
||||
"linkedin_url", "skills", "emails",
|
||||
) # fmt: skip
|
||||
|
||||
def get_skills(self, obj: Profile) -> list[str]:
|
||||
# ``prefetch_related`` in the view keeps this at one extra query total.
|
||||
return [link.skill.name for link in obj.profile_skills.all()[:10]]
|
||||
|
||||
|
||||
class ProfileDetailSerializer(PIIMixin, serializers.ModelSerializer):
|
||||
headline = serializers.CharField(read_only=True)
|
||||
current_company = CompanySerializer(read_only=True)
|
||||
skills = serializers.SerializerMethodField()
|
||||
experiences = ExperienceSerializer(many=True, read_only=True)
|
||||
educations = EducationSerializer(many=True, read_only=True)
|
||||
emails = serializers.SerializerMethodField()
|
||||
phone_numbers = serializers.SerializerMethodField()
|
||||
pii_masked = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Profile
|
||||
fields = (
|
||||
"id", "external_id", "full_name", "first_name", "last_name", "gender", "headline",
|
||||
"job_title", "job_title_role", "job_title_sub_role", "seniority_levels",
|
||||
"current_company", "industry", "job_summary", "job_start_date",
|
||||
"location_name", "location_locality", "location_region", "location_country",
|
||||
"location_continent", "summary", "years_experience", "inferred_salary",
|
||||
"connections", "interests", "languages", "linkedin_url", "linkedin_username",
|
||||
"github_url", "twitter_url", "skills", "experiences", "educations",
|
||||
"emails", "phone_numbers", "pii_masked",
|
||||
) # fmt: skip
|
||||
|
||||
def get_skills(self, obj: Profile) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{"name": link.skill.name, "slug": link.skill.slug} for link in obj.profile_skills.all()
|
||||
]
|
||||
|
||||
def get_pii_masked(self, obj: Profile) -> bool: # noqa: ARG002
|
||||
return not self._can_view_pii()
|
||||
Reference in New Issue
Block a user