""" 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"]