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:
2026-09-03 08:13:10 +00:00
commit a464e550c9
133 changed files with 17019 additions and 0 deletions
View File
+30
View File
@@ -0,0 +1,30 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.utils.translation import gettext_lazy as _
from apps.accounts.models import User
@admin.register(User)
class UserAdmin(BaseUserAdmin):
ordering = ("-date_joined",)
list_display = ("email", "full_name", "role", "is_active", "is_staff", "date_joined")
list_filter = ("role", "is_active", "is_staff", "is_superuser")
search_fields = ("email", "full_name")
readonly_fields = ("date_joined", "last_login")
fieldsets = (
(None, {"fields": ("email", "password")}),
(_("Personal info"), {"fields": ("full_name",)}),
(_("Permissions"), {"fields": ("role", "is_active", "is_staff", "is_superuser", "groups")}),
(_("Important dates"), {"fields": ("last_login", "date_joined")}),
)
add_fieldsets = (
(
None,
{
"classes": ("wide",),
"fields": ("email", "full_name", "role", "password1", "password2"),
},
),
)
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.accounts"
verbose_name = "Accounts"
@@ -0,0 +1,59 @@
"""
``python manage.py seed_users`` - create the demo accounts used by the README.
Credentials come from the environment so no password is ever committed; the
command is idempotent and refuses to run with production settings unless
``--force`` is given.
"""
from __future__ import annotations
import os
from typing import Any
from django.core.management.base import BaseCommand
from apps.accounts.models import Role, User
DEMO_USERS = (
("DEMO_ADMIN_EMAIL", "DEMO_ADMIN_PASSWORD", Role.ADMIN, True),
("DEMO_ANALYST_EMAIL", "DEMO_ANALYST_PASSWORD", Role.ANALYST, False),
("DEMO_VIEWER_EMAIL", "DEMO_VIEWER_PASSWORD", Role.VIEWER, False),
)
class Command(BaseCommand):
help = "Create the demo admin / analyst / viewer accounts."
def add_arguments(self, parser) -> None: # noqa: ANN001
parser.add_argument("--force", action="store_true", help="Run even outside development.")
def handle(self, *args: Any, **options: Any) -> None:
for email_var, password_var, role, is_staff in DEMO_USERS:
email = os.environ.get(email_var)
password = os.environ.get(password_var)
if not email or not password:
self.stdout.write(
self.style.WARNING(f"Skipping {role}: {email_var}/{password_var} not set.")
)
continue
user = User.objects.filter(email=email.lower()).first()
if user:
user.role = role
user.is_staff = is_staff
user.is_superuser = is_staff
user.set_password(password)
user.save(update_fields=["role", "is_staff", "is_superuser", "password"])
self.stdout.write(f"Updated {email} ({role}).")
continue
User.objects.create_user(
email=email,
password=password,
role=role,
full_name=f"Demo {role.label}",
is_staff=is_staff,
is_superuser=is_staff,
)
self.stdout.write(self.style.SUCCESS(f"Created {email} ({role})."))
@@ -0,0 +1,103 @@
# Generated by Django 5.1.15 on 2026-09-03 03:36
import apps.accounts.models
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
]
operations = [
migrations.CreateModel(
name="User",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("password", models.CharField(max_length=128, verbose_name="password")),
(
"last_login",
models.DateTimeField(blank=True, null=True, verbose_name="last login"),
),
(
"is_superuser",
models.BooleanField(
default=False,
help_text="Designates that this user has all permissions without explicitly assigning them.",
verbose_name="superuser status",
),
),
(
"email",
models.EmailField(
db_index=True, max_length=254, unique=True, verbose_name="email address"
),
),
(
"full_name",
models.CharField(blank=True, max_length=150, verbose_name="full name"),
),
(
"role",
models.CharField(
choices=[
("admin", "Administrator"),
("analyst", "Analyst"),
("viewer", "Viewer"),
],
default="viewer",
help_text="Determines which fields and actions the user has access to.",
max_length=16,
verbose_name="role",
),
),
("is_active", models.BooleanField(default=True, verbose_name="active")),
("is_staff", models.BooleanField(default=False, verbose_name="staff status")),
(
"date_joined",
models.DateTimeField(
default=django.utils.timezone.now, verbose_name="date joined"
),
),
(
"groups",
models.ManyToManyField(
blank=True,
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
related_name="user_set",
related_query_name="user",
to="auth.group",
verbose_name="groups",
),
),
(
"user_permissions",
models.ManyToManyField(
blank=True,
help_text="Specific permissions for this user.",
related_name="user_set",
related_query_name="user",
to="auth.permission",
verbose_name="user permissions",
),
),
],
options={
"verbose_name": "user",
"verbose_name_plural": "users",
"ordering": ("-date_joined",),
},
managers=[
("objects", apps.accounts.models.UserManager()),
],
),
]
+92
View File
@@ -0,0 +1,92 @@
"""
Custom user model.
Two things are worth pointing out:
* The username field is the e-mail address - the dataset users are internal
analysts, and an e-mail is the identifier they already have.
* Authorisation is expressed as a single ``role`` field instead of a pile of
Django groups. With three well defined roles this is easier to reason about,
easier to test, and it keeps the JWT payload small.
"""
from __future__ import annotations
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
class Role(models.TextChoices):
ADMIN = "admin", _("Administrator")
ANALYST = "analyst", _("Analyst")
VIEWER = "viewer", _("Viewer")
class UserManager(BaseUserManager):
"""Manager that normalises e-mails and refuses to create users without one."""
use_in_migrations = True
def create_user(self, email: str, password: str | None = None, **extra) -> User:
if not email:
raise ValueError("Users must have an email address.")
email = self.normalize_email(email).lower()
user = self.model(email=email, **extra)
# ``set_password`` hashes with Argon2 (see PASSWORD_HASHERS).
user.set_password(password)
user.full_clean(exclude=["password"])
user.save(using=self._db)
return user
def create_superuser(self, email: str, password: str | None = None, **extra) -> User:
extra.setdefault("is_staff", True)
extra.setdefault("is_superuser", True)
extra.setdefault("role", Role.ADMIN)
if extra.get("is_staff") is not True or extra.get("is_superuser") is not True:
raise ValueError("Superuser must have is_staff=True and is_superuser=True.")
return self.create_user(email, password, **extra)
class User(AbstractBaseUser, PermissionsMixin):
email = models.EmailField(_("email address"), unique=True, db_index=True)
full_name = models.CharField(_("full name"), max_length=150, blank=True)
role = models.CharField(
_("role"),
max_length=16,
choices=Role.choices,
default=Role.VIEWER,
help_text=_("Determines which fields and actions the user has access to."),
)
is_active = models.BooleanField(_("active"), default=True)
is_staff = models.BooleanField(_("staff status"), default=False)
date_joined = models.DateTimeField(_("date joined"), default=timezone.now)
objects = UserManager()
USERNAME_FIELD = "email"
REQUIRED_FIELDS: list[str] = []
class Meta:
verbose_name = _("user")
verbose_name_plural = _("users")
ordering = ("-date_joined",)
def __str__(self) -> str:
return self.email
# -- Authorisation helpers ---------------------------------------------
@property
def is_admin_role(self) -> bool:
return self.role == Role.ADMIN or self.is_superuser
@property
def can_view_pii(self) -> bool:
"""
Whether raw contact details may be returned to this user.
Viewers only ever see masked e-mails and phone numbers; unmasking is a
deliberate privilege granted to analysts and administrators.
"""
return self.role in (Role.ADMIN, Role.ANALYST) or self.is_superuser
+88
View File
@@ -0,0 +1,88 @@
"""Serializers for registration, login and profile of the API's own users."""
from __future__ import annotations
import logging
from typing import Any
from django.contrib.auth import password_validation
from django.db import IntegrityError
from rest_framework import serializers
from rest_framework_simplejwt.serializers import (
TokenObtainPairSerializer as BaseTokenObtainPairSerializer,
)
from apps.accounts.models import Role, User
audit = logging.getLogger("audit.auth")
class UserSerializer(serializers.ModelSerializer):
can_view_pii = serializers.BooleanField(read_only=True)
class Meta:
model = User
fields = ("id", "email", "full_name", "role", "can_view_pii", "date_joined")
read_only_fields = fields
class RegisterSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True, style={"input_type": "password"})
password_confirm = serializers.CharField(write_only=True, style={"input_type": "password"})
class Meta:
model = User
fields = ("email", "full_name", "password", "password_confirm")
def validate_email(self, value: str) -> str:
return value.strip().lower()
def validate_password(self, value: str) -> str:
# Runs Django's configured validators: length, common passwords, etc.
password_validation.validate_password(value)
return value
def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
if attrs["password"] != attrs["password_confirm"]:
raise serializers.ValidationError({"password_confirm": "Passwords do not match."})
return attrs
def create(self, validated_data: dict[str, Any]) -> User:
validated_data.pop("password_confirm")
password = validated_data.pop("password")
try:
# Self-service sign-up always yields the least privileged role;
# elevation is an explicit admin action.
return User.objects.create_user(password=password, role=Role.VIEWER, **validated_data)
except IntegrityError as exc: # pragma: no cover - race with unique index
raise serializers.ValidationError(
{"email": "A user with this email already exists."}
) from exc
class TokenObtainPairSerializer(BaseTokenObtainPairSerializer):
"""Adds identity claims to the access token and records an audit event."""
@classmethod
def get_token(cls, user: User): # noqa: ANN206
token = super().get_token(user)
token["role"] = user.role
token["email"] = user.email
return token
def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
data = super().validate(attrs)
audit.info(
"login_succeeded",
extra={
"event": "login_succeeded",
"user_id": self.user.id,
"user_role": self.user.role,
},
)
data["user"] = UserSerializer(self.user).data
return data
class LogoutSerializer(serializers.Serializer):
refresh = serializers.CharField()
+13
View File
@@ -0,0 +1,13 @@
from django.urls import path
from apps.accounts.views import LoginView, LogoutView, MeView, RefreshView, RegisterView
app_name = "accounts"
urlpatterns = [
path("register/", RegisterView.as_view(), name="register"),
path("login/", LoginView.as_view(), name="login"),
path("refresh/", RefreshView.as_view(), name="refresh"),
path("logout/", LogoutView.as_view(), name="logout"),
path("me/", MeView.as_view(), name="me"),
]
+85
View File
@@ -0,0 +1,85 @@
"""Authentication endpoints: register, login, refresh, logout, me."""
from __future__ import annotations
import contextlib
import logging
from drf_spectacular.utils import extend_schema
from rest_framework import generics, status
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework_simplejwt.exceptions import TokenError
from rest_framework_simplejwt.tokens import RefreshToken
from rest_framework_simplejwt.views import TokenObtainPairView as BaseTokenObtainPairView
from rest_framework_simplejwt.views import TokenRefreshView as BaseTokenRefreshView
from apps.accounts.models import User
from apps.accounts.serializers import LogoutSerializer, RegisterSerializer, UserSerializer
from apps.core.throttling import LoginRateThrottle
audit = logging.getLogger("audit.auth")
@extend_schema(tags=["auth"])
class RegisterView(generics.CreateAPIView):
"""Self-service sign-up. New accounts always get the ``viewer`` role."""
serializer_class = RegisterSerializer
permission_classes = [AllowAny]
throttle_classes = [LoginRateThrottle]
queryset = User.objects.all()
def create(self, request: Request, *args, **kwargs) -> Response:
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = serializer.save()
audit.info("user_registered", extra={"event": "user_registered", "user_id": user.id})
return Response(UserSerializer(user).data, status=status.HTTP_201_CREATED)
@extend_schema(tags=["auth"])
class LoginView(BaseTokenObtainPairView):
"""Exchange credentials for an access/refresh token pair."""
permission_classes = [AllowAny]
throttle_classes = [LoginRateThrottle]
@extend_schema(tags=["auth"])
class RefreshView(BaseTokenRefreshView):
"""Rotate a refresh token; the presented token is blacklisted afterwards."""
permission_classes = [AllowAny]
throttle_classes = [LoginRateThrottle]
@extend_schema(tags=["auth"], request=LogoutSerializer, responses={205: None})
class LogoutView(APIView):
"""Blacklist a refresh token so it can no longer be rotated."""
permission_classes = [IsAuthenticated]
serializer_class = LogoutSerializer
def post(self, request: Request) -> Response:
serializer = LogoutSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
# An already-invalid token is not an error worth surfacing: the
# caller's intent (be logged out) is satisfied either way.
with contextlib.suppress(TokenError):
RefreshToken(serializer.validated_data["refresh"]).blacklist()
audit.info("logout", extra={"event": "logout", "user_id": request.user.id})
return Response(status=status.HTTP_205_RESET_CONTENT)
@extend_schema(tags=["auth"])
class MeView(generics.RetrieveAPIView):
"""Identity and effective privileges of the calling user."""
serializer_class = UserSerializer
permission_classes = [IsAuthenticated]
def get_object(self) -> User:
return self.request.user