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.asterisk_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": "Active Directory", "separator": True, "collapsible": True, "items": [ { "title": "Сопоставление пользователей", "icon": "link", "link": reverse_lazy("admin:ad_connector_match_employees"), }, { "title": "Дерево OU / групп", "icon": "account_tree", "link": reverse_lazy("admin:ad_connector_ou_manage"), }, { "title": "Импорт сотрудников из AD", "icon": "download", "link": reverse_lazy("admin:ad_connector_import_employees"), }, { "title": "Журнал синхронизации AD", "icon": "history", "link": reverse_lazy("admin:ad_connector_adsynclog_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, }