Files
ACORE/acore/settings/base.py
T
lokses 0057f367a2 core: кастомные страницы ошибок 404/403/500/503 + режим техработ
Единый стиль портала (логотип, тема, кнопки) через error_base.html:
- 404 — страница не найдена
- 403 — доступ запрещён (+ ссылка на IT-поддержку)
- 500 — внутренняя ошибка (автономна: без request/context-процессоров)
- 503 — технические работы

MaintenanceModeMiddleware: при MAINTENANCE_MODE=True отдаёт 503 всем, кроме
суперпользователей и путей входа/админки/статики (чтобы админ мог войти и
выключить режим). По умолчанию выключен — поведение не меняется.

- tests: рендер всех шаблонов, кастомный 404, режим техработ вкл/выкл/супер

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-21 23:54:34 +03:00

740 lines
30 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.
from pathlib import Path
from django.urls import reverse_lazy
import environ
BASE_DIR = Path(__file__).resolve().parent.parent.parent
env = environ.Env()
environ.Env.read_env(BASE_DIR / '.env')
SECRET_KEY = env('SECRET_KEY')
ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=[])
# Ключ для шифрования секретных полей в БД (EncryptedCharField: пароли/токены
# коннекторов, access-токены Mattermost). Задавайте ОТДЕЛЬНО от SECRET_KEY —
# тогда утечка SECRET_KEY не раскрывает секреты at-rest.
# Пустой default: production требует явный ключ; local может fallback на SECRET_KEY.
FIELD_ENCRYPTION_KEY = env('FIELD_ENCRYPTION_KEY', default='')
# E016 — ложноположительный для proxy-моделей над Directory (django-fast-treenode
# динамически добавляет индекс по _path, который proxy «наследует»). На реальных
# таблицах индексы корректны; для proxy это лишь предупреждение проверки.
SILENCED_SYSTEM_CHECKS = ['models.E016']
# Базовый URL портала — используется для абсолютных ссылок в уведомлениях
# (например, Mattermost-бот шлёт ссылку на задачу)
PORTAL_BASE_URL = env('PORTAL_BASE_URL', default='http://localhost:8000')
# CARTO raster basemaps (Leaflet): без ключа используется OpenStreetMap.
# Бесплатный ключ: https://carto.com/basemaps/apikey/
CARTO_BASEMAPS_API_KEY = env('CARTO_BASEMAPS_API_KEY', default='')
# ACORE → PNMS identity sync (portal_connector)
PNMS_BASE_URL = env('PNMS_BASE_URL', default='')
PNMS_ACORE_TOKEN = env('PNMS_ACORE_TOKEN', default='')
MAINTENANCE_MODE = env.bool('MAINTENANCE_MODE', default=False) # режим техработ: всем 503, кроме суперпользователей
PNMS_SYNC_ENABLED = env.bool('PNMS_SYNC_ENABLED', default=False)
# ACORE → MSORTER: только сотрудники в группе «Почта» (админы UI — «Администратор почты»).
MSORTER_BASE_URL = env('MSORTER_BASE_URL', default='')
MSORTER_ACORE_TOKEN = env('MSORTER_ACORE_TOKEN', default='')
MSORTER_SYNC_ENABLED = env.bool('MSORTER_SYNC_ENABLED', default=False)
MSORTER_ACCESS_GROUP = env('MSORTER_ACCESS_GROUP', default='Почта')
MSORTER_ADMIN_GROUP = env('MSORTER_ADMIN_GROUP', default='Администратор почты')
# ACORE → PVMS: identity + reports. PVMS_ATTENDANCE_SOT=1 — ACORE больше не пишет AttendanceLog.
PVMS_BASE_URL = env('PVMS_BASE_URL', default='')
PVMS_ACORE_TOKEN = env('PVMS_ACORE_TOKEN', default='')
# Reports only; falls back to PVMS_ACORE_TOKEN when empty.
PVMS_REPORT_TOKEN = env('PVMS_REPORT_TOKEN', default='')
PVMS_SYNC_ENABLED = env.bool('PVMS_SYNC_ENABLED', default=False)
PVMS_ATTENDANCE_SOT = env.bool('PVMS_ATTENDANCE_SOT', default=False)
# Токен слэш-команды Mattermost (System Console/Team → Integrations → Slash Commands).
# Пустая строка по умолчанию — запросы без совпадающего токена всегда отклоняются (fail-closed),
# пока команда реально не создана в Mattermost и токен не прописан в .env.
MATTERMOST_SLASH_COMMAND_TOKEN = env('MATTERMOST_SLASH_COMMAND_TOKEN', default='')
# Токен интерактивных кнопок Mattermost (attachment actions). Fail-closed:
# пустой токен → task_done_action отклоняет все запросы. Можно совпадать со
# slash-токеном или задать отдельный секрет.
MATTERMOST_INTERACTIVE_TOKEN = env(
'MATTERMOST_INTERACTIVE_TOKEN',
default=env('MATTERMOST_SLASH_COMMAND_TOKEN', default=''),
)
INSTALLED_APPS = [
# Admin
"unfold", # before django.contrib.admin
"unfold.contrib.filters", # optional, if special filters are needed
"unfold.contrib.forms", # optional, if special form elements are needed
"unfold.contrib.inlines", # optional, if special inlines are needed
"unfold.contrib.import_export", # optional, if django-import-export package is used
"unfold.contrib.guardian", # optional, if django-guardian package is used
"unfold.contrib.simple_history", # optional, if django-simple-history package is used
"unfold.contrib.location_field", # optional, if django-location-field package is used
"unfold.contrib.constance", # optional, if django-constance package is used
# Django
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.postgres', # ArrayField (mattermost sync location_types)
# Third-party
'treenode',
'location_field',
'import_export',
'rest_framework',
# Core
'core',
# Apps
'apps.locations',
'apps.employees',
'apps.visit_manager',
'tinymce',
'apps.it_manager',
'apps.reports',
'apps.feed',
'apps.tasks',
'apps.hiring',
'apps.utilities',
'apps.power_assets',
'apps.rro',
'apps.help',
'apps.ad_connector',
'apps.mail_connector',
'apps.mailparser',
'apps.mattermost_connector',
'apps.portal_connector.apps.PortalConnectorConfig',
'apps.nextcloud_connector',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware', # выбор языка (после Session, до Common)
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'core.middleware.MaintenanceModeMiddleware', # режим техработ (503), после Auth
'apps.employees.middleware.TwoFactorMiddleware', # обязательная 2FA для staff (после Auth)
'core.middleware.DevAccessPreviewMiddleware',
'core.middleware.PortalSectionAccessMiddleware', # барьер разделов (после preview)
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'core.middleware.UpdateLastSeenMiddleware',
]
ROOT_URLCONF = 'acore.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.request',
'django.template.context_processors.i18n',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'core.context_processors.portal_map_config',
'core.context_processors.access_preview',
],
},
},
]
WSGI_APPLICATION = 'acore.wsgi.application'
ASGI_APPLICATION = 'acore.asgi.application'
DATABASES = {
'default': env.db('DATABASE_URL')
}
AUTH_USER_MODEL = 'employees.Employee'
AUTH_PASSWORD_VALIDATORS = [
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
{'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
]
LANGUAGE_CODE = 'ru' # базовый язык интерфейса (исходные строки msgid)
LANGUAGES = [
('ru', 'Русский'),
('uk', 'Українська'),
('en', 'English'),
]
LOCALE_PATHS = [BASE_DIR / 'locale']
TIME_ZONE = 'Europe/Kyiv'
USE_I18N = True
USE_TZ = True
STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_DIRS = [BASE_DIR / 'static']
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
LOGIN_URL = '/accounts/login/'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# Срок хранения для авто-очистки (manage.py cleanup): журналы синхронизации,
# инвайты паролей старше N дней удаляются. Просроченные сессии — всегда.
CLEANUP_LOG_RETENTION_DAYS = env.int('CLEANUP_LOG_RETENTION_DAYS', default=90)
# Required for OSM tile usage policy: send origin as Referer on cross-origin requests.
SECURE_REFERRER_POLICY = 'strict-origin-when-cross-origin'
# --- Security ---
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax'
CSRF_COOKIE_HTTPONLY = True
CSRF_COOKIE_SAMESITE = 'Lax'
X_FRAME_OPTIONS = 'DENY'
# File upload limits
DATA_UPLOAD_MAX_MEMORY_SIZE = 15 * 1024 * 1024 # 15 MB
FILE_UPLOAD_MAX_MEMORY_SIZE = 15 * 1024 * 1024 # 15 MB
DATA_UPLOAD_MAX_NUMBER_FIELDS = 500
UNFOLD = {
"SITE_TITLE": "A-CORE",
"SITE_HEADER": "A-CORE",
"SITE_SUBHEADER": "Управление аптечной сетью",
"SITE_URL": "/",
"COLORS": {
"base": {
"50": "249, 250, 251",
"100": "243, 244, 246",
"200": "229, 231, 235",
"300": "209, 213, 219",
"400": "156, 163, 175",
"500": "107, 114, 128",
"600": "75, 85, 99",
"700": "55, 65, 81",
"800": "31, 41, 55",
"900": "17, 24, 39",
"950": "3, 7, 18",
},
"primary": {
"50": "255, 241, 242",
"100": "255, 228, 230",
"200": "254, 205, 211",
"300": "253, 164, 175",
"400": "251, 113, 133",
"500": "244, 63, 94",
"600": "227, 6, 19",
"700": "190, 18, 60",
"800": "159, 18, 57",
"900": "136, 19, 55",
"950": "76, 5, 25",
},
"font": {
"subtle-light": "var(--color-base-500)",
"subtle-dark": "var(--color-base-400)",
"default-light": "var(--color-base-600)",
"default-dark": "var(--color-base-300)",
"important-light": "var(--color-base-900)",
"important-dark": "var(--color-base-100)",
},
},
"SIDEBAR": {
"show_search": True,
"show_all_applications": False,
"navigation": [
{
"title": "Сотрудники",
"separator": False,
"collapsible": False,
"items": [
{
"title": "Сотрудники",
"icon": "people",
"link": reverse_lazy("admin:employees_employee_changelist"),
},
{
"title": "Группы доступа",
"icon": "shield_person",
"link": reverse_lazy("admin:auth_group_changelist"),
},
{
"title": "Индивидуальные права",
"icon": "key",
"link": reverse_lazy("admin:employees_employeepermissionoverride_changelist"),
},
{
"title": "Кадровые заявки",
"icon": "assignment_ind",
"link": reverse_lazy("admin:employees_employeehrrequest_changelist"),
},
{
"title": "Нормы отпусков",
"icon": "beach_access",
"link": reverse_lazy("admin:employees_leaveentitlement_changelist"),
},
{
"title": "Настройки по умолчанию",
"icon": "tune",
"link": reverse_lazy("admin:employees_employeedefaults_changelist"),
},
{
"title": "Дашборд по умолчанию",
"icon": "dashboard_customize",
"link": reverse_lazy("admin:core_dashboarddefaults_changelist"),
},
],
},
{
"title": "Найм",
"separator": True,
"collapsible": True,
"items": [
{
"title": "Заявки на найм",
"icon": "how_to_reg",
"link": reverse_lazy("admin:hiring_hiringrequest_changelist"),
},
{
"title": "Вакансии",
"icon": "work",
"link": reverse_lazy("admin:hiring_vacancy_changelist"),
},
{
"title": "Интерн-аккаунты",
"icon": "badge",
"link": reverse_lazy("admin:hiring_internaccount_changelist"),
},
],
},
{
"title": "Структура компании",
"separator": True,
"collapsible": True,
"items": [
{
"title": "Аптеки",
"icon": "local_pharmacy",
"link": reverse_lazy("admin:locations_pharmacy_changelist"),
},
{
"title": "Локации (офисы, склады)",
"icon": "location_on",
"link": reverse_lazy("admin:locations_location_changelist"),
},
{
"title": "Справочники",
"icon": "menu_book",
"link": reverse_lazy("admin:core_directory_changelist"),
},
{
"title": "Типы справочников",
"icon": "category",
"link": reverse_lazy("admin:core_directorytype_changelist"),
},
{
"title": "Системы доступа",
"icon": "vpn_key",
"link": reverse_lazy("admin:core_accesssystem_changelist"),
},
{
"title": "Колонки дислокации",
"icon": "view_column",
"link": reverse_lazy("admin:locations_dislocationcolumnpreset_changelist"),
},
{
"title": "Видимость карточки аптеки",
"icon": "visibility",
"link": reverse_lazy("admin:locations_pharmacycardvisibility_changelist"),
},
],
},
{
"title": "Учёт посещений",
"separator": True,
"collapsible": True,
"items": [
{
"title": "Записи посещений",
"icon": "how_to_reg",
"link": reverse_lazy("admin:visit_manager_attendancelog_changelist"),
},
{
"title": "Рабочие смены (факт)",
"icon": "work_history",
"link": reverse_lazy("admin:visit_manager_workshift_changelist"),
},
{
"title": "Шаблоны смен",
"icon": "schedule",
"link": reverse_lazy("admin:visit_manager_shift_changelist"),
},
{
"title": "Расписания",
"icon": "event_note",
"link": reverse_lazy("admin:visit_manager_scheduletemplate_changelist"),
},
{
"title": "Рабочие расписания",
"icon": "calendar_month",
"link": reverse_lazy("admin:visit_manager_workschedule_changelist"),
},
{
"title": "События календаря",
"icon": "event",
"link": reverse_lazy("admin:visit_manager_calendarevent_changelist"),
},
{
"title": "Праздники",
"icon": "celebration",
"link": reverse_lazy("admin:visit_manager_holiday_changelist"),
},
],
},
{
"title": "IT-отдел",
"separator": True,
"collapsible": True,
"items": [
{
"title": "Оборудование",
"icon": "computer",
"link": reverse_lazy("admin:it_manager_equipment_changelist"),
},
{
"title": "Модели оборудования",
"icon": "devices",
"link": reverse_lazy("admin:it_manager_equipmentmodel_changelist"),
},
{
"title": "Расходники",
"icon": "ink_eraser",
"link": reverse_lazy("admin:it_manager_consumable_changelist"),
},
{
"title": "Заявки",
"icon": "confirmation_number",
"link": reverse_lazy("admin:it_manager_ticket_changelist"),
},
{
"title": "Журнал работ",
"icon": "build",
"link": reverse_lazy("admin:it_manager_worklog_changelist"),
},
{
"title": "Обращения",
"icon": "support_agent",
"link": reverse_lazy("admin:it_manager_servicecall_changelist"),
},
{
"title": "Провайдеры",
"icon": "router",
"link": reverse_lazy("admin:it_manager_internetprovider_changelist"),
},
{
"title": "Договоры интернет",
"icon": "cable",
"link": reverse_lazy("admin:it_manager_internetcontract_changelist"),
},
],
},
{
"title": "Эксплуатация",
"separator": True,
"collapsible": True,
"items": [
{
"title": "Кассы РРО/ПРРО",
"icon": "point_of_sale",
"link": reverse_lazy("admin:rro_cashregister_changelist"),
},
{
"title": "Показания счётчиков",
"icon": "speed",
"link": reverse_lazy("admin:utilities_utilityreading_changelist"),
},
{
"title": "Настройки счётчиков",
"icon": "settings_input_component",
"link": reverse_lazy("admin:utilities_utilitymeterconfig_changelist"),
},
{
"title": "Энергооборудование",
"icon": "bolt",
"link": reverse_lazy("admin:power_assets_powerasset_changelist"),
},
{
"title": "Журнал обслуживания",
"icon": "construction",
"link": reverse_lazy("admin:power_assets_powerassetservice_changelist"),
},
],
},
{
"title": "Отчёты",
"separator": True,
"collapsible": True,
"items": [
{
"title": "Шаблоны отчётов",
"icon": "description",
"link": reverse_lazy("admin:reports_reporttemplate_changelist"),
},
{
"title": "Публикации отчётов",
"icon": "publish",
"link": reverse_lazy("admin:reports_reportpublication_changelist"),
},
{
"title": "Документы отчётов",
"icon": "folder_shared",
"link": reverse_lazy("admin:reports_reportdocument_changelist"),
},
],
},
{
"title": "Лента и задачи",
"separator": True,
"collapsible": True,
"items": [
{
"title": "Публикации",
"icon": "feed",
"link": reverse_lazy("admin:feed_post_changelist"),
},
{
"title": "Задачи",
"icon": "task_alt",
"link": reverse_lazy("admin:tasks_task_changelist"),
},
],
},
{
"title": "База знаний",
"separator": True,
"collapsible": True,
"items": [
{
"title": "Категории",
"icon": "folder",
"link": reverse_lazy("admin:help_helpcategory_changelist"),
},
{
"title": "Статьи",
"icon": "article",
"link": reverse_lazy("admin:help_helparticle_changelist"),
},
{
"title": "Предложения",
"icon": "lightbulb",
"link": reverse_lazy("admin:help_helpcontribution_changelist"),
},
],
},
{
"title": "Mattermost",
"separator": True,
"collapsible": True,
"items": [
{
"title": "Боты",
"icon": "smart_toy",
"link": reverse_lazy("admin:mattermost_connector_mattermostbot_changelist"),
},
{
"title": "Пользователи",
"icon": "chat",
"link": reverse_lazy("admin:mattermost_connector_mattermostuser_changelist"),
},
{
"title": "Команды",
"icon": "group",
"link": reverse_lazy("admin:mattermost_connector_mattermostteam_changelist"),
},
{
"title": "Каналы",
"icon": "tag",
"link": reverse_lazy("admin:mattermost_connector_mattermostchannel_changelist"),
},
{
"title": "Синхронизация команд",
"icon": "sync",
"link": reverse_lazy("admin:mattermost_connector_teamsyncsettings_changelist"),
},
{
"title": "Синхронизация каналов",
"icon": "sync_alt",
"link": reverse_lazy("admin:mattermost_connector_channelsyncsettings_changelist"),
},
],
},
{
"title": "Интеграции и настройки",
"separator": True,
"collapsible": True,
"items": [
{
"title": "Конфиги коннекторов",
"icon": "settings",
"link": reverse_lazy("admin:core_connectorconfig_changelist"),
},
{
"title": "Журнал AD",
"icon": "manage_accounts",
"link": reverse_lazy("admin:ad_connector_adsynclog_changelist"),
},
{
"title": "Журнал Carbonio",
"icon": "mail",
"link": reverse_lazy("admin:mail_connector_carboniosynclog_changelist"),
},
{
"title": "Журнал Nextcloud",
"icon": "cloud",
"link": reverse_lazy("admin:nextcloud_connector_nextcloudsynclog_changelist"),
},
{
"title": "Журнал Mattermost",
"icon": "forum",
"link": reverse_lazy("admin:mattermost_connector_mattermostsynclog_changelist"),
},
],
},
],
},
}
LOCATION_FIELD = {
'map.provider': 'openstreetmap',
'map.zoom': 14,
'search.provider': 'nominatim',
'search.suffix': '',
'provider.openstreetmap.max_zoom': 19,
}
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.SessionAuthentication',
# BasicAuthentication убран: передаёт credentials в открытом виде
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
],
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 20,
}
# Без аргументов `manage.py test` должен покрывать core + apps.*, а не только
# то, что unittest находит от корня проекта (см. acore/test_runner.py).
TEST_RUNNER = 'acore.test_runner.AcoreDiscoverRunner'
# Локально — in-process кэш. В production.py переопределяется на Redis:
# иначе троттлинг логина на нескольких воркерах не общий и сбрасывается рестартом.
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'LOCATION': 'acore-local',
}
}
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'verbose': {
'format': '{levelname} {asctime} {module} {process:d} {thread:d} {message}',
'style': '{',
},
'simple': {
'format': '{levelname} {message}',
'style': '{',
},
},
'handlers': {
'file': {
'level': 'INFO',
'class': 'logging.handlers.RotatingFileHandler',
'filename': BASE_DIR / 'logs' / 'django.log',
'maxBytes': 10 * 1024 * 1024,
'backupCount': 5,
'formatter': 'verbose',
},
'console': {
'level': 'INFO',
'class': 'logging.StreamHandler',
'formatter': 'simple',
},
},
'root': {
'handlers': ['console', 'file'],
'level': 'INFO',
},
'loggers': {
'apps.mattermost_connector': {
'handlers': ['file'],
'level': 'INFO',
'propagate': False,
},
'apps.ad_connector': {
'handlers': ['file'],
'level': 'INFO',
'propagate': False,
},
'security.audit': {
'handlers': ['file'],
'level': 'INFO',
'propagate': False,
},
'apps.mail_connector': {
'handlers': ['file'],
'level': 'INFO',
'propagate': False,
},
'apps.nextcloud_connector': {
'handlers': ['file'],
'level': 'INFO',
'propagate': False,
},
'apps.visit_manager': {
'handlers': ['file'],
'level': 'INFO',
'propagate': False,
},
},
}
# --- Celery ---
CELERY_BROKER_URL = env('CELERY_BROKER_URL', default='redis://localhost:6379/0')
CELERY_RESULT_BACKEND = env('CELERY_RESULT_BACKEND', default=CELERY_BROKER_URL)
CELERY_TIMEZONE = TIME_ZONE
# Без брокера (локальная разработка) задачи выполняются синхронно
CELERY_TASK_ALWAYS_EAGER = env.bool('CELERY_TASK_ALWAYS_EAGER', default=False)
# --- TinyMCE (редактор постов ленты) ---
TINYMCE_DEFAULT_CONFIG = {
'height': 360,
'menubar': False,
'plugins': 'lists link table autolink image charmap',
'toolbar': (
'undo redo | blocks | bold italic underline strikethrough | '
'forecolor | alignleft aligncenter alignright | '
'bullist numlist | link table | removeformat'
),
'language': 'uk',
'license_key': 'gpl',
'promotion': False,
'branding': False,
'convert_urls': False,
}