Corp/backend/eusrr_backend/settings.py

946 lines
38 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 os
import tempfile
from datetime import timedelta
from pathlib import Path
from celery.schedules import crontab
from dotenv import load_dotenv
load_dotenv()
BASE_DIR = Path(__file__).resolve().parent.parent
# -----------------------------------------------------------------------------
# БАЗОВЫЕ НАСТРОЙКИ
# -----------------------------------------------------------------------------
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "dev-insecure-key")
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"))
# 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",
"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",
"tasks.apps.TasksConfig",
"guests.apps.GuestsConfig",
"push_notifications", # django-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 ОГРАНИЧЕНИЯ ДЛЯ РЕГИСТРАЦИИ
# -----------------------------------------------------------------------------
# Список разрешенных IP-адресов или сетей для регистрации (формат CIDR)
# Примеры:
# None - разрешены только локальные IP (по умолчанию)
# ['*'] - разрешены все IP
# ['192.168.1.0/24', '10.0.0.0/8'] - конкретные сети
# ['192.168.1.100', '192.168.1.101'] - конкретные IP
REGISTRATION_ALLOWED_IPS = [
"127.0.0.0/8", # localhost
"10.0.0.0/8", # приватная сеть класса A
"172.16.0.0/12", # приватная сеть класса B (172.16-31.x.x)
"172.11.0.0/16", # ваша корпоративная сеть
"192.168.0.0/16", # приватная сеть класса C
]
# Медиа файлы
MEDIA_ROOT = os.path.join(BASE_DIR, "media/")
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"
)
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,
},
"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_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")
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"))
# -----------------------------------------------------------------------------
# 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": {
"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 = {
"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"))
)
# 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", "")
MOYSKLAD_API_BASE_URL = os.getenv(
"MOYSKLAD_API_BASE_URL", "https://api.moysklad.ru/api/remap/1.2"
)
# Приоритеты очередей (закомментировано для упрощения в development)
# В production можно раскомментировать для масштабирования
# CELERY_TASK_ROUTES = {
# 'notifications.*': {'queue': 'notifications'},
# 'documents.tasks.*': {'queue': 'default'},
# 'employees.tasks.*': {'queue': 'default'},
# }
# Настройки для периодических задач (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 = {
"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,
},
"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.08.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"),
}