Initial commit — ACORE pharmacy network management system
Django project: employees, locations, visit_manager, it_manager, mattermost_connector and stub connectors (ad, mail, portal).
This commit is contained in:
+152
@@ -0,0 +1,152 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
pip-wheel-metadata/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
||||
__pypa__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
|
||||
# Static and media
|
||||
staticfiles/
|
||||
media/
|
||||
|
||||
# Project-specific
|
||||
.env*
|
||||
.directory
|
||||
packets.txt
|
||||
|
||||
# Claude Code
|
||||
.claude/settings.local.json
|
||||
@@ -0,0 +1,169 @@
|
||||
# ACORE: описание проекта и связей
|
||||
|
||||
Этот документ можно передать как стартовую архитектурную справку для другого проекта.
|
||||
Он описывает текущую структуру ACORE и показывает, как организованы доменные связи.
|
||||
|
||||
## 1. Назначение проекта
|
||||
|
||||
ACORE — Django-проект для управления сетью аптек.
|
||||
Основные сценарии:
|
||||
- учет сотрудников и их оргструктуры;
|
||||
- учет аптек и геолокаций;
|
||||
- справочники организаций, отделов, должностей, регионов;
|
||||
- интеграции (Mattermost, Active Directory, почта, портал);
|
||||
- отображение данных в админке и web-интерфейсе.
|
||||
|
||||
## 2. Технологический стек
|
||||
|
||||
- Python + Django
|
||||
- Django ORM
|
||||
- Django Admin (кастомизированный)
|
||||
- Много приложений в папке apps
|
||||
- SQLite в текущей локальной среде
|
||||
|
||||
## 3. Модульная структура
|
||||
|
||||
- acore: конфигурация проекта, URL и настройки
|
||||
- core: базовые модели, справочники, общие view и утилиты
|
||||
- apps/employees: сотрудники, права, сериализация и админка
|
||||
- apps/locations: локации и аптеки
|
||||
- apps/visit_manager: расписания и посещаемость
|
||||
- apps/mattermost_connector: интеграция Mattermost
|
||||
- apps/ad_connector: интеграция Active Directory
|
||||
- apps/mail_connector, apps/mailparser, apps/portal_connector: интеграционные модули
|
||||
|
||||
## 4. Ключевые доменные сущности
|
||||
|
||||
### 4.1 База
|
||||
|
||||
- BaseModel (core.models.base):
|
||||
- uuid, created_at, updated_at
|
||||
- используется как абстрактная база для большинства сущностей
|
||||
|
||||
### 4.2 Справочники
|
||||
|
||||
- DirectoryType (core.models.directories): тип справочника
|
||||
- Directory (core.models.directories): универсальный элемент справочника (иерархический)
|
||||
|
||||
Типичные значения DirectoryType:
|
||||
- organization
|
||||
- department
|
||||
- post
|
||||
- region, district, city, city_district
|
||||
|
||||
Directory используется как единая точка для:
|
||||
- организаций;
|
||||
- отделов;
|
||||
- должностей;
|
||||
- региональной структуры;
|
||||
- других классификаторов.
|
||||
|
||||
### 4.3 Локации и аптеки
|
||||
|
||||
- Location (apps.locations.models.base): базовая географическая сущность
|
||||
- связь на Directory (location_region)
|
||||
- тип локации: pharmacy, warehouse, office
|
||||
- Pharmacy (apps.locations.models.pharmacies): наследник Location (multi-table inheritance)
|
||||
- organization -> Directory (organization)
|
||||
- head_pharmacist -> Employee (AUTH_USER_MODEL)
|
||||
- уникальность: organization + pharmacy_number
|
||||
- PharmacyPhone:
|
||||
- pharmacy -> Pharmacy (1:N)
|
||||
- PharmacyHistory:
|
||||
- pharmacy -> Pharmacy (1:N)
|
||||
- changed_by -> Employee (AUTH_USER_MODEL)
|
||||
|
||||
### 4.4 Сотрудники
|
||||
|
||||
- Employee (apps.employees.models.base): кастомный пользователь
|
||||
- user_organization -> Directory (organization)
|
||||
- user_pharmacy -> Pharmacy
|
||||
- department -> Directory (department)
|
||||
- post -> Directory (post)
|
||||
- manager -> Employee (self FK, иерархия руководитель-подчиненный)
|
||||
- PermissionGroup (apps.employees.models.permissions): профиль для auth.Group
|
||||
- EmployeePermissionOverride:
|
||||
- employee -> Employee
|
||||
- permission -> auth.Permission
|
||||
|
||||
## 5. Связи между модулями
|
||||
|
||||
- core.Directory и core.DirectoryType являются центральным справочным слоем
|
||||
- locations.Pharmacy использует Directory для организации и региона
|
||||
- employees.Employee использует:
|
||||
- Directory для организации, отдела и должности
|
||||
- Pharmacy для привязки сотрудника к аптеке
|
||||
- core.views строит страницы, агрегируя Employee, Pharmacy и Directory
|
||||
- employees.admin и locations.admin подключают visit_manager для связанных inlines и мониторинга активности
|
||||
|
||||
## 6. Поток данных (упрощенно)
|
||||
|
||||
1. Справочники (DirectoryType/Directory) формируют допустимые значения.
|
||||
2. Создаются Location и Pharmacy с привязкой к организации и региону.
|
||||
3. Создаются Employee с привязкой к организации/аптеке/отделу/должности.
|
||||
4. Интерфейс (core.views + templates) выводит агрегированные данные и фильтры.
|
||||
5. Интеграторы (Mattermost/AD/почта/портал) синхронизируют внешние системы.
|
||||
|
||||
## 7. Маршрутизация
|
||||
|
||||
Главные URL находятся в acore/urls.py:
|
||||
- административный интерфейс;
|
||||
- страницы dashboard, employees, pharmacies и другие;
|
||||
- подключение маршрутов приложения employees.
|
||||
|
||||
## 8. Что перенести в другой проект
|
||||
|
||||
Если создавать похожий проект, рекомендуется сохранить такой каркас:
|
||||
|
||||
1. Единая модель справочников (DirectoryType + Directory) для всех классификаторов.
|
||||
2. Базовая абстрактная модель (BaseModel) с uuid и аудит-полями.
|
||||
3. Разделение на доменные приложения:
|
||||
- people (сотрудники и права)
|
||||
- locations (локации и объекты)
|
||||
- integrations (внешние коннекторы)
|
||||
4. Наследование для локаций:
|
||||
- общий Location
|
||||
- специализированные типы (Pharmacy, Warehouse и т.д.).
|
||||
5. Явные внешние ключи на справочники вместо жестко зашитых enum-значений.
|
||||
|
||||
## 9. Краткий шаблон для нового проекта
|
||||
|
||||
Название проекта: <ProjectName>
|
||||
|
||||
Цель:
|
||||
- <Ключевая бизнес-цель>
|
||||
|
||||
Контексты:
|
||||
- Справочники
|
||||
- Локации/объекты
|
||||
- Сотрудники
|
||||
- Интеграции
|
||||
|
||||
Базовые модели:
|
||||
- BaseModel
|
||||
- DirectoryType
|
||||
- Directory
|
||||
|
||||
Основные сущности:
|
||||
- Location
|
||||
- <DomainObject1>
|
||||
- Employee
|
||||
|
||||
Ключевые связи:
|
||||
- Employee -> Directory (organization/department/post)
|
||||
- Employee -> <DomainObject1>
|
||||
- <DomainObject1> -> Directory (region/organization)
|
||||
|
||||
Технические решения:
|
||||
- Django apps по доменным контекстам
|
||||
- Сервисный слой интеграций
|
||||
- Единые правила валидации и аудита
|
||||
|
||||
## 10. Итог
|
||||
|
||||
Текущий ACORE построен вокруг трех опор:
|
||||
- универсальные справочники;
|
||||
- доменные модели сотрудников и аптек;
|
||||
- интеграционные коннекторы.
|
||||
|
||||
Такая структура хорошо переносится в другой проект с минимальными изменениями доменной модели.
|
||||
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
ASGI config for acore project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'acore.settings')
|
||||
|
||||
application = get_asgi_application()
|
||||
@@ -0,0 +1,406 @@
|
||||
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=[])
|
||||
|
||||
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',
|
||||
|
||||
# Third-party
|
||||
'treenode',
|
||||
'location_field',
|
||||
'import_export',
|
||||
'rest_framework',
|
||||
|
||||
# Core
|
||||
'core',
|
||||
|
||||
# Apps
|
||||
'apps.locations',
|
||||
'apps.employees',
|
||||
'apps.visit_manager',
|
||||
'apps.it_manager',
|
||||
'apps.ad_connector',
|
||||
'apps.mail_connector',
|
||||
'apps.mailparser',
|
||||
'apps.mattermost_connector',
|
||||
'apps.portal_connector',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
]
|
||||
|
||||
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.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
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'
|
||||
TIME_ZONE = 'Europe/Kiev'
|
||||
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'
|
||||
|
||||
# Required for OSM tile usage policy: send origin as Referer on cross-origin requests.
|
||||
SECURE_REFERRER_POLICY = 'strict-origin-when-cross-origin'
|
||||
|
||||
|
||||
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": "Структура компании",
|
||||
"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": "Учёт посещений",
|
||||
"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": "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": "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": "Mattermost",
|
||||
"separator": True,
|
||||
"collapsible": True,
|
||||
"items": [
|
||||
{
|
||||
"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"),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
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',
|
||||
'rest_framework.authentication.BasicAuthentication',
|
||||
],
|
||||
'DEFAULT_PERMISSION_CLASSES': [
|
||||
'rest_framework.permissions.IsAuthenticated',
|
||||
],
|
||||
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
|
||||
'PAGE_SIZE': 20,
|
||||
}
|
||||
|
||||
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': 'ERROR',
|
||||
'class': 'logging.FileHandler',
|
||||
'filename': BASE_DIR / 'logs' / 'django.log',
|
||||
'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,
|
||||
},
|
||||
'apps.mail_connector': {
|
||||
'handlers': ['file'],
|
||||
'level': 'INFO',
|
||||
'propagate': False,
|
||||
},
|
||||
'apps.visit_manager': {
|
||||
'handlers': ['file'],
|
||||
'level': 'INFO',
|
||||
'propagate': False,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
from .base import * # noqa
|
||||
|
||||
DEBUG = True
|
||||
|
||||
ALLOWED_HOSTS = ['*']
|
||||
|
||||
# Django Debug Toolbar — подключить если нужно
|
||||
# INSTALLED_APPS += ['debug_toolbar']
|
||||
# MIDDLEWARE += ['debug_toolbar.middleware.DebugToolbarMiddleware']
|
||||
# INTERNAL_IPS = ['127.0.0.1']
|
||||
@@ -0,0 +1,9 @@
|
||||
from .base import * # noqa
|
||||
|
||||
DEBUG = False
|
||||
|
||||
CSRF_COOKIE_SECURE = True
|
||||
SESSION_COOKIE_SECURE = True
|
||||
SECURE_HSTS_SECONDS = 31536000
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
||||
SECURE_SSL_REDIRECT = True
|
||||
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
URL configuration for acore project.
|
||||
|
||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||
https://docs.djangoproject.com/en/6.0/topics/http/urls/
|
||||
Examples:
|
||||
Function views
|
||||
1. Add an import: from my_app import views
|
||||
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||
Class-based views
|
||||
1. Add an import: from other_app.views import Home
|
||||
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||
Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.urls import path, include
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
|
||||
from core import views
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path('api/attendance/', include('apps.visit_manager.api.urls')),
|
||||
path('', include('apps.employees.urls')),
|
||||
path('', views.index_view, name='index'),
|
||||
path('accounts/login/', views.login_view, name='login'),
|
||||
path('login/', views.login_view, name='login'),
|
||||
path('logout/', views.logout_view, name='logout'),
|
||||
path('dashboard/', views.dashboard_view, name='dashboard'),
|
||||
path('employees/', views.employees_view, name='employees'),
|
||||
path('pharmacies/', views.pharmacies_view, name='pharmacies'),
|
||||
path('calendar/', views.calendar_view, name='calendar'),
|
||||
path('timetrack/', views.timetrack_view, name='timetrack'),
|
||||
path('reports/', views.reports_view, name='reports'),
|
||||
path('documents/', views.documents_view, name='documents'),
|
||||
path('recruiting/', views.recruiting_view, name='recruiting'),
|
||||
path('structure/', views.structure_view, name='structure'),
|
||||
path('settings/', views.settings_view, name='settings'),
|
||||
path('modal-add-employee/', views.modal_add_employee_view, name='modal_add_employee'),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
urlpatterns += staticfiles_urlpatterns()
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
WSGI config for acore project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'acore.settings')
|
||||
|
||||
application = get_wsgi_application()
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AdConnectorConfig(AppConfig):
|
||||
name = 'apps.ad_connector'
|
||||
verbose_name = 'Active Directory Connector'
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
@@ -0,0 +1,224 @@
|
||||
from django import forms
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from unfold.admin import ModelAdmin, TabularInline
|
||||
|
||||
from core.models import Directory
|
||||
from .models import Employee, PermissionGroup
|
||||
from apps.visit_manager.models import WorkSchedule, AttendanceLog
|
||||
|
||||
class HierarchicalDepartmentChoiceField(forms.ModelChoiceField):
|
||||
"""Поле выбора отдела с отступами по иерархии в выпадающем списке."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
queryset = kwargs.get('queryset') or Directory.objects.filter(
|
||||
directory_type__code='department',
|
||||
is_active=True,
|
||||
).select_related('directory_type').order_by('_path')
|
||||
kwargs['queryset'] = queryset
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def label_from_instance(self, obj):
|
||||
depth = (getattr(obj, '_path', '') or '').count('.')
|
||||
indent = '---' * depth + ('--- ' if depth else '')
|
||||
return f'{indent}{obj.name}'
|
||||
|
||||
class OfficeWarehouseChoiceField(forms.ModelChoiceField):
|
||||
def label_from_instance(self, obj):
|
||||
type_label = obj.get_location_type_display()
|
||||
addr = obj.get_full_address() or obj.address or ''
|
||||
return f"{type_label} — {addr}" if addr else type_label
|
||||
|
||||
|
||||
class EmployeeAdminForm(forms.ModelForm):
|
||||
department = HierarchicalDepartmentChoiceField(
|
||||
label=_('Отдел'),
|
||||
required=False,
|
||||
empty_label=_('— Выберите отдел —'),
|
||||
widget=forms.Select(attrs={
|
||||
'class': 'hierarchy-department-select',
|
||||
'data-placeholder': 'Департамент → Отдел',
|
||||
}),
|
||||
help_text=_('Выберите из списка. Уровни вложенности отображаются отступами.'),
|
||||
)
|
||||
user_location = OfficeWarehouseChoiceField(
|
||||
queryset=None,
|
||||
required=False,
|
||||
empty_label=_('— Выберите офис или склад —'),
|
||||
label=_('Локация (офис/склад)'),
|
||||
help_text=_('Заполняется для location_type=office или warehouse'),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
from apps.locations.models import Location
|
||||
self.fields['user_location'].queryset = (
|
||||
Location.objects
|
||||
.filter(location_type__in=['office', 'warehouse'], is_active=True)
|
||||
.select_related('location_region')
|
||||
.order_by('location_type', 'address')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Employee
|
||||
fields = '__all__'
|
||||
|
||||
class WorkScheduleInline(TabularInline):
|
||||
model = WorkSchedule
|
||||
fk_name = 'employee'
|
||||
extra = 0
|
||||
fields = ('schedule_template', 'location', 'start_date', 'end_date', 'is_primary', 'is_active')
|
||||
readonly_fields = ('is_active',)
|
||||
ordering = ('-start_date',)
|
||||
show_change_link = True
|
||||
|
||||
def has_add_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
class AttendanceLogInline(TabularInline):
|
||||
model = AttendanceLog
|
||||
fk_name = 'employee'
|
||||
extra = 0
|
||||
max_num = 0
|
||||
fields = ('timestamp', 'direction', 'location', 'device_id', 'synced')
|
||||
readonly_fields = ('timestamp', 'direction', 'location', 'device_id', 'synced')
|
||||
ordering = ('-timestamp',)
|
||||
show_change_link = True
|
||||
|
||||
def has_add_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
@admin.register(Employee)
|
||||
class EmployeeAdmin(ModelAdmin):
|
||||
form = EmployeeAdminForm
|
||||
inlines = [WorkScheduleInline, AttendanceLogInline]
|
||||
|
||||
class Media:
|
||||
css = {'all': ('users/css/employee_admin.css',)}
|
||||
js = ('assets/js/admin_phone_format.js',)
|
||||
|
||||
list_display = [
|
||||
'photo_thumbnail', 'username', 'last_name', 'first_name', 'role',
|
||||
'user_location_type', 'user_pharmacy', 'department_display', 'post',
|
||||
'is_active', 'hire_date'
|
||||
]
|
||||
list_filter = ['role', 'is_active', 'is_staff', 'user_location_type', 'department']
|
||||
search_fields = ['username', 'email', 'last_name', 'first_name', 'middle_name']
|
||||
list_select_related = ['user_pharmacy', 'department', 'post']
|
||||
readonly_fields = ('photo_preview',)
|
||||
|
||||
fieldsets = (
|
||||
(_('Аутентификация'), {
|
||||
'fields': ('username', 'email', 'role', 'is_staff', 'is_active'),
|
||||
'classes': ('tab',),
|
||||
}),
|
||||
(_('ФИО'), {
|
||||
'fields': ('last_name', 'first_name', 'middle_name'),
|
||||
'classes': ('tab',),
|
||||
}),
|
||||
(_('Рабочая информация'), {
|
||||
'fields': (
|
||||
'user_location_type', 'user_organization',
|
||||
'user_pharmacy', 'user_location',
|
||||
'department', 'post', 'manager',
|
||||
),
|
||||
'classes': ('tab',),
|
||||
}),
|
||||
(_('Даты'), {
|
||||
'fields': ('hire_date', 'termination_date', 'probation_end_date', 'date_joined'),
|
||||
'classes': ('tab',),
|
||||
}),
|
||||
(_('Личные данные'), {
|
||||
'fields': ('birth_date', 'inn_code', 'photo', 'photo_preview'),
|
||||
'classes': ('tab',),
|
||||
}),
|
||||
(_('Контакты'), {
|
||||
'fields': (
|
||||
'phone', 'personal_phone', 'personal_email',
|
||||
'emergency_contact_name', 'emergency_contact_phone', 'emergency_contact_relation',
|
||||
),
|
||||
'classes': ('tab',),
|
||||
}),
|
||||
('Mattermost', {
|
||||
'fields': ('mattermost_user_id', 'mattermost_username', 'mattermost_sync_status',
|
||||
'mattermost_last_sync', 'mattermost_sync_enabled'),
|
||||
'classes': ('tab', 'collapse'),
|
||||
}),
|
||||
('Active Directory', {
|
||||
'fields': ('ad_user_dn', 'ad_sync_status', 'ad_sync_error', 'ad_last_sync', 'ad_sync_enabled'),
|
||||
'classes': ('tab', 'collapse'),
|
||||
}),
|
||||
(_('Графік та відвідуваність'), {
|
||||
'fields': (),
|
||||
'classes': ('tab',),
|
||||
'description': _('Графіки роботи та записи відвідуваності відображаються нижче.'),
|
||||
}),
|
||||
)
|
||||
|
||||
# def permissions_summary(self, obj):
|
||||
# perms = obj.effective_permissions()
|
||||
# group = obj.permission_group.name if obj.permission_group_id else None
|
||||
|
||||
# if not perms and not group:
|
||||
# return mark_safe('<span style="color:#999;">—</span>')
|
||||
|
||||
# parts = []
|
||||
# if group:
|
||||
# parts.append(format_html(
|
||||
# '<span style="background:#1a7f37;color:#fff;'
|
||||
# 'padding:2px 6px;border-radius:4px;font-size:11px;">📁 {}</span>',
|
||||
# group,
|
||||
# ))
|
||||
# for p in perms:
|
||||
# parts.append(format_html(
|
||||
# '<span style="background:#6c47ff;color:#fff;'
|
||||
# 'padding:2px 6px;border-radius:4px;font-size:11px;">{}</span>',
|
||||
# p,
|
||||
# ))
|
||||
# return mark_safe(' '.join(parts))
|
||||
# permissions_summary.short_description = _('Группа / Разрешения')
|
||||
|
||||
def photo_thumbnail(self, obj):
|
||||
if obj.photo:
|
||||
return format_html(
|
||||
'<img src="{}" style="width:36px;height:36px;border-radius:50%;object-fit:cover;">',
|
||||
obj.photo.url
|
||||
)
|
||||
return mark_safe('<span style="color:#999;">—</span>')
|
||||
photo_thumbnail.short_description = ''
|
||||
|
||||
def photo_preview(self, obj):
|
||||
if obj.photo:
|
||||
return format_html(
|
||||
'<img src="{}" style="max-height:200px;border-radius:8px;">',
|
||||
obj.photo.url
|
||||
)
|
||||
return mark_safe('<span style="color:#999;">Фото не загружено</span>')
|
||||
photo_preview.short_description = _('Предпросмотр')
|
||||
|
||||
def department_display(self, obj): # noqa: E303
|
||||
"""Отдел с полным путём иерархии без дубликатов (Офис → HR → Кадры)."""
|
||||
if not obj.department:
|
||||
return mark_safe('<span style="color:#999;">—</span>')
|
||||
try:
|
||||
parts = [n.name for n in obj.department.get_ancestors()] + [obj.department.name]
|
||||
# Убираем подряд идущие дубликаты (родитель и ребёнок с одинаковым именем)
|
||||
unique = []
|
||||
for p in parts:
|
||||
if not unique or unique[-1] != p:
|
||||
unique.append(p)
|
||||
path = ' → '.join(unique)
|
||||
return format_html(
|
||||
'<span class="department-path" title="{}">{}</span>',
|
||||
path, path
|
||||
)
|
||||
except Exception:
|
||||
return obj.department.name or '—'
|
||||
department_display.short_description = _('Отдел')
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class EmployeesConfig(AppConfig):
|
||||
name = 'apps.employees'
|
||||
verbose_name = 'Сотрудники'
|
||||
@@ -0,0 +1,110 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-06 10:32
|
||||
|
||||
import core.utils.validators
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
('core', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='EmployeePermissionOverride',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('override_type', models.CharField(choices=[('grant', 'Выдать'), ('revoke', 'Отозвать')], max_length=10, verbose_name='Тип')),
|
||||
('valid_from', models.DateField(blank=True, null=True, verbose_name='Действует с')),
|
||||
('valid_until', models.DateField(blank=True, null=True, verbose_name='Действует до')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='Активен')),
|
||||
('comment', models.TextField(blank=True, verbose_name='Причина / комментарий')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Override разрешения',
|
||||
'verbose_name_plural': 'Override разрешений',
|
||||
'ordering': ['employee', 'permission__codename'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PermissionGroup',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('description', models.TextField(blank=True, verbose_name='Описание')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='Активна')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Группа разрешений',
|
||||
'verbose_name_plural': 'Группы разрешений',
|
||||
'ordering': ['group__name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Employee',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('username', models.CharField(help_text='Уникальный логин для входа в систему', max_length=150, unique=True, verbose_name='Логин')),
|
||||
('email', models.EmailField(blank=True, help_text='Рабочий email. Если не указан — генерируется из username@local.domain', max_length=254, null=True, unique=True, verbose_name='Email адрес')),
|
||||
('role', models.CharField(choices=[('admin', 'Администратор сети'), ('warehouse', 'Сотрудник склада'), ('pharmacist', 'Фармацевт'), ('manager', 'Заведующий аптекой'), ('revisor', 'Ревизор'), ('office', 'Офисный сотрудник')], help_text='Роль определяет права доступа в API', max_length=20, verbose_name='Роль')),
|
||||
('last_name', models.CharField(max_length=150, verbose_name='Фамилия')),
|
||||
('first_name', models.CharField(max_length=150, verbose_name='Имя')),
|
||||
('middle_name', models.CharField(blank=True, max_length=150, verbose_name='Отчество')),
|
||||
('is_staff', models.BooleanField(default=False, help_text='Доступ в Django admin', verbose_name='Персонал')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Работает ли сотрудник сейчас', verbose_name='Активный')),
|
||||
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Дата создания аккаунта')),
|
||||
('birth_date', models.DateField(blank=True, null=True, validators=[core.utils.validators.validate_birth_date], verbose_name='Дата рождения')),
|
||||
('inn_code', models.CharField(blank=True, help_text='Идентификационный номер налогоплательщика', max_length=10, null=True, validators=[core.utils.validators.validate_ukrainian_tax_number], verbose_name='ИНН')),
|
||||
('phone', models.CharField(blank=True, max_length=20, validators=[core.utils.validators.validate_ukrainian_phone], verbose_name='Рабочий телефон')),
|
||||
('personal_phone', models.CharField(blank=True, max_length=20, validators=[core.utils.validators.validate_ukrainian_phone], verbose_name='Личный телефон')),
|
||||
('personal_email', models.EmailField(blank=True, max_length=254, verbose_name='Личный email')),
|
||||
('emergency_contact_name', models.CharField(blank=True, max_length=200, verbose_name='Контакт в экстренных случаях')),
|
||||
('emergency_contact_phone', models.CharField(blank=True, max_length=20, validators=[core.utils.validators.validate_ukrainian_phone], verbose_name='Телефон экстренного контакта')),
|
||||
('emergency_contact_relation', models.CharField(blank=True, max_length=100, verbose_name='Степень родства')),
|
||||
('user_location_type', models.CharField(blank=True, choices=[('pharmacy', 'Аптека'), ('warehouse', 'Склад'), ('office', 'Офис')], help_text='Где работает: аптека / склад / офис', max_length=20, null=True, verbose_name='Тип локации')),
|
||||
('hire_date', models.DateField(verbose_name='Дата приёма на работу')),
|
||||
('termination_date', models.DateField(blank=True, null=True, verbose_name='Дата увольнения')),
|
||||
('probation_end_date', models.DateField(blank=True, null=True, verbose_name='Конец испытательного срока')),
|
||||
('photo', models.ImageField(blank=True, null=True, upload_to='employees/photos/%Y/%m/', verbose_name='Фото')),
|
||||
('mattermost_user_id', models.CharField(blank=True, max_length=50, null=True, verbose_name='ID Mattermost')),
|
||||
('mattermost_username', models.CharField(blank=True, max_length=50, null=True, verbose_name='Логин Mattermost')),
|
||||
('mattermost_sync_status', models.CharField(choices=[('pending', 'Ожидает'), ('synced', 'Синхронизирован'), ('error', 'Ошибка'), ('disabled', 'Отключена')], default='pending', max_length=20, verbose_name='Статус синхронизации Mattermost')),
|
||||
('mattermost_last_sync', models.DateTimeField(blank=True, null=True, verbose_name='Последняя синхронизация Mattermost')),
|
||||
('mattermost_sync_enabled', models.BooleanField(default=True, verbose_name='Синхронизация Mattermost')),
|
||||
('ad_user_dn', models.CharField(blank=True, help_text='CN=Ivanov Ivan,OU=Users,DC=company,DC=local', max_length=500, verbose_name='AD User DN')),
|
||||
('ad_sync_status', models.CharField(choices=[('not_synced', 'Не синхронизирован'), ('synced', 'Синхронизирован'), ('error', 'Ошибка'), ('disabled', 'Отключена')], default='not_synced', max_length=20, verbose_name='Статус синхронизации AD')),
|
||||
('ad_sync_error', models.TextField(blank=True, verbose_name='Ошибка синхронизации AD')),
|
||||
('ad_last_sync', models.DateTimeField(blank=True, null=True, verbose_name='Последняя синхронизация AD')),
|
||||
('ad_sync_enabled', models.BooleanField(default=False, help_text='Только для офисных сотрудников', verbose_name='Синхронизация AD')),
|
||||
('department', models.ForeignKey(blank=True, help_text='Заполняется для office / warehouse', limit_choices_to={'directory_type__code': 'department', 'is_active': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='department_employees', to='core.directory', verbose_name='Отдел')),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||
('manager', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='subordinates', to=settings.AUTH_USER_MODEL, verbose_name='Руководитель')),
|
||||
('post', models.ForeignKey(blank=True, limit_choices_to={'directory_type__code': 'post', 'is_active': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='post_employees', to='core.directory', verbose_name='Должность')),
|
||||
('user_organization', models.ForeignKey(blank=True, help_text='Юрлицо: АКС, АКС Одесса, Тира, Пентальфа...', limit_choices_to={'directory_type__code': 'organization', 'is_active': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='organization_employees', to='core.directory', verbose_name='Организация')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Сотрудник',
|
||||
'verbose_name_plural': 'Сотрудники',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,59 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-06 10:32
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
('employees', '0001_initial'),
|
||||
('locations', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='employee',
|
||||
name='user_pharmacy',
|
||||
field=models.ForeignKey(blank=True, help_text='Заполняется только для location_type=pharmacy', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pharmacy_employees', to='locations.pharmacy', verbose_name='Аптека'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='employeepermissionoverride',
|
||||
name='employee',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='permission_overrides', to=settings.AUTH_USER_MODEL, verbose_name='Сотрудник'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='employeepermissionoverride',
|
||||
name='permission',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='auth.permission', verbose_name='Разрешение'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='permissiongroup',
|
||||
name='group',
|
||||
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to='auth.group', verbose_name='Группа'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='employee',
|
||||
index=models.Index(fields=['last_name', 'first_name', 'middle_name'], name='employees_e_last_na_6eb13b_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='employee',
|
||||
index=models.Index(fields=['email'], name='employees_e_email_8f5bbc_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='employee',
|
||||
index=models.Index(fields=['is_active'], name='employees_e_is_acti_ff761b_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='employee',
|
||||
index=models.Index(fields=['role'], name='employees_e_role_caa0a5_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='employeepermissionoverride',
|
||||
index=models.Index(fields=['employee', 'is_active'], name='employees_e_employe_a3846b_idx'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-24 08:50
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('employees', '0002_initial'),
|
||||
('locations', '0002_remove_pharmacy_locations_p_last_sy_0dc5a0_idx_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='employee',
|
||||
name='user_location',
|
||||
field=models.ForeignKey(blank=True, help_text='Заполняется для location_type=office или warehouse', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='location_employees', to='locations.location', verbose_name='Локация (офис/склад)'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,2 @@
|
||||
from .base import Employee, EmployeeManager
|
||||
from .permissions import PermissionGroup, EmployeePermissionOverride
|
||||
@@ -0,0 +1,310 @@
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from core.models import BaseModel
|
||||
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, BaseUserManager
|
||||
from core.utils.validators import validate_ukrainian_phone, validate_birth_date, validate_ukrainian_tax_number
|
||||
|
||||
class EmployeeManager(BaseUserManager):
|
||||
def create_user(self, email=None, password=None, **extra_fields):
|
||||
if not email:
|
||||
username = extra_fields.get('username')
|
||||
if username:
|
||||
email = f"{username}@local.domain"
|
||||
else:
|
||||
raise ValueError('Необходимо указать email или username')
|
||||
email = self.normalize_email(email)
|
||||
user = self.model(email=email, **extra_fields)
|
||||
user.set_password(password)
|
||||
user.save(using=self._db)
|
||||
return user
|
||||
|
||||
def create_superuser(self, email=None, password=None, **extra_fields):
|
||||
extra_fields.setdefault('is_staff', True)
|
||||
extra_fields.setdefault('is_superuser', True)
|
||||
extra_fields.setdefault('is_active', True)
|
||||
return self.create_user(email, password, **extra_fields)
|
||||
|
||||
|
||||
class Employee(AbstractBaseUser, PermissionsMixin, BaseModel):
|
||||
ROLES = [
|
||||
('admin', _('Администратор сети')),
|
||||
('warehouse', _('Сотрудник склада')),
|
||||
('pharmacist', _('Фармацевт')),
|
||||
('manager', _('Заведующий аптекой')),
|
||||
('revisor', _('Ревизор')),
|
||||
('office', _('Офисный сотрудник')),
|
||||
]
|
||||
|
||||
username = models.CharField(
|
||||
_('Логин'), max_length=150, unique=True,
|
||||
help_text=_('Уникальный логин для входа в систему')
|
||||
)
|
||||
email = models.EmailField(
|
||||
_('Email адрес'), unique=True, blank=True, null=True,
|
||||
help_text=_('Рабочий email. Если не указан — генерируется из username@local.domain')
|
||||
)
|
||||
role = models.CharField(
|
||||
_('Роль'), max_length=20, choices=ROLES,
|
||||
help_text=_('Роль определяет права доступа в API')
|
||||
)
|
||||
|
||||
last_name = models.CharField(_('Фамилия'), max_length=150)
|
||||
first_name = models.CharField(_('Имя'), max_length=150)
|
||||
middle_name = models.CharField(_('Отчество'), max_length=150, blank=True)
|
||||
|
||||
is_staff = models.BooleanField(_('Персонал'), default=False,
|
||||
help_text=_('Доступ в Django admin'))
|
||||
is_active = models.BooleanField(_('Активный'), default=True,
|
||||
help_text=_('Работает ли сотрудник сейчас'))
|
||||
date_joined = models.DateTimeField(_('Дата создания аккаунта'), default=timezone.now)
|
||||
|
||||
birth_date = models.DateField(
|
||||
_('Дата рождения'), null=True, blank=True,
|
||||
validators=[validate_birth_date]
|
||||
)
|
||||
inn_code = models.CharField(
|
||||
_('ИНН'), max_length=10, blank=True, null=True,
|
||||
help_text=_('Идентификационный номер налогоплательщика'),
|
||||
validators=[validate_ukrainian_tax_number]
|
||||
)
|
||||
|
||||
phone = models.CharField(
|
||||
_('Рабочий телефон'), max_length=20, blank=True,
|
||||
validators=[validate_ukrainian_phone]
|
||||
)
|
||||
personal_phone = models.CharField(
|
||||
_('Личный телефон'), max_length=20, blank=True,
|
||||
validators=[validate_ukrainian_phone]
|
||||
)
|
||||
personal_email = models.EmailField(_('Личный email'), blank=True)
|
||||
|
||||
emergency_contact_name = models.CharField(_('Контакт в экстренных случаях'), max_length=200, blank=True)
|
||||
emergency_contact_phone = models.CharField(_('Телефон экстренного контакта'), max_length=20, blank=True,
|
||||
validators=[validate_ukrainian_phone])
|
||||
emergency_contact_relation = models.CharField(_('Степень родства'), max_length=100, blank=True)
|
||||
|
||||
LOCATION_TYPES = [
|
||||
('pharmacy', _('Аптека')),
|
||||
('warehouse', _('Склад')),
|
||||
('office', _('Офис')),
|
||||
]
|
||||
user_location_type = models.CharField(
|
||||
_('Тип локации'), max_length=20, choices=LOCATION_TYPES,
|
||||
help_text=_('Где работает: аптека / склад / офис'),
|
||||
null=True, blank=True
|
||||
)
|
||||
user_organization = models.ForeignKey(
|
||||
'core.Directory', on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
limit_choices_to={'directory_type__code': 'organization', 'is_active': True},
|
||||
related_name='organization_employees', verbose_name=_('Организация'),
|
||||
help_text=_('Юрлицо: АКС, АКС Одесса, Тира, Пентальфа...')
|
||||
)
|
||||
user_pharmacy = models.ForeignKey(
|
||||
'locations.Pharmacy', on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name='pharmacy_employees', verbose_name=_('Аптека'),
|
||||
help_text=_('Заполняется только для location_type=pharmacy')
|
||||
)
|
||||
user_location = models.ForeignKey(
|
||||
'locations.Location', on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name='location_employees', verbose_name=_('Локация (офис/склад)'),
|
||||
help_text=_('Заполняется для location_type=office или warehouse')
|
||||
)
|
||||
department = models.ForeignKey(
|
||||
'core.Directory', on_delete=models.SET_NULL,
|
||||
limit_choices_to={'directory_type__code': 'department', 'is_active': True},
|
||||
related_name='department_employees', verbose_name=_('Отдел'),
|
||||
null=True, blank=True,
|
||||
help_text=_('Заполняется для office / warehouse')
|
||||
)
|
||||
post = models.ForeignKey(
|
||||
'core.Directory', on_delete=models.SET_NULL,
|
||||
limit_choices_to={'directory_type__code': 'post', 'is_active': True},
|
||||
related_name='post_employees', verbose_name=_('Должность'),
|
||||
null=True, blank=True
|
||||
)
|
||||
manager = models.ForeignKey(
|
||||
'self', on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name='subordinates', verbose_name=_('Руководитель')
|
||||
)
|
||||
|
||||
hire_date = models.DateField(_('Дата приёма на работу'))
|
||||
termination_date = models.DateField(_('Дата увольнения'), null=True, blank=True)
|
||||
probation_end_date = models.DateField(_('Конец испытательного срока'), null=True, blank=True)
|
||||
|
||||
photo = models.ImageField(
|
||||
_('Фото'), upload_to='employees/photos/%Y/%m/',
|
||||
blank=True, null=True
|
||||
)
|
||||
|
||||
mattermost_user_id = models.CharField(_('ID Mattermost'), max_length=50, blank=True, null=True)
|
||||
mattermost_username = models.CharField(_('Логин Mattermost'), max_length=50, blank=True, null=True)
|
||||
mattermost_sync_status = models.CharField(
|
||||
_('Статус синхронизации Mattermost'), max_length=20,
|
||||
choices=[
|
||||
('pending', _('Ожидает')),
|
||||
('synced', _('Синхронизирован')),
|
||||
('error', _('Ошибка')),
|
||||
('disabled', _('Отключена')),
|
||||
],
|
||||
default='pending'
|
||||
)
|
||||
mattermost_last_sync = models.DateTimeField(_('Последняя синхронизация Mattermost'), null=True, blank=True)
|
||||
mattermost_sync_enabled = models.BooleanField(_('Синхронизация Mattermost'), default=True)
|
||||
|
||||
ad_user_dn = models.CharField('AD User DN', max_length=500, blank=True,
|
||||
help_text='CN=Ivanov Ivan,OU=Users,DC=company,DC=local')
|
||||
ad_sync_status = models.CharField(
|
||||
_('Статус синхронизации AD'), max_length=20,
|
||||
choices=[
|
||||
('not_synced', _('Не синхронизирован')),
|
||||
('synced', _('Синхронизирован')),
|
||||
('error', _('Ошибка')),
|
||||
('disabled', _('Отключена')),
|
||||
],
|
||||
default='not_synced'
|
||||
)
|
||||
ad_sync_error = models.TextField(_('Ошибка синхронизации AD'), blank=True)
|
||||
ad_last_sync = models.DateTimeField(_('Последняя синхронизация AD'), null=True, blank=True)
|
||||
ad_sync_enabled = models.BooleanField(_('Синхронизация AD'), default=False,
|
||||
help_text=_('Только для офисных сотрудников'))
|
||||
|
||||
objects = EmployeeManager()
|
||||
|
||||
USERNAME_FIELD = 'username'
|
||||
REQUIRED_FIELDS = ['first_name', 'last_name', 'hire_date', 'role']
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Сотрудник')
|
||||
verbose_name_plural = _('Сотрудники')
|
||||
indexes = [
|
||||
models.Index(fields=['last_name', 'first_name', 'middle_name']),
|
||||
models.Index(fields=['email']),
|
||||
models.Index(fields=['is_active']),
|
||||
models.Index(fields=['role']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
if self.middle_name:
|
||||
return f"{self.last_name} {self.first_name} {self.middle_name}"
|
||||
return f"{self.last_name} {self.first_name}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.email and self.username:
|
||||
self.email = f"{self.username}@local.domain"
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if self.user_location_type:
|
||||
loc = self.user_location_type
|
||||
if loc == 'pharmacy':
|
||||
if not self.user_pharmacy_id:
|
||||
raise ValidationError({'user_pharmacy': 'Для фармацевта обязательно укажите аптеку'})
|
||||
if self.department_id:
|
||||
raise ValidationError({'department': 'Для фармацевта отдел не указывается'})
|
||||
if self.user_location_id:
|
||||
raise ValidationError({'user_location': 'Локация (офис/склад) — только для office/warehouse'})
|
||||
elif loc in ['office', 'warehouse']:
|
||||
if not self.department_id:
|
||||
raise ValidationError({'department': f'Для сотрудника {loc} обязателен отдел'})
|
||||
if not self.user_location_id:
|
||||
raise ValidationError({'user_location': 'Укажите конкретную локацию (офис или склад)'})
|
||||
if self.user_location_id and self.user_location.location_type != loc:
|
||||
raise ValidationError({'user_location': f'Локация должна быть типа «{self.get_user_location_type_display()}»'})
|
||||
if self.user_pharmacy_id:
|
||||
raise ValidationError({'user_pharmacy': 'Аптека только для фармацевтов'})
|
||||
|
||||
if self.post_id and self.user_location_type:
|
||||
if self.post.location_types.exists():
|
||||
if not self.post.location_types.filter(code=self.user_location_type).exists():
|
||||
names = ', '.join(lt.name for lt in self.post.location_types.all())
|
||||
raise ValidationError({'post': f'Должность доступна только для: {names}'})
|
||||
|
||||
if self.termination_date and self.hire_date:
|
||||
if self.termination_date < self.hire_date:
|
||||
raise ValidationError({'termination_date': 'Не может быть раньше даты приёма'})
|
||||
|
||||
if self.manager_id and self.manager_id == self.pk:
|
||||
raise ValidationError({'manager': 'Сотрудник не может быть своим руководителем'})
|
||||
|
||||
@property
|
||||
def short_name(self):
|
||||
s = self.last_name
|
||||
if self.first_name:
|
||||
s += f" {self.first_name[0]}."
|
||||
if self.middle_name:
|
||||
s += f"{self.middle_name[0]}."
|
||||
return s
|
||||
|
||||
@property
|
||||
def age(self):
|
||||
if not self.birth_date:
|
||||
return None
|
||||
today = timezone.now().date()
|
||||
age = today.year - self.birth_date.year
|
||||
if today < self.birth_date.replace(year=today.year):
|
||||
age -= 1
|
||||
return age
|
||||
|
||||
@property
|
||||
def work_experience(self):
|
||||
if not self.hire_date:
|
||||
return 0
|
||||
end = self.termination_date or timezone.now().date()
|
||||
exp = end.year - self.hire_date.year
|
||||
if end < self.hire_date.replace(year=end.year):
|
||||
exp -= 1
|
||||
return max(0, exp)
|
||||
|
||||
@property
|
||||
def is_on_probation(self):
|
||||
if not self.probation_end_date:
|
||||
return False
|
||||
return timezone.now().date() <= self.probation_end_date
|
||||
|
||||
@property
|
||||
def is_manager(self):
|
||||
return self.subordinates.exists()
|
||||
|
||||
@property
|
||||
def subordinate_count(self):
|
||||
return self.subordinates.filter(is_active=True).count()
|
||||
|
||||
def get_manager_chain(self):
|
||||
managers = []
|
||||
current = self.manager
|
||||
while current and len(managers) < 10:
|
||||
managers.append(current)
|
||||
current = current.manager
|
||||
return managers
|
||||
|
||||
def get_full_name(self):
|
||||
return self.__str__()
|
||||
|
||||
def get_short_name(self):
|
||||
return self.short_name
|
||||
|
||||
def has_perm(self, perm, obj=None):
|
||||
today = timezone.now().date()
|
||||
codename = perm.split('.')[-1]
|
||||
override = self.permission_overrides.filter(
|
||||
permission__codename=codename,
|
||||
is_active=True,
|
||||
).filter(
|
||||
models.Q(valid_from__isnull=True) | models.Q(valid_from__lte=today)
|
||||
).filter(
|
||||
models.Q(valid_until__isnull=True) | models.Q(valid_until__gte=today)
|
||||
).values_list('override_type', flat=True).first()
|
||||
|
||||
if override == 'revoke':
|
||||
return False
|
||||
if override == 'grant':
|
||||
return True
|
||||
|
||||
return super().has_perm(perm, obj)
|
||||
@@ -0,0 +1,80 @@
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from core.models import BaseModel
|
||||
|
||||
|
||||
class PermissionGroup(BaseModel):
|
||||
"""
|
||||
Расширение стандартной auth.Group — добавляет описание и флаг активности.
|
||||
Права назначаются через стандартный механизм auth.Group.permissions.
|
||||
"""
|
||||
group = models.OneToOneField(
|
||||
'auth.Group', on_delete=models.CASCADE,
|
||||
related_name='profile', verbose_name=_('Группа')
|
||||
)
|
||||
description = models.TextField(_('Описание'), blank=True)
|
||||
is_active = models.BooleanField(_('Активна'), default=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Группа разрешений')
|
||||
verbose_name_plural = _('Группы разрешений')
|
||||
ordering = ['group__name']
|
||||
|
||||
def __str__(self):
|
||||
return self.group.name
|
||||
|
||||
def permission_codes(self) -> list[str]:
|
||||
return list(
|
||||
self.group.permissions.values_list('codename', flat=True)
|
||||
)
|
||||
|
||||
|
||||
class EmployeePermissionOverride(BaseModel):
|
||||
"""
|
||||
Индивидуальные override-ы прав для сотрудника.
|
||||
grant — выдать право сверх группы.
|
||||
revoke — отозвать право даже если оно есть в группе.
|
||||
Поддерживает временны́е ограничения через valid_from / valid_until.
|
||||
"""
|
||||
OVERRIDE_TYPES = [
|
||||
('grant', _('Выдать')),
|
||||
('revoke', _('Отозвать')),
|
||||
]
|
||||
|
||||
employee = models.ForeignKey(
|
||||
'employees.Employee', on_delete=models.CASCADE,
|
||||
related_name='permission_overrides', verbose_name=_('Сотрудник')
|
||||
)
|
||||
permission = models.ForeignKey(
|
||||
'auth.Permission', on_delete=models.CASCADE,
|
||||
verbose_name=_('Разрешение')
|
||||
)
|
||||
override_type = models.CharField(
|
||||
_('Тип'), max_length=10, choices=OVERRIDE_TYPES
|
||||
)
|
||||
valid_from = models.DateField(_('Действует с'), null=True, blank=True)
|
||||
valid_until = models.DateField(_('Действует до'), null=True, blank=True)
|
||||
is_active = models.BooleanField(_('Активен'), default=True)
|
||||
comment = models.TextField(_('Причина / комментарий'), blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Override разрешения')
|
||||
verbose_name_plural = _('Override разрешений')
|
||||
ordering = ['employee', 'permission__codename']
|
||||
indexes = [
|
||||
models.Index(fields=['employee', 'is_active']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.employee} — {self.get_override_type_display()}: {self.permission.codename}"
|
||||
|
||||
def is_currently_active(self) -> bool:
|
||||
if not self.is_active:
|
||||
return False
|
||||
today = timezone.now().date()
|
||||
if self.valid_from and today < self.valid_from:
|
||||
return False
|
||||
if self.valid_until and today > self.valid_until:
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,15 @@
|
||||
from rest_framework import serializers
|
||||
from apps.employees.models import Employee
|
||||
|
||||
class EmployeeSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Employee
|
||||
fields = [
|
||||
'id', 'username', 'first_name', 'last_name', 'middle_name', 'email',
|
||||
'role', 'phone', 'personal_phone', 'personal_email', 'inn_code', 'birth_date',
|
||||
'emergency_contact_name', 'emergency_contact_phone', 'emergency_contact_relation',
|
||||
'user_location_type', 'user_organization', 'photo',
|
||||
'mattermost_username', 'mattermost_sync_enabled',
|
||||
'is_staff', 'is_active', 'date_joined'
|
||||
]
|
||||
read_only_fields = ['id', 'date_joined']
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1,10 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from . import views
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'employees', views.EmployeeViewSet)
|
||||
|
||||
urlpatterns = [
|
||||
path('api/', include(router.urls)),
|
||||
]
|
||||
@@ -0,0 +1,12 @@
|
||||
from django.shortcuts import render
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from .models import Employee
|
||||
from .serializers import EmployeeSerializer
|
||||
|
||||
# Create your views here.
|
||||
|
||||
class EmployeeViewSet(viewsets.ModelViewSet):
|
||||
queryset = Employee.objects.all()
|
||||
serializer_class = EmployeeSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
@@ -0,0 +1,673 @@
|
||||
from django import forms
|
||||
from django.contrib import admin
|
||||
from django.utils import timezone
|
||||
from django.utils.html import format_html
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from unfold.admin import ModelAdmin, TabularInline
|
||||
|
||||
from .models import (
|
||||
Equipment, EquipmentAssignment,
|
||||
Ticket, TicketComment,
|
||||
InternetProvider, InternetContract,
|
||||
WorkLog, ServiceCall,
|
||||
Consumable,
|
||||
)
|
||||
|
||||
|
||||
class LocationChoiceField(forms.ModelChoiceField):
|
||||
def label_from_instance(self, obj):
|
||||
type_label = obj.get_location_type_display()
|
||||
addr = obj.get_full_address() or obj.address or ''
|
||||
if obj.location_type == 'pharmacy':
|
||||
try:
|
||||
num = obj.pharmacy.pharmacy_number
|
||||
return f'Аптека №{num} — {addr}'
|
||||
except Exception:
|
||||
pass
|
||||
return f'{type_label} — {addr}' if addr else type_label
|
||||
|
||||
|
||||
class InternetContractForm(forms.ModelForm):
|
||||
location = LocationChoiceField(
|
||||
queryset=None,
|
||||
label=_('Локация'),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
from apps.locations.models import Location
|
||||
self.fields['location'].queryset = (
|
||||
Location.objects
|
||||
.filter(is_active=True)
|
||||
.select_related('location_region')
|
||||
.prefetch_related('pharmacy')
|
||||
.order_by('location_type', 'address')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = InternetContract
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Equipment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class EquipmentAssignmentInline(TabularInline):
|
||||
model = EquipmentAssignment
|
||||
extra = 0
|
||||
fields = ('assigned_to', 'location', 'assigned_by', 'assigned_at', 'returned_at', 'notes')
|
||||
readonly_fields = ('assigned_at',)
|
||||
ordering = ('-assigned_at',)
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
class ConsumableInline(TabularInline):
|
||||
model = Consumable
|
||||
extra = 0
|
||||
fields = ('consumable_type', 'brand', 'model_number', 'color', 'status',
|
||||
'page_yield', 'installed_date', 'replaced_date')
|
||||
ordering = ('color',)
|
||||
verbose_name = 'Расходник'
|
||||
verbose_name_plural = 'Расходники (картриджи, тонеры)'
|
||||
|
||||
def get_queryset(self, request):
|
||||
return super().get_queryset(request)
|
||||
|
||||
|
||||
@admin.register(Equipment)
|
||||
class EquipmentAdmin(ModelAdmin):
|
||||
list_display = [
|
||||
'type_display', 'brand', 'model', 'serial_number',
|
||||
'status_display', 'assigned_to', 'location',
|
||||
'warranty_display', 'updated_at',
|
||||
]
|
||||
list_filter = ['equipment_type', 'status', 'location__location_type']
|
||||
search_fields = ['brand', 'model', 'serial_number', 'inventory_number', 'notes']
|
||||
list_select_related = ['assigned_to', 'location']
|
||||
ordering = ['equipment_type', 'brand', 'model']
|
||||
|
||||
readonly_fields = ['created_at', 'updated_at', 'warranty_display']
|
||||
|
||||
fieldsets = (
|
||||
(_('Оборудование'), {
|
||||
'fields': ('equipment_type', 'brand', 'model', 'serial_number', 'inventory_number'),
|
||||
}),
|
||||
(_('Статус и размещение'), {
|
||||
'fields': ('status', 'assigned_to', 'location'),
|
||||
}),
|
||||
(_('Гарантия и закупка'), {
|
||||
'fields': ('purchase_date', 'warranty_until', 'warranty_display'),
|
||||
}),
|
||||
(_('Примечания'), {
|
||||
'fields': ('notes',),
|
||||
'classes': ('collapse',),
|
||||
}),
|
||||
(_('Служебное'), {
|
||||
'fields': ('created_at', 'updated_at'),
|
||||
'classes': ('collapse',),
|
||||
}),
|
||||
)
|
||||
|
||||
inlines = [EquipmentAssignmentInline, ConsumableInline]
|
||||
|
||||
def get_inlines(self, request, obj):
|
||||
inlines = [EquipmentAssignmentInline]
|
||||
if obj and obj.equipment_type in ('printer', 'scanner'):
|
||||
inlines.append(ConsumableInline)
|
||||
return inlines
|
||||
|
||||
EQUIPMENT_ICONS = {
|
||||
'laptop': '💻', 'desktop': '🖥️', 'monitor': '🖥️',
|
||||
'printer': '🖨️', 'phone': '📱', 'tablet': '📟',
|
||||
'router': '📡', 'ups': '🔋', 'scanner': '📠',
|
||||
'other': '📦',
|
||||
}
|
||||
|
||||
STATUS_COLORS = {
|
||||
'stock': '#6b7280',
|
||||
'assigned': '#16a34a',
|
||||
'repair': '#ca8a04',
|
||||
'written_off': '#dc2626',
|
||||
}
|
||||
|
||||
def type_display(self, obj):
|
||||
icon = self.EQUIPMENT_ICONS.get(obj.equipment_type, '📦')
|
||||
return format_html('{} {}', icon, obj.get_equipment_type_display())
|
||||
type_display.short_description = _('Тип')
|
||||
type_display.admin_order_field = 'equipment_type'
|
||||
|
||||
def status_display(self, obj):
|
||||
color = self.STATUS_COLORS.get(obj.status, '#6b7280')
|
||||
return format_html(
|
||||
'<span style="color:{};font-weight:bold;">{}</span>',
|
||||
color, obj.get_status_display()
|
||||
)
|
||||
status_display.short_description = _('Статус')
|
||||
status_display.admin_order_field = 'status'
|
||||
|
||||
def warranty_display(self, obj):
|
||||
status = obj.warranty_status
|
||||
if status == 'Действительна':
|
||||
return format_html('<span style="color:#16a34a;">{}</span>', status)
|
||||
if 'Истекает' in status:
|
||||
return format_html('<span style="color:#ca8a04;">{}</span>', status)
|
||||
if status == 'Истекла':
|
||||
return format_html('<span style="color:#dc2626;">{}</span>', status)
|
||||
return status
|
||||
warranty_display.short_description = _('Гарантия')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Consumables
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@admin.register(Consumable)
|
||||
class ConsumableAdmin(ModelAdmin):
|
||||
list_display = [
|
||||
'consumable_type_display', 'brand', 'model_number',
|
||||
'color_display', 'status_display', 'equipment', 'location',
|
||||
'page_yield', 'installed_date',
|
||||
]
|
||||
list_filter = ['consumable_type', 'status', 'color']
|
||||
search_fields = ['brand', 'model_number', 'notes',
|
||||
'equipment__brand', 'equipment__model']
|
||||
list_select_related = ['equipment', 'location']
|
||||
ordering = ['consumable_type', 'color', 'brand']
|
||||
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
|
||||
fieldsets = (
|
||||
(_('Расходник'), {
|
||||
'fields': ('consumable_type', 'brand', 'model_number', 'color'),
|
||||
}),
|
||||
(_('Статус и размещение'), {
|
||||
'fields': ('status', 'equipment', 'location'),
|
||||
}),
|
||||
(_('Эксплуатация'), {
|
||||
'fields': ('page_yield', 'purchase_date', 'installed_date', 'replaced_date'),
|
||||
}),
|
||||
(_('Примечания'), {
|
||||
'fields': ('notes',),
|
||||
'classes': ('collapse',),
|
||||
}),
|
||||
(_('Служебное'), {
|
||||
'fields': ('created_at', 'updated_at'),
|
||||
'classes': ('collapse',),
|
||||
}),
|
||||
)
|
||||
|
||||
COLOR_CIRCLES = {
|
||||
'black': '#1f2937',
|
||||
'cyan': '#0891b2',
|
||||
'magenta': '#db2777',
|
||||
'yellow': '#ca8a04',
|
||||
'color': 'linear-gradient(135deg,#0891b2,#db2777,#ca8a04)',
|
||||
'na': '#9ca3af',
|
||||
}
|
||||
|
||||
STATUS_COLORS = {
|
||||
'in_stock': '#16a34a',
|
||||
'installed': '#2563eb',
|
||||
'empty': '#dc2626',
|
||||
'ordered': '#ca8a04',
|
||||
'disposed': '#9ca3af',
|
||||
}
|
||||
|
||||
def consumable_type_display(self, obj):
|
||||
return obj.get_consumable_type_display()
|
||||
consumable_type_display.short_description = _('Тип')
|
||||
consumable_type_display.admin_order_field = 'consumable_type'
|
||||
|
||||
def color_display(self, obj):
|
||||
bg = self.COLOR_CIRCLES.get(obj.color, '#9ca3af')
|
||||
style = f'background:{bg}' if 'gradient' not in bg else f'background-image:{bg}'
|
||||
return format_html(
|
||||
'<span style="display:inline-flex;align-items:center;gap:6px;">'
|
||||
'<span style="display:inline-block;width:12px;height:12px;'
|
||||
'border-radius:50%;{};border:1px solid #e5e7eb;"></span>{}'
|
||||
'</span>',
|
||||
style, obj.get_color_display(),
|
||||
)
|
||||
color_display.short_description = _('Цвет')
|
||||
color_display.admin_order_field = 'color'
|
||||
|
||||
def status_display(self, obj):
|
||||
color = self.STATUS_COLORS.get(obj.status, '#6b7280')
|
||||
return format_html(
|
||||
'<span style="color:{};font-weight:bold;">{}</span>',
|
||||
color, obj.get_status_display(),
|
||||
)
|
||||
status_display.short_description = _('Статус')
|
||||
status_display.admin_order_field = 'status'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tickets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TicketCommentInline(TabularInline):
|
||||
model = TicketComment
|
||||
extra = 1
|
||||
fields = ('author', 'text', 'is_internal', 'created_at')
|
||||
readonly_fields = ('created_at',)
|
||||
ordering = ('created_at',)
|
||||
|
||||
|
||||
@admin.register(Ticket)
|
||||
class TicketAdmin(ModelAdmin):
|
||||
list_display = [
|
||||
'number', 'title', 'ticket_type_display', 'priority_display',
|
||||
'status_display', 'requester', 'assignee', 'created_at', 'overdue_display',
|
||||
]
|
||||
list_filter = ['status', 'priority', 'ticket_type', 'assignee']
|
||||
search_fields = ['number', 'title', 'description', 'requester__last_name',
|
||||
'requester__first_name']
|
||||
list_select_related = ['requester', 'assignee', 'equipment', 'location']
|
||||
ordering = ['-created_at']
|
||||
date_hierarchy = 'created_at'
|
||||
|
||||
readonly_fields = ['number', 'created_at', 'updated_at', 'resolved_at', 'closed_at']
|
||||
|
||||
fieldsets = (
|
||||
(_('Заявка'), {
|
||||
'fields': ('number', 'title', 'ticket_type', 'priority', 'status'),
|
||||
}),
|
||||
(_('Участники'), {
|
||||
'fields': ('requester', 'assignee'),
|
||||
}),
|
||||
(_('Связи'), {
|
||||
'fields': ('equipment', 'location'),
|
||||
}),
|
||||
(_('Описание и решение'), {
|
||||
'fields': ('description', 'resolution'),
|
||||
}),
|
||||
(_('Даты'), {
|
||||
'fields': ('created_at', 'updated_at', 'resolved_at', 'closed_at'),
|
||||
'classes': ('collapse',),
|
||||
}),
|
||||
)
|
||||
|
||||
inlines = [TicketCommentInline]
|
||||
|
||||
actions = ['mark_in_progress', 'mark_resolved', 'mark_closed']
|
||||
|
||||
PRIORITY_COLORS = {
|
||||
'low': '#6b7280',
|
||||
'medium': '#2563eb',
|
||||
'high': '#ca8a04',
|
||||
'critical': '#dc2626',
|
||||
}
|
||||
|
||||
STATUS_COLORS = {
|
||||
'new': '#2563eb',
|
||||
'in_progress': '#ca8a04',
|
||||
'waiting': '#9333ea',
|
||||
'resolved': '#16a34a',
|
||||
'closed': '#6b7280',
|
||||
}
|
||||
|
||||
def ticket_type_display(self, obj):
|
||||
return obj.get_ticket_type_display()
|
||||
ticket_type_display.short_description = _('Тип')
|
||||
ticket_type_display.admin_order_field = 'ticket_type'
|
||||
|
||||
def priority_display(self, obj):
|
||||
color = self.PRIORITY_COLORS.get(obj.priority, '#6b7280')
|
||||
return format_html(
|
||||
'<span style="color:{};font-weight:bold;">{}</span>',
|
||||
color, obj.get_priority_display()
|
||||
)
|
||||
priority_display.short_description = _('Приоритет')
|
||||
priority_display.admin_order_field = 'priority'
|
||||
|
||||
def status_display(self, obj):
|
||||
color = self.STATUS_COLORS.get(obj.status, '#6b7280')
|
||||
return format_html(
|
||||
'<span style="color:{};font-weight:bold;">{}</span>',
|
||||
color, obj.get_status_display()
|
||||
)
|
||||
status_display.short_description = _('Статус')
|
||||
status_display.admin_order_field = 'status'
|
||||
|
||||
def overdue_display(self, obj):
|
||||
if obj.is_overdue:
|
||||
return format_html('<span style="color:#dc2626;font-weight:bold;">⚠ Просрочена</span>')
|
||||
return ''
|
||||
overdue_display.short_description = _('SLA')
|
||||
|
||||
@admin.action(description=_('Взять в работу'))
|
||||
def mark_in_progress(self, request, queryset):
|
||||
updated = queryset.filter(status='new').update(
|
||||
status='in_progress', assignee=request.user
|
||||
)
|
||||
self.message_user(request, f'Взято в работу: {updated}')
|
||||
|
||||
@admin.action(description=_('Отметить решёнными'))
|
||||
def mark_resolved(self, request, queryset):
|
||||
from django.utils import timezone
|
||||
for ticket in queryset.exclude(status__in=['resolved', 'closed']):
|
||||
ticket.status = 'resolved'
|
||||
ticket.save()
|
||||
self.message_user(request, f'Отмечено решёнными: {queryset.count()}')
|
||||
|
||||
@admin.action(description=_('Закрыть'))
|
||||
def mark_closed(self, request, queryset):
|
||||
for ticket in queryset.exclude(status='closed'):
|
||||
ticket.status = 'closed'
|
||||
ticket.save()
|
||||
self.message_user(request, f'Закрыто: {queryset.count()}')
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
if not change:
|
||||
obj.requester = obj.requester or request.user
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internet Providers & Contracts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class InternetContractInline(TabularInline):
|
||||
model = InternetContract
|
||||
extra = 0
|
||||
fields = ('location', 'contract_number', 'service_type', 'status',
|
||||
'bandwidth_down', 'bandwidth_up', 'monthly_cost', 'end_date')
|
||||
show_change_link = True
|
||||
|
||||
|
||||
@admin.register(InternetProvider)
|
||||
class InternetProviderAdmin(ModelAdmin):
|
||||
list_display = ['name', 'phone', 'support_phone', 'email',
|
||||
'active_contracts_count', 'is_active']
|
||||
list_filter = ['is_active']
|
||||
search_fields = ['name', 'phone', 'email', 'support_phone']
|
||||
ordering = ['name']
|
||||
|
||||
fieldsets = (
|
||||
(_('Провайдер'), {
|
||||
'fields': ('name', 'is_active'),
|
||||
}),
|
||||
(_('Контакты'), {
|
||||
'fields': ('phone', 'email', 'website'),
|
||||
}),
|
||||
(_('Техподдержка'), {
|
||||
'fields': ('support_phone', 'support_email'),
|
||||
}),
|
||||
(_('Примечания'), {
|
||||
'fields': ('notes',),
|
||||
'classes': ('collapse',),
|
||||
}),
|
||||
)
|
||||
|
||||
inlines = [InternetContractInline]
|
||||
|
||||
def active_contracts_count(self, obj):
|
||||
count = obj.active_contracts_count
|
||||
if count:
|
||||
return format_html('<span style="color:#16a34a;font-weight:bold;">{}</span>', count)
|
||||
return format_html('<span style="color:#9ca3af;">0</span>')
|
||||
active_contracts_count.short_description = _('Активных договоров')
|
||||
|
||||
|
||||
@admin.register(InternetContract)
|
||||
class InternetContractAdmin(ModelAdmin):
|
||||
form = InternetContractForm
|
||||
list_display = [
|
||||
'provider', 'location', 'contract_number', 'service_type_display',
|
||||
'connection_type_display', 'status_display', 'bandwidth_display',
|
||||
'monthly_cost', 'end_date', 'expiry_display',
|
||||
]
|
||||
list_filter = ['status', 'service_type', 'connection_type', 'provider', 'location__location_type']
|
||||
search_fields = ['contract_number', 'account_number', 'static_ips',
|
||||
'provider__name', 'location__address']
|
||||
list_select_related = ['provider', 'location']
|
||||
ordering = ['location', 'provider']
|
||||
|
||||
readonly_fields = ['created_at', 'updated_at', 'ip_list_display']
|
||||
|
||||
fieldsets = (
|
||||
(_('Договор'), {
|
||||
'fields': ('provider', 'contract_number', 'account_number',
|
||||
'service_type', 'connection_type', 'status'),
|
||||
}),
|
||||
(_('Локация'), {
|
||||
'fields': ('location',),
|
||||
}),
|
||||
(_('Параметры'), {
|
||||
'fields': ('bandwidth_down', 'bandwidth_up', 'monthly_cost'),
|
||||
}),
|
||||
(_('Сроки'), {
|
||||
'fields': ('start_date', 'end_date'),
|
||||
}),
|
||||
(_('IP-адреса'), {
|
||||
'fields': ('static_ips', 'ip_list_display'),
|
||||
}),
|
||||
(_('Примечания'), {
|
||||
'fields': ('notes',),
|
||||
'classes': ('collapse',),
|
||||
}),
|
||||
(_('Служебное'), {
|
||||
'fields': ('created_at', 'updated_at'),
|
||||
'classes': ('collapse',),
|
||||
}),
|
||||
)
|
||||
|
||||
STATUS_COLORS = {
|
||||
'active': '#16a34a',
|
||||
'suspended': '#ca8a04',
|
||||
'terminated': '#dc2626',
|
||||
}
|
||||
|
||||
def status_display(self, obj):
|
||||
color = self.STATUS_COLORS.get(obj.status, '#6b7280')
|
||||
return format_html(
|
||||
'<span style="color:{};font-weight:bold;">{}</span>',
|
||||
color, obj.get_status_display()
|
||||
)
|
||||
status_display.short_description = _('Статус')
|
||||
status_display.admin_order_field = 'status'
|
||||
|
||||
def service_type_display(self, obj):
|
||||
return obj.get_service_type_display()
|
||||
service_type_display.short_description = _('Услуга')
|
||||
service_type_display.admin_order_field = 'service_type'
|
||||
|
||||
def connection_type_display(self, obj):
|
||||
return obj.get_connection_type_display() if obj.connection_type else '—'
|
||||
connection_type_display.short_description = _('Технология')
|
||||
connection_type_display.admin_order_field = 'connection_type'
|
||||
|
||||
def expiry_display(self, obj):
|
||||
if not obj.end_date:
|
||||
return '—'
|
||||
if obj.status != 'active':
|
||||
return str(obj.end_date)
|
||||
if obj.is_expiring_soon:
|
||||
days = (obj.end_date - timezone.now().date()).days
|
||||
return format_html('<span style="color:#dc2626;font-weight:bold;">⚠ {} дн.</span>', days)
|
||||
return str(obj.end_date)
|
||||
expiry_display.short_description = _('Окончание')
|
||||
expiry_display.admin_order_field = 'end_date'
|
||||
|
||||
def ip_list_display(self, obj):
|
||||
ips = obj.static_ip_list
|
||||
if not ips:
|
||||
return '—'
|
||||
return mark_safe('<br>'.join(
|
||||
format_html(
|
||||
'<code style="background:#f3f4f6;padding:2px 6px;border-radius:4px;">{}</code>',
|
||||
ip
|
||||
)
|
||||
for ip in ips
|
||||
))
|
||||
ip_list_display.short_description = _('Статические IP')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Work Logs & Service Calls
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class LocationChoiceFieldSimple(forms.ModelChoiceField):
|
||||
def label_from_instance(self, obj):
|
||||
type_label = obj.get_location_type_display()
|
||||
addr = obj.get_full_address() or obj.address or ''
|
||||
if obj.location_type == 'pharmacy':
|
||||
try:
|
||||
num = obj.pharmacy.pharmacy_number
|
||||
return f'Аптека №{num} — {addr}'
|
||||
except Exception:
|
||||
pass
|
||||
return f'{type_label} — {addr}' if addr else type_label
|
||||
|
||||
|
||||
def _location_queryset():
|
||||
from apps.locations.models import Location
|
||||
return (
|
||||
Location.objects
|
||||
.filter(is_active=True)
|
||||
.select_related('location_region')
|
||||
.prefetch_related('pharmacy')
|
||||
.order_by('location_type', 'address')
|
||||
)
|
||||
|
||||
|
||||
class WorkLogForm(forms.ModelForm):
|
||||
location = LocationChoiceFieldSimple(
|
||||
queryset=None, required=False, label=_('Локация'),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['location'].queryset = _location_queryset()
|
||||
|
||||
class Meta:
|
||||
model = WorkLog
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class ServiceCallForm(forms.ModelForm):
|
||||
location = LocationChoiceFieldSimple(
|
||||
queryset=None, required=False, label=_('Локация'),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['location'].queryset = _location_queryset()
|
||||
|
||||
class Meta:
|
||||
model = ServiceCall
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
@admin.register(WorkLog)
|
||||
class WorkLogAdmin(ModelAdmin):
|
||||
form = WorkLogForm
|
||||
list_display = ['date', 'technician', 'work_type_display', 'location',
|
||||
'hours_spent', 'ticket', 'created_at']
|
||||
list_filter = ['work_type', 'technician', 'date']
|
||||
search_fields = ['description', 'result', 'technician__last_name',
|
||||
'technician__first_name']
|
||||
list_select_related = ['technician', 'location', 'ticket', 'equipment']
|
||||
ordering = ['-date', '-created_at']
|
||||
date_hierarchy = 'date'
|
||||
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
|
||||
fieldsets = (
|
||||
(_('Работа'), {
|
||||
'fields': ('date', 'technician', 'work_type', 'location'),
|
||||
}),
|
||||
(_('Описание'), {
|
||||
'fields': ('description', 'result', 'hours_spent'),
|
||||
}),
|
||||
(_('Связи'), {
|
||||
'fields': ('equipment', 'ticket'),
|
||||
}),
|
||||
(_('Служебное'), {
|
||||
'fields': ('created_at', 'updated_at'),
|
||||
'classes': ('collapse',),
|
||||
}),
|
||||
)
|
||||
|
||||
WORK_TYPE_COLORS = {
|
||||
'maintenance': '#2563eb',
|
||||
'installation': '#16a34a',
|
||||
'repair': '#ca8a04',
|
||||
'network': '#9333ea',
|
||||
'software': '#0891b2',
|
||||
'backup': '#6b7280',
|
||||
'other': '#6b7280',
|
||||
}
|
||||
|
||||
def work_type_display(self, obj):
|
||||
color = self.WORK_TYPE_COLORS.get(obj.work_type, '#6b7280')
|
||||
return format_html(
|
||||
'<span style="color:{};font-weight:bold;">{}</span>',
|
||||
color, obj.get_work_type_display()
|
||||
)
|
||||
work_type_display.short_description = _('Вид работ')
|
||||
work_type_display.admin_order_field = 'work_type'
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
if not change and not obj.technician_id:
|
||||
obj.technician = request.user
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
|
||||
@admin.register(ServiceCall)
|
||||
class ServiceCallAdmin(ModelAdmin):
|
||||
form = ServiceCallForm
|
||||
list_display = ['date_time', 'caller', 'handler', 'channel_display',
|
||||
'location', 'duration_display', 'ticket']
|
||||
list_filter = ['channel', 'handler', 'date_time']
|
||||
search_fields = ['description', 'resolution',
|
||||
'caller__last_name', 'caller__first_name',
|
||||
'handler__last_name', 'handler__first_name']
|
||||
list_select_related = ['caller', 'handler', 'location', 'ticket']
|
||||
ordering = ['-date_time']
|
||||
date_hierarchy = 'date_time'
|
||||
|
||||
readonly_fields = ['created_at', 'duration_display']
|
||||
|
||||
fieldsets = (
|
||||
(_('Обращение'), {
|
||||
'fields': ('date_time', 'channel', 'caller', 'handler', 'location'),
|
||||
}),
|
||||
(_('Содержание'), {
|
||||
'fields': ('description', 'resolution', 'minutes_spent', 'duration_display'),
|
||||
}),
|
||||
(_('Связи'), {
|
||||
'fields': ('ticket',),
|
||||
}),
|
||||
(_('Служебное'), {
|
||||
'fields': ('created_at',),
|
||||
'classes': ('collapse',),
|
||||
}),
|
||||
)
|
||||
|
||||
CHANNEL_ICONS = {
|
||||
'phone': '📞',
|
||||
'chat': '💬',
|
||||
'email': '📧',
|
||||
'personal': '🚶',
|
||||
'other': '📋',
|
||||
}
|
||||
|
||||
def channel_display(self, obj):
|
||||
icon = self.CHANNEL_ICONS.get(obj.channel, '📋')
|
||||
return format_html('{} {}', icon, obj.get_channel_display())
|
||||
channel_display.short_description = _('Канал')
|
||||
channel_display.admin_order_field = 'channel'
|
||||
|
||||
def duration_display(self, obj):
|
||||
return obj.duration_display
|
||||
duration_display.short_description = _('Длительность')
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
if not change and not obj.handler_id:
|
||||
obj.handler = request.user
|
||||
super().save_model(request, obj, form, change)
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ItManagerConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.it_manager'
|
||||
verbose_name = 'IT — Оборудование и заявки'
|
||||
@@ -0,0 +1,131 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-24 09:37
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('locations', '0002_remove_pharmacy_locations_p_last_sy_0dc5a0_idx_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Equipment',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('equipment_type', models.CharField(choices=[('laptop', 'Ноутбук'), ('desktop', 'ПК'), ('monitor', 'Монитор'), ('printer', 'Принтер'), ('phone', 'Телефон'), ('tablet', 'Планшет'), ('router', 'Роутер/Свитч'), ('ups', 'ИБП'), ('scanner', 'Сканер'), ('other', 'Другое')], max_length=20, verbose_name='Тип')),
|
||||
('brand', models.CharField(max_length=100, verbose_name='Бренд')),
|
||||
('model', models.CharField(max_length=100, verbose_name='Модель')),
|
||||
('serial_number', models.CharField(max_length=100, unique=True, verbose_name='Серийный номер')),
|
||||
('inventory_number', models.CharField(blank=True, max_length=50, null=True, unique=True, verbose_name='Инвентарный номер')),
|
||||
('status', models.CharField(choices=[('stock', 'На складе'), ('assigned', 'Выдано'), ('repair', 'В ремонте'), ('written_off', 'Списано')], db_index=True, default='stock', max_length=20, verbose_name='Статус')),
|
||||
('purchase_date', models.DateField(blank=True, null=True, verbose_name='Дата покупки')),
|
||||
('warranty_until', models.DateField(blank=True, null=True, verbose_name='Гарантия до')),
|
||||
('notes', models.TextField(blank=True, verbose_name='Примечания')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('assigned_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_equipment', to=settings.AUTH_USER_MODEL, verbose_name='Выдано сотруднику')),
|
||||
('location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='location_equipment', to='locations.location', verbose_name='Локация')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Оборудование',
|
||||
'verbose_name_plural': 'Оборудование',
|
||||
'ordering': ['equipment_type', 'brand', 'model'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='EquipmentAssignment',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('assigned_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Дата выдачи')),
|
||||
('returned_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата возврата')),
|
||||
('notes', models.TextField(blank=True, verbose_name='Примечания')),
|
||||
('assigned_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issued_equipment', to=settings.AUTH_USER_MODEL, verbose_name='Выдал')),
|
||||
('assigned_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='equipment_assignments', to=settings.AUTH_USER_MODEL, verbose_name='Сотрудник')),
|
||||
('equipment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assignments', to='it_manager.equipment', verbose_name='Оборудование')),
|
||||
('location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='equipment_assignments', to='locations.location', verbose_name='Локация')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Перемещение оборудования',
|
||||
'verbose_name_plural': 'История перемещений',
|
||||
'ordering': ['-assigned_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Ticket',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('number', models.CharField(editable=False, max_length=20, unique=True, verbose_name='Номер')),
|
||||
('title', models.CharField(max_length=200, verbose_name='Заголовок')),
|
||||
('ticket_type', models.CharField(choices=[('repair', 'Ремонт'), ('replacement', 'Замена оборудования'), ('new_equipment', 'Новое оборудование'), ('access', 'Доступ / Аккаунт'), ('consultation', 'Консультация'), ('other', 'Другое')], db_index=True, max_length=20, verbose_name='Тип')),
|
||||
('priority', models.CharField(choices=[('low', 'Низкий'), ('medium', 'Средний'), ('high', 'Высокий'), ('critical', 'Критический')], db_index=True, default='medium', max_length=10, verbose_name='Приоритет')),
|
||||
('status', models.CharField(choices=[('new', 'Новая'), ('in_progress', 'В работе'), ('waiting', 'Ожидание'), ('resolved', 'Решена'), ('closed', 'Закрыта')], db_index=True, default='new', max_length=20, verbose_name='Статус')),
|
||||
('description', models.TextField(verbose_name='Описание проблемы')),
|
||||
('resolution', models.TextField(blank=True, verbose_name='Решение')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('resolved_at', models.DateTimeField(blank=True, null=True, verbose_name='Решена в')),
|
||||
('closed_at', models.DateTimeField(blank=True, null=True, verbose_name='Закрыта в')),
|
||||
('assignee', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_tickets', to=settings.AUTH_USER_MODEL, verbose_name='Исполнитель')),
|
||||
('equipment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tickets', to='it_manager.equipment', verbose_name='Оборудование')),
|
||||
('location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='it_tickets', to='locations.location', verbose_name='Локация')),
|
||||
('requester', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_tickets', to=settings.AUTH_USER_MODEL, verbose_name='Заявитель')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Заявка',
|
||||
'verbose_name_plural': 'Заявки',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TicketComment',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('text', models.TextField(verbose_name='Комментарий')),
|
||||
('is_internal', models.BooleanField(default=False, help_text='Виден только сотрудникам IT', verbose_name='Внутренний')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('author', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='ticket_comments', to=settings.AUTH_USER_MODEL, verbose_name='Автор')),
|
||||
('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='it_manager.ticket', verbose_name='Заявка')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Комментарий',
|
||||
'verbose_name_plural': 'Комментарии',
|
||||
'ordering': ['created_at'],
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='equipment',
|
||||
index=models.Index(fields=['status'], name='it_manager__status_f1197a_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='equipment',
|
||||
index=models.Index(fields=['equipment_type'], name='it_manager__equipme_0beeb3_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='equipment',
|
||||
index=models.Index(fields=['assigned_to'], name='it_manager__assigne_3c2a08_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='equipmentassignment',
|
||||
index=models.Index(fields=['equipment', 'assigned_at'], name='it_manager__equipme_87de0f_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='ticket',
|
||||
index=models.Index(fields=['status', 'priority'], name='it_manager__status_6e5b9f_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='ticket',
|
||||
index=models.Index(fields=['requester', 'status'], name='it_manager__request_a11dcf_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='ticket',
|
||||
index=models.Index(fields=['assignee', 'status'], name='it_manager__assigne_555f6b_idx'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,63 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-24 09:44
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('it_manager', '0001_initial'),
|
||||
('locations', '0002_remove_pharmacy_locations_p_last_sy_0dc5a0_idx_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='InternetProvider',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=200, unique=True, verbose_name='Название')),
|
||||
('phone', models.CharField(blank=True, max_length=50, verbose_name='Телефон')),
|
||||
('email', models.EmailField(blank=True, max_length=254, verbose_name='Email')),
|
||||
('website', models.URLField(blank=True, verbose_name='Сайт')),
|
||||
('support_phone', models.CharField(blank=True, max_length=50, verbose_name='Телефон техподдержки')),
|
||||
('support_email', models.EmailField(blank=True, max_length=254, verbose_name='Email техподдержки')),
|
||||
('notes', models.TextField(blank=True, verbose_name='Примечания')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='Активен')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Провайдер',
|
||||
'verbose_name_plural': 'Провайдеры',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='InternetContract',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('contract_number', models.CharField(max_length=100, verbose_name='Номер договора')),
|
||||
('service_type', models.CharField(choices=[('internet', 'Интернет'), ('telephony', 'Телефония'), ('vpn', 'VPN'), ('dedicated', 'Выделенная линия'), ('other', 'Другое')], default='internet', max_length=20, verbose_name='Тип услуги')),
|
||||
('status', models.CharField(choices=[('active', 'Активен'), ('suspended', 'Приостановлен'), ('terminated', 'Расторгнут')], db_index=True, default='active', max_length=20, verbose_name='Статус')),
|
||||
('start_date', models.DateField(verbose_name='Дата начала')),
|
||||
('end_date', models.DateField(blank=True, null=True, verbose_name='Дата окончания')),
|
||||
('monthly_cost', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Стоимость в месяц')),
|
||||
('bandwidth_down', models.PositiveIntegerField(blank=True, null=True, verbose_name='Скорость вниз (Мбит/с)')),
|
||||
('bandwidth_up', models.PositiveIntegerField(blank=True, null=True, verbose_name='Скорость вверх (Мбит/с)')),
|
||||
('static_ips', models.TextField(blank=True, help_text='По одному на строку', verbose_name='Статические IP')),
|
||||
('account_number', models.CharField(blank=True, max_length=100, verbose_name='Лицевой счёт')),
|
||||
('notes', models.TextField(blank=True, verbose_name='Примечания')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('location', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='internet_contracts', to='locations.location', verbose_name='Локация')),
|
||||
('provider', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='contracts', to='it_manager.internetprovider', verbose_name='Провайдер')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Договор с провайдером',
|
||||
'verbose_name_plural': 'Договоры с провайдерами',
|
||||
'ordering': ['location', 'provider'],
|
||||
'indexes': [models.Index(fields=['status'], name='it_manager__status_e06eaa_idx'), models.Index(fields=['location', 'status'], name='it_manager__locatio_6abc78_idx')],
|
||||
'unique_together': {('provider', 'contract_number')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-24 09:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('it_manager', '0002_internetprovider_internetcontract'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='internetcontract',
|
||||
name='connection_type',
|
||||
field=models.CharField(blank=True, choices=[('fiber', 'Оптоволокно (FTTH/FTTB)'), ('ethernet', 'Ethernet (FTTB)'), ('docsis', 'Кабель (DOCSIS)'), ('adsl', 'xDSL (ADSL/VDSL)'), ('4g', '4G/LTE'), ('5g', '5G'), ('radio', 'Радиоканал'), ('other', 'Другое')], max_length=20, verbose_name='Технология подключения'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,63 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-24 10:26
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('it_manager', '0003_internetcontract_connection_type'),
|
||||
('locations', '0002_remove_pharmacy_locations_p_last_sy_0dc5a0_idx_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ServiceCall',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('date_time', models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='Дата и время')),
|
||||
('channel', models.CharField(choices=[('phone', 'Телефон'), ('chat', 'Чат / мессенджер'), ('email', 'Email'), ('personal', 'Личный визит'), ('other', 'Другое')], db_index=True, max_length=20, verbose_name='Канал')),
|
||||
('description', models.TextField(verbose_name='Описание обращения')),
|
||||
('resolution', models.TextField(blank=True, verbose_name='Решение')),
|
||||
('minutes_spent', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='Затрачено минут')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('caller', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='service_calls_made', to=settings.AUTH_USER_MODEL, verbose_name='Кто обратился')),
|
||||
('handler', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='service_calls_handled', to=settings.AUTH_USER_MODEL, verbose_name='Принял обращение')),
|
||||
('location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='service_calls', to='locations.location', verbose_name='Локация')),
|
||||
('ticket', models.ForeignKey(blank=True, help_text='Если обращение переросло в тикет', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='service_calls', to='it_manager.ticket', verbose_name='Связанная заявка')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Обращение',
|
||||
'verbose_name_plural': 'Обращения',
|
||||
'ordering': ['-date_time'],
|
||||
'indexes': [models.Index(fields=['date_time', 'handler'], name='it_manager__date_ti_f1008d_idx'), models.Index(fields=['channel'], name='it_manager__channel_098784_idx')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='WorkLog',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('date', models.DateField(db_index=True, default=django.utils.timezone.localdate, verbose_name='Дата')),
|
||||
('work_type', models.CharField(choices=[('maintenance', 'Техническое обслуживание'), ('installation', 'Установка / настройка'), ('repair', 'Ремонт'), ('network', 'Сетевые работы'), ('software', 'Программное обеспечение'), ('backup', 'Резервное копирование'), ('other', 'Другое')], db_index=True, max_length=20, verbose_name='Вид работ')),
|
||||
('description', models.TextField(verbose_name='Описание работ')),
|
||||
('result', models.TextField(blank=True, verbose_name='Результат')),
|
||||
('hours_spent', models.DecimalField(blank=True, decimal_places=1, max_digits=5, null=True, verbose_name='Затрачено часов')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('equipment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='work_logs', to='it_manager.equipment', verbose_name='Оборудование')),
|
||||
('location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='work_logs', to='locations.location', verbose_name='Локация')),
|
||||
('technician', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='work_logs', to=settings.AUTH_USER_MODEL, verbose_name='Исполнитель')),
|
||||
('ticket', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='work_logs', to='it_manager.ticket', verbose_name='Заявка')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Выполненная работа',
|
||||
'verbose_name_plural': 'Журнал выполненных работ',
|
||||
'ordering': ['-date', '-created_at'],
|
||||
'indexes': [models.Index(fields=['date', 'technician'], name='it_manager__date_6abf78_idx'), models.Index(fields=['work_type'], name='it_manager__work_ty_8d1ee5_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,41 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-24 10:56
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('it_manager', '0004_servicecall_worklog'),
|
||||
('locations', '0002_remove_pharmacy_locations_p_last_sy_0dc5a0_idx_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Consumable',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('consumable_type', models.CharField(choices=[('cartridge', 'Картридж'), ('toner', 'Тонер'), ('drum', 'Фотобарабан'), ('ink', 'Чернила'), ('other', 'Другое')], db_index=True, default='cartridge', max_length=20, verbose_name='Тип')),
|
||||
('brand', models.CharField(blank=True, max_length=100, verbose_name='Производитель')),
|
||||
('model_number', models.CharField(max_length=100, verbose_name='Модель / артикул')),
|
||||
('color', models.CharField(choices=[('black', 'Чёрный'), ('cyan', 'Голубой (C)'), ('magenta', 'Пурпурный (M)'), ('yellow', 'Жёлтый (Y)'), ('color', 'Цветной (триколор)'), ('na', 'Не применимо')], default='black', max_length=10, verbose_name='Цвет')),
|
||||
('status', models.CharField(choices=[('in_stock', 'На складе'), ('installed', 'Установлен'), ('empty', 'Закончился / на замену'), ('ordered', 'Заказан'), ('disposed', 'Утилизирован')], db_index=True, default='in_stock', max_length=20, verbose_name='Статус')),
|
||||
('page_yield', models.PositiveIntegerField(blank=True, null=True, verbose_name='Ресурс (страниц)')),
|
||||
('purchase_date', models.DateField(blank=True, null=True, verbose_name='Дата покупки')),
|
||||
('installed_date', models.DateField(blank=True, null=True, verbose_name='Дата установки')),
|
||||
('replaced_date', models.DateField(blank=True, null=True, verbose_name='Дата замены')),
|
||||
('notes', models.TextField(blank=True, verbose_name='Примечания')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('equipment', models.ForeignKey(blank=True, limit_choices_to={'equipment_type': 'printer'}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='consumables', to='it_manager.equipment', verbose_name='Устройство')),
|
||||
('location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='consumables', to='locations.location', verbose_name='Локация хранения')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Расходник',
|
||||
'verbose_name_plural': 'Расходники',
|
||||
'ordering': ['equipment', 'consumable_type', 'color'],
|
||||
'indexes': [models.Index(fields=['status'], name='it_manager__status_53998f_idx'), models.Index(fields=['equipment', 'status'], name='it_manager__equipme_8f7405_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
from .equipment import Equipment, EquipmentAssignment
|
||||
from .tickets import Ticket, TicketComment
|
||||
from .providers import InternetProvider, InternetContract
|
||||
from .worklogs import WorkLog, ServiceCall
|
||||
from .consumables import Consumable
|
||||
@@ -0,0 +1,82 @@
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class Consumable(models.Model):
|
||||
CONSUMABLE_TYPES = [
|
||||
('cartridge', 'Картридж'),
|
||||
('toner', 'Тонер'),
|
||||
('drum', 'Фотобарабан'),
|
||||
('ink', 'Чернила'),
|
||||
('other', 'Другое'),
|
||||
]
|
||||
|
||||
COLOR_CHOICES = [
|
||||
('black', 'Чёрный'),
|
||||
('cyan', 'Голубой (C)'),
|
||||
('magenta', 'Пурпурный (M)'),
|
||||
('yellow', 'Жёлтый (Y)'),
|
||||
('color', 'Цветной (триколор)'),
|
||||
('na', 'Не применимо'),
|
||||
]
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('in_stock', 'На складе'),
|
||||
('installed', 'Установлен'),
|
||||
('empty', 'Закончился / на замену'),
|
||||
('ordered', 'Заказан'),
|
||||
('disposed', 'Утилизирован'),
|
||||
]
|
||||
|
||||
consumable_type = models.CharField('Тип', max_length=20,
|
||||
choices=CONSUMABLE_TYPES, default='cartridge', db_index=True)
|
||||
brand = models.CharField('Производитель', max_length=100, blank=True)
|
||||
model_number = models.CharField('Модель / артикул', max_length=100)
|
||||
color = models.CharField('Цвет', max_length=10,
|
||||
choices=COLOR_CHOICES, default='black')
|
||||
status = models.CharField('Статус', max_length=20,
|
||||
choices=STATUS_CHOICES, default='in_stock', db_index=True)
|
||||
|
||||
equipment = models.ForeignKey(
|
||||
'it_manager.Equipment', on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name='consumables', verbose_name='Устройство',
|
||||
limit_choices_to={'equipment_type': 'printer'},
|
||||
)
|
||||
location = models.ForeignKey(
|
||||
'locations.Location', on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name='consumables', verbose_name='Локация хранения',
|
||||
)
|
||||
|
||||
page_yield = models.PositiveIntegerField('Ресурс (страниц)', null=True, blank=True)
|
||||
purchase_date = models.DateField('Дата покупки', null=True, blank=True)
|
||||
installed_date = models.DateField('Дата установки', null=True, blank=True)
|
||||
replaced_date = models.DateField('Дата замены', null=True, blank=True)
|
||||
|
||||
notes = models.TextField('Примечания', blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Расходник'
|
||||
verbose_name_plural = 'Расходники'
|
||||
ordering = ['equipment', 'consumable_type', 'color']
|
||||
indexes = [
|
||||
models.Index(fields=['status']),
|
||||
models.Index(fields=['equipment', 'status']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
parts = [self.get_consumable_type_display()]
|
||||
if self.brand:
|
||||
parts.append(self.brand)
|
||||
parts.append(self.model_number)
|
||||
if self.color != 'na':
|
||||
parts.append(f'({self.get_color_display()})')
|
||||
return ' '.join(parts)
|
||||
|
||||
@property
|
||||
def is_low(self):
|
||||
"""Нужно заказывать: закончился или заказан."""
|
||||
return self.status in ('empty', 'ordered')
|
||||
@@ -0,0 +1,125 @@
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class Equipment(models.Model):
|
||||
EQUIPMENT_TYPES = [
|
||||
('laptop', 'Ноутбук'),
|
||||
('desktop', 'ПК'),
|
||||
('monitor', 'Монитор'),
|
||||
('printer', 'Принтер'),
|
||||
('phone', 'Телефон'),
|
||||
('tablet', 'Планшет'),
|
||||
('router', 'Роутер/Свитч'),
|
||||
('ups', 'ИБП'),
|
||||
('scanner', 'Сканер'),
|
||||
('other', 'Другое'),
|
||||
]
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('stock', 'На складе'),
|
||||
('assigned', 'Выдано'),
|
||||
('repair', 'В ремонте'),
|
||||
('written_off', 'Списано'),
|
||||
]
|
||||
|
||||
equipment_type = models.CharField('Тип', max_length=20, choices=EQUIPMENT_TYPES)
|
||||
brand = models.CharField('Бренд', max_length=100)
|
||||
model = models.CharField('Модель', max_length=100)
|
||||
serial_number = models.CharField('Серийный номер', max_length=100, unique=True)
|
||||
inventory_number = models.CharField('Инвентарный номер', max_length=50,
|
||||
blank=True, unique=True, null=True)
|
||||
|
||||
status = models.CharField('Статус', max_length=20,
|
||||
choices=STATUS_CHOICES, default='stock', db_index=True)
|
||||
assigned_to = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name='assigned_equipment', verbose_name='Выдано сотруднику',
|
||||
)
|
||||
location = models.ForeignKey(
|
||||
'locations.Location', on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name='location_equipment', verbose_name='Локация',
|
||||
)
|
||||
|
||||
purchase_date = models.DateField('Дата покупки', null=True, blank=True)
|
||||
warranty_until = models.DateField('Гарантия до', null=True, blank=True)
|
||||
|
||||
notes = models.TextField('Примечания', blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Оборудование'
|
||||
verbose_name_plural = 'Оборудование'
|
||||
ordering = ['equipment_type', 'brand', 'model']
|
||||
indexes = [
|
||||
models.Index(fields=['status']),
|
||||
models.Index(fields=['equipment_type']),
|
||||
models.Index(fields=['assigned_to']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.get_equipment_type_display()} {self.brand} {self.model} [{self.serial_number}]'
|
||||
|
||||
@property
|
||||
def is_under_warranty(self):
|
||||
if not self.warranty_until:
|
||||
return None
|
||||
return self.warranty_until >= timezone.now().date()
|
||||
|
||||
@property
|
||||
def warranty_status(self):
|
||||
if not self.warranty_until:
|
||||
return 'Нет данных'
|
||||
if self.is_under_warranty:
|
||||
days = (self.warranty_until - timezone.now().date()).days
|
||||
if days <= 30:
|
||||
return f'Истекает через {days} дн.'
|
||||
return 'Действительна'
|
||||
return 'Истекла'
|
||||
|
||||
|
||||
class EquipmentAssignment(models.Model):
|
||||
"""История выдачи и перемещений оборудования."""
|
||||
|
||||
equipment = models.ForeignKey(
|
||||
Equipment, on_delete=models.CASCADE,
|
||||
related_name='assignments', verbose_name='Оборудование',
|
||||
)
|
||||
assigned_to = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name='equipment_assignments', verbose_name='Сотрудник',
|
||||
)
|
||||
location = models.ForeignKey(
|
||||
'locations.Location', on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name='equipment_assignments', verbose_name='Локация',
|
||||
)
|
||||
assigned_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name='issued_equipment', verbose_name='Выдал',
|
||||
)
|
||||
assigned_at = models.DateTimeField('Дата выдачи', default=timezone.now)
|
||||
returned_at = models.DateTimeField('Дата возврата', null=True, blank=True)
|
||||
notes = models.TextField('Примечания', blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Перемещение оборудования'
|
||||
verbose_name_plural = 'История перемещений'
|
||||
ordering = ['-assigned_at']
|
||||
indexes = [
|
||||
models.Index(fields=['equipment', 'assigned_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
target = self.assigned_to or self.location or 'Склад'
|
||||
return f'{self.equipment} → {target} ({self.assigned_at:%d.%m.%Y})'
|
||||
|
||||
@property
|
||||
def is_active(self):
|
||||
return self.returned_at is None
|
||||
@@ -0,0 +1,117 @@
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class InternetProvider(models.Model):
|
||||
name = models.CharField('Название', max_length=200, unique=True)
|
||||
phone = models.CharField('Телефон', max_length=50, blank=True)
|
||||
email = models.EmailField('Email', blank=True)
|
||||
website = models.URLField('Сайт', blank=True)
|
||||
support_phone = models.CharField('Телефон техподдержки', max_length=50, blank=True)
|
||||
support_email = models.EmailField('Email техподдержки', blank=True)
|
||||
notes = models.TextField('Примечания', blank=True)
|
||||
is_active = models.BooleanField('Активен', default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Провайдер'
|
||||
verbose_name_plural = 'Провайдеры'
|
||||
ordering = ['name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def active_contracts_count(self):
|
||||
return self.contracts.filter(status='active').count()
|
||||
|
||||
|
||||
class InternetContract(models.Model):
|
||||
SERVICE_TYPES = [
|
||||
('internet', 'Интернет'),
|
||||
('telephony', 'Телефония'),
|
||||
('vpn', 'VPN'),
|
||||
('dedicated', 'Выделенная линия'),
|
||||
('other', 'Другое'),
|
||||
]
|
||||
|
||||
CONNECTION_TYPES = [
|
||||
('fiber', 'Оптоволокно (FTTH/FTTB)'),
|
||||
('ethernet', 'Ethernet (FTTB)'),
|
||||
('docsis', 'Кабель (DOCSIS)'),
|
||||
('adsl', 'xDSL (ADSL/VDSL)'),
|
||||
('4g', '4G/LTE'),
|
||||
('5g', '5G'),
|
||||
('radio', 'Радиоканал'),
|
||||
('other', 'Другое'),
|
||||
]
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('active', 'Активен'),
|
||||
('suspended', 'Приостановлен'),
|
||||
('terminated', 'Расторгнут'),
|
||||
]
|
||||
|
||||
provider = models.ForeignKey(
|
||||
InternetProvider, on_delete=models.PROTECT,
|
||||
related_name='contracts', verbose_name='Провайдер',
|
||||
)
|
||||
location = models.ForeignKey(
|
||||
'locations.Location', on_delete=models.PROTECT,
|
||||
related_name='internet_contracts', verbose_name='Локация',
|
||||
)
|
||||
|
||||
contract_number = models.CharField('Номер договора', max_length=100)
|
||||
service_type = models.CharField('Тип услуги', max_length=20,
|
||||
choices=SERVICE_TYPES, default='internet')
|
||||
connection_type = models.CharField('Технология подключения', max_length=20,
|
||||
choices=CONNECTION_TYPES, blank=True)
|
||||
status = models.CharField('Статус', max_length=20,
|
||||
choices=STATUS_CHOICES, default='active', db_index=True)
|
||||
|
||||
start_date = models.DateField('Дата начала')
|
||||
end_date = models.DateField('Дата окончания', null=True, blank=True)
|
||||
|
||||
monthly_cost = models.DecimalField('Стоимость в месяц', max_digits=10, decimal_places=2,
|
||||
null=True, blank=True)
|
||||
bandwidth_down = models.PositiveIntegerField('Скорость вниз (Мбит/с)', null=True, blank=True)
|
||||
bandwidth_up = models.PositiveIntegerField('Скорость вверх (Мбит/с)', null=True, blank=True)
|
||||
|
||||
static_ips = models.TextField('Статические IP', blank=True,
|
||||
help_text='По одному на строку')
|
||||
account_number = models.CharField('Лицевой счёт', max_length=100, blank=True)
|
||||
notes = models.TextField('Примечания', blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Договор с провайдером'
|
||||
verbose_name_plural = 'Договоры с провайдерами'
|
||||
ordering = ['location', 'provider']
|
||||
unique_together = [('provider', 'contract_number')]
|
||||
indexes = [
|
||||
models.Index(fields=['status']),
|
||||
models.Index(fields=['location', 'status']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.provider} — {self.location} (№{self.contract_number})'
|
||||
|
||||
@property
|
||||
def is_expiring_soon(self):
|
||||
if not self.end_date or self.status != 'active':
|
||||
return False
|
||||
return (self.end_date - timezone.now().date()).days <= 30
|
||||
|
||||
@property
|
||||
def bandwidth_display(self):
|
||||
if self.bandwidth_down and self.bandwidth_up:
|
||||
return f'{self.bandwidth_down}↓ / {self.bandwidth_up}↑ Мбит/с'
|
||||
if self.bandwidth_down:
|
||||
return f'{self.bandwidth_down} Мбит/с'
|
||||
return '—'
|
||||
|
||||
@property
|
||||
def static_ip_list(self):
|
||||
return [ip.strip() for ip in self.static_ips.splitlines() if ip.strip()]
|
||||
@@ -0,0 +1,133 @@
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class Ticket(models.Model):
|
||||
TICKET_TYPES = [
|
||||
('repair', 'Ремонт'),
|
||||
('replacement', 'Замена оборудования'),
|
||||
('new_equipment', 'Новое оборудование'),
|
||||
('access', 'Доступ / Аккаунт'),
|
||||
('consultation', 'Консультация'),
|
||||
('other', 'Другое'),
|
||||
]
|
||||
|
||||
PRIORITY_CHOICES = [
|
||||
('low', 'Низкий'),
|
||||
('medium', 'Средний'),
|
||||
('high', 'Высокий'),
|
||||
('critical', 'Критический'),
|
||||
]
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('new', 'Новая'),
|
||||
('in_progress', 'В работе'),
|
||||
('waiting', 'Ожидание'),
|
||||
('resolved', 'Решена'),
|
||||
('closed', 'Закрыта'),
|
||||
]
|
||||
|
||||
number = models.CharField('Номер', max_length=20, unique=True, editable=False)
|
||||
title = models.CharField('Заголовок', max_length=200)
|
||||
ticket_type = models.CharField('Тип', max_length=20, choices=TICKET_TYPES, db_index=True)
|
||||
priority = models.CharField('Приоритет', max_length=10,
|
||||
choices=PRIORITY_CHOICES, default='medium', db_index=True)
|
||||
status = models.CharField('Статус', max_length=20,
|
||||
choices=STATUS_CHOICES, default='new', db_index=True)
|
||||
|
||||
requester = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.PROTECT,
|
||||
related_name='created_tickets', verbose_name='Заявитель',
|
||||
)
|
||||
assignee = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name='assigned_tickets', verbose_name='Исполнитель',
|
||||
)
|
||||
equipment = models.ForeignKey(
|
||||
'it_manager.Equipment', on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name='tickets', verbose_name='Оборудование',
|
||||
)
|
||||
location = models.ForeignKey(
|
||||
'locations.Location', on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name='it_tickets', verbose_name='Локация',
|
||||
)
|
||||
|
||||
description = models.TextField('Описание проблемы')
|
||||
resolution = models.TextField('Решение', blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
resolved_at = models.DateTimeField('Решена в', null=True, blank=True)
|
||||
closed_at = models.DateTimeField('Закрыта в', null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Заявка'
|
||||
verbose_name_plural = 'Заявки'
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['status', 'priority']),
|
||||
models.Index(fields=['requester', 'status']),
|
||||
models.Index(fields=['assignee', 'status']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f'[{self.number}] {self.title}'
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.number:
|
||||
self.number = self._generate_number()
|
||||
if self.status == 'resolved' and not self.resolved_at:
|
||||
self.resolved_at = timezone.now()
|
||||
if self.status == 'closed' and not self.closed_at:
|
||||
self.closed_at = timezone.now()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def _generate_number(self):
|
||||
today = timezone.now()
|
||||
prefix = f'IT-{today.year}{today.month:02d}'
|
||||
last = (
|
||||
Ticket.objects
|
||||
.filter(number__startswith=prefix)
|
||||
.order_by('-number')
|
||||
.values_list('number', flat=True)
|
||||
.first()
|
||||
)
|
||||
seq = int(last.split('-')[-1]) + 1 if last else 1
|
||||
return f'{prefix}-{seq:04d}'
|
||||
|
||||
@property
|
||||
def is_overdue(self):
|
||||
if self.status in ('resolved', 'closed'):
|
||||
return False
|
||||
sla_hours = {'critical': 4, 'high': 8, 'medium': 24, 'low': 72}
|
||||
limit = sla_hours.get(self.priority, 24)
|
||||
return (timezone.now() - self.created_at).total_seconds() / 3600 > limit
|
||||
|
||||
|
||||
class TicketComment(models.Model):
|
||||
ticket = models.ForeignKey(
|
||||
Ticket, on_delete=models.CASCADE,
|
||||
related_name='comments', verbose_name='Заявка',
|
||||
)
|
||||
author = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.PROTECT,
|
||||
related_name='ticket_comments', verbose_name='Автор',
|
||||
)
|
||||
text = models.TextField('Комментарий')
|
||||
is_internal = models.BooleanField(
|
||||
'Внутренний', default=False,
|
||||
help_text='Виден только сотрудникам IT',
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Комментарий'
|
||||
verbose_name_plural = 'Комментарии'
|
||||
ordering = ['created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.ticket.number} / {self.author} / {self.created_at:%d.%m.%Y %H:%M}'
|
||||
@@ -0,0 +1,117 @@
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class WorkLog(models.Model):
|
||||
WORK_TYPES = [
|
||||
('maintenance', 'Техническое обслуживание'),
|
||||
('installation', 'Установка / настройка'),
|
||||
('repair', 'Ремонт'),
|
||||
('network', 'Сетевые работы'),
|
||||
('software', 'Программное обеспечение'),
|
||||
('backup', 'Резервное копирование'),
|
||||
('other', 'Другое'),
|
||||
]
|
||||
|
||||
date = models.DateField('Дата', default=timezone.localdate, db_index=True)
|
||||
technician = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.PROTECT,
|
||||
related_name='work_logs', verbose_name='Исполнитель',
|
||||
)
|
||||
location = models.ForeignKey(
|
||||
'locations.Location', on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name='work_logs', verbose_name='Локация',
|
||||
)
|
||||
work_type = models.CharField('Вид работ', max_length=20, choices=WORK_TYPES, db_index=True)
|
||||
description = models.TextField('Описание работ')
|
||||
result = models.TextField('Результат', blank=True)
|
||||
hours_spent = models.DecimalField('Затрачено часов', max_digits=5, decimal_places=1,
|
||||
null=True, blank=True)
|
||||
|
||||
equipment = models.ForeignKey(
|
||||
'it_manager.Equipment', on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name='work_logs', verbose_name='Оборудование',
|
||||
)
|
||||
ticket = models.ForeignKey(
|
||||
'it_manager.Ticket', on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name='work_logs', verbose_name='Заявка',
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Выполненная работа'
|
||||
verbose_name_plural = 'Журнал выполненных работ'
|
||||
ordering = ['-date', '-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['date', 'technician']),
|
||||
models.Index(fields=['work_type']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.get_work_type_display()} — {self.technician} — {self.date:%d.%m.%Y}'
|
||||
|
||||
|
||||
class ServiceCall(models.Model):
|
||||
CHANNEL_CHOICES = [
|
||||
('phone', 'Телефон'),
|
||||
('chat', 'Чат / мессенджер'),
|
||||
('email', 'Email'),
|
||||
('personal', 'Личный визит'),
|
||||
('other', 'Другое'),
|
||||
]
|
||||
|
||||
date_time = models.DateTimeField('Дата и время', default=timezone.now, db_index=True)
|
||||
caller = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.PROTECT,
|
||||
related_name='service_calls_made', verbose_name='Кто обратился',
|
||||
)
|
||||
handler = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name='service_calls_handled', verbose_name='Принял обращение',
|
||||
)
|
||||
channel = models.CharField('Канал', max_length=20, choices=CHANNEL_CHOICES, db_index=True)
|
||||
location = models.ForeignKey(
|
||||
'locations.Location', on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name='service_calls', verbose_name='Локация',
|
||||
)
|
||||
description = models.TextField('Описание обращения')
|
||||
resolution = models.TextField('Решение', blank=True)
|
||||
minutes_spent = models.PositiveSmallIntegerField('Затрачено минут', null=True, blank=True)
|
||||
|
||||
ticket = models.ForeignKey(
|
||||
'it_manager.Ticket', on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name='service_calls', verbose_name='Связанная заявка',
|
||||
help_text='Если обращение переросло в тикет',
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Обращение'
|
||||
verbose_name_plural = 'Обращения'
|
||||
ordering = ['-date_time']
|
||||
indexes = [
|
||||
models.Index(fields=['date_time', 'handler']),
|
||||
models.Index(fields=['channel']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return (f'{self.get_channel_display()} — {self.caller} — '
|
||||
f'{self.date_time:%d.%m.%Y %H:%M}')
|
||||
|
||||
@property
|
||||
def duration_display(self):
|
||||
if not self.minutes_spent:
|
||||
return '—'
|
||||
if self.minutes_spent < 60:
|
||||
return f'{self.minutes_spent} мин.'
|
||||
h, m = divmod(self.minutes_spent, 60)
|
||||
return f'{h}ч {m}м' if m else f'{h}ч'
|
||||
@@ -0,0 +1,352 @@
|
||||
from django import forms
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils import timezone
|
||||
from unfold.admin import ModelAdmin, TabularInline
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
from unfold.contrib.import_export.forms import ExportForm, ImportForm
|
||||
from core.models import Directory
|
||||
from .models import Location, Pharmacy, PharmacyPhone, PharmacyHistory
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class HierarchicalRegionChoiceField(forms.ModelChoiceField):
|
||||
"""Поле выбора региона с отступами по иерархии в выпадающем списке."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
queryset = kwargs.get('queryset') or Directory.objects.filter(
|
||||
directory_type__code__in=['region', 'district', 'city', 'city_district'],
|
||||
is_active=True,
|
||||
).select_related('directory_type').order_by('_path')
|
||||
kwargs['queryset'] = queryset
|
||||
kwargs.setdefault('required', True)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def label_from_instance(self, obj):
|
||||
depth = (getattr(obj, '_path', '') or '').count('.')
|
||||
indent = '\u00A0\u00A0' * depth + ('└ ' if depth else '')
|
||||
return f'{indent}{obj.name}'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Location
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class LocationAdminForm(forms.ModelForm):
|
||||
location_region = HierarchicalRegionChoiceField(
|
||||
label=_('Город/район'),
|
||||
empty_label=_('— Выберите регион —'),
|
||||
widget=forms.Select(attrs={'class': 'hierarchy-region-select'}),
|
||||
help_text=_('Выберите из списка. Уровни вложенности отображаются отступами.'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Location
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
@admin.register(Location)
|
||||
class LocationAdmin(ModelAdmin, ImportExportModelAdmin):
|
||||
form = LocationAdminForm
|
||||
import_form_class = ImportForm
|
||||
export_form_class = ExportForm
|
||||
class Media:
|
||||
css = {'all': ('locations/css/location_admin.css',)}
|
||||
|
||||
list_display = ['address_display', 'location_type', 'location_region',
|
||||
'coordinates_display', 'is_active', 'updated_at']
|
||||
list_filter = ['location_type', 'is_active']
|
||||
search_fields = ['address', 'street', 'house_number', 'comments']
|
||||
list_select_related = ['location_region']
|
||||
ordering = ['location_region', 'address']
|
||||
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
|
||||
fieldsets = (
|
||||
(_('Тип и регион'), {
|
||||
'fields': ('location_type', 'location_region', 'is_active'),
|
||||
}),
|
||||
(_('Адрес'), {
|
||||
'fields': ('street', 'house_number', 'building', 'apartment', 'address'),
|
||||
}),
|
||||
(_('Координаты'), {
|
||||
'fields': ('location',),
|
||||
}),
|
||||
(_('Прочее'), {
|
||||
'fields': ('comments',),
|
||||
'classes': ('collapse',),
|
||||
}),
|
||||
(_('Служебное'), {
|
||||
'fields': ('created_at', 'updated_at'),
|
||||
'classes': ('collapse',),
|
||||
}),
|
||||
)
|
||||
|
||||
def get_form(self, request, obj=None, **kwargs):
|
||||
form = super().get_form(request, obj, **kwargs)
|
||||
if 'location_type' in form.base_fields:
|
||||
# Аптеки создаются только через «Аптеки» (там номер, лицензия и т.д.)
|
||||
form.base_fields['location_type'].choices = [
|
||||
(k, v) for k, v in form.base_fields['location_type'].choices
|
||||
if k != 'pharmacy'
|
||||
]
|
||||
return form
|
||||
|
||||
def address_display(self, obj):
|
||||
region = obj.location_region.name if obj.location_region else '—'
|
||||
addr = obj.get_full_address() or obj.address or '—'
|
||||
return format_html(
|
||||
'<strong>{}</strong><br><span style="color:#666;font-size:12px;">{}</span>',
|
||||
region, addr,
|
||||
)
|
||||
address_display.short_description = _('Регион / Адрес')
|
||||
address_display.admin_order_field = 'address'
|
||||
|
||||
def coordinates_display(self, obj):
|
||||
if obj.has_coordinates and obj.latitude is not None and obj.longitude is not None:
|
||||
return format_html('📍 {}', f'{float(obj.latitude):.5f}, {float(obj.longitude):.5f}')
|
||||
return '—'
|
||||
coordinates_display.short_description = _('Координаты')
|
||||
coordinates_display.admin_order_field = 'location'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pharmacy filters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class LicenseStatusFilter(admin.SimpleListFilter):
|
||||
title = _('Статус лицензии')
|
||||
parameter_name = 'license_status'
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
return [
|
||||
('valid', _('Действительна')),
|
||||
('expiring', _('Истекает (30 дн.)')),
|
||||
('expired', _('Истекла')),
|
||||
('none', _('Нет лицензии')),
|
||||
]
|
||||
|
||||
def queryset(self, request, queryset):
|
||||
today = timezone.now().date()
|
||||
soon = today + timezone.timedelta(days=30)
|
||||
if self.value() == 'valid':
|
||||
return queryset.filter(license_expiry_date__gt=soon)
|
||||
if self.value() == 'expiring':
|
||||
return queryset.filter(license_expiry_date__gte=today, license_expiry_date__lte=soon)
|
||||
if self.value() == 'expired':
|
||||
return queryset.filter(license_expiry_date__lt=today)
|
||||
if self.value() == 'none':
|
||||
return queryset.filter(license_number='') | queryset.filter(license_number__isnull=True)
|
||||
return queryset
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pharmacy inlines
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class PharmacyPhoneInline(TabularInline):
|
||||
model = PharmacyPhone
|
||||
extra = 0
|
||||
fields = ('phone', 'phone_type', 'is_primary', 'is_active', 'notes')
|
||||
|
||||
|
||||
class PharmacyHistoryInline(TabularInline):
|
||||
model = PharmacyHistory
|
||||
extra = 0
|
||||
readonly_fields = ('changed_field', 'old_value', 'new_value', 'changed_at', 'changed_by')
|
||||
fields = ('changed_at', 'changed_by', 'changed_field', 'old_value', 'new_value')
|
||||
ordering = ('-changed_at',)
|
||||
|
||||
def has_add_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pharmacy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class PharmacyAdminForm(forms.ModelForm):
|
||||
location_region = HierarchicalRegionChoiceField(
|
||||
label=_('Город/район'),
|
||||
required=True,
|
||||
empty_label=_('— Выберите регион —'),
|
||||
widget=forms.Select(attrs={'class': 'hierarchy-region-select'}),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Pharmacy
|
||||
fields = '__all__'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if 'location_type' in self.fields:
|
||||
self.fields['location_type'].initial = 'pharmacy'
|
||||
self.fields['location_type'].widget = forms.HiddenInput()
|
||||
|
||||
|
||||
@admin.register(Pharmacy)
|
||||
class PharmacyAdmin(ModelAdmin, ImportExportModelAdmin):
|
||||
form = PharmacyAdminForm
|
||||
import_form_class = ImportForm
|
||||
export_form_class = ExportForm
|
||||
|
||||
class Media:
|
||||
css = {'all': ('locations/css/location_admin.css',)}
|
||||
js = ('assets/js/admin_phone_format.js',)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# List view
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
list_display = [
|
||||
'pharmacy_display',
|
||||
'organization',
|
||||
'location_region',
|
||||
'phone_numbers_display',
|
||||
'status_display',
|
||||
'license_status_display',
|
||||
'head_pharmacist',
|
||||
'employees_count_display',
|
||||
'working_hours',
|
||||
'is_active',
|
||||
]
|
||||
list_editable = ['working_hours', 'is_active']
|
||||
list_filter = ['organization', 'status', 'is_active', LicenseStatusFilter]
|
||||
search_fields = ['pharmacy_number', 'address', 'phone', 'license_number',
|
||||
'head_pharmacist__last_name']
|
||||
list_select_related = ['organization', 'location_region', 'head_pharmacist']
|
||||
ordering = ['organization', 'pharmacy_number']
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Detail view — tabs
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
readonly_fields = [
|
||||
'employees_count_display', 'license_status_display',
|
||||
'created_at', 'updated_at',
|
||||
]
|
||||
|
||||
fieldsets = (
|
||||
(_('Основное'), {
|
||||
'classes': ('tab',),
|
||||
'fields': (
|
||||
'organization', 'pharmacy_number',
|
||||
'status', 'is_active', 'working_hours',
|
||||
'location_type',
|
||||
),
|
||||
}),
|
||||
(_('Адрес'), {
|
||||
'classes': ('tab',),
|
||||
'fields': (
|
||||
'location_region',
|
||||
'street', 'house_number', 'building', 'apartment',
|
||||
'address',
|
||||
'location',
|
||||
),
|
||||
}),
|
||||
(_('Контакты'), {
|
||||
'classes': ('tab',),
|
||||
'fields': ('phone',),
|
||||
}),
|
||||
(_('Лицензия'), {
|
||||
'classes': ('tab',),
|
||||
'fields': (
|
||||
'license_number', 'license_expiry_date',
|
||||
'license_status_display',
|
||||
),
|
||||
}),
|
||||
(_('Персонал'), {
|
||||
'classes': ('tab',),
|
||||
'fields': (
|
||||
'head_pharmacist', 'pharmacists_count',
|
||||
'employees_count_display',
|
||||
),
|
||||
}),
|
||||
(_('Примечания'), {
|
||||
'classes': ('tab',),
|
||||
'fields': ('comments', 'created_at', 'updated_at'),
|
||||
}),
|
||||
)
|
||||
|
||||
inlines = [PharmacyPhoneInline, PharmacyHistoryInline]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Queryset
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_queryset(self, request):
|
||||
return super().get_queryset(request).prefetch_related('phones')
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# List display methods
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def pharmacy_display(self, obj):
|
||||
org = obj.organization.name if obj.organization else ''
|
||||
addr = obj.get_full_address() or obj.address or ''
|
||||
short_addr = addr[:70] + '…' if len(addr) > 70 else addr
|
||||
return format_html(
|
||||
'<strong>№{} {}</strong>'
|
||||
'<br><span style="color:#6b7280;font-size:12px;">{}</span>',
|
||||
obj.pharmacy_number, org, short_addr,
|
||||
)
|
||||
pharmacy_display.short_description = _('Аптека')
|
||||
pharmacy_display.admin_order_field = 'pharmacy_number'
|
||||
|
||||
STATUS_COLORS = {
|
||||
'open': '#16a34a',
|
||||
'closed': '#dc2626',
|
||||
'suspended': '#ca8a04',
|
||||
'preparing': '#2563eb',
|
||||
}
|
||||
|
||||
def status_display(self, obj):
|
||||
color = self.STATUS_COLORS.get(obj.status, '#6b7280')
|
||||
return format_html(
|
||||
'<span style="color:{};font-weight:bold;">{}</span>',
|
||||
color, obj.get_status_display(),
|
||||
)
|
||||
status_display.short_description = _('Статус')
|
||||
status_display.admin_order_field = 'status'
|
||||
|
||||
def license_status_display(self, obj):
|
||||
status = obj.license_status
|
||||
color = {
|
||||
'Действительна': '#16a34a',
|
||||
'Истекла': '#dc2626',
|
||||
'Бессрочная': '#6b7280',
|
||||
'Нет лицензии': '#6b7280',
|
||||
}.get(status, '#ca8a04' if 'Истекает' in status else '#6b7280')
|
||||
return format_html('<span style="color:{};">{}</span>', color, status)
|
||||
license_status_display.short_description = _('Лицензия')
|
||||
license_status_display.admin_order_field = 'license_expiry_date'
|
||||
|
||||
def employees_count_display(self, obj):
|
||||
count = obj.employees_count
|
||||
color = '#16a34a' if count else '#9ca3af'
|
||||
return format_html(
|
||||
'<span style="color:{};font-weight:bold;">{}</span>',
|
||||
color, count or 0,
|
||||
)
|
||||
employees_count_display.short_description = _('Сотрудников')
|
||||
|
||||
def phone_numbers_display(self, obj):
|
||||
from django.utils.safestring import mark_safe
|
||||
numbers = []
|
||||
if obj.phone:
|
||||
numbers.append(obj.phone)
|
||||
numbers.extend(
|
||||
p.phone for p in obj.phones.all()
|
||||
if p.phone and p.phone != obj.phone
|
||||
)
|
||||
if not numbers:
|
||||
return '—'
|
||||
return mark_safe('<br>'.join(numbers))
|
||||
phone_numbers_display.short_description = _('Телефоны')
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class LocationsConfig(AppConfig):
|
||||
name = 'apps.locations'
|
||||
verbose_name = 'Локации'
|
||||
@@ -0,0 +1,126 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-06 10:32
|
||||
|
||||
import core.utils.validators
|
||||
import django.db.models.deletion
|
||||
import location_field.models.plain
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('core', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Location',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('location_type', models.CharField(choices=[('pharmacy', 'Аптека'), ('warehouse', 'Склад'), ('office', 'Офис')], max_length=20, verbose_name='Тип локации')),
|
||||
('street', models.CharField(blank=True, max_length=100, verbose_name='Улица')),
|
||||
('house_number', models.CharField(blank=True, max_length=20, verbose_name='Номер дома')),
|
||||
('building', models.CharField(blank=True, max_length=20, verbose_name='Корпус/строение')),
|
||||
('apartment', models.CharField(blank=True, max_length=20, verbose_name='Помещение/офис')),
|
||||
('address', models.CharField(blank=True, help_text='Заполняется автоматически из полей выше', max_length=255, verbose_name='Полный адрес')),
|
||||
('location', location_field.models.plain.PlainLocationField(blank=True, default='46.4825,30.7233', max_length=63, verbose_name='Координаты на карте')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='Активна')),
|
||||
('comments', models.TextField(blank=True, verbose_name='Комментарии')),
|
||||
('location_region', models.ForeignKey(limit_choices_to={'directory_type__code__in': ['region', 'district', 'city', 'city_district'], 'is_active': True}, on_delete=django.db.models.deletion.PROTECT, related_name='region_locations', to='core.directory', verbose_name='Город/район')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Локация',
|
||||
'verbose_name_plural': 'Локации',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Pharmacy',
|
||||
fields=[
|
||||
('location_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='locations.location')),
|
||||
('pharmacy_number', models.PositiveIntegerField(help_text='Числовой номер аптеки внутри организации', verbose_name='Номер аптеки')),
|
||||
('license_number', models.CharField(blank=True, max_length=50, null=True, verbose_name='Номер лицензии')),
|
||||
('license_expiry_date', models.DateField(blank=True, null=True, verbose_name='Дата окончания лицензии')),
|
||||
('pharmacists_count', models.PositiveSmallIntegerField(default=1, help_text='Кешированное поле — обновляется при синхронизации', verbose_name='Количество фармацевтов')),
|
||||
('phone', models.CharField(blank=True, max_length=20, validators=[core.utils.validators.validate_ukrainian_phone], verbose_name='Телефон')),
|
||||
('working_hours', models.CharField(default='08:00-20:00', max_length=100, verbose_name='Часы работы')),
|
||||
('has_online_ordering', models.BooleanField(default=True, verbose_name='Онлайн заказы')),
|
||||
('sync_url', models.URLField(blank=True, help_text='Адрес PPOS: http://192.168.1.10:8000', verbose_name='URL локального сервера')),
|
||||
('sync_token', models.CharField(blank=True, help_text='Генерируется автоматически при сохранении, если пусто', max_length=255, verbose_name='Токен синхронизации')),
|
||||
('last_synced_at', models.DateTimeField(blank=True, help_text='Заполняется при первой синхронизации с PPOS', null=True, verbose_name='Последняя синхронизация')),
|
||||
('head_pharmacist', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='head_pharmacist_at', to=settings.AUTH_USER_MODEL, verbose_name='Заведующий аптекой')),
|
||||
('organization', models.ForeignKey(help_text='Юрлицо: АКС, АКС Одесса, Тира, Пентальфа...', limit_choices_to={'directory_type__code': 'organization', 'is_active': True}, on_delete=django.db.models.deletion.PROTECT, related_name='pharmacies', to='core.directory', verbose_name='Организация')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Аптека',
|
||||
'verbose_name_plural': 'Аптеки',
|
||||
'ordering': ['organization', 'pharmacy_number'],
|
||||
},
|
||||
bases=('locations.location',),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PharmacyPhone',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('phone', models.CharField(max_length=20, validators=[core.utils.validators.validate_ukrainian_phone], verbose_name='Телефон')),
|
||||
('phone_type', models.CharField(choices=[('main', 'Основной'), ('mobile', 'Мобильный')], default='main', max_length=10, verbose_name='Тип')),
|
||||
('is_primary', models.BooleanField(default=False, verbose_name='Основной номер')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='Активен')),
|
||||
('notes', models.CharField(blank=True, max_length=200, verbose_name='Примечания')),
|
||||
('pharmacy', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='phones', to='locations.pharmacy', verbose_name='Аптека')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Телефон аптеки',
|
||||
'verbose_name_plural': 'Телефоны аптек',
|
||||
'ordering': ['-is_primary', 'phone_type'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PharmacyHistory',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('changed_field', models.CharField(max_length=50, verbose_name='Изменённое поле')),
|
||||
('old_value', models.TextField(blank=True, verbose_name='Старое значение')),
|
||||
('new_value', models.TextField(blank=True, verbose_name='Новое значение')),
|
||||
('changed_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата изменения')),
|
||||
('changed_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Кто изменил')),
|
||||
('pharmacy', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='history', to='locations.pharmacy', verbose_name='Аптека')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'История изменений аптеки',
|
||||
'verbose_name_plural': 'История изменений аптек',
|
||||
'ordering': ['-changed_at'],
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='location',
|
||||
index=models.Index(fields=['location'], name='locations_l_locatio_4beb8b_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='pharmacy',
|
||||
index=models.Index(fields=['pharmacy_number'], name='locations_p_pharmac_6fd8df_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='pharmacy',
|
||||
index=models.Index(fields=['organization'], name='locations_p_organiz_c5677d_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='pharmacy',
|
||||
index=models.Index(fields=['head_pharmacist'], name='locations_p_head_ph_d88025_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='pharmacy',
|
||||
index=models.Index(fields=['last_synced_at'], name='locations_p_last_sy_0dc5a0_idx'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='pharmacy',
|
||||
constraint=models.UniqueConstraint(fields=('organization', 'pharmacy_number'), name='unique_pharmacy_number_per_organization'),
|
||||
),
|
||||
]
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-06 13:27
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('locations', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveIndex(
|
||||
model_name='pharmacy',
|
||||
name='locations_p_last_sy_0dc5a0_idx',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='pharmacy',
|
||||
name='has_online_ordering',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='pharmacy',
|
||||
name='last_synced_at',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='pharmacy',
|
||||
name='sync_token',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='pharmacy',
|
||||
name='sync_url',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-24 11:16
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('locations', '0002_remove_pharmacy_locations_p_last_sy_0dc5a0_idx_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='pharmacy',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('open', 'Открыта'), ('closed', 'Закрыта'), ('suspended', 'Остановлена'), ('preparing', 'Подготовка к запуску')], db_index=True, default='open', max_length=20, verbose_name='Статус'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,2 @@
|
||||
from .base import Location, LocationManager
|
||||
from .pharmacies import Pharmacy, PharmacyManager, PharmacyPhone, PharmacyHistory
|
||||
@@ -0,0 +1,126 @@
|
||||
from django.db import models
|
||||
from core.models import BaseModel
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from location_field.models.plain import PlainLocationField
|
||||
|
||||
|
||||
class LocationManager(models.Manager):
|
||||
|
||||
def active(self):
|
||||
return self.filter(is_active=True)
|
||||
|
||||
def by_type(self, location_type_code):
|
||||
return self.filter(location_type=location_type_code, is_active=True)
|
||||
|
||||
def by_region(self, region_id):
|
||||
return self.filter(location_region_id=region_id, is_active=True)
|
||||
|
||||
def with_coordinates(self):
|
||||
return self.exclude(location='')
|
||||
|
||||
|
||||
class Location(BaseModel):
|
||||
"""
|
||||
Базовая географическая точка.
|
||||
Pharmacy и Warehouse наследуются отсюда через multi-table inheritance.
|
||||
"""
|
||||
location_type = models.CharField(_('Тип локации'), max_length=20, choices=[
|
||||
('pharmacy', _('Аптека')),
|
||||
('warehouse', _('Склад')),
|
||||
('office', _('Офис')),
|
||||
])
|
||||
location_region = models.ForeignKey(
|
||||
'core.Directory', on_delete=models.PROTECT,
|
||||
limit_choices_to={'directory_type__code__in': ['region', 'district','city', 'city_district'], 'is_active': True},
|
||||
related_name='region_locations', verbose_name=_('Город/район')
|
||||
)
|
||||
|
||||
street = models.CharField(_('Улица'), max_length=100, blank=True)
|
||||
house_number = models.CharField(_('Номер дома'), max_length=20, blank=True)
|
||||
building = models.CharField(_('Корпус/строение'), max_length=20, blank=True)
|
||||
apartment = models.CharField(_('Помещение/офис'), max_length=20, blank=True)
|
||||
address = models.CharField(_('Полный адрес'), max_length=255, blank=True,
|
||||
help_text=_('Заполняется автоматически из полей выше'))
|
||||
|
||||
# --- Координаты (django-location-field: "широта,долгота") ---
|
||||
# Центр по умолчанию: Одесса (46.4825°N, 30.7233°E)
|
||||
location = PlainLocationField(
|
||||
based_fields=['address'],
|
||||
zoom=14,
|
||||
verbose_name=_('Координаты на карте'),
|
||||
blank=True,
|
||||
default='46.4825,30.7233',
|
||||
)
|
||||
|
||||
is_active = models.BooleanField(_('Активна'), default=True)
|
||||
comments = models.TextField(_('Комментарии'), blank=True)
|
||||
|
||||
objects = LocationManager()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Локация')
|
||||
verbose_name_plural = _('Локации')
|
||||
indexes = [
|
||||
# models.Index(fields=['location_type', 'is_active']),
|
||||
# models.Index(fields=['location_region', 'is_active']),
|
||||
models.Index(fields=['location']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
region = self.location_region.name if self.location_region else ''
|
||||
addr = self.get_full_address() or self.address
|
||||
parts = [p for p in (region, addr) if p]
|
||||
return ', '.join(parts) or self.get_location_type_display()
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
lat, lon = self.latitude, self.longitude
|
||||
if lat is not None and not (-90 <= lat <= 90):
|
||||
raise ValidationError({'location': 'Широта должна быть от -90 до 90'})
|
||||
if lon is not None and not (-180 <= lon <= 180):
|
||||
raise ValidationError({'location': 'Долгота должна быть от -180 до 180'})
|
||||
|
||||
def get_full_address(self):
|
||||
parts = []
|
||||
# if self.location_region:
|
||||
# parts.append(self.location_region.name)
|
||||
if self.street:
|
||||
parts.append(f"ул. {self.street}")
|
||||
if self.house_number:
|
||||
parts.append(f"д. {self.house_number}")
|
||||
if self.building:
|
||||
parts.append(f"корп. {self.building}")
|
||||
if self.apartment:
|
||||
parts.append(f"пом. {self.apartment}")
|
||||
return ", ".join(parts) if parts else self.address
|
||||
|
||||
@property
|
||||
def latitude(self):
|
||||
"""Широта из location (для обратной совместимости)."""
|
||||
if not self.location:
|
||||
return None
|
||||
parts = str(self.location).split(',')
|
||||
if len(parts) >= 2:
|
||||
try:
|
||||
return float(parts[0].strip())
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return None
|
||||
|
||||
@property
|
||||
def longitude(self):
|
||||
"""Долгота из location (для обратной совместимости)."""
|
||||
if not self.location:
|
||||
return None
|
||||
parts = str(self.location).split(',')
|
||||
if len(parts) >= 2:
|
||||
try:
|
||||
return float(parts[1].strip())
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return None
|
||||
|
||||
@property
|
||||
def has_coordinates(self):
|
||||
return bool(self.location)
|
||||
@@ -0,0 +1,233 @@
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from core.utils.validators import validate_ukrainian_phone
|
||||
from django.utils import timezone
|
||||
from .base import Location
|
||||
|
||||
class PharmacyManager(models.Manager):
|
||||
|
||||
def get_queryset(self):
|
||||
# Location.location_type — CharField
|
||||
return super().get_queryset().filter(location_type='pharmacy')
|
||||
|
||||
def active(self):
|
||||
return self.get_queryset().filter(is_active=True)
|
||||
|
||||
def by_organization(self, organization_id):
|
||||
return self.get_queryset().filter(
|
||||
organization_id=organization_id, is_active=True
|
||||
)
|
||||
|
||||
# def by_format(self, format_code):
|
||||
# return self.get_queryset().filter(
|
||||
# pharmacy_format__code=format_code, is_active=True
|
||||
# )
|
||||
|
||||
def by_region(self, region_id):
|
||||
return self.get_queryset().filter(
|
||||
location_region_id=region_id, is_active=True
|
||||
)
|
||||
|
||||
|
||||
|
||||
class Pharmacy(Location):
|
||||
"""
|
||||
Аптека — расширение Location через multi-table inheritance.
|
||||
Все общие поля (адрес, координаты, регион) живут в Location.
|
||||
"""
|
||||
objects = PharmacyManager()
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('open', _('Открыта')),
|
||||
('closed', _('Закрыта')),
|
||||
('suspended', _('Остановлена')),
|
||||
('preparing', _('Подготовка к запуску')),
|
||||
]
|
||||
|
||||
# --- Идентификация ---
|
||||
pharmacy_number = models.PositiveIntegerField(
|
||||
_('Номер аптеки'),
|
||||
help_text=_('Числовой номер аптеки внутри организации')
|
||||
)
|
||||
organization = models.ForeignKey(
|
||||
'core.Directory', on_delete=models.PROTECT,
|
||||
limit_choices_to={'directory_type__code': 'organization', 'is_active': True},
|
||||
related_name='pharmacies', verbose_name=_('Организация'),
|
||||
help_text=_('Юрлицо: АКС, АКС Одесса, Тира, Пентальфа...')
|
||||
)
|
||||
# pharmacy_format = models.ForeignKey(
|
||||
# 'core.Directory', on_delete=models.PROTECT,
|
||||
# limit_choices_to={'directory_type__code': 'pharmacy_format', 'is_active': True},
|
||||
# related_name='pharmacy_format_set', verbose_name='Формат аптеки',
|
||||
# null=True, blank=True
|
||||
# )
|
||||
|
||||
# --- Лицензия ---
|
||||
license_number = models.CharField(_('Номер лицензии'), max_length=50,
|
||||
blank=True, null=True)
|
||||
license_expiry_date = models.DateField(_('Дата окончания лицензии'),
|
||||
blank=True, null=True)
|
||||
|
||||
# --- Персонал ---
|
||||
head_pharmacist = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name='head_pharmacist_at', verbose_name=_('Заведующий аптекой')
|
||||
)
|
||||
pharmacists_count = models.PositiveSmallIntegerField(
|
||||
_('Количество фармацевтов'), default=1,
|
||||
help_text=_('Кешированное поле — обновляется при синхронизации')
|
||||
)
|
||||
|
||||
status = models.CharField(
|
||||
_('Статус'), max_length=20,
|
||||
choices=STATUS_CHOICES, default='open', db_index=True,
|
||||
)
|
||||
|
||||
phone = models.CharField(_('Телефон'), max_length=20, blank=True,
|
||||
validators=[validate_ukrainian_phone])
|
||||
working_hours = models.CharField(_('Часы работы'), max_length=100,
|
||||
default='08:00-20:00')
|
||||
|
||||
# --- Услуги (через справочник — без хардкода булевых флагов) ---
|
||||
# services = models.ManyToManyField(
|
||||
# 'core.Directory', blank=True,
|
||||
# limit_choices_to={'directory_type__code': 'pharmacy_service', 'is_active': True},
|
||||
# related_name='pharmacies_with_service', verbose_name='Услуги',
|
||||
# help_text='Измерение давления, тест на глюкозу, дежурная аптека...'
|
||||
# )
|
||||
|
||||
# --- Онлайн ---
|
||||
# has_online_ordering = models.BooleanField(_('Онлайн заказы'), default=True)
|
||||
|
||||
# sync_url = models.URLField(_('URL локального сервера'), blank=True,
|
||||
# help_text=_('Адрес PPOS: http://192.168.1.10:8000'))
|
||||
# sync_token = models.CharField(
|
||||
# _('Токен синхронизации'), max_length=255, blank=True,
|
||||
# help_text=_('Генерируется автоматически при сохранении, если пусто')
|
||||
# )
|
||||
# last_synced_at = models.DateTimeField(_('Последняя синхронизация'), null=True, blank=True,
|
||||
# help_text=_('Заполняется при первой синхронизации с PPOS'))
|
||||
|
||||
# # --- Mattermost ---
|
||||
# mattermost_channel = models.ForeignKey(
|
||||
# 'mattermost_connector.MattermostChannel', on_delete=models.SET_NULL,
|
||||
# null=True, blank=True,
|
||||
# related_name='pharmacy', verbose_name='Канал Mattermost'
|
||||
# )
|
||||
# mattermost_auto_create_channel = models.BooleanField(
|
||||
# 'Автосоздание канала', default=True
|
||||
# )
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Аптека')
|
||||
verbose_name_plural = _('Аптеки')
|
||||
ordering = ['organization', 'pharmacy_number']
|
||||
indexes = [
|
||||
models.Index(fields=['pharmacy_number']),
|
||||
models.Index(fields=['organization']),
|
||||
# models.Index(fields=['pharmacy_format']),
|
||||
models.Index(fields=['head_pharmacist']),
|
||||
# models.Index(fields=['last_synced_at']),
|
||||
]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=['organization', 'pharmacy_number'],
|
||||
name='unique_pharmacy_number_per_organization'
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
org = self.organization.name if self.organization else ''
|
||||
return f"{org} Аптека №{self.pharmacy_number}: {self.address}"
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if self.license_expiry_date and self.license_expiry_date < timezone.now().date():
|
||||
raise ValidationError({'license_expiry_date': 'Лицензия истекла'})
|
||||
if self.pharmacists_count < 1:
|
||||
raise ValidationError({'pharmacists_count': 'Минимум 1 фармацевт'})
|
||||
if self.head_pharmacist and hasattr(self.head_pharmacist, 'post') and self.head_pharmacist.post:
|
||||
if getattr(self.head_pharmacist.post, 'code', None) != 'pharmacy_manager':
|
||||
raise ValidationError({
|
||||
'head_pharmacist': 'Сотрудник должен иметь должность заведующего аптекой'
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.location_type:
|
||||
self.location_type = 'pharmacy'
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def is_license_valid(self):
|
||||
if not self.license_expiry_date:
|
||||
return None
|
||||
return self.license_expiry_date >= timezone.now().date()
|
||||
|
||||
@property
|
||||
def license_status(self):
|
||||
if not self.license_number:
|
||||
return 'Нет лицензии'
|
||||
if not self.license_expiry_date:
|
||||
return 'Бессрочная'
|
||||
if self.is_license_valid:
|
||||
days_left = (self.license_expiry_date - timezone.now().date()).days
|
||||
if days_left <= 30:
|
||||
return f'Истекает через {days_left} дн.'
|
||||
return 'Действительна'
|
||||
return 'Истекла'
|
||||
|
||||
def get_employees(self):
|
||||
"""Сотрудники аптеки."""
|
||||
from django.contrib.auth import get_user_model
|
||||
return get_user_model().objects.filter(user_pharmacy=self, is_active=True)
|
||||
|
||||
@property
|
||||
def employees_count(self):
|
||||
return self.get_employees().count()
|
||||
|
||||
|
||||
class PharmacyPhone(models.Model):
|
||||
"""Множественные телефоны аптеки"""
|
||||
PHONE_TYPES = [
|
||||
('main', _('Основной')),
|
||||
('mobile', _('Мобильный')),
|
||||
]
|
||||
pharmacy = models.ForeignKey(Pharmacy, on_delete=models.CASCADE,
|
||||
related_name='phones', verbose_name='Аптека')
|
||||
phone = models.CharField(_('Телефон'), max_length=20,
|
||||
validators=[validate_ukrainian_phone])
|
||||
phone_type = models.CharField(_('Тип'), max_length=10, choices=PHONE_TYPES, default='main')
|
||||
is_primary = models.BooleanField(_('Основной номер'), default=False)
|
||||
is_active = models.BooleanField(_('Активен'), default=True)
|
||||
notes = models.CharField(_('Примечания'), max_length=200, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Телефон аптеки')
|
||||
verbose_name_plural = _('Телефоны аптек')
|
||||
ordering = ['-is_primary', 'phone_type']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.pharmacy} — {self.phone}"
|
||||
|
||||
|
||||
class PharmacyHistory(models.Model):
|
||||
"""Аудит изменений полей аптеки"""
|
||||
pharmacy = models.ForeignKey(Pharmacy, on_delete=models.CASCADE,
|
||||
related_name='history', verbose_name=_('Аптека'))
|
||||
changed_field = models.CharField(_('Изменённое поле'), max_length=50)
|
||||
old_value = models.TextField(_('Старое значение'), blank=True)
|
||||
new_value = models.TextField(_('Новое значение'), blank=True)
|
||||
changed_at = models.DateTimeField(_('Дата изменения'), auto_now_add=True)
|
||||
changed_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True,
|
||||
verbose_name=_('Кто изменил')
|
||||
# ← FK вместо CharField из оригинала
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('История изменений аптеки')
|
||||
verbose_name_plural = _('История изменений аптек')
|
||||
ordering = ['-changed_at']
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class MailConnectorConfig(AppConfig):
|
||||
name = 'apps.mail_connector'
|
||||
verbose_name = 'Carbonio CE Connector'
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class MailparserConfig(AppConfig):
|
||||
name = 'apps.mailparser'
|
||||
verbose_name = 'Сортировщик писем'
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
Executable
Executable
+231
@@ -0,0 +1,231 @@
|
||||
from django.contrib import admin
|
||||
from unfold.admin import ModelAdmin, TabularInline
|
||||
from unfold.decorators import display
|
||||
from django.utils.html import format_html
|
||||
from .models import (
|
||||
MattermostUser,
|
||||
MattermostTeam,
|
||||
MattermostChannel,
|
||||
MattermostTeamMembership,
|
||||
MattermostChannelMembership,
|
||||
TeamSyncSettings,
|
||||
ChannelSyncSettings
|
||||
)
|
||||
|
||||
|
||||
@admin.register(MattermostTeam)
|
||||
class MattermostTeamAdmin(ModelAdmin):
|
||||
"""Админка для команд Mattermost"""
|
||||
list_display = ('display_name', 'name', 'team_id', 'has_sync_settings', 'created_at')
|
||||
search_fields = ('display_name', 'name', 'team_id')
|
||||
readonly_fields = ('team_id', 'created_at')
|
||||
ordering = ('display_name',)
|
||||
|
||||
@display(description='Настройки синхронизации', boolean=True)
|
||||
def has_sync_settings(self, obj):
|
||||
return hasattr(obj, 'sync_settings')
|
||||
|
||||
|
||||
@admin.register(TeamSyncSettings)
|
||||
class TeamSyncSettingsAdmin(ModelAdmin):
|
||||
"""Админка для настроек синхронизации команд"""
|
||||
list_display = (
|
||||
'team_display',
|
||||
'is_active_display',
|
||||
'is_default_display',
|
||||
'auto_add_users',
|
||||
'priority',
|
||||
'filter_by_department',
|
||||
'departments_count'
|
||||
)
|
||||
list_filter = ('is_active', 'is_default', 'auto_add_users', 'filter_by_department')
|
||||
search_fields = ('team__display_name', 'team__name', 'notes')
|
||||
filter_horizontal = ('departments',)
|
||||
|
||||
fieldsets = (
|
||||
('Команда', {
|
||||
'fields': ('team',)
|
||||
}),
|
||||
('Основные настройки', {
|
||||
'fields': ('is_active', 'is_default', 'priority', 'auto_add_users')
|
||||
}),
|
||||
('Фильтрация по отделам', {
|
||||
'fields': ('filter_by_department', 'departments'),
|
||||
'description': 'Если включено, в команду будут добавлены только пользователи из выбранных отделов'
|
||||
}),
|
||||
('Дополнительно', {
|
||||
'fields': ('notes',),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
actions = ['activate_sync', 'deactivate_sync', 'set_as_default']
|
||||
|
||||
@display(description='Команда')
|
||||
def team_display(self, obj):
|
||||
return obj.team.display_name
|
||||
|
||||
@display(description='Активна', boolean=True)
|
||||
def is_active_display(self, obj):
|
||||
return obj.is_active
|
||||
|
||||
@display(description='По умолчанию', boolean=True)
|
||||
def is_default_display(self, obj):
|
||||
return obj.is_default
|
||||
|
||||
@display(description='Отделов')
|
||||
def departments_count(self, obj):
|
||||
if not obj.filter_by_department:
|
||||
return format_html('<span style="color: gray;">Все</span>')
|
||||
count = obj.departments.count()
|
||||
return format_html(
|
||||
'<span style="color: {};">{}</span>',
|
||||
'green' if count > 0 else 'red',
|
||||
count if count > 0 else 'Не указано!'
|
||||
)
|
||||
|
||||
def activate_sync(self, request, queryset):
|
||||
"""Активировать синхронизацию"""
|
||||
updated = queryset.update(is_active=True)
|
||||
self.message_user(request, f'Активировано настроек: {updated}')
|
||||
activate_sync.short_description = 'Активировать синхронизацию'
|
||||
|
||||
def deactivate_sync(self, request, queryset):
|
||||
"""Деактивировать синхронизацию"""
|
||||
updated = queryset.update(is_active=False)
|
||||
self.message_user(request, f'Деактивировано настроек: {updated}')
|
||||
deactivate_sync.short_description = 'Деактивировать синхронизацию'
|
||||
|
||||
def set_as_default(self, request, queryset):
|
||||
"""Установить как команду по умолчанию"""
|
||||
if queryset.count() > 1:
|
||||
self.message_user(request, 'Можно выбрать только одну команду', level='error')
|
||||
return
|
||||
|
||||
team_setting = queryset.first()
|
||||
if team_setting:
|
||||
# Снимаем флаг со всех остальных
|
||||
TeamSyncSettings.objects.exclude(pk=team_setting.pk).update(is_default=False)
|
||||
team_setting.is_default = True
|
||||
team_setting.save()
|
||||
self.message_user(request, f'{team_setting.team.display_name} установлена как команда по умолчанию')
|
||||
set_as_default.short_description = 'Установить как команду по умолчанию'
|
||||
|
||||
|
||||
class TeamMembershipInline(TabularInline):
|
||||
"""Inline для управления членством в командах"""
|
||||
model = MattermostTeamMembership
|
||||
extra = 1
|
||||
verbose_name = 'Членство в команде'
|
||||
verbose_name_plural = 'Команды пользователя'
|
||||
|
||||
fields = ('team', 'role', 'joined_at')
|
||||
readonly_fields = ('joined_at',)
|
||||
autocomplete_fields = ['team']
|
||||
|
||||
def get_queryset(self, request):
|
||||
return super().get_queryset(request).select_related('team')
|
||||
|
||||
|
||||
@admin.register(MattermostUser)
|
||||
class MattermostUserAdmin(ModelAdmin):
|
||||
"""Админка для пользователей Mattermost"""
|
||||
list_display = ('django_user', 'username', 'email', 'is_active', 'teams_count', 'last_sync')
|
||||
list_filter = ('is_active',)
|
||||
search_fields = ('username', 'email', 'django_user__first_name', 'django_user__last_name')
|
||||
readonly_fields = ('mattermost_id', 'created_at', 'updated_at', 'last_sync')
|
||||
inlines = [TeamMembershipInline]
|
||||
|
||||
@display(description='Команд')
|
||||
def teams_count(self, obj):
|
||||
count = obj.mattermostteammembership_set.count()
|
||||
if count > 0:
|
||||
return format_html(
|
||||
'<span style="color: green; font-weight: bold;">{}</span>',
|
||||
count
|
||||
)
|
||||
return format_html('<span style="color: gray;">0</span>')
|
||||
|
||||
|
||||
@admin.register(MattermostChannel)
|
||||
class MattermostChannelAdmin(ModelAdmin):
|
||||
"""Админка для каналов Mattermost"""
|
||||
list_display = ('display_name', 'name', 'team', 'channel_type', 'has_sync_settings', 'created_at')
|
||||
list_filter = ('channel_type', 'team')
|
||||
search_fields = ('display_name', 'name', 'channel_id')
|
||||
readonly_fields = ('channel_id', 'created_at')
|
||||
|
||||
@display(description='Настройки синхронизации', boolean=True)
|
||||
def has_sync_settings(self, obj):
|
||||
return hasattr(obj, 'sync_settings')
|
||||
|
||||
|
||||
@admin.register(ChannelSyncSettings)
|
||||
class ChannelSyncSettingsAdmin(ModelAdmin):
|
||||
"""Админка для настроек синхронизации каналов"""
|
||||
list_display = (
|
||||
'channel_display',
|
||||
'team_display',
|
||||
'is_active_display',
|
||||
'auto_add_users',
|
||||
'priority',
|
||||
'filter_by_department',
|
||||
'departments_count'
|
||||
)
|
||||
list_filter = ('is_active', 'auto_add_users', 'filter_by_department', 'channel__team')
|
||||
search_fields = ('channel__display_name', 'channel__name', 'notes')
|
||||
filter_horizontal = ('departments',)
|
||||
|
||||
fieldsets = (
|
||||
('Канал', {
|
||||
'fields': ('channel',)
|
||||
}),
|
||||
('Основные настройки', {
|
||||
'fields': ('is_active', 'priority', 'auto_add_users')
|
||||
}),
|
||||
('Фильтрация по отделам', {
|
||||
'fields': ('filter_by_department', 'departments'),
|
||||
'description': 'Если включено, в канал будут добавлены только пользователи из выбранных отделов'
|
||||
}),
|
||||
('Дополнительно', {
|
||||
'fields': ('notes',),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
actions = ['activate_sync', 'deactivate_sync']
|
||||
|
||||
@display(description='Канал')
|
||||
def channel_display(self, obj):
|
||||
return obj.channel.display_name
|
||||
|
||||
@display(description='Команда')
|
||||
def team_display(self, obj):
|
||||
return obj.channel.team.display_name
|
||||
|
||||
@display(description='Активна', boolean=True)
|
||||
def is_active_display(self, obj):
|
||||
return obj.is_active
|
||||
|
||||
@display(description='Отделов')
|
||||
def departments_count(self, obj):
|
||||
if not obj.filter_by_department:
|
||||
return format_html('<span style="color: gray;">Все</span>')
|
||||
count = obj.departments.count()
|
||||
return format_html(
|
||||
'<span style="color: {};">{}</span>',
|
||||
'green' if count > 0 else 'red',
|
||||
count if count > 0 else 'Не указано!'
|
||||
)
|
||||
|
||||
def activate_sync(self, request, queryset):
|
||||
"""Активировать синхронизацию"""
|
||||
updated = queryset.update(is_active=True)
|
||||
self.message_user(request, f'Активировано настроек: {updated}')
|
||||
activate_sync.short_description = 'Активировать синхронизацию'
|
||||
|
||||
def deactivate_sync(self, request, queryset):
|
||||
"""Деактивировать синхронизацию"""
|
||||
updated = queryset.update(is_active=False)
|
||||
self.message_user(request, f'Деактивировано настроек: {updated}')
|
||||
deactivate_sync.short_description = 'Деактивировать синхронизацию'
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class MattermostConnectorConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.mattermost_connector'
|
||||
|
||||
def ready(self):
|
||||
"""Подключаем сигналы при инициализации приложения"""
|
||||
import apps.mattermost_connector.signals # noqa
|
||||
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
Management команда для создания каналов для аптек в Mattermost
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from apps.locations.models import Pharmacy
|
||||
from apps.mattermost_connector.services.pharmacy_channel_service import get_or_create_pharmacy_channel
|
||||
from apps.mattermost_connector.models import MattermostTeam
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Создать каналы для аптек в Mattermost'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--pharmacy-id',
|
||||
type=int,
|
||||
help='ID конкретной аптеки для создания канала',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--team',
|
||||
type=str,
|
||||
help='Название или ID команды Mattermost для создания каналов',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--force',
|
||||
action='store_true',
|
||||
help='Пересоздать каналы даже если они уже существуют',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
pharmacy_id = options.get('pharmacy_id')
|
||||
team_name = options.get('team')
|
||||
force = options.get('force')
|
||||
|
||||
# Определяем команду для создания каналов
|
||||
parent_team_id = None
|
||||
if team_name:
|
||||
from django.db import models
|
||||
team = MattermostTeam.objects.filter(
|
||||
models.Q(name=team_name) | models.Q(team_id=team_name)
|
||||
).first()
|
||||
|
||||
if not team:
|
||||
self.stdout.write(self.style.ERROR(f'Команда "{team_name}" не найдена'))
|
||||
return
|
||||
|
||||
parent_team_id = team.team_id
|
||||
self.stdout.write(f"Создание каналов в команде: {team.display_name}")
|
||||
else:
|
||||
# Используем команду по умолчанию
|
||||
team = MattermostTeam.objects.filter(sync_settings__is_default=True).first()
|
||||
if team:
|
||||
parent_team_id = team.team_id
|
||||
self.stdout.write(f"Создание каналов в команде по умолчанию: {team.display_name}")
|
||||
else:
|
||||
self.stdout.write(self.style.WARNING("Команда по умолчанию не установлена"))
|
||||
|
||||
# Создание канала для конкретной аптеки
|
||||
if pharmacy_id:
|
||||
try:
|
||||
pharmacy = Pharmacy.objects.get(pk=pharmacy_id)
|
||||
self.stdout.write(f"\nСоздание канала для аптеки #{pharmacy.pharmacy_number}")
|
||||
|
||||
if pharmacy.mattermost_channel and not force:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f" ⚠ Канал уже существует: {pharmacy.mattermost_channel.display_name}")
|
||||
)
|
||||
self.stdout.write(" Используйте --force для пересоздания")
|
||||
return
|
||||
|
||||
if force and pharmacy.mattermost_channel:
|
||||
# Отвязываем старый канал
|
||||
pharmacy.mattermost_channel = None
|
||||
pharmacy.save(update_fields=['mattermost_channel'])
|
||||
|
||||
channel = get_or_create_pharmacy_channel(pharmacy, parent_team_id)
|
||||
|
||||
if channel:
|
||||
self.stdout.write(self.style.SUCCESS(f" ✓ Канал создан: {channel.display_name}"))
|
||||
else:
|
||||
self.stdout.write(self.style.ERROR(f" ✗ Ошибка создания канала"))
|
||||
|
||||
except Pharmacy.DoesNotExist:
|
||||
self.stdout.write(self.style.ERROR(f'Аптека #{pharmacy_id} не найдена'))
|
||||
return
|
||||
|
||||
# Массовое создание каналов для всех аптек
|
||||
pharmacies = Pharmacy.objects.filter(is_active=True)
|
||||
|
||||
if not force:
|
||||
# Фильтруем только аптеки без каналов
|
||||
pharmacies = pharmacies.filter(mattermost_channel__isnull=True)
|
||||
|
||||
total = pharmacies.count()
|
||||
self.stdout.write(f"\nНайдено аптек для обработки: {total}\n")
|
||||
|
||||
if total == 0:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
"\nВсе активные аптеки уже имеют каналы.\n"
|
||||
"Используйте --force для пересоздания каналов."
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
created_count = 0
|
||||
error_count = 0
|
||||
|
||||
for pharmacy in pharmacies:
|
||||
self.stdout.write(f"\nАптека #{pharmacy.pharmacy_number}: {pharmacy.address}")
|
||||
|
||||
if force and pharmacy.mattermost_channel:
|
||||
# Отвязываем старый канал
|
||||
self.stdout.write(f" ↻ Отвязываем существующий канал...")
|
||||
pharmacy.mattermost_channel = None
|
||||
pharmacy.save(update_fields=['mattermost_channel'])
|
||||
|
||||
try:
|
||||
channel = get_or_create_pharmacy_channel(pharmacy, parent_team_id)
|
||||
|
||||
if channel:
|
||||
created_count += 1
|
||||
self.stdout.write(self.style.SUCCESS(f" ✓ Канал создан: {channel.display_name}"))
|
||||
else:
|
||||
error_count += 1
|
||||
self.stdout.write(self.style.ERROR(f" ✗ Ошибка создания канала"))
|
||||
|
||||
except Exception as e:
|
||||
error_count += 1
|
||||
self.stdout.write(self.style.ERROR(f" ✗ Ошибка: {e}"))
|
||||
|
||||
# Итоговая статистика
|
||||
self.stdout.write("\n" + "=" * 60)
|
||||
self.stdout.write(f"Всего аптек обработано: {total}")
|
||||
self.stdout.write(self.style.SUCCESS(f"Успешно создано каналов: {created_count}"))
|
||||
if error_count > 0:
|
||||
self.stdout.write(self.style.ERROR(f"Ошибок: {error_count}"))
|
||||
self.stdout.write("=" * 60)
|
||||
self.stdout.write("\nСотрудники будут автоматически добавлены в каналы при следующей синхронизации.")
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
Management команда для импорта каналов из Mattermost
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import models
|
||||
from apps.mattermost_connector.mattermost_service import MattermostService
|
||||
from apps.mattermost_connector.models import MattermostTeam, MattermostChannel
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Импортировать каналы из Mattermost'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--team',
|
||||
type=str,
|
||||
help='Импортировать каналы только для указанной команды (name или team_id)',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
service = MattermostService()
|
||||
team_filter = options.get('team')
|
||||
|
||||
try:
|
||||
# Получаем список команд
|
||||
if team_filter:
|
||||
teams = MattermostTeam.objects.filter(
|
||||
models.Q(name=team_filter) | models.Q(team_id=team_filter)
|
||||
)
|
||||
if not teams.exists():
|
||||
self.stdout.write(self.style.ERROR(f'Команда "{team_filter}" не найдена'))
|
||||
return
|
||||
else:
|
||||
teams = MattermostTeam.objects.all()
|
||||
|
||||
total_imported = 0
|
||||
total_updated = 0
|
||||
|
||||
for team in teams:
|
||||
self.stdout.write(f"\nКоманда: {team.display_name}")
|
||||
self.stdout.write("=" * 60)
|
||||
|
||||
try:
|
||||
# Получаем каналы команды
|
||||
channels_data = service.get_channels_for_team(team.team_id)
|
||||
|
||||
for channel_data in channels_data:
|
||||
channel_id = channel_data.get('id')
|
||||
name = channel_data.get('name')
|
||||
display_name = channel_data.get('display_name')
|
||||
channel_type = channel_data.get('type')
|
||||
|
||||
# Создаем или обновляем канал
|
||||
channel, created = MattermostChannel.objects.update_or_create(
|
||||
channel_id=channel_id,
|
||||
defaults={
|
||||
'team': team,
|
||||
'name': name,
|
||||
'display_name': display_name,
|
||||
'channel_type': channel_type
|
||||
}
|
||||
)
|
||||
|
||||
if created:
|
||||
total_imported += 1
|
||||
self.stdout.write(f" ✓ Импортирован: {display_name} ({channel_type})")
|
||||
else:
|
||||
total_updated += 1
|
||||
self.stdout.write(f" ↻ Обновлен: {display_name} ({channel_type})")
|
||||
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.ERROR(f" ✗ Ошибка получения каналов: {e}"))
|
||||
|
||||
# Итоговая статистика
|
||||
self.stdout.write("\n" + "=" * 60)
|
||||
self.stdout.write(self.style.SUCCESS(f"\nВсего импортировано: {total_imported}"))
|
||||
if total_updated > 0:
|
||||
self.stdout.write(self.style.WARNING(f"Обновлено: {total_updated}"))
|
||||
|
||||
# Инструкции
|
||||
self.stdout.write("\n" + "=" * 60)
|
||||
self.stdout.write(self.style.SUCCESS("\nСледующие шаги:"))
|
||||
self.stdout.write("\n1. Перейдите в админ-панель: Mattermost → Настройки синхронизации каналов")
|
||||
self.stdout.write("2. Создайте настройки для каналов, в которые нужно автоматически добавлять пользователей")
|
||||
self.stdout.write("3. Настройте фильтрацию по отделам для каждого канала")
|
||||
self.stdout.write("\n4. После настройки пользователи будут автоматически добавляться при синхронизации")
|
||||
self.stdout.write("=" * 60 + "\n")
|
||||
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.ERROR(f"\nОшибка: {e}"))
|
||||
import traceback
|
||||
self.stdout.write(traceback.format_exc())
|
||||
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
Management команда для импорта команд из Mattermost
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import models
|
||||
from apps.mattermost_connector.mattermost_service import MattermostService
|
||||
from apps.mattermost_connector.models import MattermostTeam, TeamSyncSettings
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Импортировать команды из Mattermost и отобразить список для настройки'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--auto-create-settings',
|
||||
action='store_true',
|
||||
help='Автоматически создать настройки для всех импортированных команд',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--set-default',
|
||||
type=str,
|
||||
help='Установить указанную команду (по имени или ID) как команду по умолчанию',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
service = MattermostService()
|
||||
auto_create = options.get('auto_create_settings')
|
||||
set_default = options.get('set_default')
|
||||
|
||||
try:
|
||||
self.stdout.write("Получение списка команд из Mattermost...")
|
||||
teams = service.get_teams()
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"\nНайдено команд: {len(teams)}"))
|
||||
self.stdout.write("=" * 80)
|
||||
|
||||
imported_count = 0
|
||||
updated_count = 0
|
||||
settings_created = 0
|
||||
|
||||
for team_data in teams:
|
||||
team_id = team_data.get('id')
|
||||
name = team_data.get('name')
|
||||
display_name = team_data.get('display_name')
|
||||
description = team_data.get('description', '')
|
||||
|
||||
self.stdout.write(f"\n{self.style.HTTP_INFO('Команда:')} {display_name}")
|
||||
self.stdout.write(f" ID: {team_id}")
|
||||
self.stdout.write(f" Name: {name}")
|
||||
if description:
|
||||
self.stdout.write(f" Описание: {description}")
|
||||
|
||||
# Создаем или обновляем команду
|
||||
team, created = MattermostTeam.objects.update_or_create(
|
||||
team_id=team_id,
|
||||
defaults={
|
||||
'name': name,
|
||||
'display_name': display_name,
|
||||
'description': description
|
||||
}
|
||||
)
|
||||
|
||||
if created:
|
||||
imported_count += 1
|
||||
self.stdout.write(self.style.SUCCESS(f" ✓ Импортирована"))
|
||||
else:
|
||||
updated_count += 1
|
||||
self.stdout.write(self.style.WARNING(f" ↻ Обновлена"))
|
||||
|
||||
# Проверяем наличие настроек синхронизации
|
||||
has_settings = hasattr(team, 'sync_settings')
|
||||
if has_settings:
|
||||
settings = team.sync_settings
|
||||
self.stdout.write(f" Настройки: {'✓ Активны' if settings.is_active else '✗ Неактивны'}")
|
||||
if settings.is_default:
|
||||
self.stdout.write(self.style.SUCCESS(f" ★ Команда по умолчанию"))
|
||||
else:
|
||||
self.stdout.write(self.style.WARNING(f" ⚠ Настройки синхронизации не созданы"))
|
||||
|
||||
# Автоматическое создание настроек
|
||||
if auto_create:
|
||||
TeamSyncSettings.objects.create(
|
||||
team=team,
|
||||
auto_add_users=True,
|
||||
is_active=True,
|
||||
priority=0
|
||||
)
|
||||
settings_created += 1
|
||||
self.stdout.write(self.style.SUCCESS(f" ✓ Настройки созданы автоматически"))
|
||||
|
||||
# Устанавливаем команду по умолчанию
|
||||
if set_default:
|
||||
default_team = MattermostTeam.objects.filter(
|
||||
models.Q(name=set_default) | models.Q(team_id=set_default)
|
||||
).first()
|
||||
|
||||
if default_team:
|
||||
# Снимаем флаг со всех команд
|
||||
TeamSyncSettings.objects.update(is_default=False)
|
||||
|
||||
# Создаем или обновляем настройки для выбранной команды
|
||||
settings, created = TeamSyncSettings.objects.get_or_create(
|
||||
team=default_team,
|
||||
defaults={
|
||||
'auto_add_users': True,
|
||||
'is_active': True,
|
||||
'is_default': True,
|
||||
'priority': 100
|
||||
}
|
||||
)
|
||||
|
||||
if not created:
|
||||
settings.is_default = True
|
||||
settings.save()
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"\n✓ Команда '{default_team.display_name}' установлена как команда по умолчанию")
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f"\n✗ Команда '{set_default}' не найдена")
|
||||
)
|
||||
|
||||
# Итоговая статистика
|
||||
self.stdout.write("\n" + "=" * 80)
|
||||
self.stdout.write(self.style.SUCCESS(f"\nИмпортировано новых команд: {imported_count}"))
|
||||
if updated_count > 0:
|
||||
self.stdout.write(self.style.WARNING(f"Обновлено существующих: {updated_count}"))
|
||||
if settings_created > 0:
|
||||
self.stdout.write(self.style.SUCCESS(f"Создано настроек: {settings_created}"))
|
||||
|
||||
# Инструкции
|
||||
self.stdout.write("\n" + "=" * 80)
|
||||
self.stdout.write(self.style.SUCCESS("\nСледующие шаги:"))
|
||||
self.stdout.write("\n1. Перейдите в админ-панель: Mattermost → Настройки синхронизации команд")
|
||||
self.stdout.write("2. Для каждой команды настройте:")
|
||||
self.stdout.write(" - Активна: включить/выключить синхронизацию")
|
||||
self.stdout.write(" - Автоматически добавлять пользователей: вкл/выкл")
|
||||
self.stdout.write(" - Команда по умолчанию: основная команда для всех пользователей")
|
||||
self.stdout.write(" - Фильтровать по отделам: добавлять только пользователей из определенных отделов")
|
||||
self.stdout.write("\n3. После настройки выполните синхронизацию:")
|
||||
self.stdout.write(" python manage.py sync_users_to_teams")
|
||||
self.stdout.write("=" * 80 + "\n")
|
||||
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.ERROR(f"\nОшибка: {e}"))
|
||||
import traceback
|
||||
self.stdout.write(traceback.format_exc())
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
Команда для синхронизации фото профилей из Mattermost в ACORE
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.core.files.base import ContentFile
|
||||
from django.contrib.auth import get_user_model
|
||||
from apps.mattermost_connector.mattermost_service import MattermostService
|
||||
|
||||
Employee = get_user_model()
|
||||
import logging
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Синхронизация фото профилей из Mattermost в ACORE'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--force',
|
||||
action='store_true',
|
||||
help='Принудительно обновить фото даже если оно уже есть в ACORE',
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--email',
|
||||
type=str,
|
||||
help='Синхронизировать фото только для конкретного пользователя',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
service = MattermostService()
|
||||
|
||||
if options['email']:
|
||||
# Синхронизируем одного пользователя
|
||||
try:
|
||||
employee = Employee.objects.get(email=options['email'])
|
||||
self.sync_photo(service, employee, options['force'])
|
||||
except Employee.DoesNotExist:
|
||||
self.stdout.write(self.style.ERROR(f'Сотрудник с email {options["email"]} не найден'))
|
||||
return
|
||||
|
||||
# Массовая синхронизация
|
||||
employees = Employee.objects.filter(
|
||||
is_active=True,
|
||||
mattermost_sync_enabled=True,
|
||||
mattermost_user_id__isnull=False
|
||||
)
|
||||
|
||||
# Если не force, синхронизируем только тех, у кого нет фото
|
||||
if not options['force']:
|
||||
employees = employees.filter(photo='')
|
||||
|
||||
total = employees.count()
|
||||
self.stdout.write(f"Найдено сотрудников для синхронизации фото: {total}")
|
||||
|
||||
success = 0
|
||||
errors = 0
|
||||
skipped = 0
|
||||
|
||||
for employee in employees:
|
||||
result = self.sync_photo(service, employee, options['force'])
|
||||
if result == 'success':
|
||||
success += 1
|
||||
elif result == 'error':
|
||||
errors += 1
|
||||
else:
|
||||
skipped += 1
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'\nГотово! Успешно: {success}, Пропущено: {skipped}, Ошибок: {errors}'
|
||||
))
|
||||
|
||||
def sync_photo(self, service, employee, force=False):
|
||||
"""
|
||||
Синхронизация фото для одного сотрудника
|
||||
|
||||
Returns:
|
||||
str: 'success', 'skipped' или 'error'
|
||||
"""
|
||||
try:
|
||||
# Проверяем что есть mattermost_user_id
|
||||
if not employee.mattermost_user_id:
|
||||
self.stdout.write(f"Пропущен {employee.email}: нет mattermost_user_id")
|
||||
return 'skipped'
|
||||
|
||||
# Если уже есть фото и не force, пропускаем
|
||||
if employee.photo and not force:
|
||||
self.stdout.write(f"Пропущен {employee.email}: фото уже есть")
|
||||
return 'skipped'
|
||||
|
||||
self.stdout.write(f"Синхронизация фото для {employee.email}...")
|
||||
|
||||
# Получаем фото из Mattermost
|
||||
try:
|
||||
photo_data = service.get_profile_image(employee.mattermost_user_id)
|
||||
except Exception as e:
|
||||
# Возможно у пользователя нет фото в Mattermost
|
||||
if '404' in str(e):
|
||||
self.stdout.write(f"Пропущен {employee.email}: нет фото в Mattermost")
|
||||
return 'skipped'
|
||||
raise
|
||||
|
||||
# Сохраняем фото в ACORE
|
||||
filename = f"{employee.username}.jpg"
|
||||
employee.photo.save(filename, ContentFile(photo_data), save=True)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f'[OK] {employee.email}'))
|
||||
return 'success'
|
||||
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.ERROR(f'[ERROR] {employee.email}: {e}'))
|
||||
logger.error(f"Ошибка синхронизации фото для {employee.email}: {e}")
|
||||
return 'error'
|
||||
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
Management команда для синхронизации членства в командах из Mattermost
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.auth import get_user_model
|
||||
from apps.mattermost_connector.models import MattermostUser, MattermostTeam, MattermostTeamMembership
|
||||
|
||||
Employee = get_user_model()
|
||||
from apps.mattermost_connector.mattermost_service import MattermostService
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Синхронизировать членство пользователей в командах из Mattermost'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--user-id',
|
||||
type=int,
|
||||
help='ID конкретного пользователя для синхронизации',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--all',
|
||||
action='store_true',
|
||||
help='Синхронизировать всех пользователей (включая неактивных)',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
user_id = options.get('user_id')
|
||||
sync_all = options.get('all')
|
||||
|
||||
service = MattermostService()
|
||||
|
||||
# Синхронизация конкретного пользователя
|
||||
if user_id:
|
||||
try:
|
||||
employee = Employee.objects.get(pk=user_id)
|
||||
self.stdout.write(f"Синхронизация членства для: {employee.get_full_name()} ({employee.email})")
|
||||
|
||||
if not employee.mattermost_user_id:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f"Пользователь не синхронизирован с Mattermost.")
|
||||
)
|
||||
return
|
||||
|
||||
success = self.sync_user_teams(service, employee)
|
||||
if success:
|
||||
self.stdout.write(self.style.SUCCESS(f'✓ Членство синхронизировано'))
|
||||
else:
|
||||
self.stdout.write(self.style.ERROR(f'✗ Ошибка синхронизации'))
|
||||
|
||||
except Employee.DoesNotExist:
|
||||
self.stdout.write(self.style.ERROR(f'Пользователь #{user_id} не найден'))
|
||||
return
|
||||
|
||||
# Формируем queryset
|
||||
if sync_all:
|
||||
queryset = Employee.objects.all()
|
||||
self.stdout.write("Синхронизация членства для всех пользователей...")
|
||||
else:
|
||||
queryset = Employee.objects.filter(is_active=True)
|
||||
self.stdout.write("Синхронизация членства для активных пользователей...")
|
||||
|
||||
# Фильтруем только пользователей с mattermost_user_id
|
||||
queryset = queryset.filter(mattermost_user_id__isnull=False)
|
||||
|
||||
total = queryset.count()
|
||||
self.stdout.write(f"Найдено пользователей для синхронизации: {total}")
|
||||
|
||||
if total == 0:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
"\nНет пользователей с mattermost_user_id.\n"
|
||||
"Сначала выполните синхронизацию пользователей с Mattermost."
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
success_count = 0
|
||||
error_count = 0
|
||||
|
||||
for employee in queryset:
|
||||
self.stdout.write(f"\nОбработка: {employee.get_full_name()} ({employee.email})")
|
||||
|
||||
try:
|
||||
if self.sync_user_teams(service, employee):
|
||||
success_count += 1
|
||||
self.stdout.write(self.style.SUCCESS(f" ✓ Синхронизировано"))
|
||||
else:
|
||||
error_count += 1
|
||||
self.stdout.write(self.style.ERROR(f" ✗ Ошибка"))
|
||||
except Exception as e:
|
||||
error_count += 1
|
||||
self.stdout.write(self.style.ERROR(f" ✗ Ошибка: {e}"))
|
||||
|
||||
# Итоговая статистика
|
||||
self.stdout.write("\n" + "=" * 50)
|
||||
self.stdout.write(f"Всего пользователей: {total}")
|
||||
self.stdout.write(self.style.SUCCESS(f"Успешно синхронизировано: {success_count}"))
|
||||
if error_count > 0:
|
||||
self.stdout.write(self.style.ERROR(f"Ошибок: {error_count}"))
|
||||
self.stdout.write("=" * 50)
|
||||
|
||||
def sync_user_teams(self, service: MattermostService, employee: Employee) -> bool:
|
||||
"""Синхронизация команд пользователя из Mattermost"""
|
||||
try:
|
||||
# Получаем MattermostUser
|
||||
mattermost_user = MattermostUser.objects.filter(
|
||||
mattermost_id=employee.mattermost_user_id
|
||||
).first()
|
||||
|
||||
if not mattermost_user:
|
||||
logger.warning(f"MattermostUser не найден для {employee.email}")
|
||||
return False
|
||||
|
||||
# Получаем команды пользователя из Mattermost API
|
||||
teams_data = service.get_user_teams(employee.mattermost_user_id)
|
||||
|
||||
if not teams_data:
|
||||
self.stdout.write(f" Пользователь не состоит в командах")
|
||||
return True
|
||||
|
||||
added_count = 0
|
||||
updated_count = 0
|
||||
|
||||
for team_data in teams_data:
|
||||
team_id = team_data.get('id')
|
||||
|
||||
# Находим команду в БД
|
||||
team = MattermostTeam.objects.filter(team_id=team_id).first()
|
||||
|
||||
if not team:
|
||||
self.stdout.write(f" ⚠ Команда {team_data.get('display_name')} не найдена в БД, пропускаем")
|
||||
continue
|
||||
|
||||
# Получаем информацию о членстве (роль)
|
||||
member_data = service.get_team_member(team_id, employee.mattermost_user_id)
|
||||
role = self.extract_role(member_data)
|
||||
|
||||
# Создаем или обновляем членство
|
||||
membership, created = MattermostTeamMembership.objects.update_or_create(
|
||||
user=mattermost_user,
|
||||
team=team,
|
||||
defaults={'role': role}
|
||||
)
|
||||
|
||||
if created:
|
||||
added_count += 1
|
||||
self.stdout.write(f" + Добавлено: {team.display_name} ({role})")
|
||||
else:
|
||||
updated_count += 1
|
||||
self.stdout.write(f" ↻ Обновлено: {team.display_name} ({role})")
|
||||
|
||||
self.stdout.write(f" Итого: добавлено {added_count}, обновлено {updated_count}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка синхронизации команд для {employee.email}: {e}")
|
||||
return False
|
||||
|
||||
def extract_role(self, member_data: dict) -> str:
|
||||
"""Извлечение роли из данных членства"""
|
||||
roles = member_data.get('roles', '')
|
||||
|
||||
if 'team_admin' in roles:
|
||||
return 'team_admin'
|
||||
elif 'team_user' in roles:
|
||||
return 'team_user'
|
||||
else:
|
||||
return 'team_user' # по умолчанию
|
||||
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
Management команда для синхронизации пользователей в команды Mattermost
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.auth import get_user_model
|
||||
from apps.mattermost_connector.services.sync_service import add_user_to_teams
|
||||
|
||||
Employee = get_user_model()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Синхронизировать пользователей в команды Mattermost согласно настройкам'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--user-id',
|
||||
type=int,
|
||||
help='ID конкретного пользователя для синхронизации',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--all',
|
||||
action='store_true',
|
||||
help='Синхронизировать всех пользователей (включая неактивных)',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
user_id = options.get('user_id')
|
||||
sync_all = options.get('all')
|
||||
|
||||
# Синхронизация конкретного пользователя
|
||||
if user_id:
|
||||
try:
|
||||
employee = Employee.objects.get(pk=user_id)
|
||||
self.stdout.write(f"Синхронизация пользователя: {employee.get_full_name()} ({employee.email})")
|
||||
|
||||
if not employee.mattermost_user_id:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f"Пользователь не синхронизирован с Mattermost. Сначала выполните синхронизацию пользователя.")
|
||||
)
|
||||
return
|
||||
|
||||
if add_user_to_teams(employee):
|
||||
self.stdout.write(self.style.SUCCESS(f'✓ Пользователь успешно синхронизирован'))
|
||||
else:
|
||||
self.stdout.write(self.style.ERROR(f'✗ Ошибка синхронизации пользователя'))
|
||||
|
||||
except Employee.DoesNotExist:
|
||||
self.stdout.write(self.style.ERROR(f'Пользователь #{user_id} не найден'))
|
||||
return
|
||||
|
||||
# Формируем queryset
|
||||
if sync_all:
|
||||
queryset = Employee.objects.all()
|
||||
self.stdout.write("Синхронизация всех пользователей...")
|
||||
else:
|
||||
queryset = Employee.objects.filter(is_active=True)
|
||||
self.stdout.write("Синхронизация активных пользователей...")
|
||||
|
||||
# Фильтруем только пользователей с mattermost_user_id
|
||||
queryset = queryset.filter(mattermost_user_id__isnull=False)
|
||||
|
||||
total = queryset.count()
|
||||
self.stdout.write(f"Найдено пользователей для синхронизации: {total}")
|
||||
|
||||
if total == 0:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
"\nНет пользователей с mattermost_user_id.\n"
|
||||
"Сначала выполните синхронизацию пользователей с Mattermost."
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
success_count = 0
|
||||
error_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for employee in queryset:
|
||||
self.stdout.write(f"\nОбработка: {employee.get_full_name()} ({employee.email})")
|
||||
|
||||
try:
|
||||
if add_user_to_teams(employee):
|
||||
success_count += 1
|
||||
self.stdout.write(self.style.SUCCESS(f" ✓ Синхронизирован"))
|
||||
else:
|
||||
skipped_count += 1
|
||||
self.stdout.write(self.style.WARNING(f" - Пропущен"))
|
||||
except Exception as e:
|
||||
error_count += 1
|
||||
self.stdout.write(self.style.ERROR(f" ✗ Ошибка: {e}"))
|
||||
|
||||
# Итоговая статистика
|
||||
self.stdout.write("\n" + "=" * 50)
|
||||
self.stdout.write(f"Всего пользователей: {total}")
|
||||
self.stdout.write(self.style.SUCCESS(f"Успешно синхронизировано: {success_count}"))
|
||||
if skipped_count > 0:
|
||||
self.stdout.write(self.style.WARNING(f"Пропущено: {skipped_count}"))
|
||||
if error_count > 0:
|
||||
self.stdout.write(self.style.ERROR(f"Ошибок: {error_count}"))
|
||||
self.stdout.write("=" * 50)
|
||||
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
Management команда для обновления часового пояса пользователей в Mattermost
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.auth import get_user_model
|
||||
from apps.mattermost_connector.mattermost_service import MattermostService
|
||||
|
||||
Employee = get_user_model()
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Обновить часовой пояс для всех пользователей Mattermost на Europe/Kiev'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--user-id',
|
||||
type=int,
|
||||
help='ID конкретного пользователя для обновления',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--all',
|
||||
action='store_true',
|
||||
help='Обновить всех пользователей (включая неактивных)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--timezone',
|
||||
type=str,
|
||||
default='Europe/Kiev',
|
||||
help='Часовой пояс для установки (по умолчанию: Europe/Kiev)',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
user_id = options.get('user_id')
|
||||
update_all = options.get('all')
|
||||
timezone_name = options.get('timezone')
|
||||
|
||||
service = MattermostService()
|
||||
|
||||
# Обновление конкретного пользователя
|
||||
if user_id:
|
||||
try:
|
||||
employee = Employee.objects.get(pk=user_id)
|
||||
self.stdout.write(f"Обновление часового пояса для: {employee.get_full_name()} ({employee.email})")
|
||||
|
||||
if not employee.mattermost_user_id:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f"Пользователь не синхронизирован с Mattermost.")
|
||||
)
|
||||
return
|
||||
|
||||
timezone_data = {
|
||||
'useAutomaticTimezone': 'false',
|
||||
'manualTimezone': timezone_name,
|
||||
'automaticTimezone': ''
|
||||
}
|
||||
|
||||
try:
|
||||
service.update_user_preferences(employee.mattermost_user_id, timezone_data)
|
||||
self.stdout.write(self.style.SUCCESS(f'✓ Часовой пояс обновлен на {timezone_name}'))
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.ERROR(f'✗ Ошибка обновления: {e}'))
|
||||
|
||||
except Employee.DoesNotExist:
|
||||
self.stdout.write(self.style.ERROR(f'Пользователь #{user_id} не найден'))
|
||||
return
|
||||
|
||||
# Формируем queryset
|
||||
if update_all:
|
||||
queryset = Employee.objects.all()
|
||||
self.stdout.write(f"Обновление часового пояса для всех пользователей на {timezone_name}...")
|
||||
else:
|
||||
queryset = Employee.objects.filter(is_active=True)
|
||||
self.stdout.write(f"Обновление часового пояса для активных пользователей на {timezone_name}...")
|
||||
|
||||
# Фильтруем только пользователей с mattermost_user_id
|
||||
queryset = queryset.filter(mattermost_user_id__isnull=False)
|
||||
|
||||
total = queryset.count()
|
||||
self.stdout.write(f"Найдено пользователей для обновления: {total}")
|
||||
|
||||
if total == 0:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
"\nНет пользователей с mattermost_user_id.\n"
|
||||
"Сначала выполните синхронизацию пользователей с Mattermost."
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
success_count = 0
|
||||
error_count = 0
|
||||
|
||||
timezone_data = {
|
||||
'useAutomaticTimezone': 'false',
|
||||
'manualTimezone': timezone_name,
|
||||
'automaticTimezone': ''
|
||||
}
|
||||
|
||||
for employee in queryset:
|
||||
self.stdout.write(f"\nОбработка: {employee.get_full_name()} ({employee.email})")
|
||||
|
||||
try:
|
||||
service.update_user_preferences(employee.mattermost_user_id, timezone_data)
|
||||
success_count += 1
|
||||
self.stdout.write(self.style.SUCCESS(f" ✓ Обновлен"))
|
||||
except Exception as e:
|
||||
error_count += 1
|
||||
self.stdout.write(self.style.ERROR(f" ✗ Ошибка: {e}"))
|
||||
|
||||
# Итоговая статистика
|
||||
self.stdout.write("\n" + "=" * 50)
|
||||
self.stdout.write(f"Всего пользователей: {total}")
|
||||
self.stdout.write(self.style.SUCCESS(f"Успешно обновлено: {success_count}"))
|
||||
if error_count > 0:
|
||||
self.stdout.write(self.style.ERROR(f"Ошибок: {error_count}"))
|
||||
self.stdout.write("=" * 50)
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
# services/mattermost_service.py
|
||||
import requests
|
||||
from typing import Dict, Optional, List
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MattermostConfigError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class MattermostService:
|
||||
def __init__(self):
|
||||
from core.models import ConnectorConfig
|
||||
cfg = ConnectorConfig.objects.filter(connector_type='mattermost').first()
|
||||
|
||||
if not cfg:
|
||||
raise MattermostConfigError(
|
||||
'Настройки коннектора Mattermost не найдены. '
|
||||
'Добавьте запись в Core → Настройки коннекторов.'
|
||||
)
|
||||
if not cfg.is_enabled:
|
||||
raise MattermostConfigError(
|
||||
'Коннектор Mattermost отключён. '
|
||||
'Включите его в Core → Настройки коннекторов.'
|
||||
)
|
||||
|
||||
scheme = 'https' if cfg.use_ssl else 'http'
|
||||
port = f':{cfg.port}' if cfg.port else ''
|
||||
self.base_url = f'{scheme}://{cfg.host}{port}'
|
||||
self.token = cfg.token
|
||||
self.headers = {
|
||||
'Authorization': f'Bearer {self.token}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
def _make_request(self, method: str, endpoint: str, data: Dict = None) -> Dict:
|
||||
"""Базовый метод для выполнения HTTP запросов"""
|
||||
url = f"{self.base_url}/api/v4/{endpoint}"
|
||||
|
||||
try:
|
||||
logger.info(f"Mattermost API request: {method} {url}")
|
||||
if data:
|
||||
logger.info(f"Request data: {data}")
|
||||
|
||||
response = requests.request(
|
||||
method=method,
|
||||
url=url,
|
||||
headers=self.headers,
|
||||
json=data,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
# Логируем ответ при ошибке
|
||||
if not response.ok:
|
||||
logger.error(f"Mattermost API error response: {response.status_code}")
|
||||
logger.error(f"Response body: {response.text}")
|
||||
|
||||
response.raise_for_status()
|
||||
return response.json() if response.content else {}
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Mattermost API error: {e}")
|
||||
if hasattr(e, 'response') and e.response is not None:
|
||||
logger.error(f"Error response text: {e.response.text}")
|
||||
raise
|
||||
|
||||
def create_user(self, user_data: Dict) -> Dict:
|
||||
"""Создание нового пользователя"""
|
||||
endpoint = "users"
|
||||
return self._make_request('POST', endpoint, user_data)
|
||||
|
||||
def get_user(self, user_id: str) -> Dict:
|
||||
"""Получение информации о пользователе"""
|
||||
endpoint = f"users/{user_id}"
|
||||
return self._make_request('GET', endpoint)
|
||||
|
||||
def get_user_by_email(self, email: str) -> Dict:
|
||||
"""Получение пользователя по email"""
|
||||
endpoint = f"users/email/{email}"
|
||||
return self._make_request('GET', endpoint)
|
||||
|
||||
def get_user_by_username(self, username: str) -> Dict:
|
||||
"""Получение пользователя по username"""
|
||||
endpoint = f"users/username/{username}"
|
||||
return self._make_request('GET', endpoint)
|
||||
|
||||
def update_user(self, user_id: str, user_data: Dict) -> Dict:
|
||||
"""Обновление данных пользователя через PATCH"""
|
||||
endpoint = f"users/{user_id}/patch"
|
||||
return self._make_request('PATCH', endpoint, user_data)
|
||||
|
||||
def deactivate_user(self, user_id: str) -> Dict:
|
||||
"""Деактивация пользователя"""
|
||||
endpoint = f"users/{user_id}"
|
||||
return self._make_request('DELETE', endpoint)
|
||||
|
||||
def activate_user(self, user_id: str) -> Dict:
|
||||
"""Активация пользователя"""
|
||||
endpoint = f"users/{user_id}/active"
|
||||
return self._make_request('PUT', endpoint, {"active": True})
|
||||
|
||||
def add_user_to_team(self, team_id: str, user_id: str) -> Dict:
|
||||
"""Добавление пользователя в команду"""
|
||||
endpoint = f"teams/{team_id}/members"
|
||||
data = {"team_id": team_id, "user_id": user_id}
|
||||
return self._make_request('POST', endpoint, data)
|
||||
|
||||
def remove_user_from_team(self, team_id: str, user_id: str) -> Dict:
|
||||
"""Удаление пользователя из команды"""
|
||||
endpoint = f"teams/{team_id}/members/{user_id}"
|
||||
return self._make_request('DELETE', endpoint)
|
||||
|
||||
def add_user_to_channel(self, channel_id: str, user_id: str) -> Dict:
|
||||
"""Добавление пользователя в канал"""
|
||||
endpoint = f"channels/{channel_id}/members"
|
||||
data = {"user_id": user_id}
|
||||
return self._make_request('POST', endpoint, data)
|
||||
|
||||
def get_teams(self) -> List[Dict]:
|
||||
"""Получение списка команд"""
|
||||
endpoint = "teams"
|
||||
return self._make_request('GET', endpoint)
|
||||
|
||||
def get_channels_for_team(self, team_id: str) -> List[Dict]:
|
||||
"""Получение каналов команды"""
|
||||
endpoint = f"teams/{team_id}/channels"
|
||||
return self._make_request('GET', endpoint)
|
||||
|
||||
def reset_password(self, user_id: str, new_password: str) -> Dict:
|
||||
"""Сброс пароля пользователя (от имени администратора)"""
|
||||
endpoint = f"users/{user_id}/password"
|
||||
data = {"new_password": new_password}
|
||||
return self._make_request('PUT', endpoint, data)
|
||||
|
||||
def update_user_preferences(self, user_id: str, timezone_data: Dict) -> Dict:
|
||||
"""Обновление настроек пользователя (например, часовой пояс)"""
|
||||
endpoint = f"users/{user_id}/preferences"
|
||||
preferences = [
|
||||
{
|
||||
"user_id": user_id,
|
||||
"category": "timezone",
|
||||
"name": "useAutomaticTimezone",
|
||||
"value": timezone_data.get('useAutomaticTimezone', 'false')
|
||||
},
|
||||
{
|
||||
"user_id": user_id,
|
||||
"category": "timezone",
|
||||
"name": "manualTimezone",
|
||||
"value": timezone_data.get('manualTimezone', 'Europe/Kiev')
|
||||
},
|
||||
{
|
||||
"user_id": user_id,
|
||||
"category": "timezone",
|
||||
"name": "automaticTimezone",
|
||||
"value": timezone_data.get('automaticTimezone', '')
|
||||
}
|
||||
]
|
||||
return self._make_request('PUT', endpoint, preferences)
|
||||
|
||||
def get_user_teams(self, user_id: str) -> List[Dict]:
|
||||
"""Получение списка команд пользователя"""
|
||||
endpoint = f"users/{user_id}/teams"
|
||||
return self._make_request('GET', endpoint)
|
||||
|
||||
def get_team_member(self, team_id: str, user_id: str) -> Dict:
|
||||
"""Получение информации о членстве пользователя в команде"""
|
||||
endpoint = f"teams/{team_id}/members/{user_id}"
|
||||
return self._make_request('GET', endpoint)
|
||||
|
||||
def create_channel(self, channel_data: Dict) -> Dict:
|
||||
"""Создание нового канала"""
|
||||
endpoint = "channels"
|
||||
return self._make_request('POST', endpoint, channel_data)
|
||||
|
||||
def set_profile_image(self, user_id: str, image_file) -> Dict:
|
||||
"""
|
||||
Установка фото профиля пользователя
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя в Mattermost
|
||||
image_file: Файл изображения (file object или path)
|
||||
"""
|
||||
url = f"{self.base_url}/api/v4/users/{user_id}/image"
|
||||
|
||||
try:
|
||||
logger.info(f"Установка фото профиля для пользователя {user_id}")
|
||||
|
||||
# Если передан путь к файлу, открываем его
|
||||
if isinstance(image_file, str):
|
||||
with open(image_file, 'rb') as f:
|
||||
files = {'image': f}
|
||||
response = requests.post(
|
||||
url,
|
||||
headers={'Authorization': f'Bearer {self.token}'},
|
||||
files=files,
|
||||
timeout=30
|
||||
)
|
||||
else:
|
||||
# Если передан file object
|
||||
files = {'image': image_file}
|
||||
response = requests.post(
|
||||
url,
|
||||
headers={'Authorization': f'Bearer {self.token}'},
|
||||
files=files,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
logger.error(f"Mattermost API error response: {response.status_code}")
|
||||
logger.error(f"Response body: {response.text}")
|
||||
|
||||
response.raise_for_status()
|
||||
logger.info(f"Фото профиля успешно установлено для {user_id}")
|
||||
return response.json() if response.content else {}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка установки фото профиля: {e}")
|
||||
raise
|
||||
|
||||
def get_profile_image(self, user_id: str) -> bytes:
|
||||
"""
|
||||
Получение фото профиля пользователя из Mattermost
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя в Mattermost
|
||||
|
||||
Returns:
|
||||
bytes: Содержимое изображения
|
||||
"""
|
||||
url = f"{self.base_url}/api/v4/users/{user_id}/image"
|
||||
|
||||
try:
|
||||
logger.info(f"Получение фото профиля для пользователя {user_id}")
|
||||
response = requests.get(
|
||||
url,
|
||||
headers={'Authorization': f'Bearer {self.token}'},
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
logger.error(f"Mattermost API error response: {response.status_code}")
|
||||
logger.error(f"Response body: {response.text}")
|
||||
|
||||
response.raise_for_status()
|
||||
logger.info(f"Фото профиля успешно получено для {user_id}")
|
||||
return response.content
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения фото профиля: {e}")
|
||||
raise
|
||||
@@ -0,0 +1,141 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-06 10:32
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('core', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='MattermostChannel',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('channel_id', models.CharField(max_length=100, unique=True)),
|
||||
('name', models.CharField(max_length=150)),
|
||||
('display_name', models.CharField(max_length=150)),
|
||||
('channel_type', models.CharField(choices=[('O', 'Open'), ('P', 'Private'), ('D', 'Direct'), ('G', 'Group')], max_length=1)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'mattermost_channels',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MattermostTeam',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('team_id', models.CharField(max_length=100, unique=True)),
|
||||
('name', models.CharField(max_length=150)),
|
||||
('display_name', models.CharField(max_length=150)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'mattermost_teams',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ChannelSyncSettings',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('auto_add_users', models.BooleanField(default=True, help_text='Автоматически добавлять новых пользователей в этот канал при синхронизации', verbose_name='Автоматически добавлять пользователей')),
|
||||
('priority', models.IntegerField(default=0, help_text='Порядок добавления пользователей (выше = раньше)', verbose_name='Приоритет')),
|
||||
('filter_by_department', models.BooleanField(default=False, help_text='Добавлять только пользователей из указанных отделов', verbose_name='Фильтровать по отделам')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Включить/выключить синхронизацию для этого канала', verbose_name='Активна')),
|
||||
('notes', models.TextField(blank=True, help_text='Дополнительная информация о канале', verbose_name='Примечания')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('departments', models.ManyToManyField(blank=True, help_text='Отделы, пользователи которых будут добавлены в канал', limit_choices_to={'directory_type__code': 'department', 'is_active': True}, to='core.directory', verbose_name='Отделы')),
|
||||
('channel', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='sync_settings', to='mattermost_connector.mattermostchannel', verbose_name='Канал Mattermost')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Настройки синхронизации канала',
|
||||
'verbose_name_plural': 'Настройки синхронизации каналов',
|
||||
'db_table': 'mattermost_channel_sync_settings',
|
||||
'ordering': ['-priority', 'channel__display_name'],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='mattermostchannel',
|
||||
name='team',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='mattermost_connector.mattermostteam'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MattermostUser',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('mattermost_id', models.CharField(max_length=100, unique=True)),
|
||||
('username', models.CharField(max_length=150)),
|
||||
('email', models.EmailField(max_length=254)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('last_sync', models.DateTimeField(blank=True, null=True)),
|
||||
('django_user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='mattermost_profile', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Mattermost User',
|
||||
'verbose_name_plural': 'Mattermost Users',
|
||||
'db_table': 'mattermost_users',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TeamSyncSettings',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('auto_add_users', models.BooleanField(default=True, help_text='Автоматически добавлять новых пользователей в эту команду при синхронизации', verbose_name='Автоматически добавлять пользователей')),
|
||||
('is_default', models.BooleanField(default=False, help_text='Использовать эту команду как основную для синхронизации', verbose_name='Команда по умолчанию')),
|
||||
('priority', models.IntegerField(default=0, help_text='Порядок добавления пользователей (выше = раньше)', verbose_name='Приоритет')),
|
||||
('filter_by_department', models.BooleanField(default=False, help_text='Добавлять только пользователей из указанных отделов', verbose_name='Фильтровать по отделам')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Включить/выключить синхронизацию для этой команды', verbose_name='Активна')),
|
||||
('notes', models.TextField(blank=True, help_text='Дополнительная информация о команде', verbose_name='Примечания')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('departments', models.ManyToManyField(blank=True, help_text='Отделы, пользователи которых будут добавлены в команду', limit_choices_to={'directory_type__code': 'department', 'is_active': True}, to='core.directory', verbose_name='Отделы')),
|
||||
('team', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='sync_settings', to='mattermost_connector.mattermostteam', verbose_name='Команда Mattermost')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Настройки синхронизации команды',
|
||||
'verbose_name_plural': 'Настройки синхронизации команд',
|
||||
'db_table': 'mattermost_team_sync_settings',
|
||||
'ordering': ['-priority', 'team__display_name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MattermostTeamMembership',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('role', models.CharField(default='team_user', max_length=50)),
|
||||
('joined_at', models.DateTimeField(auto_now_add=True)),
|
||||
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='mattermost_connector.mattermostteam')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='mattermost_connector.mattermostuser')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'mattermost_team_memberships',
|
||||
'unique_together': {('user', 'team')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MattermostChannelMembership',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('role', models.CharField(default='channel_user', max_length=50)),
|
||||
('joined_at', models.DateTimeField(auto_now_add=True)),
|
||||
('channel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='mattermost_connector.mattermostchannel')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='mattermost_connector.mattermostuser')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'mattermost_channel_memberships',
|
||||
'unique_together': {('user', 'channel')},
|
||||
},
|
||||
),
|
||||
]
|
||||
Executable
+222
@@ -0,0 +1,222 @@
|
||||
# models.py
|
||||
from django.db import models
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
class MattermostUser(models.Model):
|
||||
"""Связь между Django пользователем и Mattermost пользователем"""
|
||||
django_user = models.OneToOneField(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='mattermost_profile'
|
||||
)
|
||||
mattermost_id = models.CharField(max_length=100, unique=True)
|
||||
username = models.CharField(max_length=150)
|
||||
email = models.EmailField()
|
||||
is_active = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
last_sync = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'mattermost_users'
|
||||
verbose_name = 'Mattermost User'
|
||||
verbose_name_plural = 'Mattermost Users'
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.django_user.username} -> {self.username}"
|
||||
|
||||
class MattermostTeam(models.Model):
|
||||
"""Команды Mattermost"""
|
||||
team_id = models.CharField(max_length=100, unique=True)
|
||||
name = models.CharField(max_length=150)
|
||||
display_name = models.CharField(max_length=150)
|
||||
description = models.TextField(blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'mattermost_teams'
|
||||
|
||||
def __str__(self):
|
||||
return self.display_name
|
||||
|
||||
class MattermostChannel(models.Model):
|
||||
"""Каналы Mattermost"""
|
||||
CHANNEL_TYPES = [
|
||||
('O', 'Open'),
|
||||
('P', 'Private'),
|
||||
('D', 'Direct'),
|
||||
('G', 'Group'),
|
||||
]
|
||||
|
||||
channel_id = models.CharField(max_length=100, unique=True)
|
||||
team = models.ForeignKey(MattermostTeam, on_delete=models.CASCADE)
|
||||
name = models.CharField(max_length=150)
|
||||
display_name = models.CharField(max_length=150)
|
||||
channel_type = models.CharField(max_length=1, choices=CHANNEL_TYPES)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'mattermost_channels'
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.team.name}:{self.name}"
|
||||
|
||||
class MattermostTeamMembership(models.Model):
|
||||
"""Членство пользователей в командах"""
|
||||
user = models.ForeignKey(MattermostUser, on_delete=models.CASCADE)
|
||||
team = models.ForeignKey(MattermostTeam, on_delete=models.CASCADE)
|
||||
role = models.CharField(max_length=50, default='team_user')
|
||||
joined_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'mattermost_team_memberships'
|
||||
unique_together = ['user', 'team']
|
||||
|
||||
class MattermostChannelMembership(models.Model):
|
||||
"""Членство пользователей в каналах"""
|
||||
user = models.ForeignKey(MattermostUser, on_delete=models.CASCADE)
|
||||
channel = models.ForeignKey(MattermostChannel, on_delete=models.CASCADE)
|
||||
role = models.CharField(max_length=50, default='channel_user')
|
||||
joined_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'mattermost_channel_memberships'
|
||||
unique_together = ['user', 'channel']
|
||||
|
||||
|
||||
class TeamSyncSettings(models.Model):
|
||||
"""Настройки синхронизации команд Mattermost"""
|
||||
team = models.OneToOneField(
|
||||
MattermostTeam,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='sync_settings',
|
||||
verbose_name='Команда Mattermost'
|
||||
)
|
||||
|
||||
auto_add_users = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name='Автоматически добавлять пользователей',
|
||||
help_text='Автоматически добавлять новых пользователей в эту команду при синхронизации'
|
||||
)
|
||||
|
||||
is_default = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name='Команда по умолчанию',
|
||||
help_text='Использовать эту команду как основную для синхронизации'
|
||||
)
|
||||
|
||||
priority = models.IntegerField(
|
||||
default=0,
|
||||
verbose_name='Приоритет',
|
||||
help_text='Порядок добавления пользователей (выше = раньше)'
|
||||
)
|
||||
|
||||
filter_by_department = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name='Фильтровать по отделам',
|
||||
help_text='Добавлять только пользователей из указанных отделов'
|
||||
)
|
||||
|
||||
departments = models.ManyToManyField(
|
||||
'core.Directory',
|
||||
blank=True,
|
||||
limit_choices_to={'directory_type__code': 'department', 'is_active': True},
|
||||
verbose_name='Отделы',
|
||||
help_text='Отделы, пользователи которых будут добавлены в команду'
|
||||
)
|
||||
|
||||
is_active = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name='Активна',
|
||||
help_text='Включить/выключить синхронизацию для этой команды'
|
||||
)
|
||||
|
||||
notes = models.TextField(
|
||||
blank=True,
|
||||
verbose_name='Примечания',
|
||||
help_text='Дополнительная информация о команде'
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'mattermost_team_sync_settings'
|
||||
verbose_name = 'Настройки синхронизации команды'
|
||||
verbose_name_plural = 'Настройки синхронизации команд'
|
||||
ordering = ['-priority', 'team__display_name']
|
||||
|
||||
def __str__(self):
|
||||
status = "✓" if self.is_active else "✗"
|
||||
default = " (по умолчанию)" if self.is_default else ""
|
||||
return f"{status} {self.team.display_name}{default}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Если эта команда устанавливается как default, снимаем флаг с других
|
||||
if self.is_default:
|
||||
TeamSyncSettings.objects.filter(is_default=True).exclude(pk=self.pk).update(is_default=False)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class ChannelSyncSettings(models.Model):
|
||||
"""Настройки синхронизации каналов Mattermost"""
|
||||
channel = models.OneToOneField(
|
||||
MattermostChannel,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='sync_settings',
|
||||
verbose_name='Канал Mattermost'
|
||||
)
|
||||
|
||||
auto_add_users = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name='Автоматически добавлять пользователей',
|
||||
help_text='Автоматически добавлять новых пользователей в этот канал при синхронизации'
|
||||
)
|
||||
|
||||
priority = models.IntegerField(
|
||||
default=0,
|
||||
verbose_name='Приоритет',
|
||||
help_text='Порядок добавления пользователей (выше = раньше)'
|
||||
)
|
||||
|
||||
filter_by_department = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name='Фильтровать по отделам',
|
||||
help_text='Добавлять только пользователей из указанных отделов'
|
||||
)
|
||||
|
||||
departments = models.ManyToManyField(
|
||||
'core.Directory',
|
||||
blank=True,
|
||||
limit_choices_to={'directory_type__code': 'department', 'is_active': True},
|
||||
verbose_name='Отделы',
|
||||
help_text='Отделы, пользователи которых будут добавлены в канал'
|
||||
)
|
||||
|
||||
is_active = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name='Активна',
|
||||
help_text='Включить/выключить синхронизацию для этого канала'
|
||||
)
|
||||
|
||||
notes = models.TextField(
|
||||
blank=True,
|
||||
verbose_name='Примечания',
|
||||
help_text='Дополнительная информация о канале'
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'mattermost_channel_sync_settings'
|
||||
verbose_name = 'Настройки синхронизации канала'
|
||||
verbose_name_plural = 'Настройки синхронизации каналов'
|
||||
ordering = ['-priority', 'channel__display_name']
|
||||
|
||||
def __str__(self):
|
||||
status = "✓" if self.is_active else "✗"
|
||||
return f"{status} {self.channel.display_name} ({self.channel.team.display_name})"
|
||||
@@ -0,0 +1,271 @@
|
||||
"""
|
||||
Сервис для управления каналами аптек в Mattermost
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
from django.conf import settings
|
||||
from apps.mattermost_connector.mattermost_service import MattermostService
|
||||
from django.contrib.auth import get_user_model
|
||||
from apps.mattermost_connector.models import MattermostChannel, MattermostTeam, MattermostChannelMembership, MattermostUser
|
||||
|
||||
Employee = get_user_model()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _transliterate_org_name(name):
|
||||
"""
|
||||
Транслитерация названия организации для использования в имени канала
|
||||
|
||||
Args:
|
||||
name: Название организации
|
||||
|
||||
Returns:
|
||||
str: Транслитерированное название (только буквы латиницы, цифры и дефисы)
|
||||
"""
|
||||
# Словарь для транслитерации кириллицы
|
||||
translit_map = {
|
||||
'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'e',
|
||||
'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'y', 'к': 'k', 'л': 'l', 'м': 'm',
|
||||
'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u',
|
||||
'ф': 'f', 'х': 'h', 'ц': 'ts', 'ч': 'ch', 'ш': 'sh', 'щ': 'sch',
|
||||
'ъ': '', 'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya',
|
||||
'А': 'A', 'Б': 'B', 'В': 'V', 'Г': 'G', 'Д': 'D', 'Е': 'E', 'Ё': 'E',
|
||||
'Ж': 'Zh', 'З': 'Z', 'И': 'I', 'Й': 'Y', 'К': 'K', 'Л': 'L', 'М': 'M',
|
||||
'Н': 'N', 'О': 'O', 'П': 'P', 'Р': 'R', 'С': 'S', 'Т': 'T', 'У': 'U',
|
||||
'Ф': 'F', 'Х': 'H', 'Ц': 'Ts', 'Ч': 'Ch', 'Ш': 'Sh', 'Щ': 'Sch',
|
||||
'Ъ': '', 'Ы': 'Y', 'Ь': '', 'Э': 'E', 'Ю': 'Yu', 'Я': 'Ya',
|
||||
}
|
||||
|
||||
# Транслитерируем
|
||||
result = ''
|
||||
for char in name:
|
||||
if char in translit_map:
|
||||
result += translit_map[char]
|
||||
elif char.isalnum() or char == '-':
|
||||
result += char
|
||||
elif char == ' ':
|
||||
result += '-'
|
||||
|
||||
# Убираем лишние дефисы и приводим к нижнему регистру
|
||||
result = re.sub(r'-+', '-', result).strip('-').lower()
|
||||
|
||||
# Ограничиваем длину (Mattermost имеет ограничения на длину имени канала)
|
||||
if len(result) > 20:
|
||||
result = result[:20].rstrip('-')
|
||||
|
||||
return result if result else 'org'
|
||||
|
||||
|
||||
def get_or_create_pharmacy_channel(pharmacy, parent_team_id=None):
|
||||
"""
|
||||
Создание или получение канала для аптеки
|
||||
|
||||
Args:
|
||||
pharmacy: Объект аптеки
|
||||
parent_team_id: ID команды Mattermost, в которой создать канал
|
||||
|
||||
Returns:
|
||||
MattermostChannel или None
|
||||
"""
|
||||
# Если канал уже существует, возвращаем его
|
||||
if pharmacy.mattermost_channel:
|
||||
return pharmacy.mattermost_channel
|
||||
|
||||
# Если автосоздание отключено, не создаем
|
||||
if not pharmacy.mattermost_auto_create_channel:
|
||||
return None
|
||||
|
||||
service = MattermostService()
|
||||
|
||||
try:
|
||||
# Определяем команду для создания канала
|
||||
if parent_team_id:
|
||||
team = MattermostTeam.objects.filter(team_id=parent_team_id).first()
|
||||
else:
|
||||
# Используем команду по умолчанию или первую доступную
|
||||
team = MattermostTeam.objects.filter(sync_settings__is_default=True).first()
|
||||
if not team:
|
||||
team = MattermostTeam.objects.first()
|
||||
|
||||
if not team:
|
||||
logger.error("Не найдена команда Mattermost для создания канала аптеки")
|
||||
return None
|
||||
|
||||
# Получаем код организации (если есть) или создаем из названия
|
||||
org_code = pharmacy.organization.code if pharmacy.organization.code else _transliterate_org_name(pharmacy.organization.name)
|
||||
|
||||
# Формируем имя канала (без спецсимволов, только строчные буквы, цифры и дефисы)
|
||||
channel_name = f"apt-{org_code}-{pharmacy.pharmacy_number}".lower()
|
||||
channel_display_name = f"{org_code.upper()}-{pharmacy.pharmacy_number}"
|
||||
|
||||
# Создаем приватный канал
|
||||
channel_data = {
|
||||
'team_id': team.team_id,
|
||||
'name': channel_name,
|
||||
'display_name': channel_display_name,
|
||||
'type': 'P', # P = private, O = public
|
||||
'purpose': f'Чат для сотрудников аптеки {org_code.upper()}-{pharmacy.pharmacy_number}',
|
||||
'header': f'{pharmacy.organization.name} - Аптека №{pharmacy.pharmacy_number}'
|
||||
}
|
||||
|
||||
# Создаем канал в Mattermost
|
||||
channel_response = service.create_channel(channel_data)
|
||||
|
||||
# Сохраняем в базу
|
||||
channel = MattermostChannel.objects.create(
|
||||
team=team,
|
||||
channel_id=channel_response['id'],
|
||||
name=channel_response['name'],
|
||||
display_name=channel_response['display_name'],
|
||||
channel_type=channel_response['type']
|
||||
)
|
||||
|
||||
# Привязываем к аптеке
|
||||
pharmacy.mattermost_channel = channel
|
||||
pharmacy.save(update_fields=['mattermost_channel'])
|
||||
|
||||
logger.info(f"Создан канал '{channel_display_name}' для аптеки #{pharmacy.pharmacy_number}")
|
||||
|
||||
# Добавляем всех сотрудников аптеки в канал
|
||||
add_pharmacy_employees_to_channel(pharmacy)
|
||||
|
||||
return channel
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания канала для аптеки #{pharmacy.pharmacy_number}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def add_pharmacy_employees_to_channel(pharmacy):
|
||||
"""
|
||||
Добавление всех сотрудников аптеки в её канал
|
||||
|
||||
Args:
|
||||
pharmacy: Объект аптеки
|
||||
"""
|
||||
if not pharmacy.mattermost_channel:
|
||||
logger.warning(f"У аптеки #{pharmacy.pharmacy_number} нет канала Mattermost")
|
||||
return False
|
||||
|
||||
service = MattermostService()
|
||||
added_count = 0
|
||||
|
||||
try:
|
||||
# Получаем всех сотрудников этой аптеки
|
||||
employees = Employee.objects.filter(
|
||||
user_pharmacy=pharmacy,
|
||||
is_active=True,
|
||||
mattermost_user_id__isnull=False
|
||||
)
|
||||
|
||||
for employee in employees:
|
||||
try:
|
||||
# Получаем MattermostUser
|
||||
mattermost_user = MattermostUser.objects.filter(
|
||||
mattermost_id=employee.mattermost_user_id
|
||||
).first()
|
||||
|
||||
if not mattermost_user:
|
||||
continue
|
||||
|
||||
# Проверяем, не добавлен ли уже
|
||||
membership_exists = MattermostChannelMembership.objects.filter(
|
||||
user=mattermost_user,
|
||||
channel=pharmacy.mattermost_channel
|
||||
).exists()
|
||||
|
||||
if not membership_exists:
|
||||
# Добавляем в канал
|
||||
service.add_user_to_channel(
|
||||
pharmacy.mattermost_channel.channel_id,
|
||||
employee.mattermost_user_id
|
||||
)
|
||||
|
||||
# Создаем запись о членстве
|
||||
MattermostChannelMembership.objects.create(
|
||||
user=mattermost_user,
|
||||
channel=pharmacy.mattermost_channel
|
||||
)
|
||||
|
||||
added_count += 1
|
||||
logger.info(f"Сотрудник {employee.email} добавлен в канал аптеки #{pharmacy.pharmacy_number}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка добавления {employee.email} в канал аптеки: {e}")
|
||||
|
||||
logger.info(f"Добавлено {added_count} сотрудников в канал аптеки #{pharmacy.pharmacy_number}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка добавления сотрудников в канал аптеки #{pharmacy.pharmacy_number}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def add_employee_to_pharmacy_channel(employee):
|
||||
"""
|
||||
Добавление сотрудника в канал его аптеки
|
||||
|
||||
Args:
|
||||
employee: Объект сотрудника
|
||||
"""
|
||||
# Проверяем, что сотрудник привязан к аптеке
|
||||
if not employee.user_pharmacy:
|
||||
return False
|
||||
|
||||
pharmacy = employee.user_pharmacy
|
||||
|
||||
# Проверяем наличие канала у аптеки
|
||||
if not pharmacy.mattermost_channel:
|
||||
# Пытаемся создать канал
|
||||
logger.info(f"Канал для аптеки #{pharmacy.pharmacy_number} не найден, создаем...")
|
||||
channel = get_or_create_pharmacy_channel(pharmacy)
|
||||
if not channel:
|
||||
logger.warning(f"Не удалось создать канал для аптеки #{pharmacy.pharmacy_number}")
|
||||
return False
|
||||
|
||||
# Проверяем, что у сотрудника есть mattermost_user_id
|
||||
if not employee.mattermost_user_id:
|
||||
logger.warning(f"У сотрудника {employee.email} нет mattermost_user_id")
|
||||
return False
|
||||
|
||||
service = MattermostService()
|
||||
|
||||
try:
|
||||
# Получаем MattermostUser
|
||||
mattermost_user = MattermostUser.objects.filter(
|
||||
mattermost_id=employee.mattermost_user_id
|
||||
).first()
|
||||
|
||||
if not mattermost_user:
|
||||
logger.warning(f"MattermostUser не найден для {employee.email}")
|
||||
return False
|
||||
|
||||
# Проверяем, не добавлен ли уже
|
||||
membership_exists = MattermostChannelMembership.objects.filter(
|
||||
user=mattermost_user,
|
||||
channel=pharmacy.mattermost_channel
|
||||
).exists()
|
||||
|
||||
if membership_exists:
|
||||
logger.debug(f"Сотрудник {employee.email} уже в канале аптеки #{pharmacy.pharmacy_number}")
|
||||
return True
|
||||
|
||||
# Добавляем в канал
|
||||
service.add_user_to_channel(
|
||||
pharmacy.mattermost_channel.channel_id,
|
||||
employee.mattermost_user_id
|
||||
)
|
||||
|
||||
# Создаем запись о членстве
|
||||
MattermostChannelMembership.objects.create(
|
||||
user=mattermost_user,
|
||||
channel=pharmacy.mattermost_channel
|
||||
)
|
||||
|
||||
logger.info(f"Сотрудник {employee.email} добавлен в канал аптеки #{pharmacy.pharmacy_number}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка добавления {employee.email} в канал аптеки: {e}")
|
||||
return False
|
||||
+470
@@ -0,0 +1,470 @@
|
||||
from typing import Optional
|
||||
from django.utils import timezone
|
||||
from django.contrib.auth import get_user_model
|
||||
from apps.mattermost_connector.models import MattermostUser
|
||||
|
||||
Employee = get_user_model()
|
||||
from apps.mattermost_connector.mattermost_service import MattermostService
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def generate_username(employee: Employee) -> str:
|
||||
"""Генерация username для Mattermost"""
|
||||
if employee.username:
|
||||
return employee.username
|
||||
|
||||
# Если нет username, генерируем из email
|
||||
base = employee.email.split('@')[0].lower()
|
||||
# Убираем недопустимые символы
|
||||
base = ''.join(c if c.isalnum() or c in '-_.' else '_' for c in base)
|
||||
return base
|
||||
|
||||
|
||||
def sync_employee(employee: Employee, password: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Синхронизация сотрудника с Mattermost
|
||||
|
||||
Args:
|
||||
employee: Объект сотрудника
|
||||
password: Пароль пользователя (если известен, используется при создании)
|
||||
"""
|
||||
service = MattermostService()
|
||||
|
||||
try:
|
||||
mattermost_user = None
|
||||
mm_data = None
|
||||
|
||||
# 1. Проверяем, есть ли уже mattermost_user_id у сотрудника
|
||||
if employee.mattermost_user_id:
|
||||
try:
|
||||
mm_data = service.get_user(employee.mattermost_user_id)
|
||||
mattermost_user = MattermostUser.objects.filter(
|
||||
mattermost_id=employee.mattermost_user_id
|
||||
).first()
|
||||
logger.info(f"Найден существующий пользователь Mattermost по ID: {employee.mattermost_user_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Пользователь {employee.mattermost_user_id} не найден в Mattermost: {e}")
|
||||
Employee.objects.filter(pk=employee.pk).update(mattermost_user_id=None)
|
||||
employee.mattermost_user_id = None
|
||||
|
||||
# 2. Если ID нет, пытаемся найти по email
|
||||
if not employee.mattermost_user_id:
|
||||
try:
|
||||
mm_data = service.get_user_by_email(employee.email)
|
||||
logger.info(f"Найден существующий пользователь Mattermost по email: {employee.email}")
|
||||
|
||||
# Обновляем Employee через QuerySet (без вызова signals)
|
||||
Employee.objects.filter(pk=employee.pk).update(
|
||||
mattermost_user_id=mm_data['id'],
|
||||
mattermost_username=mm_data['username']
|
||||
)
|
||||
employee.mattermost_user_id = mm_data['id']
|
||||
employee.mattermost_username = mm_data['username']
|
||||
|
||||
# Создаем или обновляем MattermostUser
|
||||
mattermost_user, created = MattermostUser.objects.get_or_create(
|
||||
mattermost_id=mm_data['id'],
|
||||
defaults={
|
||||
'django_user': employee,
|
||||
'username': mm_data['username'],
|
||||
'email': mm_data['email'],
|
||||
'is_active': not mm_data.get('delete_at', 0)
|
||||
}
|
||||
)
|
||||
|
||||
if not created and mattermost_user.django_user != employee:
|
||||
mattermost_user.django_user = employee
|
||||
mattermost_user.save()
|
||||
|
||||
except Exception as e:
|
||||
logger.info(f"Пользователь с email {employee.email} не найден в Mattermost, будет создан новый")
|
||||
mm_data = None
|
||||
|
||||
# 3. Если пользователь не найден - создаем нового
|
||||
if not mm_data:
|
||||
username = generate_username(employee)
|
||||
|
||||
# Проверяем уникальность username
|
||||
counter = 1
|
||||
original_username = username
|
||||
while True:
|
||||
try:
|
||||
service.get_user_by_username(username)
|
||||
username = f"{original_username}{counter}"
|
||||
counter += 1
|
||||
except:
|
||||
break
|
||||
|
||||
# Используем переданный пароль или генерируем новый
|
||||
user_password = password if password else generate_random_password()
|
||||
|
||||
user_data = {
|
||||
'email': employee.email,
|
||||
'username': username,
|
||||
'password': user_password,
|
||||
'first_name': employee.first_name or '',
|
||||
'last_name': employee.last_name or '',
|
||||
'nickname': employee.get_full_name() or '',
|
||||
'position': employee.post.name if employee.post else '',
|
||||
'auth_service': '', # Обязательное поле для пользователей без SSO
|
||||
'auth_data': '' # Обязательное поле для пользователей без SSO
|
||||
}
|
||||
|
||||
logger.info(f"Создание нового пользователя Mattermost: {username} (пароль {'передан из Django' if password else 'сгенерирован'})")
|
||||
mm_data = service.create_user(user_data)
|
||||
|
||||
# Устанавливаем timezone отдельно через preferences
|
||||
try:
|
||||
timezone_data = {
|
||||
'useAutomaticTimezone': 'false',
|
||||
'manualTimezone': 'Europe/Kiev',
|
||||
'automaticTimezone': ''
|
||||
}
|
||||
service.update_user_preferences(mm_data['id'], timezone_data)
|
||||
logger.info(f"Установлен часовой пояс для {username}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось установить часовой пояс для {username}: {e}")
|
||||
|
||||
# Создаем запись в MattermostUser
|
||||
mattermost_user = MattermostUser.objects.create(
|
||||
django_user=employee,
|
||||
mattermost_id=mm_data['id'],
|
||||
username=mm_data['username'],
|
||||
email=mm_data['email'],
|
||||
is_active=True
|
||||
)
|
||||
|
||||
# Обновляем Employee через QuerySet
|
||||
Employee.objects.filter(pk=employee.pk).update(
|
||||
mattermost_user_id=mm_data['id'],
|
||||
mattermost_username=mm_data['username'],
|
||||
mattermost_sync_status='synced',
|
||||
mattermost_last_sync=timezone.now()
|
||||
)
|
||||
employee.mattermost_user_id = mm_data['id']
|
||||
employee.mattermost_username = mm_data['username']
|
||||
employee.mattermost_sync_status = 'synced'
|
||||
employee.mattermost_last_sync = timezone.now()
|
||||
|
||||
logger.info(f"Создан пользователь Mattermost для {employee.email}")
|
||||
|
||||
# Синхронизируем фото при создании
|
||||
if employee.photo:
|
||||
try:
|
||||
service.set_profile_image(mm_data['id'], employee.photo.path)
|
||||
logger.info(f"Фото профиля синхронизировано для {employee.email}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось синхронизировать фото для {employee.email}: {e}")
|
||||
|
||||
else:
|
||||
# 4. Обновляем существующего пользователя
|
||||
update_data = {
|
||||
'email': employee.email,
|
||||
'first_name': employee.first_name,
|
||||
'last_name': employee.last_name,
|
||||
'nickname': employee.get_full_name(),
|
||||
'position': employee.post.name if employee.post else '',
|
||||
}
|
||||
|
||||
logger.info(f"Обновление пользователя Mattermost: {employee.mattermost_user_id}")
|
||||
service.update_user(employee.mattermost_user_id, update_data)
|
||||
|
||||
# Синхронизируем фото при обновлении
|
||||
if employee.photo:
|
||||
try:
|
||||
service.set_profile_image(employee.mattermost_user_id, employee.photo.path)
|
||||
logger.info(f"Фото профиля обновлено для {employee.email}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось обновить фото для {employee.email}: {e}")
|
||||
|
||||
# Обновляем MattermostUser
|
||||
if mattermost_user:
|
||||
mattermost_user.email = employee.email
|
||||
mattermost_user.updated_at = timezone.now()
|
||||
mattermost_user.last_sync = timezone.now()
|
||||
mattermost_user.save()
|
||||
|
||||
# Обновляем Employee через QuerySet
|
||||
Employee.objects.filter(pk=employee.pk).update(
|
||||
mattermost_sync_status='synced',
|
||||
mattermost_last_sync=timezone.now()
|
||||
)
|
||||
employee.mattermost_sync_status = 'synced'
|
||||
employee.mattermost_last_sync = timezone.now()
|
||||
|
||||
logger.info(f"Обновлен пользователь Mattermost для {employee.email}")
|
||||
|
||||
# 5. Добавляем пользователя в команды согласно настройкам
|
||||
add_user_to_teams(employee)
|
||||
|
||||
# 6. Добавляем пользователя в каналы согласно настройкам
|
||||
add_user_to_channels(employee)
|
||||
|
||||
# 7. Если сотрудник привязан к аптеке, добавляем в канал аптеки
|
||||
if employee.user_pharmacy:
|
||||
from apps.mattermost_connector.services.pharmacy_channel_service import add_employee_to_pharmacy_channel
|
||||
add_employee_to_pharmacy_channel(employee)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка синхронизации {employee.email}: {e}")
|
||||
Employee.objects.filter(pk=employee.pk).update(mattermost_sync_status='error')
|
||||
employee.mattermost_sync_status = 'error'
|
||||
return False
|
||||
def deactivate_mattermost_user(employee: Employee) -> bool:
|
||||
"""Деактивация пользователя в Mattermost"""
|
||||
if not employee.mattermost_user_id:
|
||||
return False
|
||||
|
||||
service = MattermostService()
|
||||
|
||||
try:
|
||||
service.deactivate_user(employee.mattermost_user_id)
|
||||
|
||||
# Обновляем MattermostUser
|
||||
mattermost_user = MattermostUser.objects.filter(
|
||||
mattermost_id=employee.mattermost_user_id
|
||||
).first()
|
||||
|
||||
if mattermost_user:
|
||||
mattermost_user.is_active = False
|
||||
mattermost_user.save()
|
||||
|
||||
logger.info(f"Деактивирован пользователь Mattermost для {employee.email}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка деактивации {employee.email}: {e}")
|
||||
return False
|
||||
|
||||
def sync_password(employee: Employee, new_password: str) -> bool:
|
||||
"""
|
||||
Синхронизация пароля с Mattermost
|
||||
|
||||
Args:
|
||||
employee: Объект сотрудника
|
||||
new_password: Новый пароль в plain-text
|
||||
"""
|
||||
if not employee.mattermost_user_id:
|
||||
logger.warning(f"Не могу синхронизировать пароль для {employee.email}: нет mattermost_user_id")
|
||||
return False
|
||||
|
||||
service = MattermostService()
|
||||
|
||||
try:
|
||||
logger.info(f"Синхронизация пароля для {employee.email} в Mattermost")
|
||||
service.reset_password(employee.mattermost_user_id, new_password)
|
||||
|
||||
# Обновляем время последней синхронизации
|
||||
Employee.objects.filter(pk=employee.pk).update(
|
||||
mattermost_last_sync=timezone.now()
|
||||
)
|
||||
employee.mattermost_last_sync = timezone.now()
|
||||
|
||||
logger.info(f"Пароль успешно синхронизирован для {employee.email}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка синхронизации пароля для {employee.email}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def activate_mattermost_user(employee: Employee) -> bool:
|
||||
"""Активация пользователя в Mattermost"""
|
||||
if not employee.mattermost_user_id:
|
||||
return False
|
||||
|
||||
service = MattermostService()
|
||||
|
||||
try:
|
||||
service.activate_user(employee.mattermost_user_id)
|
||||
|
||||
# Обновляем MattermostUser
|
||||
mattermost_user = MattermostUser.objects.filter(
|
||||
mattermost_id=employee.mattermost_user_id
|
||||
).first()
|
||||
|
||||
if mattermost_user:
|
||||
mattermost_user.is_active = True
|
||||
mattermost_user.save()
|
||||
|
||||
logger.info(f"Активирован пользователь Mattermost для {employee.email}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка активации {employee.email}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def generate_random_password(length: int = 16) -> str:
|
||||
"""Генерация случайного пароля"""
|
||||
import secrets
|
||||
import string
|
||||
|
||||
alphabet = string.ascii_letters + string.digits + string.punctuation
|
||||
password = ''.join(secrets.choice(alphabet) for _ in range(length))
|
||||
return password
|
||||
|
||||
|
||||
def add_user_to_teams(employee: Employee) -> bool:
|
||||
"""
|
||||
Добавление пользователя в команды Mattermost согласно настройкам синхронизации
|
||||
|
||||
Args:
|
||||
employee: Объект сотрудника
|
||||
|
||||
Returns:
|
||||
bool: True если успешно добавлен хотя бы в одну команду
|
||||
"""
|
||||
from apps.mattermost_connector.models import TeamSyncSettings, MattermostTeamMembership, MattermostUser
|
||||
|
||||
if not employee.mattermost_user_id:
|
||||
logger.warning(f"Пользователь {employee.email} не имеет mattermost_user_id")
|
||||
return False
|
||||
|
||||
service = MattermostService()
|
||||
added_to_teams = 0
|
||||
|
||||
try:
|
||||
# Получаем MattermostUser
|
||||
mattermost_user = MattermostUser.objects.filter(mattermost_id=employee.mattermost_user_id).first()
|
||||
if not mattermost_user:
|
||||
logger.warning(f"MattermostUser не найден для {employee.email}")
|
||||
return False
|
||||
|
||||
# Получаем все активные настройки синхронизации, отсортированные по приоритету
|
||||
team_settings = TeamSyncSettings.objects.filter(is_active=True).select_related('team').prefetch_related('departments')
|
||||
|
||||
for setting in team_settings:
|
||||
# Проверяем, нужно ли добавлять пользователя в эту команду
|
||||
should_add = False
|
||||
|
||||
if setting.auto_add_users:
|
||||
if setting.filter_by_department:
|
||||
# Проверяем, что пользователь из нужного отдела
|
||||
if employee.department and setting.departments.filter(id=employee.department.id).exists():
|
||||
should_add = True
|
||||
else:
|
||||
# Добавляем всех пользователей
|
||||
should_add = True
|
||||
|
||||
if should_add:
|
||||
# Проверяем, не добавлен ли уже пользователь в эту команду
|
||||
membership_exists = MattermostTeamMembership.objects.filter(
|
||||
user=mattermost_user,
|
||||
team=setting.team
|
||||
).exists()
|
||||
|
||||
if not membership_exists:
|
||||
try:
|
||||
# Добавляем пользователя в команду
|
||||
logger.info(f"Добавление {employee.email} в команду {setting.team.display_name}")
|
||||
service.add_user_to_team(setting.team.team_id, employee.mattermost_user_id)
|
||||
|
||||
# Создаем запись о членстве
|
||||
MattermostTeamMembership.objects.create(
|
||||
user=mattermost_user,
|
||||
team=setting.team
|
||||
)
|
||||
|
||||
added_to_teams += 1
|
||||
logger.info(f"Пользователь {employee.email} добавлен в команду {setting.team.display_name}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка добавления {employee.email} в команду {setting.team.display_name}: {e}")
|
||||
else:
|
||||
logger.debug(f"Пользователь {employee.email} уже в команде {setting.team.display_name}")
|
||||
|
||||
if added_to_teams > 0:
|
||||
logger.info(f"Пользователь {employee.email} добавлен в {added_to_teams} команд(у/ы)")
|
||||
return True
|
||||
else:
|
||||
logger.info(f"Пользователь {employee.email} уже добавлен во все необходимые команды")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка добавления пользователя {employee.email} в команды: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def add_user_to_channels(employee: Employee) -> bool:
|
||||
"""
|
||||
Добавление пользователя в каналы Mattermost согласно настройкам синхронизации
|
||||
|
||||
Args:
|
||||
employee: Объект сотрудника
|
||||
|
||||
Returns:
|
||||
bool: True если успешно добавлен хотя бы в один канал
|
||||
"""
|
||||
from apps.mattermost_connector.models import ChannelSyncSettings, MattermostChannelMembership, MattermostUser
|
||||
|
||||
if not employee.mattermost_user_id:
|
||||
logger.warning(f"Пользователь {employee.email} не имеет mattermost_user_id")
|
||||
return False
|
||||
|
||||
service = MattermostService()
|
||||
added_to_channels = 0
|
||||
|
||||
try:
|
||||
# Получаем MattermostUser
|
||||
mattermost_user = MattermostUser.objects.filter(mattermost_id=employee.mattermost_user_id).first()
|
||||
if not mattermost_user:
|
||||
logger.warning(f"MattermostUser не найден для {employee.email}")
|
||||
return False
|
||||
|
||||
# Получаем все активные настройки синхронизации каналов, отсортированные по приоритету
|
||||
channel_settings = ChannelSyncSettings.objects.filter(is_active=True).select_related('channel', 'channel__team').prefetch_related('departments')
|
||||
|
||||
for setting in channel_settings:
|
||||
# Проверяем, нужно ли добавлять пользователя в этот канал
|
||||
should_add = False
|
||||
|
||||
if setting.auto_add_users:
|
||||
if setting.filter_by_department:
|
||||
# Проверяем, что пользователь из нужного отдела
|
||||
if employee.department and setting.departments.filter(id=employee.department.id).exists():
|
||||
should_add = True
|
||||
else:
|
||||
# Добавляем всех пользователей
|
||||
should_add = True
|
||||
|
||||
if should_add:
|
||||
# Проверяем, не добавлен ли уже пользователь в этот канал
|
||||
membership_exists = MattermostChannelMembership.objects.filter(
|
||||
user=mattermost_user,
|
||||
channel=setting.channel
|
||||
).exists()
|
||||
|
||||
if not membership_exists:
|
||||
try:
|
||||
# Добавляем пользователя в канал
|
||||
logger.info(f"Добавление {employee.email} в канал {setting.channel.display_name}")
|
||||
service.add_user_to_channel(setting.channel.channel_id, employee.mattermost_user_id)
|
||||
|
||||
# Создаем запись о членстве
|
||||
MattermostChannelMembership.objects.create(
|
||||
user=mattermost_user,
|
||||
channel=setting.channel
|
||||
)
|
||||
|
||||
added_to_channels += 1
|
||||
logger.info(f"Пользователь {employee.email} добавлен в канал {setting.channel.display_name}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка добавления {employee.email} в канал {setting.channel.display_name}: {e}")
|
||||
else:
|
||||
logger.debug(f"Пользователь {employee.email} уже в канале {setting.channel.display_name}")
|
||||
|
||||
if added_to_channels > 0:
|
||||
logger.info(f"Пользователь {employee.email} добавлен в {added_to_channels} канал(ов)")
|
||||
return True
|
||||
else:
|
||||
logger.info(f"Пользователь {employee.email} уже добавлен во все необходимые каналы")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка добавления пользователя {employee.email} в каналы: {e}")
|
||||
return False
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
Сигналы для синхронизации с Mattermost
|
||||
"""
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.contrib.auth import get_user_model
|
||||
from apps.mattermost_connector.mattermost_service import MattermostService
|
||||
|
||||
Employee = get_user_model()
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@receiver(post_save, sender=Employee)
|
||||
def sync_employee_photo_to_mattermost(sender, instance, created, **kwargs):
|
||||
"""
|
||||
Синхронизация фото сотрудника с Mattermost при изменении
|
||||
|
||||
Срабатывает при:
|
||||
- Загрузке нового фото
|
||||
- Обновлении существующего фото
|
||||
"""
|
||||
# Проверяем что у сотрудника есть mattermost_user_id и фото
|
||||
if not instance.mattermost_user_id or not instance.photo:
|
||||
return
|
||||
|
||||
# Проверяем что синхронизация включена
|
||||
if not instance.mattermost_sync_enabled:
|
||||
return
|
||||
|
||||
# Избегаем рекурсии - проверяем что изменилось именно поле photo
|
||||
if not created:
|
||||
# Получаем предыдущее состояние из БД
|
||||
try:
|
||||
old_instance = Employee.objects.get(pk=instance.pk)
|
||||
# Если фото не изменилось, не синхронизируем
|
||||
if old_instance.photo == instance.photo:
|
||||
return
|
||||
except Employee.DoesNotExist:
|
||||
return
|
||||
|
||||
# Синхронизируем фото
|
||||
try:
|
||||
service = MattermostService()
|
||||
service.set_profile_image(instance.mattermost_user_id, instance.photo.path)
|
||||
logger.info(f"Фото профиля автоматически синхронизировано для {instance.email}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка автоматической синхронизации фото для {instance.email}: {e}")
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
from celery import shared_task
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
Employee = get_user_model()
|
||||
from apps.mattermost_connector.services.sync_service import sync_employee
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@shared_task
|
||||
def sync_employee_to_mattermost(employee_id):
|
||||
"""
|
||||
Асинхронная задача синхронизации сотрудника с Mattermost
|
||||
"""
|
||||
try:
|
||||
employee = Employee.objects.get(id=employee_id)
|
||||
sync_employee(employee)
|
||||
logger.info(f"Задача синхронизации для {employee.email} выполнена")
|
||||
except Employee.DoesNotExist:
|
||||
logger.error(f"Сотрудник с ID {employee_id} не найден")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в задаче синхронизации: {e}")
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PortalConnectorConfig(AppConfig):
|
||||
name = 'apps.portal_connector'
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user