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"), }