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,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"),
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -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()),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user