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:
2026-05-31 14:21:47 +03:00
commit 131cb27d16
166 changed files with 14990 additions and 0 deletions
+152
View File
@@ -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
+169
View File
@@ -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 построен вокруг трех опор:
- универсальные справочники;
- доменные модели сотрудников и аптек;
- интеграционные коннекторы.
Такая структура хорошо переносится в другой проект с минимальными изменениями доменной модели.
View File
+16
View File
@@ -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()
View File
+406
View File
@@ -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,
},
},
}
+10
View File
@@ -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']
+9
View File
@@ -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
+47
View File
@@ -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)
+16
View File
@@ -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()
View File
+3
View File
@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AdConnectorConfig(AppConfig):
name = 'apps.ad_connector'
verbose_name = 'Active Directory Connector'
+3
View File
@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+3
View File
@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.
View File
+224
View File
@@ -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 = _('Отдел')
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class EmployeesConfig(AppConfig):
name = 'apps.employees'
verbose_name = 'Сотрудники'
+110
View File
@@ -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': 'Сотрудники',
},
),
]
+59
View File
@@ -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='Локация (офис/склад)'),
),
]
+2
View File
@@ -0,0 +1,2 @@
from .base import Employee, EmployeeManager
from .permissions import PermissionGroup, EmployeePermissionOverride
+310
View File
@@ -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)
+80
View File
@@ -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
+15
View File
@@ -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']
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+10
View File
@@ -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)),
]
+12
View File
@@ -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]
View File
+673
View File
@@ -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)
+7
View File
@@ -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 — Оборудование и заявки'
+131
View File
@@ -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')],
},
),
]
+5
View File
@@ -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
+82
View File
@@ -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')
+125
View File
@@ -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
+117
View File
@@ -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()]
+133
View File
@@ -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}'
+117
View File
@@ -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}ч'
View File
+352
View File
@@ -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 = _('Телефоны')
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class LocationsConfig(AppConfig):
name = 'apps.locations'
verbose_name = 'Локации'
+126
View File
@@ -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'),
),
]
@@ -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='Статус'),
),
]
+2
View File
@@ -0,0 +1,2 @@
from .base import Location, LocationManager
from .pharmacies import Pharmacy, PharmacyManager, PharmacyPhone, PharmacyHistory
+126
View File
@@ -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)
+233
View File
@@ -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']
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+3
View File
@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.
View File
+3
View File
@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class MailConnectorConfig(AppConfig):
name = 'apps.mail_connector'
verbose_name = 'Carbonio CE Connector'
+3
View File
@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+3
View File
@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.
View File
+3
View File
@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class MailparserConfig(AppConfig):
name = 'apps.mailparser'
verbose_name = 'Сортировщик писем'
+3
View File
@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+3
View File
@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.
View File
+231
View File
@@ -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 = 'Деактивировать синхронизацию'
+10
View File
@@ -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
View File
@@ -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Сотрудники будут автоматически добавлены в каналы при следующей синхронизации.")
@@ -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())
@@ -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
View File
@@ -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')},
},
),
]
+222
View File
@@ -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
View File
@@ -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
+49
View File
@@ -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}")
+22
View File
@@ -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}")
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+3
View File
@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.
View File
+3
View File
@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class PortalConnectorConfig(AppConfig):
name = 'apps.portal_connector'
+3
View File
@@ -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