Files
cyberyan_linkedin_search/backend/config/settings/base.py
T

346 lines
13 KiB
Python
Raw Normal View History

"""
Base settings shared by every environment.
Environment specific modules (``dev``, ``prod``, ``test``) import everything
from here and override only what differs. Nothing secret is ever hard coded:
every credential is read from the environment through ``django-environ``.
"""
from datetime import timedelta
from pathlib import Path
import environ
BASE_DIR = Path(__file__).resolve().parent.parent.parent
env = environ.Env(
DJANGO_DEBUG=(bool, False),
DJANGO_ALLOWED_HOSTS=(list, ["localhost", "127.0.0.1"]),
CORS_ALLOWED_ORIGINS=(list, ["http://localhost:5173", "http://localhost:8080"]),
CSRF_TRUSTED_ORIGINS=(list, ["http://localhost:5173", "http://localhost:8080"]),
SEARCH_BACKEND=(str, "elasticsearch"),
ELASTICSEARCH_HOSTS=(list, ["http://elasticsearch:9200"]),
ELASTICSEARCH_USERNAME=(str, ""),
ELASTICSEARCH_PASSWORD=(str, ""),
ELASTICSEARCH_INDEX=(str, "linkedin-profiles"),
ELASTICSEARCH_TIMEOUT=(int, 10),
LOGSTASH_ENABLED=(bool, False),
LOGSTASH_HOST=(str, "logstash"),
LOGSTASH_PORT=(int, 5959),
LOG_LEVEL=(str, "INFO"),
THROTTLE_ANON=(str, "30/min"),
THROTTLE_USER=(str, "300/min"),
THROTTLE_SEARCH=(str, "120/min"),
THROTTLE_LOGIN=(str, "10/min"),
SERVICE_NAME=(str, "linkedin-search-api"),
ENVIRONMENT=(str, "development"),
)
# Read the local .env file when present (docker-compose injects real env vars).
env_file = BASE_DIR.parent / ".env"
if env_file.exists():
env.read_env(str(env_file))
# ---------------------------------------------------------------------------
# Core
# ---------------------------------------------------------------------------
SECRET_KEY = env("DJANGO_SECRET_KEY")
DEBUG = env("DJANGO_DEBUG")
ALLOWED_HOSTS = env("DJANGO_ALLOWED_HOSTS")
ENVIRONMENT = env("ENVIRONMENT")
SERVICE_NAME = env("SERVICE_NAME")
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.postgres",
# Third party
"rest_framework",
"rest_framework_simplejwt.token_blacklist",
"django_filters",
"corsheaders",
"drf_spectacular",
# Local
"apps.core",
"apps.accounts",
"apps.profiles",
"apps.search",
]
MIDDLEWARE = [
"apps.core.middleware.RequestIDMiddleware",
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"corsheaders.middleware.CorsMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"apps.core.middleware.AccessLogMiddleware",
]
ROOT_URLCONF = "config.urls"
WSGI_APPLICATION = "config.wsgi.application"
ASGI_APPLICATION = "config.asgi.application"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
# ---------------------------------------------------------------------------
# Database
# ---------------------------------------------------------------------------
DATABASES = {
"default": env.db("DATABASE_URL"),
}
DATABASES["default"]["CONN_MAX_AGE"] = env.int("DATABASE_CONN_MAX_AGE", default=60)
DATABASES["default"]["ATOMIC_REQUESTS"] = False
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# ---------------------------------------------------------------------------
# Cache (also backs DRF throttling counters)
# ---------------------------------------------------------------------------
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": env("REDIS_URL", default="redis://redis:6379/1"),
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
"IGNORE_EXCEPTIONS": True, # a cache outage must not take the API down
},
"KEY_PREFIX": "lks",
}
}
DJANGO_REDIS_IGNORE_EXCEPTIONS = True
# ---------------------------------------------------------------------------
# Authentication
# ---------------------------------------------------------------------------
AUTH_USER_MODEL = "accounts.User"
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
"OPTIONS": {"min_length": 10},
},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
# Argon2 first: modern, memory hard, recommended by OWASP.
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.Argon2PasswordHasher",
"django.contrib.auth.hashers.PBKDF2PasswordHasher",
"django.contrib.auth.hashers.BCryptSHA256PasswordHasher",
]
SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=env.int("JWT_ACCESS_MINUTES", default=15)),
"REFRESH_TOKEN_LIFETIME": timedelta(days=env.int("JWT_REFRESH_DAYS", default=7)),
"ROTATE_REFRESH_TOKENS": True,
"BLACKLIST_AFTER_ROTATION": True,
"UPDATE_LAST_LOGIN": True,
"ALGORITHM": "HS256",
"SIGNING_KEY": env("JWT_SIGNING_KEY", default=SECRET_KEY),
"AUTH_HEADER_TYPES": ("Bearer",),
"USER_ID_FIELD": "id",
"USER_ID_CLAIM": "user_id",
"TOKEN_OBTAIN_SERIALIZER": "apps.accounts.serializers.TokenObtainPairSerializer",
}
# ---------------------------------------------------------------------------
# Django REST Framework
# ---------------------------------------------------------------------------
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": (
"rest_framework_simplejwt.authentication.JWTAuthentication",
),
# Deny by default; endpoints opt in to public access explicitly.
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
"DEFAULT_PAGINATION_CLASS": "apps.core.pagination.PageNumberPagination",
"PAGE_SIZE": 20,
"DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",),
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
"DEFAULT_THROTTLE_CLASSES": (
"rest_framework.throttling.AnonRateThrottle",
"rest_framework.throttling.UserRateThrottle",
),
"DEFAULT_THROTTLE_RATES": {
"anon": env("THROTTLE_ANON"),
"user": env("THROTTLE_USER"),
"search": env("THROTTLE_SEARCH"),
"login": env("THROTTLE_LOGIN"),
},
"EXCEPTION_HANDLER": "apps.core.exceptions.api_exception_handler",
"DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",),
# URL namespace = API version, so /api/v1/... is resolved automatically and
# a future /api/v2/ is a routing change rather than a rewrite.
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.NamespaceVersioning",
"DEFAULT_VERSION": "v1",
"ALLOWED_VERSIONS": ["v1"],
"UNAUTHENTICATED_USER": "django.contrib.auth.models.AnonymousUser",
}
SPECTACULAR_SETTINGS = {
"TITLE": "LinkedIn Profile Search API",
"DESCRIPTION": (
"Keyword search and faceted filtering over a LinkedIn profile dataset. "
"Backed by Elasticsearch with a PostgreSQL full-text fallback."
),
"VERSION": "1.0.0",
"SERVE_INCLUDE_SCHEMA": False,
"COMPONENT_SPLIT_REQUEST": True,
"SCHEMA_PATH_PREFIX": "/api/v1",
"SERVE_PERMISSIONS": ["rest_framework.permissions.AllowAny"],
}
# ---------------------------------------------------------------------------
# CORS / CSRF
# ---------------------------------------------------------------------------
CORS_ALLOWED_ORIGINS = env("CORS_ALLOWED_ORIGINS")
CORS_ALLOW_CREDENTIALS = False
CORS_ALLOW_HEADERS = (
"accept",
"authorization",
"content-type",
"origin",
"user-agent",
"x-request-id",
)
CORS_EXPOSE_HEADERS = ("x-request-id",)
CSRF_TRUSTED_ORIGINS = env("CSRF_TRUSTED_ORIGINS")
# ---------------------------------------------------------------------------
# Baseline security headers (tightened further in prod.py)
# ---------------------------------------------------------------------------
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"
SECURE_CROSS_ORIGIN_OPENER_POLICY = "same-origin"
X_FRAME_OPTIONS = "DENY"
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
CSRF_COOKIE_HTTPONLY = False
CSRF_COOKIE_SAMESITE = "Lax"
DATA_UPLOAD_MAX_MEMORY_SIZE = 5 * 1024 * 1024 # 5 MB
DATA_UPLOAD_MAX_NUMBER_FIELDS = 1000
# ---------------------------------------------------------------------------
# Internationalisation
# ---------------------------------------------------------------------------
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
# ---------------------------------------------------------------------------
# Static files
# ---------------------------------------------------------------------------
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STORAGES = {
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
}
# ---------------------------------------------------------------------------
# Search
# ---------------------------------------------------------------------------
SEARCH = {
# "elasticsearch" (default) or "postgres" - see apps/search/backends/
"BACKEND": env("SEARCH_BACKEND"),
"ELASTICSEARCH": {
"HOSTS": env("ELASTICSEARCH_HOSTS"),
"USERNAME": env("ELASTICSEARCH_USERNAME"),
"PASSWORD": env("ELASTICSEARCH_PASSWORD"),
"INDEX": env("ELASTICSEARCH_INDEX"),
"TIMEOUT": env("ELASTICSEARCH_TIMEOUT"),
"MAX_RETRIES": 2,
},
"MAX_PAGE_SIZE": 100,
"MAX_RESULT_WINDOW": 10_000,
"FACET_SIZE": 25,
}
# Path of the dataset used by the ``import_profiles`` management command.
DATASET_PATH = env("DATASET_PATH", default=str(BASE_DIR.parent / "data" / "linkedin_profiles.csv"))
# ---------------------------------------------------------------------------
# Logging - JSON on stdout, optionally mirrored to Logstash for the ELK stack
# ---------------------------------------------------------------------------
LOG_LEVEL = env("LOG_LEVEL")
LOGSTASH_ENABLED = env("LOGSTASH_ENABLED")
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"filters": {
"request_id": {"()": "apps.core.logging.RequestIDFilter"},
},
"formatters": {
"json": {
"()": "apps.core.logging.JSONFormatter",
"service": SERVICE_NAME,
"environment": ENVIRONMENT,
},
"console": {
"format": "[{asctime}] {levelname} {name} {message}",
"style": "{",
},
},
"handlers": {
"stdout": {
"class": "logging.StreamHandler",
"formatter": "json",
"filters": ["request_id"],
},
},
"root": {"handlers": ["stdout"], "level": LOG_LEVEL},
"loggers": {
"django": {"handlers": ["stdout"], "level": LOG_LEVEL, "propagate": False},
"django.db.backends": {"handlers": ["stdout"], "level": "WARNING", "propagate": False},
# Elasticsearch client is chatty at INFO; keep the noise down.
"elastic_transport": {"handlers": ["stdout"], "level": "WARNING", "propagate": False},
"apps": {"handlers": ["stdout"], "level": LOG_LEVEL, "propagate": False},
# Business level audit trail: every executed search lands here.
"audit.search": {"handlers": ["stdout"], "level": "INFO", "propagate": False},
"audit.auth": {"handlers": ["stdout"], "level": "INFO", "propagate": False},
},
}
if LOGSTASH_ENABLED:
LOGGING["handlers"]["logstash"] = {
"class": "logstash_async.handler.AsynchronousLogstashHandler",
"formatter": "json",
"filters": ["request_id"],
"transport": "logstash_async.transport.TcpTransport",
"host": env("LOGSTASH_HOST"),
"port": env("LOGSTASH_PORT"),
# In-memory buffer: no extra volume, and a Logstash outage never blocks
# a request thread.
"database_path": None,
}
for logger in ("root", *LOGGING["loggers"]):
target = LOGGING if logger == "root" else LOGGING["loggers"]
cfg = target["root"] if logger == "root" else target[logger]
cfg["handlers"] = [*cfg["handlers"], "logstash"]