Corp/backend/eusrr_backend/settings.py
Игорь Надеин 86fed54811
All checks were successful
Deploy production / deploy (push) Successful in 10m4s
Add OIDC back-channel logout for deactivated accounts
2026-10-27 20:01:26 +03:00

1121 lines
46 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import json
import os
import tempfile
from datetime import timedelta
from pathlib import Path
from celery.schedules import crontab
from django.core.exceptions import ImproperlyConfigured
from dotenv import load_dotenv
from api.oidc.policy import require_pkce
load_dotenv()
BASE_DIR = Path(__file__).resolve().parent.parent
# -----------------------------------------------------------------------------
# БАЗОВЫЕ НАСТРОЙКИ
# -----------------------------------------------------------------------------
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "dev-insecure-key")
SECRET_KEY_FALLBACKS = json.loads(os.getenv("DJANGO_SECRET_KEY_FALLBACKS", "[]"))
if not isinstance(SECRET_KEY_FALLBACKS, list) or not all(
isinstance(key, str) and key for key in SECRET_KEY_FALLBACKS
):
raise ImproperlyConfigured(
"DJANGO_SECRET_KEY_FALLBACKS must be a JSON list of keys"
)
DEBUG = os.getenv("DJANGO_DEBUG", "false").lower() == "true"
def _split_env_list(value: str) -> list[str]:
return [x.strip() for x in (value or "").split(",") if x.strip()]
ALLOWED_HOSTS = _split_env_list(os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1"))
# Only opt in behind a proxy which overwrites X-Forwarded-Proto and with the
# application socket restricted to that proxy. Defaults also support local HTTP.
SECURE_PROXY_SSL_HEADER = (
("HTTP_X_FORWARDED_PROTO", "https")
if os.getenv("TRUST_PROXY_SSL_HEADER", "false").lower() == "true"
else None
)
SECURE_SSL_REDIRECT = os.getenv("SECURE_SSL_REDIRECT", "false").lower() == "true"
SECURE_HSTS_SECONDS = int(os.getenv("SECURE_HSTS_SECONDS", "0"))
SECURE_HSTS_INCLUDE_SUBDOMAINS = False
SECURE_HSTS_PRELOAD = False
# Browser clients authenticate WebSocket handshakes with a short-lived ticket.
# Long-lived JWTs in query strings are accepted only during an explicit legacy
# migration window because URLs can be recorded by reverse proxies.
WEBSOCKET_ALLOW_QUERY_JWT = (
os.getenv("WEBSOCKET_ALLOW_QUERY_JWT", "false").lower() == "true"
)
WEBSOCKET_ALLOWED_ORIGINS = _split_env_list(os.getenv("WEBSOCKET_ALLOWED_ORIGINS", ""))
INSTALLED_APPS = [
"daphne",
"channels",
"corsheaders", # django-cors-headers для CORS
"django.contrib.admin",
"django_bootstrap5",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.postgres",
"widget_tweaks",
"simple_history",
"rest_framework",
"drf_spectacular",
"oauth2_provider",
"rules", # django-rules для декларативных permissions
# django-filer и зависимости
"easy_thumbnails",
"filer",
"mptt", # зависимость filer
"reversion", # django-reversion для версионирования
# Celery приложения
"django_celery_beat", # Периодические задачи
"django_celery_results", # Хранение результатов
# Основные приложения
"employees.apps.EmployeesConfig",
"api.apps.ApiConfig",
"schedule", # django-scheduler (проверенная библиотека для календаря)
# Интеграция и расширения для django-scheduler
"scheduling.apps.SchedulingConfig",
"documents.apps.DocumentsConfig",
"requests_app.apps.RequestsAppConfig",
"attendance.apps.AttendanceConfig",
"feed.apps.FeedConfig",
"realtime.apps.RealtimeConfig", # WebSocket consumers для real-time
"communications.apps.CommunicationsConfig",
"notifications.apps.NotificationsConfig",
"watson", # django-watson для полнотекстового поиска
"search.apps.SearchConfig",
"finance.apps.FinanceConfig",
"procurement.apps.ProcurementConfig",
"commerce.apps.CommerceConfig",
"projects.apps.ProjectsConfig",
"tasks.apps.TasksConfig",
"guests.apps.GuestsConfig",
"push_notifications", # Web Push
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"corsheaders.middleware.CorsMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware", # Нужен для Django Admin
"django.contrib.auth.middleware.AuthenticationMiddleware",
"eusrr_backend.middleware.RequestLoggingMiddleware",
"reversion.middleware.RevisionMiddleware",
# django-reversion для версионирования
"simple_history.middleware.HistoryRequestMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
# Legacy Django-session client only; SPA refresh lives in frontend ApiClient.
"api.middleware.JWTRefreshMiddleware",
# Защита от неавторизованных
"eusrr_backend.middleware.AuthRequiredMiddleware",
"eusrr_backend.middleware.CacheControlMiddleware", # Cache-Control headers
]
ROOT_URLCONF = "eusrr_backend.urls"
TEMPLATES_DIR = BASE_DIR / "templates"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [TEMPLATES_DIR],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"eusrr_backend.context_processors.branding",
],
},
},
]
WSGI_APPLICATION = "eusrr_backend.wsgi.application"
ASGI_APPLICATION = "eusrr_backend.asgi.application"
# -----------------------------------------------------------------------------
# БАЗА ДАННЫХ
# -----------------------------------------------------------------------------
USE_SQLITE = os.getenv("USE_SQLITE", "false").lower() == "true"
DATABASES = {
"default": {},
}
if USE_SQLITE:
DATABASES["default"] = {
"ENGINE": "django.db.backends.sqlite3",
"NAME": os.path.join(BASE_DIR, "db.sqlite3"),
}
else:
DATABASES["default"] = {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.getenv("POSTGRES_DB", "django"),
"USER": os.getenv("POSTGRES_USER", "django"),
"PASSWORD": os.getenv("POSTGRES_PASSWORD", ""),
"HOST": os.getenv("DB_HOST", "127.0.0.1"),
"PORT": os.getenv("DB_PORT", "5432"),
}
# LDAP write-back (django-ldapdb):
# подключается только при LDAP_WRITE_ENABLED=true.
# Для LDAP-аутентификации (ldap3) отдельная БД не нужна.
LDAP_WRITE_ENABLED = os.getenv("LDAP_WRITE_ENABLED", "false").lower() == "true"
if LDAP_WRITE_ENABLED:
DATABASES["ldap"] = {
"ENGINE": "ldapdb.backends.ldap",
"NAME": os.getenv("LDAP_URI", "ldap://localhost:389"),
"USER": os.getenv("LDAP_BIND_DN", "cn=admin,dc=eusrr,dc=local"),
"PASSWORD": os.getenv("LDAP_BIND_PASSWORD", "AdminPassword123!"),
}
import ldap
ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
# Router всегда нужен: предотвращает создание таблиц для LDAP-моделей в
# default DB
DATABASE_ROUTERS = ["eusrr_backend.db_routers.LdapRouter"]
# -----------------------------------------------------------------------------
# АВТОРИЗАЦИЯ / ПАРОЛИ
# -----------------------------------------------------------------------------
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": (
"django.contrib.auth.password_validation.UserAttributeSimilarityValidator"
)
},
{
"NAME": ("django.contrib.auth.password_validation.MinimumLengthValidator"),
"OPTIONS": {"min_length": 8},
},
{"NAME": ("django.contrib.auth.password_validation.CommonPasswordValidator")},
{"NAME": ("django.contrib.auth.password_validation.NumericPasswordValidator")},
]
LANGUAGE_CODE = "ru"
TIME_ZONE = "Europe/Moscow"
USE_I18N = True
USE_TZ = True
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STATICFILES_DIRS = [BASE_DIR / "static"]
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
AUTH_USER_MODEL = "employees.Employee"
# Аутентификация (используется Django Admin)
LOGIN_URL = "/admin/login/"
LOGIN_REDIRECT_URL = "/admin/"
# Внутренний URL для API клиента (для запросов view -> API внутри сервера)
# Используем локальный адрес для избежания проблем с SSL
API_BASE_URL = os.getenv("API_BASE_URL", "http://127.0.0.1:9000/api")
# LogStorm attendance analyzer integration.
LOGSTORM_API_URL = os.getenv("LOGSTORM_API_URL", "http://127.0.0.1:8010")
LOGSTORM_API_TOKEN = os.getenv("LOGSTORM_API_TOKEN", "")
LOGSTORM_TIMEOUT_SECONDS = int(os.getenv("LOGSTORM_TIMEOUT_SECONDS", "30"))
PHONE_DEFAULT_REGION = os.getenv("PHONE_DEFAULT_REGION", "RU")
PHONENUMBER_DEFAULT_REGION = "RU"
# -----------------------------------------------------------------------------
# IP ОГРАНИЧЕНИЯ ДЛЯ РЕГИСТРАЦИИ
# -----------------------------------------------------------------------------
REGISTRATION_LAN_ONLY_ENABLED = (
os.getenv("REGISTRATION_LAN_ONLY_ENABLED", "false").strip().lower() == "true"
)
# Only the confirmed CORP LAN, not every private network or all of 172.11/16.
REGISTRATION_ALLOWED_IPS = ["172.11.0.0/23"]
# Client -> Synology -> local nginx -> loopback-only ASGI server.
# These peers must append the actual upstream IP to X-Forwarded-For.
REGISTRATION_TRUSTED_PROXIES = ["127.0.0.1/32", "::1/128", "172.11.1.254/32"]
# Do not mistake a NAT gateway (or network/broadcast address) for a LAN user.
REGISTRATION_DENIED_IPS = ["172.11.0.1/32", "172.11.0.0/32", "172.11.1.255/32"]
# Медиа файлы
MEDIA_ROOT = os.path.join(BASE_DIR, "media/")
DOCUMENTS_USE_X_ACCEL_REDIRECT = (
os.getenv("DOCUMENTS_USE_X_ACCEL_REDIRECT", "false").lower() == "true"
)
CHAT_ATTACHMENTS_USE_X_ACCEL_REDIRECT = (
os.getenv("CHAT_ATTACHMENTS_USE_X_ACCEL_REDIRECT", "false").lower() == "true"
)
DOCUMENTS_X_ACCEL_REDIRECT_PREFIX = "/_protected_documents/"
MEDIA_URL = "/media/"
# Структура медиа папки:
# media/
# ├── documents/ ← Документы (читаемая структура по годам/месяцам)
# ├── avatars/ ← Аватары пользователей
# ├── chat_attachments/ ← Вложения в чате
# └── temp/ ← Временные файлы
# Параметры обработки загрузок
# Ограничивает только нефайловые данные запроса. Размер файлов не ограничен.
DATA_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024
# Крупные и небольшие файлы сразу пишутся во временное хранилище, а не в RAM.
FILE_UPLOAD_MAX_MEMORY_SIZE = 0
# -----------------------------------------------------------------------------
# ЛОГИРОВАНИЕ
# -----------------------------------------------------------------------------
LOG_LEVEL = os.getenv("LOG_LEVEL", "DEBUG" if DEBUG else "INFO").upper()
DJANGO_LOG_LEVEL = os.getenv("DJANGO_LOG_LEVEL", "INFO").upper()
LOG_FORMAT = os.getenv("LOG_FORMAT", "console" if DEBUG else "json").lower()
LOG_TRUST_X_FORWARDED_FOR = (
os.getenv("LOG_TRUST_X_FORWARDED_FOR", "false").lower() == "true"
)
LOG_TRUSTED_PROXY_CIDRS = tuple(
cidr.strip()
for cidr in os.getenv("LOG_TRUSTED_PROXY_CIDRS", "").split(",")
if cidr.strip()
)
if LOG_FORMAT not in {"console", "json"}:
LOG_FORMAT = "console" if DEBUG else "json"
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"console": {"()": "eusrr_backend.logging_utils.ConsoleFormatter"},
"json": {"()": "eusrr_backend.logging_utils.JsonFormatter"},
},
"filters": {
"context": {"()": "eusrr_backend.logging_utils.ContextFilter"},
},
"handlers": {
"console": {
"level": "DEBUG",
"class": "logging.StreamHandler",
"filters": ["context"],
"formatter": LOG_FORMAT,
},
},
"root": {"handlers": ["console"], "level": LOG_LEVEL},
"loggers": {
"django": {
"handlers": ["console"],
"level": DJANGO_LOG_LEVEL,
"propagate": False,
},
"django.server": {
"handlers": ["console"],
"level": os.getenv("DJANGO_SERVER_LOG_LEVEL", "ERROR").upper(),
"propagate": False,
},
"django.request": {
"handlers": ["console"],
"level": os.getenv("DJANGO_REQUEST_LOG_LEVEL", "ERROR").upper(),
"propagate": False,
},
# HTTP/WS lifecycle уже покрыт структурными eusrr.* событиями. Штатные
# access/debug сообщения Channels и Daphne иначе многократно дублируют
# каждый запрос и WebSocket heartbeat.
"django.channels.server": {
"handlers": ["console"],
"level": os.getenv("CHANNELS_SERVER_LOG_LEVEL", "WARNING").upper(),
"propagate": False,
},
"daphne": {
"handlers": ["console"],
"level": os.getenv("DAPHNE_LOG_LEVEL", "WARNING").upper(),
"propagate": False,
},
# Uvicorn's Gunicorn worker installs its own raw handlers before Django
# starts. Replace them explicitly so WS/OAuth URLs are always redacted.
"uvicorn": {
"handlers": ["console"],
"level": "INFO",
"propagate": False,
},
"uvicorn.error": {
"handlers": ["console"],
"level": "INFO",
"propagate": False,
},
"uvicorn.access": {
"handlers": ["console"],
"level": "WARNING",
"propagate": False,
},
"gunicorn.access": {
"handlers": ["console"],
"level": "WARNING",
"propagate": False,
},
"django.db.backends": {
"handlers": ["console"],
"level": os.getenv("DB_LOG_LEVEL", "WARNING").upper(),
"propagate": False,
},
"ldap3": {
"handlers": ["console"],
"level": os.getenv("LDAP_LOG_LEVEL", "WARNING").upper(),
"propagate": False,
},
# Низкоуровневые стадии TCP/TLS и дублирующие access-записи внешних
# HTTP-вызовов создают log storm. Бизнес-результат интеграции пишет
# commerce, а транспорт сохраняем только при реальном предупреждении.
"httpcore": {
"handlers": ["console"],
"level": os.getenv("HTTPCORE_LOG_LEVEL", "WARNING").upper(),
"propagate": False,
},
"httpx": {
"handlers": ["console"],
"level": os.getenv("HTTPX_LOG_LEVEL", "WARNING").upper(),
"propagate": False,
},
"celery": {
"handlers": ["console"],
"level": os.getenv("CELERY_LOG_LEVEL", "INFO").upper(),
"propagate": False,
},
"channels": {
"handlers": ["console"],
"level": os.getenv("CHANNELS_LOG_LEVEL", "INFO").upper(),
"propagate": False,
},
"asyncio": {
"handlers": ["console"],
"level": os.getenv("ASYNCIO_LOG_LEVEL", "WARNING").upper(),
"propagate": False,
},
"PIL": {
"handlers": ["console"],
"level": os.getenv("PIL_LOG_LEVEL", "WARNING").upper(),
"propagate": False,
},
"rules": {
"handlers": ["console"],
"level": os.getenv("RULES_LOG_LEVEL", "WARNING").upper(),
"propagate": False,
},
},
}
# -----------------------------------------------------------------------------
# БЕЗОПАСНОСТЬ И ПРОКСИ
# -----------------------------------------------------------------------------
# CSRF защита (для Django Admin и form-based views)
CSRF_TRUSTED_ORIGINS = _split_env_list(
os.getenv("CSRF_TRUSTED_ORIGINS", "https://*.sytes.net")
)
CSRF_COOKIE_HTTPONLY = False
CSRF_COOKIE_SECURE = (
os.getenv("CSRF_COOKIE_SECURE", "false" if DEBUG else "true").lower() == "true"
)
CSRF_COOKIE_SAMESITE = "Lax"
CSRF_USE_SESSIONS = False
# Примечание: API endpoints с JWT используют authentication_classes = []
# что отключает SessionAuthentication и автоматически bypass CSRF
# -----------------------------------------------------------------------------
# EMAIL
# -----------------------------------------------------------------------------
# В DEBUG режиме используем console backend (выводит письма в терминал)
# В production - SMTP
if DEBUG:
EMAIL_BACKEND = os.getenv(
"EMAIL_BACKEND", "django.core.mail.backends.console.EmailBackend"
)
else:
EMAIL_BACKEND = os.getenv(
"EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend"
)
EMAIL_HOST = os.getenv("EMAIL_HOST", "")
EMAIL_PORT = int(os.getenv("EMAIL_PORT", "465"))
EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "")
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "")
EMAIL_USE_SSL = os.getenv("EMAIL_USE_SSL", "true").lower() == "true"
# Если SSL включён (465), TLS должен быть False
EMAIL_USE_TLS = (
False if EMAIL_USE_SSL else (os.getenv("EMAIL_USE_TLS", "false").lower() == "true")
)
DEFAULT_FROM_EMAIL = os.getenv(
"DEFAULT_FROM_EMAIL", EMAIL_HOST_USER or "webmaster@localhost"
)
# -----------------------------------------------------------------------------
# SITE SETTINGS
# -----------------------------------------------------------------------------
# Название сайта для email уведомлений и UI
SITE_NAME = os.getenv("SITE_NAME", "CORP - HiRo")
SITE_URL = os.getenv("SITE_URL", "https://corp.robotail.pro").rstrip("/")
PORTAL_FRONTEND_URL = os.getenv("PORTAL_FRONTEND_URL", SITE_URL).rstrip("/")
QR_LOGIN_TOKEN_TTL_SECONDS = int(os.getenv("QR_LOGIN_TOKEN_TTL_SECONDS", "120"))
QR_LOGIN_REQUEST_TTL_SECONDS = int(os.getenv("QR_LOGIN_REQUEST_TTL_SECONDS", "120"))
# -----------------------------------------------------------------------------
# CORPORATE OAUTH2 / OPENID CONNECT PROVIDER
# -----------------------------------------------------------------------------
# The signing key is deliberately kept outside Git. A mounted Docker secret is
# preferred; an inline value is supported for environments without secret files.
OIDC_PROVIDER_ENABLED = os.getenv("OIDC_PROVIDER_ENABLED", "false").lower() == "true"
OIDC_RSA_PRIVATE_KEY_PATH = os.getenv("OIDC_RSA_PRIVATE_KEY_PATH", "").strip()
OIDC_RSA_PRIVATE_KEY = (
os.getenv("OIDC_RSA_PRIVATE_KEY", "").replace("\\n", "\n").strip()
)
if OIDC_RSA_PRIVATE_KEY_PATH:
oidc_key_path = Path(OIDC_RSA_PRIVATE_KEY_PATH)
if not oidc_key_path.is_file():
raise ImproperlyConfigured(
f"OIDC_RSA_PRIVATE_KEY_PATH does not exist: {oidc_key_path}"
)
OIDC_RSA_PRIVATE_KEY = oidc_key_path.read_text(encoding="utf-8").strip()
OIDC_RSA_PRIVATE_KEYS_INACTIVE = []
for inactive_key_path_value in _split_env_list(
os.getenv("OIDC_RSA_PRIVATE_KEY_PATHS_INACTIVE", "")
):
inactive_key_path = Path(inactive_key_path_value)
if not inactive_key_path.is_file():
raise ImproperlyConfigured(
"OIDC_RSA_PRIVATE_KEY_PATHS_INACTIVE contains a missing file: "
f"{inactive_key_path}"
)
OIDC_RSA_PRIVATE_KEYS_INACTIVE.append(
inactive_key_path.read_text(encoding="utf-8").strip()
)
if OIDC_PROVIDER_ENABLED and not OIDC_RSA_PRIVATE_KEY:
raise ImproperlyConfigured(
"OIDC_PROVIDER_ENABLED requires OIDC_RSA_PRIVATE_KEY_PATH or "
"OIDC_RSA_PRIVATE_KEY"
)
OIDC_ISSUER_URL = os.getenv("OIDC_ISSUER_URL", f"{SITE_URL}/o").rstrip("/")
OIDC_BROWSER_SESSION_SECONDS = int(
os.getenv("OIDC_BROWSER_SESSION_SECONDS", str(8 * 60 * 60))
)
OIDC_PORTAL_LOGIN_URL = os.getenv(
"OIDC_PORTAL_LOGIN_URL", f"{PORTAL_FRONTEND_URL}/login"
)
OIDC_LEGACY_NO_PKCE_CLIENT_IDS = _split_env_list(
os.getenv("OIDC_LEGACY_NO_PKCE_CLIENT_IDS", "")
)
OIDC_BACKCHANNEL_LOGOUT_ENABLED = (
os.getenv("OIDC_BACKCHANNEL_LOGOUT_ENABLED", "false").lower() == "true"
)
# Django needs this explicit setting when project migrations reference the
# OAuth Toolkit's swappable application model.
OAUTH2_PROVIDER_APPLICATION_MODEL = os.getenv(
"OAUTH2_PROVIDER_APPLICATION_MODEL", "oauth2_provider.Application"
)
OAUTH2_PROVIDER = {
"OIDC_ENABLED": OIDC_PROVIDER_ENABLED,
"OIDC_ISS_ENDPOINT": OIDC_ISSUER_URL,
"OIDC_RSA_PRIVATE_KEY": OIDC_RSA_PRIVATE_KEY,
"OIDC_RSA_PRIVATE_KEYS_INACTIVE": OIDC_RSA_PRIVATE_KEYS_INACTIVE,
"OIDC_RESPONSE_TYPES_SUPPORTED": ["code"],
"OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED": [
"client_secret_basic",
"client_secret_post",
],
"OAUTH2_VALIDATOR_CLASS": "api.oidc.validators.CorporateOAuth2Validator",
"SCOPES": {
"openid": "Подтвердить личность",
"profile": "Имя, логин и фотография профиля",
"email": "Корпоративная электронная почта",
"groups": "Корпоративные группы доступа",
},
"DEFAULT_SCOPES": ["openid", "profile", "email"],
"ACCESS_TOKEN_EXPIRE_SECONDS": int(os.getenv("OIDC_ACCESS_TOKEN_SECONDS", "3600")),
"ID_TOKEN_EXPIRE_SECONDS": int(os.getenv("OIDC_ID_TOKEN_SECONDS", "600")),
"REFRESH_TOKEN_EXPIRE_SECONDS": timedelta(
seconds=int(os.getenv("OIDC_REFRESH_TOKEN_SECONDS", str(12 * 60 * 60)))
),
"ROTATE_REFRESH_TOKEN": True,
"REFRESH_TOKEN_REUSE_PROTECTION": True,
"REQUEST_APPROVAL_PROMPT": "auto",
# The legacy Gitea exception is explicit and cannot exempt public clients.
"PKCE_REQUIRED": require_pkce,
"ALLOWED_REDIRECT_URI_SCHEMES": ["https"] + (["http"] if DEBUG else []),
"ALLOW_URI_WILDCARDS": False,
"ALLOW_LOCALHOST_LOOPBACK": DEBUG,
"COMPLIANT_BCP_RFC9700_IMPLICIT_GRANT": True,
"COMPLIANT_BCP_RFC9700_AUTHZ_RESPONSE_ISS": True,
"COMPLIANT_BCP_RFC9700_PASSWORD_GRANT": True,
"COMPLIANT_BCP_RFC9700_PKCE_METHOD": True,
"COMPLIANT_BCP_RFC9700_ACCESS_TOKEN_TRANSPORT": True,
"COMPLIANT_BCP_RFC9700_TOKEN_STORAGE": True,
}
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
SESSION_COOKIE_SECURE = (
os.getenv("SESSION_COOKIE_SECURE", "true" if not DEBUG else "false").lower()
== "true"
)
# -----------------------------------------------------------------------------
# NOTIFICATIONS
# -----------------------------------------------------------------------------
# Порог для определения массовой рассылки (количество получателей)
# При массовой рассылке уведомления создаются быстро, а отправка идёт в фоне
NOTIFICATION_BULK_THRESHOLD = int(os.getenv("NOTIFICATION_BULK_THRESHOLD", "10"))
# Конфигурация модуля уведомлений
NOTIFICATIONS_CONFIG = {
"SITE_NAME": SITE_NAME,
"DEFAULT_FROM_EMAIL": DEFAULT_FROM_EMAIL,
}
# -----------------------------------------------------------------------------
# CHANNELS & CACHE
# -----------------------------------------------------------------------------
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": (
f"redis://{os.getenv('REDIS_HOST', '127.0.0.1')}:"
f"{os.getenv('REDIS_PORT', '6379')}/1"
),
"OPTIONS": {
"db": "1", # Используем отдельную БД для кэша (Channels - 0)
},
"KEY_PREFIX": "eusrr_cache",
"TIMEOUT": 300, # 5 минут по умолчанию
}
}
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {
"hosts": [
(
os.getenv("REDIS_HOST", "127.0.0.1"),
int(os.getenv("REDIS_PORT", "6379")),
)
],
# Absorb short notification/task bursts without silently dropping
# events; expiry bounds memory when clients disappear abruptly.
"capacity": int(os.getenv("CHANNEL_LAYER_CAPACITY", "1000")),
"expiry": int(os.getenv("CHANNEL_LAYER_EXPIRY", "60")),
"group_expiry": int(os.getenv("CHANNEL_LAYER_GROUP_EXPIRY", "86400")),
},
}
}
# -----------------------------------------------------------------------------
# DRF / JWT
# -----------------------------------------------------------------------------
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"api.auth.authentication.SessionAwareJWTAuthentication",
"rest_framework.authentication.SessionAuthentication",
],
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated",
"rest_framework.permissions.DjangoModelPermissions",
],
"DEFAULT_THROTTLE_CLASSES": [
"rest_framework.throttling.AnonRateThrottle",
"rest_framework.throttling.UserRateThrottle",
"rest_framework.throttling.ScopedRateThrottle",
],
"DEFAULT_THROTTLE_RATES": {
"anon": "60/min",
"user": "600/min",
"auth_login": "10/min",
"auth_refresh": "60/min",
"auth_sensitive": "10/min",
"auth_email": "5/hour",
"websocket_ticket": "30/min",
"registration": "5/hour",
},
"DEFAULT_PAGINATION_CLASS": "api.pagination.StandardPagination",
"PAGE_SIZE": 20,
}
SPECTACULAR_SETTINGS = {
"TITLE": f"{SITE_NAME} API",
"DESCRIPTION": (
"OpenAPI-документация backend API CORP - HiRo. "
"Включает JWT-аутентификацию, session auth и основные REST endpoints."
),
"VERSION": "1.0.0",
"SERVE_INCLUDE_SCHEMA": False,
"SCHEMA_PATH_PREFIX": r"/api(?:/v1)?",
"COMPONENT_SPLIT_REQUEST": True,
"SORT_OPERATIONS": True,
"SWAGGER_UI_SETTINGS": {
"deepLinking": True,
"displayRequestDuration": True,
"filter": True,
"persistAuthorization": True,
},
"ENUM_NAME_OVERRIDES": {
"ProjectDayStatusEnum": ["draft", "confirmed"],
"ProcurementStatusEnum": [
("draft", "Черновик"),
("waiting", "Ожидает"),
("pending", "На согласовании"),
("approved", "Одобрено"),
("rejected", "Отклонено"),
("in_progress", "В работе"),
("completed", "Завершено"),
("cancelled", "Отменено"),
],
"EquipmentStatusEnum": [
("available", "Доступно"),
("in_use", "В использовании"),
("maintenance", "На обслуживании"),
("repair", "В ремонте"),
("retired", "Списано"),
("lost", "Утеряно"),
],
"RequestTypeEnum": [
("short_day_off", "Короткий отгул"),
("vacation", "Отпуск"),
("sick_leave", "Больничный"),
("day_off", "Отгул"),
("maternity", "Декрет"),
("transfer", "Перевод"),
("dismissal", "Увольнение"),
("other", "Другое"),
],
"FeedPostTypeEnum": [
("company", "Новость компании"),
("department", "Новость отдела"),
("employee", "Публикация сотрудника"),
],
"ChatTypeEnum": [
("private", "Личный"),
("group", "Групповой"),
("channel", "Канал"),
("announcement", "Объявления"),
("global", "Глобальный"),
("comments", "Комментарии"),
],
"PayrollApprovalStatusEnum": [
("draft", "Черновик"),
("approved", "Утверждено"),
("voided", "Аннулировано"),
],
"PayrollInputSourceEnum": [
("manual", "Вручную"),
("excel", "Excel"),
("api", "API"),
("attendance", "Посещаемость"),
],
},
"PREPROCESSING_HOOKS": [
"api.schema.exclude_duplicate_auth_routes",
],
"POSTPROCESSING_HOOKS": [
"drf_spectacular.hooks.postprocess_schema_enums",
"api.schema.assign_operation_tags",
],
}
SIMPLE_JWT = {
# Decouple API token and integration-key lifetimes from Django session keys.
"SIGNING_KEY": os.getenv("JWT_SIGNING_KEY", SECRET_KEY),
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=int(os.getenv("JWT_ACCESS_MIN", "30"))),
"REFRESH_TOKEN_LIFETIME": timedelta(days=int(os.getenv("JWT_REFRESH_DAYS", "90"))),
"ALGORITHM": "HS256",
"AUTH_HEADER_TYPES": ("Bearer",),
"LEEWAY": 30,
}
# Автообновление JWT токенов: за сколько минут до истечения обновлять
JWT_REFRESH_THRESHOLD_MINUTES = int(os.getenv("JWT_REFRESH_THRESHOLD_MIN", "5"))
# -----------------------------------------------------------------------------
# АУТЕНТИФИКАЦИОННЫЕ БЭКЕНДЫ
# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------
# AUTHENTICATION_BACKENDS
# -----------------------------------------------------------------------------
# Порядок важен: первый успешный бэкенд останавливает цепочку
AUTHENTICATION_BACKENDS = [
# работает только если LDAP_ENABLED=True
"eusrr_backend.auth_backends.LDAP3Backend",
# фоллбэк для режима без LDAP
"eusrr_backend.auth_backends.EmailOrPhoneBackend",
# экстренный доступ для админа
"eusrr_backend.auth_backends.SuperuserOnlyBackend",
# расчёт прав на основе должностей
"eusrr_backend.auth_backends.PositionRoleBackend",
"rules.permissions.ObjectPermissionBackend",
# django-rules для object-level permissions
"django.contrib.auth.backends.ModelBackend", # стандартный Django бэкенд
]
# -----------------------------------------------------------------------------
# LDAP / Active Directory
# -----------------------------------------------------------------------------
LDAP_ENABLED = os.getenv("LDAP_ENABLED", "true").lower() == "true"
# Основное подключение
LDAP_URI = os.getenv("LDAP_URI", "ldap://localhost:389")
LDAP_BIND_DN = os.getenv("LDAP_BIND_DN", "cn=admin,dc=eusrr,dc=local")
LDAP_BIND_PASSWORD = os.getenv("LDAP_BIND_PASSWORD", "AdminPassword123!")
# TLS/CA
# путь к CA bundle/серту (если нужен)
LDAP_CA_CERTS = os.getenv("LDAP_CA_CERTS", "")
# Где искать пользователей
LDAP_USER_BASE = os.getenv("LDAP_USER_BASE", "OU=Users,DC=eusrr,DC=local")
# Где создавать новых пользователей (может отличаться от базы поиска)
LDAP_USERS_BASE = os.getenv("LDAP_USERS_BASE", LDAP_USER_BASE)
# Базовый DN для операций создания (если не указан department_dn)
LDAP_BASE_DN = os.getenv("LDAP_BASE_DN", LDAP_USERS_BASE)
# UPN-суффикс для создания пользователей (userPrincipalName) - домен БЕЗ @
LDAP_UPN_SUFFIX = os.getenv("LDAP_UPN_SUFFIX", "eusrr.local")
LDAP_USER_FILTER = os.getenv(
"LDAP_USER_FILTER", "(&(objectCategory=person)(objectClass=user))"
)
LDAP_ATTR_MAIL = os.getenv("LDAP_ATTR_MAIL", "mail")
LDAP_ATTR_GIVENNAME = os.getenv("LDAP_ATTR_GIVENNAME", "givenName")
LDAP_ATTR_SN = os.getenv("LDAP_ATTR_SN", "sn")
LDAP_ATTR_PHONE = os.getenv("LDAP_ATTR_PHONE", "telephoneNumber")
# Атрибут LDAP для хранения Django pk сотрудника (employeeNumber по RFC
# 2798, employeeID для AD)
LDAP_EMPLOYEE_ID_ATTR = os.getenv("LDAP_EMPLOYEE_ID_ATTR", "employeeNumber")
LDAP_PHONE_ATTRS = tuple(
_split_env_list(os.getenv("LDAP_PHONE_ATTRS", "mobile,telephoneNumber"))
)
LDAP_LOGIN_UID_ATTRS = tuple(
_split_env_list(os.getenv("LDAP_LOGIN_UID_ATTRS", "uid,sAMAccountName"))
)
LDAP_LOGIN_MAIL_ATTRS = tuple(
_split_env_list(os.getenv("LDAP_LOGIN_MAIL_ATTRS", "mail,userPrincipalName"))
)
# WRITE-BACK (LDAP_WRITE_ENABLED определён выше, в секции БАЗА ДАННЫХ)
LDAP_WRITE_DN = os.getenv("LDAP_WRITE_DN", LDAP_BIND_DN)
LDAP_WRITE_PASSWORD = os.getenv("LDAP_WRITE_PASSWORD", LDAP_BIND_PASSWORD)
LDAP_WRITE_TIMEOUT = int(os.getenv("LDAP_WRITE_TIMEOUT", "5"))
# Белый список: локальные поля -> LDAP-атрибуты
LDAP_WRITE_ATTRS = {
"first_name": LDAP_ATTR_GIVENNAME,
"last_name": LDAP_ATTR_SN,
# фактическое имя локального телефонного поля подставляет код
"phone": LDAP_ATTR_PHONE,
}
LDAP_CONNECT_TIMEOUT = int(os.getenv("LDAP_CONNECT_TIMEOUT", "5"))
LDAP_OPERATION_TIMEOUT = int(os.getenv("LDAP_OPERATION_TIMEOUT", "10"))
LDAP_DEPARTMENTS_BASE = os.getenv(
"LDAP_DEPARTMENTS_BASE", "OU=Departments,DC=eusrr,DC=local"
)
LDAP_DISMISSED_BASE = os.getenv("LDAP_DISMISSED_BASE", "OU=Dismissed,DC=eusrr,DC=local")
LDAP_GROUPS_BASE = os.getenv("LDAP_GROUPS_BASE", "OU=Groups,DC=eusrr,DC=local")
LDAP_POSITIONS_BASE = os.getenv("LDAP_POSITIONS_BASE", "OU=Positions,DC=eusrr,DC=local")
LDAP_GUESTS_BASE = os.getenv("LDAP_GUESTS_BASE", f"OU=Guests,{LDAP_BASE_DN}")
LDAP_GUESTS_ACTIVE_BASE = os.getenv(
"LDAP_GUESTS_ACTIVE_BASE", f"OU=Active,{LDAP_GUESTS_BASE}"
)
LDAP_GUESTS_DEACTIVATED_BASE = os.getenv(
"LDAP_GUESTS_DEACTIVATED_BASE", f"OU=Deactivated,{LDAP_GUESTS_BASE}"
)
GUESTS_ID_MIN = int(os.getenv("GUESTS_ID_MIN", "900000000000000"))
GUESTS_ID_START = int(os.getenv("GUESTS_ID_START", "900000000000001"))
GUESTS_ID_MAX = int(os.getenv("GUESTS_ID_MAX", "999999999999999"))
GUESTS_ID_PREFIX = os.getenv("GUESTS_ID_PREFIX", "9")
GUESTS_ID_LENGTH = int(os.getenv("GUESTS_ID_LENGTH", "15"))
GUESTS_REQUIRE_ID_DOCUMENT = (
os.getenv("GUESTS_REQUIRE_ID_DOCUMENT", "false").lower() == "true"
)
GUESTS_NOTIFY_ON_EXPIRATION = (
os.getenv("GUESTS_NOTIFY_ON_EXPIRATION", "true").lower() == "true"
)
GUESTS_AUTO_DISABLE_ON_INVITER_INACTIVE = (
os.getenv("GUESTS_AUTO_DISABLE_ON_INVITER_INACTIVE", "false").lower() == "true"
)
BRAND_NAME = os.getenv("BRAND_NAME", "CORP - HiRo")
BRAND_LOGO = "img/logo.png"
# Web Push Notifications (VAPID)
# Ключи для авторизации push-уведомлений
VAPID_PUBLIC_KEY = os.getenv(
"VAPID_PUBLIC_KEY",
(
"BMTitZy9r4ygYJBgGdaZuCkb7rwR7iHLJv0DkNpaJOLESotONETWtZAQ"
"xnJVU_Yo9v-iYo-7dWEeF0VEjMcGMkQ"
),
)
VAPID_PRIVATE_KEY = os.getenv(
"VAPID_PRIVATE_KEY",
(
"MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgCNnpupg3"
"xbtFUiOSUZ6L7s6puxuEjzR73kTL7v8bMvKhRANCAATE4rWcva-MoGCQY"
"BnWmbgpG-68Ee4hyyb9A5DaWiTixEqLTjRE1rWQEMZyVVP2KPb_omKPu3"
"VhHhdFRIzHBjJE"
),
)
VAPID_ADMIN_EMAIL = os.getenv("VAPID_ADMIN_EMAIL", "robotail-info@yandex.ru")
# django-push-notifications settings
PUSH_NOTIFICATIONS_SETTINGS = {
"WP_PRIVATE_KEY": VAPID_PRIVATE_KEY,
"WP_CLAIMS": {"sub": f"mailto:{VAPID_ADMIN_EMAIL}"},
"UPDATE_ON_DUPLICATE_REG_ID": True,
"UNIQUE_REG_ID": True,
}
# -----------------------------------------------------------------------------
# CELERY CONFIGURATION
# -----------------------------------------------------------------------------
CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", "redis://localhost:6379/1")
# Сериализация
CELERY_ACCEPT_CONTENT = ["json"]
CELERY_TASK_SERIALIZER = "json"
CELERY_RESULT_SERIALIZER = "json"
# Таймзона
CELERY_TIMEZONE = "Europe/Moscow"
CELERY_ENABLE_UTC = True
# Мониторинг и отладка
CELERY_TASK_TRACK_STARTED = True
# Сохраняем единый Django formatter; Celery не заменяет root handlers.
CELERY_WORKER_HIJACK_ROOT_LOGGER = False
CELERY_TASK_TIME_LIMIT = 30 * 60 # 30 минут максимум на задачу
CELERY_TASK_SOFT_TIME_LIMIT = 25 * 60 # Мягкий лимит - 25 минут
# Integration credentials are encrypted at rest. Set an explicit Fernet key in
# production; when omitted, a stable key is derived from DJANGO_SECRET_KEY.
COMMERCE_CREDENTIAL_KEY = os.getenv("COMMERCE_CREDENTIAL_KEY", "")
COMMERCE_QUEUED_STALE_SECONDS = int(os.getenv("COMMERCE_QUEUED_STALE_SECONDS", "120"))
COMMERCE_RUNNING_STALE_SECONDS = int(os.getenv("COMMERCE_RUNNING_STALE_SECONDS", "600"))
# Commerce imports walk large remote datasets and must not inherit the short
# global limits used by routine background tasks.
COMMERCE_TASK_SOFT_TIME_LIMIT = int(
os.getenv("COMMERCE_TASK_SOFT_TIME_LIMIT", str(90 * 60))
)
COMMERCE_TASK_TIME_LIMIT = int(os.getenv("COMMERCE_TASK_TIME_LIMIT", str(120 * 60)))
if COMMERCE_TASK_SOFT_TIME_LIMIT >= COMMERCE_TASK_TIME_LIMIT:
raise ValueError(
"COMMERCE_TASK_SOFT_TIME_LIMIT must be lower than COMMERCE_TASK_TIME_LIMIT."
)
MOYSKLAD_API_BASE_URL = os.getenv(
"MOYSKLAD_API_BASE_URL", "https://api.moysklad.ru/api/remap/1.2"
)
# Долгий импорт commerce изолирован от коротких фоновых задач. Worker должен
# слушать очередь commerce отдельно либо вместе с default: -Q celery,commerce.
CELERY_TASK_ROUTES = {
"commerce.sync_connection_run": {"queue": "commerce"},
}
CELERY_TASK_ANNOTATIONS = {
# Requeue after a hard worker loss stays disabled until every provider has
# an explicit, end-to-end idempotency contract.
"commerce.sync_connection_run": {"reject_on_worker_lost": False},
}
# Настройки для периодических задач (Celery Beat)
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
# PID файлы для Celery (используем /tmp чтобы не требовать root прав)
CELERY_BEAT_PIDFILE = os.path.join(tempfile.gettempdir(), "celery-beat.pid")
CELERYD_PIDFILE = os.path.join(tempfile.gettempdir(), "celery-worker.pid")
# Расписание периодических задач
CELERY_BEAT_SCHEDULE = {
"oidc-backchannel-logout": {
"task": "api.oidc.dispatch_backchannel_logout",
"schedule": 60.0,
},
"commerce-sync-due-connections": {
"task": "commerce.dispatch_due_connection_syncs",
"schedule": 60.0,
},
"open-monthly-payroll-period": {
"task": "finance.tasks.open_monthly_payroll_period",
"schedule": crontab(day_of_month="1", hour=0, minute=5),
},
"cleanup-orphaned-attachments": {
"task": "communications.tasks.cleanup_orphaned_attachments",
"schedule": 3600.0, # Каждый час
},
"notifications-daily-digests": {
"task": "notifications.send_digest_emails",
"schedule": crontab(hour=9, minute=0),
"args": ("daily",),
},
"notifications-weekly-digests": {
"task": "notifications.send_digest_emails",
"schedule": crontab(hour=9, minute=0, day_of_week="monday"),
"args": ("weekly",),
},
"tasks-due-notifications": {
"task": "tasks.dispatch_task_due_notifications",
"schedule": crontab(hour=9, minute=5),
},
"tasks-scheduled-automations": {
"task": "tasks.dispatch_scheduled_automations",
"schedule": 60.0,
},
"tasks-refresh-execution-skills": {
"task": "tasks.refresh_task_execution_skills",
"schedule": crontab(hour=0, minute=20),
},
"process-ldap-sync-queue": {
"task": "employees.tasks.process_ldap_queue",
"schedule": 60.0, # Каждую минуту проверяем очередь LDAP retry
},
"process-due-personnel-actions": {
"task": "requests_app.tasks.process_due_personnel_actions",
"schedule": crontab(hour=0, minute=10), # Каждый день в 00:10
},
"attendance-auto-sync-dispatcher": {
"task": "attendance.tasks.dispatch_attendance_auto_sync",
"schedule": 60.0,
},
"activate-due-guest-visits": {
"task": "guests.tasks.activate_due_guest_visits",
"schedule": 60.0,
},
"expire-guest-visits": {
"task": "guests.tasks.expire_guest_visits",
"schedule": 60.0,
},
"detect-inactive-guest-inviters": {
"task": "guests.tasks.detect_inactive_inviters",
"schedule": 300.0,
},
}
# Опции по умолчанию для всех задач
CELERY_TASK_ACKS_LATE = True # Подтверждаем выполнение после завершения
CELERY_WORKER_PREFETCH_MULTIPLIER = 1 # Берем по 1 задаче за раз
CELERY_WORKER_MAX_TASKS_PER_CHILD = 1000 # Перезапуск worker после 1000 задач
# -----------------------------------------------------------------------------
# DJANGO-FILER CONFIGURATION
# -----------------------------------------------------------------------------
FILER_ENABLE_PERMISSIONS = True # Включаем ACL для файлов
FILER_IS_PUBLIC_DEFAULT = False # По умолчанию файлы приватные
FILER_CANONICAL_URL = "canonical/" # URL для канонических ссылок
# Настройка хранилищ для разных типов файлов
FILER_STORAGES = {
"public": {
"main": {
"ENGINE": "filer.storage.PublicFileSystemStorage",
"OPTIONS": {
"location": os.path.join(MEDIA_ROOT, "documents/public/"),
"base_url": "/media/documents/public/",
},
# Структура: 2026/03/14/filename.pdf
"UPLOAD_TO": "filer.utils.generate_filename.by_date",
},
"thumbnails": {
"ENGINE": "filer.storage.PublicFileSystemStorage",
"OPTIONS": {
"location": os.path.join(MEDIA_ROOT, "documents/public_thumbnails/"),
"base_url": "/media/documents/public_thumbnails/",
},
},
},
"private": {
"main": {
"ENGINE": "filer.storage.PrivateFileSystemStorage",
"OPTIONS": {
"location": os.path.join(MEDIA_ROOT, "documents/private/"),
"base_url": "/smedia/documents/private/",
},
# Структура: 2026/03/14/filename.pdf
"UPLOAD_TO": "filer.utils.generate_filename.by_date",
},
"thumbnails": {
"ENGINE": "filer.storage.PrivateFileSystemStorage",
"OPTIONS": {
"location": os.path.join(MEDIA_ROOT, "documents/private_thumbnails/"),
"base_url": "/smedia/documents/private_thumbnails/",
},
},
},
}
# easy-thumbnails настройки для filer
THUMBNAIL_PROCESSORS = (
"easy_thumbnails.processors.colorspace",
"easy_thumbnails.processors.autocrop",
"filer.thumbnail_processors.scale_and_crop_with_subject_location",
"easy_thumbnails.processors.filters",
)
THUMBNAIL_HIGH_RESOLUTION = True # Поддержка retina-дисплеев
THUMBNAIL_PRESERVE_EXTENSIONS = ("png", "gif") # Сохранять расширения
# Размеры thumbnails по умолчанию
THUMBNAIL_ALIASES = {
"": {
"admin_thumbnail": {"size": (100, 100), "crop": True},
"small": {"size": (200, 200), "crop": False},
"medium": {"size": (400, 400), "crop": False},
"large": {"size": (800, 800), "crop": False},
},
}
# CORS Configuration for Next.js frontend
CORS_ALLOWED_ORIGINS = [
"http://localhost:3000",
"http://127.0.0.1:3000",
]
CORS_ALLOW_CREDENTIALS = True
# ===== Communications App Settings =====
# Функция для разрешения участников чата (callback)
# Позволяет подключить бизнес-специфичную логику (EUSRR: Department,
# EmployeeDepartment)
COMMUNICATIONS_PARTICIPANT_RESOLVER = "employees.utils.resolve_chat_participants"
# -----------------------------------------------------------------------------
# FINANCE / PAYROLL
# -----------------------------------------------------------------------------
# Rules are versioned inputs of the deterministic payroll core. Changing a
# value creates a different ruleset fingerprint; do not silently reuse a
# version after changing calculation semantics.
FINANCE_PAYROLL = {
"RULESET_ID": os.getenv("PAYROLL_RULESET_ID", "eusrr-standard"),
"RULESET_VERSION": os.getenv("PAYROLL_RULESET_VERSION", "2026.09.1"),
"EFFECTIVE_FROM": os.getenv("PAYROLL_EFFECTIVE_FROM", "2026-01-01"),
"EFFECTIVE_TO": os.getenv("PAYROLL_EFFECTIVE_TO") or None,
# Excel-compatible point basis is base accrual plus bonus inputs. Below the
# target it is reduced proportionally; above target the configured excess
# price is used. A blank price resolves to the same in-norm point price.
"POINT_POLICY": os.getenv(
"PAYROLL_POINT_POLICY",
"proportional_with_excess",
),
"MONEY_QUANTUM": os.getenv("PAYROLL_MONEY_QUANTUM", "0.01"),
"ROUNDING": os.getenv("PAYROLL_ROUNDING", "half_up"),
"ALLOW_NEGATIVE_PAYABLE": os.getenv("PAYROLL_ALLOW_NEGATIVE_PAYABLE", "true"),
"BASE_RATE_CODE": os.getenv("PAYROLL_BASE_RATE_CODE", "BASE"),
# Temporary pilot mode. Set to false after the granular finance roles and
# maker-checker workflow have been fully tested.
"SIMPLE_ADMIN_ACCESS": os.getenv("PAYROLL_SIMPLE_ADMIN_ACCESS", "true"),
}