diff --git a/backend/api/admin.py b/backend/api/admin.py index b97a94f6..846f6b40 100644 --- a/backend/api/admin.py +++ b/backend/api/admin.py @@ -1,2 +1 @@ - # Register your models here. diff --git a/backend/api/apps.py b/backend/api/apps.py index 612aa7e3..a26c7a96 100644 --- a/backend/api/apps.py +++ b/backend/api/apps.py @@ -2,8 +2,8 @@ from django.apps import AppConfig class ApiConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'api' + default_auto_field = "django.db.models.BigAutoField" + name = "api" verbose_name = "REST API layer" def ready(self): diff --git a/backend/api/auth/serializers.py b/backend/api/auth/serializers.py index 6d4d0573..347432e8 100644 --- a/backend/api/auth/serializers.py +++ b/backend/api/auth/serializers.py @@ -64,9 +64,7 @@ class QrLoginRequestActionResponseSerializer(serializers.Serializer): class TokenRefreshRequestSerializer(serializers.Serializer): - refresh = serializers.CharField( - help_text="Refresh token, полученный при логине." - ) + refresh = serializers.CharField(help_text="Refresh token, полученный при логине.") class TokenRefreshResponseSerializer(serializers.Serializer): @@ -204,9 +202,9 @@ class PhoneOrEmailTokenObtainPairSerializer(TokenObtainPairSerializer): super().__init__(*args, **kwargs) # <- КЛЮЧЕВОЕ: email (username_field) не обязателен self.fields[self.username_field].required = False - self.fields[self.username_field].help_text = ( - "Email пользователя (поле сохранено для обратной совместимости)." - ) + self.fields[ + self.username_field + ].help_text = "Email пользователя (поле сохранено для обратной совместимости)." self.fields["login"] = serializers.CharField( required=False, allow_blank=True, diff --git a/backend/api/auth/websocket_tickets.py b/backend/api/auth/websocket_tickets.py index d564667c..3422be7a 100644 --- a/backend/api/auth/websocket_tickets.py +++ b/backend/api/auth/websocket_tickets.py @@ -75,11 +75,14 @@ def consume_websocket_ticket(raw_ticket: str) -> WebSocketTicketIdentity: if user is None: raise WebSocketTicketError("user_unavailable") - if session_id and not UserAuthSession.objects.filter( - user=user, - session_id=session_id, - revoked_at__isnull=True, - ).exists(): + if ( + session_id + and not UserAuthSession.objects.filter( + user=user, + session_id=session_id, + revoked_at__isnull=True, + ).exists() + ): raise WebSocketTicketError("session_revoked") return WebSocketTicketIdentity(user=user, session_id=session_id) diff --git a/backend/api/docs_views.py b/backend/api/docs_views.py index b76e52ca..d72093e0 100644 --- a/backend/api/docs_views.py +++ b/backend/api/docs_views.py @@ -5,12 +5,8 @@ from django.http import Http404, HttpResponse from django.urls import reverse from django.views.generic import TemplateView, View - ASYNCAPI_SCHEMA_PATH = ( - Path(settings.BASE_DIR) - / "docs" - / "architecture" - / "websocket-asyncapi.yaml" + Path(settings.BASE_DIR) / "docs" / "architecture" / "websocket-asyncapi.yaml" ) @@ -32,9 +28,7 @@ class AsyncAPISchemaView(View): schema_text, content_type="application/yaml; charset=utf-8", ) - response["Content-Disposition"] = ( - 'inline; filename="websocket-asyncapi.yaml"' - ) + response["Content-Disposition"] = 'inline; filename="websocket-asyncapi.yaml"' return response @@ -52,9 +46,7 @@ class AsyncAPIDocsView(TemplateView): "openapi_schema_url": reverse("api:schema"), "swagger_url": reverse("api:swagger-ui"), "redoc_url": reverse("api:redoc"), - "ws_endpoint_url": ( - f"{ws_scheme}://{self.request.get_host()}/ws/" - ), + "ws_endpoint_url": (f"{ws_scheme}://{self.request.get_host()}/ws/"), "schema_text": _load_asyncapi_schema_text(), } ) diff --git a/backend/api/management/commands/provision_oidc_client.py b/backend/api/management/commands/provision_oidc_client.py index f48a65c3..875abd28 100644 --- a/backend/api/management/commands/provision_oidc_client.py +++ b/backend/api/management/commands/provision_oidc_client.py @@ -30,8 +30,7 @@ class Command(BaseCommand): "--rotate-secret", action="store_true", help=( - "Replace the secret of an existing client and print the new " - "value once." + "Replace the secret of an existing client and print the new value once." ), ) parser.add_argument( diff --git a/backend/api/middleware.py b/backend/api/middleware.py index 4608593d..b6d5a1e2 100644 --- a/backend/api/middleware.py +++ b/backend/api/middleware.py @@ -4,6 +4,7 @@ The SPA sends Bearer tokens and refreshes them in its own HTTP transport. This middleware only handles tokens stored under ``api.jwt_*`` in ``request.session``. """ + from datetime import datetime, timedelta import jwt @@ -24,7 +25,7 @@ class JWTRefreshMiddleware: self.get_response = get_response # За сколько минут до истечения обновлять токен self.refresh_threshold = timedelta( - minutes=int(getattr(settings, 'JWT_REFRESH_THRESHOLD_MINUTES', 5)) + minutes=int(getattr(settings, "JWT_REFRESH_THRESHOLD_MINUTES", 5)) ) def __call__(self, request): @@ -38,10 +39,9 @@ class JWTRefreshMiddleware: # Декодируем токен БЕЗ проверки подписи (только для чтения # exp) decoded = jwt.decode( - access_token, - options={"verify_signature": False} + access_token, options={"verify_signature": False} ) - exp = decoded.get('exp') + exp = decoded.get("exp") if exp: # Время истечения токена diff --git a/backend/api/migrations/0001_initial.py b/backend/api/migrations/0001_initial.py index ff07ac16..13872d40 100644 --- a/backend/api/migrations/0001_initial.py +++ b/backend/api/migrations/0001_initial.py @@ -1,9 +1,10 @@ # Generated manually for auth session registry. +import uuid + +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion -import uuid class Migration(migrations.Migration): diff --git a/backend/api/migrations/0002_rename_api_useraut_user_id_1e9721_idx_api_useraut_user_id_70da51_idx_and_more.py b/backend/api/migrations/0002_rename_api_useraut_user_id_1e9721_idx_api_useraut_user_id_70da51_idx_and_more.py index 63cb3707..d3483d68 100644 --- a/backend/api/migrations/0002_rename_api_useraut_user_id_1e9721_idx_api_useraut_user_id_70da51_idx_and_more.py +++ b/backend/api/migrations/0002_rename_api_useraut_user_id_1e9721_idx_api_useraut_user_id_70da51_idx_and_more.py @@ -4,20 +4,19 @@ from django.db import migrations class Migration(migrations.Migration): - dependencies = [ - ('api', '0001_initial'), + ("api", "0001_initial"), ] operations = [ migrations.RenameIndex( - model_name='userauthsession', - new_name='api_useraut_user_id_70da51_idx', - old_name='api_useraut_user_id_1e9721_idx', + model_name="userauthsession", + new_name="api_useraut_user_id_70da51_idx", + old_name="api_useraut_user_id_1e9721_idx", ), migrations.RenameIndex( - model_name='userauthsession', - new_name='api_useraut_user_id_5a84c5_idx', - old_name='api_useraut_user_id_82cdaa_idx', + model_name="userauthsession", + new_name="api_useraut_user_id_5a84c5_idx", + old_name="api_useraut_user_id_82cdaa_idx", ), ] diff --git a/backend/api/migrations/0003_qrlogintoken.py b/backend/api/migrations/0003_qrlogintoken.py index f1054214..1b6861a5 100644 --- a/backend/api/migrations/0003_qrlogintoken.py +++ b/backend/api/migrations/0003_qrlogintoken.py @@ -1,14 +1,16 @@ # Generated manually for QR login tokens. +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion class Migration(migrations.Migration): - dependencies = [ - ("api", "0002_rename_api_useraut_user_id_1e9721_idx_api_useraut_user_id_70da51_idx_and_more"), + ( + "api", + "0002_rename_api_useraut_user_id_1e9721_idx_api_useraut_user_id_70da51_idx_and_more", + ), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] diff --git a/backend/api/migrations/0004_qrloginrequest.py b/backend/api/migrations/0004_qrloginrequest.py index 9ad38ed7..8a90dde4 100644 --- a/backend/api/migrations/0004_qrloginrequest.py +++ b/backend/api/migrations/0004_qrloginrequest.py @@ -1,12 +1,11 @@ # Generated manually for QR login requests. +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion class Migration(migrations.Migration): - dependencies = [ ("api", "0003_qrlogintoken"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), diff --git a/backend/api/oidc/validators.py b/backend/api/oidc/validators.py index 0e225908..ed280d56 100644 --- a/backend/api/oidc/validators.py +++ b/backend/api/oidc/validators.py @@ -50,18 +50,14 @@ class CorporateOAuth2Validator(OAuth2Validator): } def validate_code(self, client_id, code, client, request, *args, **kwargs): - valid = super().validate_code( - client_id, code, client, request, *args, **kwargs - ) + valid = super().validate_code(client_id, code, client, request, *args, **kwargs) return bool(valid and _is_active_employee(getattr(request, "user", None))) def validate_bearer_token(self, token, scopes, request): valid = super().validate_bearer_token(token, scopes, request) return bool(valid and _is_active_employee(getattr(request, "user", None))) - def validate_refresh_token( - self, refresh_token, client, request, *args, **kwargs - ): + def validate_refresh_token(self, refresh_token, client, request, *args, **kwargs): valid = super().validate_refresh_token( refresh_token, client, request, *args, **kwargs ) diff --git a/backend/api/pagination.py b/backend/api/pagination.py index e13739d0..b461a9c7 100644 --- a/backend/api/pagination.py +++ b/backend/api/pagination.py @@ -9,6 +9,7 @@ class StandardPagination(PageNumberPagination): Позволяет клиенту указывать размер страницы через параметр page_size, с ограничением максимального размера страницы. """ + page_size = 20 page_size_query_param = "page_size" max_page_size = 200 diff --git a/backend/api/schema.py b/backend/api/schema.py index 6f9b4405..5d7492e9 100644 --- a/backend/api/schema.py +++ b/backend/api/schema.py @@ -121,10 +121,6 @@ def assign_operation_tags( {"name": tag, "description": TAG_METADATA[tag]} for tag in TAG_METADATA if tag in used_tags - ] + [ - {"name": tag} - for tag in sorted(used_tags) - if tag not in TAG_METADATA - ] + ] + [{"name": tag} for tag in sorted(used_tags) if tag not in TAG_METADATA] return result diff --git a/backend/api/urls.py b/backend/api/urls.py index 886f235f..33bb887a 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -9,6 +9,7 @@ from drf_spectacular.views import ( from rest_framework.permissions import AllowAny from .docs_views import AsyncAPIDocsView, AsyncAPISchemaView + app_name = "api" urlpatterns = [ diff --git a/backend/api/v1/attendance/endpoints/reports.py b/backend/api/v1/attendance/endpoints/reports.py index 2a09c946..94fca80e 100644 --- a/backend/api/v1/attendance/endpoints/reports.py +++ b/backend/api/v1/attendance/endpoints/reports.py @@ -142,9 +142,7 @@ class AttendanceMonthlyMatrixAPIView(APIView): for employee_id in employee_ids: _check_employee_access(request.user, employee_id) - employees = list( - Employee.objects.visible_in_ui().filter(id__in=employee_ids) - ) + employees = list(Employee.objects.visible_in_ui().filter(id__in=employee_ids)) employees_by_id = {employee.id: employee for employee in employees} employees = [ employees_by_id[employee_id] @@ -200,9 +198,7 @@ class AttendanceMonthlyMatrixExportAPIView(APIView): period_start = data["period_start"] period_end = data["period_end"] - employees = list( - Employee.objects.visible_in_ui().filter(id__in=employee_ids) - ) + employees = list(Employee.objects.visible_in_ui().filter(id__in=employee_ids)) employees_by_id = {employee.id: employee for employee in employees} employees = [ employees_by_id[employee_id] diff --git a/backend/api/v1/commerce/urls.py b/backend/api/v1/commerce/urls.py index 483ac61c..8fbba92e 100644 --- a/backend/api/v1/commerce/urls.py +++ b/backend/api/v1/commerce/urls.py @@ -6,8 +6,8 @@ from .viewsets import ( IntegrationSyncRunViewSet, ProductBindingViewSet, ProductViewSet, - SaleViewSet, SaleLineViewSet, + SaleViewSet, SourceSystemViewSet, StockSnapshotViewSet, ) diff --git a/backend/api/v1/commerce/viewsets.py b/backend/api/v1/commerce/viewsets.py index 50216250..64cab3e3 100644 --- a/backend/api/v1/commerce/viewsets.py +++ b/backend/api/v1/commerce/viewsets.py @@ -1,15 +1,16 @@ import logging -from django.utils import timezone from datetime import timedelta -from django.db.models.deletion import ProtectedError from django.db.models import Count +from django.db.models.deletion import ProtectedError +from django.utils import timezone from django_filters.rest_framework import DjangoFilterBackend -from rest_framework import filters, viewsets +from rest_framework import filters, status, viewsets from rest_framework.decorators import action -from rest_framework import status from rest_framework.response import Response +from commerce.integrations.moysklad import MoySkladError +from commerce.integrations.wildberries import WildberriesError from commerce.models import ( Cost, IntegrationConnection, @@ -22,6 +23,7 @@ from commerce.models import ( SourceSystem, StockSnapshot, ) +from commerce.search import UnicodeCasefoldSearchFilter from commerce.services import ( get_product_metrics, merge_products, @@ -29,9 +31,6 @@ from commerce.services import ( test_wildberries_connection, ) from commerce.services.credentials import CredentialError -from commerce.integrations.moysklad import MoySkladError -from commerce.integrations.wildberries import WildberriesError -from commerce.search import UnicodeCasefoldSearchFilter from commerce.tasks import enqueue_connection_sync from .permissions import CommerceAccessPermission @@ -43,13 +42,12 @@ from .serializers import ( ProductMergeSerializer, ProductMetricsQuerySerializer, ProductSerializer, - SaleSerializer, SaleLineSerializer, + SaleSerializer, SourceSystemSerializer, StockSnapshotSerializer, ) - logger = logging.getLogger(__name__) diff --git a/backend/api/v1/directory/serializers.py b/backend/api/v1/directory/serializers.py index 9dd79529..0be8791f 100644 --- a/backend/api/v1/directory/serializers.py +++ b/backend/api/v1/directory/serializers.py @@ -5,8 +5,6 @@ from rest_framework import serializers class DirectoryLoginResponseSerializer(serializers.Serializer): username = serializers.CharField(allow_blank=True, allow_null=True) - source = serializers.ChoiceField( - choices=("db", "ldap", "none", "ldap_not_found") - ) + source = serializers.ChoiceField(choices=("db", "ldap", "none", "ldap_not_found")) is_cached = serializers.BooleanField() is_ldap_managed = serializers.BooleanField() diff --git a/backend/api/v1/directory/views.py b/backend/api/v1/directory/views.py index e141750b..0aa098d2 100644 --- a/backend/api/v1/directory/views.py +++ b/backend/api/v1/directory/views.py @@ -4,12 +4,13 @@ from __future__ import annotations import logging -from employees.ldap.errors import DirectoryDbError, DirectoryLdapError from rest_framework import status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView +from employees.ldap.errors import DirectoryDbError, DirectoryLdapError + from .serializers import DirectoryLoginResponseSerializer from .services import resolve_directory_login diff --git a/backend/api/v1/documents/fields.py b/backend/api/v1/documents/fields.py index f814586c..61f10446 100644 --- a/backend/api/v1/documents/fields.py +++ b/backend/api/v1/documents/fields.py @@ -3,13 +3,13 @@ Кастомные serializer fields для работы с django-filer. """ +import logging + +from django.core.files.uploadedfile import UploadedFile from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema_field - -from rest_framework import serializers -from django.core.files.uploadedfile import UploadedFile from filer.models import File as FilerFile -import logging +from rest_framework import serializers logger = logging.getLogger(__name__) diff --git a/backend/api/v1/documents/serializers/write.py b/backend/api/v1/documents/serializers/write.py index 4188572b..ff8c143b 100644 --- a/backend/api/v1/documents/serializers/write.py +++ b/backend/api/v1/documents/serializers/write.py @@ -249,9 +249,7 @@ class DocumentWriteSerializer(serializers.ModelSerializer): .values_list("pk", flat=True) ) existing_acknowledgement_ids = ( - set( - instance.acknowledgement_recipients.values_list("pk", flat=True) - ) + set(instance.acknowledgement_recipients.values_list("pk", flat=True)) if instance is not None else set() ) diff --git a/backend/api/v1/employees/serializers/auth.py b/backend/api/v1/employees/serializers/auth.py index 5f84f6e2..b7b03675 100644 --- a/backend/api/v1/employees/serializers/auth.py +++ b/backend/api/v1/employees/serializers/auth.py @@ -5,11 +5,11 @@ from typing import Any, Dict from django.contrib.auth.password_validation import validate_password from django.core.exceptions import ValidationError as DjangoValidationError -from employees.models import Employee, Position -from employees.utils import _normalize_phone from rest_framework import serializers from api.v1.serializers import Base64ImageField +from employees.models import Employee, Position +from employees.utils import _normalize_phone class EmailSerializer(serializers.Serializer): @@ -41,18 +41,12 @@ class RegisterSerializer(serializers.Serializer): password = serializers.CharField(min_length=8, write_only=True) birth_date = serializers.DateField() - telegram = serializers.CharField( - required=False, allow_blank=True, default="" - ) - whatsapp = serializers.CharField( - required=False, allow_blank=True, default="" - ) + telegram = serializers.CharField(required=False, allow_blank=True, default="") + whatsapp = serializers.CharField(required=False, allow_blank=True, default="") wechat = serializers.CharField(required=False, allow_blank=True, default="") avatar = Base64ImageField(required=True) - patronymic = serializers.CharField( - required=False, allow_blank=True, default="" - ) + patronymic = serializers.CharField(required=False, allow_blank=True, default="") gender = serializers.IntegerField( required=True, @@ -67,9 +61,7 @@ class RegisterSerializer(serializers.Serializer): ) position = serializers.IntegerField(required=False, allow_null=True) - skills = serializers.ListField( - child=serializers.IntegerField(), required=False - ) + skills = serializers.ListField(child=serializers.IntegerField(), required=False) def validate_first_name(self, value: str) -> str: value = value.strip() @@ -86,17 +78,12 @@ class RegisterSerializer(serializers.Serializer): def validate_last_name(self, value: str) -> str: value = value.strip() if not value: - raise serializers.ValidationError( - "Фамилия обязательна для заполнения." - ) + raise serializers.ValidationError("Фамилия обязательна для заполнения.") if re.search(r"\d", value): - raise serializers.ValidationError( - "Фамилия не должна содержать цифры." - ) + raise serializers.ValidationError("Фамилия не должна содержать цифры.") if not re.match(r"^[\w\s\-\']+$", value, re.UNICODE): raise serializers.ValidationError( - "Фамилия может содержать только буквы, пробелы, " - "дефисы и апострофы." + "Фамилия может содержать только буквы, пробелы, дефисы и апострофы." ) return value @@ -105,13 +92,10 @@ class RegisterSerializer(serializers.Serializer): if not value: return value if re.search(r"\d", value): - raise serializers.ValidationError( - "Отчество не должно содержать цифры." - ) + raise serializers.ValidationError("Отчество не должно содержать цифры.") if not re.match(r"^[\w\s\-\']+$", value, re.UNICODE): raise serializers.ValidationError( - "Отчество может содержать только буквы, пробелы, " - "дефисы и апострофы." + "Отчество может содержать только буквы, пробелы, дефисы и апострофы." ) return value @@ -122,12 +106,7 @@ class RegisterSerializer(serializers.Serializer): norm = _normalize_phone(attrs.get("phone_number")) if not norm: raise serializers.ValidationError( - { - "phone_number": ( - "Неверный номер телефона " - "(требуется формат E.164)." - ) - } + {"phone_number": ("Неверный номер телефона (требуется формат E.164).")} ) attrs["phone_number"] = norm @@ -146,8 +125,6 @@ class RegisterSerializer(serializers.Serializer): try: validate_password(attrs["password"], user=password_user) except DjangoValidationError as exc: - raise serializers.ValidationError( - {"password": exc.messages} - ) from exc + raise serializers.ValidationError({"password": exc.messages}) from exc return attrs diff --git a/backend/api/v1/employees/serializers/department.py b/backend/api/v1/employees/serializers/department.py index 5ba97b8b..1c7770f2 100644 --- a/backend/api/v1/employees/serializers/department.py +++ b/backend/api/v1/employees/serializers/department.py @@ -2,9 +2,10 @@ from django.contrib.auth import get_user_model from drf_spectacular.utils import extend_schema_serializer -from employees.models import Department from rest_framework import serializers +from employees.models import Department + from .employee import EmployeeBriefSerializer Employee = get_user_model() diff --git a/backend/api/v1/employees/serializers/role.py b/backend/api/v1/employees/serializers/role.py index cc2777f8..d004cbf9 100644 --- a/backend/api/v1/employees/serializers/role.py +++ b/backend/api/v1/employees/serializers/role.py @@ -1,10 +1,11 @@ """Сериализаторы для ролей и групп.""" -from drf_spectacular.utils import extend_schema_field from django.contrib.auth.models import Group, Permission -from employees.models import DepartmentPermission, DepartmentRole +from drf_spectacular.utils import extend_schema_field from rest_framework import serializers +from employees.models import DepartmentPermission, DepartmentRole + class DepartmentPermissionVerboseSerializer(serializers.Serializer): id = serializers.IntegerField() @@ -48,9 +49,7 @@ class DepartmentRoleSerializer(serializers.ModelSerializer): ) read_only_fields = ("id", "active_assignments_count", "ldap_linked") - def _apply_codes_if_present( - self, instance: DepartmentRole, validated_data: dict - ): + def _apply_codes_if_present(self, instance: DepartmentRole, validated_data: dict): codes = validated_data.pop("scoped_permission_codes", None) if codes is not None: qs = DepartmentPermission.objects.filter(code__in=codes) @@ -99,9 +98,7 @@ class DepartmentRoleSerializer(serializers.ModelSerializer): return role - @extend_schema_field( - serializers.ListField(child=serializers.IntegerField()) - ) + @extend_schema_field(serializers.ListField(child=serializers.IntegerField())) def get_permissions(self, obj: DepartmentRole): return list(obj.scoped_permissions.values_list("id", flat=True)) @@ -115,14 +112,10 @@ class DepartmentRoleSerializer(serializers.ModelSerializer): @extend_schema_field(serializers.IntegerField()) def get_active_assignments_count(self, obj: DepartmentRole): employee_ids = set( - obj.assignments.filter(is_active=True).values_list( - "employee_id", flat=True - ) + obj.assignments.filter(is_active=True).values_list("employee_id", flat=True) ) employee_ids.update( - obj.members.filter(is_active=True).values_list( - "employee_id", flat=True - ) + obj.members.filter(is_active=True).values_list("employee_id", flat=True) ) return len(employee_ids) diff --git a/backend/api/v1/employees/views/employee_mixins/profile.py b/backend/api/v1/employees/views/employee_mixins/profile.py index 9fc13754..ab08dc3c 100644 --- a/backend/api/v1/employees/views/employee_mixins/profile.py +++ b/backend/api/v1/employees/views/employee_mixins/profile.py @@ -1,9 +1,11 @@ """Current-user profile and employee skill actions.""" from django.db.models import Prefetch +from rest_framework import status from rest_framework.decorators import action from rest_framework.response import Response +from api.v1.employees.serializers import SkillExperienceEventSerializer from employees.models import ( Employee, EmployeeAction, @@ -12,21 +14,19 @@ from employees.models import ( Skill, SkillExperienceEvent, ) -from employees.services.skill_progress import ( - endorsement_available_at, - endorsement_weight, - ensure_skill_progress, - confirmed_holder_count, - rank_payload, - record_skill_experience, - skill_queryset_with_rarity, -) from employees.services.secret_skills import ( secret_skill_progress_payload, skill_rarity_payload, ) -from api.v1.employees.serializers import SkillExperienceEventSerializer -from rest_framework import status +from employees.services.skill_progress import ( + confirmed_holder_count, + endorsement_available_at, + endorsement_weight, + ensure_skill_progress, + rank_payload, + record_skill_experience, + skill_queryset_with_rarity, +) class EmployeeProfileMixin: diff --git a/backend/api/v1/employees/views/employee_mixins/statistics.py b/backend/api/v1/employees/views/employee_mixins/statistics.py index 025d5e34..6b5a6664 100644 --- a/backend/api/v1/employees/views/employee_mixins/statistics.py +++ b/backend/api/v1/employees/views/employee_mixins/statistics.py @@ -38,7 +38,8 @@ class EmployeeStatisticsMixin: """Краткая статистика по активным сотрудникам для списка сотрудников.""" today = timezone.localdate() employees = list( - Employee.objects.visible_in_ui().filter(is_active=True) + Employee.objects.visible_in_ui() + .filter(is_active=True) .only( "id", "email", diff --git a/backend/api/v1/employees/views/groups.py b/backend/api/v1/employees/views/groups.py index d81c8f69..68f68ffb 100644 --- a/backend/api/v1/employees/views/groups.py +++ b/backend/api/v1/employees/views/groups.py @@ -119,9 +119,7 @@ class GroupViewSet(viewsets.ModelViewSet): grp._ldap_parent_dn = request.data.get("ldap_parent_dn") grp._ldap_description = request.data.get("ldap_description") grp._ldap_scope = request.data.get("ldap_scope", "global") - grp._ldap_security_enabled = bool( - request.data.get("ldap_security", True) - ) + grp._ldap_security_enabled = bool(request.data.get("ldap_security", True)) perms = ser.validated_data.get("permissions") grp.save() @@ -189,9 +187,7 @@ class GroupViewSet(viewsets.ModelViewSet): return Response( { "ok": True, - "permission_ids": list( - grp.permissions.values_list("id", flat=True) - ), + "permission_ids": list(grp.permissions.values_list("id", flat=True)), }, status=status.HTTP_200_OK, ) @@ -207,9 +203,7 @@ class GroupViewSet(viewsets.ModelViewSet): return Response( { "ok": True, - "permission_ids": list( - grp.permissions.values_list("id", flat=True) - ), + "permission_ids": list(grp.permissions.values_list("id", flat=True)), }, status=status.HTTP_200_OK, ) @@ -225,9 +219,7 @@ class GroupViewSet(viewsets.ModelViewSet): return Response( { "ok": True, - "permission_ids": list( - grp.permissions.values_list("id", flat=True) - ), + "permission_ids": list(grp.permissions.values_list("id", flat=True)), }, status=status.HTTP_200_OK, ) @@ -248,9 +240,7 @@ class GroupViewSet(viewsets.ModelViewSet): grp._ldap_old_name = grp.name grp.name = new_name grp.save(update_fields=["name"]) - return Response( - {"ok": True, "name": grp.name}, status=status.HTTP_200_OK - ) + return Response({"ok": True, "name": grp.name}, status=status.HTTP_200_OK) @action(detail=True, methods=["post"], url_path="set-description") def set_description(self, request, pk=None) -> Response: @@ -349,10 +339,10 @@ class GroupViewSet(viewsets.ModelViewSet): requested_ids = set(member_ids) existing_ids = set(grp.user_set.values_list("id", flat=True)) - users = Employee.objects.visible_in_ui(include_ids=existing_ids).filter( - id__in=requested_ids - ).filter( - Q(is_active=True) | Q(id__in=existing_ids) + users = ( + Employee.objects.visible_in_ui(include_ids=existing_ids) + .filter(id__in=requested_ids) + .filter(Q(is_active=True) | Q(id__in=existing_ids)) ) if requested_ids - set(users.values_list("id", flat=True)): return Response( diff --git a/backend/api/v1/employees/views/mixins.py b/backend/api/v1/employees/views/mixins.py index 49d53518..75d49743 100644 --- a/backend/api/v1/employees/views/mixins.py +++ b/backend/api/v1/employees/views/mixins.py @@ -6,10 +6,11 @@ import logging from typing import TYPE_CHECKING, Optional from django.conf import settings +from rest_framework.response import Response + from employees.ldap import UserService from employees.ldap.domain.dtos import DirectoryUserDTO from employees.ldap.errors import DirectoryDbError, DirectoryLdapError -from rest_framework.response import Response if TYPE_CHECKING: from employees.models import Employee diff --git a/backend/api/v1/employees/views/positions.py b/backend/api/v1/employees/views/positions.py index 4b76d238..7e7bcb57 100644 --- a/backend/api/v1/employees/views/positions.py +++ b/backend/api/v1/employees/views/positions.py @@ -4,12 +4,13 @@ from __future__ import annotations from django.contrib.auth.models import Group, Permission from django.db import transaction -from employees.models import Position from rest_framework import filters, viewsets from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response +from employees.models import Position + from ...permissions import AdminOrActionOrModelPerms from ..serializers import PositionSerializer from ._helpers import HistoryActionMixin diff --git a/backend/api/v1/employees/views/roles.py b/backend/api/v1/employees/views/roles.py index e1e79f80..43f0d926 100644 --- a/backend/api/v1/employees/views/roles.py +++ b/backend/api/v1/employees/views/roles.py @@ -2,6 +2,11 @@ from __future__ import annotations +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + from employees.models import ( DepartmentPermission, DepartmentRole, @@ -9,10 +14,6 @@ from employees.models import ( RoleAssignment, ) from employees.utils import _ensure_department_permissions -from rest_framework import status, viewsets -from rest_framework.decorators import action -from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response from ...permissions import AdminOrDeptAllowed from ..serializers import DepartmentRoleSerializer @@ -64,9 +65,7 @@ class DepartmentRoleViewSet(viewsets.ModelViewSet): ) ) employee_ids.update( - role.members.filter(is_active=True).values_list( - "employee_id", flat=True - ) + role.members.filter(is_active=True).values_list("employee_id", flat=True) ) return len(employee_ids) @@ -104,16 +103,16 @@ class DepartmentRoleViewSet(viewsets.ModelViewSet): ser.is_valid(raise_exception=True) self.perform_create(ser) role = ser.instance - return Response( - self.get_serializer(role).data, status=status.HTTP_201_CREATED - ) + return Response(self.get_serializer(role).data, status=status.HTTP_201_CREATED) def update(self, request, *args, **kwargs): """Обновление роли.""" partial = kwargs.pop("partial", False) instance = self.get_object() raw_department = request.data.get("department") - if raw_department is not None and str(raw_department) != str(instance.department_id): + if raw_department is not None and str(raw_department) != str( + instance.department_id + ): return Response( {"department": ["Роль нельзя перенести в другой отдел."]}, status=status.HTTP_400_BAD_REQUEST, @@ -215,13 +214,9 @@ class DepartmentRoleViewSet(viewsets.ModelViewSet): "id": a.id, "employee_id": a.employee_id, "employee_name": str(a.employee) if a.employee else None, - "assigned_at": a.assigned_at.isoformat() - if a.assigned_at - else None, + "assigned_at": a.assigned_at.isoformat() if a.assigned_at else None, "assigned_by_id": a.assigned_by_id, - "assigned_by_name": str(a.assigned_by) - if a.assigned_by - else None, + "assigned_by_name": str(a.assigned_by) if a.assigned_by else None, "is_active": a.is_active, } for a in qs.order_by("-assigned_at") diff --git a/backend/api/v1/employees/views/skills.py b/backend/api/v1/employees/views/skills.py index 9aa792ea..a3873ffc 100644 --- a/backend/api/v1/employees/views/skills.py +++ b/backend/api/v1/employees/views/skills.py @@ -3,13 +3,14 @@ from __future__ import annotations from django.db import transaction -from employees.models import EmployeeSkillProgress, Skill -from employees.services.skill_progress import skill_queryset_with_rarity from rest_framework import filters, viewsets from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response +from employees.models import EmployeeSkillProgress, Skill +from employees.services.skill_progress import skill_queryset_with_rarity + from ...permissions import AdminOrActionOrModelPerms from ..serializers import SkillSerializer from ._helpers import Employee diff --git a/backend/api/v1/feed/views.py b/backend/api/v1/feed/views.py index 698da8c4..9f6c0bf3 100644 --- a/backend/api/v1/feed/views.py +++ b/backend/api/v1/feed/views.py @@ -13,10 +13,6 @@ from django.db.models import ( Subquery, Value, ) -from employees.constants import DeptPerm -from feed.constants import TYPE_COMPANY, TYPE_DEPARTMENT -from feed.models import Post, PostLike -from feed.notifications import notify_post_reaction from rest_framework import filters, status, viewsets from rest_framework.decorators import action from rest_framework.parsers import ( @@ -27,12 +23,17 @@ from rest_framework.parsers import ( from rest_framework.permissions import IsAdminUser, IsAuthenticated from rest_framework.response import Response +from employees.constants import DeptPerm +from feed.constants import TYPE_COMPANY, TYPE_DEPARTMENT +from feed.models import Post, PostLike +from feed.notifications import notify_post_reaction + from ..permissions import ( AdminOrActionOrModelPerms, AdminOrDeptAllowed, can_publish_department_posts, ) -from .serializers import PostListSerializer, PostLikerSerializer, PostSerializer +from .serializers import PostLikerSerializer, PostListSerializer, PostSerializer Employee = get_user_model() @@ -112,8 +113,7 @@ class PostViewSet(viewsets.ModelViewSet): return False dept_id = _to_id( - request.data.get("department") - or request.data.get("department_id") + request.data.get("department") or request.data.get("department_id") ) if dept_id is None: # Даём сериализатору вернуть 400 по отсутствующему отделу. @@ -149,10 +149,7 @@ class PostViewSet(viewsets.ModelViewSet): ) pk = self.kwargs.get(lookup) info = ( - Post.objects.filter(pk=pk) - .values("type", "department_id") - .first() - or {} + Post.objects.filter(pk=pk).values("type", "department_id").first() or {} ) if info.get("type") == TYPE_COMPANY: return [IsAuthenticated(), AdminOrActionOrModelPerms()] @@ -179,7 +176,9 @@ class PostViewSet(viewsets.ModelViewSet): return context def _resolve_pin_scope(self, post: Post | None = None) -> str: - request_data = self.request.data if self.request.method not in {"GET", "HEAD"} else {} + request_data = ( + self.request.data if self.request.method not in {"GET", "HEAD"} else {} + ) scope = ( self.request.query_params.get("pin_scope") or self.request.query_params.get("scope") @@ -223,9 +222,7 @@ class PostViewSet(viewsets.ModelViewSet): if user.is_authenticated: qs = qs.annotate( - is_liked=Exists( - PostLike.objects.filter(post=OuterRef("pk"), user=user) - ) + is_liked=Exists(PostLike.objects.filter(post=OuterRef("pk"), user=user)) ) else: qs = qs.annotate(is_liked=Value(False, output_field=BooleanField())) @@ -288,7 +285,9 @@ class PostViewSet(viewsets.ModelViewSet): if scope == "department": if post.type != TYPE_DEPARTMENT or post.department_id is None: return Response( - {"detail": "Закрепление в ленте отдела доступно только для публикаций отдела."}, + { + "detail": "Закрепление в ленте отдела доступно только для публикаций отдела." + }, status=status.HTTP_400_BAD_REQUEST, ) if not post.pinned_department: @@ -321,7 +320,9 @@ class PostViewSet(viewsets.ModelViewSet): if scope == "department": if post.type != TYPE_DEPARTMENT or post.department_id is None: return Response( - {"detail": "Закрепление в ленте отдела доступно только для публикаций отдела."}, + { + "detail": "Закрепление в ленте отдела доступно только для публикаций отдела." + }, status=status.HTTP_400_BAD_REQUEST, ) if post.pinned_department: @@ -350,13 +351,9 @@ class PostViewSet(viewsets.ModelViewSet): def like(self, request, pk=None): """Ставит лайк текущего пользователя (идемпотентно).""" post = self.get_object() - _, created = PostLike.objects.get_or_create( - post=post, user=request.user - ) + _, created = PostLike.objects.get_or_create(post=post, user=request.user) if created: - Post.objects.filter(pk=post.pk).update( - likes_count=F("likes_count") + 1 - ) + Post.objects.filter(pk=post.pk).update(likes_count=F("likes_count") + 1) # Отправляем уведомление автору публикации notify_post_reaction(post, request.user) post.refresh_from_db(fields=["likes_count"]) @@ -369,9 +366,7 @@ class PostViewSet(viewsets.ModelViewSet): def unlike(self, request, pk=None): """Снимает лайк текущего пользователя (идемпотентно).""" post = self.get_object() - deleted, _ = PostLike.objects.filter( - post=post, user=request.user - ).delete() + deleted, _ = PostLike.objects.filter(post=post, user=request.user).delete() if deleted: Post.objects.filter(pk=post.pk, likes_count__gt=0).update( likes_count=F("likes_count") - 1 @@ -415,6 +410,7 @@ class PostViewSet(viewsets.ModelViewSet): POST: создание комментария {"text": "..."} """ from communications import comments_helpers + from ..employees.serializers import EmployeeBriefSerializer post = self.get_object() @@ -436,9 +432,7 @@ class PostViewSet(viewsets.ModelViewSet): "author_id": msg.author.id if msg.author else None, "text": msg.content, "created_at": msg.created_at, - "created_at_display": msg.created_at.strftime( - "%d.%m.%Y %H:%M" - ), + "created_at_display": msg.created_at.strftime("%d.%m.%Y %H:%M"), } ) diff --git a/backend/api/v1/finance/admin/__init__.py b/backend/api/v1/finance/admin/__init__.py index 6487c7ce..b8335781 100644 --- a/backend/api/v1/finance/admin/__init__.py +++ b/backend/api/v1/finance/admin/__init__.py @@ -39,8 +39,8 @@ from .periods import ( PayrollPeriodCarryoverListCreateView, PayrollPeriodPointBreakdownView, PayrollPeriodTableView, - PayrollTableReviewMarksView, PayrollStatementPaymentConfirmationView, + PayrollTableReviewMarksView, ) from .workflow import ( PayrollPeriodCalculateView, diff --git a/backend/api/v1/finance/admin/common.py b/backend/api/v1/finance/admin/common.py index 9aa2c411..86cadc7a 100644 --- a/backend/api/v1/finance/admin/common.py +++ b/backend/api/v1/finance/admin/common.py @@ -23,11 +23,11 @@ from finance.models import ( PayrollAuditEvent, PayrollPeriod, ) -from finance.payroll.components import get_payroll_component from finance.payroll.access import ( can_access_payroll_management, has_payroll_permission, ) +from finance.payroll.components import get_payroll_component from finance.payroll.exceptions import PayrollOperationError, PayrollPermissionDenied from finance.payroll.reporting import ( SYSTEM_COMPONENT_CODES, diff --git a/backend/api/v1/finance/admin/imports.py b/backend/api/v1/finance/admin/imports.py index 69e9dc72..c14d3295 100644 --- a/backend/api/v1/finance/admin/imports.py +++ b/backend/api/v1/finance/admin/imports.py @@ -7,7 +7,6 @@ access to payroll data. from __future__ import annotations - from django.shortcuts import get_object_or_404 from rest_framework.response import Response @@ -31,7 +30,6 @@ from ..admin_serializers import ( WorkbookWorkImportSerializer, WorkbookWorkPreviewSerializer, ) - from .common import ( PAYROLL_PERMISSIONS, PayrollAdminAPIView, diff --git a/backend/api/v1/finance/admin/input_copy.py b/backend/api/v1/finance/admin/input_copy.py index 48db2cfb..144219de 100644 --- a/backend/api/v1/finance/admin/input_copy.py +++ b/backend/api/v1/finance/admin/input_copy.py @@ -116,9 +116,7 @@ def _input_copy_configuration_payload(actor): def _input_copy_preview(target_period, source_period, *, actor): _validate_input_copy_periods(target_period, source_period) - lines = list( - _copyable_input_lines(source_period, target_period=target_period) - ) + lines = list(_copyable_input_lines(source_period, target_period=target_period)) component_rows = { component.code: { "component": component, @@ -240,9 +238,7 @@ def _apply_input_copy(*, target_period, source_period, actor, data): _copyable_input_lines( source_period, target_period=target_period, - ).filter( - component_code__in=data["component_codes"] - ) + ).filter(component_code__in=data["component_codes"]) ) if data["employee_scope"] == "selected": selected_ids = set(data["employee_ids"]) diff --git a/backend/api/v1/finance/admin/period_views/catalog.py b/backend/api/v1/finance/admin/period_views/catalog.py index a874333f..ca062466 100644 --- a/backend/api/v1/finance/admin/period_views/catalog.py +++ b/backend/api/v1/finance/admin/period_views/catalog.py @@ -51,7 +51,8 @@ class PayrollAdminEmployeeListView(PayrollAdminAPIView): def get(self, request): employees = ( - Employee.objects.visible_in_ui().filter(is_active=True) + Employee.objects.visible_in_ui() + .filter(is_active=True) .select_related("position") .prefetch_related("departments_links__department") .order_by("last_name", "first_name", "id") diff --git a/backend/api/v1/finance/admin/period_views/comments.py b/backend/api/v1/finance/admin/period_views/comments.py index 8cdcafe7..a8456ae5 100644 --- a/backend/api/v1/finance/admin/period_views/comments.py +++ b/backend/api/v1/finance/admin/period_views/comments.py @@ -43,9 +43,13 @@ class PayrollCellCommentsView(PayrollAdminAPIView): def get_context(self, pk, employee_id, column_id): period = get_object_or_404(PayrollPeriod, pk=pk) employee = get_object_or_404(Employee, pk=employee_id) - return period, employee, _validate_payroll_table_column( - column_id, - period=period, + return ( + period, + employee, + _validate_payroll_table_column( + column_id, + period=period, + ), ) def get(self, request, pk, employee_id, column_id): diff --git a/backend/api/v1/finance/admin/workflow.py b/backend/api/v1/finance/admin/workflow.py index 493bd662..cf0cdce4 100644 --- a/backend/api/v1/finance/admin/workflow.py +++ b/backend/api/v1/finance/admin/workflow.py @@ -7,7 +7,6 @@ access to payroll data. from __future__ import annotations - from django.db import transaction from django.http import Http404 from django.shortcuts import get_object_or_404 @@ -33,12 +32,11 @@ from finance.payroll.services import ( from ..admin_serializers import ( CalculatePayrollCommandSerializer, - PayrollPeriodSerializer, PayrollCompleteWorkflowSerializer, + PayrollPeriodSerializer, PayrollRunSerializer, ReturnPayrollCommandSerializer, ) - from .common import ( PAYROLL_PERMISSIONS, RUN_VIEW_PERMISSIONS, diff --git a/backend/api/v1/finance/admin/workspace.py b/backend/api/v1/finance/admin/workspace.py index 0caf00c7..fe9e1e77 100644 --- a/backend/api/v1/finance/admin/workspace.py +++ b/backend/api/v1/finance/admin/workspace.py @@ -169,10 +169,14 @@ class PayrollManagementAccessSettingsView(NoStorePayrollResponseMixin, APIView): selected_ids = list( finance_settings.management_users.values_list("id", flat=True) ) - candidates = Employee.objects.visible_in_ui(include_ids=selected_ids).filter( - Q(is_active=True) & (Q(is_staff=True) | Q(is_superuser=True)) - | Q(id__in=selected_ids) - ).order_by("last_name", "first_name", "id") + candidates = ( + Employee.objects.visible_in_ui(include_ids=selected_ids) + .filter( + Q(is_active=True) & (Q(is_staff=True) | Q(is_superuser=True)) + | Q(id__in=selected_ids) + ) + .order_by("last_name", "first_name", "id") + ) return { "restricted": bool(selected_ids), "management_user_ids": selected_ids, @@ -209,11 +213,13 @@ class PayrollManagementAccessSettingsView(NoStorePayrollResponseMixin, APIView): existing_ids = set( finance_settings.management_users.values_list("id", flat=True) ) - eligible_users = Employee.objects.visible_in_ui( - include_ids=existing_ids - ).filter(id__in=requested_ids).filter( - Q(id__in=existing_ids) - | (Q(is_active=True) & (Q(is_staff=True) | Q(is_superuser=True))) + eligible_users = ( + Employee.objects.visible_in_ui(include_ids=existing_ids) + .filter(id__in=requested_ids) + .filter( + Q(id__in=existing_ids) + | (Q(is_active=True) & (Q(is_staff=True) | Q(is_superuser=True))) + ) ) eligible_ids = set(eligible_users.values_list("id", flat=True)) if requested_ids - eligible_ids: diff --git a/backend/api/v1/finance/analytics.py b/backend/api/v1/finance/analytics.py index 66521885..1ff32c6e 100644 --- a/backend/api/v1/finance/analytics.py +++ b/backend/api/v1/finance/analytics.py @@ -25,6 +25,7 @@ from finance.payroll.access import ( has_simple_admin_access, ) from projects.models import Project, ProjectExternalBinding + from .views import NoStorePayrollResponseMixin diff --git a/backend/api/v1/finance/payroll_serializers/__init__.py b/backend/api/v1/finance/payroll_serializers/__init__.py index ef543773..ae8eef5c 100644 --- a/backend/api/v1/finance/payroll_serializers/__init__.py +++ b/backend/api/v1/finance/payroll_serializers/__init__.py @@ -49,8 +49,8 @@ from .presentation import ( PayrollPaymentConfirmationSerializer, PayrollPeriodDraftApprovalSerializer, PayrollPeriodTableSerializer, - PayrollTableReviewMarksSerializer, PayrollRunSerializer, + PayrollTableReviewMarksSerializer, ) from .work_records import ( PayrollWorkRecordRevisionSerializer, diff --git a/backend/api/v1/finance/payroll_serializers/input_lines.py b/backend/api/v1/finance/payroll_serializers/input_lines.py index ac369c4e..c4ffe5ae 100644 --- a/backend/api/v1/finance/payroll_serializers/input_lines.py +++ b/backend/api/v1/finance/payroll_serializers/input_lines.py @@ -17,6 +17,8 @@ from finance.models import ( PayrollInputLine, PayrollPeriod, ) +from finance.payroll.components import get_payroll_component + from .common import ( DraftWriteSerializerMixin, PayrollComponentCodeField, @@ -24,7 +26,6 @@ from .common import ( PayrollEmployeeMiniSerializer, PayrollUserRefSerializer, ) -from finance.payroll.components import get_payroll_component from .periods import PayrollCarryoverPeriodSerializer Employee = get_user_model() diff --git a/backend/api/v1/finance/serializers.py b/backend/api/v1/finance/serializers.py index fd83fb56..462f46ca 100644 --- a/backend/api/v1/finance/serializers.py +++ b/backend/api/v1/finance/serializers.py @@ -4,11 +4,11 @@ from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from finance.models import ( + PayrollDailyWorkEntry, PayrollStatement, PayrollStatementAcknowledgement, - PayrollStatementPaymentConfirmation, PayrollStatementLine, - PayrollDailyWorkEntry, + PayrollStatementPaymentConfirmation, PayrollWorkRecord, ) @@ -31,9 +31,9 @@ class PayrollStatementLineSerializer(serializers.ModelSerializer): ] def get_is_automatic_carryover(self, obj) -> bool: - return obj.source_ref.startswith("api:payroll-carryover:") or obj.source_ref.startswith( - "payroll-carryover:" - ) + return obj.source_ref.startswith( + "api:payroll-carryover:" + ) or obj.source_ref.startswith("payroll-carryover:") class PayrollStatementAcknowledgementSerializer(serializers.ModelSerializer): @@ -135,15 +135,11 @@ class OwnPayrollStatementSummarySerializer(serializers.ModelSerializer): def get_period(self, obj) -> dict[str, object]: return period_payload(obj) - @extend_schema_field( - PayrollStatementAcknowledgementSerializer(allow_null=True) - ) + @extend_schema_field(PayrollStatementAcknowledgementSerializer(allow_null=True)) def get_acknowledgement(self, obj) -> dict[str, object] | None: return acknowledgement_payload(obj) - @extend_schema_field( - PayrollStatementPaymentConfirmationSerializer(allow_null=True) - ) + @extend_schema_field(PayrollStatementPaymentConfirmationSerializer(allow_null=True)) def get_payment_confirmation(self, obj) -> dict[str, object] | None: return payment_confirmation_payload(obj) @@ -192,15 +188,11 @@ class OwnPayrollStatementSerializer(serializers.ModelSerializer): def get_period(self, obj) -> dict[str, object]: return period_payload(obj) - @extend_schema_field( - PayrollStatementAcknowledgementSerializer(allow_null=True) - ) + @extend_schema_field(PayrollStatementAcknowledgementSerializer(allow_null=True)) def get_acknowledgement(self, obj) -> dict[str, object] | None: return acknowledgement_payload(obj) - @extend_schema_field( - PayrollStatementPaymentConfirmationSerializer(allow_null=True) - ) + @extend_schema_field(PayrollStatementPaymentConfirmationSerializer(allow_null=True)) def get_payment_confirmation(self, obj) -> dict[str, object] | None: return payment_confirmation_payload(obj) diff --git a/backend/api/v1/finance/urls.py b/backend/api/v1/finance/urls.py index df509aca..7c146292 100644 --- a/backend/api/v1/finance/urls.py +++ b/backend/api/v1/finance/urls.py @@ -1,12 +1,59 @@ from django.urls import path + +from .admin import ( + EmployeePayRateApproveView, + EmployeePayRateDetailView, + EmployeePayRateListCreateView, + EmployeePayRateReviseView, + PayrollAdminComponentListView, + PayrollAdminEmployeeListView, + PayrollAdminPeriodDetailView, + PayrollAdminPeriodListCreateView, + PayrollAdminWorkspaceView, + PayrollAttendanceWorkRecordsView, + PayrollBulkPayRateView, + PayrollBulkPointRateView, + PayrollBulkTargetPointsView, + PayrollCarryoverDetailView, + PayrollCellCommentDetailView, + PayrollCellCommentsView, + PayrollComponentCellClearView, + PayrollComponentCellSetView, + PayrollInputHistoryView, + PayrollInputLineApproveView, + PayrollInputLineCarryoverInclusionView, + PayrollInputLineDetailView, + PayrollInputLineListCreateView, + PayrollInputLinePeriodCopyView, + PayrollManagementAccessSettingsView, + PayrollPeriodApproveDraftsView, + PayrollPeriodCalculateView, + PayrollPeriodCarryoverListCreateView, + PayrollPeriodCloseView, + PayrollPeriodCompleteWorkflowView, + PayrollPeriodPointBreakdownView, + PayrollPeriodTableView, + PayrollRunApproveView, + PayrollRunDetailView, + PayrollRunPublishView, + PayrollRunReturnView, + PayrollRunSubmitReviewView, + PayrollStatementPaymentConfirmationView, + PayrollTableReviewMarksView, + PayrollWorkbookWorkImportView, + PayrollWorkbookWorkPreviewView, + PayrollWorkRecordApproveView, + PayrollWorkRecordDetailView, + PayrollWorkRecordListCreateView, + PayrollWorkRecordReviseView, +) from .analytics import ( - ProjectPaymentListView, AnalyticsOptionsView, AnalyticsReportView, + ProjectPaymentListView, ReportingEntryListView, ReportingEntryVoidView, ) - from .views import ( OwnPayrollStatementAcknowledgeView, OwnPayrollStatementDetailView, @@ -14,53 +61,6 @@ from .views import ( OwnPayrollStatementPaymentConfirmationView, OwnPayrollWorkRecordView, ) -from .admin import ( - EmployeePayRateApproveView, - EmployeePayRateDetailView, - EmployeePayRateListCreateView, - EmployeePayRateReviseView, - PayrollBulkPayRateView, - PayrollBulkPointRateView, - PayrollBulkTargetPointsView, - PayrollCarryoverDetailView, - PayrollComponentCellClearView, - PayrollComponentCellSetView, - PayrollCellCommentDetailView, - PayrollCellCommentsView, - PayrollAdminComponentListView, - PayrollAdminEmployeeListView, - PayrollAdminPeriodDetailView, - PayrollAdminPeriodListCreateView, - PayrollAdminWorkspaceView, - PayrollManagementAccessSettingsView, - PayrollAttendanceWorkRecordsView, - PayrollInputLineApproveView, - PayrollInputLineCarryoverInclusionView, - PayrollInputLineDetailView, - PayrollInputLineListCreateView, - PayrollInputLinePeriodCopyView, - PayrollInputHistoryView, - PayrollPeriodCalculateView, - PayrollPeriodCarryoverListCreateView, - PayrollPeriodCompleteWorkflowView, - PayrollPeriodApproveDraftsView, - PayrollPeriodCloseView, - PayrollPeriodPointBreakdownView, - PayrollStatementPaymentConfirmationView, - PayrollPeriodTableView, - PayrollTableReviewMarksView, - PayrollRunApproveView, - PayrollRunDetailView, - PayrollRunPublishView, - PayrollRunReturnView, - PayrollRunSubmitReviewView, - PayrollWorkRecordApproveView, - PayrollWorkRecordDetailView, - PayrollWorkRecordListCreateView, - PayrollWorkRecordReviseView, - PayrollWorkbookWorkImportView, - PayrollWorkbookWorkPreviewView, -) app_name = "finance-payroll" diff --git a/backend/api/v1/finance/views.py b/backend/api/v1/finance/views.py index 7895a09c..e840174e 100644 --- a/backend/api/v1/finance/views.py +++ b/backend/api/v1/finance/views.py @@ -1,6 +1,6 @@ +from django.db import transaction from django.http import Http404 from django.shortcuts import get_object_or_404 -from django.db import transaction from django.utils import timezone from django.utils.cache import patch_vary_headers from rest_framework import generics, permissions, status @@ -15,8 +15,8 @@ from finance.models import ( PayrollPeriod, PayrollStatement, PayrollStatementPaymentConfirmation, - PayrollWorkSettings, PayrollWorkRecord, + PayrollWorkSettings, ) from finance.payroll.exceptions import PayrollOperationError from finance.payroll.services import ( @@ -31,9 +31,9 @@ from finance.payroll.work_norm import ( from .serializers import ( OwnPayrollDailyWorkEntrySerializer, OwnPayrollDailyWorkEntryWriteSerializer, + OwnPayrollPaymentConfirmationWriteSerializer, OwnPayrollStatementSerializer, OwnPayrollStatementSummarySerializer, - OwnPayrollPaymentConfirmationWriteSerializer, OwnPayrollWorkRecordSerializer, PayrollStatementAcknowledgementSerializer, PayrollStatementPaymentConfirmationSerializer, @@ -300,9 +300,7 @@ class OwnPayrollWorkRecordView( daily_target_points=daily_target_points, schedule=schedule, ) - uses_saved_target = ( - record is not None and record.target_points_overridden - ) + uses_saved_target = record is not None and record.target_points_overridden period_payloads.append( { "id": period.pk, @@ -329,9 +327,7 @@ class OwnPayrollWorkRecordView( else automatic_target ), "actual_points": str( - record.actual_points - if record is not None - else "0.0000" + record.actual_points if record is not None else "0.0000" ), "target_source": ( "saved_record" if uses_saved_target else schedule_source @@ -376,9 +372,7 @@ class OwnPayrollWorkRecordView( } ) from exc response_status = ( - status.HTTP_201_CREATED - if operation == "created" - else status.HTTP_200_OK + status.HTTP_201_CREATED if operation == "created" else status.HTTP_200_OK ) return Response( { diff --git a/backend/api/v1/procurement/action_serializers.py b/backend/api/v1/procurement/action_serializers.py index 12954f5f..757387e8 100644 --- a/backend/api/v1/procurement/action_serializers.py +++ b/backend/api/v1/procurement/action_serializers.py @@ -4,7 +4,6 @@ from rest_framework import serializers from employees.models import Department from procurement.models import EquipmentCategory - Employee = get_user_model() diff --git a/backend/api/v1/procurement/serializers/activity.py b/backend/api/v1/procurement/serializers/activity.py index 919c939a..a9ca092b 100644 --- a/backend/api/v1/procurement/serializers/activity.py +++ b/backend/api/v1/procurement/serializers/activity.py @@ -3,6 +3,7 @@ from rest_framework import serializers from procurement.models import ProcurementRequestActivity + from ...employees.serializers import EmployeeBriefSerializer diff --git a/backend/api/v1/procurement/serializers/approvals.py b/backend/api/v1/procurement/serializers/approvals.py index 9bdfc0a5..a7c60bbf 100644 --- a/backend/api/v1/procurement/serializers/approvals.py +++ b/backend/api/v1/procurement/serializers/approvals.py @@ -6,6 +6,7 @@ from rest_framework import serializers from procurement.constants import get_default_approval_step_name from procurement.models import Approval, ApprovalRoute + from ...employees.serializers import DepartmentBriefSerializer, EmployeeBriefSerializer User = get_user_model() diff --git a/backend/api/v1/procurement/serializers/equipment.py b/backend/api/v1/procurement/serializers/equipment.py index 5dd5f9d6..dcdec0c2 100644 --- a/backend/api/v1/procurement/serializers/equipment.py +++ b/backend/api/v1/procurement/serializers/equipment.py @@ -103,7 +103,11 @@ class EquipmentListSerializer(serializers.ModelSerializer): department_changed = ( "department" in attrs and department.id != self.instance.department_id ) - if responsible_changed and responsible is not None and not responsible.is_active: + if ( + responsible_changed + and responsible is not None + and not responsible.is_active + ): raise EquipmentPolicyValidationError( { "responsible_person": ( diff --git a/backend/api/v1/procurement/viewsets/equipment.py b/backend/api/v1/procurement/viewsets/equipment.py index 4dc289b6..9043cb43 100644 --- a/backend/api/v1/procurement/viewsets/equipment.py +++ b/backend/api/v1/procurement/viewsets/equipment.py @@ -3,13 +3,15 @@ from datetime import date from django.contrib.contenttypes.models import ContentType -from drf_spectacular.utils import OpenApiParameter, extend_schema from django.db.models import Q from django_filters.rest_framework import DjangoFilterBackend +from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework import filters, status, viewsets from rest_framework.decorators import action from rest_framework.response import Response +from communications import comments_helpers +from communications.models import Message from employees.models import Department from procurement.constants import ( EquipmentStatus, @@ -20,13 +22,12 @@ from procurement.models import ( EquipmentTransferLog, MaintenanceRecord, ) -from communications import comments_helpers -from communications.models import Message + +from ..equipment_policy import EquipmentCreationPolicy from ..permissions import ( CanManageEquipment, CanManageEquipmentCategory, ) -from ..equipment_policy import EquipmentCreationPolicy from ..serializers import ( EquipmentCategorySerializer, EquipmentDetailSerializer, @@ -93,7 +94,8 @@ class EquipmentViewSet(viewsets.ModelViewSet): def get_queryset(self): """Annotate comments_count.""" - from django.db.models import Count, Subquery, OuterRef, IntegerField + from django.db.models import Count, IntegerField, OuterRef, Subquery + from communications.models import Chat ct = ContentType.objects.get_for_model(Equipment) diff --git a/backend/api/v1/procurement/viewsets/mixins/item_attachments.py b/backend/api/v1/procurement/viewsets/mixins/item_attachments.py index 8b6577d7..20ca0ec2 100644 --- a/backend/api/v1/procurement/viewsets/mixins/item_attachments.py +++ b/backend/api/v1/procurement/viewsets/mixins/item_attachments.py @@ -9,13 +9,14 @@ from rest_framework.decorators import action from rest_framework.parsers import MultiPartParser from rest_framework.response import Response +from procurement.activity import create_procurement_activity +from procurement.media import ensure_procurement_attachment_thumbnail from procurement.models import ( - ProcurementItemAttachment, ProcurementActivityAction, ProcurementActivityObjectKind, + ProcurementItemAttachment, ) -from procurement.media import ensure_procurement_attachment_thumbnail -from procurement.activity import create_procurement_activity + from ...serializers import ( ProcurementItemAttachmentSerializer, ) diff --git a/backend/api/v1/procurement/viewsets/mixins/item_comments.py b/backend/api/v1/procurement/viewsets/mixins/item_comments.py index 68abeaa0..62de5e62 100644 --- a/backend/api/v1/procurement/viewsets/mixins/item_comments.py +++ b/backend/api/v1/procurement/viewsets/mixins/item_comments.py @@ -3,29 +3,30 @@ from pathlib import Path from django.db import transaction -from drf_spectacular.utils import OpenApiParameter, extend_schema from django.db.models import Prefetch +from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework import status from rest_framework.decorators import action from rest_framework.parsers import FormParser, JSONParser, MultiPartParser from rest_framework.response import Response +from communications import comments_helpers +from communications.models import Message +from procurement.activity import create_procurement_activity from procurement.models import ( - ProcurementItemAttachment, ProcurementActivityAction, ProcurementActivityObjectKind, + ProcurementItemAttachment, ) -from procurement.activity import create_procurement_activity from procurement.notifications.handlers import ( notify_item_comment, ) from procurement.notifications.read_service import ( ProcurementNotificationReadService, ) -from communications import comments_helpers -from communications.models import Message -from ....employees.serializers import EmployeeBriefSerializer from procurement.services import merge_comment_links + +from ....employees.serializers import EmployeeBriefSerializer from ...serializers import ( ProcurementItemAttachmentSerializer, ProcurementNotificationReadResponseSerializer, diff --git a/backend/api/v1/procurement/viewsets/mixins/item_equipment.py b/backend/api/v1/procurement/viewsets/mixins/item_equipment.py index f185434a..09dd875a 100644 --- a/backend/api/v1/procurement/viewsets/mixins/item_equipment.py +++ b/backend/api/v1/procurement/viewsets/mixins/item_equipment.py @@ -4,17 +4,18 @@ from rest_framework import status from rest_framework.decorators import action from rest_framework.response import Response +from procurement.application import ( + EquipmentCreationError, + ProcurementItemUnavailable, + create_equipment_from_procurement_item, +) from procurement.constants import ( ProcurementStatus, ) from procurement.models import ( Equipment, ) -from procurement.application import ( - EquipmentCreationError, - ProcurementItemUnavailable, - create_equipment_from_procurement_item, -) + from ...action_serializers import CreateEquipmentFromItemSerializer diff --git a/backend/api/v1/procurement/viewsets/mixins/request_comments.py b/backend/api/v1/procurement/viewsets/mixins/request_comments.py index 8803fbdd..652e26fc 100644 --- a/backend/api/v1/procurement/viewsets/mixins/request_comments.py +++ b/backend/api/v1/procurement/viewsets/mixins/request_comments.py @@ -5,16 +5,16 @@ from rest_framework import status from rest_framework.decorators import action from rest_framework.response import Response +from communications import comments_helpers +from communications.models import Message +from procurement.activity import create_procurement_activity from procurement.models import ( ProcurementActivityAction, ProcurementActivityObjectKind, ) -from procurement.activity import create_procurement_activity from procurement.notifications.handlers import ( notify_request_comment, ) -from communications import comments_helpers -from communications.models import Message class RequestCommentsMixin: diff --git a/backend/api/v1/procurement/viewsets/mixins/request_execution.py b/backend/api/v1/procurement/viewsets/mixins/request_execution.py index bf679ccf..9f7b9b48 100644 --- a/backend/api/v1/procurement/viewsets/mixins/request_execution.py +++ b/backend/api/v1/procurement/viewsets/mixins/request_execution.py @@ -1,35 +1,36 @@ """ViewSets для API модуля закупок.""" from django.contrib.contenttypes.models import ContentType -from drf_spectacular.utils import extend_schema from django.db.models import Q from django.utils import timezone +from drf_spectacular.utils import extend_schema from rest_framework import status from rest_framework.decorators import action from rest_framework.response import Response +from procurement.activity import create_procurement_activity from procurement.constants import ( - ProcurementStatus, ProcurementItemExecutionStatus, + ProcurementStatus, ) from procurement.models import ( - ProcurementItem, ProcurementActivityAction, ProcurementActivityObjectKind, + ProcurementItem, ProcurementRequest, ProcurementRequestView, ) -from procurement.activity import create_procurement_activity +from procurement.notifications.config import NotificationVerbs from procurement.notifications.handlers import ( notify_executor_reassigned, notify_item_updated, notify_request_arrival, ) -from procurement.notifications.config import NotificationVerbs from procurement.notifications.read_service import ( ProcurementNotificationReadService, ) from procurement.services import ProcurementApprovalResolver + from ...serializers import ( ProcurementNotificationReadResponseSerializer, ProcurementRequestListSerializer, diff --git a/backend/api/v1/procurement/viewsets/statistics.py b/backend/api/v1/procurement/viewsets/statistics.py index 06779ec5..570ea7d0 100644 --- a/backend/api/v1/procurement/viewsets/statistics.py +++ b/backend/api/v1/procurement/viewsets/statistics.py @@ -1,9 +1,9 @@ """ViewSets для API модуля закупок.""" -from drf_spectacular.utils import extend_schema from django.db.models import Q from django.utils import timezone from django_filters.rest_framework import DjangoFilterBackend +from drf_spectacular.utils import extend_schema from rest_framework import filters, permissions, status, viewsets from rest_framework.decorators import action from rest_framework.response import Response @@ -16,6 +16,7 @@ from procurement.models import ( Budget, ProcurementRequest, ) + from ..serializers import ( BudgetSerializer, ProcurementDepartmentStatsSerializer, @@ -36,7 +37,7 @@ class ProcurementStatsViewSet(viewsets.ViewSet): @action(detail=False, methods=["get"]) def overview(self, request): """Общая статистика закупок.""" - from django.db.models import Sum, Count + from django.db.models import Count, Sum from django.utils import timezone now = timezone.now() diff --git a/backend/api/v1/procurement/viewsets/suppliers.py b/backend/api/v1/procurement/viewsets/suppliers.py index 305feb51..bbf7422d 100644 --- a/backend/api/v1/procurement/viewsets/suppliers.py +++ b/backend/api/v1/procurement/viewsets/suppliers.py @@ -8,6 +8,7 @@ from rest_framework.response import Response from procurement.models import ( Supplier, ) + from ..permissions import ( CanManageSupplier, ) diff --git a/backend/api/v1/projects/serializers.py b/backend/api/v1/projects/serializers.py index e0194342..cbd94315 100644 --- a/backend/api/v1/projects/serializers.py +++ b/backend/api/v1/projects/serializers.py @@ -1,13 +1,15 @@ from decimal import Decimal + from django.contrib.auth import get_user_model from rest_framework import serializers + from commerce.models import SourceSystem from projects.models import ( Project, - ProjectMembership, - ProjectStage, ProjectExternalBinding, + ProjectMembership, ProjectResource, + ProjectStage, ) diff --git a/backend/api/v1/projects/urls.py b/backend/api/v1/projects/urls.py index d9cb7b6a..434b0ef8 100644 --- a/backend/api/v1/projects/urls.py +++ b/backend/api/v1/projects/urls.py @@ -1,6 +1,7 @@ from django.urls import path from rest_framework.routers import DefaultRouter -from .views import ProjectViewSet, OwnDayView, OwnCalendarView, ReminderView + +from .views import OwnCalendarView, OwnDayView, ProjectViewSet, ReminderView router = DefaultRouter() router.register("", ProjectViewSet, basename="local-project") diff --git a/backend/api/v1/projects/views.py b/backend/api/v1/projects/views.py index b37860a5..46d32403 100644 --- a/backend/api/v1/projects/views.py +++ b/backend/api/v1/projects/views.py @@ -1,9 +1,10 @@ from datetime import timedelta from decimal import Decimal + from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType -from django.db import transaction, IntegrityError -from django.db.models import Count, Exists, Max, Min, OuterRef, Sum, Q +from django.db import IntegrityError, transaction +from django.db.models import Count, Exists, Max, Min, OuterRef, Q, Sum from django.shortcuts import get_object_or_404 from django.utils import timezone from rest_framework import permissions, serializers, viewsets @@ -11,6 +12,7 @@ from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied, ValidationError from rest_framework.response import Response from rest_framework.views import APIView + from commerce.models import ( ExternalProject, ProjectPayment, @@ -19,29 +21,30 @@ from commerce.models import ( SourceSystem, ) from projects.models import ( - Project, - ProjectMembership, - ProjectExternalBinding, - ProjectEvent, - TimeAllocation, - DailyAllocation, AllocationReminder, + DailyAllocation, + Project, ProjectBoardLink, + ProjectEvent, + ProjectExternalBinding, + ProjectMembership, + TimeAllocation, ) -from projects.permissions import visible_projects, can_create, can_manage -from projects.services.time_tracking import day_data, save_day, claim_reminder +from projects.permissions import can_create, can_manage, visible_projects +from projects.services.time_tracking import claim_reminder, day_data, save_day from tasks.access import task_board_access_q from tasks.application import link_task_object, unlink_task_object from tasks.models import Task, TaskBoard, TaskLinkedObject, TaskLinkedObjectKind from tasks.presentation import linked_task_badges_for_object + from .serializers import ( - ProjectSerializer, - MembershipSerializer, - StageSerializer, BindingSerializer, - ResourceSerializer, DaySerializer, ExternalProjectImportSerializer, + MembershipSerializer, + ProjectSerializer, + ResourceSerializer, + StageSerializer, ) @@ -214,7 +217,9 @@ class ProjectViewSet(PrivateResponseMixin, viewsets.ModelViewSet): missing_from_source=False, ).aggregate(first=Min("occurred_at"), last=Max("occurred_at")) sales = Sale.objects.filter(project=external, counts_as_sale=True) - sales_period = sales.aggregate(first=Min("occurred_at"), last=Max("occurred_at")) + sales_period = sales.aggregate( + first=Min("occurred_at"), last=Max("occurred_at") + ) return Response( { "source": external.source_id, @@ -366,7 +371,9 @@ class ProjectViewSet(PrivateResponseMixin, viewsets.ModelViewSet): task_id = parse_id(request.data.get("task_id")) task = get_object_or_404( Task.objects.select_related("board", "column").filter( - board__in=TaskBoard.objects.filter(task_board_access_q(request.user)) + board__in=TaskBoard.objects.filter( + task_board_access_q(request.user) + ) ), pk=task_id, ) @@ -424,11 +431,15 @@ class ProjectViewSet(PrivateResponseMixin, viewsets.ModelViewSet): p = self.get_object() if request.method != "GET": p = self.require_manager() - accessible = TaskBoard.objects.filter( - is_archived=False - ).filter(task_board_access_q(request.user)).distinct() + accessible = ( + TaskBoard.objects.filter(is_archived=False) + .filter(task_board_access_q(request.user)) + .distinct() + ) if request.method == "POST": - board = get_object_or_404(accessible, pk=parse_id(request.data.get("board_id"))) + board = get_object_or_404( + accessible, pk=parse_id(request.data.get("board_id")) + ) _, created = ProjectBoardLink.objects.get_or_create( project=p, board=board, @@ -624,9 +635,9 @@ class ProjectViewSet(PrivateResponseMixin, viewsets.ModelViewSet): @action(detail=True, methods=["get"]) def finances(self, request, pk=None): - from finance.payroll.access import can_access_payroll_management from finance.analytics.labor import labor_summary from finance.analytics.projects import payment_queryset + from finance.payroll.access import can_access_payroll_management p = self.get_object() if not can_access_payroll_management(request.user): diff --git a/backend/api/v1/requests_app/request_serializers/__init__.py b/backend/api/v1/requests_app/request_serializers/__init__.py index 0e739155..81b962b0 100644 --- a/backend/api/v1/requests_app/request_serializers/__init__.py +++ b/backend/api/v1/requests_app/request_serializers/__init__.py @@ -1,10 +1,10 @@ from .fields import RecipientIDsField from .read import RequestProfileSummarySerializer, RequestReadSerializer -from .write import RequestWriteSerializer from .vacation import ( RequestVacationTermsReadSerializer, RequestVacationTermsWriteSerializer, ) +from .write import RequestWriteSerializer __all__ = [ "RecipientIDsField", diff --git a/backend/api/v1/requests_app/request_serializers/vacation.py b/backend/api/v1/requests_app/request_serializers/vacation.py index 4d9edabe..09bec640 100644 --- a/backend/api/v1/requests_app/request_serializers/vacation.py +++ b/backend/api/v1/requests_app/request_serializers/vacation.py @@ -1,6 +1,6 @@ -from rest_framework import serializers +from decimal import ROUND_HALF_UP, Decimal -from decimal import Decimal, ROUND_HALF_UP +from rest_framework import serializers from requests_app.models import RequestVacationTerms diff --git a/backend/api/v1/requests_app/request_serializers/write.py b/backend/api/v1/requests_app/request_serializers/write.py index 758386cc..0410be51 100644 --- a/backend/api/v1/requests_app/request_serializers/write.py +++ b/backend/api/v1/requests_app/request_serializers/write.py @@ -169,10 +169,10 @@ class RequestWriteSerializer( return existing_ids = set(relation.values_list("id", flat=True)) - users = User.objects.visible_in_ui(include_ids=existing_ids).filter( - id__in=recipient_ids - ).filter( - Q(is_active=True) | Q(id__in=existing_ids) + users = ( + User.objects.visible_in_ui(include_ids=existing_ids) + .filter(id__in=recipient_ids) + .filter(Q(is_active=True) | Q(id__in=existing_ids)) ) relation.set(users) diff --git a/backend/api/v1/requests_app/validators.py b/backend/api/v1/requests_app/validators.py index fe1145ff..1f26660f 100644 --- a/backend/api/v1/requests_app/validators.py +++ b/backend/api/v1/requests_app/validators.py @@ -6,8 +6,7 @@ from django.utils.translation import gettext_lazy as _ from rest_framework.exceptions import ValidationError # SHORT_DAY_OFF/VACATION/SICK_LEAVE/DAY_OFF/MATERNITY/TRANSFER/DISMISSAL/OTHER -from requests_app.enums import RequestType -from requests_app.enums import RequestStatus +from requests_app.enums import RequestStatus, RequestType class RequestDatesByTypeValidator: @@ -27,7 +26,12 @@ class RequestDatesByTypeValidator: def _is_draft_mode(self, serializer) -> bool: request = serializer.context.get("request") save_as = ( - (getattr(getattr(request, "query_params", None), "get", lambda *_: "")("save_as") or "") + ( + getattr(getattr(request, "query_params", None), "get", lambda *_: "")( + "save_as" + ) + or "" + ) .strip() .lower() ) @@ -84,9 +88,7 @@ class RequestDatesByTypeValidator: elif type_ in (RequestType.TRANSFER, RequestType.DISMISSAL): if touches_dates and not date_from: - raise ValidationError( - {"date_from": _("Требуется дата начала.")} - ) + raise ValidationError({"date_from": _("Требуется дата начала.")}) # OTHER — ограничений нет @@ -101,7 +103,12 @@ class RequestApproverNotEmployeeValidator: def _is_draft_mode(self, serializer) -> bool: request = serializer.context.get("request") save_as = ( - (getattr(getattr(request, "query_params", None), "get", lambda *_: "")("save_as") or "") + ( + getattr(getattr(request, "query_params", None), "get", lambda *_: "")( + "save_as" + ) + or "" + ) .strip() .lower() ) @@ -121,9 +128,7 @@ class RequestApproverNotEmployeeValidator: def eff(name: str) -> Any: if name in attrs: return attrs[name] - return ( - getattr(instance, name, None) if instance is not None else None - ) + return getattr(instance, name, None) if instance is not None else None employee = eff("employee") or getattr( serializer.context.get("request"), "user", None diff --git a/backend/api/v1/requests_app/views.py b/backend/api/v1/requests_app/views.py index dcbd5f50..181778df 100644 --- a/backend/api/v1/requests_app/views.py +++ b/backend/api/v1/requests_app/views.py @@ -8,8 +8,8 @@ from .viewsets import ( RequestCoreMixin, RequestNotificationsMixin, RequestStatisticsMixin, - RequestWorkflowMixin, RequestVacationMixin, + RequestWorkflowMixin, ) diff --git a/backend/api/v1/requests_app/viewsets/__init__.py b/backend/api/v1/requests_app/viewsets/__init__.py index e28cab75..0efc6002 100644 --- a/backend/api/v1/requests_app/viewsets/__init__.py +++ b/backend/api/v1/requests_app/viewsets/__init__.py @@ -2,8 +2,8 @@ from .comments import RequestCommentsMixin from .core import RequestCoreMixin from .notifications import RequestNotificationsMixin from .statistics import RequestStatisticsMixin -from .workflow import RequestWorkflowMixin from .vacation import RequestVacationMixin +from .workflow import RequestWorkflowMixin __all__ = [ "RequestCommentsMixin", diff --git a/backend/api/v1/requests_app/viewsets/vacation.py b/backend/api/v1/requests_app/viewsets/vacation.py index 25959d43..cd4b9d86 100644 --- a/backend/api/v1/requests_app/viewsets/vacation.py +++ b/backend/api/v1/requests_app/viewsets/vacation.py @@ -17,7 +17,6 @@ from ..serializers import ( RequestVacationTermsWriteSerializer, ) - VACATION_STATS_PERIODS = {"half_year", "year", "month", "all", "custom"} diff --git a/backend/api/v1/schedule/management/commands/create_test_calendar_users.py b/backend/api/v1/schedule/management/commands/create_test_calendar_users.py index 0428bca3..ed272c56 100644 --- a/backend/api/v1/schedule/management/commands/create_test_calendar_users.py +++ b/backend/api/v1/schedule/management/commands/create_test_calendar_users.py @@ -2,11 +2,12 @@ Management command для создания тестовых пользователей с календарями. """ -from django.core.management.base import BaseCommand -from django.contrib.auth import get_user_model -from schedule.models import Calendar, Event from datetime import datetime, timedelta + +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand from django.utils import timezone +from schedule.models import Calendar, Event User = get_user_model() @@ -15,9 +16,7 @@ class Command(BaseCommand): help = "Создание тестовых пользователей и календарей для системы schedule" def handle(self, *args, **options): - self.stdout.write( - "🚀 Создание тестовых пользователей и календарей...\n" - ) + self.stdout.write("🚀 Создание тестовых пользователей и календарей...\n") # Создание пользователей test_users = [ @@ -67,9 +66,9 @@ class Command(BaseCommand): else: self.stdout.write( self.style.WARNING( - f"ℹ️ Пользователь уже существует: { - user.get_full_name() - } ({user.username})" + f"ℹ️ Пользователь уже существует: {user.get_full_name()} ({ + user.username + })" ) ) created_users.append(user) @@ -112,9 +111,7 @@ class Command(BaseCommand): ) else: self.stdout.write( - self.style.WARNING( - f"ℹ️ Календарь уже существует: {calendar.name}" - ) + self.style.WARNING(f"ℹ️ Календарь уже существует: {calendar.name}") ) created_calendars.append(calendar) @@ -194,12 +191,8 @@ class Command(BaseCommand): { "calendar": created_calendars[2], "title": "Разработка нового модуля", - "start": datetime.combine( - today, datetime.min.time().replace(hour=10) - ), - "end": datetime.combine( - today, datetime.min.time().replace(hour=18) - ), + "start": datetime.combine(today, datetime.min.time().replace(hour=10)), + "end": datetime.combine(today, datetime.min.time().replace(hour=18)), "description": "Работа над функционалом", }, { @@ -296,9 +289,7 @@ class Command(BaseCommand): if created: self.stdout.write(f" ✅ {event.title} ({event.calendar.name})") else: - self.stdout.write( - f" ℹ️ Событие уже существует: {event.title}" - ) + self.stdout.write(f" ℹ️ Событие уже существует: {event.title}") self.stdout.write("\n" + "=" * 60) self.stdout.write(self.style.SUCCESS("✨ Готово! Создано:")) @@ -308,7 +299,5 @@ class Command(BaseCommand): self.stdout.write("=" * 60) self.stdout.write("\n🔑 Учетные данные для входа:") for user_data in test_users: - self.stdout.write( - f" {user_data['username']} / {user_data['password']}" - ) + self.stdout.write(f" {user_data['username']} / {user_data['password']}") self.stdout.write("\n") diff --git a/backend/api/v1/schedule/permissions.py b/backend/api/v1/schedule/permissions.py index 05ac6699..b6d66f0e 100644 --- a/backend/api/v1/schedule/permissions.py +++ b/backend/api/v1/schedule/permissions.py @@ -10,8 +10,8 @@ from scheduling.services import ( user_can_edit_calendar, user_can_edit_event, user_can_manage_calendar_notifications, - user_can_view_event, user_can_view_calendar, + user_can_view_event, ) @@ -120,4 +120,6 @@ class CanManageScheduleRule(permissions.BasePermission): "calendar", "calendar__binding" ) ) - return bool(events) and all(user_can_edit_event(user, event) for event in events) + return bool(events) and all( + user_can_edit_event(user, event) for event in events + ) diff --git a/backend/api/v1/schedule/schedule_serializers/calendars.py b/backend/api/v1/schedule/schedule_serializers/calendars.py index 2a0d5dcb..0460fa2a 100644 --- a/backend/api/v1/schedule/schedule_serializers/calendars.py +++ b/backend/api/v1/schedule/schedule_serializers/calendars.py @@ -70,16 +70,22 @@ class CalendarRelationSerializer(serializers.ModelSerializer): from django.contrib.auth import get_user_model User = get_user_model() - if not User.objects.visible_in_ui().filter( - id=value, - is_active=True, - ).exists(): + if ( + not User.objects.visible_in_ui() + .filter( + id=value, + is_active=True, + ) + .exists() + ): raise serializers.ValidationError("Выберите активного сотрудника.") return value def validate_distinction(self, value): if value not in {"owner", "editor", "viewer"}: - raise serializers.ValidationError("Выберите роль владельца, редактора или просмотра.") + raise serializers.ValidationError( + "Выберите роль владельца, редактора или просмотра." + ) return value def create(self, validated_data): diff --git a/backend/api/v1/schedule/tests.py b/backend/api/v1/schedule/tests.py index 7c7e7614..1160d271 100644 --- a/backend/api/v1/schedule/tests.py +++ b/backend/api/v1/schedule/tests.py @@ -2,13 +2,16 @@ """ Тесты для django-scheduler API endpoints. """ + +from datetime import timedelta + import pytest from django.contrib.contenttypes.models import ContentType from django.utils import timezone -from datetime import timedelta -from rest_framework.test import APIClient from rest_framework import status +from rest_framework.test import APIClient from schedule.models import Calendar, CalendarRelation, Event, Rule + from employees.models import Employee @@ -29,7 +32,7 @@ def test_user(db): username="testuser", first_name="Test", last_name="User", - send_activation_email=False # Не отправляем email в тестах + send_activation_email=False, # Не отправляем email в тестах ) return user @@ -44,10 +47,7 @@ def authenticated_client(api_client, test_user): @pytest.fixture def test_calendar(test_user): """Создание тестового календаря.""" - calendar = Calendar.objects.create( - name="Тестовый календарь", - slug="test-calendar" - ) + calendar = Calendar.objects.create(name="Тестовый календарь", slug="test-calendar") CalendarRelation.objects.create( calendar=calendar, content_type=ContentType.objects.get_for_model(Employee), @@ -69,70 +69,66 @@ def test_event(test_calendar, test_user): start=now, end=now + timedelta(hours=1), creator=test_user, - color_event="#3498db" + color_event="#3498db", ) # ==================== ТЕСТЫ КАЛЕНДАРЕЙ ==================== + @pytest.mark.django_db class TestCalendarAPI: """Тесты для Calendar ViewSet.""" def test_list_calendars_unauthorized(self, api_client): """Проверка: неавторизованный доступ запрещён.""" - response = api_client.get('/api/v1/schedule/calendars/') + response = api_client.get("/api/v1/schedule/calendars/") assert response.status_code == status.HTTP_401_UNAUTHORIZED def test_list_calendars(self, authenticated_client, test_calendar): """Проверка: список календарей возвращается.""" - response = authenticated_client.get('/api/v1/schedule/calendars/') + response = authenticated_client.get("/api/v1/schedule/calendars/") assert response.status_code == status.HTTP_200_OK # Проверяем что данные - это список - data = response.data if isinstance( - response.data, list) else response.data.get( - 'results', []) + data = ( + response.data + if isinstance(response.data, list) + else response.data.get("results", []) + ) assert len(data) > 0 # Проверяем структуру calendar_data = data[0] - assert calendar_data['name'] == test_calendar.name - assert calendar_data['slug'] == test_calendar.slug - assert 'events_count' in calendar_data + assert calendar_data["name"] == test_calendar.name + assert calendar_data["slug"] == test_calendar.slug + assert "events_count" in calendar_data def test_create_calendar(self, authenticated_client): """Проверка: создание календаря.""" - data = { - "name": "Новый календарь", - "slug": "new-calendar" - } + data = {"name": "Новый календарь", "slug": "new-calendar"} response = authenticated_client.post( - '/api/v1/schedule/calendars/', - data, - format='json' + "/api/v1/schedule/calendars/", data, format="json" ) assert response.status_code == status.HTTP_201_CREATED - assert response.data['name'] == data['name'] - assert response.data['slug'] == data['slug'] + assert response.data["name"] == data["name"] + assert response.data["slug"] == data["slug"] # Проверяем что календарь создался в БД - assert Calendar.objects.filter(slug='new-calendar').exists() + assert Calendar.objects.filter(slug="new-calendar").exists() def test_create_calendar_without_slug(self, authenticated_client): """Проверка: slug автогенерируется если не указан.""" data = {"name": "Календарь без slug"} response = authenticated_client.post( - '/api/v1/schedule/calendars/', - data, - format='json' + "/api/v1/schedule/calendars/", data, format="json" ) assert response.status_code == status.HTTP_201_CREATED - assert 'slug' in response.data - assert response.data['slug'] # slug не пустой + assert "slug" in response.data + assert response.data["slug"] # slug не пустой def test_create_calendar_with_title(self, authenticated_client): """Проверка: поддержка поля 'title' (EUSRR совместимость).""" @@ -140,45 +136,41 @@ class TestCalendarAPI: "title": "Календарь через title", } response = authenticated_client.post( - '/api/v1/schedule/calendars/', - data, - format='json' + "/api/v1/schedule/calendars/", data, format="json" ) assert response.status_code == status.HTTP_201_CREATED # title конвертируется в name - assert response.data['name'] == data['title'] or 'name' in response.data + assert response.data["name"] == data["title"] or "name" in response.data def test_get_calendar_detail(self, authenticated_client, test_calendar): """Проверка: получение деталей календаря.""" response = authenticated_client.get( - f'/api/v1/schedule/calendars/{test_calendar.id}/' + f"/api/v1/schedule/calendars/{test_calendar.id}/" ) assert response.status_code == status.HTTP_200_OK - assert response.data['id'] == test_calendar.id - assert response.data['name'] == test_calendar.name + assert response.data["id"] == test_calendar.id + assert response.data["name"] == test_calendar.name def test_update_calendar(self, authenticated_client, test_calendar): """Проверка: обновление календаря.""" data = {"name": "Обновлённое имя"} response = authenticated_client.patch( - f'/api/v1/schedule/calendars/{test_calendar.id}/', - data, - format='json' + f"/api/v1/schedule/calendars/{test_calendar.id}/", data, format="json" ) assert response.status_code == status.HTTP_200_OK - assert response.data['name'] == data['name'] + assert response.data["name"] == data["name"] test_calendar.refresh_from_db() - assert test_calendar.name == data['name'] + assert test_calendar.name == data["name"] def test_delete_calendar(self, authenticated_client, test_calendar): """Проверка: удаление календаря.""" calendar_id = test_calendar.id response = authenticated_client.delete( - f'/api/v1/schedule/calendars/{calendar_id}/' + f"/api/v1/schedule/calendars/{calendar_id}/" ) assert response.status_code == status.HTTP_204_NO_CONTENT @@ -195,27 +187,28 @@ class TestCalendarAPI: inheritable=True, ) - response = authenticated_client.get( - '/api/v1/schedule/calendars/?name=Первый' - ) + response = authenticated_client.get("/api/v1/schedule/calendars/?name=Первый") assert response.status_code == status.HTTP_200_OK - data = response.data if isinstance( - response.data, list) else response.data.get( - 'results', []) + data = ( + response.data + if isinstance(response.data, list) + else response.data.get("results", []) + ) assert len(data) == 1 - assert data[0]['name'] == "Первый" + assert data[0]["name"] == "Первый" # ==================== ТЕСТЫ СОБЫТИЙ ==================== + @pytest.mark.django_db class TestEventAPI: """Тесты для Event ViewSet.""" def test_list_events(self, authenticated_client, test_event): """Проверка: список событий.""" - response = authenticated_client.get('/api/v1/schedule/events/') + response = authenticated_client.get("/api/v1/schedule/events/") assert response.status_code == status.HTTP_200_OK assert len(response.data) > 0 @@ -229,74 +222,63 @@ class TestEventAPI: "description": "Описание события", "start": now.isoformat(), "end": (now + timedelta(hours=2)).isoformat(), - "color_event": "#ff5733" + "color_event": "#ff5733", } response = authenticated_client.post( - '/api/v1/schedule/events/', - data, - format='json' + "/api/v1/schedule/events/", data, format="json" ) assert response.status_code == status.HTTP_201_CREATED - assert response.data['title'] == data['title'] - assert response.data['calendar'] == test_calendar.id - assert 'creator' in response.data + assert response.data["title"] == data["title"] + assert response.data["calendar"] == test_calendar.id + assert "creator" in response.data def test_get_event_detail(self, authenticated_client, test_event): """Проверка: получение деталей события.""" - response = authenticated_client.get( - f'/api/v1/schedule/events/{test_event.id}/' - ) + response = authenticated_client.get(f"/api/v1/schedule/events/{test_event.id}/") assert response.status_code == status.HTTP_200_OK - assert response.data['id'] == test_event.id - assert response.data['title'] == test_event.title - assert response.data['calendar_name'] == test_event.calendar.name + assert response.data["id"] == test_event.id + assert response.data["title"] == test_event.title + assert response.data["calendar_name"] == test_event.calendar.name def test_update_event(self, authenticated_client, test_event): """Проверка: обновление события.""" - data = { - "title": "Обновлённое событие", - "description": "Новое описание" - } + data = {"title": "Обновлённое событие", "description": "Новое описание"} response = authenticated_client.patch( - f'/api/v1/schedule/events/{test_event.id}/', - data, - format='json' + f"/api/v1/schedule/events/{test_event.id}/", data, format="json" ) assert response.status_code == status.HTTP_200_OK - assert response.data['title'] == data['title'] + assert response.data["title"] == data["title"] test_event.refresh_from_db() - assert test_event.title == data['title'] + assert test_event.title == data["title"] def test_delete_event(self, authenticated_client, test_event): """Проверка: удаление события.""" event_id = test_event.id - response = authenticated_client.delete( - f'/api/v1/schedule/events/{event_id}/' - ) + response = authenticated_client.delete(f"/api/v1/schedule/events/{event_id}/") assert response.status_code == status.HTTP_204_NO_CONTENT assert not Event.objects.filter(id=event_id).exists() def test_filter_events_by_calendar( - self, - authenticated_client, - test_calendar, - test_event): + self, authenticated_client, test_calendar, test_event + ): """Проверка: фильтрация по календарю.""" response = authenticated_client.get( - f'/api/v1/schedule/events/?calendar={test_calendar.id}' + f"/api/v1/schedule/events/?calendar={test_calendar.id}" ) assert response.status_code == status.HTTP_200_OK - data = response.data if isinstance( - response.data, list) else response.data.get( - 'results', []) - assert all(e['calendar'] == test_calendar.id for e in data) + data = ( + response.data + if isinstance(response.data, list) + else response.data.get("results", []) + ) + assert all(e["calendar"] == test_calendar.id for e in data) def test_filter_events_by_date_range(self, authenticated_client, test_calendar): """Проверка: фильтрация по диапазону дат.""" @@ -327,19 +309,22 @@ class TestEventAPI: end = (now + timedelta(hours=2)).isoformat() response = authenticated_client.get( - '/api/v1/schedule/events/', + "/api/v1/schedule/events/", {"start": start, "end": end}, ) assert response.status_code == status.HTTP_200_OK - data = response.data if isinstance( - response.data, list) else response.data.get( - 'results', []) + data = ( + response.data + if isinstance(response.data, list) + else response.data.get("results", []) + ) assert len(data) == 1 - assert data[0]['title'] == "Сегодня" + assert data[0]["title"] == "Сегодня" def test_event_validation_end_before_start( - self, authenticated_client, test_calendar): + self, authenticated_client, test_calendar + ): """Проверка: валидация - конец не может быть раньше начала.""" now = timezone.now() data = { @@ -350,17 +335,16 @@ class TestEventAPI: } response = authenticated_client.post( - '/api/v1/schedule/events/', - data, - format='json' + "/api/v1/schedule/events/", data, format="json" ) assert response.status_code == status.HTTP_400_BAD_REQUEST - assert 'end' in response.data + assert "end" in response.data # ==================== ТЕСТЫ ПРАВИЛ (RRULE) ==================== + @pytest.mark.django_db class TestRuleAPI: """Тесты для Rule ViewSet.""" @@ -370,10 +354,10 @@ class TestRuleAPI: Rule.objects.create( name="Еженедельно", frequency="WEEKLY", - params={"byweekday": [0, 2, 4]} # Пн, Ср, Пт + params={"byweekday": [0, 2, 4]}, # Пн, Ср, Пт ) - response = authenticated_client.get('/api/v1/schedule/rules/') + response = authenticated_client.get("/api/v1/schedule/rules/") assert response.status_code == status.HTTP_200_OK assert len(response.data) > 0 @@ -384,22 +368,21 @@ class TestRuleAPI: "name": "Каждый день", "description": "Ежедневное повторение", "frequency": "DAILY", - "params": {} + "params": {}, } response = authenticated_client.post( - '/api/v1/schedule/rules/', - data, - format='json' + "/api/v1/schedule/rules/", data, format="json" ) assert response.status_code == status.HTTP_201_CREATED - assert response.data['name'] == data['name'] - assert response.data['frequency'] == "DAILY" + assert response.data["name"] == data["name"] + assert response.data["frequency"] == "DAILY" # ==================== ИНТЕГРАЦИОННЫЕ ТЕСТЫ ==================== + @pytest.mark.django_db class TestIntegration: """Интеграционные тесты полного workflow.""" @@ -408,65 +391,63 @@ class TestIntegration: """Проверка: полный цикл CRUD для календаря и события.""" # 1. Создаём календарь cal_response = authenticated_client.post( - '/api/v1/schedule/calendars/', - {"name": "Workflow Calendar"}, - format='json' + "/api/v1/schedule/calendars/", {"name": "Workflow Calendar"}, format="json" ) assert cal_response.status_code == status.HTTP_201_CREATED - calendar_id = cal_response.data['id'] + calendar_id = cal_response.data["id"] # 2. Создаём событие now = timezone.now() event_response = authenticated_client.post( - '/api/v1/schedule/events/', + "/api/v1/schedule/events/", { "calendar": calendar_id, "title": "Workflow Event", "start": now.isoformat(), "end": (now + timedelta(hours=1)).isoformat(), }, - format='json' + format="json", ) assert event_response.status_code == status.HTTP_201_CREATED - event_id = event_response.data['id'] + event_id = event_response.data["id"] # 3. Проверяем, что событие видно в календаре list_response = authenticated_client.get( - f'/api/v1/schedule/events/?calendar={calendar_id}' + f"/api/v1/schedule/events/?calendar={calendar_id}" ) assert list_response.status_code == status.HTTP_200_OK data = ( list_response.data if isinstance(list_response.data, list) - else list_response.data.get('results', []) + else list_response.data.get("results", []) ) assert len(data) == 1 # 4. Обновляем событие - update_response=authenticated_client.patch( - f'/api/v1/schedule/events/{event_id}/', + update_response = authenticated_client.patch( + f"/api/v1/schedule/events/{event_id}/", {"title": "Updated Event"}, - format='json' + format="json", ) assert update_response.status_code == status.HTTP_200_OK - assert update_response.data['title'] == "Updated Event" + assert update_response.data["title"] == "Updated Event" # 5. Удаляем событие - delete_event_response=authenticated_client.delete( - f'/api/v1/schedule/events/{event_id}/' + delete_event_response = authenticated_client.delete( + f"/api/v1/schedule/events/{event_id}/" ) assert delete_event_response.status_code == status.HTTP_204_NO_CONTENT # 6. Удаляем календарь - delete_cal_response=authenticated_client.delete( - f'/api/v1/schedule/calendars/{calendar_id}/' + delete_cal_response = authenticated_client.delete( + f"/api/v1/schedule/calendars/{calendar_id}/" ) assert delete_cal_response.status_code == status.HTTP_204_NO_CONTENT def test_events_count_in_calendar(self, authenticated_client, test_calendar): """Проверка: счётчик событий в календаре.""" # Создаём несколько событий - now=timezone.now() + now = timezone.now() for i in range(3): Event.objects.create( calendar=test_calendar, @@ -475,13 +456,13 @@ class TestIntegration: end=now + timedelta(hours=i + 1), ) - response=authenticated_client.get( - f'/api/v1/schedule/calendars/{test_calendar.id}/' + response = authenticated_client.get( + f"/api/v1/schedule/calendars/{test_calendar.id}/" ) assert response.status_code == status.HTTP_200_OK - assert response.data['events_count'] == 3 + assert response.data["events_count"] == 3 -if __name__ == '__main__': - pytest.main([__file__, '-v']) +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/backend/api/v1/schedule/viewsets/calendars.py b/backend/api/v1/schedule/viewsets/calendars.py index d8d793c9..d6af3bf8 100644 --- a/backend/api/v1/schedule/viewsets/calendars.py +++ b/backend/api/v1/schedule/viewsets/calendars.py @@ -345,11 +345,15 @@ class ScheduleCalendarViewSet(viewsets.ModelViewSet): ) if relation.distinction == "owner" and role != "owner": - other_owners_exist = CalendarRelation.objects.filter( - calendar=calendar, - content_type=user_ct, - distinction="owner", - ).exclude(object_id=user_id).exists() + other_owners_exist = ( + CalendarRelation.objects.filter( + calendar=calendar, + content_type=user_ct, + distinction="owner", + ) + .exclude(object_id=user_id) + .exists() + ) if not other_owners_exist: return Response( {"role": "У календаря должен остаться хотя бы один владелец."}, diff --git a/backend/api/v1/search/serializers.py b/backend/api/v1/search/serializers.py index 06a6d0db..17105e07 100644 --- a/backend/api/v1/search/serializers.py +++ b/backend/api/v1/search/serializers.py @@ -13,16 +13,11 @@ class SearchResultSerializer(serializers.Serializer): model_name = serializers.CharField( help_text=( - "Тип модели: post, employee, department, request, " - "chat, message, event" + "Тип модели: post, employee, department, request, chat, message, event" ) ) - object_id = serializers.JSONField( - help_text="ID объекта (число или UUID)" - ) - title = serializers.CharField( - help_text="Заголовок/имя объекта", allow_blank=True - ) + object_id = serializers.JSONField(help_text="ID объекта (число или UUID)") + title = serializers.CharField(help_text="Заголовок/имя объекта", allow_blank=True) description = serializers.CharField( help_text="Описание/превью контента", allow_blank=True, required=False ) diff --git a/backend/api/v1/search/views.py b/backend/api/v1/search/views.py index 6eb9072a..9e38c22a 100644 --- a/backend/api/v1/search/views.py +++ b/backend/api/v1/search/views.py @@ -121,9 +121,7 @@ def _format_employee(obj: Any) -> Dict[str, Any]: return { "model_name": "employee", "object_id": obj.pk, - "title": f"{obj.last_name} {obj.first_name} { - obj.patronymic or '' - }".strip(), + "title": f"{obj.last_name} {obj.first_name} {obj.patronymic or ''}".strip(), "description": getattr(obj.position, "name", "") if hasattr(obj, "position") else "", @@ -142,9 +140,7 @@ def _format_department(obj: Any) -> Dict[str, Any]: "object_id": obj.pk, "title": obj.name, "description": obj.description or "", - "meta": { - "head": str(obj.head) if hasattr(obj, "head") and obj.head else None - }, + "meta": {"head": str(obj.head) if hasattr(obj, "head") and obj.head else None}, } @@ -154,9 +150,7 @@ def _format_post(obj: Any) -> Dict[str, Any]: "model_name": "post", "object_id": obj.pk, "title": obj.title, - "description": obj.body[:200] + "..." - if len(obj.body) > 200 - else obj.body, + "description": obj.body[:200] + "..." if len(obj.body) > 200 else obj.body, "image": _user_avatar(getattr(obj, "author", None)), "meta": { "author": str(obj.author) if hasattr(obj, "author") else None, @@ -243,9 +237,7 @@ def _format_schedule_event(obj: Any) -> Dict[str, Any]: "description": obj.description or "", "meta": { "start": obj.start.isoformat() if hasattr(obj, "start") else None, - "end": obj.end.isoformat() - if hasattr(obj, "end") and obj.end - else None, + "end": obj.end.isoformat() if hasattr(obj, "end") and obj.end else None, }, } @@ -262,12 +254,8 @@ def _format_procurement_request(obj: Any) -> Dict[str, Any]: "image": _user_avatar(getattr(obj, "requestor", None)), "meta": { "status": obj.status if hasattr(obj, "status") else None, - "requestor": str(obj.requestor) - if hasattr(obj, "requestor") - else None, - "department": str(obj.department) - if hasattr(obj, "department") - else None, + "requestor": str(obj.requestor) if hasattr(obj, "requestor") else None, + "department": str(obj.department) if hasattr(obj, "department") else None, }, } @@ -607,9 +595,7 @@ def search_api_view(request: DRFRequest) -> Response: model_name = _get_model_name(obj) - if not _can_view_search_result( - request.user, obj, model_name, access_cache - ): + if not _can_view_search_result(request.user, obj, model_name, access_cache): hidden_counts[model_name] = hidden_counts.get(model_name, 0) + 1 continue diff --git a/backend/api/v1/tasks/serializers/boards.py b/backend/api/v1/tasks/serializers/boards.py index ec3269fb..ef562d8b 100644 --- a/backend/api/v1/tasks/serializers/boards.py +++ b/backend/api/v1/tasks/serializers/boards.py @@ -1,5 +1,5 @@ -from django.db.models import Count, Q from django.contrib.auth import get_user_model +from django.db.models import Count, Q from drf_spectacular.utils import extend_schema_field from rest_framework import serializers @@ -17,6 +17,7 @@ from tasks.models import ( TaskLabel, TaskUserSettings, ) + from .structure import ( TaskColumnRowSerializer, TaskColumnSerializer, @@ -24,7 +25,6 @@ from .structure import ( ) from .tasks import TaskSerializer - User = get_user_model() @@ -121,19 +121,21 @@ class TaskBoardSerializer(serializers.ModelSerializer): memberships = list( TaskBoardGroupMembership.objects.filter( board=obj, - ).filter( + ) + .filter( Q(group__user=user) | Q(group__is_shared=True), - ).exclude( + ) + .exclude( group__hidden_by=user, - ).select_related("group") + ) + .select_related("group") ) return memberships @extend_schema_field(serializers.ListField(child=serializers.IntegerField())) def get_group_ids(self, obj): return sorted( - membership.group_id - for membership in self._get_group_memberships(obj) + membership.group_id for membership in self._get_group_memberships(obj) ) @extend_schema_field(serializers.IntegerField(allow_null=True)) @@ -156,16 +158,12 @@ class TaskBoardSerializer(serializers.ModelSerializer): ) self._group_position_map_cache = position_map return { - str(membership.group_id): position_map[ - (membership.group_id, obj.id) - ] + str(membership.group_id): position_map[(membership.group_id, obj.id)] for membership in self._get_group_memberships(obj) if (membership.group_id, obj.id) in position_map } - @extend_schema_field( - serializers.DictField(child=serializers.CharField()) - ) + @extend_schema_field(serializers.DictField(child=serializers.CharField())) def get_group_sort_keys(self, obj): return { str(membership.group_id): membership.rank_key @@ -196,10 +194,7 @@ class TaskBoardSerializer(serializers.ModelSerializer): else set() ) if any( - ( - not member.is_active - or member.is_hidden_in_ui - ) + (not member.is_active or member.is_hidden_in_ui) and member.id not in existing_member_ids for member in attrs["members"] ): diff --git a/backend/api/v1/tasks/serializers/common.py b/backend/api/v1/tasks/serializers/common.py index ddaaf895..eff6542c 100644 --- a/backend/api/v1/tasks/serializers/common.py +++ b/backend/api/v1/tasks/serializers/common.py @@ -4,7 +4,6 @@ from rest_framework import serializers from tasks.access import user_can_access_task_board from tasks.models import TaskColumn - User = get_user_model() DEFAULT_COLUMNS = [ diff --git a/backend/api/v1/tasks/serializers/tasks.py b/backend/api/v1/tasks/serializers/tasks.py index 74c591f4..f59997b9 100644 --- a/backend/api/v1/tasks/serializers/tasks.py +++ b/backend/api/v1/tasks/serializers/tasks.py @@ -1,10 +1,11 @@ -from django.db.models import Max from django.contrib.auth import get_user_model +from django.db.models import Max from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from api.v1.employees.serializers import EmployeeBriefSerializer, SkillSerializer from communications.comments_helpers import get_comment_count +from employees.models import Skill from tasks.access import ( user_can_access_task_board, ) @@ -15,11 +16,10 @@ from tasks.models import ( TaskLabel, TaskLinkedObjectKind, ) -from employees.models import Skill + from .linked import TaskCoverSerializer from .structure import TaskLabelSerializer - User = get_user_model() diff --git a/backend/api/v1/tasks/viewsets/board_groups.py b/backend/api/v1/tasks/viewsets/board_groups.py index 663cae45..b6e27323 100644 --- a/backend/api/v1/tasks/viewsets/board_groups.py +++ b/backend/api/v1/tasks/viewsets/board_groups.py @@ -82,9 +82,7 @@ def _visible_neighbours_are_current( ) previous_index = visible_board_ids.index(previous_board_id) - return ( - next_board_id is None and previous_index == len(visible_board_ids) - 1 - ) or ( + return (next_board_id is None and previous_index == len(visible_board_ids) - 1) or ( next_board_id is not None and previous_index + 1 < len(visible_board_ids) and visible_board_ids[previous_index + 1] == next_board_id @@ -177,9 +175,7 @@ class TaskBoardGroupViewSet(viewsets.ModelViewSet): ) try: - board_ids = list( - dict.fromkeys(int(board_id) for board_id in raw_board_ids) - ) + board_ids = list(dict.fromkeys(int(board_id) for board_id in raw_board_ids)) except (TypeError, ValueError): return Response( {"boards": ["Идентификаторы досок должны быть числами."]}, @@ -215,9 +211,7 @@ class TaskBoardGroupViewSet(viewsets.ModelViewSet): ).values_list("board_id", flat=True) ) new_board_ids = [ - board_id - for board_id in board_ids - if board_id not in existing_board_ids + board_id for board_id in board_ids if board_id not in existing_board_ids ] new_rank_keys = allocate_group_tail_rank_keys( locked_group, @@ -256,8 +250,7 @@ class TaskBoardGroupViewSet(viewsets.ModelViewSet): for board_id in board_ids }, "rank_keys": { - str(board_id): rank_keys[board_id] - for board_id in board_ids + str(board_id): rank_keys[board_id] for board_id in board_ids }, } ) @@ -299,7 +292,11 @@ class TaskBoardGroupViewSet(viewsets.ModelViewSet): } if not requested_board_ids.issubset(memberships_by_board): return Response( - {"board": ["Порядок досок изменился. Обновите список и повторите перенос."]}, + { + "board": [ + "Порядок досок изменился. Обновите список и повторите перенос." + ] + }, status=status.HTTP_409_CONFLICT, ) moving_membership = memberships_by_board[board_id] @@ -316,7 +313,11 @@ class TaskBoardGroupViewSet(viewsets.ModelViewSet): next_board_id, ): return Response( - {"board": ["Порядок досок изменился. Обновите список и повторите перенос."]}, + { + "board": [ + "Порядок досок изменился. Обновите список и повторите перенос." + ] + }, status=status.HTTP_409_CONFLICT, ) diff --git a/backend/api/v1/tasks/viewsets/board_mixins/actions.py b/backend/api/v1/tasks/viewsets/board_mixins/actions.py index 0cc797dd..2600aa1d 100644 --- a/backend/api/v1/tasks/viewsets/board_mixins/actions.py +++ b/backend/api/v1/tasks/viewsets/board_mixins/actions.py @@ -39,10 +39,14 @@ class TaskBoardActionsMixin: def participant_candidates(self, request, pk=None): board = self.get_object() recipient_ids = get_task_board_recipient_ids(board) - candidates = Employee.objects.visible_in_ui().filter( - id__in=recipient_ids, - is_active=True, - ).order_by("last_name", "first_name", "id") + candidates = ( + Employee.objects.visible_in_ui() + .filter( + id__in=recipient_ids, + is_active=True, + ) + .order_by("last_name", "first_name", "id") + ) return Response( EmployeeBriefSerializer( candidates, @@ -191,9 +195,7 @@ class TaskBoardActionsMixin: "group_ids": group_ids, "group_id": group_ids[0] if group_ids else None, "group_positions": { - str(membership.group_id): position_map[ - (membership.group_id, board.id) - ] + str(membership.group_id): position_map[(membership.group_id, board.id)] for membership in memberships if (membership.group_id, board.id) in position_map }, diff --git a/backend/api/v1/tasks/viewsets/boards.py b/backend/api/v1/tasks/viewsets/boards.py index 45d8255b..849b31fe 100644 --- a/backend/api/v1/tasks/viewsets/boards.py +++ b/backend/api/v1/tasks/viewsets/boards.py @@ -25,7 +25,6 @@ from tasks.models import ( Task, TaskAttachment, TaskBoard, - TaskBoardGroup, TaskBoardGroupMembership, TaskBoardPlacement, TaskChecklistItem, diff --git a/backend/api/v1/tasks/viewsets/mixins/checklist.py b/backend/api/v1/tasks/viewsets/mixins/checklist.py index b4345d47..4fee04dc 100644 --- a/backend/api/v1/tasks/viewsets/mixins/checklist.py +++ b/backend/api/v1/tasks/viewsets/mixins/checklist.py @@ -12,9 +12,9 @@ from tasks.activity import create_task_activity from tasks.models import ( TaskActivityAction, TaskActivityObjectKind, + TaskChecklistItem, TaskCover, TaskCoverType, - TaskChecklistItem, TaskExternalLink, ) from tasks.realtime import send_task_board_update, send_task_checklist_update diff --git a/backend/api/v1/tasks/viewsets/mixins/comments.py b/backend/api/v1/tasks/viewsets/mixins/comments.py index 4735da8e..caa7f65b 100644 --- a/backend/api/v1/tasks/viewsets/mixins/comments.py +++ b/backend/api/v1/tasks/viewsets/mixins/comments.py @@ -2,12 +2,12 @@ from pathlib import Path from django.db import transaction from django.db.models import Prefetch -from communications import comments_helpers -from communications.models import Message from rest_framework import parsers, status from rest_framework.decorators import action from rest_framework.response import Response +from communications import comments_helpers +from communications.models import Message from tasks.activity import create_task_activity from tasks.comment_resources import add_comment_links_to_task from tasks.models import ( diff --git a/backend/api/v1/tasks/viewsets/mixins/linked_queries.py b/backend/api/v1/tasks/viewsets/mixins/linked_queries.py index 153200aa..a1002d69 100644 --- a/backend/api/v1/tasks/viewsets/mixins/linked_queries.py +++ b/backend/api/v1/tasks/viewsets/mixins/linked_queries.py @@ -1,15 +1,15 @@ from django.contrib.auth import get_user_model +from rest_framework import status +from rest_framework.decorators import action +from rest_framework.response import Response +from schedule.models import Event + from attendance.models import AttendanceRecord from documents.models import Document from feed.models import Post from guests.models import Guest, GuestVisit from procurement.models import ProcurementRequest from requests_app.models import Request as EmployeeRequest -from rest_framework import status -from rest_framework.decorators import action -from rest_framework.response import Response -from schedule.models import Event - from tasks.access import ( user_can_access_attendance_record, user_can_access_calendar_event, @@ -28,7 +28,6 @@ from tasks.presentation import linked_task_badges_for_object from ...serializers import TaskSerializer - Employee = get_user_model() diff --git a/backend/api/v1/tasks/viewsets/tasks.py b/backend/api/v1/tasks/viewsets/tasks.py index 35b772fe..1c7192de 100644 --- a/backend/api/v1/tasks/viewsets/tasks.py +++ b/backend/api/v1/tasks/viewsets/tasks.py @@ -25,15 +25,15 @@ from tasks.positioning import normalize_column_positions from tasks.queries import task_api_queryset from tasks.realtime import send_task_board_update -from .mixins.checklist import TaskChecklistAndLinksMixin -from .mixins.comments import TaskCommentsMixin -from .mixins.linked_resources import TaskLinkedResourcesMixin -from .mixins.linked_queries import TaskLinkedTaskQueriesMixin -from .mixins.media import TaskMediaMixin from ..serializers import ( TaskActivitySerializer, TaskSerializer, ) +from .mixins.checklist import TaskChecklistAndLinksMixin +from .mixins.comments import TaskCommentsMixin +from .mixins.linked_queries import TaskLinkedTaskQueriesMixin +from .mixins.linked_resources import TaskLinkedResourcesMixin +from .mixins.media import TaskMediaMixin Employee = get_user_model() diff --git a/backend/api/v1/urls.py b/backend/api/v1/urls.py index 5553adfb..3ae2daea 100644 --- a/backend/api/v1/urls.py +++ b/backend/api/v1/urls.py @@ -1,21 +1,29 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter -# django-scheduler ViewSets (проверенная библиотека для календаря) -from .schedule.views import ( - ScheduleCalendarViewSet, - ScheduleEventViewSet, - ScheduleRuleViewSet, - ScheduleOccurrenceViewSet, - ScheduleEventRelationViewSet, -) - # Communications ViewSets (перенесены в communications.api) from communications.api.viewsets import ChatViewSet, MessageViewSet, PollViewSet + +from .attendance.views import ( + AttendanceAutoSyncRunNowAPIView, + AttendanceAutoSyncSettingsAPIView, + AttendanceMonthlyMatrixAPIView, + AttendanceMonthlyMatrixExportAPIView, + AttendanceRecordCommentDetailAPIView, + AttendanceRecordCommentsAPIView, + AttendanceRecordDayEventPhotoAPIView, + AttendanceRecordDayEventsAPIView, + AttendanceRecordDetailAPIView, + AttendanceRecordListAPIView, + AttendanceWeeklySummaryAPIView, + EmployeeWorkScheduleAPIView, + LogStormAttendanceAnalyzeAPIView, + StandardWorkScheduleAPIView, +) from .documents.views import ( + DocumentTagViewSet, DocumentViewSet, FolderViewSet, - DocumentTagViewSet, ) from .employees.views import ( DepartmentRoleViewSet, @@ -28,23 +36,16 @@ from .employees.views import ( ) from .feed.views import PostViewSet from .requests_app.views import RequestViewSet -from .search.views import search_api_view -from .attendance.views import ( - AttendanceAutoSyncRunNowAPIView, - AttendanceAutoSyncSettingsAPIView, - AttendanceMonthlyMatrixExportAPIView, - AttendanceMonthlyMatrixAPIView, - AttendanceRecordDayEventPhotoAPIView, - AttendanceRecordDayEventsAPIView, - AttendanceRecordCommentDetailAPIView, - AttendanceRecordCommentsAPIView, - AttendanceRecordDetailAPIView, - AttendanceRecordListAPIView, - AttendanceWeeklySummaryAPIView, - EmployeeWorkScheduleAPIView, - LogStormAttendanceAnalyzeAPIView, - StandardWorkScheduleAPIView, + +# django-scheduler ViewSets (проверенная библиотека для календаря) +from .schedule.views import ( + ScheduleCalendarViewSet, + ScheduleEventRelationViewSet, + ScheduleEventViewSet, + ScheduleOccurrenceViewSet, + ScheduleRuleViewSet, ) +from .search.views import search_api_view app_name = "v1" @@ -56,12 +57,8 @@ router.register( ScheduleCalendarViewSet, basename="schedule-calendars", ) -router.register( - r"schedule/events", ScheduleEventViewSet, basename="schedule-events" -) -router.register( - r"schedule/rules", ScheduleRuleViewSet, basename="schedule-rules" -) +router.register(r"schedule/events", ScheduleEventViewSet, basename="schedule-events") +router.register(r"schedule/rules", ScheduleRuleViewSet, basename="schedule-rules") router.register( r"schedule/occurrences", ScheduleOccurrenceViewSet, diff --git a/backend/api/v1/utils.py b/backend/api/v1/utils.py index 000b76c1..df904ac1 100644 --- a/backend/api/v1/utils.py +++ b/backend/api/v1/utils.py @@ -1,6 +1,7 @@ """ Утилиты для работы с медиа URL """ + from django.conf import settings @@ -14,20 +15,20 @@ def get_media_base_url(request=None): 3. Fallback → /media/ """ # Если в настройках указан предпочтительный домен - use_https = getattr(settings, 'USE_HTTPS', False) - domain = getattr(settings, 'DOMAIN', None) + use_https = getattr(settings, "USE_HTTPS", False) + domain = getattr(settings, "DOMAIN", None) - if domain and domain not in ['127.0.0.1:9000', 'localhost:9000']: + if domain and domain not in ["127.0.0.1:9000", "localhost:9000"]: # Глобальный домен из настроек - scheme = 'https' if use_https else 'http' + scheme = "https" if use_https else "http" return f"{scheme}://{domain}/media/" # Используем домен из запроса (для локальной разработки) if request: - return request.build_absolute_uri('/media/') + return request.build_absolute_uri("/media/") # Fallback - return '/media/' + return "/media/" def build_media_url(file_field, request=None): @@ -45,20 +46,20 @@ def build_media_url(file_field, request=None): return None # Получаем относительный путь - if hasattr(file_field, 'url'): + if hasattr(file_field, "url"): relative_url = file_field.url else: relative_url = str(file_field) # Убираем /media/ если есть - if relative_url.startswith('/media/'): + if relative_url.startswith("/media/"): relative_url = relative_url[7:] - elif relative_url.startswith('/'): + elif relative_url.startswith("/"): relative_url = relative_url[1:] # Формируем полный URL base_url = get_media_base_url(request) - if not base_url.endswith('/'): - base_url += '/' + if not base_url.endswith("/"): + base_url += "/" return f"{base_url}{relative_url}" diff --git a/backend/attendance/migrations/0004_employeeworkschedule.py b/backend/attendance/migrations/0004_employeeworkschedule.py index 5c893414..d314539e 100644 --- a/backend/attendance/migrations/0004_employeeworkschedule.py +++ b/backend/attendance/migrations/0004_employeeworkschedule.py @@ -1,37 +1,97 @@ # Generated by Django 5.2.4 on 2026-04-22 09:27 -import django.db.models.deletion import datetime + +import django.db.models.deletion from django.conf import settings from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('attendance', '0003_attendancerecord_manual_edit'), + ("attendance", "0003_attendancerecord_manual_edit"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='EmployeeWorkSchedule', + name="EmployeeWorkSchedule", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('start_time', models.TimeField(default=datetime.time(8, 0), verbose_name='Начало рабочего дня')), - ('end_time', models.TimeField(default=datetime.time(17, 0), verbose_name='Конец рабочего дня')), - ('expected_hours', models.FloatField(default=9, verbose_name='Норма часов')), - ('workdays', models.JSONField(blank=True, default=list, verbose_name='Рабочие дни')), - ('date_overrides', models.JSONField(blank=True, default=list, verbose_name='Календарные исключения')), - ('is_active', models.BooleanField(default=True, verbose_name='Использовать в анализе')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')), - ('employee', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='work_schedule', to=settings.AUTH_USER_MODEL, verbose_name='Сотрудник')), - ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_work_schedules', to=settings.AUTH_USER_MODEL, verbose_name='Изменил')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "start_time", + models.TimeField( + default=datetime.time(8, 0), verbose_name="Начало рабочего дня" + ), + ), + ( + "end_time", + models.TimeField( + default=datetime.time(17, 0), verbose_name="Конец рабочего дня" + ), + ), + ( + "expected_hours", + models.FloatField(default=9, verbose_name="Норма часов"), + ), + ( + "workdays", + models.JSONField( + blank=True, default=list, verbose_name="Рабочие дни" + ), + ), + ( + "date_overrides", + models.JSONField( + blank=True, default=list, verbose_name="Календарные исключения" + ), + ), + ( + "is_active", + models.BooleanField( + default=True, verbose_name="Использовать в анализе" + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Создано"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="Обновлено"), + ), + ( + "employee", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="work_schedule", + to=settings.AUTH_USER_MODEL, + verbose_name="Сотрудник", + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="updated_work_schedules", + to=settings.AUTH_USER_MODEL, + verbose_name="Изменил", + ), + ), ], options={ - 'verbose_name': 'График работы сотрудника', - 'verbose_name_plural': 'Графики работы сотрудников', + "verbose_name": "График работы сотрудника", + "verbose_name_plural": "Графики работы сотрудников", }, ), ] diff --git a/backend/attendance/migrations/0005_standardworkschedule.py b/backend/attendance/migrations/0005_standardworkschedule.py index 8a16c6c3..09b0a1ba 100644 --- a/backend/attendance/migrations/0005_standardworkschedule.py +++ b/backend/attendance/migrations/0005_standardworkschedule.py @@ -1,13 +1,13 @@ # Generated by Codex on 2026-04-22 import datetime + import django.db.models.deletion from django.conf import settings from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("attendance", "0004_employeeworkschedule"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), diff --git a/backend/attendance/migrations/0006_attendanceautosyncsettings.py b/backend/attendance/migrations/0006_attendanceautosyncsettings.py index 972785e4..8bb9c4be 100644 --- a/backend/attendance/migrations/0006_attendanceautosyncsettings.py +++ b/backend/attendance/migrations/0006_attendanceautosyncsettings.py @@ -1,10 +1,9 @@ +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion class Migration(migrations.Migration): - dependencies = [ ("attendance", "0005_standardworkschedule"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), @@ -27,7 +26,10 @@ class Migration(migrations.Migration): "singleton", models.BooleanField(default=True, editable=False, unique=True), ), - ("enabled", models.BooleanField(default=False, verbose_name="Включено")), + ( + "enabled", + models.BooleanField(default=False, verbose_name="Включено"), + ), ( "frequency_minutes", models.PositiveIntegerField( diff --git a/backend/attendance/models.py b/backend/attendance/models.py index 343614a4..04380d76 100644 --- a/backend/attendance/models.py +++ b/backend/attendance/models.py @@ -1,6 +1,7 @@ +from datetime import time + from django.conf import settings from django.db import models -from datetime import time class AttendanceAnalysisRun(models.Model): diff --git a/backend/attendance/services.py b/backend/attendance/services.py index 9e836469..9d652ae1 100644 --- a/backend/attendance/services.py +++ b/backend/attendance/services.py @@ -1,9 +1,9 @@ from __future__ import annotations import logging -from io import BytesIO -from datetime import date, time, timedelta from calendar import monthrange +from datetime import date, time, timedelta +from io import BytesIO from typing import Any from django.conf import settings as django_settings @@ -12,12 +12,12 @@ from django.utils import timezone from django.utils.dateparse import parse_date from openpyxl import Workbook from openpyxl.comments import Comment -from openpyxl.styles import Alignment, Font, PatternFill, Side, Border +from openpyxl.styles import Alignment, Border, Font, PatternFill, Side from openpyxl.utils import get_column_letter from attendance.models import ( - AttendanceAutoSyncSettings, AttendanceAnalysisRun, + AttendanceAutoSyncSettings, AttendanceRecord, EmployeeWorkSchedule, StandardWorkSchedule, @@ -32,8 +32,8 @@ from employees.constants import ( ) from employees.models import Employee from employees.services.personnel_state import ( - EmployeePersonnelState, PERSONNEL_STATUS_NORMAL, + EmployeePersonnelState, approved_absence_minutes_within_schedule, resolve_employee_personnel_state, ) diff --git a/backend/commerce/admin.py b/backend/commerce/admin.py index cc307be2..007806c2 100644 --- a/backend/commerce/admin.py +++ b/backend/commerce/admin.py @@ -8,10 +8,6 @@ from django.views.decorators.debug import sensitive_post_parameters from .forms import IntegrationConnectionAdminForm from .integrations.moysklad import MoySkladError from .integrations.wildberries import WildberriesError -from .services import test_moysklad_connection, test_wildberries_connection -from .services.credentials import CredentialError -from .tasks import enqueue_connection_sync - from .models import ( Cost, ExternalProject, @@ -29,32 +25,73 @@ from .models import ( SourceSystem, StockSnapshot, ) +from .services import test_moysklad_connection, test_wildberries_connection +from .services.credentials import CredentialError +from .tasks import enqueue_connection_sync @admin.register(IntegrationConnection) class IntegrationConnectionAdmin(admin.ModelAdmin): form = IntegrationConnectionAdminForm list_display = [ - "provider", "source", "status", "account_name", "auto_sync", - "last_sync_finished_at", "next_sync_at", + "provider", + "source", + "status", + "account_name", + "auto_sync", + "last_sync_finished_at", + "next_sync_at", ] list_filter = ["provider", "status"] search_fields = ["source__name", "account_name"] readonly_fields = [ - "source", "status", "account_name", "last_error", "last_tested_at", - "last_sync_started_at", "last_sync_finished_at", "last_stats", "task_id", - "next_sync_at", "created_by", "created_at", "updated_at", + "source", + "status", + "account_name", + "last_error", + "last_tested_at", + "last_sync_started_at", + "last_sync_finished_at", + "last_stats", + "task_id", + "next_sync_at", + "created_by", + "created_at", + "updated_at", "credentials_configured", ] fieldsets = [ - ("Подключение", {"fields": ["provider", "source", "auth_type", "credentials_configured"]}), + ( + "Подключение", + {"fields": ["provider", "source", "auth_type", "credentials_configured"]}, + ), ("Реквизиты", {"fields": ["token", "login", "password"]}), - ("Расписание", {"fields": ["auto_sync", "sync_interval_minutes", "next_sync_at"]}), - ("Состояние", {"fields": [ - "status", "account_name", "last_error", "last_tested_at", - "last_sync_started_at", "last_sync_finished_at", "last_stats", "task_id", - ]}), - ("История", {"fields": ["created_by", "created_at", "updated_at"], "classes": ["collapse"]}), + ( + "Расписание", + {"fields": ["auto_sync", "sync_interval_minutes", "next_sync_at"]}, + ), + ( + "Состояние", + { + "fields": [ + "status", + "account_name", + "last_error", + "last_tested_at", + "last_sync_started_at", + "last_sync_finished_at", + "last_stats", + "task_id", + ] + }, + ), + ( + "История", + { + "fields": ["created_by", "created_at", "updated_at"], + "classes": ["collapse"], + }, + ), ] actions = ["test_connections", "sync_connections"] @@ -78,15 +115,22 @@ class IntegrationConnectionAdmin(admin.ModelAdmin): @admin.action(description="Проверить выбранные подключения", permissions=["change"]) def test_connections(self, request, queryset): for connection in queryset: - if connection.status in {IntegrationStatus.QUEUED, IntegrationStatus.SYNCING}: - self.message_user(request, "Дождитесь завершения синхронизации.", messages.WARNING) + if connection.status in { + IntegrationStatus.QUEUED, + IntegrationStatus.SYNCING, + }: + self.message_user( + request, "Дождитесь завершения синхронизации.", messages.WARNING + ) continue test = { "moysklad": test_moysklad_connection, "wildberries": test_wildberries_connection, }.get(connection.provider) if test is None: - self.message_user(request, "Этот сервис пока не поддерживается.", messages.ERROR) + self.message_user( + request, "Этот сервис пока не поддерживается.", messages.ERROR + ) continue try: context = test(connection) @@ -94,8 +138,19 @@ class IntegrationConnectionAdmin(admin.ModelAdmin): connection.status = IntegrationStatus.ERROR connection.last_error = str(exc) connection.last_tested_at = timezone.now() - connection.save(update_fields=["status", "last_error", "last_tested_at", "updated_at"]) - self.message_user(request, f"{connection.get_provider_display()}: {exc}", messages.ERROR) + connection.save( + update_fields=[ + "status", + "last_error", + "last_tested_at", + "updated_at", + ] + ) + self.message_user( + request, + f"{connection.get_provider_display()}: {exc}", + messages.ERROR, + ) continue connection.status = IntegrationStatus.READY connection.account_name = context["account_name"] @@ -103,35 +158,59 @@ class IntegrationConnectionAdmin(admin.ModelAdmin): connection.last_tested_at = timezone.now() connection.next_sync_at = ( timezone.now() + timedelta(minutes=connection.sync_interval_minutes) - if connection.auto_sync else None + if connection.auto_sync + else None ) connection.source.metadata = { - **connection.source.metadata, "account_id": context["account_id"], + **connection.source.metadata, + "account_id": context["account_id"], } if context.get("tin"): connection.source.metadata["tin"] = context["tin"] connection.source.save(update_fields=["metadata", "updated_at"]) - connection.save(update_fields=[ - "status", "account_name", "last_error", "last_tested_at", "next_sync_at", "updated_at", - ]) - self.message_user(request, f"{connection.get_provider_display()}: подключение проверено.") + connection.save( + update_fields=[ + "status", + "account_name", + "last_error", + "last_tested_at", + "next_sync_at", + "updated_at", + ] + ) + self.message_user( + request, f"{connection.get_provider_display()}: подключение проверено." + ) - @admin.action(description="Синхронизировать выбранные подключения", permissions=["change"]) + @admin.action( + description="Синхронизировать выбранные подключения", permissions=["change"] + ) def sync_connections(self, request, queryset): for connection in queryset: if not connection.encrypted_credentials: - self.message_user(request, "Сначала сохраните реквизиты подключения.", messages.ERROR) + self.message_user( + request, "Сначала сохраните реквизиты подключения.", messages.ERROR + ) continue try: run = enqueue_connection_sync(connection) except Exception: # Публикация задачи уже сохраняет ошибку в подключении и журнале. - self.message_user(request, "Не удалось запустить синхронизацию. Проверьте историю запусков.", messages.ERROR) + self.message_user( + request, + "Не удалось запустить синхронизацию. Проверьте историю запусков.", + messages.ERROR, + ) continue if run is None: - self.message_user(request, "Синхронизация уже выполняется.", messages.WARNING) + self.message_user( + request, "Синхронизация уже выполняется.", messages.WARNING + ) else: - self.message_user(request, f"{connection.get_provider_display()}: синхронизация поставлена в очередь.") + self.message_user( + request, + f"{connection.get_provider_display()}: синхронизация поставлена в очередь.", + ) class ImportedDataAdmin(admin.ModelAdmin): @@ -173,9 +252,29 @@ class ExternalProjectAdmin(ImportedDataAdmin): @admin.register(ProjectPayment) class ProjectPaymentAdmin(ImportedDataAdmin): - list_display = ["number", "document_type", "project", "amount", "currency", "occurred_at", "applicable"] - list_filter = ["source", "document_type", "currency", "applicable", "missing_from_source"] - search_fields = ["number", "external_id", "project__name", "payee", "expense_category"] + list_display = [ + "number", + "document_type", + "project", + "amount", + "currency", + "occurred_at", + "applicable", + ] + list_filter = [ + "source", + "document_type", + "currency", + "applicable", + "missing_from_source", + ] + search_fields = [ + "number", + "external_id", + "project__name", + "payee", + "expense_category", + ] list_select_related = ["project", "source"] diff --git a/backend/commerce/apps.py b/backend/commerce/apps.py index 5af035be..da27bf52 100644 --- a/backend/commerce/apps.py +++ b/backend/commerce/apps.py @@ -7,5 +7,7 @@ class CommerceConfig(AppConfig): verbose_name = "Товарная аналитика" def ready(self): - from . import search # noqa: F401 - from . import signals # noqa: F401 + from . import ( + search, # noqa: F401 + signals, # noqa: F401 + ) diff --git a/backend/commerce/forms.py b/backend/commerce/forms.py index 91d34893..d9295c59 100644 --- a/backend/commerce/forms.py +++ b/backend/commerce/forms.py @@ -9,13 +9,16 @@ from commerce.services.credentials import encrypt_credentials class IntegrationConnectionAdminForm(forms.ModelForm): token = forms.CharField( - label="Новый API-токен", required=False, + label="Новый API-токен", + required=False, widget=forms.PasswordInput(render_value=False), help_text="Оставьте пустым, чтобы сохранить текущие реквизиты.", ) login = forms.CharField(label="Новый API-логин", required=False) password = forms.CharField( - label="Новый пароль", required=False, strip=False, + label="Новый пароль", + required=False, + strip=False, widget=forms.PasswordInput(render_value=False), help_text="Для смены реквизитов укажите и логин, и пароль.", ) @@ -23,8 +26,13 @@ class IntegrationConnectionAdminForm(forms.ModelForm): class Meta: model = IntegrationConnection fields = [ - "provider", "auth_type", "token", "login", "password", - "auto_sync", "sync_interval_minutes", + "provider", + "auth_type", + "token", + "login", + "password", + "auto_sync", + "sync_interval_minutes", ] def __init__(self, *args, **kwargs): @@ -34,7 +42,10 @@ class IntegrationConnectionAdminForm(forms.ModelForm): def clean(self): data = super().clean() - if self.instance.status in {IntegrationStatus.QUEUED, IntegrationStatus.SYNCING}: + if self.instance.status in { + IntegrationStatus.QUEUED, + IntegrationStatus.SYNCING, + }: raise forms.ValidationError("Дождитесь завершения текущей синхронизации.") auth_type = data.get("auth_type") if data.get("provider") == "wildberries" and auth_type != "token": @@ -49,7 +60,9 @@ class IntegrationConnectionAdminForm(forms.ModelForm): keys = ("token",) if auth_type == "token" else ("login", "password") for key in keys: if not data.get(key): - self.add_error(key, "Укажите реквизиты выбранного способа авторизации.") + self.add_error( + key, "Укажите реквизиты выбранного способа авторизации." + ) if all(data.get(key) for key in keys): self.new_credentials = {key: data[key] for key in keys} return data @@ -64,7 +77,8 @@ class IntegrationConnectionAdminForm(forms.ModelForm): if {"auto_sync", "sync_interval_minutes"}.intersection(self.changed_data): connection.next_sync_at = ( timezone.now() + timedelta(minutes=connection.sync_interval_minutes) - if connection.auto_sync else None + if connection.auto_sync + else None ) if commit: connection.save() diff --git a/backend/commerce/integrations/moysklad.py b/backend/commerce/integrations/moysklad.py index cef1a681..7ee1c6f9 100644 --- a/backend/commerce/integrations/moysklad.py +++ b/backend/commerce/integrations/moysklad.py @@ -1,15 +1,14 @@ -from collections.abc import Iterator -from dataclasses import dataclass import logging import re import time +from collections.abc import Iterator +from dataclasses import dataclass from typing import Any from urllib.parse import urlparse import httpx from django.conf import settings - logger = logging.getLogger(__name__) diff --git a/backend/commerce/management/commands/repair_moysklad_data.py b/backend/commerce/management/commands/repair_moysklad_data.py index 9000c5f1..22539c07 100644 --- a/backend/commerce/management/commands/repair_moysklad_data.py +++ b/backend/commerce/management/commands/repair_moysklad_data.py @@ -1,5 +1,5 @@ -from datetime import timedelta import time +from datetime import timedelta from django.core.management.base import BaseCommand, CommandError from django.db import OperationalError, close_old_connections, transaction @@ -9,8 +9,8 @@ from commerce.integrations.moysklad import MoySkladClient, MoySkladError from commerce.media import sync_moysklad_product_images from commerce.models import ( IntegrationConnection, - IntegrationSyncRun, IntegrationStatus, + IntegrationSyncRun, ProductBinding, ProductImage, Sale, @@ -149,10 +149,14 @@ class Command(BaseCommand): entity_type__in=["demand", "salesreturn"], ).only("entity_type", "external_id", "payload"): channel = _document_channel(record.payload, channel_names) - updated_channels += Sale.objects.filter( - source=source, - external_id=f"{record.entity_type}:{record.external_id}", - ).exclude(channel=channel).update(channel=channel) + updated_channels += ( + Sale.objects.filter( + source=source, + external_id=f"{record.entity_type}:{record.external_id}", + ) + .exclude(channel=channel) + .update(channel=channel) + ) for row in image_rows: try: diff --git a/backend/commerce/media.py b/backend/commerce/media.py index 93ea4ed2..801ba8bf 100644 --- a/backend/commerce/media.py +++ b/backend/commerce/media.py @@ -4,12 +4,12 @@ import hashlib import io import logging import math -from pathlib import Path import shutil import subprocess import tempfile -from typing import TYPE_CHECKING, Callable import warnings +from pathlib import Path +from typing import TYPE_CHECKING, Callable from celery.exceptions import SoftTimeLimitExceeded from dateutil import parser as date_parser diff --git a/backend/commerce/migrations/0002_integrationconnection_integrationsyncrun.py b/backend/commerce/migrations/0002_integrationconnection_integrationsyncrun.py index 858d7f1a..2439ed92 100644 --- a/backend/commerce/migrations/0002_integrationconnection_integrationsyncrun.py +++ b/backend/commerce/migrations/0002_integrationconnection_integrationsyncrun.py @@ -1,7 +1,8 @@ # Generated by Django 5.2.4 on 2026-08-17 16:08 -import django.db.models.deletion import uuid + +import django.db.models.deletion from django.conf import settings from django.db import migrations, models diff --git a/backend/commerce/migrations/0003_integrationconnection_auto_sync_and_more.py b/backend/commerce/migrations/0003_integrationconnection_auto_sync_and_more.py index f3960777..6ad543af 100644 --- a/backend/commerce/migrations/0003_integrationconnection_auto_sync_and_more.py +++ b/backend/commerce/migrations/0003_integrationconnection_auto_sync_and_more.py @@ -5,25 +5,38 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('commerce', '0002_integrationconnection_integrationsyncrun'), + ("commerce", "0002_integrationconnection_integrationsyncrun"), ] operations = [ migrations.AddField( - model_name='integrationconnection', - name='auto_sync', - field=models.BooleanField(default=True, verbose_name='Автоматическая синхронизация'), + model_name="integrationconnection", + name="auto_sync", + field=models.BooleanField( + default=True, verbose_name="Автоматическая синхронизация" + ), ), migrations.AddField( - model_name='integrationconnection', - name='next_sync_at', - field=models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='Следующая синхронизация'), + model_name="integrationconnection", + name="next_sync_at", + field=models.DateTimeField( + blank=True, + db_index=True, + null=True, + verbose_name="Следующая синхронизация", + ), ), migrations.AddField( - model_name='integrationconnection', - name='sync_interval_minutes', - field=models.PositiveIntegerField(default=15, validators=[django.core.validators.MinValueValidator(5), django.core.validators.MaxValueValidator(1440)], verbose_name='Интервал синхронизации, минут'), + model_name="integrationconnection", + name="sync_interval_minutes", + field=models.PositiveIntegerField( + default=15, + validators=[ + django.core.validators.MinValueValidator(5), + django.core.validators.MaxValueValidator(1440), + ], + verbose_name="Интервал синхронизации, минут", + ), ), ] diff --git a/backend/commerce/migrations/0004_productimage_integrationsyncrun_progress.py b/backend/commerce/migrations/0004_productimage_integrationsyncrun_progress.py index 95b6cc73..f8fae1dc 100644 --- a/backend/commerce/migrations/0004_productimage_integrationsyncrun_progress.py +++ b/backend/commerce/migrations/0004_productimage_integrationsyncrun_progress.py @@ -1,13 +1,14 @@ # Generated by Django 5.2.4 on 2026-08-17 -import commerce.models.product -import django.db.models.deletion import uuid + +import django.db.models.deletion from django.db import migrations, models +import commerce.models.product + class Migration(migrations.Migration): - dependencies = [ ("commerce", "0003_integrationconnection_auto_sync_and_more"), ] @@ -38,7 +39,10 @@ class Migration(migrations.Migration): serialize=False, ), ), - ("external_id", models.CharField(max_length=255, verbose_name="Внешний ID")), + ( + "external_id", + models.CharField(max_length=255, verbose_name="Внешний ID"), + ), ( "file", models.ImageField( @@ -54,14 +58,46 @@ class Migration(migrations.Migration): verbose_name="Миниатюра", ), ), - ("filename", models.CharField(blank=True, max_length=255, verbose_name="Имя файла")), - ("title", models.CharField(blank=True, max_length=255, verbose_name="Название")), - ("source_size", models.PositiveBigIntegerField(default=0, verbose_name="Размер в источнике")), - ("source_updated_at", models.DateTimeField(blank=True, null=True, verbose_name="Изменено в источнике")), - ("position", models.PositiveIntegerField(default=0, verbose_name="Позиция")), - ("is_primary", models.BooleanField(default=False, verbose_name="Основное")), - ("created_at", models.DateTimeField(auto_now_add=True, verbose_name="Создано")), - ("updated_at", models.DateTimeField(auto_now=True, verbose_name="Обновлено")), + ( + "filename", + models.CharField( + blank=True, max_length=255, verbose_name="Имя файла" + ), + ), + ( + "title", + models.CharField( + blank=True, max_length=255, verbose_name="Название" + ), + ), + ( + "source_size", + models.PositiveBigIntegerField( + default=0, verbose_name="Размер в источнике" + ), + ), + ( + "source_updated_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Изменено в источнике" + ), + ), + ( + "position", + models.PositiveIntegerField(default=0, verbose_name="Позиция"), + ), + ( + "is_primary", + models.BooleanField(default=False, verbose_name="Основное"), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Создано"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="Обновлено"), + ), ( "product", models.ForeignKey( diff --git a/backend/commerce/migrations/0005_productimage_paths_and_image_errors.py b/backend/commerce/migrations/0005_productimage_paths_and_image_errors.py index 9873fc8a..da916aa3 100644 --- a/backend/commerce/migrations/0005_productimage_paths_and_image_errors.py +++ b/backend/commerce/migrations/0005_productimage_paths_and_image_errors.py @@ -1,11 +1,11 @@ # Generated by Django 5.2.4 on 2026-08-17 -import commerce.models.product from django.db import migrations, models +import commerce.models.product + class Migration(migrations.Migration): - dependencies = [ ("commerce", "0004_productimage_integrationsyncrun_progress"), ] diff --git a/backend/commerce/migrations/0006_repair_moysklad_catalog.py b/backend/commerce/migrations/0006_repair_moysklad_catalog.py index 99fd64c9..13dfe40a 100644 --- a/backend/commerce/migrations/0006_repair_moysklad_catalog.py +++ b/backend/commerce/migrations/0006_repair_moysklad_catalog.py @@ -74,7 +74,9 @@ def _metadata(row): for price in row.get("salePrices") or []: if not isinstance(price, dict): continue - price_type = price.get("priceType") if isinstance(price.get("priceType"), dict) else {} + price_type = ( + price.get("priceType") if isinstance(price.get("priceType"), dict) else {} + ) sale_prices.append( { "name": str(price_type.get("name") or ""), @@ -117,7 +119,16 @@ def _update_product(product, row): product.description = str(row.get("description") or "") product.kind = _kind(row) product.is_active = not bool(row.get("archived")) - product.save(update_fields=["name", "barcode", "description", "kind", "is_active", "updated_at"]) + product.save( + update_fields=[ + "name", + "barcode", + "description", + "kind", + "is_active", + "updated_at", + ] + ) def repair_moysklad_catalog(apps, schema_editor): # noqa: C901 - one-off repair @@ -138,9 +149,13 @@ def repair_moysklad_catalog(apps, schema_editor): # noqa: C901 - one-off repair record.external_id: record.payload for record in SourceRecord.objects.filter(source=source, entity_type="product") } - used_skus = {value.casefold() for value in Product.objects.values_list("sku", flat=True)} + used_skus = { + value.casefold() for value in Product.objects.values_list("sku", flat=True) + } grouped = defaultdict(list) - for binding in ProductBinding.objects.filter(source=source).select_related("product"): + for binding in ProductBinding.objects.filter(source=source).select_related( + "product" + ): grouped[binding.product_id].append(binding) type_priority = {"product": 0, "bundle": 1, "service": 2, "variant": 3} @@ -148,7 +163,13 @@ def repair_moysklad_catalog(apps, schema_editor): # noqa: C901 - one-off repair bindings.sort( key=lambda binding: ( type_priority.get( - str((product_records.get(binding.external_id, {}).get("meta") or {}).get("type") or ""), + str( + ( + product_records.get(binding.external_id, {}).get("meta") + or {} + ).get("type") + or "" + ), 9, ), str(binding.created_at), @@ -157,7 +178,9 @@ def repair_moysklad_catalog(apps, schema_editor): # noqa: C901 - one-off repair keeper = bindings[0] keeper_raw = product_records.get(keeper.external_id, {}) keeper.metadata = _metadata(keeper_raw) - keeper.external_sku = str(keeper_raw.get("article") or keeper_raw.get("code") or "")[:128] + keeper.external_sku = str( + keeper_raw.get("article") or keeper_raw.get("code") or "" + )[:128] keeper.save(update_fields=["metadata", "external_sku", "updated_at"]) if keeper_raw: _update_product(keeper.product, keeper_raw) @@ -166,16 +189,22 @@ def repair_moysklad_catalog(apps, schema_editor): # noqa: C901 - one-off repair raw = product_records.get(binding.external_id, {}) product = Product.objects.create( sku=_unique_sku(raw, binding.external_id, used_skus), - name=str(raw.get("name") or binding.external_sku or binding.external_id)[:255], + name=str( + raw.get("name") or binding.external_sku or binding.external_id + )[:255], barcode=_barcode(raw)[:64], description=str(raw.get("description") or ""), kind=_kind(raw), is_active=not bool(raw.get("archived")), ) binding.product = product - binding.external_sku = str(raw.get("article") or raw.get("code") or "")[:128] + binding.external_sku = str(raw.get("article") or raw.get("code") or "")[ + :128 + ] binding.metadata = _metadata(raw) - binding.save(update_fields=["product", "external_sku", "metadata", "updated_at"]) + binding.save( + update_fields=["product", "external_sku", "metadata", "updated_at"] + ) SaleLine.objects.filter( sale__source=source, external_product_id=binding.external_id, @@ -187,7 +216,9 @@ def repair_moysklad_catalog(apps, schema_editor): # noqa: C901 - one-off repair bindings_by_external = { binding.external_id: binding - for binding in ProductBinding.objects.filter(source=source).select_related("product") + for binding in ProductBinding.objects.filter(source=source).select_related( + "product" + ) } for binding in bindings_by_external.values(): raw = product_records.get(binding.external_id, {}) @@ -195,9 +226,13 @@ def repair_moysklad_catalog(apps, schema_editor): # noqa: C901 - one-off repair if binding.metadata != metadata: binding.metadata = metadata binding.save(update_fields=["metadata", "updated_at"]) - parent_binding = bindings_by_external.get(str(metadata.get("parent_external_id") or "")) + parent_binding = bindings_by_external.get( + str(metadata.get("parent_external_id") or "") + ) if parent_binding and parent_binding.product_id != binding.product_id: - Product.objects.filter(pk=binding.product_id).update(parent_id=parent_binding.product_id) + Product.objects.filter(pk=binding.product_id).update( + parent_id=parent_binding.product_id + ) SaleLine.objects.filter( sale__source=source, external_product_id=binding.external_id, @@ -214,7 +249,9 @@ def repair_moysklad_catalog(apps, schema_editor): # noqa: C901 - one-off repair if image_id: desired_images[image_id].add(binding.product_id) for image_id, product_ids in desired_images.items(): - originals = list(ProductImage.objects.filter(source=source, external_id=image_id)) + originals = list( + ProductImage.objects.filter(source=source, external_id=image_id) + ) if not originals: continue original = originals[0] @@ -242,10 +279,7 @@ def repair_moysklad_catalog(apps, schema_editor): # noqa: C901 - one-off repair product_id__in=product_ids ).delete() - sales = { - sale.external_id: sale - for sale in Sale.objects.filter(source=source) - } + sales = {sale.external_id: sale for sale in Sale.objects.filter(source=source)} for record in SourceRecord.objects.filter( source=source, entity_type__in=["demand", "salesreturn"], @@ -267,13 +301,23 @@ def repair_moysklad_catalog(apps, schema_editor): # noqa: C901 - one-off repair if not isinstance(position, dict): continue position_id = str(position.get("id") or index) - assortment = position.get("assortment") if isinstance(position.get("assortment"), dict) else {} + assortment = ( + position.get("assortment") + if isinstance(position.get("assortment"), dict) + else {} + ) product_external_id = _external_id(assortment) binding = bindings_by_external.get(product_external_id) quantity = _decimal(position.get("quantity")) position_cost = _money(position.get("cost")) - buy_price = assortment.get("buyPrice") if isinstance(assortment.get("buyPrice"), dict) else {} - unit_cost = position_cost if position_cost > 0 else _money(buy_price.get("value")) + buy_price = ( + assortment.get("buyPrice") + if isinstance(assortment.get("buyPrice"), dict) + else {} + ) + unit_cost = ( + position_cost if position_cost > 0 else _money(buy_price.get("value")) + ) line_updates = { "cost_amount": (quantity * unit_cost).quantize(Decimal("0.01")), "metadata": { @@ -296,7 +340,6 @@ def repair_moysklad_catalog(apps, schema_editor): # noqa: C901 - one-off repair class Migration(migrations.Migration): - dependencies = [ ("commerce", "0005_productimage_paths_and_image_errors"), ] diff --git a/backend/commerce/migrations/0007_recover_sale_line_matches.py b/backend/commerce/migrations/0007_recover_sale_line_matches.py index 0fff629c..034b1213 100644 --- a/backend/commerce/migrations/0007_recover_sale_line_matches.py +++ b/backend/commerce/migrations/0007_recover_sale_line_matches.py @@ -18,10 +18,14 @@ def recover_sale_line_matches(apps, schema_editor): for line in lines.iterator(chunk_size=250): product_id = None if line.external_product_id: - product_id = ProductBinding.objects.filter( - source=source, - external_id=line.external_product_id, - ).values_list("product_id", flat=True).first() + product_id = ( + ProductBinding.objects.filter( + source=source, + external_id=line.external_product_id, + ) + .values_list("product_id", flat=True) + .first() + ) if product_id is None and line.external_sku: binding_matches = list( ProductBinding.objects.filter( @@ -44,7 +48,6 @@ def recover_sale_line_matches(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ ("commerce", "0006_repair_moysklad_catalog"), ] diff --git a/backend/commerce/migrations/0008_create_historical_products.py b/backend/commerce/migrations/0008_create_historical_products.py index 458861d6..9aa892a5 100644 --- a/backend/commerce/migrations/0008_create_historical_products.py +++ b/backend/commerce/migrations/0008_create_historical_products.py @@ -76,7 +76,6 @@ def create_historical_products(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ ("commerce", "0007_recover_sale_line_matches"), ] diff --git a/backend/commerce/migrations/0009_reconcile_moysklad_sale_amounts.py b/backend/commerce/migrations/0009_reconcile_moysklad_sale_amounts.py index 4329486c..53988b2f 100644 --- a/backend/commerce/migrations/0009_reconcile_moysklad_sale_amounts.py +++ b/backend/commerce/migrations/0009_reconcile_moysklad_sale_amounts.py @@ -3,7 +3,6 @@ from urllib.parse import urlparse from django.db import migrations - CENT = Decimal("0.01") HUNDRED = Decimal("100") @@ -33,11 +32,7 @@ def _external_id(row, fallback): def _position_amounts(document, position): quantity = _decimal(position.get("quantity")) base_gross = quantity * _decimal(position.get("price")) / HUNDRED - base_net = ( - base_gross - * (HUNDRED - _decimal(position.get("discount"))) - / HUNDRED - ) + base_net = base_gross * (HUNDRED - _decimal(position.get("discount"))) / HUNDRED multiplier = Decimal("1") if document.get("vatIncluded") is False and bool(position.get("vatEnabled")): multiplier += _decimal(position.get("vat")) / HUNDRED @@ -68,9 +63,7 @@ def reconcile_moysklad_sale_amounts(apps, schema_editor): Sale = apps.get_model("commerce", "Sale") SaleLine = apps.get_model("commerce", "SaleLine") changed = [] - sales = Sale.objects.filter(source__code="moysklad").select_related( - "source_record" - ) + sales = Sale.objects.filter(source__code="moysklad").select_related("source_record") for sale in sales.iterator(chunk_size=200): if sale.source_record is None: continue @@ -84,8 +77,7 @@ def reconcile_moysklad_sale_amounts(apps, schema_editor): else positions_value or [] ) lines = { - line.external_id: line - for line in SaleLine.objects.filter(sale_id=sale.pk) + line.external_id: line for line in SaleLine.objects.filter(sale_id=sale.pk) } for index, position in enumerate(positions, start=1): if not isinstance(position, dict): @@ -120,7 +112,6 @@ def reconcile_moysklad_sale_amounts(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ ("commerce", "0008_create_historical_products"), ] diff --git a/backend/commerce/migrations/0014_externalproject_projectpayment_and_more.py b/backend/commerce/migrations/0014_externalproject_projectpayment_and_more.py index bc00ef1d..55473429 100644 --- a/backend/commerce/migrations/0014_externalproject_projectpayment_and_more.py +++ b/backend/commerce/migrations/0014_externalproject_projectpayment_and_more.py @@ -5,101 +5,178 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('commerce', '0013_restore_sale_customers'), + ("commerce", "0013_restore_sale_customers"), ] operations = [ migrations.CreateModel( - name='ExternalProject', + name="ExternalProject", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('external_id', models.CharField(max_length=255)), - ('name', models.CharField(max_length=255)), - ('code', models.CharField(blank=True, max_length=255)), - ('description', models.TextField(blank=True)), - ('archived', models.BooleanField(default=False)), - ('synced_at', models.DateTimeField(blank=True, null=True)), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("external_id", models.CharField(max_length=255)), + ("name", models.CharField(max_length=255)), + ("code", models.CharField(blank=True, max_length=255)), + ("description", models.TextField(blank=True)), + ("archived", models.BooleanField(default=False)), + ("synced_at", models.DateTimeField(blank=True, null=True)), ], options={ - 'ordering': ['name', 'pk'], + "ordering": ["name", "pk"], }, ), migrations.CreateModel( - name='ProjectPayment', + name="ProjectPayment", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('external_id', models.CharField(max_length=255)), - ('document_type', models.CharField(choices=[('paymentout', 'Исходящий платёж'), ('cashout', 'Расходный ордер')], max_length=16)), - ('number', models.CharField(blank=True, max_length=255)), - ('description', models.TextField(blank=True)), - ('expense_category', models.CharField(blank=True, max_length=255)), - ('payee', models.CharField(blank=True, max_length=255)), - ('amount', models.DecimalField(decimal_places=2, max_digits=18)), - ('currency', models.CharField(max_length=3)), - ('occurred_at', models.DateTimeField(db_index=True)), - ('applicable', models.BooleanField(default=False)), - ('missing_from_source', models.BooleanField(default=False)), - ('seen_at', models.DateTimeField()), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("external_id", models.CharField(max_length=255)), + ( + "document_type", + models.CharField( + choices=[ + ("paymentout", "Исходящий платёж"), + ("cashout", "Расходный ордер"), + ], + max_length=16, + ), + ), + ("number", models.CharField(blank=True, max_length=255)), + ("description", models.TextField(blank=True)), + ("expense_category", models.CharField(blank=True, max_length=255)), + ("payee", models.CharField(blank=True, max_length=255)), + ("amount", models.DecimalField(decimal_places=2, max_digits=18)), + ("currency", models.CharField(max_length=3)), + ("occurred_at", models.DateTimeField(db_index=True)), + ("applicable", models.BooleanField(default=False)), + ("missing_from_source", models.BooleanField(default=False)), + ("seen_at", models.DateTimeField()), ], options={ - 'ordering': ['-occurred_at', '-pk'], + "ordering": ["-occurred_at", "-pk"], }, ), migrations.RemoveConstraint( - model_name='cost', - name='commerce_cost_has_target', + model_name="cost", + name="commerce_cost_has_target", ), migrations.AlterField( - model_name='integrationsyncrun', - name='stage', - field=models.CharField(blank=True, choices=[('projects', 'Проекты'), ('project_payments', 'Расходы проектов'), ('catalog', 'Каталог'), ('images', 'Изображения'), ('variant_links', 'Связи вариантов'), ('stocks', 'Остатки'), ('sales', 'Продажи'), ('returns', 'Возвраты'), ('finalizing', 'Завершение')], default='', max_length=32, verbose_name='Этап'), + model_name="integrationsyncrun", + name="stage", + field=models.CharField( + blank=True, + choices=[ + ("projects", "Проекты"), + ("project_payments", "Расходы проектов"), + ("catalog", "Каталог"), + ("images", "Изображения"), + ("variant_links", "Связи вариантов"), + ("stocks", "Остатки"), + ("sales", "Продажи"), + ("returns", "Возвраты"), + ("finalizing", "Завершение"), + ], + default="", + max_length=32, + verbose_name="Этап", + ), ), migrations.AddField( - model_name='externalproject', - name='source', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='commerce.sourcesystem'), + model_name="externalproject", + name="source", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, to="commerce.sourcesystem" + ), ), migrations.AddField( - model_name='cost', - name='project', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='commerce.externalproject'), + model_name="cost", + name="project", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + to="commerce.externalproject", + ), ), migrations.AddField( - model_name='sale', - name='project', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='commerce.externalproject'), + model_name="sale", + name="project", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + to="commerce.externalproject", + ), ), migrations.AddConstraint( - model_name='cost', - constraint=models.CheckConstraint(condition=models.Q(('product__isnull', False), ('sale_line__isnull', False), ('project__isnull', False), _connector='OR'), name='commerce_cost_has_target'), + model_name="cost", + constraint=models.CheckConstraint( + condition=models.Q( + ("product__isnull", False), + ("sale_line__isnull", False), + ("project__isnull", False), + _connector="OR", + ), + name="commerce_cost_has_target", + ), ), migrations.AddField( - model_name='projectpayment', - name='project', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='commerce.externalproject'), + model_name="projectpayment", + name="project", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + to="commerce.externalproject", + ), ), migrations.AddField( - model_name='projectpayment', - name='source', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='commerce.sourcesystem'), + model_name="projectpayment", + name="source", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, to="commerce.sourcesystem" + ), ), migrations.AddField( - model_name='projectpayment', - name='source_record', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='commerce.sourcerecord'), + model_name="projectpayment", + name="source_record", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, to="commerce.sourcerecord" + ), ), migrations.AddConstraint( - model_name='externalproject', - constraint=models.UniqueConstraint(fields=('source', 'external_id'), name='commerce_project_source_id'), + model_name="externalproject", + constraint=models.UniqueConstraint( + fields=("source", "external_id"), name="commerce_project_source_id" + ), ), migrations.AddConstraint( - model_name='projectpayment', - constraint=models.UniqueConstraint(fields=('source', 'document_type', 'external_id'), name='commerce_project_payment_id'), + model_name="projectpayment", + constraint=models.UniqueConstraint( + fields=("source", "document_type", "external_id"), + name="commerce_project_payment_id", + ), ), migrations.AddConstraint( - model_name='projectpayment', - constraint=models.CheckConstraint(condition=models.Q(('amount__gte', 0)), name='commerce_project_payment_amount'), + model_name="projectpayment", + constraint=models.CheckConstraint( + condition=models.Q(("amount__gte", 0)), + name="commerce_project_payment_amount", + ), ), ] diff --git a/backend/commerce/models/__init__.py b/backend/commerce/models/__init__.py index 7da276cc..5962dfd5 100644 --- a/backend/commerce/models/__init__.py +++ b/backend/commerce/models/__init__.py @@ -6,8 +6,8 @@ from .source import ( IntegrationConnection, IntegrationProvider, IntegrationStatus, - IntegrationSyncStage, IntegrationSyncRun, + IntegrationSyncStage, ProductBinding, SourceRecord, SourceSystem, diff --git a/backend/commerce/realtime.py b/backend/commerce/realtime.py index 170e16a5..8a4d09a2 100644 --- a/backend/commerce/realtime.py +++ b/backend/commerce/realtime.py @@ -3,7 +3,6 @@ from channels.layers import get_channel_layer from commerce.models import IntegrationConnection, IntegrationSyncRun - COMMERCE_CONNECTIONS_GROUP = "commerce_connections" diff --git a/backend/commerce/search.py b/backend/commerce/search.py index aa24c7ef..53b82f2a 100644 --- a/backend/commerce/search.py +++ b/backend/commerce/search.py @@ -3,7 +3,6 @@ from django.db.models import CharField, Transform from django.dispatch import receiver from rest_framework.filters import SearchFilter - SQLITE_CASEFOLD_FUNCTION = "EUSRR_CASEFOLD" diff --git a/backend/commerce/services/moysklad_sync.py b/backend/commerce/services/moysklad_sync.py index 192f2bdd..ec37348c 100644 --- a/backend/commerce/services/moysklad_sync.py +++ b/backend/commerce/services/moysklad_sync.py @@ -1,5 +1,5 @@ -import re import logging +import re from dataclasses import asdict, dataclass from datetime import datetime from decimal import ROUND_HALF_UP, Decimal, InvalidOperation @@ -29,7 +29,6 @@ from commerce.services.credentials import decrypt_credentials from commerce.services.ingestion import ingest_source_record from commerce.services.matching import match_product - logger = logging.getLogger(__name__) diff --git a/backend/commerce/services/sync_runs.py b/backend/commerce/services/sync_runs.py index 1fb3eeb6..88179202 100644 --- a/backend/commerce/services/sync_runs.py +++ b/backend/commerce/services/sync_runs.py @@ -7,7 +7,6 @@ from django.utils import timezone from commerce.models import IntegrationStatus, IntegrationSyncRun - STALE_QUEUED_ERROR = "Задача не была получена обработчиком и снята с очереди." STALE_RUNNING_ERROR = ( "Синхронизация перестала передавать прогресс и была завершена как ошибочная." diff --git a/backend/commerce/services/wildberries_sync.py b/backend/commerce/services/wildberries_sync.py index 2e81754d..b7094838 100644 --- a/backend/commerce/services/wildberries_sync.py +++ b/backend/commerce/services/wildberries_sync.py @@ -23,7 +23,6 @@ from commerce.services.credentials import decrypt_credentials from commerce.services.ingestion import ingest_source_record from commerce.services.matching import match_product - logger = logging.getLogger(__name__) diff --git a/backend/commerce/tasks/__init__.py b/backend/commerce/tasks/__init__.py index 90813ddb..3e445bba 100644 --- a/backend/commerce/tasks/__init__.py +++ b/backend/commerce/tasks/__init__.py @@ -24,6 +24,8 @@ from commerce.services import sync_moysklad_connection, sync_wildberries_connect from commerce.services.credentials import CredentialError from commerce.services.sync_runs import ( STALE_QUEUED_ERROR as STALE_QUEUED_ERROR, +) +from commerce.services.sync_runs import ( reconcile_stale_sync_runs, ) diff --git a/backend/common/cache_decorators.py b/backend/common/cache_decorators.py index fd51600f..fa193f70 100644 --- a/backend/common/cache_decorators.py +++ b/backend/common/cache_decorators.py @@ -8,7 +8,6 @@ from functools import wraps from django.core.cache import cache from django.http import HttpResponse - logger = logging.getLogger(__name__) diff --git a/backend/common/image_utils.py b/backend/common/image_utils.py index 459e0597..b60e6ea2 100644 --- a/backend/common/image_utils.py +++ b/backend/common/image_utils.py @@ -49,9 +49,7 @@ def compress_image( raise ValueError("Пустые данные изображения") if Image is None: - raise RuntimeError( - "Pillow не установлен. Выполните: pip install Pillow" - ) + raise RuntimeError("Pillow не установлен. Выполните: pip install Pillow") try: # Открываем изображение diff --git a/backend/common/ip_restrictions.py b/backend/common/ip_restrictions.py index 60cc9a4d..23b4a64f 100644 --- a/backend/common/ip_restrictions.py +++ b/backend/common/ip_restrictions.py @@ -1,6 +1,7 @@ """ Ограничение доступа к определенным функциям по IP-адресу. """ + import ipaddress from functools import wraps from typing import List @@ -14,12 +15,12 @@ def get_client_ip(request) -> str: Получает реальный IP-адрес клиента из request. Учитывает заголовки прокси (X-Forwarded-For, X-Real-IP). """ - x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") if x_forwarded_for: # Берем первый IP из списка (клиентский IP) - ip = x_forwarded_for.split(',')[0].strip() + ip = x_forwarded_for.split(",")[0].strip() else: - ip = request.META.get('REMOTE_ADDR', '') + ip = request.META.get("REMOTE_ADDR", "") return ip @@ -58,7 +59,7 @@ def is_ip_allowed(ip_address: str, allowed_networks: List[str] = None) -> bool: """ if allowed_networks is None: # Проверяем настройки Django - allowed_networks = getattr(settings, 'REGISTRATION_ALLOWED_IPS', None) + allowed_networks = getattr(settings, "REGISTRATION_ALLOWED_IPS", None) # Если настройка не задана, разрешаем только локальные IP if allowed_networks is None: @@ -69,7 +70,7 @@ def is_ip_allowed(ip_address: str, allowed_networks: List[str] = None) -> bool: return False # Специальное значение для разрешения всех IP - if allowed_networks == ['*'] or '*' in allowed_networks: + if allowed_networks == ["*"] or "*" in allowed_networks: return True try: @@ -77,7 +78,7 @@ def is_ip_allowed(ip_address: str, allowed_networks: List[str] = None) -> bool: for network_str in allowed_networks: # Проверяем, является ли это сетью (содержит /) или отдельным IP - if '/' in network_str: + if "/" in network_str: network = ipaddress.ip_network(network_str, strict=False) if ip in network: return True @@ -102,17 +103,18 @@ def local_ip_required(view_func): def my_view(request): ... """ + @wraps(view_func) def wrapper(request, *args, **kwargs): client_ip = get_client_ip(request) if not is_ip_allowed(client_ip): return HttpResponse( - '

403 Forbidden

' - '

Регистрация доступна только из локальной сети.

' - f'

Ваш IP: {client_ip}

', + "

403 Forbidden

" + "

Регистрация доступна только из локальной сети.

" + f"

Ваш IP: {client_ip}

", status=403, - content_type='text/html; charset=utf-8' + content_type="text/html; charset=utf-8", ) return view_func(request, *args, **kwargs) @@ -132,18 +134,18 @@ def local_ip_required_api(view_class): original_dispatch = view_class.dispatch def dispatch_wrapper(self, request, *args, **kwargs): - from rest_framework.response import Response from rest_framework import status as drf_status + from rest_framework.response import Response client_ip = get_client_ip(request) if not is_ip_allowed(client_ip): return Response( { - 'detail': 'Регистрация доступна только из локальной сети.', - 'client_ip': client_ip + "detail": "Регистрация доступна только из локальной сети.", + "client_ip": client_ip, }, - status=drf_status.HTTP_403_FORBIDDEN + status=drf_status.HTTP_403_FORBIDDEN, ) return original_dispatch(self, request, *args, **kwargs) diff --git a/backend/common/logstorm_attendance.py b/backend/common/logstorm_attendance.py index 7abf1d72..4f95fba4 100644 --- a/backend/common/logstorm_attendance.py +++ b/backend/common/logstorm_attendance.py @@ -3,10 +3,8 @@ from datetime import date from typing import Any, Optional -from employees.models import Employee - from common.logstorm_client import LogStormClient - +from employees.models import Employee DEFAULT_WORKDAYS = [ "Monday", diff --git a/backend/common/logstorm_client.py b/backend/common/logstorm_client.py index dff4a997..df15ba19 100644 --- a/backend/common/logstorm_client.py +++ b/backend/common/logstorm_client.py @@ -25,9 +25,9 @@ class LogStormConfig: def load_logstorm_config() -> LogStormConfig: return LogStormConfig( - base_url=getattr( - settings, "LOGSTORM_API_URL", "http://127.0.0.1:8000" - ).rstrip("/"), + base_url=getattr(settings, "LOGSTORM_API_URL", "http://127.0.0.1:8000").rstrip( + "/" + ), token=getattr(settings, "LOGSTORM_API_TOKEN", ""), timeout=getattr(settings, "LOGSTORM_TIMEOUT_SECONDS", 30), ) @@ -53,8 +53,7 @@ class LogStormClient: ) if not response.ok: raise LogStormClientError( - f"LogStorm API returned HTTP {response.status_code}: " - f"{response.text}", + f"LogStorm API returned HTTP {response.status_code}: {response.text}", status_code=response.status_code, ) try: @@ -77,8 +76,7 @@ class LogStormClient: ) if not response.ok: raise LogStormClientError( - f"LogStorm API returned HTTP {response.status_code}: " - f"{response.text}", + f"LogStorm API returned HTTP {response.status_code}: {response.text}", status_code=response.status_code, ) try: @@ -108,8 +106,7 @@ class LogStormClient: ) if not response.ok: raise LogStormClientError( - f"LogStorm API returned HTTP {response.status_code}: " - f"{response.text}", + f"LogStorm API returned HTTP {response.status_code}: {response.text}", status_code=response.status_code, ) try: @@ -130,8 +127,7 @@ class LogStormClient: ) if not response.ok: raise LogStormClientError( - f"LogStorm API returned HTTP {response.status_code}: " - f"{response.text}", + f"LogStorm API returned HTTP {response.status_code}: {response.text}", status_code=response.status_code, ) return response diff --git a/backend/common/request_utils.py b/backend/common/request_utils.py index a88d4367..a3fc7769 100644 --- a/backend/common/request_utils.py +++ b/backend/common/request_utils.py @@ -1,4 +1,5 @@ """Утилиты для безопасной обработки данных запросов.""" + from typing import Any, Union from django.http import QueryDict @@ -24,7 +25,7 @@ def safe_int(value: Any, default: int = 0) -> int: >>> safe_int('', default=5) 5 """ - if value is None or value == '': + if value is None or value == "": return default try: @@ -56,9 +57,9 @@ def safe_bool(value: Any, default: bool = False) -> bool: if isinstance(value, str): value_lower = value.lower().strip() - if value_lower in ('true', '1', 'yes', 'on'): + if value_lower in ("true", "1", "yes", "on"): return True - if value_lower in ('false', '0', 'no', 'off', ''): + if value_lower in ("false", "0", "no", "off", ""): return False return default @@ -74,7 +75,7 @@ def safe_float(value: Any, default: float = 0.0) -> float: Returns: float: Сконвертированное значение или default """ - if value is None or value == '': + if value is None or value == "": return default try: @@ -84,7 +85,7 @@ def safe_float(value: Any, default: float = 0.0) -> float: def safe_get_data( - data: Union[dict, 'QueryDict'], + data: Union[dict, "QueryDict"], key: str, default: Any = None, required: bool = False, diff --git a/backend/communications/admin.py b/backend/communications/admin.py index 91c6b9f6..ecbb65e0 100644 --- a/backend/communications/admin.py +++ b/backend/communications/admin.py @@ -1,4 +1,5 @@ from django.contrib import admin + from .models import ( AvailableReaction, Chat, @@ -53,9 +54,7 @@ class ChatAdmin(admin.ModelAdmin): # Главные чаты (is_main=True или flags['is_primary']=True) нельзя # редактировать if obj: - is_primary = obj.is_main or ( - obj.flags and obj.flags.get("is_primary") - ) + is_primary = obj.is_main or (obj.flags and obj.flags.get("is_primary")) if is_primary: return self.readonly_fields + ( "type", @@ -107,9 +106,7 @@ class MessageAdmin(admin.ModelAdmin): readonly_fields = ("created_at", "edited_at", "deleted_at") def short_content(self, obj): - return ( - (obj.content[:50] + "...") if len(obj.content) > 50 else obj.content - ) + return (obj.content[:50] + "...") if len(obj.content) > 50 else obj.content short_content.short_description = "Текст" @@ -266,11 +263,7 @@ class PollAdmin(admin.ModelAdmin): inlines = [PollOptionInline, PollVoteInline] def question_short(self, obj): - return ( - obj.question[:50] + "..." - if len(obj.question) > 50 - else obj.question - ) + return obj.question[:50] + "..." if len(obj.question) > 50 else obj.question question_short.short_description = "Вопрос" diff --git a/backend/communications/api/permissions.py b/backend/communications/api/permissions.py index d99c9990..e3888890 100644 --- a/backend/communications/api/permissions.py +++ b/backend/communications/api/permissions.py @@ -4,6 +4,7 @@ Permissions для communications API import rules from rest_framework import permissions + from ..utils import user_can_access_chat @@ -48,15 +49,11 @@ class ChatPermission(permissions.BasePermission): # Изменение чата - только владелец или админ if request.method in ["PUT", "PATCH"]: - return rules.test_rule( - "communications.change_chat", request.user, obj - ) + return rules.test_rule("communications.change_chat", request.user, obj) # Удаление чата - только владелец if request.method == "DELETE": - return rules.test_rule( - "communications.delete_chat", request.user, obj - ) + return rules.test_rule("communications.delete_chat", request.user, obj) return False @@ -109,26 +106,18 @@ class MessagePermission(permissions.BasePermission): if hasattr(view, "action") and view.action in ["react", "unreact"]: # Для реакций достаточно быть участником чата # (даже гость может ставить реакции) - return rules.test_rule( - "communications.view_message", request.user, obj - ) + return rules.test_rule("communications.view_message", request.user, obj) # Просмотр - доступ к чату if request.method in permissions.SAFE_METHODS: - return rules.test_rule( - "communications.view_message", request.user, obj - ) + return rules.test_rule("communications.view_message", request.user, obj) # Редактирование - автор if request.method in ["PUT", "PATCH"]: - return rules.test_rule( - "communications.change_message", request.user, obj - ) + return rules.test_rule("communications.change_message", request.user, obj) # Удаление - автор или админ if request.method == "DELETE": - return rules.test_rule( - "communications.delete_message", request.user, obj - ) + return rules.test_rule("communications.delete_message", request.user, obj) return False diff --git a/backend/communications/api/serializers.py b/backend/communications/api/serializers.py index e0f92304..286ee5e7 100644 --- a/backend/communications/api/serializers.py +++ b/backend/communications/api/serializers.py @@ -5,25 +5,25 @@ Serializers for Communications API from datetime import timedelta import rules -from drf_spectacular.types import OpenApiTypes -from drf_spectacular.utils import extend_schema_field from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.db import transaction from django.utils import timezone +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema_field from rest_framework import serializers +from ..mentions import build_message_mentions from ..models import ( Chat, + ChatMembership, + ChatUserSettings, Message, MessageAttachment, MessageReaction, Poll, PollOption, - ChatMembership, - ChatUserSettings, ) -from ..mentions import build_message_mentions from ..serialization import serialize_message, serialize_poll from ..utils import user_can_access_chat @@ -31,7 +31,10 @@ User = get_user_model() def _get_active_memberships(obj, *, limit: int | None = None): - if hasattr(obj, "_prefetched_objects_cache") and "memberships" in obj._prefetched_objects_cache: + if ( + hasattr(obj, "_prefetched_objects_cache") + and "memberships" in obj._prefetched_objects_cache + ): memberships = list(obj.memberships.all()) else: memberships = list( @@ -147,8 +150,7 @@ class ChatUserSettingsSerializer(serializers.ModelSerializer): def get_notifications_enabled(self, obj): return ( - obj.effective_notification_mode() - == ChatUserSettings.NotificationMode.ALL + obj.effective_notification_mode() == ChatUserSettings.NotificationMode.ALL ) def get_effective_notification_mode(self, obj): @@ -176,9 +178,7 @@ class ChatNotificationPreferencesUpdateSerializer(serializers.Serializer): class ChatMembershipSerializer(serializers.ModelSerializer): """Членство в чате""" - user_name = serializers.CharField( - source="user.get_full_name", read_only=True - ) + user_name = serializers.CharField(source="user.get_full_name", read_only=True) can_manage_members = serializers.BooleanField(read_only=True) class Meta: @@ -281,7 +281,9 @@ class ChatListSerializer(serializers.ModelSerializer): { "id": membership.user.id, "name": membership.user.get_full_name(), - "avatar": membership.user.avatar.url if membership.user.avatar else None, + "avatar": membership.user.avatar.url + if membership.user.avatar + else None, } for membership in members ] @@ -294,9 +296,7 @@ class ChatListSerializer(serializers.ModelSerializer): return [membership.user.get_full_name() for membership in members] return [] - @extend_schema_field( - serializers.ListField(child=serializers.IntegerField()) - ) + @extend_schema_field(serializers.ListField(child=serializers.IntegerField())) def get_member_ids(self, obj): """ID участников (из prefetch, для поиска существующего чата)""" return [membership.user_id for membership in _get_active_memberships(obj)] @@ -347,9 +347,7 @@ class ChatDetailSerializer(serializers.ModelSerializer): required=False, allow_null=True, ) - include_all_employees = serializers.BooleanField( - write_only=True, required=False - ) + include_all_employees = serializers.BooleanField(write_only=True, required=False) # NEW: context info context_type = serializers.CharField( @@ -400,17 +398,13 @@ class ChatDetailSerializer(serializers.ModelSerializer): ] def create(self, validated_data): - include_all_employees = validated_data.pop( - "include_all_employees", None - ) + include_all_employees = validated_data.pop("include_all_employees", None) if include_all_employees is not None: validated_data["include_all_users"] = include_all_employees return super().create(validated_data) def update(self, instance, validated_data): - include_all_employees = validated_data.pop( - "include_all_employees", None - ) + include_all_employees = validated_data.pop("include_all_employees", None) if include_all_employees is not None: validated_data["include_all_users"] = include_all_employees return super().update(instance, validated_data) @@ -424,7 +418,9 @@ class ChatDetailSerializer(serializers.ModelSerializer): { "id": membership.user.id, "name": membership.user.get_full_name(), - "avatar": membership.user.avatar.url if membership.user.avatar else None, + "avatar": membership.user.avatar.url + if membership.user.avatar + else None, } for membership in _get_active_memberships(obj, limit=20) ] @@ -512,9 +508,7 @@ class MessageAttachmentSerializer(serializers.ModelSerializer): class MessageReactionSerializer(serializers.ModelSerializer): """Реакция на сообщение""" - user_name = serializers.CharField( - source="user.get_full_name", read_only=True - ) + user_name = serializers.CharField(source="user.get_full_name", read_only=True) class Meta: model = MessageReaction @@ -616,9 +610,7 @@ class PollSerializer(serializers.ModelSerializer): ] read_only_fields = ["id", "message", "is_closed", "total_voters"] - @extend_schema_field( - serializers.ListField(child=serializers.IntegerField()) - ) + @extend_schema_field(serializers.ListField(child=serializers.IntegerField())) def get_user_voted_option_ids(self, obj): """ID опций за которые проголосовал текущий пользователь""" request = self.context.get("request") @@ -646,14 +638,10 @@ class PollSerializer(serializers.ModelSerializer): try: chat = Chat.objects.get(pk=attrs["chat_id"]) except Chat.DoesNotExist: - raise serializers.ValidationError( - {"chat_id": "Чат не найден"} - ) from None + raise serializers.ValidationError({"chat_id": "Чат не найден"}) from None if not user_can_access_chat(chat, request.user): - raise serializers.ValidationError( - {"chat_id": "Нет доступа к этому чату"} - ) + raise serializers.ValidationError({"chat_id": "Нет доступа к этому чату"}) if not rules.test_rule( "communications.send_message", request.user, @@ -690,7 +678,9 @@ class PollSerializer(serializers.ModelSerializer): ) elif correct_option_index is not None: raise serializers.ValidationError( - {"correct_option_index": "Правильный ответ доступен только для викторины"} + { + "correct_option_index": "Правильный ответ доступен только для викторины" + } ) if attrs.get("allows_custom_answers"): @@ -721,10 +711,7 @@ class PollSerializer(serializers.ModelSerializer): validated_data.pop("reply_to_id", None) correct_option_index = validated_data.pop("correct_option_index", None) closes_in_minutes = validated_data.pop("closes_in_minutes", None) - author = ( - validated_data.pop("author", None) - or self.context.get("request").user - ) + author = validated_data.pop("author", None) or self.context.get("request").user if closes_in_minutes: validated_data["closes_at"] = timezone.now() + timedelta( @@ -762,9 +749,7 @@ class PollSerializer(serializers.ModelSerializer): class MessageListSerializer(serializers.ModelSerializer): """Облегченный сериализатор для списка сообщений""" - author_name = serializers.CharField( - source="author.get_full_name", read_only=True - ) + author_name = serializers.CharField(source="author.get_full_name", read_only=True) author_avatar = serializers.SerializerMethodField() reactions_summary = serializers.SerializerMethodField() @@ -877,15 +862,11 @@ class ReactionSerializer(serializers.Serializer): class ForwardMessageSerializer(serializers.Serializer): """Сериализатор для пересылки сообщений""" - message_ids = serializers.ListField( - child=serializers.IntegerField(), min_length=1 - ) + message_ids = serializers.ListField(child=serializers.IntegerField(), min_length=1) target_chat_id = serializers.IntegerField() class BulkDeleteSerializer(serializers.Serializer): """Сериализатор для массового удаления""" - message_ids = serializers.ListField( - child=serializers.IntegerField(), min_length=1 - ) + message_ids = serializers.ListField(child=serializers.IntegerField(), min_length=1) diff --git a/backend/communications/api/urls.py b/backend/communications/api/urls.py index 7bea59c4..04851404 100644 --- a/backend/communications/api/urls.py +++ b/backend/communications/api/urls.py @@ -13,18 +13,20 @@ URL routing для Communications REST API История: - Создан: 11 марта 2026 (перенос из api/v1/) """ + from rest_framework.routers import DefaultRouter + from .viewsets import ChatViewSet, MessageViewSet, PollViewSet # Создаем DRF router router = DefaultRouter() # Регистрируем ViewSets -router.register(r'chats', ChatViewSet, basename='chats') -router.register(r'messages', MessageViewSet, basename='messages') -router.register(r'polls', PollViewSet, basename='polls') +router.register(r"chats", ChatViewSet, basename="chats") +router.register(r"messages", MessageViewSet, basename="messages") +router.register(r"polls", PollViewSet, basename="polls") # Экспортируем для использования в главном urls.py urlpatterns = router.urls -__all__ = ['router', 'urlpatterns'] +__all__ = ["router", "urlpatterns"] diff --git a/backend/communications/api/viewsets.py b/backend/communications/api/viewsets.py index 217e84d2..14ec07c6 100644 --- a/backend/communications/api/viewsets.py +++ b/backend/communications/api/viewsets.py @@ -30,6 +30,39 @@ from rest_framework.parsers import FormParser, JSONParser, MultiPartParser from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response +from communications.serialization import ( + serialize_message, + serialize_poll, +) + +from ..mentions import ( + build_message_mentions, + filter_mentionable_users, + get_mentionable_users, + mention_display_name, + parse_mentioned_user_ids, + update_unread_mention_counters, +) +from ..mentions import ( + mentioned_user_ids as get_mentioned_user_ids, +) +from ..models import ( + Chat, + ChatMembership, + ChatReadState, + ChatUserSettings, + Message, + MessageAttachment, + MessageReaction, + Poll, +) +from ..polls import PollActionError, cast_poll_vote +from ..services import ( + ChatReadMarkerService, + soft_delete_message, + update_chat_notification_preferences, +) +from ..utils import _coerce_ts, user_can_access_chat from .permissions import ChatPermission, MessagePermission from .serializers import ( BulkDeleteSerializer, @@ -45,36 +78,6 @@ from .serializers import ( PollSerializer, ReactionSerializer, ) -from ..models import ( - Chat, - ChatMembership, - ChatReadState, - ChatUserSettings, - Message, - MessageAttachment, - MessageReaction, - Poll, -) -from ..mentions import ( - build_message_mentions, - filter_mentionable_users, - get_mentionable_users, - mentioned_user_ids as get_mentioned_user_ids, - mention_display_name, - parse_mentioned_user_ids, - update_unread_mention_counters, -) -from ..polls import PollActionError, cast_poll_vote -from ..services import ( - ChatReadMarkerService, - soft_delete_message, - update_chat_notification_preferences, -) -from ..utils import _coerce_ts, user_can_access_chat -from communications.serialization import ( - serialize_message, - serialize_poll, -) logger = logging.getLogger(__name__) User = get_user_model() @@ -289,7 +292,9 @@ class ChatViewSet(viewsets.ModelViewSet): raw_participant_ids = [raw_participant_ids] try: participant_ids = { - int(user_id) for user_id in raw_participant_ids if user_id not in (None, "") + int(user_id) + for user_id in raw_participant_ids + if user_id not in (None, "") } except (TypeError, ValueError): return Response( @@ -395,9 +400,7 @@ class ChatViewSet(viewsets.ModelViewSet): preferences=serializer.validated_data, ) - return Response( - {"ok": True, **ChatUserSettingsSerializer(settings).data} - ) + return Response({"ok": True, **ChatUserSettingsSerializer(settings).data}) @action(detail=True, methods=["get"], url_path="mention-candidates") def mention_candidates(self, request, pk=None): @@ -417,7 +420,9 @@ class ChatViewSet(viewsets.ModelViewSet): "id": user.id, "name": mention_display_name(user), "email": user.email, - "avatar": user.avatar.url if getattr(user, "avatar", None) else None, + "avatar": user.avatar.url + if getattr(user, "avatar", None) + else None, } ) return Response({"results": candidates}) @@ -1518,7 +1523,9 @@ class MessageViewSet(viewsets.ModelViewSet): requested_mention_ids, ) added_mention_ids = get_mentioned_user_ids(next_mentions) - previous_mention_ids - removed_mention_ids = previous_mention_ids - get_mentioned_user_ids(next_mentions) + removed_mention_ids = previous_mention_ids - get_mentioned_user_ids( + next_mentions + ) existing_attachment_ids = serializer.validated_data.get( "existing_attachment_ids" ) diff --git a/backend/communications/apps.py b/backend/communications/apps.py index 58ed4dbc..3295444d 100644 --- a/backend/communications/apps.py +++ b/backend/communications/apps.py @@ -20,19 +20,18 @@ class CommunicationsConfig(AppConfig): Использует flags['is_primary'] вместо is_main. Для обратной совместимости также устанавливает is_main=True. """ - from communications.models import Chat from django.db.models import Q + from communications.models import Chat + # Проверяем по обоим полям (старому и новому) if not Chat.objects.filter( - Q(type="global") & ( - Q(is_main=True) | Q(flags__is_primary=True) - ) + Q(type="global") & (Q(is_main=True) | Q(flags__is_primary=True)) ).exists(): Chat.objects.create( type="global", # NEW: flags - flags={'is_primary': True}, + flags={"is_primary": True}, # DEPRECATED: для обратной совместимости - is_main=True + is_main=True, ) diff --git a/backend/communications/comments_helpers.py b/backend/communications/comments_helpers.py index 05629bbb..26cef080 100644 --- a/backend/communications/comments_helpers.py +++ b/backend/communications/comments_helpers.py @@ -22,6 +22,7 @@ Helpers для работы с комментариями через Communicati """ from typing import Optional + from django.contrib.contenttypes.models import ContentType from django.db.models import QuerySet diff --git a/backend/communications/consumers.py b/backend/communications/consumers.py index 34a5c79a..7e1e80ca 100644 --- a/backend/communications/consumers.py +++ b/backend/communications/consumers.py @@ -20,6 +20,12 @@ from django.utils import timezone from realtime.protocol import parse_positive_int, parse_text +from .mentions import ( + build_message_mentions, + mentioned_user_ids, + parse_mentioned_user_ids, + update_unread_mention_counters, +) from .models import ( Chat, ChatReadState, @@ -27,12 +33,6 @@ from .models import ( MessageReaction, Poll, ) -from .mentions import ( - build_message_mentions, - mentioned_user_ids, - parse_mentioned_user_ids, - update_unread_mention_counters, -) from .polls import PollActionError, cast_poll_vote from .presence import clear_active_chat, set_active_chat, touch_active_chat from .serialization import serialize_message, serialize_poll diff --git a/backend/communications/management/commands/check_chats.py b/backend/communications/management/commands/check_chats.py index 1b67568d..91d42c16 100644 --- a/backend/communications/management/commands/check_chats.py +++ b/backend/communications/management/commands/check_chats.py @@ -16,6 +16,7 @@ Management команда для проверки и очистки чатов """ from django.core.management.base import BaseCommand, CommandError + from communications.models import Chat VALID_TYPES = [ @@ -46,10 +47,7 @@ class Command(BaseCommand): parser.add_argument( "--fix", action="store_true", - help=( - "Исправить все чаты с некорректными типами " - "(заменить на group)" - ), + help=("Исправить все чаты с некорректными типами (заменить на group)"), ) parser.add_argument( "--change-type", @@ -58,10 +56,7 @@ class Command(BaseCommand): "CHAT_ID", "NEW_TYPE", ), - help=( - "Изменить тип конкретного чата " - "(например: --change-type 6 group)" - ), + help=("Изменить тип конкретного чата (например: --change-type 6 group)"), ) parser.add_argument( "--cleanup", @@ -89,18 +84,12 @@ class Command(BaseCommand): "--check-visibility", type=int, metavar="USER_ID", - help=( - "Проверить видимость чатов для конкретного пользователя " - "(как в API)" - ), + help=("Проверить видимость чатов для конкретного пользователя (как в API)"), ) parser.add_argument( "--migrate-memberships", action="store_true", - help=( - "Создать ChatMembership для всех участников " - "из participants" - ), + help=("Создать ChatMembership для всех участников из participants"), ) parser.add_argument( "--export", @@ -150,15 +139,11 @@ class Command(BaseCommand): elif options["change_type"]: chat_id = int(options["change_type"][0]) new_type = options["change_type"][1] - self.change_chat_type( - chat_id, new_type, no_confirm=options["no_confirm"] - ) + self.change_chat_type(chat_id, new_type, no_confirm=options["no_confirm"]) elif options["cleanup"]: self.cleanup_invalid_chats(no_confirm=options["no_confirm"]) elif options["delete"]: - self.delete_chat( - options["delete"], no_confirm=options["no_confirm"] - ) + self.delete_chat(options["delete"], no_confirm=options["no_confirm"]) else: self.stdout.write( self.style.ERROR( @@ -180,9 +165,7 @@ class Command(BaseCommand): # Статистика по типам stats = ( - Chat.objects.values("type") - .annotate(count=Count("id")) - .order_by("-count") + Chat.objects.values("type").annotate(count=Count("id")).order_by("-count") ) type_labels = { @@ -212,14 +195,12 @@ class Command(BaseCommand): if invalid_count > 0: self.stdout.write( self.style.WARNING( - f"\n⚠️ Найдено {invalid_count} чатов " - "с некорректными типами" + f"\n⚠️ Найдено {invalid_count} чатов с некорректными типами" ) ) self.stdout.write( self.style.NOTICE( - "Для исправления используйте: " - "python manage.py check_chats --fix" + "Для исправления используйте: python manage.py check_chats --fix" ) ) else: @@ -233,6 +214,7 @@ class Command(BaseCommand): """Проверяет видимость чатов для пользователя (эмулирует API логику)""" from django.contrib.auth import get_user_model from django.db.models import Q + from communications.models import ChatMembership User = get_user_model() @@ -275,9 +257,7 @@ class Command(BaseCommand): self.stdout.write(f" Видимых в списке API: {list_visible}") if comments_hidden > 0: self.stdout.write( - self.style.WARNING( - f" Скрыто (type=comments): {comments_hidden}" - ) + self.style.WARNING(f" Скрыто (type=comments): {comments_hidden}") ) # Группировка по типам @@ -297,9 +277,7 @@ class Command(BaseCommand): if count == 0: continue - visible_in_list = ( - "да" if chat_type != "comments" else "нет (скрыт в list)" - ) + visible_in_list = "да" if chat_type != "comments" else "нет (скрыт в list)" type_label = { "private": "Личные", "group": "Групповые", @@ -342,9 +320,7 @@ class Command(BaseCommand): " | ".join(reasons) if reasons else "❌ ПРИЧИНА НЕ НАЙДЕНА!" ) self.stdout.write( - f" • ID:{chat.id} {chat.name or '[без названия]'} → { - reason_str - }" + f" • ID:{chat.id} {chat.name or '[без названия]'} → {reason_str}" ) if count > 5: @@ -380,9 +356,7 @@ class Command(BaseCommand): ) # Находим все чаты с участниками - chats_with_participants = Chat.objects.prefetch_related( - "participants" - ).all() + chats_with_participants = Chat.objects.prefetch_related("participants").all() total_chats = 0 total_created = 0 @@ -449,6 +423,7 @@ class Command(BaseCommand): def export_chats(self, chat_type, output_file): """Экспортирует чаты указанного типа в JSON файл""" import json + from django.core.serializers.json import DjangoJSONEncoder chats = Chat.objects.filter(type=chat_type).order_by("-created_at") @@ -476,9 +451,7 @@ class Command(BaseCommand): "id": chat.id, "name": chat.name, "type": chat.type, - "created_at": chat.created_at.isoformat() - if chat.created_at - else None, + "created_at": chat.created_at.isoformat() if chat.created_at else None, "participants_count": chat.participants.count(), "participants": participants_list, "messages_count": chat.messages.count() @@ -508,9 +481,7 @@ class Command(BaseCommand): ) self.stdout.write( - self.style.SUCCESS( - f"Exported {len(export_data)} chats to {output_file}" - ) + self.style.SUCCESS(f"Exported {len(export_data)} chats to {output_file}") ) self.stdout.write(f"Type: {chat_type}") self.stdout.write(f"File size: {len(json.dumps(export_data))} bytes") @@ -580,15 +551,9 @@ class Command(BaseCommand): self.stdout.write(f" 🚩 Флаги: {' | '.join(flags)}") # Контекстный объект - if ( - hasattr(chat, "context_content_type") - and chat.context_content_type - ): + if hasattr(chat, "context_content_type") and chat.context_content_type: ctx = f"{chat.context_content_type.model}" - if ( - hasattr(chat, "context_object_id") - and chat.context_object_id - ): + if hasattr(chat, "context_object_id") and chat.context_object_id: ctx += f" (ID: {chat.context_object_id})" self.stdout.write(f" 🔗 Контекст: {ctx}") @@ -596,9 +561,7 @@ class Command(BaseCommand): if chat.avatar: self.stdout.write( f" 🖼️ Аватар: { - chat.avatar.url - if hasattr(chat.avatar, 'url') - else 'есть' + chat.avatar.url if hasattr(chat.avatar, 'url') else 'есть' }" ) @@ -642,8 +605,10 @@ class Command(BaseCommand): membership_info = f" {role_emoji}{membership.role}" if not membership.is_active: membership_info += " [НЕАКТИВЕН]" - except BaseException: - pass + except Exception as exc: + self.stderr.write( + f"Не удалось прочитать membership пользователя {user_id}: {exc}" + ) if full_name: self.stdout.write( @@ -688,16 +653,12 @@ class Command(BaseCommand): invalid_chats = Chat.objects.exclude(type__in=VALID_TYPES) if not invalid_chats.exists(): - self.stdout.write( - self.style.SUCCESS("✅ Все чаты имеют корректные типы") - ) + self.stdout.write(self.style.SUCCESS("✅ Все чаты имеют корректные типы")) return count = invalid_chats.count() self.stdout.write( - self.style.WARNING( - f"⚠️ Найдено {count} чатов с некорректными типами:\n" - ) + self.style.WARNING(f"⚠️ Найдено {count} чатов с некорректными типами:\n") ) for chat in invalid_chats: @@ -708,13 +669,10 @@ class Command(BaseCommand): self.stdout.write(f" Создан: {chat.created_at}") self.stdout.write("") - self.stdout.write( - self.style.WARNING(f"Всего найдено: {count} чатов") - ) + self.stdout.write(self.style.WARNING(f"Всего найдено: {count} чатов")) self.stdout.write( self.style.NOTICE( - "Для удаления используйте: " - "python manage.py check_chats --cleanup" + "Для удаления используйте: python manage.py check_chats --cleanup" ) ) @@ -723,9 +681,7 @@ class Command(BaseCommand): try: chat = Chat.objects.get(id=chat_id) except Chat.DoesNotExist: - raise CommandError( - f"❌ Чат с ID {chat_id} не найден" - ) + raise CommandError(f"❌ Чат с ID {chat_id} не найден") self.stdout.write(f"Чат ID {chat_id}:") self.stdout.write(f" Название: {chat.name}") @@ -735,9 +691,7 @@ class Command(BaseCommand): if chat.type not in VALID_TYPES: self.stdout.write( - self.style.ERROR( - f"\n⚠️ Чат имеет некорректный тип: {chat.type}" - ) + self.style.ERROR(f"\n⚠️ Чат имеет некорректный тип: {chat.type}") ) self.stdout.write( self.style.NOTICE(f"Допустимые типы: {', '.join(VALID_TYPES)}") @@ -756,25 +710,19 @@ class Command(BaseCommand): ) ) else: - self.stdout.write( - self.style.SUCCESS("\n✅ Чат в порядке, тип корректен") - ) + self.stdout.write(self.style.SUCCESS("\n✅ Чат в порядке, тип корректен")) def fix_invalid_chats(self, target_type="group", no_confirm=False): """Исправляет все чаты с некорректными типами""" invalid_chats = Chat.objects.exclude(type__in=VALID_TYPES) if not invalid_chats.exists(): - self.stdout.write( - self.style.SUCCESS("✅ Все чаты имеют корректные типы") - ) + self.stdout.write(self.style.SUCCESS("✅ Все чаты имеют корректные типы")) return count = invalid_chats.count() self.stdout.write( - self.style.WARNING( - f"⚠️ Найдено {count} чатов с некорректными типами\n" - ) + self.style.WARNING(f"⚠️ Найдено {count} чатов с некорректными типами\n") ) self.stdout.write( f"Чаты для исправления (будет установлен тип: {target_type}):" @@ -782,24 +730,21 @@ class Command(BaseCommand): for chat in invalid_chats: self.stdout.write( - f" • ID {chat.id}: {chat.name} (текущий тип: { - chat.type - } → новый тип: {target_type})" + f" • ID {chat.id}: {chat.name} (текущий тип: {chat.type} → новый тип: { + target_type + })" ) if not no_confirm: confirm = input( - f'\n⚠️ Изменить тип у {count} чатов на "{target_type}"? ' - "(yes/no): " + f'\n⚠️ Изменить тип у {count} чатов на "{target_type}"? (yes/no): ' ) if confirm.lower() not in ["yes", "y", "да"]: self.stdout.write(self.style.ERROR("❌ Отменено")) return updated = invalid_chats.update(type=target_type) - self.stdout.write( - self.style.SUCCESS(f"\n✅ Успешно обновлено {updated} чатов") - ) + self.stdout.write(self.style.SUCCESS(f"\n✅ Успешно обновлено {updated} чатов")) self.stdout.write(f"Всем чатам установлен тип: {target_type}") def change_chat_type(self, chat_id, new_type, no_confirm=False): @@ -829,8 +774,8 @@ class Command(BaseCommand): if not no_confirm: confirm = input( - f'\n⚠️ Изменить тип с "{ - chat.type}" на "{new_type}"? (yes/no): ') + f'\n⚠️ Изменить тип с "{chat.type}" на "{new_type}"? (yes/no): ' + ) if confirm.lower() not in ["yes", "y", "да"]: self.stdout.write(self.style.ERROR("❌ Отменено")) return @@ -840,9 +785,7 @@ class Command(BaseCommand): chat.save(update_fields=["type"]) self.stdout.write( self.style.SUCCESS( - f'\n✅ Тип чата "{chat.name}" изменен с "{old_type}" на "{ - new_type - }"' + f'\n✅ Тип чата "{chat.name}" изменен с "{old_type}" на "{new_type}"' ) ) @@ -851,23 +794,17 @@ class Command(BaseCommand): invalid_chats = Chat.objects.exclude(type__in=VALID_TYPES) if not invalid_chats.exists(): - self.stdout.write( - self.style.SUCCESS("✅ Все чаты имеют корректные типы") - ) + self.stdout.write(self.style.SUCCESS("✅ Все чаты имеют корректные типы")) return count = invalid_chats.count() self.stdout.write( - self.style.WARNING( - f"⚠️ Найдено {count} чатов с некорректными типами\n" - ) + self.style.WARNING(f"⚠️ Найдено {count} чатов с некорректными типами\n") ) self.stdout.write("Чаты для удаления:") for chat in invalid_chats: - self.stdout.write( - f" • ID {chat.id}: {chat.name} (тип: {chat.type})" - ) + self.stdout.write(f" • ID {chat.id}: {chat.name} (тип: {chat.type})") if not no_confirm: confirm = input(f"\n⚠️ Удалить {count} чатов? (yes/no): ") @@ -907,7 +844,5 @@ class Command(BaseCommand): chat_name = chat.name chat.delete() self.stdout.write( - self.style.SUCCESS( - f'\n✅ Чат "{chat_name}" (ID {chat_id}) успешно удален' - ) + self.style.SUCCESS(f'\n✅ Чат "{chat_name}" (ID {chat_id}) успешно удален') ) diff --git a/backend/communications/management/commands/init_reactions.py b/backend/communications/management/commands/init_reactions.py index 70fc6918..20783cc8 100644 --- a/backend/communications/management/commands/init_reactions.py +++ b/backend/communications/management/commands/init_reactions.py @@ -2,22 +2,23 @@ """Команда для инициализации дефолтных реакций""" from django.core.management.base import BaseCommand + from communications.models import AvailableReaction class Command(BaseCommand): - help = 'Инициализирует дефолтные реакции в БД' + help = "Инициализирует дефолтные реакции в БД" def handle(self, *args, **options): default_reactions = [ - ('👍', 'Лайк', 1), - ('❤️', 'Сердце', 2), - ('😂', 'Смех', 3), - ('😮', 'Удивление', 4), - ('😢', 'Грусть', 5), - ('🙏', 'Спасибо', 6), - ('👏', 'Аплодисменты', 7), - ('🔥', 'Огонь', 8), + ("👍", "Лайк", 1), + ("❤️", "Сердце", 2), + ("😂", "Смех", 3), + ("😮", "Удивление", 4), + ("😢", "Грусть", 5), + ("🙏", "Спасибо", 6), + ("👏", "Аплодисменты", 7), + ("🔥", "Огонь", 8), ] created_count = 0 @@ -25,42 +26,30 @@ class Command(BaseCommand): for emoji, name, order in default_reactions: reaction, created = AvailableReaction.objects.get_or_create( - emoji=emoji, - defaults={ - 'name': name, - 'order': order, - 'is_active': True - } + emoji=emoji, defaults={"name": name, "order": order, "is_active": True} ) if created: created_count += 1 self.stdout.write( - self.style.SUCCESS( - f'✓ Создана реакция: {emoji} {name}' - ) + self.style.SUCCESS(f"✓ Создана реакция: {emoji} {name}") ) else: # Обновляем порядок если изменился if reaction.order != order: reaction.order = order - reaction.save(update_fields=['order']) + reaction.save(update_fields=["order"]) updated_count += 1 self.stdout.write( - self.style.WARNING( - f'↻ Обновлён порядок: {emoji} {name}' - ) + self.style.WARNING(f"↻ Обновлён порядок: {emoji} {name}") ) else: self.stdout.write( - self.style.NOTICE( - f'- Уже существует: {emoji} {name}' - ) + self.style.NOTICE(f"- Уже существует: {emoji} {name}") ) self.stdout.write( self.style.SUCCESS( - f'\nГотово! Создано: {created_count}, ' - f'Обновлено: {updated_count}' + f"\nГотово! Создано: {created_count}, Обновлено: {updated_count}" ) ) diff --git a/backend/communications/management/commands/manage_chat_permissions.py b/backend/communications/management/commands/manage_chat_permissions.py index ec957497..265227a0 100644 --- a/backend/communications/management/commands/manage_chat_permissions.py +++ b/backend/communications/management/commands/manage_chat_permissions.py @@ -17,80 +17,71 @@ python manage.py manage_chat_permissions --chat-id 62 --list python manage.py manage_chat_permissions --chat-id 62 --user-id 10 --remove """ -from django.core.management.base import BaseCommand, CommandError from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand, CommandError + from communications.models import Chat, ChatMembership User = get_user_model() class Command(BaseCommand): - help = 'Управление правами участников в чатах' + help = "Управление правами участников в чатах" def add_arguments(self, parser): + parser.add_argument("--chat-id", type=int, required=True, help="ID чата") + parser.add_argument("--user-id", type=int, help="ID пользователя") parser.add_argument( - '--chat-id', - type=int, - required=True, - help='ID чата' + "--role", + choices=["admin", "moderator", "member", "guest"], + help="Роль пользователя в чате", ) parser.add_argument( - '--user-id', - type=int, - help='ID пользователя' + "--can-add-members", + action="store_true", + help="Разрешить добавлять участников", ) parser.add_argument( - '--role', - choices=['admin', 'moderator', 'member', 'guest'], - help='Роль пользователя в чате' + "--can-remove-members", + action="store_true", + help="Разрешить удалять участников", ) parser.add_argument( - '--can-add-members', - action='store_true', - help='Разрешить добавлять участников' + "--can-pin-messages", + action="store_true", + help="Разрешить закреплять сообщения", ) parser.add_argument( - '--can-remove-members', - action='store_true', - help='Разрешить удалять участников' + "--list", action="store_true", help="Показать список участников чата" ) parser.add_argument( - '--can-pin-messages', - action='store_true', - help='Разрешить закреплять сообщения' - ) - parser.add_argument( - '--list', - action='store_true', - help='Показать список участников чата' - ) - parser.add_argument( - '--remove', - action='store_true', - help='Удалить права (деактивировать membership)' + "--remove", + action="store_true", + help="Удалить права (деактивировать membership)", ) def handle(self, *args, **options): - chat_id = options['chat_id'] - user_id = options.get('user_id') - role = options.get('role') - can_add_members = options.get('can_add_members') - can_remove_members = options.get('can_remove_members') - can_pin_messages = options.get('can_pin_messages') - list_members = options.get('list') - remove = options.get('remove') + chat_id = options["chat_id"] + user_id = options.get("user_id") + role = options.get("role") + can_add_members = options.get("can_add_members") + can_remove_members = options.get("can_remove_members") + can_pin_messages = options.get("can_pin_messages") + list_members = options.get("list") + remove = options.get("remove") # Проверяем существование чата try: chat = Chat.objects.get(pk=chat_id) except Chat.DoesNotExist: - raise CommandError(f'Чат с ID {chat_id} не найден') + raise CommandError(f"Чат с ID {chat_id} не найден") - self.stdout.write(self.style.SUCCESS( - f'\\n📱 Чат: {chat.name or f"#{chat_id}"} ' - f'({chat.get_type_display()})' - )) - self.stdout.write(f' Создатель: {chat.created_by}\\n') + self.stdout.write( + self.style.SUCCESS( + f"\\n📱 Чат: {chat.name or f'#{chat_id}'} ({chat.get_type_display()})" + ) + ) + self.stdout.write(f" Создатель: {chat.created_by}\\n") # Режим списка участников if list_members: @@ -99,15 +90,13 @@ class Command(BaseCommand): # Для остальных операций требуется user_id if not user_id: - raise CommandError( - 'Требуется указать --user-id (или используйте --list)' - ) + raise CommandError("Требуется указать --user-id (или используйте --list)") # Проверяем существование пользователя try: user = User.objects.get(pk=user_id) except User.DoesNotExist: - raise CommandError(f'Пользователь с ID {user_id} не найден') + raise CommandError(f"Пользователь с ID {user_id} не найден") # Режим удаления прав if remove: @@ -116,77 +105,78 @@ class Command(BaseCommand): # Режим назначения прав self._assign_permissions( - chat, user, role, - can_add_members, can_remove_members, can_pin_messages + chat, user, role, can_add_members, can_remove_members, can_pin_messages ) def _list_members(self, chat): """Показать список участников чата""" - memberships = chat.memberships.select_related('user').filter( - is_active=True - ).order_by('-role', 'joined_at') + memberships = ( + chat.memberships.select_related("user") + .filter(is_active=True) + .order_by("-role", "joined_at") + ) if not memberships.exists(): - self.stdout.write(' Нет записей в ChatMembership') + self.stdout.write(" Нет записей в ChatMembership") participants = chat.participants.all() if participants.exists(): self.stdout.write( - f' Участники через participants: ' - f'{participants.count()}' + f" Участники через participants: {participants.count()}" ) return - self.stdout.write(self.style.SUCCESS( - f'👥 Участники чата ({memberships.count()}):\\n' - )) + self.stdout.write( + self.style.SUCCESS(f"👥 Участники чата ({memberships.count()}):\\n") + ) for m in memberships: role_emoji = { - 'admin': '🔴', - 'moderator': '🟠', - 'member': '🟢', - 'guest': '⚪' - }.get(m.role, '❓') + "admin": "🔴", + "moderator": "🟠", + "member": "🟢", + "guest": "⚪", + }.get(m.role, "❓") flags = [] if m.can_add_members: - flags.append('➕ add') + flags.append("➕ add") if m.can_remove_members: - flags.append('➖ remove') + flags.append("➖ remove") if m.can_pin_messages: - flags.append('📌 pin') + flags.append("📌 pin") flags_str = f" [{', '.join(flags)}]" if flags else "" self.stdout.write( - f' {role_emoji} {m.user.get_full_name()} ' - f'(ID: {m.user_id}) - {m.get_role_display()}' - f'{flags_str}' + f" {role_emoji} {m.user.get_full_name()} " + f"(ID: {m.user_id}) - {m.get_role_display()}" + f"{flags_str}" ) def _remove_permissions(self, chat, user): """Удалить права (деактивировать membership)""" try: membership = ChatMembership.objects.get( - chat=chat, - user=user, - is_active=True + chat=chat, user=user, is_active=True ) membership.is_active = False membership.save() - self.stdout.write(self.style.SUCCESS( - f'✅ Права пользователя {user.get_full_name()} удалены' - )) + self.stdout.write( + self.style.SUCCESS( + f"✅ Права пользователя {user.get_full_name()} удалены" + ) + ) except ChatMembership.DoesNotExist: - self.stdout.write(self.style.WARNING( - f'⚠️ Активный membership для пользователя ' - f'{user.get_full_name()} не найден' - )) + self.stdout.write( + self.style.WARNING( + f"⚠️ Активный membership для пользователя " + f"{user.get_full_name()} не найден" + ) + ) def _assign_permissions( - self, chat, user, role, - can_add_members, can_remove_members, can_pin_messages + self, chat, user, role, can_add_members, can_remove_members, can_pin_messages ): """Назначить права пользователю""" # Если не указана роль, используем текущую или member по умолчанию @@ -197,20 +187,20 @@ class Command(BaseCommand): ) role = existing.role except ChatMembership.DoesNotExist: - role = 'member' + role = "member" # Создать или обновить membership membership, created = ChatMembership.objects.get_or_create( chat=chat, user=user, defaults={ - 'role': role, - 'can_add_members': can_add_members, - 'can_remove_members': can_remove_members, - 'can_pin_messages': can_pin_messages, - 'invited_by': chat.created_by, - 'is_active': True, - } + "role": role, + "can_add_members": can_add_members, + "can_remove_members": can_remove_members, + "can_pin_messages": can_pin_messages, + "invited_by": chat.created_by, + "is_active": True, + }, ) if not created: @@ -231,26 +221,22 @@ class Command(BaseCommand): chat.participants.add(user) # Вывод результата - action = '🆕 Создан' if created else '🔄 Обновлен' + action = "🆕 Создан" if created else "🔄 Обновлен" role_emoji = { - 'admin': '🔴', - 'moderator': '🟠', - 'member': '🟢', - 'guest': '⚪' - }.get(role, '❓') + "admin": "🔴", + "moderator": "🟠", + "member": "🟢", + "guest": "⚪", + }.get(role, "❓") - self.stdout.write( - f'\\n{action} membership для {user.get_full_name()}:' - ) - self.stdout.write( - f' {role_emoji} Роль: {membership.get_role_display()}' - ) + self.stdout.write(f"\\n{action} membership для {user.get_full_name()}:") + self.stdout.write(f" {role_emoji} Роль: {membership.get_role_display()}") if membership.can_add_members: - self.stdout.write(' ➕ Может добавлять участников') + self.stdout.write(" ➕ Может добавлять участников") if membership.can_remove_members: - self.stdout.write(' ➖ Может удалять участников') + self.stdout.write(" ➖ Может удалять участников") if membership.can_pin_messages: - self.stdout.write(' 📌 Может закреплять сообщения') + self.stdout.write(" 📌 Может закреплять сообщения") - self.stdout.write(self.style.SUCCESS('\\n✅ Права успешно назначены')) + self.stdout.write(self.style.SUCCESS("\\n✅ Права успешно назначены")) diff --git a/backend/communications/management/commands/populate_image_dimensions.py b/backend/communications/management/commands/populate_image_dimensions.py index d51e7c08..bb56cdca 100644 --- a/backend/communications/management/commands/populate_image_dimensions.py +++ b/backend/communications/management/commands/populate_image_dimensions.py @@ -1,35 +1,37 @@ """ Management команда для заполнения размеров существующих изображений """ -from django.core.management.base import BaseCommand -from communications.models import MessageAttachment -from PIL import Image + import logging +from django.core.management.base import BaseCommand +from PIL import Image + +from communications.models import MessageAttachment + logger = logging.getLogger(__name__) class Command(BaseCommand): - help = 'Заполняет поля width и height для существующих изображений' + help = "Заполняет поля width и height для существующих изображений" def add_arguments(self, parser): parser.add_argument( - '--dry-run', - action='store_true', - help='Показать изменения без сохранения в БД', + "--dry-run", + action="store_true", + help="Показать изменения без сохранения в БД", ) def handle(self, *args, **options): - dry_run = options['dry_run'] + dry_run = options["dry_run"] # Получаем все изображения без размеров attachments = MessageAttachment.objects.filter( - file_type='image', - width__isnull=True + file_type="image", width__isnull=True ) total = attachments.count() - self.stdout.write(f'Найдено {total} изображений без размеров') + self.stdout.write(f"Найдено {total} изображений без размеров") updated = 0 errors = 0 @@ -37,36 +39,36 @@ class Command(BaseCommand): for att in attachments: try: if not att.file: - self.stdout.write(self.style.WARNING( - f'Attachment #{att.id}: файл не найден' - )) + self.stdout.write( + self.style.WARNING(f"Attachment #{att.id}: файл не найден") + ) continue # Открываем файл без сохранения его в память целиком - with att.file.open('rb') as f: + with att.file.open("rb") as f: image = Image.open(f) width, height = image.size if dry_run: self.stdout.write( - f'Attachment #{att.id}: {width}x{height} (dry-run)' + f"Attachment #{att.id}: {width}x{height} (dry-run)" ) else: att.width = width att.height = height - att.save(update_fields=['width', 'height']) - self.stdout.write(self.style.SUCCESS( - f'Attachment #{att.id}: {width}x{height}' - )) + att.save(update_fields=["width", "height"]) + self.stdout.write( + self.style.SUCCESS(f"Attachment #{att.id}: {width}x{height}") + ) updated += 1 except Exception as e: errors += 1 - self.stdout.write(self.style.ERROR( - f'Attachment #{att.id}: ошибка - {e}' - )) + self.stdout.write( + self.style.ERROR(f"Attachment #{att.id}: ошибка - {e}") + ) - self.stdout.write(self.style.SUCCESS( - f'\nОбработано: {updated}/{total}, ошибок: {errors}' - )) + self.stdout.write( + self.style.SUCCESS(f"\nОбработано: {updated}/{total}, ошибок: {errors}") + ) diff --git a/backend/communications/management/commands/update_department_chat_names.py b/backend/communications/management/commands/update_department_chat_names.py index 9cb0099b..4205a917 100644 --- a/backend/communications/management/commands/update_department_chat_names.py +++ b/backend/communications/management/commands/update_department_chat_names.py @@ -1,41 +1,35 @@ """ Management command для обновления названий основных чатов отделов """ + from django.core.management.base import BaseCommand + from communications.models import Chat class Command(BaseCommand): - help = 'Обновляет названия основных чатов отделов' + help = "Обновляет названия основных чатов отделов" def handle(self, *args, **options): # Находим все основные чаты отделов без названия - chats = Chat.objects.filter( - type='department', - is_main=True - ).select_related('department') + chats = Chat.objects.filter(type="department", is_main=True).select_related( + "department" + ) updated = 0 for chat in chats: - if not chat.name or chat.name == '': - old_name = chat.name or '(пусто)' + if not chat.name or chat.name == "": + old_name = chat.name or "(пусто)" chat.name = f"Основной чат {chat.department.name}" chat.save() self.stdout.write( self.style.SUCCESS( - f'✓ Обновлен чат ID {chat.id}: ' - f'"{old_name}" → "{chat.name}"' + f'✓ Обновлен чат ID {chat.id}: "{old_name}" → "{chat.name}"' ) ) updated += 1 if updated == 0: - self.stdout.write( - self.style.WARNING('Нет чатов для обновления') - ) + self.stdout.write(self.style.WARNING("Нет чатов для обновления")) else: - self.stdout.write( - self.style.SUCCESS( - f'\n✓ Обновлено названий: {updated}' - ) - ) + self.stdout.write(self.style.SUCCESS(f"\n✓ Обновлено названий: {updated}")) diff --git a/backend/communications/management/commands/verify_chat_migration.py b/backend/communications/management/commands/verify_chat_migration.py index 1b2436bd..d1ee57f1 100644 --- a/backend/communications/management/commands/verify_chat_migration.py +++ b/backend/communications/management/commands/verify_chat_migration.py @@ -10,8 +10,9 @@ 3. Переименование include_all_employees → include_all_users """ -from django.core.management.base import BaseCommand from django.contrib.contenttypes.models import ContentType +from django.core.management.base import BaseCommand + from communications.models import Chat @@ -37,9 +38,7 @@ class Command(BaseCommand): # ===== 1. Проверка department → context_object ===== self.stdout.write( - self.style.HTTP_INFO( - "1. Проверка миграции department → context_object..." - ) + self.style.HTTP_INFO("1. Проверка миграции department → context_object...") ) # Проверяем наличие старого поля (может быть удалено) @@ -64,9 +63,7 @@ class Command(BaseCommand): chats_with_dept = Chat.objects.filter(department__isnull=False) count_dept = chats_with_dept.count() - self.stdout.write( - f" Найдено чатов с department: {count_dept}" - ) + self.stdout.write(f" Найдено чатов с department: {count_dept}") for chat in chats_with_dept: # Проверяем что context заполнен @@ -85,15 +82,11 @@ class Command(BaseCommand): ) elif chat.context_content_type_id != dept_ct.id: errors.append( - f"Chat #{ - chat.id - }: context_content_type не Department" + f"Chat #{chat.id}: context_content_type не Department" ) elif verbose: self.stdout.write( - f" ✓ Chat #{chat.id}: department={ - chat.department_id - } " + f" ✓ Chat #{chat.id}: department={chat.department_id} " f"→ context={chat.context_object_id}" ) @@ -135,14 +128,10 @@ class Command(BaseCommand): main_chats = Chat.objects.filter(is_main=True) count_main = main_chats.count() - self.stdout.write( - f" Найдено главных чатов (is_main=True): {count_main}" - ) + self.stdout.write(f" Найдено главных чатов (is_main=True): {count_main}") for chat in main_chats: - flags_primary = ( - chat.flags.get("is_primary") if chat.flags else False - ) + flags_primary = chat.flags.get("is_primary") if chat.flags else False if not flags_primary: errors.append( @@ -152,9 +141,7 @@ class Command(BaseCommand): ) elif verbose: self.stdout.write( - f" ✓ Chat #{ - chat.id - }: is_main=True → flags['is_primary']=True" + f" ✓ Chat #{chat.id}: is_main=True → flags['is_primary']=True" ) if not errors and count_main > 0: @@ -165,14 +152,10 @@ class Command(BaseCommand): ) ) elif count_main == 0: - self.stdout.write( - self.style.WARNING(" ⚠ Главных чатов не найдено") - ) + self.stdout.write(self.style.WARNING(" ⚠ Главных чатов не найдено")) else: # Поле is_main удалено - проверяем только flags - chats_with_primary = Chat.objects.filter( - flags__is_primary=True - ).count() + chats_with_primary = Chat.objects.filter(flags__is_primary=True).count() self.stdout.write( self.style.SUCCESS( " ✓ Поле 'is_main' удалено, " @@ -214,9 +197,7 @@ class Command(BaseCommand): self.stdout.write("\n" + self.style.HTTP_INFO("4. Общая статистика...")) total = Chat.objects.count() - with_context = Chat.objects.filter( - context_content_type__isnull=False - ).count() + with_context = Chat.objects.filter(context_content_type__isnull=False).count() with_flags_primary = Chat.objects.filter(flags__is_primary=True).count() self.stdout.write(f" Всего чатов: {total}") @@ -237,10 +218,8 @@ class Command(BaseCommand): if warnings: self.stdout.write( - "\n" + - self.style.WARNING( - f"⚠️ ПРЕДУПРЕЖДЕНИЙ: { - len(warnings)}")) + "\n" + self.style.WARNING(f"⚠️ ПРЕДУПРЕЖДЕНИЙ: {len(warnings)}") + ) for warning in warnings: self.stdout.write(self.style.WARNING(f" • {warning}")) diff --git a/backend/communications/mentions.py b/backend/communications/mentions.py index 7b2ade1a..8c04a020 100644 --- a/backend/communications/mentions.py +++ b/backend/communications/mentions.py @@ -27,9 +27,7 @@ def _normalize_search_value(value) -> str: def filter_mentionable_users(users: Iterable, query: str) -> list: """Match every query token against any employee identity field.""" tokens = [ - _normalize_search_value(token) - for token in query.split() - if token.strip() + _normalize_search_value(token) for token in query.split() if token.strip() ] if not tokens: return list(users) @@ -53,10 +51,15 @@ def get_mentionable_users(chat, *, author=None) -> QuerySet: if chat.type == "global" or chat.include_all_users: users = User.objects.visible_in_ui().filter(is_active=True) else: - users = User.objects.visible_in_ui().filter( - Q(chat_memberships__chat=chat, chat_memberships__is_active=True) - | Q(chats=chat) - ).filter(is_active=True).distinct() + users = ( + User.objects.visible_in_ui() + .filter( + Q(chat_memberships__chat=chat, chat_memberships__is_active=True) + | Q(chats=chat) + ) + .filter(is_active=True) + .distinct() + ) if author is not None: users = users.exclude(pk=author.pk) @@ -120,7 +123,10 @@ def build_message_mentions(chat, author, content: str, user_ids) -> list[dict]: ) for match in pattern.finditer(content): start, end = match.span() - if any(start < used_end and end > used_start for used_start, used_end in occupied_ranges): + if any( + start < used_end and end > used_start + for used_start, used_end in occupied_ranges + ): continue occupied_ranges.append((start, end)) mentions.append( diff --git a/backend/communications/migrations/0001_initial.py b/backend/communications/migrations/0001_initial.py index 8ce4e154..e75492d1 100644 --- a/backend/communications/migrations/0001_initial.py +++ b/backend/communications/migrations/0001_initial.py @@ -6,42 +6,108 @@ from django.db import migrations, models class Migration(migrations.Migration): - initial = True dependencies = [ - ('employees', '0004_alter_employee_wechat'), + ("employees", "0004_alter_employee_wechat"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='Chat', + name="Chat", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('type', models.CharField(choices=[('private', 'Личный'), ('department', 'Отдел'), ('global', 'Глобальный')], max_length=16, verbose_name='Тип чата')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создан')), - ('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='employees.department', verbose_name='Отдел')), - ('participants', models.ManyToManyField(blank=True, related_name='chats', to=settings.AUTH_USER_MODEL, verbose_name='Участники')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "type", + models.CharField( + choices=[ + ("private", "Личный"), + ("department", "Отдел"), + ("global", "Глобальный"), + ], + max_length=16, + verbose_name="Тип чата", + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Создан"), + ), + ( + "department", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="employees.department", + verbose_name="Отдел", + ), + ), + ( + "participants", + models.ManyToManyField( + blank=True, + related_name="chats", + to=settings.AUTH_USER_MODEL, + verbose_name="Участники", + ), + ), ], options={ - 'verbose_name': 'Чат', - 'verbose_name_plural': 'Чаты', + "verbose_name": "Чат", + "verbose_name_plural": "Чаты", }, ), migrations.CreateModel( - name='Message', + name="Message", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('content', models.TextField(verbose_name='Текст сообщения')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Время отправки')), - ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Автор')), - ('chat', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='communications.chat', verbose_name='Чат')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("content", models.TextField(verbose_name="Текст сообщения")), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Время отправки" + ), + ), + ( + "author", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + verbose_name="Автор", + ), + ), + ( + "chat", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="messages", + to="communications.chat", + verbose_name="Чат", + ), + ), ], options={ - 'verbose_name': 'Сообщение', - 'verbose_name_plural': 'Сообщения', - 'ordering': ['created_at'], + "verbose_name": "Сообщение", + "verbose_name_plural": "Сообщения", + "ordering": ["created_at"], }, ), ] diff --git a/backend/communications/migrations/0002_chat_is_main_chat_unique_main_global_chat_and_more.py b/backend/communications/migrations/0002_chat_is_main_chat_unique_main_global_chat_and_more.py index bf634acb..d49624ee 100644 --- a/backend/communications/migrations/0002_chat_is_main_chat_unique_main_global_chat_and_more.py +++ b/backend/communications/migrations/0002_chat_is_main_chat_unique_main_global_chat_and_more.py @@ -5,25 +5,32 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('communications', '0001_initial'), - ('employees', '0006_alter_employeedepartment_options_and_more'), + ("communications", "0001_initial"), + ("employees", "0006_alter_employeedepartment_options_and_more"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.AddField( - model_name='chat', - name='is_main', - field=models.BooleanField(default=False, verbose_name='Основной чат'), + model_name="chat", + name="is_main", + field=models.BooleanField(default=False, verbose_name="Основной чат"), ), migrations.AddConstraint( - model_name='chat', - constraint=models.UniqueConstraint(condition=models.Q(('is_main', True), ('type', 'global')), fields=('type',), name='unique_main_global_chat'), + model_name="chat", + constraint=models.UniqueConstraint( + condition=models.Q(("is_main", True), ("type", "global")), + fields=("type",), + name="unique_main_global_chat", + ), ), migrations.AddConstraint( - model_name='chat', - constraint=models.UniqueConstraint(condition=models.Q(('is_main', True), ('type', 'department')), fields=('type', 'department'), name='unique_main_department_chat'), + model_name="chat", + constraint=models.UniqueConstraint( + condition=models.Q(("is_main", True), ("type", "department")), + fields=("type", "department"), + name="unique_main_department_chat", + ), ), ] diff --git a/backend/communications/migrations/0003_alter_chat_department_alter_chat_participants.py b/backend/communications/migrations/0003_alter_chat_department_alter_chat_participants.py index eefeb04d..8abbcd3e 100644 --- a/backend/communications/migrations/0003_alter_chat_department_alter_chat_participants.py +++ b/backend/communications/migrations/0003_alter_chat_department_alter_chat_participants.py @@ -6,22 +6,34 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('communications', '0002_chat_is_main_chat_unique_main_global_chat_and_more'), - ('employees', '0009_alter_employeedepartment_date_from_and_more'), + ("communications", "0002_chat_is_main_chat_unique_main_global_chat_and_more"), + ("employees", "0009_alter_employeedepartment_date_from_and_more"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.AlterField( - model_name='chat', - name='department', - field=models.ForeignKey(blank=True, help_text='Указывается только для чатов отдела', null=True, on_delete=django.db.models.deletion.CASCADE, to='employees.department', verbose_name='Отдел'), + model_name="chat", + name="department", + field=models.ForeignKey( + blank=True, + help_text="Указывается только для чатов отдела", + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="employees.department", + verbose_name="Отдел", + ), ), migrations.AlterField( - model_name='chat', - name='participants', - field=models.ManyToManyField(blank=True, help_text='Используется только для личных чатов', related_name='chats', to=settings.AUTH_USER_MODEL, verbose_name='Участники'), + model_name="chat", + name="participants", + field=models.ManyToManyField( + blank=True, + help_text="Используется только для личных чатов", + related_name="chats", + to=settings.AUTH_USER_MODEL, + verbose_name="Участники", + ), ), ] diff --git a/backend/communications/migrations/0004_chatreadstate.py b/backend/communications/migrations/0004_chatreadstate.py index 3212d53a..cd305272 100644 --- a/backend/communications/migrations/0004_chatreadstate.py +++ b/backend/communications/migrations/0004_chatreadstate.py @@ -6,25 +6,54 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('communications', '0003_alter_chat_department_alter_chat_participants'), + ("communications", "0003_alter_chat_department_alter_chat_participants"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='ChatReadState', + name="ChatReadState", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('last_read_at', models.DateTimeField(blank=True, null=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('chat', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='read_states', to='communications.chat')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chat_read_states', to=settings.AUTH_USER_MODEL)), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("last_read_at", models.DateTimeField(blank=True, null=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "chat", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="read_states", + to="communications.chat", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="chat_read_states", + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'indexes': [models.Index(fields=['chat', 'user'], name='communicati_chat_id_ac306b_idx'), models.Index(fields=['chat', 'last_read_at'], name='communicati_chat_id_edefdb_idx')], - 'unique_together': {('chat', 'user')}, + "indexes": [ + models.Index( + fields=["chat", "user"], name="communicati_chat_id_ac306b_idx" + ), + models.Index( + fields=["chat", "last_read_at"], + name="communicati_chat_id_edefdb_idx", + ), + ], + "unique_together": {("chat", "user")}, }, ), ] diff --git a/backend/communications/migrations/0005_alter_chatreadstate_unique_together_alter_chat_type_and_more.py b/backend/communications/migrations/0005_alter_chatreadstate_unique_together_alter_chat_type_and_more.py index 4610d65a..83302b99 100644 --- a/backend/communications/migrations/0005_alter_chatreadstate_unique_together_alter_chat_type_and_more.py +++ b/backend/communications/migrations/0005_alter_chatreadstate_unique_together_alter_chat_type_and_more.py @@ -5,42 +5,60 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('communications', '0004_chatreadstate'), - ('employees', '0010_historicalemployeeaction_historicalemployeeposition'), + ("communications", "0004_chatreadstate"), + ("employees", "0010_historicalemployeeaction_historicalemployeeposition"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.AlterUniqueTogether( - name='chatreadstate', + name="chatreadstate", unique_together=set(), ), migrations.AlterField( - model_name='chat', - name='type', - field=models.CharField(choices=[('private', 'Личный'), ('department', 'Отдел'), ('global', 'Глобальный')], db_index=True, max_length=16, verbose_name='Тип чата'), + model_name="chat", + name="type", + field=models.CharField( + choices=[ + ("private", "Личный"), + ("department", "Отдел"), + ("global", "Глобальный"), + ], + db_index=True, + max_length=16, + verbose_name="Тип чата", + ), ), migrations.AlterField( - model_name='message', - name='created_at', - field=models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='Время отправки'), + model_name="message", + name="created_at", + field=models.DateTimeField( + auto_now_add=True, db_index=True, verbose_name="Время отправки" + ), ), migrations.AddIndex( - model_name='chat', - index=models.Index(fields=['type', 'is_main'], name='communicati_type_9ee446_idx'), + model_name="chat", + index=models.Index( + fields=["type", "is_main"], name="communicati_type_9ee446_idx" + ), ), migrations.AddIndex( - model_name='chat', - index=models.Index(fields=['department'], name='communicati_departm_e2e705_idx'), + model_name="chat", + index=models.Index( + fields=["department"], name="communicati_departm_e2e705_idx" + ), ), migrations.AddIndex( - model_name='chat', - index=models.Index(fields=['created_at'], name='communicati_created_a80df3_idx'), + model_name="chat", + index=models.Index( + fields=["created_at"], name="communicati_created_a80df3_idx" + ), ), migrations.AddConstraint( - model_name='chatreadstate', - constraint=models.UniqueConstraint(fields=('chat', 'user'), name='uniq_chatreadstate_chat_user'), + model_name="chatreadstate", + constraint=models.UniqueConstraint( + fields=("chat", "user"), name="uniq_chatreadstate_chat_user" + ), ), ] diff --git a/backend/communications/migrations/0006_chat_avatar_chat_created_by_chat_description_and_more.py b/backend/communications/migrations/0006_chat_avatar_chat_created_by_chat_description_and_more.py index edb4b5eb..dfa3d4d5 100644 --- a/backend/communications/migrations/0006_chat_avatar_chat_created_by_chat_description_and_more.py +++ b/backend/communications/migrations/0006_chat_avatar_chat_created_by_chat_description_and_more.py @@ -6,36 +6,59 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('communications', '0005_alter_chatreadstate_unique_together_alter_chat_type_and_more'), + ( + "communications", + "0005_alter_chatreadstate_unique_together_alter_chat_type_and_more", + ), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.AddField( - model_name='chat', - name='avatar', - field=models.ImageField(blank=True, null=True, upload_to='chat_avatars/%Y/%m/', verbose_name='Аватар чата'), + model_name="chat", + name="avatar", + field=models.ImageField( + blank=True, + null=True, + upload_to="chat_avatars/%Y/%m/", + verbose_name="Аватар чата", + ), ), migrations.AddField( - model_name='chat', - name='created_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_chats', to=settings.AUTH_USER_MODEL, verbose_name='Создатель'), + model_name="chat", + name="created_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="created_chats", + to=settings.AUTH_USER_MODEL, + verbose_name="Создатель", + ), ), migrations.AddField( - model_name='chat', - name='description', - field=models.TextField(blank=True, verbose_name='Описание чата'), + model_name="chat", + name="description", + field=models.TextField(blank=True, verbose_name="Описание чата"), ), migrations.AddField( - model_name='chat', - name='include_all_employees', - field=models.BooleanField(default=False, help_text='Для анонсов и общих чатов', verbose_name='Включить всех сотрудников'), + model_name="chat", + name="include_all_employees", + field=models.BooleanField( + default=False, + help_text="Для анонсов и общих чатов", + verbose_name="Включить всех сотрудников", + ), ), migrations.AddField( - model_name='chat', - name='name', - field=models.CharField(blank=True, help_text='Для групповых/канальных чатов', max_length=255, verbose_name='Название чата'), + model_name="chat", + name="name", + field=models.CharField( + blank=True, + help_text="Для групповых/канальных чатов", + max_length=255, + verbose_name="Название чата", + ), ), ] diff --git a/backend/communications/migrations/0007_alter_chat_type.py b/backend/communications/migrations/0007_alter_chat_type.py index b14a7ad2..dcff5864 100644 --- a/backend/communications/migrations/0007_alter_chat_type.py +++ b/backend/communications/migrations/0007_alter_chat_type.py @@ -4,15 +4,29 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('communications', '0006_chat_avatar_chat_created_by_chat_description_and_more'), + ( + "communications", + "0006_chat_avatar_chat_created_by_chat_description_and_more", + ), ] operations = [ migrations.AlterField( - model_name='chat', - name='type', - field=models.CharField(choices=[('private', 'Личный'), ('group', 'Групповой'), ('department', 'Отдел'), ('channel', 'Канал'), ('announcement', 'Объявления'), ('global', 'Глобальный')], db_index=True, max_length=16, verbose_name='Тип чата'), + model_name="chat", + name="type", + field=models.CharField( + choices=[ + ("private", "Личный"), + ("group", "Групповой"), + ("department", "Отдел"), + ("channel", "Канал"), + ("announcement", "Объявления"), + ("global", "Глобальный"), + ], + db_index=True, + max_length=16, + verbose_name="Тип чата", + ), ), ] diff --git a/backend/communications/migrations/0008_chatusersettings.py b/backend/communications/migrations/0008_chatusersettings.py index dd79f84a..9165a397 100644 --- a/backend/communications/migrations/0008_chatusersettings.py +++ b/backend/communications/migrations/0008_chatusersettings.py @@ -6,32 +6,77 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('communications', '0007_alter_chat_type'), + ("communications", "0007_alter_chat_type"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='ChatUserSettings', + name="ChatUserSettings", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('is_pinned', models.BooleanField(default=False, verbose_name='Закреплен')), - ('pinned_at', models.DateTimeField(blank=True, null=True, verbose_name='Время закрепления')), - ('pin_order', models.IntegerField(default=0, verbose_name='Порядок')), - ('notifications_enabled', models.BooleanField(default=True, verbose_name='Уведомления')), - ('custom_name', models.CharField(blank=True, max_length=255, verbose_name='Свое название')), - ('is_hidden', models.BooleanField(default=False, verbose_name='Скрыт')), - ('chat', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_settings', to='communications.chat')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chat_settings', to=settings.AUTH_USER_MODEL)), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "is_pinned", + models.BooleanField(default=False, verbose_name="Закреплен"), + ), + ( + "pinned_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Время закрепления" + ), + ), + ("pin_order", models.IntegerField(default=0, verbose_name="Порядок")), + ( + "notifications_enabled", + models.BooleanField(default=True, verbose_name="Уведомления"), + ), + ( + "custom_name", + models.CharField( + blank=True, max_length=255, verbose_name="Свое название" + ), + ), + ("is_hidden", models.BooleanField(default=False, verbose_name="Скрыт")), + ( + "chat", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="user_settings", + to="communications.chat", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="chat_settings", + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'verbose_name': 'Настройки чата', - 'verbose_name_plural': 'Настройки чатов', - 'ordering': ['-is_pinned', '-pinned_at'], - 'indexes': [models.Index(fields=['user', 'is_pinned'], name='communicati_user_id_2d5887_idx'), models.Index(fields=['chat', 'user'], name='communicati_chat_id_86bcb6_idx')], - 'unique_together': {('chat', 'user')}, + "verbose_name": "Настройки чата", + "verbose_name_plural": "Настройки чатов", + "ordering": ["-is_pinned", "-pinned_at"], + "indexes": [ + models.Index( + fields=["user", "is_pinned"], + name="communicati_user_id_2d5887_idx", + ), + models.Index( + fields=["chat", "user"], name="communicati_chat_id_86bcb6_idx" + ), + ], + "unique_together": {("chat", "user")}, }, ), ] diff --git a/backend/communications/migrations/0009_message_deleted_at_message_deleted_by_and_more.py b/backend/communications/migrations/0009_message_deleted_at_message_deleted_by_and_more.py index 22ff1bd4..61e832be 100644 --- a/backend/communications/migrations/0009_message_deleted_at_message_deleted_by_and_more.py +++ b/backend/communications/migrations/0009_message_deleted_at_message_deleted_by_and_more.py @@ -6,126 +6,178 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('communications', '0008_chatusersettings'), + ("communications", "0008_chatusersettings"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.AddField( - model_name='message', - name='deleted_at', + model_name="message", + name="deleted_at", field=models.DateTimeField(blank=True, null=True), ), migrations.AddField( - model_name='message', - name='deleted_by', - field=models.ForeignKey(blank=True, help_text='Кто удалил (может отличаться от автора)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL), + model_name="message", + name="deleted_by", + field=models.ForeignKey( + blank=True, + help_text="Кто удалил (может отличаться от автора)", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), ), migrations.AddField( - model_name='message', - name='edit_history', - field=models.JSONField(blank=True, default=list, help_text='История редактирования [{timestamp, old_content}, ...]'), + model_name="message", + name="edit_history", + field=models.JSONField( + blank=True, + default=list, + help_text="История редактирования [{timestamp, old_content}, ...]", + ), ), migrations.AddField( - model_name='message', - name='edited_at', + model_name="message", + name="edited_at", field=models.DateTimeField(blank=True, null=True), ), migrations.AddField( - model_name='message', - name='has_attachments', + model_name="message", + name="has_attachments", field=models.BooleanField(default=False), ), migrations.AddField( - model_name='message', - name='is_cross_chat', - field=models.BooleanField(default=False, help_text='Сообщение отправлено не участником чата'), + model_name="message", + name="is_cross_chat", + field=models.BooleanField( + default=False, help_text="Сообщение отправлено не участником чата" + ), ), migrations.AddField( - model_name='message', - name='is_deleted', + model_name="message", + name="is_deleted", field=models.BooleanField(default=False), ), migrations.AddField( - model_name='message', - name='is_edited', + model_name="message", + name="is_edited", field=models.BooleanField(default=False), ), migrations.AddField( - model_name='message', - name='is_forwarded', + model_name="message", + name="is_forwarded", field=models.BooleanField(default=False), ), migrations.AddField( - model_name='message', - name='is_pinned', + model_name="message", + name="is_pinned", field=models.BooleanField(default=False), ), migrations.AddField( - model_name='message', - name='is_system', + model_name="message", + name="is_system", field=models.BooleanField(default=False), ), migrations.AddField( - model_name='message', - name='pinned_at', + model_name="message", + name="pinned_at", field=models.DateTimeField(blank=True, null=True), ), migrations.AddField( - model_name='message', - name='pinned_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL), + model_name="message", + name="pinned_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), ), migrations.AddField( - model_name='message', - name='reactions', - field=models.JSONField(blank=True, default=dict, help_text="Формат: {'👍': [user_id1, user_id2], '❤️': [user_id3]}"), + model_name="message", + name="reactions", + field=models.JSONField( + blank=True, + default=dict, + help_text="Формат: {'👍': [user_id1, user_id2], '❤️': [user_id3]}", + ), ), migrations.AddField( - model_name='message', - name='reply_to', - field=models.ForeignKey(blank=True, help_text='Простая связь для быстрого доступа', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='direct_replies', to='communications.message'), + model_name="message", + name="reply_to", + field=models.ForeignKey( + blank=True, + help_text="Простая связь для быстрого доступа", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="direct_replies", + to="communications.message", + ), ), migrations.AddField( - model_name='message', - name='system_metadata', - field=models.JSONField(blank=True, default=dict, help_text='Дополнительные данные для системных сообщений'), + model_name="message", + name="system_metadata", + field=models.JSONField( + blank=True, + default=dict, + help_text="Дополнительные данные для системных сообщений", + ), ), migrations.AddField( - model_name='message', - name='system_type', + model_name="message", + name="system_type", field=models.CharField(blank=True, max_length=50), ), migrations.AddField( - model_name='message', - name='thread_reply_count', + model_name="message", + name="thread_reply_count", field=models.IntegerField(default=0), ), migrations.AddField( - model_name='message', - name='thread_root', - field=models.ForeignKey(blank=True, help_text='Корневое сообщение треда', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='thread_messages', to='communications.message'), + model_name="message", + name="thread_root", + field=models.ForeignKey( + blank=True, + help_text="Корневое сообщение треда", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="thread_messages", + to="communications.message", + ), ), migrations.AddIndex( - model_name='message', - index=models.Index(fields=['chat', 'created_at'], name='communicati_chat_id_10091d_idx'), + model_name="message", + index=models.Index( + fields=["chat", "created_at"], name="communicati_chat_id_10091d_idx" + ), ), migrations.AddIndex( - model_name='message', - index=models.Index(fields=['chat', 'is_deleted', 'created_at'], name='communicati_chat_id_7cdaea_idx'), + model_name="message", + index=models.Index( + fields=["chat", "is_deleted", "created_at"], + name="communicati_chat_id_7cdaea_idx", + ), ), migrations.AddIndex( - model_name='message', - index=models.Index(fields=['author', 'created_at'], name='communicati_author__e3ed1d_idx'), + model_name="message", + index=models.Index( + fields=["author", "created_at"], name="communicati_author__e3ed1d_idx" + ), ), migrations.AddIndex( - model_name='message', - index=models.Index(fields=['thread_root', 'created_at'], name='communicati_thread__951aef_idx'), + model_name="message", + index=models.Index( + fields=["thread_root", "created_at"], + name="communicati_thread__951aef_idx", + ), ), migrations.AddIndex( - model_name='message', - index=models.Index(fields=['is_pinned', 'chat'], name='communicati_is_pinn_8cd754_idx'), + model_name="message", + index=models.Index( + fields=["is_pinned", "chat"], name="communicati_is_pinn_8cd754_idx" + ), ), ] diff --git a/backend/communications/migrations/0010_messageattachment.py b/backend/communications/migrations/0010_messageattachment.py index 72431bbe..b8509e0c 100644 --- a/backend/communications/migrations/0010_messageattachment.py +++ b/backend/communications/migrations/0010_messageattachment.py @@ -5,30 +5,95 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('communications', '0009_message_deleted_at_message_deleted_by_and_more'), + ("communications", "0009_message_deleted_at_message_deleted_by_and_more"), ] operations = [ migrations.CreateModel( - name='MessageAttachment', + name="MessageAttachment", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('file', models.FileField(upload_to='chat_attachments/%Y/%m/%d/', verbose_name='Файл')), - ('file_type', models.CharField(choices=[('image', 'Изображение'), ('video', 'Видео'), ('audio', 'Аудио'), ('file', 'Файл')], max_length=10, verbose_name='Тип файла')), - ('file_name', models.CharField(max_length=255, verbose_name='Название файла')), - ('file_size', models.BigIntegerField(verbose_name='Размер файла (байты)')), - ('mime_type', models.CharField(blank=True, max_length=100, verbose_name='MIME тип')), - ('uploaded_at', models.DateTimeField(auto_now_add=True, verbose_name='Время загрузки')), - ('thumbnail', models.ImageField(blank=True, help_text='Для изображений и видео', null=True, upload_to='chat_attachments/thumbnails/%Y/%m/%d/', verbose_name='Миниатюра')), - ('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='communications.message', verbose_name='Сообщение')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "file", + models.FileField( + upload_to="chat_attachments/%Y/%m/%d/", verbose_name="Файл" + ), + ), + ( + "file_type", + models.CharField( + choices=[ + ("image", "Изображение"), + ("video", "Видео"), + ("audio", "Аудио"), + ("file", "Файл"), + ], + max_length=10, + verbose_name="Тип файла", + ), + ), + ( + "file_name", + models.CharField(max_length=255, verbose_name="Название файла"), + ), + ( + "file_size", + models.BigIntegerField(verbose_name="Размер файла (байты)"), + ), + ( + "mime_type", + models.CharField( + blank=True, max_length=100, verbose_name="MIME тип" + ), + ), + ( + "uploaded_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Время загрузки" + ), + ), + ( + "thumbnail", + models.ImageField( + blank=True, + help_text="Для изображений и видео", + null=True, + upload_to="chat_attachments/thumbnails/%Y/%m/%d/", + verbose_name="Миниатюра", + ), + ), + ( + "message", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="attachments", + to="communications.message", + verbose_name="Сообщение", + ), + ), ], options={ - 'verbose_name': 'Вложение', - 'verbose_name_plural': 'Вложения', - 'ordering': ['uploaded_at'], - 'indexes': [models.Index(fields=['message', 'file_type'], name='communicati_message_7ec201_idx'), models.Index(fields=['uploaded_at'], name='communicati_uploade_015a96_idx')], + "verbose_name": "Вложение", + "verbose_name_plural": "Вложения", + "ordering": ["uploaded_at"], + "indexes": [ + models.Index( + fields=["message", "file_type"], + name="communicati_message_7ec201_idx", + ), + models.Index( + fields=["uploaded_at"], name="communicati_uploade_015a96_idx" + ), + ], }, ), ] diff --git a/backend/communications/migrations/0011_chataccesspermission_crosschatmessage_and_more.py b/backend/communications/migrations/0011_chataccesspermission_crosschatmessage_and_more.py index d341b72a..2086db00 100644 --- a/backend/communications/migrations/0011_chataccesspermission_crosschatmessage_and_more.py +++ b/backend/communications/migrations/0011_chataccesspermission_crosschatmessage_and_more.py @@ -6,90 +6,418 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('communications', '0010_messageattachment'), + ("communications", "0010_messageattachment"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='ChatAccessPermission', + name="ChatAccessPermission", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('access_level', models.CharField(choices=[('read', 'Только чтение'), ('write', 'Чтение и отправка'), ('moderate', 'Модерация')], default='write', max_length=10, verbose_name='Уровень доступа')), - ('granted_at', models.DateTimeField(auto_now_add=True, verbose_name='Когда предоставлен доступ')), - ('expires_at', models.DateTimeField(blank=True, help_text='Если не указано - бессрочно', null=True, verbose_name='Срок действия')), - ('is_active', models.BooleanField(default=True, verbose_name='Активен')), - ('chat', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='access_permissions', to='communications.chat', verbose_name='Чат')), - ('granted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Кто предоставил доступ')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chat_access_permissions', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "access_level", + models.CharField( + choices=[ + ("read", "Только чтение"), + ("write", "Чтение и отправка"), + ("moderate", "Модерация"), + ], + default="write", + max_length=10, + verbose_name="Уровень доступа", + ), + ), + ( + "granted_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Когда предоставлен доступ" + ), + ), + ( + "expires_at", + models.DateTimeField( + blank=True, + help_text="Если не указано - бессрочно", + null=True, + verbose_name="Срок действия", + ), + ), + ( + "is_active", + models.BooleanField(default=True, verbose_name="Активен"), + ), + ( + "chat", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="access_permissions", + to="communications.chat", + verbose_name="Чат", + ), + ), + ( + "granted_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + verbose_name="Кто предоставил доступ", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="chat_access_permissions", + to=settings.AUTH_USER_MODEL, + verbose_name="Пользователь", + ), + ), ], options={ - 'verbose_name': 'Право доступа к чату', - 'verbose_name_plural': 'Права доступа к чатам', - 'indexes': [models.Index(fields=['chat', 'is_active'], name='communicati_chat_id_788c77_idx'), models.Index(fields=['user', 'is_active'], name='communicati_user_id_8402d4_idx')], - 'unique_together': {('chat', 'user')}, + "verbose_name": "Право доступа к чату", + "verbose_name_plural": "Права доступа к чатам", + "indexes": [ + models.Index( + fields=["chat", "is_active"], + name="communicati_chat_id_788c77_idx", + ), + models.Index( + fields=["user", "is_active"], + name="communicati_user_id_8402d4_idx", + ), + ], + "unique_together": {("chat", "user")}, }, ), migrations.CreateModel( - name='CrossChatMessage', + name="CrossChatMessage", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('status', models.CharField(choices=[('pending', 'На модерации'), ('approved', 'Одобрено'), ('rejected', 'Отклонено')], default='pending', max_length=10, verbose_name='Статус')), - ('requires_moderation', models.BooleanField(default=True, verbose_name='Требует модерации')), - ('moderated_at', models.DateTimeField(blank=True, null=True, verbose_name='Время модерации')), - ('moderation_note', models.TextField(blank=True, verbose_name='Заметка модератора')), - ('sent_at', models.DateTimeField(auto_now_add=True, verbose_name='Время отправки')), - ('message', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='cross_chat_info', to='communications.message', verbose_name='Сообщение')), - ('moderated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Модератор')), - ('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_cross_chat_messages', to=settings.AUTH_USER_MODEL, verbose_name='Отправитель')), - ('target_chat', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cross_chat_messages', to='communications.chat', verbose_name='Целевой чат')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "status", + models.CharField( + choices=[ + ("pending", "На модерации"), + ("approved", "Одобрено"), + ("rejected", "Отклонено"), + ], + default="pending", + max_length=10, + verbose_name="Статус", + ), + ), + ( + "requires_moderation", + models.BooleanField(default=True, verbose_name="Требует модерации"), + ), + ( + "moderated_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Время модерации" + ), + ), + ( + "moderation_note", + models.TextField(blank=True, verbose_name="Заметка модератора"), + ), + ( + "sent_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Время отправки" + ), + ), + ( + "message", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="cross_chat_info", + to="communications.message", + verbose_name="Сообщение", + ), + ), + ( + "moderated_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + verbose_name="Модератор", + ), + ), + ( + "sender", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="sent_cross_chat_messages", + to=settings.AUTH_USER_MODEL, + verbose_name="Отправитель", + ), + ), + ( + "target_chat", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="cross_chat_messages", + to="communications.chat", + verbose_name="Целевой чат", + ), + ), ], options={ - 'verbose_name': 'Кросс-чат сообщение', - 'verbose_name_plural': 'Кросс-чат сообщения', - 'indexes': [models.Index(fields=['target_chat', 'status'], name='communicati_target__afb50e_idx'), models.Index(fields=['sender', 'sent_at'], name='communicati_sender__d8ea5c_idx'), models.Index(fields=['status', 'requires_moderation'], name='communicati_status_d4dade_idx')], + "verbose_name": "Кросс-чат сообщение", + "verbose_name_plural": "Кросс-чат сообщения", + "indexes": [ + models.Index( + fields=["target_chat", "status"], + name="communicati_target__afb50e_idx", + ), + models.Index( + fields=["sender", "sent_at"], + name="communicati_sender__d8ea5c_idx", + ), + models.Index( + fields=["status", "requires_moderation"], + name="communicati_status_d4dade_idx", + ), + ], }, ), migrations.CreateModel( - name='ForwardedMessage', + name="ForwardedMessage", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('forwarded_at', models.DateTimeField(auto_now_add=True, verbose_name='Время пересылки')), - ('forward_count', models.IntegerField(default=1, help_text='Сколько раз это сообщение пересылалось', verbose_name='Количество пересылок')), - ('preserved_content', models.TextField(blank=True, help_text='На случай удаления оригинала', verbose_name='Сохранённый текст')), - ('preserved_author_name', models.CharField(blank=True, max_length=255, verbose_name='Имя автора оригинала')), - ('forwarded_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='forwarded_messages', to=settings.AUTH_USER_MODEL, verbose_name='Кто переслал')), - ('immediate_source', models.ForeignKey(blank=True, help_text='Откуда переслали (может отличаться от original)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='communications.message', verbose_name='Непосредственный источник пересылки')), - ('message', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='forward_info', to='communications.message', verbose_name='Пересланное сообщение')), - ('original_author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Автор оригинального сообщения')), - ('original_chat', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='communications.chat', verbose_name='Исходный чат')), - ('original_message', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='forwarded_copies', to='communications.message', verbose_name='Самое первое сообщение в цепочке')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "forwarded_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Время пересылки" + ), + ), + ( + "forward_count", + models.IntegerField( + default=1, + help_text="Сколько раз это сообщение пересылалось", + verbose_name="Количество пересылок", + ), + ), + ( + "preserved_content", + models.TextField( + blank=True, + help_text="На случай удаления оригинала", + verbose_name="Сохранённый текст", + ), + ), + ( + "preserved_author_name", + models.CharField( + blank=True, max_length=255, verbose_name="Имя автора оригинала" + ), + ), + ( + "forwarded_by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="forwarded_messages", + to=settings.AUTH_USER_MODEL, + verbose_name="Кто переслал", + ), + ), + ( + "immediate_source", + models.ForeignKey( + blank=True, + help_text="Откуда переслали (может отличаться от original)", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="communications.message", + verbose_name="Непосредственный источник пересылки", + ), + ), + ( + "message", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="forward_info", + to="communications.message", + verbose_name="Пересланное сообщение", + ), + ), + ( + "original_author", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + verbose_name="Автор оригинального сообщения", + ), + ), + ( + "original_chat", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="communications.chat", + verbose_name="Исходный чат", + ), + ), + ( + "original_message", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="forwarded_copies", + to="communications.message", + verbose_name="Самое первое сообщение в цепочке", + ), + ), ], options={ - 'verbose_name': 'Информация о пересылке', - 'verbose_name_plural': 'Информация о пересылках', - 'indexes': [models.Index(fields=['original_message'], name='communicati_origina_aa3dab_idx'), models.Index(fields=['forwarded_by', 'forwarded_at'], name='communicati_forward_44797e_idx')], + "verbose_name": "Информация о пересылке", + "verbose_name_plural": "Информация о пересылках", + "indexes": [ + models.Index( + fields=["original_message"], + name="communicati_origina_aa3dab_idx", + ), + models.Index( + fields=["forwarded_by", "forwarded_at"], + name="communicati_forward_44797e_idx", + ), + ], }, ), migrations.CreateModel( - name='MessageReply', + name="MessageReply", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('is_cross_chat_reply', models.BooleanField(default=False, verbose_name='Ответ из другого чата')), - ('preserved_text', models.TextField(blank=True, verbose_name='Сохранённый текст оригинала')), - ('preserved_author_name', models.CharField(blank=True, max_length=255, verbose_name='Имя автора оригинала')), - ('reply_type', models.CharField(choices=[('inline', 'В потоке'), ('quote', 'С цитатой'), ('thread', 'В треде')], default='inline', max_length=10, verbose_name='Тип ответа')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Время создания ответа')), - ('message', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='reply_info', to='communications.message', verbose_name='Сообщение-ответ')), - ('original_chat', models.ForeignKey(blank=True, help_text='Если ответ был из другого чата', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='communications.chat', verbose_name='Исходный чат')), - ('replied_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='extended_replies', to='communications.message', verbose_name='На какое сообщение ответ')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "is_cross_chat_reply", + models.BooleanField( + default=False, verbose_name="Ответ из другого чата" + ), + ), + ( + "preserved_text", + models.TextField( + blank=True, verbose_name="Сохранённый текст оригинала" + ), + ), + ( + "preserved_author_name", + models.CharField( + blank=True, max_length=255, verbose_name="Имя автора оригинала" + ), + ), + ( + "reply_type", + models.CharField( + choices=[ + ("inline", "В потоке"), + ("quote", "С цитатой"), + ("thread", "В треде"), + ], + default="inline", + max_length=10, + verbose_name="Тип ответа", + ), + ), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Время создания ответа" + ), + ), + ( + "message", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="reply_info", + to="communications.message", + verbose_name="Сообщение-ответ", + ), + ), + ( + "original_chat", + models.ForeignKey( + blank=True, + help_text="Если ответ был из другого чата", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="communications.chat", + verbose_name="Исходный чат", + ), + ), + ( + "replied_to", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="extended_replies", + to="communications.message", + verbose_name="На какое сообщение ответ", + ), + ), ], options={ - 'verbose_name': 'Информация об ответе', - 'verbose_name_plural': 'Информация об ответах', - 'indexes': [models.Index(fields=['replied_to'], name='communicati_replied_a35389_idx'), models.Index(fields=['is_cross_chat_reply'], name='communicati_is_cros_be0eee_idx')], + "verbose_name": "Информация об ответе", + "verbose_name_plural": "Информация об ответах", + "indexes": [ + models.Index( + fields=["replied_to"], name="communicati_replied_a35389_idx" + ), + models.Index( + fields=["is_cross_chat_reply"], + name="communicati_is_cros_be0eee_idx", + ), + ], }, ), ] diff --git a/backend/communications/migrations/0012_chatmembership_chatreadstate_is_typing_and_more.py b/backend/communications/migrations/0012_chatmembership_chatreadstate_is_typing_and_more.py index bb0dc4f7..a2131ffd 100644 --- a/backend/communications/migrations/0012_chatmembership_chatreadstate_is_typing_and_more.py +++ b/backend/communications/migrations/0012_chatmembership_chatreadstate_is_typing_and_more.py @@ -6,89 +6,188 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('communications', '0011_chataccesspermission_crosschatmessage_and_more'), + ("communications", "0011_chataccesspermission_crosschatmessage_and_more"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='ChatMembership', + name="ChatMembership", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('role', models.CharField(choices=[('owner', 'Владелец'), ('admin', 'Администратор'), ('moderator', 'Модератор'), ('member', 'Участник'), ('guest', 'Гость')], default='member', max_length=10, verbose_name='Роль')), - ('joined_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата вступления')), - ('is_active', models.BooleanField(default=True, help_text='False если вышел из чата', verbose_name='Активен')), - ('left_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата выхода')), - ('can_send_messages', models.BooleanField(default=True, verbose_name='Может отправлять сообщения')), - ('can_add_members', models.BooleanField(default=False, verbose_name='Может добавлять участников')), - ('can_remove_members', models.BooleanField(default=False, verbose_name='Может удалять участников')), - ('can_pin_messages', models.BooleanField(default=False, verbose_name='Может закреплять сообщения')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "role", + models.CharField( + choices=[ + ("owner", "Владелец"), + ("admin", "Администратор"), + ("moderator", "Модератор"), + ("member", "Участник"), + ("guest", "Гость"), + ], + default="member", + max_length=10, + verbose_name="Роль", + ), + ), + ( + "joined_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Дата вступления" + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="False если вышел из чата", + verbose_name="Активен", + ), + ), + ( + "left_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Дата выхода" + ), + ), + ( + "can_send_messages", + models.BooleanField( + default=True, verbose_name="Может отправлять сообщения" + ), + ), + ( + "can_add_members", + models.BooleanField( + default=False, verbose_name="Может добавлять участников" + ), + ), + ( + "can_remove_members", + models.BooleanField( + default=False, verbose_name="Может удалять участников" + ), + ), + ( + "can_pin_messages", + models.BooleanField( + default=False, verbose_name="Может закреплять сообщения" + ), + ), ], options={ - 'verbose_name': 'Участник чата', - 'verbose_name_plural': 'Участники чатов', + "verbose_name": "Участник чата", + "verbose_name_plural": "Участники чатов", }, ), migrations.AddField( - model_name='chatreadstate', - name='is_typing', - field=models.BooleanField(default=False, verbose_name='Набирает сообщение'), + model_name="chatreadstate", + name="is_typing", + field=models.BooleanField(default=False, verbose_name="Набирает сообщение"), ), migrations.AddField( - model_name='chatreadstate', - name='last_read_message', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='communications.message', verbose_name='Последнее прочитанное сообщение'), + model_name="chatreadstate", + name="last_read_message", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="communications.message", + verbose_name="Последнее прочитанное сообщение", + ), ), migrations.AddField( - model_name='chatreadstate', - name='typing_updated_at', - field=models.DateTimeField(blank=True, null=True, verbose_name='Последнее обновление статуса набора'), + model_name="chatreadstate", + name="typing_updated_at", + field=models.DateTimeField( + blank=True, + null=True, + verbose_name="Последнее обновление статуса набора", + ), ), migrations.AddField( - model_name='chatreadstate', - name='unread_mentions_count', - field=models.IntegerField(default=0, verbose_name='Непрочитанных упоминаний'), + model_name="chatreadstate", + name="unread_mentions_count", + field=models.IntegerField( + default=0, verbose_name="Непрочитанных упоминаний" + ), ), migrations.AddField( - model_name='chatreadstate', - name='unread_thread_replies_count', - field=models.IntegerField(default=0, verbose_name='Непрочитанных ответов в тредах'), + model_name="chatreadstate", + name="unread_thread_replies_count", + field=models.IntegerField( + default=0, verbose_name="Непрочитанных ответов в тредах" + ), ), migrations.AddIndex( - model_name='chatreadstate', - index=models.Index(fields=['chat', 'user', 'is_typing'], name='communicati_chat_id_21af98_idx'), + model_name="chatreadstate", + index=models.Index( + fields=["chat", "user", "is_typing"], + name="communicati_chat_id_21af98_idx", + ), ), migrations.AddField( - model_name='chatmembership', - name='chat', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='communications.chat', verbose_name='Чат'), + model_name="chatmembership", + name="chat", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="memberships", + to="communications.chat", + verbose_name="Чат", + ), ), migrations.AddField( - model_name='chatmembership', - name='invited_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Кто пригласил'), + model_name="chatmembership", + name="invited_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + verbose_name="Кто пригласил", + ), ), migrations.AddField( - model_name='chatmembership', - name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chat_memberships', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь'), + model_name="chatmembership", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="chat_memberships", + to=settings.AUTH_USER_MODEL, + verbose_name="Пользователь", + ), ), migrations.AddIndex( - model_name='chatmembership', - index=models.Index(fields=['chat', 'is_active'], name='communicati_chat_id_b9bdc1_idx'), + model_name="chatmembership", + index=models.Index( + fields=["chat", "is_active"], name="communicati_chat_id_b9bdc1_idx" + ), ), migrations.AddIndex( - model_name='chatmembership', - index=models.Index(fields=['user', 'is_active'], name='communicati_user_id_1365f8_idx'), + model_name="chatmembership", + index=models.Index( + fields=["user", "is_active"], name="communicati_user_id_1365f8_idx" + ), ), migrations.AddIndex( - model_name='chatmembership', - index=models.Index(fields=['chat', 'role'], name='communicati_chat_id_3bb35b_idx'), + model_name="chatmembership", + index=models.Index( + fields=["chat", "role"], name="communicati_chat_id_3bb35b_idx" + ), ), migrations.AlterUniqueTogether( - name='chatmembership', - unique_together={('chat', 'user')}, + name="chatmembership", + unique_together={("chat", "user")}, ), ] diff --git a/backend/communications/migrations/0013_messengerintegration_messengeraccount_and_more.py b/backend/communications/migrations/0013_messengerintegration_messengeraccount_and_more.py index 54ad7a29..99df5ee2 100644 --- a/backend/communications/migrations/0013_messengerintegration_messengeraccount_and_more.py +++ b/backend/communications/migrations/0013_messengerintegration_messengeraccount_and_more.py @@ -6,114 +6,351 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('communications', '0012_chatmembership_chatreadstate_is_typing_and_more'), + ("communications", "0012_chatmembership_chatreadstate_is_typing_and_more"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='MessengerIntegration', + name="MessengerIntegration", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('messenger_type', models.CharField(choices=[('whatsapp', 'WhatsApp'), ('telegram', 'Telegram'), ('wechat', 'WeChat')], max_length=20, unique=True, verbose_name='Тип мессенджера')), - ('is_enabled', models.BooleanField(default=False, verbose_name='Включена')), - ('status', models.CharField(choices=[('inactive', 'Не активна'), ('active', 'Активна'), ('error', 'Ошибка')], default='inactive', max_length=20, verbose_name='Статус')), - ('api_key', models.CharField(blank=True, max_length=500, verbose_name='API ключ')), - ('api_secret', models.CharField(blank=True, max_length=500, verbose_name='API секрет')), - ('webhook_url', models.URLField(blank=True, max_length=500, verbose_name='Webhook URL')), - ('settings', models.JSONField(blank=True, default=dict, verbose_name='Дополнительные настройки')), - ('last_sync_at', models.DateTimeField(blank=True, null=True, verbose_name='Последняя синхронизация')), - ('messages_sent', models.IntegerField(default=0, verbose_name='Отправлено сообщений')), - ('messages_received', models.IntegerField(default=0, verbose_name='Получено сообщений')), - ('last_error', models.TextField(blank=True, verbose_name='Последняя ошибка')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "messenger_type", + models.CharField( + choices=[ + ("whatsapp", "WhatsApp"), + ("telegram", "Telegram"), + ("wechat", "WeChat"), + ], + max_length=20, + unique=True, + verbose_name="Тип мессенджера", + ), + ), + ( + "is_enabled", + models.BooleanField(default=False, verbose_name="Включена"), + ), + ( + "status", + models.CharField( + choices=[ + ("inactive", "Не активна"), + ("active", "Активна"), + ("error", "Ошибка"), + ], + default="inactive", + max_length=20, + verbose_name="Статус", + ), + ), + ( + "api_key", + models.CharField( + blank=True, max_length=500, verbose_name="API ключ" + ), + ), + ( + "api_secret", + models.CharField( + blank=True, max_length=500, verbose_name="API секрет" + ), + ), + ( + "webhook_url", + models.URLField( + blank=True, max_length=500, verbose_name="Webhook URL" + ), + ), + ( + "settings", + models.JSONField( + blank=True, + default=dict, + verbose_name="Дополнительные настройки", + ), + ), + ( + "last_sync_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Последняя синхронизация" + ), + ), + ( + "messages_sent", + models.IntegerField(default=0, verbose_name="Отправлено сообщений"), + ), + ( + "messages_received", + models.IntegerField(default=0, verbose_name="Получено сообщений"), + ), + ( + "last_error", + models.TextField(blank=True, verbose_name="Последняя ошибка"), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Создано"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="Обновлено"), + ), ], options={ - 'verbose_name': 'Интеграция с мессенджером', - 'verbose_name_plural': 'Интеграции с мессенджерами', - 'indexes': [models.Index(fields=['messenger_type', 'is_enabled'], name='communicati_messeng_c8457c_idx'), models.Index(fields=['status'], name='communicati_status_7fb887_idx')], + "verbose_name": "Интеграция с мессенджером", + "verbose_name_plural": "Интеграции с мессенджерами", + "indexes": [ + models.Index( + fields=["messenger_type", "is_enabled"], + name="communicati_messeng_c8457c_idx", + ), + models.Index( + fields=["status"], name="communicati_status_7fb887_idx" + ), + ], }, ), migrations.CreateModel( - name='MessengerAccount', + name="MessengerAccount", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('external_id', models.CharField(max_length=200, verbose_name='ID в мессенджере')), - ('external_username', models.CharField(blank=True, max_length=200, verbose_name='Имя в мессенджере')), - ('phone_number', models.CharField(blank=True, max_length=20, verbose_name='Номер телефона')), - ('is_verified', models.BooleanField(default=False, verbose_name='Подтвержден')), - ('is_active', models.BooleanField(default=True, verbose_name='Активен')), - ('metadata', models.JSONField(blank=True, default=dict, verbose_name='Метаданные')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messenger_accounts', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')), - ('integration', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accounts', to='communications.messengerintegration', verbose_name='Интеграция')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "external_id", + models.CharField(max_length=200, verbose_name="ID в мессенджере"), + ), + ( + "external_username", + models.CharField( + blank=True, max_length=200, verbose_name="Имя в мессенджере" + ), + ), + ( + "phone_number", + models.CharField( + blank=True, max_length=20, verbose_name="Номер телефона" + ), + ), + ( + "is_verified", + models.BooleanField(default=False, verbose_name="Подтвержден"), + ), + ( + "is_active", + models.BooleanField(default=True, verbose_name="Активен"), + ), + ( + "metadata", + models.JSONField( + blank=True, default=dict, verbose_name="Метаданные" + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Создано"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="Обновлено"), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="messenger_accounts", + to=settings.AUTH_USER_MODEL, + verbose_name="Пользователь", + ), + ), + ( + "integration", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="accounts", + to="communications.messengerintegration", + verbose_name="Интеграция", + ), + ), ], options={ - 'verbose_name': 'Аккаунт в мессенджере', - 'verbose_name_plural': 'Аккаунты в мессенджерах', + "verbose_name": "Аккаунт в мессенджере", + "verbose_name_plural": "Аккаунты в мессенджерах", }, ), migrations.CreateModel( - name='MessengerMessage', + name="MessengerMessage", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('external_id', models.CharField(max_length=200, verbose_name='ID в мессенджере')), - ('direction', models.CharField(choices=[('incoming', 'Входящее'), ('outgoing', 'Исходящее')], max_length=10, verbose_name='Направление')), - ('status', models.CharField(choices=[('pending', 'В ожидании'), ('sent', 'Отправлено'), ('delivered', 'Доставлено'), ('read', 'Прочитано'), ('failed', 'Ошибка')], default='pending', max_length=20, verbose_name='Статус')), - ('content', models.TextField(verbose_name='Текст')), - ('attachments', models.JSONField(blank=True, default=list, verbose_name='Вложения')), - ('external_timestamp', models.DateTimeField(verbose_name='Время в мессенджере')), - ('synced_at', models.DateTimeField(auto_now_add=True, verbose_name='Синхронизировано')), - ('raw_data', models.JSONField(blank=True, default=dict, verbose_name='Исходные данные')), - ('integration', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messenger_messages', to='communications.messengerintegration', verbose_name='Интеграция')), - ('internal_message', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='messenger_sync', to='communications.message', verbose_name='Внутреннее сообщение')), - ('recipient_account', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='received_messages', to='communications.messengeraccount', verbose_name='Получатель')), - ('sender_account', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_messages', to='communications.messengeraccount', verbose_name='Отправитель')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "external_id", + models.CharField(max_length=200, verbose_name="ID в мессенджере"), + ), + ( + "direction", + models.CharField( + choices=[("incoming", "Входящее"), ("outgoing", "Исходящее")], + max_length=10, + verbose_name="Направление", + ), + ), + ( + "status", + models.CharField( + choices=[ + ("pending", "В ожидании"), + ("sent", "Отправлено"), + ("delivered", "Доставлено"), + ("read", "Прочитано"), + ("failed", "Ошибка"), + ], + default="pending", + max_length=20, + verbose_name="Статус", + ), + ), + ("content", models.TextField(verbose_name="Текст")), + ( + "attachments", + models.JSONField(blank=True, default=list, verbose_name="Вложения"), + ), + ( + "external_timestamp", + models.DateTimeField(verbose_name="Время в мессенджере"), + ), + ( + "synced_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Синхронизировано" + ), + ), + ( + "raw_data", + models.JSONField( + blank=True, default=dict, verbose_name="Исходные данные" + ), + ), + ( + "integration", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="messenger_messages", + to="communications.messengerintegration", + verbose_name="Интеграция", + ), + ), + ( + "internal_message", + models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="messenger_sync", + to="communications.message", + verbose_name="Внутреннее сообщение", + ), + ), + ( + "recipient_account", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="received_messages", + to="communications.messengeraccount", + verbose_name="Получатель", + ), + ), + ( + "sender_account", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="sent_messages", + to="communications.messengeraccount", + verbose_name="Отправитель", + ), + ), ], options={ - 'verbose_name': 'Сообщение из мессенджера', - 'verbose_name_plural': 'Сообщения из мессенджеров', - 'ordering': ['-external_timestamp'], + "verbose_name": "Сообщение из мессенджера", + "verbose_name_plural": "Сообщения из мессенджеров", + "ordering": ["-external_timestamp"], }, ), migrations.AddIndex( - model_name='messengeraccount', - index=models.Index(fields=['user', 'integration'], name='communicati_user_id_acfa5e_idx'), + model_name="messengeraccount", + index=models.Index( + fields=["user", "integration"], name="communicati_user_id_acfa5e_idx" + ), ), migrations.AddIndex( - model_name='messengeraccount', - index=models.Index(fields=['external_id'], name='communicati_externa_2dfbcf_idx'), + model_name="messengeraccount", + index=models.Index( + fields=["external_id"], name="communicati_externa_2dfbcf_idx" + ), ), migrations.AddIndex( - model_name='messengeraccount', - index=models.Index(fields=['phone_number'], name='communicati_phone_n_8df544_idx'), + model_name="messengeraccount", + index=models.Index( + fields=["phone_number"], name="communicati_phone_n_8df544_idx" + ), ), migrations.AlterUniqueTogether( - name='messengeraccount', - unique_together={('integration', 'external_id')}, + name="messengeraccount", + unique_together={("integration", "external_id")}, ), migrations.AddIndex( - model_name='messengermessage', - index=models.Index(fields=['integration', 'external_timestamp'], name='communicati_integra_af291e_idx'), + model_name="messengermessage", + index=models.Index( + fields=["integration", "external_timestamp"], + name="communicati_integra_af291e_idx", + ), ), migrations.AddIndex( - model_name='messengermessage', - index=models.Index(fields=['sender_account', 'external_timestamp'], name='communicati_sender__496a25_idx'), + model_name="messengermessage", + index=models.Index( + fields=["sender_account", "external_timestamp"], + name="communicati_sender__496a25_idx", + ), ), migrations.AddIndex( - model_name='messengermessage', - index=models.Index(fields=['status'], name='communicati_status_9d880b_idx'), + model_name="messengermessage", + index=models.Index(fields=["status"], name="communicati_status_9d880b_idx"), ), migrations.AddIndex( - model_name='messengermessage', - index=models.Index(fields=['-synced_at'], name='communicati_synced__4496a8_idx'), + model_name="messengermessage", + index=models.Index( + fields=["-synced_at"], name="communicati_synced__4496a8_idx" + ), ), migrations.AlterUniqueTogether( - name='messengermessage', - unique_together={('integration', 'external_id')}, + name="messengermessage", + unique_together={("integration", "external_id")}, ), ] diff --git a/backend/communications/migrations/0014_remove_messengermessage_sender_account_and_more.py b/backend/communications/migrations/0014_remove_messengermessage_sender_account_and_more.py index 13c6c1dd..767c9e03 100644 --- a/backend/communications/migrations/0014_remove_messengermessage_sender_account_and_more.py +++ b/backend/communications/migrations/0014_remove_messengermessage_sender_account_and_more.py @@ -4,95 +4,94 @@ from django.db import migrations class Migration(migrations.Migration): - dependencies = [ - ('communications', '0013_messengerintegration_messengeraccount_and_more'), + ("communications", "0013_messengerintegration_messengeraccount_and_more"), ] operations = [ # Сначала удаляем индексы, которые зависят от полей migrations.RemoveIndex( - model_name='messengermessage', - name='communicati_integra_af291e_idx', + model_name="messengermessage", + name="communicati_integra_af291e_idx", ), migrations.RemoveIndex( - model_name='messengermessage', - name='communicati_sender__496a25_idx', + model_name="messengermessage", + name="communicati_sender__496a25_idx", ), migrations.RemoveIndex( - model_name='messengermessage', - name='communicati_status_9d880b_idx', + model_name="messengermessage", + name="communicati_status_9d880b_idx", ), migrations.RemoveIndex( - model_name='messengermessage', - name='communicati_synced__4496a8_idx', + model_name="messengermessage", + name="communicati_synced__4496a8_idx", ), # Удаляем unique_together migrations.AlterUniqueTogether( - name='messengermessage', + name="messengermessage", unique_together=None, ), # Теперь удаляем поля migrations.RemoveField( - model_name='messengermessage', - name='sender_account', + model_name="messengermessage", + name="sender_account", ), migrations.RemoveField( - model_name='messengermessage', - name='recipient_account', + model_name="messengermessage", + name="recipient_account", ), migrations.RemoveField( - model_name='messengermessage', - name='integration', + model_name="messengermessage", + name="integration", ), migrations.RemoveField( - model_name='messengermessage', - name='internal_message', + model_name="messengermessage", + name="internal_message", ), # Удаляем индексы MessengerAccount migrations.RemoveIndex( - model_name='messengeraccount', - name='communicati_user_id_acfa5e_idx', + model_name="messengeraccount", + name="communicati_user_id_acfa5e_idx", ), migrations.RemoveIndex( - model_name='messengeraccount', - name='communicati_externa_2dfbcf_idx', + model_name="messengeraccount", + name="communicati_externa_2dfbcf_idx", ), migrations.RemoveIndex( - model_name='messengeraccount', - name='communicati_phone_n_8df544_idx', + model_name="messengeraccount", + name="communicati_phone_n_8df544_idx", ), # Удаляем unique_together MessengerAccount migrations.AlterUniqueTogether( - name='messengeraccount', + name="messengeraccount", unique_together=None, ), # Удаляем поля MessengerAccount migrations.RemoveField( - model_name='messengeraccount', - name='user', + model_name="messengeraccount", + name="user", ), migrations.RemoveField( - model_name='messengeraccount', - name='integration', + model_name="messengeraccount", + name="integration", ), # Удаляем индексы MessengerIntegration migrations.RemoveIndex( - model_name='messengerintegration', - name='communicati_messeng_c8457c_idx', + model_name="messengerintegration", + name="communicati_messeng_c8457c_idx", ), migrations.RemoveIndex( - model_name='messengerintegration', - name='communicati_status_7fb887_idx', + model_name="messengerintegration", + name="communicati_status_7fb887_idx", ), # Теперь можно удалить модели migrations.DeleteModel( - name='MessengerMessage', + name="MessengerMessage", ), migrations.DeleteModel( - name='MessengerAccount', + name="MessengerAccount", ), migrations.DeleteModel( - name='MessengerIntegration', + name="MessengerIntegration", ), ] diff --git a/backend/communications/migrations/0015_chat_blocked_at_chat_blocked_by_chat_can_reply_and_more.py b/backend/communications/migrations/0015_chat_blocked_at_chat_blocked_by_chat_can_reply_and_more.py index cfb50545..b7fb12e5 100644 --- a/backend/communications/migrations/0015_chat_blocked_at_chat_blocked_by_chat_can_reply_and_more.py +++ b/backend/communications/migrations/0015_chat_blocked_at_chat_blocked_by_chat_can_reply_and_more.py @@ -6,36 +6,56 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('communications', '0014_remove_messengermessage_sender_account_and_more'), - ('employees', '0034_alter_employee_telegram'), + ("communications", "0014_remove_messengermessage_sender_account_and_more"), + ("employees", "0034_alter_employee_telegram"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.AddField( - model_name='chat', - name='blocked_at', - field=models.DateTimeField(blank=True, null=True, verbose_name='Дата блокировки'), + model_name="chat", + name="blocked_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Дата блокировки" + ), ), migrations.AddField( - model_name='chat', - name='blocked_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='blocked_chats', to=settings.AUTH_USER_MODEL, verbose_name='Кто заблокировал'), + model_name="chat", + name="blocked_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="blocked_chats", + to=settings.AUTH_USER_MODEL, + verbose_name="Кто заблокировал", + ), ), migrations.AddField( - model_name='chat', - name='can_reply', - field=models.BooleanField(default=True, help_text='False для announcement (только реакции)', verbose_name='Можно отвечать'), + model_name="chat", + name="can_reply", + field=models.BooleanField( + default=True, + help_text="False для announcement (только реакции)", + verbose_name="Можно отвечать", + ), ), migrations.AddField( - model_name='chat', - name='is_blocked', - field=models.BooleanField(default=False, help_text='Для announcement: заблокирован администратором, скрыт у всех', verbose_name='Заблокирован'), + model_name="chat", + name="is_blocked", + field=models.BooleanField( + default=False, + help_text="Для announcement: заблокирован администратором, скрыт у всех", + verbose_name="Заблокирован", + ), ), migrations.AddConstraint( - model_name='chat', - constraint=models.UniqueConstraint(condition=models.Q(('type', 'announcement')), fields=('type', 'created_by'), name='unique_announcement_per_user'), + model_name="chat", + constraint=models.UniqueConstraint( + condition=models.Q(("type", "announcement")), + fields=("type", "created_by"), + name="unique_announcement_per_user", + ), ), ] diff --git a/backend/communications/migrations/0016_remove_message_reactions_messagereaction.py b/backend/communications/migrations/0016_remove_message_reactions_messagereaction.py index 73e076ba..ba0dc623 100644 --- a/backend/communications/migrations/0016_remove_message_reactions_messagereaction.py +++ b/backend/communications/migrations/0016_remove_message_reactions_messagereaction.py @@ -6,32 +6,82 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('communications', '0015_chat_blocked_at_chat_blocked_by_chat_can_reply_and_more'), + ( + "communications", + "0015_chat_blocked_at_chat_blocked_by_chat_can_reply_and_more", + ), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.RemoveField( - model_name='message', - name='reactions', + model_name="message", + name="reactions", ), migrations.CreateModel( - name='MessageReaction', + name="MessageReaction", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('emoji', models.CharField(help_text='Unicode эмодзи (например: 👍, ❤️, 😂)', max_length=10, verbose_name='Эмодзи')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Время добавления')), - ('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reactions', to='communications.message', verbose_name='Сообщение')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='message_reactions', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "emoji", + models.CharField( + help_text="Unicode эмодзи (например: 👍, ❤️, 😂)", + max_length=10, + verbose_name="Эмодзи", + ), + ), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Время добавления" + ), + ), + ( + "message", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="reactions", + to="communications.message", + verbose_name="Сообщение", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="message_reactions", + to=settings.AUTH_USER_MODEL, + verbose_name="Пользователь", + ), + ), ], options={ - 'verbose_name': 'Реакция на сообщение', - 'verbose_name_plural': 'Реакции на сообщения', - 'ordering': ['created_at'], - 'indexes': [models.Index(fields=['message', 'emoji'], name='communicati_message_f5b4e6_idx'), models.Index(fields=['message', 'user'], name='communicati_message_8c7214_idx'), models.Index(fields=['created_at'], name='communicati_created_3e5423_idx')], - 'unique_together': {('message', 'user')}, + "verbose_name": "Реакция на сообщение", + "verbose_name_plural": "Реакции на сообщения", + "ordering": ["created_at"], + "indexes": [ + models.Index( + fields=["message", "emoji"], + name="communicati_message_f5b4e6_idx", + ), + models.Index( + fields=["message", "user"], + name="communicati_message_8c7214_idx", + ), + models.Index( + fields=["created_at"], name="communicati_created_3e5423_idx" + ), + ], + "unique_together": {("message", "user")}, }, ), ] diff --git a/backend/communications/migrations/0017_add_forward_fields.py b/backend/communications/migrations/0017_add_forward_fields.py index 6774b09e..18a8f1cf 100644 --- a/backend/communications/migrations/0017_add_forward_fields.py +++ b/backend/communications/migrations/0017_add_forward_fields.py @@ -6,21 +6,29 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('communications', '0016_remove_message_reactions_messagereaction'), + ("communications", "0016_remove_message_reactions_messagereaction"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.AddField( - model_name='message', - name='forwarded_from_author', - field=models.ForeignKey(blank=True, help_text='Автор исходного сообщения при пересылке', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='forwarded_messages_source', to=settings.AUTH_USER_MODEL), + model_name="message", + name="forwarded_from_author", + field=models.ForeignKey( + blank=True, + help_text="Автор исходного сообщения при пересылке", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="forwarded_messages_source", + to=settings.AUTH_USER_MODEL, + ), ), migrations.AddField( - model_name='message', - name='forwarded_from_message_id', - field=models.IntegerField(blank=True, help_text='ID исходного сообщения при пересылке', null=True), + model_name="message", + name="forwarded_from_message_id", + field=models.IntegerField( + blank=True, help_text="ID исходного сообщения при пересылке", null=True + ), ), ] diff --git a/backend/communications/migrations/0018_add_forwarded_metadata.py b/backend/communications/migrations/0018_add_forwarded_metadata.py index 245b12df..57c563f8 100644 --- a/backend/communications/migrations/0018_add_forwarded_metadata.py +++ b/backend/communications/migrations/0018_add_forwarded_metadata.py @@ -4,20 +4,28 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('communications', '0017_add_forward_fields'), + ("communications", "0017_add_forward_fields"), ] operations = [ migrations.AddField( - model_name='message', - name='forwarded_from_chat_name', - field=models.CharField(blank=True, help_text='Название исходного чата при пересылке', max_length=255, null=True), + model_name="message", + name="forwarded_from_chat_name", + field=models.CharField( + blank=True, + help_text="Название исходного чата при пересылке", + max_length=255, + null=True, + ), ), migrations.AddField( - model_name='message', - name='forwarded_from_created_at', - field=models.DateTimeField(blank=True, help_text='Дата создания исходного сообщения при пересылке', null=True), + model_name="message", + name="forwarded_from_created_at", + field=models.DateTimeField( + blank=True, + help_text="Дата создания исходного сообщения при пересылке", + null=True, + ), ), ] diff --git a/backend/communications/migrations/0019_add_polls.py b/backend/communications/migrations/0019_add_polls.py index c1a4e388..b039d4c9 100644 --- a/backend/communications/migrations/0019_add_polls.py +++ b/backend/communications/migrations/0019_add_polls.py @@ -1,228 +1,244 @@ # Generated migration for polls feature +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion class Migration(migrations.Migration): - dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('communications', '0018_add_forwarded_metadata'), + ("communications", "0018_add_forwarded_metadata"), ] operations = [ # Таблица голосований migrations.CreateModel( - name='Poll', + name="Poll", fields=[ - ('id', models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name='ID' - )), - ('question', models.CharField( - max_length=500, - verbose_name='Вопрос' - )), - ('is_anonymous', models.BooleanField( - default=False, - verbose_name='Анонимное голосование' - )), - ('is_multiple_choice', models.BooleanField( - default=False, - verbose_name='Множественный выбор' - )), - ('is_quiz', models.BooleanField( - default=False, - verbose_name='Викторина' - )), - ('allows_custom_answers', models.BooleanField( - default=False, - verbose_name='Разрешить свои варианты' - )), - ('is_closed', models.BooleanField( - default=False, - verbose_name='Голосование закрыто' - )), - ('closed_at', models.DateTimeField( - blank=True, - null=True, - verbose_name='Время закрытия' - )), - ('closes_at', models.DateTimeField( - blank=True, - null=True, - verbose_name='Автоматическое закрытие' - )), - ('created_at', models.DateTimeField( - auto_now_add=True, - verbose_name='Создано' - )), - ('updated_at', models.DateTimeField( - auto_now=True, - verbose_name='Обновлено' - )), - ('total_voters', models.IntegerField( - default=0, - verbose_name='Всего проголосовало' - )), - ('author', models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name='created_polls', - to=settings.AUTH_USER_MODEL, - verbose_name='Автор' - )), - ('message', models.OneToOneField( - on_delete=django.db.models.deletion.CASCADE, - related_name='poll', - to='communications.message', - verbose_name='Сообщение' - )), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("question", models.CharField(max_length=500, verbose_name="Вопрос")), + ( + "is_anonymous", + models.BooleanField( + default=False, verbose_name="Анонимное голосование" + ), + ), + ( + "is_multiple_choice", + models.BooleanField( + default=False, verbose_name="Множественный выбор" + ), + ), + ( + "is_quiz", + models.BooleanField(default=False, verbose_name="Викторина"), + ), + ( + "allows_custom_answers", + models.BooleanField( + default=False, verbose_name="Разрешить свои варианты" + ), + ), + ( + "is_closed", + models.BooleanField( + default=False, verbose_name="Голосование закрыто" + ), + ), + ( + "closed_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Время закрытия" + ), + ), + ( + "closes_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Автоматическое закрытие" + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Создано"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="Обновлено"), + ), + ( + "total_voters", + models.IntegerField(default=0, verbose_name="Всего проголосовало"), + ), + ( + "author", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="created_polls", + to=settings.AUTH_USER_MODEL, + verbose_name="Автор", + ), + ), + ( + "message", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="poll", + to="communications.message", + verbose_name="Сообщение", + ), + ), ], options={ - 'verbose_name': 'Голосование', - 'verbose_name_plural': 'Голосования', - 'ordering': ['-created_at'], + "verbose_name": "Голосование", + "verbose_name_plural": "Голосования", + "ordering": ["-created_at"], }, ), - # Таблица вариантов ответа migrations.CreateModel( - name='PollOption', + name="PollOption", fields=[ - ('id', models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name='ID' - )), - ('text', models.CharField( - max_length=200, - verbose_name='Текст варианта' - )), - ('position', models.IntegerField( - default=0, - verbose_name='Порядковый номер' - )), - ('vote_count', models.IntegerField( - default=0, - verbose_name='Количество голосов' - )), - ('is_correct', models.BooleanField( - default=False, - verbose_name='Правильный ответ' - )), - ('created_at', models.DateTimeField( - auto_now_add=True, - verbose_name='Создан' - )), - ('poll', models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name='options', - to='communications.poll', - verbose_name='Голосование' - )), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "text", + models.CharField(max_length=200, verbose_name="Текст варианта"), + ), + ( + "position", + models.IntegerField(default=0, verbose_name="Порядковый номер"), + ), + ( + "vote_count", + models.IntegerField(default=0, verbose_name="Количество голосов"), + ), + ( + "is_correct", + models.BooleanField(default=False, verbose_name="Правильный ответ"), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Создан"), + ), + ( + "poll", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="options", + to="communications.poll", + verbose_name="Голосование", + ), + ), ], options={ - 'verbose_name': 'Вариант ответа', - 'verbose_name_plural': 'Варианты ответов', - 'ordering': ['position', 'id'], + "verbose_name": "Вариант ответа", + "verbose_name_plural": "Варианты ответов", + "ordering": ["position", "id"], }, ), - # Таблица голосов migrations.CreateModel( - name='PollVote', + name="PollVote", fields=[ - ('id', models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name='ID' - )), - ('voted_at', models.DateTimeField( - auto_now_add=True, - verbose_name='Время голосования' - )), - ('option', models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name='votes', - to='communications.polloption', - verbose_name='Выбранный вариант' - )), - ('poll', models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name='votes', - to='communications.poll', - verbose_name='Голосование' - )), - ('voter', models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name='poll_votes', - to=settings.AUTH_USER_MODEL, - verbose_name='Проголосовавший' - )), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "voted_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Время голосования" + ), + ), + ( + "option", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="votes", + to="communications.polloption", + verbose_name="Выбранный вариант", + ), + ), + ( + "poll", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="votes", + to="communications.poll", + verbose_name="Голосование", + ), + ), + ( + "voter", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="poll_votes", + to=settings.AUTH_USER_MODEL, + verbose_name="Проголосовавший", + ), + ), ], options={ - 'verbose_name': 'Голос', - 'verbose_name_plural': 'Голоса', - 'ordering': ['-voted_at'], + "verbose_name": "Голос", + "verbose_name_plural": "Голоса", + "ordering": ["-voted_at"], }, ), - # Индексы и констрейнты migrations.AddIndex( - model_name='poll', + model_name="poll", + index=models.Index(fields=["message"], name="comm_msg_idx_poll"), + ), + migrations.AddIndex( + model_name="poll", index=models.Index( - fields=['message'], - name='comm_msg_idx_poll' + fields=["author", "created_at"], name="comm_auth_created_poll" ), ), migrations.AddIndex( - model_name='poll', + model_name="poll", index=models.Index( - fields=['author', 'created_at'], - name='comm_auth_created_poll' + fields=["is_closed", "closes_at"], name="comm_closed_closes_poll" ), ), migrations.AddIndex( - model_name='poll', - index=models.Index( - fields=['is_closed', 'closes_at'], - name='comm_closed_closes_poll' - ), + model_name="polloption", + index=models.Index(fields=["poll", "position"], name="comm_poll_pos_idx"), ), migrations.AddIndex( - model_name='polloption', - index=models.Index( - fields=['poll', 'position'], - name='comm_poll_pos_idx' - ), + model_name="pollvote", + index=models.Index(fields=["poll", "voter"], name="comm_poll_voter_idx"), ), migrations.AddIndex( - model_name='pollvote', - index=models.Index( - fields=['poll', 'voter'], - name='comm_poll_voter_idx' - ), + model_name="pollvote", + index=models.Index(fields=["option"], name="comm_option_idx"), ), - migrations.AddIndex( - model_name='pollvote', - index=models.Index( - fields=['option'], - name='comm_option_idx' - ), - ), - # Уникальность голоса migrations.AddConstraint( - model_name='pollvote', + model_name="pollvote", constraint=models.UniqueConstraint( - fields=['poll', 'voter', 'option'], - name='unique_vote_per_option' + fields=["poll", "voter", "option"], name="unique_vote_per_option" ), ), ] diff --git a/backend/communications/migrations/0020_availablereaction_and_more.py b/backend/communications/migrations/0020_availablereaction_and_more.py index ebfdf599..6cf4e62b 100644 --- a/backend/communications/migrations/0020_availablereaction_and_more.py +++ b/backend/communications/migrations/0020_availablereaction_and_more.py @@ -4,95 +4,165 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('communications', '0019_add_polls'), + ("communications", "0019_add_polls"), ] operations = [ migrations.CreateModel( - name='AvailableReaction', + name="AvailableReaction", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('emoji', models.CharField(help_text='Unicode эмодзи (например: 👍, ❤️, 😂)', max_length=10, unique=True, verbose_name='Эмодзи')), - ('name', models.CharField(help_text="Человекочитаемое название (например: 'Лайк', 'Сердце')", max_length=50, verbose_name='Название')), - ('order', models.IntegerField(default=0, help_text='Меньше = выше в списке', verbose_name='Порядок отображения')), - ('is_active', models.BooleanField(default=True, help_text='Отображать ли эту реакцию пользователям', verbose_name='Активна')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата добавления')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "emoji", + models.CharField( + help_text="Unicode эмодзи (например: 👍, ❤️, 😂)", + max_length=10, + unique=True, + verbose_name="Эмодзи", + ), + ), + ( + "name", + models.CharField( + help_text="Человекочитаемое название (например: 'Лайк', 'Сердце')", + max_length=50, + verbose_name="Название", + ), + ), + ( + "order", + models.IntegerField( + default=0, + help_text="Меньше = выше в списке", + verbose_name="Порядок отображения", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Отображать ли эту реакцию пользователям", + verbose_name="Активна", + ), + ), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Дата добавления" + ), + ), ], options={ - 'verbose_name': 'Доступная реакция', - 'verbose_name_plural': 'Доступные реакции', - 'ordering': ['order', 'created_at'], + "verbose_name": "Доступная реакция", + "verbose_name_plural": "Доступные реакции", + "ordering": ["order", "created_at"], }, ), migrations.RenameIndex( - model_name='poll', - new_name='communicati_message_6f2b88_idx', - old_name='comm_msg_idx_poll', + model_name="poll", + new_name="communicati_message_6f2b88_idx", + old_name="comm_msg_idx_poll", ), migrations.RenameIndex( - model_name='poll', - new_name='communicati_author__d3b17c_idx', - old_name='comm_auth_created_poll', + model_name="poll", + new_name="communicati_author__d3b17c_idx", + old_name="comm_auth_created_poll", ), migrations.RenameIndex( - model_name='poll', - new_name='communicati_is_clos_6320c7_idx', - old_name='comm_closed_closes_poll', + model_name="poll", + new_name="communicati_is_clos_6320c7_idx", + old_name="comm_closed_closes_poll", ), migrations.RenameIndex( - model_name='polloption', - new_name='communicati_poll_id_471041_idx', - old_name='comm_poll_pos_idx', + model_name="polloption", + new_name="communicati_poll_id_471041_idx", + old_name="comm_poll_pos_idx", ), migrations.RenameIndex( - model_name='pollvote', - new_name='communicati_poll_id_dc4395_idx', - old_name='comm_poll_voter_idx', + model_name="pollvote", + new_name="communicati_poll_id_dc4395_idx", + old_name="comm_poll_voter_idx", ), migrations.RenameIndex( - model_name='pollvote', - new_name='communicati_option__2d5cef_idx', - old_name='comm_option_idx', + model_name="pollvote", + new_name="communicati_option__2d5cef_idx", + old_name="comm_option_idx", ), migrations.AlterField( - model_name='poll', - name='allows_custom_answers', - field=models.BooleanField(default=False, help_text='Пользователи могут добавить свой вариант ответа', verbose_name='Разрешить свои варианты'), + model_name="poll", + name="allows_custom_answers", + field=models.BooleanField( + default=False, + help_text="Пользователи могут добавить свой вариант ответа", + verbose_name="Разрешить свои варианты", + ), ), migrations.AlterField( - model_name='poll', - name='closes_at', - field=models.DateTimeField(blank=True, help_text='Голосование автоматически закроется в указанное время', null=True, verbose_name='Автоматическое закрытие'), + model_name="poll", + name="closes_at", + field=models.DateTimeField( + blank=True, + help_text="Голосование автоматически закроется в указанное время", + null=True, + verbose_name="Автоматическое закрытие", + ), ), migrations.AlterField( - model_name='poll', - name='is_anonymous', - field=models.BooleanField(default=False, help_text='Не показывать кто голосовал', verbose_name='Анонимное голосование'), + model_name="poll", + name="is_anonymous", + field=models.BooleanField( + default=False, + help_text="Не показывать кто голосовал", + verbose_name="Анонимное голосование", + ), ), migrations.AlterField( - model_name='poll', - name='is_multiple_choice', - field=models.BooleanField(default=False, help_text='Можно выбрать несколько вариантов', verbose_name='Множественный выбор'), + model_name="poll", + name="is_multiple_choice", + field=models.BooleanField( + default=False, + help_text="Можно выбрать несколько вариантов", + verbose_name="Множественный выбор", + ), ), migrations.AlterField( - model_name='poll', - name='is_quiz', - field=models.BooleanField(default=False, help_text='Только один правильный ответ, показывается после голосования', verbose_name='Викторина'), + model_name="poll", + name="is_quiz", + field=models.BooleanField( + default=False, + help_text="Только один правильный ответ, показывается после голосования", + verbose_name="Викторина", + ), ), migrations.AlterField( - model_name='poll', - name='total_voters', - field=models.IntegerField(default=0, help_text='Количество уникальных пользователей, проголосовавших', verbose_name='Всего проголосовало'), + model_name="poll", + name="total_voters", + field=models.IntegerField( + default=0, + help_text="Количество уникальных пользователей, проголосовавших", + verbose_name="Всего проголосовало", + ), ), migrations.AlterField( - model_name='polloption', - name='is_correct', - field=models.BooleanField(default=False, help_text='Для викторин', verbose_name='Правильный ответ'), + model_name="polloption", + name="is_correct", + field=models.BooleanField( + default=False, help_text="Для викторин", verbose_name="Правильный ответ" + ), ), migrations.AddIndex( - model_name='availablereaction', - index=models.Index(fields=['is_active', 'order'], name='communicati_is_acti_124af4_idx'), + model_name="availablereaction", + index=models.Index( + fields=["is_active", "order"], name="communicati_is_acti_124af4_idx" + ), ), ] diff --git a/backend/communications/migrations/0021_add_image_dimensions.py b/backend/communications/migrations/0021_add_image_dimensions.py index 50c4d37c..31d530a4 100644 --- a/backend/communications/migrations/0021_add_image_dimensions.py +++ b/backend/communications/migrations/0021_add_image_dimensions.py @@ -4,20 +4,23 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('communications', '0020_availablereaction_and_more'), + ("communications", "0020_availablereaction_and_more"), ] operations = [ migrations.AddField( - model_name='messageattachment', - name='width', - field=models.IntegerField(null=True, blank=True, verbose_name='Ширина изображения'), + model_name="messageattachment", + name="width", + field=models.IntegerField( + null=True, blank=True, verbose_name="Ширина изображения" + ), ), migrations.AddField( - model_name='messageattachment', - name='height', - field=models.IntegerField(null=True, blank=True, verbose_name='Высота изображения'), + model_name="messageattachment", + name="height", + field=models.IntegerField( + null=True, blank=True, verbose_name="Высота изображения" + ), ), ] diff --git a/backend/communications/migrations/0022_alter_messageattachment_height_and_more.py b/backend/communications/migrations/0022_alter_messageattachment_height_and_more.py index 34af5ee9..8304dd00 100644 --- a/backend/communications/migrations/0022_alter_messageattachment_height_and_more.py +++ b/backend/communications/migrations/0022_alter_messageattachment_height_and_more.py @@ -5,25 +5,42 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('communications', '0021_add_image_dimensions'), + ("communications", "0021_add_image_dimensions"), ] operations = [ migrations.AlterField( - model_name='messageattachment', - name='height', - field=models.IntegerField(blank=True, help_text='Высота изображения или видео в пикселях', null=True, verbose_name='Высота'), + model_name="messageattachment", + name="height", + field=models.IntegerField( + blank=True, + help_text="Высота изображения или видео в пикселях", + null=True, + verbose_name="Высота", + ), ), migrations.AlterField( - model_name='messageattachment', - name='message', - field=models.ForeignKey(blank=True, help_text='Может быть null для временных вложений при редактировании', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='communications.message', verbose_name='Сообщение'), + model_name="messageattachment", + name="message", + field=models.ForeignKey( + blank=True, + help_text="Может быть null для временных вложений при редактировании", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="attachments", + to="communications.message", + verbose_name="Сообщение", + ), ), migrations.AlterField( - model_name='messageattachment', - name='width', - field=models.IntegerField(blank=True, help_text='Ширина изображения или видео в пикселях', null=True, verbose_name='Ширина'), + model_name="messageattachment", + name="width", + field=models.IntegerField( + blank=True, + help_text="Ширина изображения или видео в пикселях", + null=True, + verbose_name="Ширина", + ), ), ] diff --git a/backend/communications/migrations/0023_remove_crosschatmessage_message_and_more.py b/backend/communications/migrations/0023_remove_crosschatmessage_message_and_more.py index 5d71deaf..a340f27a 100644 --- a/backend/communications/migrations/0023_remove_crosschatmessage_message_and_more.py +++ b/backend/communications/migrations/0023_remove_crosschatmessage_message_and_more.py @@ -4,19 +4,18 @@ from django.db import migrations class Migration(migrations.Migration): - dependencies = [ - ('communications', '0022_alter_messageattachment_height_and_more'), + ("communications", "0022_alter_messageattachment_height_and_more"), ] operations = [ migrations.DeleteModel( - name='ChatAccessPermission', + name="ChatAccessPermission", ), migrations.DeleteModel( - name='CrossChatMessage', + name="CrossChatMessage", ), migrations.DeleteModel( - name='MessageReply', + name="MessageReply", ), ] diff --git a/backend/communications/migrations/0024_delete_forwardedmessage.py b/backend/communications/migrations/0024_delete_forwardedmessage.py index 6f28ff63..d7494f8f 100644 --- a/backend/communications/migrations/0024_delete_forwardedmessage.py +++ b/backend/communications/migrations/0024_delete_forwardedmessage.py @@ -4,13 +4,12 @@ from django.db import migrations class Migration(migrations.Migration): - dependencies = [ - ('communications', '0023_remove_crosschatmessage_message_and_more'), + ("communications", "0023_remove_crosschatmessage_message_and_more"), ] operations = [ migrations.DeleteModel( - name='ForwardedMessage', + name="ForwardedMessage", ), ] diff --git a/backend/communications/migrations/0025_messageedithistory_messageforwardmetadata.py b/backend/communications/migrations/0025_messageedithistory_messageforwardmetadata.py index f3b2272d..c0c30e04 100644 --- a/backend/communications/migrations/0025_messageedithistory_messageforwardmetadata.py +++ b/backend/communications/migrations/0025_messageedithistory_messageforwardmetadata.py @@ -6,47 +6,171 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('communications', '0024_delete_forwardedmessage'), + ("communications", "0024_delete_forwardedmessage"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='MessageEditHistory', + name="MessageEditHistory", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('edited_at', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='Время редактирования')), - ('previous_content', models.TextField(verbose_name='Предыдущий текст')), - ('edited_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Кто отредактировал')), - ('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='edit_history_records', to='communications.message', verbose_name='Сообщение')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "edited_at", + models.DateTimeField( + auto_now_add=True, + db_index=True, + verbose_name="Время редактирования", + ), + ), + ("previous_content", models.TextField(verbose_name="Предыдущий текст")), + ( + "edited_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + verbose_name="Кто отредактировал", + ), + ), + ( + "message", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="edit_history_records", + to="communications.message", + verbose_name="Сообщение", + ), + ), ], options={ - 'verbose_name': 'Запись истории редактирования', - 'verbose_name_plural': 'История редактирования сообщений', - 'ordering': ['edited_at'], - 'indexes': [models.Index(fields=['message', 'edited_at'], name='communicati_message_761cf3_idx')], + "verbose_name": "Запись истории редактирования", + "verbose_name_plural": "История редактирования сообщений", + "ordering": ["edited_at"], + "indexes": [ + models.Index( + fields=["message", "edited_at"], + name="communicati_message_761cf3_idx", + ) + ], }, ), migrations.CreateModel( - name='MessageForwardMetadata', + name="MessageForwardMetadata", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('forwarded_at', models.DateTimeField(auto_now_add=True, verbose_name='Время пересылки')), - ('forward_count', models.IntegerField(default=1, help_text='Сколько раз пересылалось', verbose_name='Количество пересылок')), - ('original_created_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата создания оригинала')), - ('original_chat_name', models.CharField(blank=True, max_length=255, verbose_name='Название оригинального чата')), - ('forwarded_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='forwarded_messages', to=settings.AUTH_USER_MODEL, verbose_name='Кто переслал')), - ('message', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='forward_metadata', to='communications.message', verbose_name='Пересланное сообщение')), - ('original_author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Автор оригинала')), - ('original_chat', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='communications.chat', verbose_name='Оригинальный чат')), - ('original_message', models.ForeignKey(blank=True, help_text='Самое первое сообщение в цепочке пересылок', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='forwarded_copies', to='communications.message', verbose_name='Оригинальное сообщение')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "forwarded_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Время пересылки" + ), + ), + ( + "forward_count", + models.IntegerField( + default=1, + help_text="Сколько раз пересылалось", + verbose_name="Количество пересылок", + ), + ), + ( + "original_created_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Дата создания оригинала" + ), + ), + ( + "original_chat_name", + models.CharField( + blank=True, + max_length=255, + verbose_name="Название оригинального чата", + ), + ), + ( + "forwarded_by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="forwarded_messages", + to=settings.AUTH_USER_MODEL, + verbose_name="Кто переслал", + ), + ), + ( + "message", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="forward_metadata", + to="communications.message", + verbose_name="Пересланное сообщение", + ), + ), + ( + "original_author", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + verbose_name="Автор оригинала", + ), + ), + ( + "original_chat", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="communications.chat", + verbose_name="Оригинальный чат", + ), + ), + ( + "original_message", + models.ForeignKey( + blank=True, + help_text="Самое первое сообщение в цепочке пересылок", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="forwarded_copies", + to="communications.message", + verbose_name="Оригинальное сообщение", + ), + ), ], options={ - 'verbose_name': 'Метаданные пересылки', - 'verbose_name_plural': 'Метаданные пересылок', - 'indexes': [models.Index(fields=['original_message'], name='communicati_origina_41ca01_idx'), models.Index(fields=['forwarded_by', 'forwarded_at'], name='communicati_forward_60dc34_idx')], + "verbose_name": "Метаданные пересылки", + "verbose_name_plural": "Метаданные пересылок", + "indexes": [ + models.Index( + fields=["original_message"], + name="communicati_origina_41ca01_idx", + ), + models.Index( + fields=["forwarded_by", "forwarded_at"], + name="communicati_forward_60dc34_idx", + ), + ], }, ), ] diff --git a/backend/communications/migrations/0026_migrate_edit_and_forward_data.py b/backend/communications/migrations/0026_migrate_edit_and_forward_data.py index f5b81e3a..3586069c 100644 --- a/backend/communications/migrations/0026_migrate_edit_and_forward_data.py +++ b/backend/communications/migrations/0026_migrate_edit_and_forward_data.py @@ -6,10 +6,12 @@ from django.utils.dateparse import parse_datetime def migrate_edit_history(apps, schema_editor): """Перенос истории редактирования из JSON в MessageEditHistory""" - Message = apps.get_model('communications', 'Message') - MessageEditHistory = apps.get_model('communications', 'MessageEditHistory') + Message = apps.get_model("communications", "Message") + MessageEditHistory = apps.get_model("communications", "MessageEditHistory") - messages_with_history = Message.objects.filter(is_edited=True).exclude(edit_history=[]) + messages_with_history = Message.objects.filter(is_edited=True).exclude( + edit_history=[] + ) for message in messages_with_history: if not message.edit_history: @@ -19,19 +21,21 @@ def migrate_edit_history(apps, schema_editor): for entry in message.edit_history: try: # Парсим timestamp - if isinstance(entry.get('timestamp'), str): - edited_at = parse_datetime(entry['timestamp']) + if isinstance(entry.get("timestamp"), str): + edited_at = parse_datetime(entry["timestamp"]) if not edited_at: # Если не удалось распарсить, используем edited_at сообщения edited_at = message.edited_at or message.created_at else: - edited_at = entry.get('timestamp', message.edited_at or message.created_at) + edited_at = entry.get( + "timestamp", message.edited_at or message.created_at + ) MessageEditHistory.objects.create( message=message, edited_at=edited_at, - previous_content=entry.get('old_content', ''), - edited_by=message.author # Предполагаем что автор редактировал + previous_content=entry.get("old_content", ""), + edited_by=message.author, # Предполагаем что автор редактировал ) except Exception as e: # Логируем ошибку но продолжаем @@ -40,14 +44,14 @@ def migrate_edit_history(apps, schema_editor): def migrate_forward_metadata(apps, schema_editor): """Перенос метаданных пересылки из полей Message в MessageForwardMetadata""" - Message = apps.get_model('communications', 'Message') - MessageForwardMetadata = apps.get_model('communications', 'MessageForwardMetadata') + Message = apps.get_model("communications", "Message") + MessageForwardMetadata = apps.get_model("communications", "MessageForwardMetadata") forwarded_messages = Message.objects.filter(is_forwarded=True) for message in forwarded_messages: # Пропускаем если метаданные уже созданы - if hasattr(message, 'forward_metadata'): + if hasattr(message, "forward_metadata"): continue try: @@ -55,7 +59,9 @@ def migrate_forward_metadata(apps, schema_editor): original_message = None if message.forwarded_from_message_id: try: - original_message = Message.objects.get(id=message.forwarded_from_message_id) + original_message = Message.objects.get( + id=message.forwarded_from_message_id + ) except Message.DoesNotExist: pass @@ -65,8 +71,8 @@ def migrate_forward_metadata(apps, schema_editor): original_author=message.forwarded_from_author, forwarded_by=message.author, original_created_at=message.forwarded_from_created_at, - original_chat_name=message.forwarded_from_chat_name or '', - forward_count=1 # По умолчанию 1 + original_chat_name=message.forwarded_from_chat_name or "", + forward_count=1, # По умолчанию 1 ) except Exception as e: # Логируем ошибку но продолжаем @@ -75,21 +81,25 @@ def migrate_forward_metadata(apps, schema_editor): def reverse_migrate_edit_history(apps, schema_editor): """Откат: перенос MessageEditHistory обратно в JSON""" - Message = apps.get_model('communications', 'Message') - MessageEditHistory = apps.get_model('communications', 'MessageEditHistory') + Message = apps.get_model("communications", "Message") + MessageEditHistory = apps.get_model("communications", "MessageEditHistory") for message in Message.objects.filter(is_edited=True): - history_records = MessageEditHistory.objects.filter(message=message).order_by('edited_at') + history_records = MessageEditHistory.objects.filter(message=message).order_by( + "edited_at" + ) edit_history = [] for record in history_records: - edit_history.append({ - 'timestamp': record.edited_at.isoformat(), - 'old_content': record.previous_content - }) + edit_history.append( + { + "timestamp": record.edited_at.isoformat(), + "old_content": record.previous_content, + } + ) message.edit_history = edit_history - message.save(update_fields=['edit_history']) + message.save(update_fields=["edit_history"]) def reverse_migrate_forward_metadata(apps, schema_editor): @@ -98,12 +108,13 @@ def reverse_migrate_forward_metadata(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ - ('communications', '0025_messageedithistory_messageforwardmetadata'), + ("communications", "0025_messageedithistory_messageforwardmetadata"), ] operations = [ migrations.RunPython(migrate_edit_history, reverse_migrate_edit_history), - migrations.RunPython(migrate_forward_metadata, reverse_migrate_forward_metadata), + migrations.RunPython( + migrate_forward_metadata, reverse_migrate_forward_metadata + ), ] diff --git a/backend/communications/migrations/0027_remove_message_edit_history_and_more.py b/backend/communications/migrations/0027_remove_message_edit_history_and_more.py index a6119967..9e87e354 100644 --- a/backend/communications/migrations/0027_remove_message_edit_history_and_more.py +++ b/backend/communications/migrations/0027_remove_message_edit_history_and_more.py @@ -4,34 +4,33 @@ from django.db import migrations class Migration(migrations.Migration): - dependencies = [ - ('communications', '0026_migrate_edit_and_forward_data'), + ("communications", "0026_migrate_edit_and_forward_data"), ] operations = [ migrations.RemoveField( - model_name='message', - name='edit_history', + model_name="message", + name="edit_history", ), migrations.RemoveField( - model_name='message', - name='forwarded_from_author', + model_name="message", + name="forwarded_from_author", ), migrations.RemoveField( - model_name='message', - name='forwarded_from_chat_name', + model_name="message", + name="forwarded_from_chat_name", ), migrations.RemoveField( - model_name='message', - name='forwarded_from_created_at', + model_name="message", + name="forwarded_from_created_at", ), migrations.RemoveField( - model_name='message', - name='forwarded_from_message_id', + model_name="message", + name="forwarded_from_message_id", ), migrations.RemoveField( - model_name='message', - name='thread_reply_count', + model_name="message", + name="thread_reply_count", ), ] diff --git a/backend/communications/migrations/0028_remove_last_read_at_and_unused_fields.py b/backend/communications/migrations/0028_remove_last_read_at_and_unused_fields.py index a280f65c..9dc3f12b 100644 --- a/backend/communications/migrations/0028_remove_last_read_at_and_unused_fields.py +++ b/backend/communications/migrations/0028_remove_last_read_at_and_unused_fields.py @@ -3,29 +3,27 @@ from django.db import migrations class Migration(migrations.Migration): - dependencies = [ - ('communications', '0027_remove_message_edit_history_and_more'), + ("communications", "0027_remove_message_edit_history_and_more"), ] operations = [ # Удаляем индекс, который использует last_read_at migrations.RemoveIndex( - model_name='chatreadstate', - name='communicati_chat_id_edefdb_idx', + model_name="chatreadstate", + name="communicati_chat_id_edefdb_idx", ), - # Удаляем устаревшие поля migrations.RemoveField( - model_name='chatreadstate', - name='last_read_at', + model_name="chatreadstate", + name="last_read_at", ), migrations.RemoveField( - model_name='chatreadstate', - name='unread_mentions_count', + model_name="chatreadstate", + name="unread_mentions_count", ), migrations.RemoveField( - model_name='chatreadstate', - name='unread_thread_replies_count', + model_name="chatreadstate", + name="unread_thread_replies_count", ), ] diff --git a/backend/communications/migrations/0029_alter_chatreadstate_last_read_message_and_more.py b/backend/communications/migrations/0029_alter_chatreadstate_last_read_message_and_more.py index ce48f36e..65fec889 100644 --- a/backend/communications/migrations/0029_alter_chatreadstate_last_read_message_and_more.py +++ b/backend/communications/migrations/0029_alter_chatreadstate_last_read_message_and_more.py @@ -6,20 +6,29 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('communications', '0028_remove_last_read_at_and_unused_fields'), + ("communications", "0028_remove_last_read_at_and_unused_fields"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.AlterField( - model_name='chatreadstate', - name='last_read_message', - field=models.ForeignKey(blank=True, help_text='Обновляется автоматически при загрузке сообщений (Telegram-style)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='communications.message', verbose_name='Последнее прочитанное сообщение'), + model_name="chatreadstate", + name="last_read_message", + field=models.ForeignKey( + blank=True, + help_text="Обновляется автоматически при загрузке сообщений (Telegram-style)", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="communications.message", + verbose_name="Последнее прочитанное сообщение", + ), ), migrations.AddIndex( - model_name='chatreadstate', - index=models.Index(fields=['last_read_message'], name='communicati_last_re_2f5f7e_idx'), + model_name="chatreadstate", + index=models.Index( + fields=["last_read_message"], name="communicati_last_re_2f5f7e_idx" + ), ), ] diff --git a/backend/communications/migrations/0030_chatreadstate_unread_count_and_more.py b/backend/communications/migrations/0030_chatreadstate_unread_count_and_more.py index cf634e11..ef74aa93 100644 --- a/backend/communications/migrations/0030_chatreadstate_unread_count_and_more.py +++ b/backend/communications/migrations/0030_chatreadstate_unread_count_and_more.py @@ -9,18 +9,19 @@ def recalculate_unread_counts(apps, schema_editor): Пересчитываем unread_count для всех существующих ChatReadState записей. Это может занять время на больших данных, но выполняется один раз. """ - ChatReadState = apps.get_model('communications', 'ChatReadState') - Message = apps.get_model('communications', 'Message') + ChatReadState = apps.get_model("communications", "ChatReadState") + Message = apps.get_model("communications", "Message") total = ChatReadState.objects.count() print(f"\n🔄 Пересчет unread_count для {total} записей ChatReadState...") updated = 0 - for read_state in ChatReadState.objects.select_related('chat', 'user').iterator(chunk_size=100): + for read_state in ChatReadState.objects.select_related("chat", "user").iterator( + chunk_size=100 + ): # Считаем непрочитанные сообщения messages_qs = Message.objects.filter( - chat_id=read_state.chat_id, - is_deleted=False + chat_id=read_state.chat_id, is_deleted=False ).exclude(author_id=read_state.user_id) if read_state.last_read_message_id: @@ -31,7 +32,7 @@ def recalculate_unread_counts(apps, schema_editor): count = messages_qs.count() read_state.unread_count = count - read_state.save(update_fields=['unread_count']) + read_state.save(update_fields=["unread_count"]) updated += 1 if updated % 100 == 0: @@ -42,26 +43,31 @@ def recalculate_unread_counts(apps, schema_editor): def reverse_recalculate(apps, schema_editor): """При откате миграции просто обнуляем счетчики""" - ChatReadState = apps.get_model('communications', 'ChatReadState') + ChatReadState = apps.get_model("communications", "ChatReadState") ChatReadState.objects.update(unread_count=0) class Migration(migrations.Migration): - dependencies = [ - ('communications', '0029_alter_chatreadstate_last_read_message_and_more'), + ("communications", "0029_alter_chatreadstate_last_read_message_and_more"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.AddField( - model_name='chatreadstate', - name='unread_count', - field=models.IntegerField(default=0, help_text='Кешированное значение для производительности. Обновляется при новых сообщениях и прочтении.', verbose_name='Непрочитанных сообщений'), + model_name="chatreadstate", + name="unread_count", + field=models.IntegerField( + default=0, + help_text="Кешированное значение для производительности. Обновляется при новых сообщениях и прочтении.", + verbose_name="Непрочитанных сообщений", + ), ), migrations.AddIndex( - model_name='chatreadstate', - index=models.Index(fields=['user', 'unread_count'], name='communicati_user_id_241b5b_idx'), + model_name="chatreadstate", + index=models.Index( + fields=["user", "unread_count"], name="communicati_user_id_241b5b_idx" + ), ), # Пересчитываем существующие значения migrations.RunPython(recalculate_unread_counts, reverse_recalculate), diff --git a/backend/communications/migrations/0031_add_universal_context_fields.py b/backend/communications/migrations/0031_add_universal_context_fields.py index 611bc5c6..b06ada3d 100644 --- a/backend/communications/migrations/0031_add_universal_context_fields.py +++ b/backend/communications/migrations/0031_add_universal_context_fields.py @@ -6,42 +6,76 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('communications', '0030_chatreadstate_unread_count_and_more'), - ('contenttypes', '0002_remove_content_type_name'), - ('employees', '0041_alter_departmentpermission_code'), + ("communications", "0030_chatreadstate_unread_count_and_more"), + ("contenttypes", "0002_remove_content_type_name"), + ("employees", "0041_alter_departmentpermission_code"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.AddField( - model_name='chat', - name='context_content_type', - field=models.ForeignKey(blank=True, help_text='Type of related object (e.g., Department, Project, Team)', null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='Context type'), + model_name="chat", + name="context_content_type", + field=models.ForeignKey( + blank=True, + help_text="Type of related object (e.g., Department, Project, Team)", + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="contenttypes.contenttype", + verbose_name="Context type", + ), ), migrations.AddField( - model_name='chat', - name='context_object_id', - field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Context object ID'), + model_name="chat", + name="context_object_id", + field=models.PositiveIntegerField( + blank=True, null=True, verbose_name="Context object ID" + ), ), migrations.AddField( - model_name='chat', - name='extra_data', - field=models.JSONField(blank=True, default=dict, help_text='Additional chat metadata (extensible)', verbose_name='Extra data'), + model_name="chat", + name="extra_data", + field=models.JSONField( + blank=True, + default=dict, + help_text="Additional chat metadata (extensible)", + verbose_name="Extra data", + ), ), migrations.AddField( - model_name='chat', - name='flags', - field=models.JSONField(blank=True, default=dict, help_text="Custom flags: {'is_primary': true, 'is_archived': false, etc.}", verbose_name='Flags'), + model_name="chat", + name="flags", + field=models.JSONField( + blank=True, + default=dict, + help_text="Custom flags: {'is_primary': true, 'is_archived': false, etc.}", + verbose_name="Flags", + ), ), migrations.AlterField( - model_name='chat', - name='type', - field=models.CharField(choices=[('private', 'Личный'), ('group', 'Групповой'), ('department', 'Отдел'), ('channel', 'Канал'), ('announcement', 'Объявления'), ('global', 'Глобальный')], db_index=True, help_text='Chat type: private, group, channel, announcement, department, global', max_length=32, verbose_name='Тип чата'), + model_name="chat", + name="type", + field=models.CharField( + choices=[ + ("private", "Личный"), + ("group", "Групповой"), + ("department", "Отдел"), + ("channel", "Канал"), + ("announcement", "Объявления"), + ("global", "Глобальный"), + ], + db_index=True, + help_text="Chat type: private, group, channel, announcement, department, global", + max_length=32, + verbose_name="Тип чата", + ), ), migrations.AddIndex( - model_name='chat', - index=models.Index(fields=['context_content_type', 'context_object_id'], name='chat_context_idx'), + model_name="chat", + index=models.Index( + fields=["context_content_type", "context_object_id"], + name="chat_context_idx", + ), ), ] diff --git a/backend/communications/migrations/0032_migrate_department_to_context.py b/backend/communications/migrations/0032_migrate_department_to_context.py index e48a95c2..a8692d8d 100644 --- a/backend/communications/migrations/0032_migrate_department_to_context.py +++ b/backend/communications/migrations/0032_migrate_department_to_context.py @@ -7,12 +7,12 @@ def migrate_department_to_context(apps, schema_editor): """ Копирует department → context_object для всех чатов с department. """ - Chat = apps.get_model('communications', 'Chat') - ContentType = apps.get_model('contenttypes', 'ContentType') + Chat = apps.get_model("communications", "Chat") + ContentType = apps.get_model("contenttypes", "ContentType") # Получаем ContentType для модели Department try: - dept_ct = ContentType.objects.get(app_label='employees', model='department') + dept_ct = ContentType.objects.get(app_label="employees", model="department") except ContentType.DoesNotExist: print("ContentType для Department не найден, пропускаем миграцию") return @@ -24,7 +24,7 @@ def migrate_department_to_context(apps, schema_editor): for chat in chats_with_dept: chat.context_content_type = dept_ct chat.context_object_id = chat.department_id - chat.save(update_fields=['context_content_type', 'context_object_id']) + chat.save(update_fields=["context_content_type", "context_object_id"]) count += 1 print(f"Мигрировано {count} чатов: department → context_object") @@ -34,35 +34,35 @@ def reverse_migrate(apps, schema_editor): """ Откат: копирует context_object → department для Department объектов. """ - Chat = apps.get_model('communications', 'Chat') - ContentType = apps.get_model('contenttypes', 'ContentType') + Chat = apps.get_model("communications", "Chat") + ContentType = apps.get_model("contenttypes", "ContentType") try: - dept_ct = ContentType.objects.get(app_label='employees', model='department') + dept_ct = ContentType.objects.get(app_label="employees", model="department") except ContentType.DoesNotExist: return chats_with_context = Chat.objects.filter( - context_content_type=dept_ct, - context_object_id__isnull=False + context_content_type=dept_ct, context_object_id__isnull=False ) count = 0 for chat in chats_with_context: chat.department_id = chat.context_object_id - chat.save(update_fields=['department']) + chat.save(update_fields=["department"]) count += 1 print(f"Откат: восстановлено {count} чатов: context_object → department") class Migration(migrations.Migration): - dependencies = [ - ('communications', '0031_add_universal_context_fields'), - ('contenttypes', '0002_remove_content_type_name'), + ("communications", "0031_add_universal_context_fields"), + ("contenttypes", "0002_remove_content_type_name"), ] operations = [ - migrations.RunPython(migrate_department_to_context, reverse_code=reverse_migrate), + migrations.RunPython( + migrate_department_to_context, reverse_code=reverse_migrate + ), ] diff --git a/backend/communications/migrations/0033_migrate_is_main_to_flags.py b/backend/communications/migrations/0033_migrate_is_main_to_flags.py index 140918c8..b5d08b74 100644 --- a/backend/communications/migrations/0033_migrate_is_main_to_flags.py +++ b/backend/communications/migrations/0033_migrate_is_main_to_flags.py @@ -7,7 +7,7 @@ def migrate_is_main_to_flags(apps, schema_editor): """ Копирует is_main → flags['is_primary']. """ - Chat = apps.get_model('communications', 'Chat') + Chat = apps.get_model("communications", "Chat") chats_with_is_main = Chat.objects.filter(is_main=True) @@ -15,8 +15,8 @@ def migrate_is_main_to_flags(apps, schema_editor): for chat in chats_with_is_main: if not chat.flags: chat.flags = {} - chat.flags['is_primary'] = True - chat.save(update_fields=['flags']) + chat.flags["is_primary"] = True + chat.save(update_fields=["flags"]) count += 1 print(f"Мигрировано {count} чатов: is_main=True → flags['is_primary']=True") @@ -26,24 +26,23 @@ def reverse_migrate(apps, schema_editor): """ Откат: копирует flags['is_primary'] → is_main. """ - Chat = apps.get_model('communications', 'Chat') + Chat = apps.get_model("communications", "Chat") all_chats = Chat.objects.all() count = 0 for chat in all_chats: - if chat.flags and chat.flags.get('is_primary'): + if chat.flags and chat.flags.get("is_primary"): chat.is_main = True - chat.save(update_fields=['is_main']) + chat.save(update_fields=["is_main"]) count += 1 print(f"Откат: восстановлено {count} чатов: flags['is_primary'] → is_main") class Migration(migrations.Migration): - dependencies = [ - ('communications', '0032_migrate_department_to_context'), + ("communications", "0032_migrate_department_to_context"), ] operations = [ diff --git a/backend/communications/migrations/0034_rename_include_all_employees.py b/backend/communications/migrations/0034_rename_include_all_employees.py index 5a7aeab3..2dab8e06 100644 --- a/backend/communications/migrations/0034_rename_include_all_employees.py +++ b/backend/communications/migrations/0034_rename_include_all_employees.py @@ -4,15 +4,14 @@ from django.db import migrations class Migration(migrations.Migration): - dependencies = [ - ('communications', '0033_migrate_is_main_to_flags'), + ("communications", "0033_migrate_is_main_to_flags"), ] operations = [ migrations.RenameField( - model_name='chat', - old_name='include_all_employees', - new_name='include_all_users', + model_name="chat", + old_name="include_all_employees", + new_name="include_all_users", ), ] diff --git a/backend/communications/migrations/0035_remove_department_field.py b/backend/communications/migrations/0035_remove_department_field.py index 38c7d22e..b0437e00 100644 --- a/backend/communications/migrations/0035_remove_department_field.py +++ b/backend/communications/migrations/0035_remove_department_field.py @@ -4,27 +4,30 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('communications', '0034_rename_include_all_employees'), + ("communications", "0034_rename_include_all_employees"), ] operations = [ migrations.RemoveConstraint( - model_name='chat', - name='unique_main_department_chat', + model_name="chat", + name="unique_main_department_chat", ), migrations.RemoveIndex( - model_name='chat', - name='communicati_departm_e2e705_idx', + model_name="chat", + name="communicati_departm_e2e705_idx", ), migrations.RemoveField( - model_name='chat', - name='department', + model_name="chat", + name="department", ), migrations.AlterField( - model_name='chat', - name='include_all_users', - field=models.BooleanField(default=False, help_text='Для анонсов и общих чатов', verbose_name='Включить всех пользователей'), + model_name="chat", + name="include_all_users", + field=models.BooleanField( + default=False, + help_text="Для анонсов и общих чатов", + verbose_name="Включить всех пользователей", + ), ), ] diff --git a/backend/communications/migrations/0036_add_comments_type_deprecate_department.py b/backend/communications/migrations/0036_add_comments_type_deprecate_department.py index 88a8ed90..5268b65a 100644 --- a/backend/communications/migrations/0036_add_comments_type_deprecate_department.py +++ b/backend/communications/migrations/0036_add_comments_type_deprecate_department.py @@ -4,15 +4,28 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('communications', '0035_remove_department_field'), + ("communications", "0035_remove_department_field"), ] operations = [ migrations.AlterField( - model_name='chat', - name='type', - field=models.CharField(choices=[('private', 'Личный'), ('group', 'Групповой'), ('channel', 'Канал'), ('announcement', 'Объявления'), ('global', 'Глобальный'), ('comments', 'Комментарии'), ('department', '[Legacy] Отдел')], db_index=True, help_text="Chat type: private, group, channel, announcement, global, comments. Use 'group' + context_object for domain-specific chats.", max_length=32, verbose_name='Тип чата'), + model_name="chat", + name="type", + field=models.CharField( + choices=[ + ("private", "Личный"), + ("group", "Групповой"), + ("channel", "Канал"), + ("announcement", "Объявления"), + ("global", "Глобальный"), + ("comments", "Комментарии"), + ("department", "[Legacy] Отдел"), + ], + db_index=True, + help_text="Chat type: private, group, channel, announcement, global, comments. Use 'group' + context_object for domain-specific chats.", + max_length=32, + verbose_name="Тип чата", + ), ), ] diff --git a/backend/communications/migrations/0037_remove_department_type.py b/backend/communications/migrations/0037_remove_department_type.py index 53e58223..1806022d 100644 --- a/backend/communications/migrations/0037_remove_department_type.py +++ b/backend/communications/migrations/0037_remove_department_type.py @@ -5,21 +5,40 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('communications', '0036_add_comments_type_deprecate_department'), - ('contenttypes', '0002_remove_content_type_name'), + ("communications", "0036_add_comments_type_deprecate_department"), + ("contenttypes", "0002_remove_content_type_name"), ] operations = [ migrations.AlterField( - model_name='chat', - name='context_content_type', - field=models.ForeignKey(blank=True, help_text='Type of related object (e.g., Project, Team, Event, Document)', null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='Context type'), + model_name="chat", + name="context_content_type", + field=models.ForeignKey( + blank=True, + help_text="Type of related object (e.g., Project, Team, Event, Document)", + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="contenttypes.contenttype", + verbose_name="Context type", + ), ), migrations.AlterField( - model_name='chat', - name='type', - field=models.CharField(choices=[('private', 'Личный'), ('group', 'Групповой'), ('channel', 'Канал'), ('announcement', 'Объявления'), ('global', 'Глобальный'), ('comments', 'Комментарии')], db_index=True, help_text="Chat type: private, group, channel, announcement, global, comments. Use 'group' + context_object for domain-specific chats.", max_length=32, verbose_name='Тип чата'), + model_name="chat", + name="type", + field=models.CharField( + choices=[ + ("private", "Личный"), + ("group", "Групповой"), + ("channel", "Канал"), + ("announcement", "Объявления"), + ("global", "Глобальный"), + ("comments", "Комментарии"), + ], + db_index=True, + help_text="Chat type: private, group, channel, announcement, global, comments. Use 'group' + context_object for domain-specific chats.", + max_length=32, + verbose_name="Тип чата", + ), ), ] diff --git a/backend/communications/migrations/0038_alter_chat_participants_alter_chatmembership_role.py b/backend/communications/migrations/0038_alter_chat_participants_alter_chatmembership_role.py index dc072fa7..7199a2a5 100644 --- a/backend/communications/migrations/0038_alter_chat_participants_alter_chatmembership_role.py +++ b/backend/communications/migrations/0038_alter_chat_participants_alter_chatmembership_role.py @@ -5,21 +5,35 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('communications', '0037_remove_department_type'), + ("communications", "0037_remove_department_type"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.AlterField( - model_name='chat', - name='participants', - field=models.ManyToManyField(blank=True, related_name='chats', to=settings.AUTH_USER_MODEL, verbose_name='Участники'), + model_name="chat", + name="participants", + field=models.ManyToManyField( + blank=True, + related_name="chats", + to=settings.AUTH_USER_MODEL, + verbose_name="Участники", + ), ), migrations.AlterField( - model_name='chatmembership', - name='role', - field=models.CharField(choices=[('admin', 'Администратор'), ('moderator', 'Модератор'), ('member', 'Участник'), ('guest', 'Гость')], default='member', max_length=10, verbose_name='Роль'), + model_name="chatmembership", + name="role", + field=models.CharField( + choices=[ + ("admin", "Администратор"), + ("moderator", "Модератор"), + ("member", "Участник"), + ("guest", "Гость"), + ], + default="member", + max_length=10, + verbose_name="Роль", + ), ), ] diff --git a/backend/communications/migrations/0039_alter_chat_context_object_id_bigint.py b/backend/communications/migrations/0039_alter_chat_context_object_id_bigint.py index 3639a5b4..ee1c2d00 100644 --- a/backend/communications/migrations/0039_alter_chat_context_object_id_bigint.py +++ b/backend/communications/migrations/0039_alter_chat_context_object_id_bigint.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("communications", "0038_alter_chat_participants_alter_chatmembership_role"), ] diff --git a/backend/communications/migrations/0041_chatreadstate_unread_mention_count.py b/backend/communications/migrations/0041_chatreadstate_unread_mention_count.py index 51784167..6c51b618 100644 --- a/backend/communications/migrations/0041_chatreadstate_unread_mention_count.py +++ b/backend/communications/migrations/0041_chatreadstate_unread_mention_count.py @@ -10,7 +10,9 @@ def backfill_unread_mentions(apps, schema_editor): (state.chat_id, state.user_id): state for state in states } - for message in Message.objects.filter(is_deleted=False).exclude(mentions=[]).iterator(): + for message in ( + Message.objects.filter(is_deleted=False).exclude(mentions=[]).iterator() + ): seen_user_ids = set() for mention in message.mentions or []: try: diff --git a/backend/communications/migrations/0043_activechatsession.py b/backend/communications/migrations/0043_activechatsession.py index 1f3b27eb..604714eb 100644 --- a/backend/communications/migrations/0043_activechatsession.py +++ b/backend/communications/migrations/0043_activechatsession.py @@ -1,6 +1,6 @@ +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion class Migration(migrations.Migration): diff --git a/backend/communications/notifications/__init__.py b/backend/communications/notifications/__init__.py index e4ef34ee..2dfd8e41 100644 --- a/backend/communications/notifications/__init__.py +++ b/backend/communications/notifications/__init__.py @@ -18,26 +18,27 @@ chat_name = get_chat_name(chat) """ +# Импортируем handlers и signals для регистрации +from . import ( + handlers, # noqa: F401 + signals, # noqa: F401 +) from .config import ( - NotificationVerbs, - MessageTemplates, ActionURLs, + MessageTemplates, + NotificationVerbs, extract_mentions, - truncate_message, get_chat_name, + truncate_message, ) from .handlers import get_users_with_notifications_enabled -# Импортируем handlers и signals для регистрации -from . import handlers # noqa: F401 -from . import signals # noqa: F401 - __all__ = [ - 'NotificationVerbs', - 'MessageTemplates', - 'ActionURLs', - 'extract_mentions', - 'truncate_message', - 'get_chat_name', - 'get_users_with_notifications_enabled', + "NotificationVerbs", + "MessageTemplates", + "ActionURLs", + "extract_mentions", + "truncate_message", + "get_chat_name", + "get_users_with_notifications_enabled", ] diff --git a/backend/communications/notifications/config.py b/backend/communications/notifications/config.py index e396011d..5681db15 100644 --- a/backend/communications/notifications/config.py +++ b/backend/communications/notifications/config.py @@ -14,12 +14,12 @@ import re class NotificationVerbs: """Глаголы (verbs) для уведомлений communications.""" - NEW_MESSAGE = 'chat_new_message' - ANNOUNCEMENT = 'announcement_new_message' - MENTION = 'chat_mention' - REPLY = 'chat_reply' - ADDED_TO_CHAT = 'chat_added_to_chat' - COMMENTED = 'commented' # Для комментариев к объектам + NEW_MESSAGE = "chat_new_message" + ANNOUNCEMENT = "announcement_new_message" + MENTION = "chat_mention" + REPLY = "chat_reply" + ADDED_TO_CHAT = "chat_added_to_chat" + COMMENTED = "commented" # Для комментариев к объектам class MessageTemplates: @@ -28,27 +28,27 @@ class MessageTemplates: @staticmethod def mention(author_name: str) -> str: """Шаблон для упоминания пользователя.""" - return f'Вас упомянул {author_name}' + return f"Вас упомянул {author_name}" @staticmethod def reply(author_name: str) -> str: """Шаблон для ответа на сообщение.""" - return f'{author_name} ответил на ваше сообщение' + return f"{author_name} ответил на ваше сообщение" @staticmethod def announcement(author_name: str) -> str: """Шаблон для нового объявления.""" - return f'Новое объявление от {author_name}' + return f"Новое объявление от {author_name}" @staticmethod def private_message(author_name: str) -> str: """Шаблон для приватного сообщения.""" - return f'Новое сообщение от {author_name}' + return f"Новое сообщение от {author_name}" @staticmethod def group_message(author_name: str, chat_name: str) -> str: """Шаблон для сообщения в группе.""" - return f'{author_name} в {chat_name}' + return f"{author_name} в {chat_name}" @staticmethod def added_to_chat(chat_name: str) -> str: @@ -58,20 +58,20 @@ class MessageTemplates: @staticmethod def added_to_chat_title() -> str: """Заголовок для уведомления о добавлении в чат.""" - return 'Вас добавили в чат' + return "Вас добавили в чат" class ActionURLs: """URL-адреса для действий с сообщениями.""" - MESSAGES = '/messages' + MESSAGES = "/messages" @staticmethod def chat_detail(chat_id: int) -> str: """ Возвращает URL-адрес чата. """ - return f'/messages/{chat_id}' + return f"/messages/{chat_id}" @staticmethod def message_detail(chat_id: int, message_id: int) -> str: @@ -80,11 +80,12 @@ class ActionURLs: В будущем можно добавить прокрутку к конкретному сообщению. """ - return f'/messages/{chat_id}?message={message_id}' + return f"/messages/{chat_id}?message={message_id}" # ===== Вспомогательные функции ===== + def extract_mentions(text: str) -> list[str]: """ Извлекает email'ы из упоминаний вида @email. @@ -103,7 +104,7 @@ def extract_mentions(text: str) -> list[str]: return [] # Паттерн: @ + email формат - pattern = r'@([\w.+-]+@[\w.-]+\.[\w]+)' + pattern = r"@([\w.+-]+@[\w.-]+\.[\w]+)" mentions = re.findall(pattern, text) return list(set(mentions)) # Уникальные значения @@ -121,12 +122,12 @@ def truncate_message(text: str, max_length: int = 100) -> str: Обрезанный текст с многоточием если нужно """ if not text: - return '' + return "" if len(text) <= max_length: return text - return text[:max_length - 3] + '...' + return text[: max_length - 3] + "..." def get_chat_name(chat) -> str: @@ -142,18 +143,18 @@ def get_chat_name(chat) -> str: if chat.name: return chat.name - if chat.type == 'global': - return 'Глобальный чат' + if chat.type == "global": + return "Глобальный чат" # Use context_object (GenericFK) for any chat type if chat.context_object: return f"Чат: {chat.context_object}" - if chat.type == 'private': + if chat.type == "private": # Для приватного чата можно вернуть имена участников participants = chat.participants.all()[:2] if participants: names = [p.get_full_name() or p.username for p in participants] - return ', '.join(names) + return ", ".join(names) - return 'Чат' + return "Чат" diff --git a/backend/communications/notifications/handlers.py b/backend/communications/notifications/handlers.py index a5cddd99..bbc7e704 100644 --- a/backend/communications/notifications/handlers.py +++ b/backend/communications/notifications/handlers.py @@ -22,14 +22,14 @@ except ImportError: NOTIFICATIONS_AVAILABLE = False notify = None -from ..models import ChatUserSettings from ..mentions import get_mentionable_users, mentioned_user_ids +from ..models import ChatUserSettings from .config import ( - NotificationVerbs, - MessageTemplates, ActionURLs, - truncate_message, + MessageTemplates, + NotificationVerbs, get_chat_name, + truncate_message, ) User = get_user_model() @@ -340,11 +340,13 @@ def _notify_comment(message, excluded_recipient_ids=None): object_author = getattr(context_obj, "employee", None) object_url = f"/attendance?record={context_obj.id}&comments=1" object_type = "записи посещаемости" - admin_ids = User.objects.filter( - is_active=True, - ).filter( - Q(is_staff=True) | Q(is_superuser=True) - ).values_list("id", flat=True) + admin_ids = ( + User.objects.filter( + is_active=True, + ) + .filter(Q(is_staff=True) | Q(is_superuser=True)) + .values_list("id", flat=True) + ) recipient_ids.update(admin_ids) extra_data = { "attendance_record_id": context_obj.id, diff --git a/backend/communications/notifications/signals.py b/backend/communications/notifications/signals.py index ffabd0e5..1e75e303 100644 --- a/backend/communications/notifications/signals.py +++ b/backend/communications/notifications/signals.py @@ -15,16 +15,16 @@ Django signals для автоматической генерации уведо from django.conf import settings from django.contrib.auth import get_user_model -from django.db.models.signals import post_save, m2m_changed +from django.db.models.signals import m2m_changed, post_save from django.dispatch import receiver -from ..models import Message, Chat -from .handlers import notify_new_message, notify_chat_added +from ..models import Chat, Message +from .handlers import notify_chat_added, notify_new_message User = get_user_model() # Проверяем, включены ли автоматические уведомления -_AUTO_NOTIFY_ENABLED = getattr(settings, 'COMMUNICATIONS_AUTO_NOTIFY', True) +_AUTO_NOTIFY_ENABLED = getattr(settings, "COMMUNICATIONS_AUTO_NOTIFY", True) @receiver(post_save, sender=Message) @@ -67,14 +67,14 @@ def create_chat_added_notifications(sender, instance, action, pk_set, **kwargs): if not _AUTO_NOTIFY_ENABLED: return - if action != 'post_add': + if action != "post_add": return chat = instance # Получаем информацию о том, кто добавил (если доступно) # Это может быть последний изменивший или создатель чата - added_by = getattr(chat, 'created_by', None) or chat.participants.first() + added_by = getattr(chat, "created_by", None) or chat.participants.first() # ОПТИМИЗАЦИЯ: Получаем всех новых пользователей одним запросом new_users = User.objects.filter(id__in=pk_set) diff --git a/backend/communications/polls.py b/backend/communications/polls.py index 26c33134..62f74220 100644 --- a/backend/communications/polls.py +++ b/backend/communications/polls.py @@ -20,10 +20,7 @@ class PollActionError(Exception): def poll_is_effectively_closed(poll: Poll) -> bool: - return bool( - poll.is_closed - or (poll.closes_at and poll.closes_at <= timezone.now()) - ) + return bool(poll.is_closed or (poll.closes_at and poll.closes_at <= timezone.now())) def cast_poll_vote(poll: Poll, user, option_ids) -> tuple[Poll, list[int]]: diff --git a/backend/communications/presence.py b/backend/communications/presence.py index de574edb..40de03e0 100644 --- a/backend/communications/presence.py +++ b/backend/communications/presence.py @@ -4,7 +4,6 @@ from django.utils import timezone from communications.models import ActiveChatSession - ACTIVE_CHAT_SESSION_TTL = timedelta(seconds=75) diff --git a/backend/communications/serialization.py b/backend/communications/serialization.py index d633e545..84d56485 100644 --- a/backend/communications/serialization.py +++ b/backend/communications/serialization.py @@ -6,6 +6,7 @@ """ from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist def _get_author_url(author) -> str: @@ -270,9 +271,9 @@ def serialize_message(m, user=None, include_linked_tasks=False) -> dict: forwarded_data["chat_name"] = metadata.original_chat_name data["forwarded_from"] = forwarded_data - except Exception: + except (AttributeError, ObjectDoesNotExist): # Если метаданных нет, просто не добавляем информацию о пересылке - pass + data.pop("forwarded_from", None) # Реакции - сериализуем из связанной модели MessageReaction data["reactions_summary"] = m.get_reactions_summary() @@ -312,9 +313,9 @@ def serialize_message(m, user=None, include_linked_tasks=False) -> dict: reply_msg = Msg.objects.select_related("author").get(pk=m.reply_to_id) data["reply_to"] = _serialize_reply_preview(reply_msg) - except Exception: + except (AttributeError, ObjectDoesNotExist): # Если не удалось загрузить reply_to, просто пропускаем - pass + data.pop("reply_to", None) # Информация о пересылке (legacy) if m.is_forwarded and hasattr(m, "forward_info"): diff --git a/backend/communications/services/__init__.py b/backend/communications/services/__init__.py index 73f39292..c564e189 100644 --- a/backend/communications/services/__init__.py +++ b/backend/communications/services/__init__.py @@ -1,15 +1,15 @@ +from .chat_notification_preferences import update_chat_notification_preferences from .chat_notifications import ChatNotificationReadResult, ChatNotificationReadService from .chat_quick_reply import ChatQuickReplyError, ChatQuickReplyService from .chat_read_marker import ChatReadMarkerService -from .chat_notification_preferences import update_chat_notification_preferences from .message_deletion import soft_delete_message __all__ = [ - 'ChatNotificationReadResult', - 'ChatNotificationReadService', - 'ChatQuickReplyError', - 'ChatQuickReplyService', - 'ChatReadMarkerService', - 'update_chat_notification_preferences', - 'soft_delete_message', + "ChatNotificationReadResult", + "ChatNotificationReadService", + "ChatQuickReplyError", + "ChatQuickReplyService", + "ChatReadMarkerService", + "update_chat_notification_preferences", + "soft_delete_message", ] diff --git a/backend/communications/services/chat_notification_preferences.py b/backend/communications/services/chat_notification_preferences.py index dea744b4..2e73b1c0 100644 --- a/backend/communications/services/chat_notification_preferences.py +++ b/backend/communications/services/chat_notification_preferences.py @@ -51,10 +51,7 @@ def update_chat_notification_preferences( settings.notifications_enabled = ( settings.notification_mode == ChatUserSettings.NotificationMode.ALL - and not ( - settings.muted_until - and settings.muted_until > timezone.now() - ) + and not (settings.muted_until and settings.muted_until > timezone.now()) ) update_fields.add("notifications_enabled") settings.save(update_fields=sorted(update_fields)) diff --git a/backend/communications/services/chat_notifications.py b/backend/communications/services/chat_notifications.py index 0c0d542d..ce6f1429 100644 --- a/backend/communications/services/chat_notifications.py +++ b/backend/communications/services/chat_notifications.py @@ -3,12 +3,11 @@ from dataclasses import dataclass from django.db import transaction from django.utils import timezone +from communications.notifications.config import NotificationVerbs from notifications.cache import invalidate_unread_summary from notifications.models import Notification -from notifications.realtime import send_notifications_read_all_event from notifications.push_lifecycle import dismiss_chat_push - -from communications.notifications.config import NotificationVerbs +from notifications.realtime import send_notifications_read_all_event @dataclass(frozen=True) @@ -36,7 +35,7 @@ class ChatNotificationReadService: chat_id: int, ) -> ChatNotificationReadResult: queryset = self._get_unread_queryset(user_id=user.id, chat_id=chat_id) - notification_ids = list(queryset.values_list('id', flat=True)) + notification_ids = list(queryset.values_list("id", flat=True)) if not notification_ids: return ChatNotificationReadResult(notification_ids=[], count=0) diff --git a/backend/communications/services/chat_quick_reply.py b/backend/communications/services/chat_quick_reply.py index c4b4cfe7..f9e52f13 100644 --- a/backend/communications/services/chat_quick_reply.py +++ b/backend/communications/services/chat_quick_reply.py @@ -1,6 +1,6 @@ +import rules from asgiref.sync import async_to_sync from channels.layers import get_channel_layer -import rules from communications.models import Message from communications.serialization import serialize_message diff --git a/backend/communications/services/chat_read_marker.py b/backend/communications/services/chat_read_marker.py index 82629f38..ccc91194 100644 --- a/backend/communications/services/chat_read_marker.py +++ b/backend/communications/services/chat_read_marker.py @@ -6,7 +6,6 @@ from channels.layers import get_channel_layer from communications.models import ChatReadState from communications.serialization import serialize_message_reader - logger = logging.getLogger(__name__) diff --git a/backend/communications/services/message_deletion.py b/backend/communications/services/message_deletion.py index f1ab4a42..f3bf5dc2 100644 --- a/backend/communications/services/message_deletion.py +++ b/backend/communications/services/message_deletion.py @@ -4,8 +4,8 @@ from django.db import transaction from django.db.models import F, Q from django.utils import timezone -from communications.models import ChatReadState, Message from communications.mentions import mentioned_user_ids +from communications.models import ChatReadState, Message from notifications.push_lifecycle import delete_message_notifications diff --git a/backend/communications/signals.py b/backend/communications/signals.py index aaba1c5f..81c5aa64 100644 --- a/backend/communications/signals.py +++ b/backend/communications/signals.py @@ -163,8 +163,8 @@ def increment_unread_count_on_new_message(sender, instance, created, **kwargs): if not created or instance.is_deleted: return - from communications.models import ChatReadState from communications.mentions import mentioned_user_ids + from communications.models import ChatReadState try: # Получаем всех участников чата diff --git a/backend/communications/tasks.py b/backend/communications/tasks.py index b8c79855..29b46c3b 100644 --- a/backend/communications/tasks.py +++ b/backend/communications/tasks.py @@ -2,10 +2,11 @@ Celery задачи для модуля communications """ +import logging +from datetime import timedelta + from celery import shared_task from django.utils import timezone -from datetime import timedelta -import logging logger = logging.getLogger(__name__) diff --git a/backend/documents/__init__.py b/backend/documents/__init__.py index 97c5c8aa..ba235d1e 100644 --- a/backend/documents/__init__.py +++ b/backend/documents/__init__.py @@ -1 +1 @@ -default_app_config = 'documents.apps.DocumentsConfig' +default_app_config = "documents.apps.DocumentsConfig" diff --git a/backend/documents/admin.py b/backend/documents/admin.py index ee880fee..7d4d0e92 100644 --- a/backend/documents/admin.py +++ b/backend/documents/admin.py @@ -12,11 +12,12 @@ Django admin для моделей Document и DocumentAcknowledgement. """ from django.contrib import admin -from django.utils.translation import gettext_lazy as _ -from django.utils.html import format_html, mark_safe -from django.urls import reverse -from .models import Document, DocumentAcknowledgement from django.contrib.auth import get_user_model +from django.urls import reverse +from django.utils.html import format_html, mark_safe +from django.utils.translation import gettext_lazy as _ + +from .models import Document, DocumentAcknowledgement User = get_user_model() @@ -37,9 +38,7 @@ class AcknowledgementInline(admin.TabularInline): if not obj.user: return "—" url = reverse( - "admin:{}_{}_change".format( - User._meta.app_label, User._meta.model_name - ), + "admin:{}_{}_change".format(User._meta.app_label, User._meta.model_name), args=[obj.user.pk], ) return format_html( @@ -166,9 +165,7 @@ class DocumentAdmin(admin.ModelAdmin): icon_url = obj.file.icons.get("48", "") if icon_url: return format_html( - '' - '' - "", + '', obj.file.url, icon_url, ) diff --git a/backend/documents/application/documents.py b/backend/documents/application/documents.py index 8c2c43f7..5be13663 100644 --- a/backend/documents/application/documents.py +++ b/backend/documents/application/documents.py @@ -13,7 +13,6 @@ from documents.models import Document, DocumentTag from documents.notifications.handlers import notify_document_audience from employees.models import Department - logger = logging.getLogger(__name__) User = get_user_model() @@ -43,10 +42,10 @@ def _set_departments(document: Document, department_ids: Sequence[int]) -> None: def _set_recipients(document: Document, recipient_ids: Sequence[int]) -> None: existing_ids = set(document.recipients.values_list("id", flat=True)) - recipients = User.objects.visible_in_ui(include_ids=existing_ids).filter( - id__in=set(recipient_ids) - ).filter( - Q(is_active=True) | Q(id__in=existing_ids) + recipients = ( + User.objects.visible_in_ui(include_ids=existing_ids) + .filter(id__in=set(recipient_ids)) + .filter(Q(is_active=True) | Q(id__in=existing_ids)) ) document.recipients.set(recipients) logger.debug( @@ -73,13 +72,11 @@ def _set_acknowledgement_recipients( document: Document, recipient_ids: Sequence[int], ) -> None: - existing_ids = set( - document.acknowledgement_recipients.values_list("id", flat=True) - ) - recipients = User.objects.visible_in_ui(include_ids=existing_ids).filter( - id__in=set(recipient_ids) - ).filter( - Q(is_active=True) | Q(id__in=existing_ids) + existing_ids = set(document.acknowledgement_recipients.values_list("id", flat=True)) + recipients = ( + User.objects.visible_in_ui(include_ids=existing_ids) + .filter(id__in=set(recipient_ids)) + .filter(Q(is_active=True) | Q(id__in=existing_ids)) ) document.acknowledgement_recipients.set(recipients) diff --git a/backend/documents/audience.py b/backend/documents/audience.py index ba7589bd..28f99fc6 100644 --- a/backend/documents/audience.py +++ b/backend/documents/audience.py @@ -5,7 +5,6 @@ from django.db.models import Q, QuerySet from .models import Document - Employee = get_user_model() diff --git a/backend/documents/conftest.py b/backend/documents/conftest.py index 40f84c06..961b9333 100644 --- a/backend/documents/conftest.py +++ b/backend/documents/conftest.py @@ -6,11 +6,11 @@ Pytest fixtures для тестирования модуля documents import pytest from django.contrib.auth import get_user_model from django.core.files.uploadedfile import SimpleUploadedFile +from filer.models import File as FilerFile +from filer.models import Folder from rest_framework.test import APIClient -from filer.models import File as FilerFile, Folder -from documents.models import ( - Document, DocumentType, DocumentTag, Cabinet -) + +from documents.models import Cabinet, Document, DocumentTag, DocumentType from employees.models import Department User = get_user_model() @@ -26,12 +26,12 @@ def api_client(): def admin_user(db): """Superuser с полными правами""" return User.objects.create_superuser( - phone_number='+79999999990', - email='admin@example.com', - password='admin123', - first_name='Admin', - last_name='User', - is_active=True + phone_number="+79999999990", + email="admin@example.com", + password="admin123", + first_name="Admin", + last_name="User", + is_active=True, ) @@ -39,12 +39,12 @@ def admin_user(db): def author_user(db): """Автор документов""" return User.objects.create_user( - phone_number='+79999999999', - email='author@example.com', - password='author123', - first_name='Автор', - last_name='Документов', - is_active=True + phone_number="+79999999999", + email="author@example.com", + password="author123", + first_name="Автор", + last_name="Документов", + is_active=True, ) @@ -52,12 +52,12 @@ def author_user(db): def viewer_user(db): """Пользователь-получатель документов""" return User.objects.create_user( - phone_number='+79999999998', - email='viewer@example.com', - password='viewer123', - first_name='Просмотрщик', - last_name='Документов', - is_active=True + phone_number="+79999999998", + email="viewer@example.com", + password="viewer123", + first_name="Просмотрщик", + last_name="Документов", + is_active=True, ) @@ -65,12 +65,12 @@ def viewer_user(db): def other_user(db): """Обычный пользователь без доступа""" return User.objects.create_user( - phone_number='+79999999997', - email='other@example.com', - password='other123', - first_name='Другой', - last_name='Пользователь', - is_active=True + phone_number="+79999999997", + email="other@example.com", + password="other123", + first_name="Другой", + last_name="Пользователь", + is_active=True, ) @@ -84,9 +84,7 @@ def department(db): def test_file(): """SimpleUploadedFile для тестирования""" return SimpleUploadedFile( - "test_document.pdf", - b"PDF content here", - content_type="application/pdf" + "test_document.pdf", b"PDF content here", content_type="application/pdf" ) @@ -97,7 +95,7 @@ def filer_file(db, author_user, test_file): file=test_file, original_filename="test_document.pdf", name="Test PDF", - owner=author_user + owner=author_user, ) @@ -110,7 +108,7 @@ def document(db, filer_file, author_user, viewer_user): description="Описание тестового документа", uploaded_by=author_user, sent_to_all=False, - acknowledgement_required=False + acknowledgement_required=False, ) doc.recipients.add(viewer_user) return doc @@ -125,45 +123,35 @@ def document_sent_to_all(db, filer_file, author_user): description="Описание", uploaded_by=author_user, sent_to_all=True, - acknowledgement_required=True + acknowledgement_required=True, ) @pytest.fixture def folder(db, author_user): """Тестовая папка filer""" - return Folder.objects.create( - name="Тестовая папка", - owner=author_user - ) + return Folder.objects.create(name="Тестовая папка", owner=author_user) @pytest.fixture def document_type(db): """Тип документа""" return DocumentType.objects.create( - name="Приказ", - code="ORDER", - description="Приказ руководства", - is_active=True + name="Приказ", code="ORDER", description="Приказ руководства", is_active=True ) @pytest.fixture def document_tag(db): """Тег документа""" - return DocumentTag.objects.create( - name="Важное", - color="#FF0000" - ) + return DocumentTag.objects.create(name="Важное", color="#FF0000") @pytest.fixture def cabinet(db): """Шкаф/кабинет документов""" return Cabinet.objects.create( - name="Кадровые документы", - description="Документы HR отдела" + name="Кадровые документы", description="Документы HR отдела" ) diff --git a/backend/documents/migrations/0001_initial.py b/backend/documents/migrations/0001_initial.py index a6c5c706..ef3594e1 100644 --- a/backend/documents/migrations/0001_initial.py +++ b/backend/documents/migrations/0001_initial.py @@ -6,7 +6,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - initial = True dependencies = [ @@ -15,36 +14,109 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='Document', + name="Document", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('title', models.CharField(max_length=255, verbose_name='Название')), - ('file', models.FileField(upload_to='documents/%Y/%m/%d/', verbose_name='Файл')), - ('description', models.TextField(blank=True, verbose_name='Описание')), - ('uploaded_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата загрузки')), - ('sent_to_all', models.BooleanField(default=False, help_text='Если включено — уведомление получат все активные сотрудники', verbose_name='Разослать всем')), - ('recipients', models.ManyToManyField(blank=True, help_text='Если `Разослать всем` отключено, документ пойдет только этим', related_name='document_recipients', to=settings.AUTH_USER_MODEL, verbose_name='Получатели')), - ('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='uploaded_documents', to=settings.AUTH_USER_MODEL, verbose_name='Загрузил')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=255, verbose_name="Название")), + ( + "file", + models.FileField( + upload_to="documents/%Y/%m/%d/", verbose_name="Файл" + ), + ), + ("description", models.TextField(blank=True, verbose_name="Описание")), + ( + "uploaded_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Дата загрузки" + ), + ), + ( + "sent_to_all", + models.BooleanField( + default=False, + help_text="Если включено — уведомление получат все активные сотрудники", + verbose_name="Разослать всем", + ), + ), + ( + "recipients", + models.ManyToManyField( + blank=True, + help_text="Если `Разослать всем` отключено, документ пойдет только этим", + related_name="document_recipients", + to=settings.AUTH_USER_MODEL, + verbose_name="Получатели", + ), + ), + ( + "uploaded_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="uploaded_documents", + to=settings.AUTH_USER_MODEL, + verbose_name="Загрузил", + ), + ), ], options={ - 'verbose_name': 'Документ', - 'verbose_name_plural': 'Документы', - 'ordering': ['-uploaded_at'], + "verbose_name": "Документ", + "verbose_name_plural": "Документы", + "ordering": ["-uploaded_at"], }, ), migrations.CreateModel( - name='DocumentAcknowledgement', + name="DocumentAcknowledgement", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('acknowledged_at', models.DateTimeField(auto_now_add=True, verbose_name='Время ознакомления')), - ('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='acknowledgements', to='documents.document', verbose_name='Документ')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='document_acknowledgements', to=settings.AUTH_USER_MODEL, verbose_name='Сотрудник')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "acknowledged_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Время ознакомления" + ), + ), + ( + "document", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="acknowledgements", + to="documents.document", + verbose_name="Документ", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="document_acknowledgements", + to=settings.AUTH_USER_MODEL, + verbose_name="Сотрудник", + ), + ), ], options={ - 'verbose_name': 'Ознакомление с документом', - 'verbose_name_plural': 'Ознакомления с документами', - 'ordering': ['-acknowledged_at'], - 'unique_together': {('document', 'user')}, + "verbose_name": "Ознакомление с документом", + "verbose_name_plural": "Ознакомления с документами", + "ordering": ["-acknowledged_at"], + "unique_together": {("document", "user")}, }, ), ] diff --git a/backend/documents/migrations/0002_document_departments.py b/backend/documents/migrations/0002_document_departments.py index e96fa2ea..8f9b6f36 100644 --- a/backend/documents/migrations/0002_document_departments.py +++ b/backend/documents/migrations/0002_document_departments.py @@ -4,16 +4,21 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('documents', '0001_initial'), - ('employees', '0034_alter_employee_telegram'), + ("documents", "0001_initial"), + ("employees", "0034_alter_employee_telegram"), ] operations = [ migrations.AddField( - model_name='document', - name='departments', - field=models.ManyToManyField(blank=True, help_text='Документ будет доступен всем сотрудникам выбранных отделов (включая будущих)', related_name='documents', to='employees.department', verbose_name='Отделы-получатели'), + model_name="document", + name="departments", + field=models.ManyToManyField( + blank=True, + help_text="Документ будет доступен всем сотрудникам выбранных отделов (включая будущих)", + related_name="documents", + to="employees.department", + verbose_name="Отделы-получатели", + ), ), ] diff --git a/backend/documents/migrations/0003_change_sent_to_all_default.py b/backend/documents/migrations/0003_change_sent_to_all_default.py index 095930fe..49be5eb9 100644 --- a/backend/documents/migrations/0003_change_sent_to_all_default.py +++ b/backend/documents/migrations/0003_change_sent_to_all_default.py @@ -4,15 +4,18 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('documents', '0002_document_departments'), + ("documents", "0002_document_departments"), ] operations = [ migrations.AlterField( - model_name='document', - name='sent_to_all', - field=models.BooleanField(default=True, help_text='Если включено — уведомление получат все активные сотрудники', verbose_name='Разослать всем'), + model_name="document", + name="sent_to_all", + field=models.BooleanField( + default=True, + help_text="Если включено — уведомление получат все активные сотрудники", + verbose_name="Разослать всем", + ), ), ] diff --git a/backend/documents/migrations/0004_documentv2_documentacknowledgementv2.py b/backend/documents/migrations/0004_documentv2_documentacknowledgementv2.py index b1a9a22f..40903250 100644 --- a/backend/documents/migrations/0004_documentv2_documentacknowledgementv2.py +++ b/backend/documents/migrations/0004_documentv2_documentacknowledgementv2.py @@ -7,49 +7,136 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('documents', '0003_change_sent_to_all_default'), - ('employees', '0041_alter_departmentpermission_code'), - ('filer', '0018_alter_file_options'), + ("documents", "0003_change_sent_to_all_default"), + ("employees", "0041_alter_departmentpermission_code"), + ("filer", "0018_alter_file_options"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='DocumentV2', + name="DocumentV2", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('title', models.CharField(max_length=255, verbose_name='Название')), - ('description', models.TextField(blank=True, verbose_name='Описание')), - ('uploaded_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата загрузки')), - ('sent_to_all', models.BooleanField(default=True, help_text='Если включено — уведомление получат все активные сотрудники', verbose_name='Разослать всем')), - ('departments', models.ManyToManyField(blank=True, help_text='Документ будет доступен всем сотрудникам выбранных отделов (включая будущих)', related_name='documents_v2', to='employees.department', verbose_name='Отделы-получатели')), - ('file', filer.fields.file.FilerFileField(blank=True, help_text='Файл, загруженный через django-filer', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='documents', to='filer.file', verbose_name='Файл')), - ('recipients', models.ManyToManyField(blank=True, help_text='Если `Разослать всем` отключено, документ пойдет только этим', related_name='document_recipients_v2', to=settings.AUTH_USER_MODEL, verbose_name='Получатели')), - ('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='uploaded_documents_v2', to=settings.AUTH_USER_MODEL, verbose_name='Загрузил')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=255, verbose_name="Название")), + ("description", models.TextField(blank=True, verbose_name="Описание")), + ( + "uploaded_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Дата загрузки" + ), + ), + ( + "sent_to_all", + models.BooleanField( + default=True, + help_text="Если включено — уведомление получат все активные сотрудники", + verbose_name="Разослать всем", + ), + ), + ( + "departments", + models.ManyToManyField( + blank=True, + help_text="Документ будет доступен всем сотрудникам выбранных отделов (включая будущих)", + related_name="documents_v2", + to="employees.department", + verbose_name="Отделы-получатели", + ), + ), + ( + "file", + filer.fields.file.FilerFileField( + blank=True, + help_text="Файл, загруженный через django-filer", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="documents", + to="filer.file", + verbose_name="Файл", + ), + ), + ( + "recipients", + models.ManyToManyField( + blank=True, + help_text="Если `Разослать всем` отключено, документ пойдет только этим", + related_name="document_recipients_v2", + to=settings.AUTH_USER_MODEL, + verbose_name="Получатели", + ), + ), + ( + "uploaded_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="uploaded_documents_v2", + to=settings.AUTH_USER_MODEL, + verbose_name="Загрузил", + ), + ), ], options={ - 'verbose_name': 'Документ v2', - 'verbose_name_plural': 'Документы v2', - 'db_table': 'documents_documentv2', - 'ordering': ['-uploaded_at'], + "verbose_name": "Документ v2", + "verbose_name_plural": "Документы v2", + "db_table": "documents_documentv2", + "ordering": ["-uploaded_at"], }, ), migrations.CreateModel( - name='DocumentAcknowledgementV2', + name="DocumentAcknowledgementV2", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('acknowledged_at', models.DateTimeField(auto_now_add=True, verbose_name='Время ознакомления')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='document_acknowledgements_v2', to=settings.AUTH_USER_MODEL, verbose_name='Сотрудник')), - ('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='acknowledgements', to='documents.documentv2', verbose_name='Документ')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "acknowledged_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Время ознакомления" + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="document_acknowledgements_v2", + to=settings.AUTH_USER_MODEL, + verbose_name="Сотрудник", + ), + ), + ( + "document", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="acknowledgements", + to="documents.documentv2", + verbose_name="Документ", + ), + ), ], options={ - 'verbose_name': 'Ознакомление с документом v2', - 'verbose_name_plural': 'Ознакомления с документами v2', - 'db_table': 'documents_documentacknowledgementv2', - 'ordering': ['-acknowledged_at'], - 'unique_together': {('document', 'user')}, + "verbose_name": "Ознакомление с документом v2", + "verbose_name_plural": "Ознакомления с документами v2", + "db_table": "documents_documentacknowledgementv2", + "ordering": ["-acknowledged_at"], + "unique_together": {("document", "user")}, }, ), ] diff --git a/backend/documents/migrations/0005_migrate_to_filer.py b/backend/documents/migrations/0005_migrate_to_filer.py index e3c20bb3..aafee72e 100644 --- a/backend/documents/migrations/0005_migrate_to_filer.py +++ b/backend/documents/migrations/0005_migrate_to_filer.py @@ -1,15 +1,16 @@ # Generated manually for filer migration import os -from django.db import migrations + import django.db.models.deletion import filer.fields.file from django.core.files import File as DjangoFile +from django.db import migrations def migrate_files_to_filer(apps, schema_editor): """Мигрирует существующие файлы из FileField в django-filer""" - Document = apps.get_model('documents', 'Document') - FilerFile = apps.get_model('filer', 'File') + Document = apps.get_model("documents", "Document") + FilerFile = apps.get_model("filer", "File") from django.conf import settings for doc in Document.objects.all(): @@ -17,12 +18,14 @@ def migrate_files_to_filer(apps, schema_editor): if doc.file: # Если есть значение в поле file (строка пути) try: # Получаем путь к файлу относительно MEDIA_ROOT - file_relative_path = str(doc.file) # например "documents/2025/12/03/file.pdf" + file_relative_path = str( + doc.file + ) # например "documents/2025/12/03/file.pdf" file_full_path = os.path.join(settings.MEDIA_ROOT, file_relative_path) if os.path.exists(file_full_path): # Создаем File в filer из существующего файла - with open(file_full_path, 'rb') as f: + with open(file_full_path, "rb") as f: filename = os.path.basename(file_relative_path) filer_file = FilerFile.objects.create( file=DjangoFile(f, name=filename), @@ -32,15 +35,17 @@ def migrate_files_to_filer(apps, schema_editor): # Присваиваем filer_file документу через временное поле doc.filer_file_id = filer_file.id - doc.save(update_fields=['filer_file']) + doc.save(update_fields=["filer_file"]) print(f"Migrated document {doc.id}: {filename}") else: - print(f"Warning: File not found for document {doc.id}: {file_full_path}") + print( + f"Warning: File not found for document {doc.id}: {file_full_path}" + ) except Exception as e: print(f"Error migrating document {doc.id}: {e}") import traceback - traceback.print_exc() + traceback.print_exc() def reverse_migration(apps, schema_editor): @@ -49,67 +54,62 @@ def reverse_migration(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ - ('documents', '0004_documentv2_documentacknowledgementv2'), - ('filer', '0018_alter_file_options'), - ('easy_thumbnails', '0001_initial'), # Нужно для создания таблиц thumbnails + ("documents", "0004_documentv2_documentacknowledgementv2"), + ("filer", "0018_alter_file_options"), + ("easy_thumbnails", "0001_initial"), # Нужно для создания таблиц thumbnails ] operations = [ # Шаг 1: Добавить временное поле для filer migrations.AddField( - model_name='document', - name='filer_file', + model_name="document", + name="filer_file", field=filer.fields.file.FilerFileField( blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, - related_name='documents_temp', - to='filer.file', - verbose_name='Файл (Filer)', - help_text='Временное поле для миграции' + related_name="documents_temp", + to="filer.file", + verbose_name="Файл (Filer)", + help_text="Временное поле для миграции", ), ), - # Шаг 2: Мигрировать данные из file в filer_file # ВРЕМЕННО ОТКЛЮЧЕНО: вызывает pending trigger events # migrations.RunPython(migrate_files_to_filer, reverse_migration), - # Шаг 3: Удалить старое поле file migrations.RemoveField( - model_name='document', - name='file', + model_name="document", + name="file", ), - # Шаг 4: Переименовать filer_file в file migrations.RenameField( - model_name='document', - old_name='filer_file', - new_name='file', + model_name="document", + old_name="filer_file", + new_name="file", ), - # Шаг 5: Удалить V2 модели migrations.RemoveField( - model_name='documentv2', - name='departments', + model_name="documentv2", + name="departments", ), migrations.RemoveField( - model_name='documentv2', - name='file', + model_name="documentv2", + name="file", ), migrations.RemoveField( - model_name='documentv2', - name='recipients', + model_name="documentv2", + name="recipients", ), migrations.RemoveField( - model_name='documentv2', - name='uploaded_by', + model_name="documentv2", + name="uploaded_by", ), migrations.DeleteModel( - name='DocumentAcknowledgementV2', + name="DocumentAcknowledgementV2", ), migrations.DeleteModel( - name='DocumentV2', + name="DocumentV2", ), ] diff --git a/backend/documents/migrations/0006_add_fsm_status.py b/backend/documents/migrations/0006_add_fsm_status.py index cde490f6..0be6a766 100644 --- a/backend/documents/migrations/0006_add_fsm_status.py +++ b/backend/documents/migrations/0006_add_fsm_status.py @@ -7,21 +7,42 @@ from django.db import migrations class Migration(migrations.Migration): - dependencies = [ - ('documents', '0005_migrate_to_filer'), - ('filer', '0018_alter_file_options'), + ("documents", "0005_migrate_to_filer"), + ("filer", "0018_alter_file_options"), ] operations = [ migrations.AddField( - model_name='document', - name='status', - field=django_fsm.FSMField(choices=[('draft', 'Черновик'), ('in_review', 'На рассмотрении'), ('approved', 'Одобрен'), ('published', 'Опубликован'), ('archived', 'Архивирован'), ('rejected', 'Отклонен')], default='draft', help_text='Текущее состояние документа в workflow', max_length=50, protected=True, verbose_name='Статус'), + model_name="document", + name="status", + field=django_fsm.FSMField( + choices=[ + ("draft", "Черновик"), + ("in_review", "На рассмотрении"), + ("approved", "Одобрен"), + ("published", "Опубликован"), + ("archived", "Архивирован"), + ("rejected", "Отклонен"), + ], + default="draft", + help_text="Текущее состояние документа в workflow", + max_length=50, + protected=True, + verbose_name="Статус", + ), ), migrations.AlterField( - model_name='document', - name='file', - field=filer.fields.file.FilerFileField(blank=True, help_text='Файл, загруженный через django-filer', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='documents', to='filer.file', verbose_name='Файл'), + model_name="document", + name="file", + field=filer.fields.file.FilerFileField( + blank=True, + help_text="Файл, загруженный через django-filer", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="documents", + to="filer.file", + verbose_name="Файл", + ), ), ] diff --git a/backend/documents/migrations/0007_add_folder_support.py b/backend/documents/migrations/0007_add_folder_support.py index 7cf2198c..436f13fe 100644 --- a/backend/documents/migrations/0007_add_folder_support.py +++ b/backend/documents/migrations/0007_add_folder_support.py @@ -5,16 +5,23 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('documents', '0006_add_fsm_status'), - ('filer', '0018_alter_file_options'), + ("documents", "0006_add_fsm_status"), + ("filer", "0018_alter_file_options"), ] operations = [ migrations.AddField( - model_name='document', - name='folder', - field=models.ForeignKey(blank=True, help_text='Папка для организации документов в иерархической структуре', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='documents', to='filer.folder', verbose_name='Папка'), + model_name="document", + name="folder", + field=models.ForeignKey( + blank=True, + help_text="Папка для организации документов в иерархической структуре", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="documents", + to="filer.folder", + verbose_name="Папка", + ), ), ] diff --git a/backend/documents/migrations/0008_add_extended_models.py b/backend/documents/migrations/0008_add_extended_models.py index 36920997..18a50f38 100644 --- a/backend/documents/migrations/0008_add_extended_models.py +++ b/backend/documents/migrations/0008_add_extended_models.py @@ -6,123 +6,363 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('documents', '0007_add_folder_support'), + ("documents", "0007_add_folder_support"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='DocumentType', + name="DocumentType", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=100, verbose_name='Название')), - ('code', models.SlugField(unique=True, verbose_name='Код')), - ('description', models.TextField(blank=True, verbose_name='Описание')), - ('metadata_schema', models.JSONField(blank=True, help_text='JSON Schema для валидации метаданных документов этого типа', null=True, verbose_name='Схема метаданных')), - ('icon', models.CharField(default='file-earmark-text', help_text='Bootstrap Icons класс', max_length=50, verbose_name='Иконка')), - ('color', models.CharField(default='#0d6efd', help_text='HEX цвет для UI', max_length=7, verbose_name='Цвет')), - ('is_active', models.BooleanField(default=True, verbose_name='Активен')), - ('order', models.IntegerField(default=0, verbose_name='Порядок')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100, verbose_name="Название")), + ("code", models.SlugField(unique=True, verbose_name="Код")), + ("description", models.TextField(blank=True, verbose_name="Описание")), + ( + "metadata_schema", + models.JSONField( + blank=True, + help_text="JSON Schema для валидации метаданных документов этого типа", + null=True, + verbose_name="Схема метаданных", + ), + ), + ( + "icon", + models.CharField( + default="file-earmark-text", + help_text="Bootstrap Icons класс", + max_length=50, + verbose_name="Иконка", + ), + ), + ( + "color", + models.CharField( + default="#0d6efd", + help_text="HEX цвет для UI", + max_length=7, + verbose_name="Цвет", + ), + ), + ( + "is_active", + models.BooleanField(default=True, verbose_name="Активен"), + ), + ("order", models.IntegerField(default=0, verbose_name="Порядок")), ], options={ - 'verbose_name': 'Тип документа', - 'verbose_name_plural': 'Типы документов', - 'ordering': ['order', 'name'], + "verbose_name": "Тип документа", + "verbose_name_plural": "Типы документов", + "ordering": ["order", "name"], }, ), migrations.AddField( - model_name='document', - name='extracted_text', - field=models.TextField(blank=True, editable=False, help_text='Текст извлечен из файла для полнотекстового поиска', verbose_name='Извлеченный текст'), + model_name="document", + name="extracted_text", + field=models.TextField( + blank=True, + editable=False, + help_text="Текст извлечен из файла для полнотекстового поиска", + verbose_name="Извлеченный текст", + ), ), migrations.AddField( - model_name='document', - name='modified_at', - field=models.DateTimeField(auto_now=True, verbose_name='Дата изменения'), + model_name="document", + name="modified_at", + field=models.DateTimeField(auto_now=True, verbose_name="Дата изменения"), ), migrations.AddField( - model_name='document', - name='modified_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='modified_documents', to=settings.AUTH_USER_MODEL, verbose_name='Последнее изменение'), + model_name="document", + name="modified_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="modified_documents", + to=settings.AUTH_USER_MODEL, + verbose_name="Последнее изменение", + ), ), migrations.CreateModel( - name='Cabinet', + name="Cabinet", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=200, verbose_name='Название')), - ('slug', models.SlugField(unique=True, verbose_name='Слаг')), - ('description', models.TextField(blank=True, verbose_name='Описание')), - ('icon', models.CharField(default='folder', help_text='Bootstrap Icons класс', max_length=50, verbose_name='Иконка')), - ('color', models.CharField(default='#6c757d', help_text='HEX цвет для UI', max_length=7, verbose_name='Цвет')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создан')), - ('order', models.IntegerField(default=0, verbose_name='Порядок')), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_cabinets', to=settings.AUTH_USER_MODEL, verbose_name='Создал')), - ('documents', models.ManyToManyField(blank=True, related_name='cabinets', to='documents.document', verbose_name='Документы')), - ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='documents.cabinet', verbose_name='Родительский кабинет')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=200, verbose_name="Название")), + ("slug", models.SlugField(unique=True, verbose_name="Слаг")), + ("description", models.TextField(blank=True, verbose_name="Описание")), + ( + "icon", + models.CharField( + default="folder", + help_text="Bootstrap Icons класс", + max_length=50, + verbose_name="Иконка", + ), + ), + ( + "color", + models.CharField( + default="#6c757d", + help_text="HEX цвет для UI", + max_length=7, + verbose_name="Цвет", + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Создан"), + ), + ("order", models.IntegerField(default=0, verbose_name="Порядок")), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="created_cabinets", + to=settings.AUTH_USER_MODEL, + verbose_name="Создал", + ), + ), + ( + "documents", + models.ManyToManyField( + blank=True, + related_name="cabinets", + to="documents.document", + verbose_name="Документы", + ), + ), + ( + "parent", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="children", + to="documents.cabinet", + verbose_name="Родительский кабинет", + ), + ), ], options={ - 'verbose_name': 'Кабинет', - 'verbose_name_plural': 'Кабинеты', - 'ordering': ['order', 'name'], + "verbose_name": "Кабинет", + "verbose_name_plural": "Кабинеты", + "ordering": ["order", "name"], }, ), migrations.CreateModel( - name='DocumentTag', + name="DocumentTag", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=100, unique=True, verbose_name='Название')), - ('slug', models.SlugField(unique=True, verbose_name='Слаг')), - ('color', models.CharField(default='#0d6efd', help_text='HEX цвет для UI', max_length=7, verbose_name='Цвет')), - ('description', models.TextField(blank=True, verbose_name='Описание')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создан')), - ('documents', models.ManyToManyField(blank=True, related_name='tags', to='documents.document', verbose_name='Документы')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "name", + models.CharField( + max_length=100, unique=True, verbose_name="Название" + ), + ), + ("slug", models.SlugField(unique=True, verbose_name="Слаг")), + ( + "color", + models.CharField( + default="#0d6efd", + help_text="HEX цвет для UI", + max_length=7, + verbose_name="Цвет", + ), + ), + ("description", models.TextField(blank=True, verbose_name="Описание")), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Создан"), + ), + ( + "documents", + models.ManyToManyField( + blank=True, + related_name="tags", + to="documents.document", + verbose_name="Документы", + ), + ), ], options={ - 'verbose_name': 'Тег', - 'verbose_name_plural': 'Теги', - 'ordering': ['name'], + "verbose_name": "Тег", + "verbose_name_plural": "Теги", + "ordering": ["name"], }, ), migrations.AddField( - model_name='document', - name='document_type', - field=models.ForeignKey(blank=True, help_text='Тип документа определяет шаблон метаданных и workflow', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='documents', to='documents.documenttype', verbose_name='Тип документа'), + model_name="document", + name="document_type", + field=models.ForeignKey( + blank=True, + help_text="Тип документа определяет шаблон метаданных и workflow", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="documents", + to="documents.documenttype", + verbose_name="Тип документа", + ), ), migrations.CreateModel( - name='DocumentAuditLog', + name="DocumentAuditLog", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('action', models.CharField(choices=[('created', 'Создан'), ('viewed', 'Просмотрен'), ('downloaded', 'Скачан'), ('edited', 'Изменен'), ('moved', 'Перемещен'), ('deleted', 'Удален'), ('status_changed', 'Изменен статус'), ('permissions_changed', 'Изменены права'), ('signed', 'Подписан')], max_length=50, verbose_name='Действие')), - ('timestamp', models.DateTimeField(auto_now_add=True, verbose_name='Время')), - ('ip_address', models.GenericIPAddressField(blank=True, null=True, verbose_name='IP адрес')), - ('user_agent', models.TextField(blank=True, verbose_name='User Agent')), - ('metadata', models.JSONField(blank=True, help_text='Дополнительная информация о действии', null=True, verbose_name='Метаданные')), - ('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='audit_log', to='documents.document', verbose_name='Документ')), - ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "action", + models.CharField( + choices=[ + ("created", "Создан"), + ("viewed", "Просмотрен"), + ("downloaded", "Скачан"), + ("edited", "Изменен"), + ("moved", "Перемещен"), + ("deleted", "Удален"), + ("status_changed", "Изменен статус"), + ("permissions_changed", "Изменены права"), + ("signed", "Подписан"), + ], + max_length=50, + verbose_name="Действие", + ), + ), + ( + "timestamp", + models.DateTimeField(auto_now_add=True, verbose_name="Время"), + ), + ( + "ip_address", + models.GenericIPAddressField( + blank=True, null=True, verbose_name="IP адрес" + ), + ), + ("user_agent", models.TextField(blank=True, verbose_name="User Agent")), + ( + "metadata", + models.JSONField( + blank=True, + help_text="Дополнительная информация о действии", + null=True, + verbose_name="Метаданные", + ), + ), + ( + "document", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="audit_log", + to="documents.document", + verbose_name="Документ", + ), + ), + ( + "user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + verbose_name="Пользователь", + ), + ), ], options={ - 'verbose_name': 'Запись аудита', - 'verbose_name_plural': 'Записи аудита', - 'ordering': ['-timestamp'], - 'indexes': [models.Index(fields=['document', '-timestamp'], name='documents_d_documen_5c0383_idx'), models.Index(fields=['user', '-timestamp'], name='documents_d_user_id_146cf5_idx'), models.Index(fields=['action', '-timestamp'], name='documents_d_action_c6b54b_idx')], + "verbose_name": "Запись аудита", + "verbose_name_plural": "Записи аудита", + "ordering": ["-timestamp"], + "indexes": [ + models.Index( + fields=["document", "-timestamp"], + name="documents_d_documen_5c0383_idx", + ), + models.Index( + fields=["user", "-timestamp"], + name="documents_d_user_id_146cf5_idx", + ), + models.Index( + fields=["action", "-timestamp"], + name="documents_d_action_c6b54b_idx", + ), + ], }, ), migrations.CreateModel( - name='DocumentMetadata', + name="DocumentMetadata", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('key', models.CharField(max_length=100, verbose_name='Ключ')), - ('value', models.TextField(verbose_name='Значение')), - ('value_type', models.CharField(choices=[('text', 'Текст'), ('number', 'Число'), ('date', 'Дата'), ('boolean', 'Да/Нет'), ('url', 'Ссылка')], default='text', max_length=20, verbose_name='Тип значения')), - ('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='metadata_entries', to='documents.document', verbose_name='Документ')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("key", models.CharField(max_length=100, verbose_name="Ключ")), + ("value", models.TextField(verbose_name="Значение")), + ( + "value_type", + models.CharField( + choices=[ + ("text", "Текст"), + ("number", "Число"), + ("date", "Дата"), + ("boolean", "Да/Нет"), + ("url", "Ссылка"), + ], + default="text", + max_length=20, + verbose_name="Тип значения", + ), + ), + ( + "document", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="metadata_entries", + to="documents.document", + verbose_name="Документ", + ), + ), ], options={ - 'verbose_name': 'Метаданные документа', - 'verbose_name_plural': 'Метаданные документов', - 'ordering': ['key'], - 'unique_together': {('document', 'key')}, + "verbose_name": "Метаданные документа", + "verbose_name_plural": "Метаданные документов", + "ordering": ["key"], + "unique_together": {("document", "key")}, }, ), ] diff --git a/backend/documents/migrations/0009_document_acknowledgement_required.py b/backend/documents/migrations/0009_document_acknowledgement_required.py index 39859eb4..d7ec9d79 100644 --- a/backend/documents/migrations/0009_document_acknowledgement_required.py +++ b/backend/documents/migrations/0009_document_acknowledgement_required.py @@ -4,15 +4,18 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('documents', '0008_add_extended_models'), + ("documents", "0008_add_extended_models"), ] operations = [ migrations.AddField( - model_name='document', - name='acknowledgement_required', - field=models.BooleanField(default=False, help_text='Если включено — сотрудники должны подтвердить ознакомление', verbose_name='Требуется ознакомление'), + model_name="document", + name="acknowledgement_required", + field=models.BooleanField( + default=False, + help_text="Если включено — сотрудники должны подтвердить ознакомление", + verbose_name="Требуется ознакомление", + ), ), ] diff --git a/backend/documents/migrations/0010_document_related_documents_documentcomment.py b/backend/documents/migrations/0010_document_related_documents_documentcomment.py index d6c3524f..5ff2fb33 100644 --- a/backend/documents/migrations/0010_document_related_documents_documentcomment.py +++ b/backend/documents/migrations/0010_document_related_documents_documentcomment.py @@ -6,35 +6,91 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('documents', '0009_document_acknowledgement_required'), + ("documents", "0009_document_acknowledgement_required"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.AddField( - model_name='document', - name='related_documents', - field=models.ManyToManyField(blank=True, help_text='Документы, связанные с этим', to='documents.document', verbose_name='Связанные документы'), + model_name="document", + name="related_documents", + field=models.ManyToManyField( + blank=True, + help_text="Документы, связанные с этим", + to="documents.document", + verbose_name="Связанные документы", + ), ), migrations.CreateModel( - name='DocumentComment', + name="DocumentComment", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('text', models.TextField(verbose_name='Текст комментария')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создан')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлен')), - ('is_edited', models.BooleanField(default=False, verbose_name='Отредактирован')), - ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='document_comments', to=settings.AUTH_USER_MODEL, verbose_name='Автор')), - ('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='documents.document', verbose_name='Документ')), - ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='documents.documentcomment', verbose_name='Родительский комментарий')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("text", models.TextField(verbose_name="Текст комментария")), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Создан"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="Обновлен"), + ), + ( + "is_edited", + models.BooleanField(default=False, verbose_name="Отредактирован"), + ), + ( + "author", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="document_comments", + to=settings.AUTH_USER_MODEL, + verbose_name="Автор", + ), + ), + ( + "document", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="comments", + to="documents.document", + verbose_name="Документ", + ), + ), + ( + "parent", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="replies", + to="documents.documentcomment", + verbose_name="Родительский комментарий", + ), + ), ], options={ - 'verbose_name': 'Комментарий к документу', - 'verbose_name_plural': 'Комментарии к документам', - 'ordering': ['created_at'], - 'indexes': [models.Index(fields=['document', 'created_at'], name='documents_d_documen_ab0b16_idx'), models.Index(fields=['author', '-created_at'], name='documents_d_author__e98132_idx')], + "verbose_name": "Комментарий к документу", + "verbose_name_plural": "Комментарии к документам", + "ordering": ["created_at"], + "indexes": [ + models.Index( + fields=["document", "created_at"], + name="documents_d_documen_ab0b16_idx", + ), + models.Index( + fields=["author", "-created_at"], + name="documents_d_author__e98132_idx", + ), + ], }, ), ] diff --git a/backend/documents/migrations/0011_remove_fsm_add_is_published.py b/backend/documents/migrations/0011_remove_fsm_add_is_published.py index e1dd03ec..e0fe626c 100644 --- a/backend/documents/migrations/0011_remove_fsm_add_is_published.py +++ b/backend/documents/migrations/0011_remove_fsm_add_is_published.py @@ -11,14 +11,16 @@ def migrate_status_to_is_published(apps, schema_editor): - 'published' → is_published=True, published_at=uploaded_at - все остальные → is_published=False """ - Document = apps.get_model('documents', 'Document') + Document = apps.get_model("documents", "Document") # Опубликованные документы - published_docs = Document.objects.filter(status='published') + published_docs = Document.objects.filter(status="published") for doc in published_docs: doc.is_published = True - doc.published_at = doc.uploaded_at # Используем дату загрузки как дату публикации - doc.save(update_fields=['is_published', 'published_at']) + doc.published_at = ( + doc.uploaded_at + ) # Используем дату загрузки как дату публикации + doc.save(update_fields=["is_published", "published_at"]) print(f"✅ Migrated {published_docs.count()} published documents") @@ -26,28 +28,38 @@ def migrate_status_to_is_published(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ - ('documents', '0010_document_related_documents_documentcomment'), + ("documents", "0010_document_related_documents_documentcomment"), ] operations = [ # Сначала добавляем новые поля migrations.AddField( - model_name='document', - name='is_published', - field=models.BooleanField(default=False, help_text='Черновик (False) или Опубликован (True)', verbose_name='Опубликован'), + model_name="document", + name="is_published", + field=models.BooleanField( + default=False, + help_text="Черновик (False) или Опубликован (True)", + verbose_name="Опубликован", + ), ), migrations.AddField( - model_name='document', - name='published_at', - field=models.DateTimeField(blank=True, help_text='Когда документ был опубликован', null=True, verbose_name='Дата публикации'), + model_name="document", + name="published_at", + field=models.DateTimeField( + blank=True, + help_text="Когда документ был опубликован", + null=True, + verbose_name="Дата публикации", + ), ), # Затем мигрируем данные - migrations.RunPython(migrate_status_to_is_published, reverse_code=migrations.RunPython.noop), + migrations.RunPython( + migrate_status_to_is_published, reverse_code=migrations.RunPython.noop + ), # Затем удаляем старое поле migrations.RemoveField( - model_name='document', - name='status', + model_name="document", + name="status", ), ] diff --git a/backend/documents/migrations/0012_remove_is_published.py b/backend/documents/migrations/0012_remove_is_published.py index 6daafaa7..43249a0a 100644 --- a/backend/documents/migrations/0012_remove_is_published.py +++ b/backend/documents/migrations/0012_remove_is_published.py @@ -4,18 +4,17 @@ from django.db import migrations class Migration(migrations.Migration): - dependencies = [ - ('documents', '0011_remove_fsm_add_is_published'), + ("documents", "0011_remove_fsm_add_is_published"), ] operations = [ migrations.RemoveField( - model_name='document', - name='is_published', + model_name="document", + name="is_published", ), migrations.RemoveField( - model_name='document', - name='published_at', + model_name="document", + name="published_at", ), ] diff --git a/backend/documents/migrations/0013_remove_document_document_type.py b/backend/documents/migrations/0013_remove_document_document_type.py index 11baade7..4b3b0752 100644 --- a/backend/documents/migrations/0013_remove_document_document_type.py +++ b/backend/documents/migrations/0013_remove_document_document_type.py @@ -4,14 +4,13 @@ from django.db import migrations class Migration(migrations.Migration): - dependencies = [ - ('documents', '0012_remove_is_published'), + ("documents", "0012_remove_is_published"), ] operations = [ migrations.RemoveField( - model_name='document', - name='document_type', + model_name="document", + name="document_type", ), ] diff --git a/backend/documents/migrations/0014_migrate_comments_to_communications.py b/backend/documents/migrations/0014_migrate_comments_to_communications.py index ecf0d7d9..bcaef157 100644 --- a/backend/documents/migrations/0014_migrate_comments_to_communications.py +++ b/backend/documents/migrations/0014_migrate_comments_to_communications.py @@ -7,116 +7,130 @@ from django.utils import timezone def migrate_comments_forward(apps, schema_editor): """Миграция documents.DocumentComment → Communications.Message (ORM с threading)""" - Chat = apps.get_model('communications', 'Chat') - Message = apps.get_model('communications', 'Message') - DocumentComment = apps.get_model('documents', 'DocumentComment') - Document = apps.get_model('documents', 'Document') - ContentType = apps.get_model('contenttypes', 'ContentType') + Chat = apps.get_model("communications", "Chat") + Message = apps.get_model("communications", "Message") + DocumentComment = apps.get_model("documents", "DocumentComment") + Document = apps.get_model("documents", "Document") + ContentType = apps.get_model("contenttypes", "ContentType") try: - doc_ct = ContentType.objects.get(app_label='documents', model='document') + doc_ct = ContentType.objects.get(app_label="documents", model="document") except ContentType.DoesNotExist: return # 1. Создаем чаты для документов с комментариями - doc_ids_with_comments = DocumentComment.objects.values_list('document_id', flat=True).distinct() + doc_ids_with_comments = DocumentComment.objects.values_list( + "document_id", flat=True + ).distinct() for doc_id in doc_ids_with_comments: if Chat.objects.filter( - type='comments', - context_content_type=doc_ct, - context_object_id=doc_id + type="comments", context_content_type=doc_ct, context_object_id=doc_id ).exists(): continue try: doc = Document.objects.get(id=doc_id) - chat_name = f"Комментарии: {doc.title[:50]}" if hasattr(doc, 'title') and doc.title else "Комментарии" + chat_name = ( + f"Комментарии: {doc.title[:50]}" + if hasattr(doc, "title") and doc.title + else "Комментарии" + ) except Document.DoesNotExist: chat_name = "Комментарии" Chat.objects.create( - type='comments', + type="comments", name=chat_name, - description='', + description="", avatar=None, created_by=None, can_reply=True, - flags={'allow_replies': True, 'allow_reactions': True, 'allow_attachments': True, 'allow_editing': True}, + flags={ + "allow_replies": True, + "allow_reactions": True, + "allow_attachments": True, + "allow_editing": True, + }, context_content_type=doc_ct, context_object_id=doc_id, created_at=timezone.now(), is_main=False, is_blocked=False, include_all_users=False, - extra_data={} + extra_data={}, ) # 2. Переносим КОРНЕВЫЕ комментарии (parent IS NULL) - root_comments = DocumentComment.objects.filter(parent__isnull=True).select_related('author', 'document') + root_comments = DocumentComment.objects.filter(parent__isnull=True).select_related( + "author", "document" + ) for comment in root_comments: try: chat = Chat.objects.get( - type='comments', + type="comments", context_content_type=doc_ct, - context_object_id=comment.document_id + context_object_id=comment.document_id, ) except Chat.DoesNotExist: continue if Message.objects.filter( - chat=chat, - author_id=comment.author_id, - created_at=comment.created_at + chat=chat, author_id=comment.author_id, created_at=comment.created_at ).exists(): continue Message.objects.create( chat=chat, author_id=comment.author_id, - content=comment.text or '', + content=comment.text or "", created_at=comment.created_at, is_system=False, has_attachments=False, is_deleted=False, - is_edited=comment.is_edited if hasattr(comment, 'is_edited') else False, - edited_at=comment.updated_at if (hasattr(comment, 'is_edited') and comment.is_edited and hasattr(comment, 'updated_at')) else None, + is_edited=comment.is_edited if hasattr(comment, "is_edited") else False, + edited_at=comment.updated_at + if ( + hasattr(comment, "is_edited") + and comment.is_edited + and hasattr(comment, "updated_at") + ) + else None, is_pinned=False, is_forwarded=False, is_cross_chat=False, - system_type='', + system_type="", system_metadata={ - 'legacy_comment_id': comment.id, - 'legacy_model': 'documents.DocumentComment' - } + "legacy_comment_id": comment.id, + "legacy_model": "documents.DocumentComment", + }, ) # 3. Переносим ОТВЕТЫ (parent IS NOT NULL) - с установкой reply_to и thread_root - reply_comments = DocumentComment.objects.filter(parent__isnull=False).select_related('author', 'document', 'parent') + reply_comments = DocumentComment.objects.filter( + parent__isnull=False + ).select_related("author", "document", "parent") for comment in reply_comments: try: chat = Chat.objects.get( - type='comments', + type="comments", context_content_type=doc_ct, - context_object_id=comment.document_id + context_object_id=comment.document_id, ) except Chat.DoesNotExist: continue if Message.objects.filter( - chat=chat, - author_id=comment.author_id, - created_at=comment.created_at + chat=chat, author_id=comment.author_id, created_at=comment.created_at ).exists(): continue # Находим родительское сообщение try: parent_msg = Message.objects.get( - chat=chat, - system_metadata__legacy_comment_id=comment.parent_id + chat=chat, system_metadata__legacy_comment_id=comment.parent_id ) except Message.DoesNotExist: continue @@ -124,52 +138,54 @@ def migrate_comments_forward(apps, schema_editor): Message.objects.create( chat=chat, author_id=comment.author_id, - content=comment.text or '', + content=comment.text or "", created_at=comment.created_at, is_system=False, has_attachments=False, is_deleted=False, - is_edited=comment.is_edited if hasattr(comment, 'is_edited') else False, - edited_at=comment.updated_at if (hasattr(comment, 'is_edited') and comment.is_edited and hasattr(comment, 'updated_at')) else None, + is_edited=comment.is_edited if hasattr(comment, "is_edited") else False, + edited_at=comment.updated_at + if ( + hasattr(comment, "is_edited") + and comment.is_edited + and hasattr(comment, "updated_at") + ) + else None, is_pinned=False, is_forwarded=False, is_cross_chat=False, reply_to=parent_msg, thread_root=parent_msg, - system_type='', + system_type="", system_metadata={ - 'legacy_comment_id': comment.id, - 'legacy_model': 'documents.DocumentComment' - } + "legacy_comment_id": comment.id, + "legacy_model": "documents.DocumentComment", + }, ) def migrate_comments_backward(apps, schema_editor): """Откат - удаляем чаты комментариев для документов""" - Chat = apps.get_model('communications', 'Chat') - ContentType = apps.get_model('contenttypes', 'ContentType') + Chat = apps.get_model("communications", "Chat") + ContentType = apps.get_model("contenttypes", "ContentType") try: - doc_ct = ContentType.objects.get(app_label='documents', model='document') - Chat.objects.filter( - type='comments', - context_content_type=doc_ct - ).delete() + doc_ct = ContentType.objects.get(app_label="documents", model="document") + Chat.objects.filter(type="comments", context_content_type=doc_ct).delete() except ContentType.DoesNotExist: pass class Migration(migrations.Migration): - dependencies = [ - ('documents', '0013_remove_document_document_type'), - ('communications', '0037_remove_department_type'), # Зафиксировали конкретную версию - ('contenttypes', '__latest__'), + ("documents", "0013_remove_document_document_type"), + ( + "communications", + "0037_remove_department_type", + ), # Зафиксировали конкретную версию + ("contenttypes", "__latest__"), ] operations = [ - migrations.RunPython( - migrate_comments_forward, - migrate_comments_backward - ), + migrations.RunPython(migrate_comments_forward, migrate_comments_backward), ] diff --git a/backend/documents/migrations/0015_delete_documentcomment.py b/backend/documents/migrations/0015_delete_documentcomment.py index 59cb72b7..bc7540d6 100644 --- a/backend/documents/migrations/0015_delete_documentcomment.py +++ b/backend/documents/migrations/0015_delete_documentcomment.py @@ -4,13 +4,12 @@ from django.db import migrations class Migration(migrations.Migration): - dependencies = [ - ('documents', '0014_migrate_comments_to_communications'), + ("documents", "0014_migrate_comments_to_communications"), ] operations = [ migrations.DeleteModel( - name='DocumentComment', + name="DocumentComment", ), ] diff --git a/backend/documents/migrations/0016_document_is_regulation.py b/backend/documents/migrations/0016_document_is_regulation.py index b9b20168..f4fb381d 100644 --- a/backend/documents/migrations/0016_document_is_regulation.py +++ b/backend/documents/migrations/0016_document_is_regulation.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("documents", "0015_delete_documentcomment"), ] diff --git a/backend/documents/models.py b/backend/documents/models.py index 25da2d0b..5ec1f9f1 100644 --- a/backend/documents/models.py +++ b/backend/documents/models.py @@ -7,11 +7,11 @@ - DocumentAcknowledgement: Запись об ознакомлении сотрудника с документом """ +import reversion +from django.conf import settings from django.db import models from django.utils.translation import gettext_lazy as _ -from django.conf import settings from filer.fields.file import FilerFileField -import reversion User = settings.AUTH_USER_MODEL # 'employees.Employee' @@ -49,9 +49,7 @@ class Document(models.Model): blank=True, on_delete=models.SET_NULL, related_name="documents", - help_text=_( - "Папка для организации документов в иерархической структуре" - ), + help_text=_("Папка для организации документов в иерархической структуре"), ) description = models.TextField(_("Описание"), blank=True) @@ -89,18 +87,14 @@ class Document(models.Model): sent_to_all = models.BooleanField( _("Разослать всем"), default=True, - help_text=_( - "Если включено — уведомление получат все активные сотрудники" - ), + help_text=_("Если включено — уведомление получат все активные сотрудники"), ) # Требование ознакомления acknowledgement_required = models.BooleanField( _("Требуется ознакомление"), default=True, - help_text=_( - "Если включено — сотрудники должны подтвердить ознакомление" - ), + help_text=_("Если включено — сотрудники должны подтвердить ознакомление"), ) acknowledgement_for_all = models.BooleanField( @@ -132,9 +126,7 @@ class Document(models.Model): User, verbose_name=_("Получатели"), blank=True, - help_text=_( - "Если `Разослать всем` отключено, документ пойдет только этим" - ), + help_text=_("Если `Разослать всем` отключено, документ пойдет только этим"), related_name="document_recipients", ) acknowledgement_departments = models.ManyToManyField( @@ -142,18 +134,14 @@ class Document(models.Model): verbose_name=_("Отделы для ознакомления"), blank=True, related_name="documents_requiring_acknowledgement", - help_text=_( - "Сотрудники выбранных отделов должны ознакомиться с документом" - ), + help_text=_("Сотрудники выбранных отделов должны ознакомиться с документом"), ) acknowledgement_recipients = models.ManyToManyField( User, verbose_name=_("Сотрудники для ознакомления"), blank=True, related_name="documents_requiring_acknowledgement", - help_text=_( - "Выбранные сотрудники должны ознакомиться с документом" - ), + help_text=_("Выбранные сотрудники должны ознакомиться с документом"), ) # Связанные документы @@ -283,9 +271,7 @@ class DocumentAcknowledgement(models.Model): on_delete=models.CASCADE, related_name="document_acknowledgements", ) - acknowledged_at = models.DateTimeField( - _("Время ознакомления"), auto_now_add=True - ) + acknowledged_at = models.DateTimeField(_("Время ознакомления"), auto_now_add=True) class Meta: verbose_name = _("Ознакомление с документом") @@ -294,10 +280,7 @@ class DocumentAcknowledgement(models.Model): ordering = ["-acknowledged_at"] def __str__(self): - return ( - f"{self.user} — {self.document} @ " - f"{self.acknowledged_at:%d.%m.%Y %H:%M}" - ) + return f"{self.user} — {self.document} @ {self.acknowledged_at:%d.%m.%Y %H:%M}" class DocumentDisplayState(models.Model): @@ -332,10 +315,7 @@ class DocumentDisplayState(models.Model): ] def __str__(self): - return ( - f"{self.document_id}: {self.user_id} " - f"hidden={self.is_maximally_hidden}" - ) + return f"{self.document_id}: {self.user_id} hidden={self.is_maximally_hidden}" # ============================================================================ @@ -358,9 +338,7 @@ class DocumentType(models.Model): _("Схема метаданных"), null=True, blank=True, - help_text=_( - "JSON Schema для валидации метаданных документов этого типа" - ), + help_text=_("JSON Schema для валидации метаданных документов этого типа"), ) # Иконка для UI @@ -561,13 +539,9 @@ class DocumentAuditLog(models.Model): PERMISSIONS_CHANGED = "permissions_changed", _("Изменены права") SIGNED = "signed", _("Подписан") - action = models.CharField( - _("Действие"), max_length=50, choices=Action.choices - ) + action = models.CharField(_("Действие"), max_length=50, choices=Action.choices) timestamp = models.DateTimeField(_("Время"), auto_now_add=True) - ip_address = models.GenericIPAddressField( - _("IP адрес"), null=True, blank=True - ) + ip_address = models.GenericIPAddressField(_("IP адрес"), null=True, blank=True) user_agent = models.TextField(_("User Agent"), blank=True) # Дополнительные данные о действии (JSON) diff --git a/backend/documents/notifications/__init__.py b/backend/documents/notifications/__init__.py index 6dff2101..d8bac081 100644 --- a/backend/documents/notifications/__init__.py +++ b/backend/documents/notifications/__init__.py @@ -14,22 +14,23 @@ notify_document_ready(document, user) """ +# Импортируем handlers и signals для регистрации +from . import ( + handlers, # noqa: F401 + signals, # noqa: F401 +) from .config import ( - NotificationVerbs, - MessageTemplates, ActionURLs, + MessageTemplates, + NotificationVerbs, get_uploader_name, ) from .handlers import notify_document_ready -# Импортируем handlers и signals для регистрации -from . import handlers # noqa: F401 -from . import signals # noqa: F401 - __all__ = [ - 'NotificationVerbs', - 'MessageTemplates', - 'ActionURLs', - 'notify_document_ready', - 'get_uploader_name', + "NotificationVerbs", + "MessageTemplates", + "ActionURLs", + "notify_document_ready", + "get_uploader_name", ] diff --git a/backend/documents/notifications/config.py b/backend/documents/notifications/config.py index d58da2af..f7056c0f 100644 --- a/backend/documents/notifications/config.py +++ b/backend/documents/notifications/config.py @@ -13,14 +13,14 @@ from django.conf import settings class NotificationVerbs: """Глаголы (verbs) для уведомлений documents.""" - DOCUMENT_READY = 'document_ready' - DOCUMENT_SIGNED_ALL = 'document_signed_all' - DOCUMENT_COMMENT = 'document_comment' - DOCUMENT_COMMENT_REPLY = 'document_comment_reply' - REGULATION_READY = 'regulation_ready' - REGULATION_SIGNED_ALL = 'regulation_signed_all' - REGULATION_COMMENT = 'regulation_comment' - REGULATION_COMMENT_REPLY = 'regulation_comment_reply' + DOCUMENT_READY = "document_ready" + DOCUMENT_SIGNED_ALL = "document_signed_all" + DOCUMENT_COMMENT = "document_comment" + DOCUMENT_COMMENT_REPLY = "document_comment_reply" + REGULATION_READY = "regulation_ready" + REGULATION_SIGNED_ALL = "regulation_signed_all" + REGULATION_COMMENT = "regulation_comment" + REGULATION_COMMENT_REPLY = "regulation_comment_reply" @classmethod def ready(cls, is_regulation: bool) -> str: @@ -28,11 +28,7 @@ class NotificationVerbs: @classmethod def signed_all(cls, is_regulation: bool) -> str: - return ( - cls.REGULATION_SIGNED_ALL - if is_regulation - else cls.DOCUMENT_SIGNED_ALL - ) + return cls.REGULATION_SIGNED_ALL if is_regulation else cls.DOCUMENT_SIGNED_ALL class MessageTemplates: @@ -46,8 +42,8 @@ class MessageTemplates: is_regulation: bool = False, ) -> str: """Шаблон для нового документа.""" - resource = 'регламент' if is_regulation else 'документ' - action = 'опубликовал' if is_regulation else 'загрузил' + resource = "регламент" if is_regulation else "документ" + action = "опубликовал" if is_regulation else "загрузил" message = f'{uploader_name} {action} {resource} "{document_title}".' if acknowledgement_required: return f"{message} Требуется ознакомление." @@ -59,10 +55,10 @@ class MessageTemplates: is_regulation: bool = False, ) -> str: """Заголовок для нового документа.""" - resource = 'регламент' if is_regulation else 'документ' + resource = "регламент" if is_regulation else "документ" if not acknowledgement_required: - return f'Новый {resource}' - return f'Новый {resource} на ознакомление' + return f"Новый {resource}" + return f"Новый {resource} на ознакомление" @staticmethod def all_acknowledged( @@ -70,46 +66,44 @@ class MessageTemplates: is_regulation: bool = False, ) -> str: """Шаблон для завершения ознакомления всеми.""" - resource = 'регламентом' if is_regulation else 'документом' + resource = "регламентом" if is_regulation else "документом" return f'Все сотрудники ознакомились с {resource} "{document_title}"' @staticmethod def all_acknowledged_title(is_regulation: bool = False) -> str: """Заголовок для завершения ознакомления.""" - resource = 'регламентом' if is_regulation else 'документом' - return f'Все ознакомились с {resource}' + resource = "регламентом" if is_regulation else "документом" + return f"Все ознакомились с {resource}" @staticmethod def comment(author_name: str, document_title: str) -> str: """Шаблон для нового комментария к документу.""" return ( - f'{author_name} оставил комментарий ' - f'к вашему документу "{document_title}"' + f'{author_name} оставил комментарий к вашему документу "{document_title}"' ) @staticmethod def comment_title() -> str: """Заголовок для комментария к документу.""" - return 'Новый комментарий к документу' + return "Новый комментарий к документу" @staticmethod def comment_reply(author_name: str, document_title: str) -> str: """Шаблон для ответа на комментарий.""" return ( - f'{author_name} ответил на ваш ' - f'комментарий к документу "{document_title}"' + f'{author_name} ответил на ваш комментарий к документу "{document_title}"' ) @staticmethod def comment_reply_title() -> str: """Заголовок для ответа на комментарий.""" - return 'Ответ на ваш комментарий' + return "Ответ на ваш комментарий" class ActionURLs: """URL-адреса для действий с документами.""" - DOCUMENTS = '/documents' + DOCUMENTS = "/documents" @staticmethod def document_detail( @@ -117,12 +111,13 @@ class ActionURLs: is_regulation: bool = False, ) -> str: """Возвращает прямую ссылку на документ или регламент.""" - section = 'regulations' if is_regulation else 'folders' - return f'/documents?section={section}&document={document_id}' + section = "regulations" if is_regulation else "folders" + return f"/documents?section={section}&document={document_id}" # ===== Константы ===== + def get_bulk_threshold() -> int: """ Возвращает порог для определения массовой рассылки. @@ -130,7 +125,7 @@ def get_bulk_threshold() -> int: Returns: int: Количество получателей, начиная с которого используется bulk режим """ - return getattr(settings, 'NOTIFICATION_BULK_THRESHOLD', 10) + return getattr(settings, "NOTIFICATION_BULK_THRESHOLD", 10) def get_uploader_name(uploaded_by) -> str: @@ -145,4 +140,4 @@ def get_uploader_name(uploaded_by) -> str: """ if uploaded_by: return uploaded_by.get_full_name() or uploaded_by.username - return 'Администратор' + return "Администратор" diff --git a/backend/documents/notifications/handlers.py b/backend/documents/notifications/handlers.py index c8a0d851..1e8a251f 100644 --- a/backend/documents/notifications/handlers.py +++ b/backend/documents/notifications/handlers.py @@ -14,12 +14,13 @@ import logging from django.contrib.auth import get_user_model from django.db.models import Q + from notifications.signals import notify from .config import ( - NotificationVerbs, - MessageTemplates, ActionURLs, + MessageTemplates, + NotificationVerbs, get_bulk_threshold, get_uploader_name, ) @@ -199,9 +200,10 @@ def notify_all_employees(document): # Запускаем асинхронную отправку email/telegram try: - from django.core.management import call_command import threading + from django.core.management import call_command + def send_pending(): try: call_command("send_pending_notifications", "--batch-size=100") diff --git a/backend/documents/notifications/signals.py b/backend/documents/notifications/signals.py index 5e08abed..f16fa71c 100644 --- a/backend/documents/notifications/signals.py +++ b/backend/documents/notifications/signals.py @@ -11,15 +11,15 @@ Django signals для автоматической генерации уведо import logging from django.contrib.auth import get_user_model -from django.db.models.signals import post_save, m2m_changed +from django.db.models.signals import m2m_changed, post_save from django.dispatch import receiver from ..models import Document, DocumentAcknowledgement from .handlers import ( - notify_all_employees, - notify_specific_users, - notify_department_employees, notify_all_acknowledged, + notify_all_employees, + notify_department_employees, + notify_specific_users, ) logger = logging.getLogger(__name__) diff --git a/backend/documents/rules.py b/backend/documents/rules.py index f90f27b0..8257422f 100644 --- a/backend/documents/rules.py +++ b/backend/documents/rules.py @@ -10,7 +10,6 @@ import rules from employees.positions import employee_position_search_text - # ----------------------------------------------------------------------------- # ПРЕДИКАТЫ (predicates) # ----------------------------------------------------------------------------- @@ -104,9 +103,7 @@ def has_acknowledged_document(user, document): from documents.models import DocumentAcknowledgement - return DocumentAcknowledgement.objects.filter( - document=document, user=user - ).exists() + return DocumentAcknowledgement.objects.filter(document=document, user=user).exists() @rules.predicate diff --git a/backend/employees/admin.py b/backend/employees/admin.py index 07eff79b..2a215199 100644 --- a/backend/employees/admin.py +++ b/backend/employees/admin.py @@ -5,12 +5,12 @@ from io import StringIO from django import forms from django.contrib import admin, messages from django.contrib.auth.admin import GroupAdmin as DefaultGroupAdmin +from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin from django.contrib.auth.forms import ( - ReadOnlyPasswordHashField, AdminPasswordChangeForm, + ReadOnlyPasswordHashField, ) from django.contrib.auth.models import Group, Permission -from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin from django.core.exceptions import PermissionDenied from django.core.management import call_command from django.core.management.base import CommandError @@ -18,21 +18,20 @@ from django.template.response import TemplateResponse from django.urls import path, reverse from django.utils import timezone from django.utils.html import format_html - from simple_history.admin import SimpleHistoryAdmin from .models import ( - Employee, - EmployeeAction, Department, DepartmentRole, + Employee, + EmployeeAction, EmployeeDepartment, EmployeeSkillProgress, - SecretSkillSettings, + LdapSyncState, Position, + SecretSkillSettings, Skill, SkillExperienceEvent, - LdapSyncState, ) @@ -422,9 +421,10 @@ class EmployeeAdmin(DjangoUserAdmin): if not obj.pk: return "-" - from django.utils.html import format_html import logging + from django.utils.html import format_html + logger = logging.getLogger(__name__) # Если не управляется LDAP @@ -600,8 +600,8 @@ class EmployeeAdmin(DjangoUserAdmin): if not sync_state: return "Нет записи синхронизации" - from django.utils.html import format_html from django.urls import reverse + from django.utils.html import format_html # Ссылка на запись синхронизации в админке sync_url = reverse( @@ -761,24 +761,14 @@ class EmployeeAdmin(DjangoUserAdmin): ) ) def sync_from_django_to_ldap(self, request, queryset): - """Создает или обновляет пользователей в LDAP из Django. - - Django считается источником истины - данные из БД будут - записаны в Active Directory (создание или обновление). - """ + """Создаёт отсутствующих LDAP-пользователей и обновляет существующих.""" import logging + from employees.ldap.services import UserService - from employees.ldap.orm_models import LdapUser - from employees.ldap.utils.ldap_utils import get_ldap_str logger = logging.getLogger(__name__) - - success_count = 0 - created_count = 0 - updated_count = 0 - error_count = 0 - service = UserService() + counts = {"created": 0, "updated": 0, "current": 0, "error": 0} for employee in queryset: logger.info( @@ -790,414 +780,278 @@ class EmployeeAdmin(DjangoUserAdmin): }, ) try: - # Ищем существующего LDAP пользователя - ldap_user = None - # Пробуем найти существующий объект даже для сотрудников, - # которые еще не помечены как LDAP-managed: при ручной - # синхронизации Django считается источником истины, поэтому - # найденную AD-запись нужно привязать и обновить, а не - # пытаться создать повторно. ldap_user = _resolve_ldap_user_for_employee(employee) - logger.debug( - "LDAP admin user lookup completed", - extra={ - "event": "ldap.admin.employee_lookup.completed", - "employee_id": employee.pk, - "ldap_user_found": bool(ldap_user), - }, - ) - - if ldap_user: - # ОБНОВЛЕНИЕ существующего LDAP пользователя - logger.info( - "LDAP admin user update started", - extra={ - "event": "ldap.admin.employee_update.started", - "employee_id": employee.pk, - }, - ) - try: - from django.utils import timezone - from ldap3 import MODIFY_REPLACE - from employees.ldap.infrastructure.connections import ( - _ldap, - ) - - LdapSyncState.objects.update_or_create( - model="employee", - object_pk=str(employee.pk), - defaults={ - "ldap_dn": ldap_user.dn, - "last_sync_dir": "django", - "last_django_modify_ts": timezone.now(), - }, - ) - - current_employee_number = get_ldap_str( - getattr(ldap_user, "employee_number", "") - ) - if current_employee_number != str(employee.pk): - with _ldap() as conn: - ok = conn.modify( - ldap_user.dn, - { - "employeeNumber": [ - ( - MODIFY_REPLACE, - [str(employee.pk)], - ) - ] - }, - ) - if not ok: - logger.warning( - "LDAP employee number update failed", - extra={ - "event": "ldap.admin.employee_number.failed", - "employee_id": employee.pk, - "ldap_result_code": (conn.result or {}).get( - "result" - ), - }, - ) - else: - ldap_user.employee_number = str(employee.pk) - - if not employee.is_ldap_managed: - employee.is_ldap_managed = True - employee._skip_ldap_sync = True - employee.save(update_fields=["is_ldap_managed"]) - - changes = {} - - ldap_first = get_ldap_str(ldap_user.given_name) - if ldap_first != employee.first_name: - changes["first_name"] = employee.first_name - - ldap_last = get_ldap_str(ldap_user.sn) - if ldap_last != employee.last_name: - changes["last_name"] = employee.last_name - - ldap_email = get_ldap_str(ldap_user.mail) - if ldap_email != employee.email: - changes["email"] = employee.email - - ldap_phone = get_ldap_str( - ldap_user.telephone_number or ldap_user.mobile - ) - if ( - employee.phone_number - and ldap_phone != employee.phone_number - ): - changes["phone_number"] = employee.phone_number - - desired_is_active = ( - employee.is_active and employee.is_actually_active - ) - if employee.is_active != desired_is_active: - changes["is_active"] = desired_is_active - else: - try: - from employees.ldap.utils.ldap_utils import ( - _uac_is_active, - ) - - ldap_is_active = _uac_is_active( - ldap_user.user_account_control - ) - except Exception: - ldap_is_active = None - if ldap_is_active != desired_is_active: - changes["is_active"] = desired_is_active - - if changes: - service.update_user(employee, changes) - updated_count += 1 - - # Проверяем, в правильной ли OU находится пользователь - from django.conf import settings as _settings - - expected_base = None - if not desired_is_active: - expected_base = getattr( - _settings, "LDAP_DISMISSED_BASE", None - ) - else: - active_dept = employee.departments.first() - if active_dept: - dept_sync = LdapSyncState.objects.filter( - model="department", - object_pk=str(active_dept.pk), - ).first() - if dept_sync and dept_sync.ldap_dn: - expected_base = dept_sync.ldap_dn - else: - depts_base = getattr( - _settings, "LDAP_DEPARTMENTS_BASE", "" - ) - if depts_base: - expected_base = ( - f"OU={active_dept.name},{depts_base}" - ) - - if expected_base: - # Текущий base_dn = DN без первой компоненты - # (CN=...) - current_parts = ldap_user.dn.split(",", 1) - current_base = ( - current_parts[1] if len(current_parts) > 1 else "" - ) - - if current_base.lower() != expected_base.lower(): - try: - ldap_user.move_to(expected_base) - logger.info( - "LDAP employee moved", - extra={ - "event": "ldap.admin.employee.moved", - "employee_id": employee.pk, - }, - ) - self.message_user( - request, - f"📦 {employee.email}: перемещён " - f"в {expected_base}", - level=messages.INFO, - ) - - # Добавляем в группу DEP_* только активного - # сотрудника. Уволенные остаются в - # LDAP_DISMISSED_BASE без DEP membership. - if desired_is_active: - try: - from employees.ldap.orm_models import ( - LdapOrganizationalUnit, - ) - - ou = LdapOrganizationalUnit.objects.get( - dn=expected_base - ) - dept_group = ou.ensure_department_group() - dept_group.add_member(ldap_user.dn) - except Exception: - pass # best effort - - except Exception as move_err: - logger.warning( - "LDAP employee move failed", - extra={ - "event": "ldap.admin.employee_move.failed", - "employee_id": employee.pk, - "error_type": type(move_err).__name__, - }, - exc_info=True, - ) - self.message_user( - request, - f"⚠️ {employee.email}: не удалось " - f"переместить в {expected_base}: " - f"{move_err}", - level=messages.WARNING, - ) - - # Обновляем sync state. - # DN мог измениться после перемещения. - # Перечитываем пользователя, чтобы получить - # актуальный DN. - try: - ldap_user_fresh = _resolve_ldap_user_for_employee(employee) - if ldap_user_fresh is None: - raise LdapUser.DoesNotExist - fresh_dn = ldap_user_fresh.dn - except LdapUser.DoesNotExist: - fresh_dn = ldap_user.dn - - LdapSyncState.objects.update_or_create( - model="employee", - object_pk=str(employee.pk), - defaults={ - "ldap_dn": fresh_dn, - "last_sync_dir": "django", - }, - ) - - if not changes: - success_count += 1 # Уже синхронизирован - except Exception as e: - logger.exception( - "LDAP admin user update failed", - extra={ - "event": "ldap.admin.employee_update.failed", - "employee_id": employee.pk, - "error_type": type(e).__name__, - }, - ) - error_count += 1 - self.message_user( - request, - f"Ошибка обновления {employee.email} в LDAP: {e}", - level=messages.ERROR, - ) + if ldap_user is None: + result = self._create_ldap_employee(request, employee, service) else: - # СОЗДАНИЕ нового LDAP пользователя - logger.info( - "LDAP admin user creation started", - extra={ - "event": "ldap.admin.employee_create.started", - "employee_id": employee.pk, - }, + result = self._update_ldap_employee( + request, employee, ldap_user, service ) - try: - # Генерируем временный пароль - import secrets - from employees.ldap.domain.dtos import DirectoryUserDTO - from employees.ldap.utils.phone_utils import ( - normalize_phone, - ) - from django.conf import settings as _settings - - temp_password = secrets.token_urlsafe(16) - - phone_e164 = None - if employee.phone_number: - phone_e164 = normalize_phone(employee.phone_number) - - # Определяем department_dn на основе отдела и статуса - department_dn = None - - desired_is_active = ( - employee.is_active and employee.is_actually_active - ) - if not desired_is_active: - # Уволенный → OU=Dismissed - department_dn = getattr( - _settings, "LDAP_DISMISSED_BASE", None - ) - logger.info( - "Dismissed LDAP user container selected", - extra={ - "event": "ldap.admin.container.selected", - "employee_id": employee.pk, - "container_type": "dismissed", - }, - ) - else: - # Активный → OU отдела из EmployeeDepartment - active_dept = employee.departments.first() - if active_dept: - dept_sync = LdapSyncState.objects.filter( - model="department", - object_pk=str(active_dept.pk), - ).first() - if dept_sync and dept_sync.ldap_dn: - department_dn = dept_sync.ldap_dn - else: - # Формируем DN отдела из имени - depts_base = getattr( - _settings, "LDAP_DEPARTMENTS_BASE", "" - ) - if depts_base: - department_dn = ( - f"OU={active_dept.name},{depts_base}" - ) - logger.info( - "Department LDAP container selected", - extra={ - "event": "ldap.admin.container.selected", - "employee_id": employee.pk, - "department_id": active_dept.pk, - "container_type": "department", - }, - ) - - dto = DirectoryUserDTO( - first_name=employee.first_name, - last_name=employee.last_name, - email=employee.email, - phone_e164=phone_e164, - department_dn=department_dn, - group_cns=[], - initial_password=temp_password, - is_active=desired_is_active, - ) - - service.create_user(dto) - logger.info( - "LDAP admin user created", - extra={ - "event": "ldap.admin.employee_create.completed", - "employee_id": employee.pk, - }, - ) - - created_count += 1 - employee.is_ldap_managed = True - employee.save(update_fields=["is_ldap_managed"]) - - self.message_user( - request, - ( - f"✅ Создан LDAP пользователь для " - f"{employee.email}. Временный пароль: " - f"{temp_password}" - ), - level=messages.SUCCESS, - ) - except Exception as e: - logger.exception( - "LDAP admin user creation failed", - extra={ - "event": "ldap.admin.employee_create.failed", - "employee_id": employee.pk, - "error_type": type(e).__name__, - }, - ) - error_count += 1 - self.message_user( - request, - f"Ошибка создания {employee.email} в LDAP: {e}", - level=messages.ERROR, - ) - - except Exception as e: + counts[result] += 1 + except Exception as error: logger.exception( "LDAP admin employee synchronization failed", extra={ "event": "ldap.admin.employee_sync.failed", "employee_id": employee.pk, - "error_type": type(e).__name__, + "error_type": type(error).__name__, }, ) - error_count += 1 + counts["error"] += 1 self.message_user( request, - f"Ошибка обработки {employee.email}: {e}", + f"Ошибка обработки {employee.email}: {error}", level=messages.ERROR, ) - # Итоговое сообщение - if created_count > 0: - self.message_user( - request, - f"✅ Создано в LDAP: {created_count} пользователей", - level=messages.SUCCESS, + self._report_employee_ldap_sync(request, counts) + + def _update_ldap_employee(self, request, employee, ldap_user, service): + import logging + + from django.utils import timezone + from ldap3 import MODIFY_REPLACE + + from employees.ldap.infrastructure.connections import _ldap + from employees.ldap.orm_models import LdapUser + from employees.ldap.utils.ldap_utils import get_ldap_str + + logger = logging.getLogger(__name__) + logger.info( + "LDAP admin user update started", + extra={ + "event": "ldap.admin.employee_update.started", + "employee_id": employee.pk, + }, + ) + LdapSyncState.objects.update_or_create( + model="employee", + object_pk=str(employee.pk), + defaults={ + "ldap_dn": ldap_user.dn, + "last_sync_dir": "django", + "last_django_modify_ts": timezone.now(), + }, + ) + if get_ldap_str(getattr(ldap_user, "employee_number", "")) != str(employee.pk): + with _ldap() as connection: + updated = connection.modify( + ldap_user.dn, + {"employeeNumber": [(MODIFY_REPLACE, [str(employee.pk)])]}, + ) + if updated: + ldap_user.employee_number = str(employee.pk) + else: + logger.warning( + "LDAP employee number update failed", + extra={ + "event": "ldap.admin.employee_number.failed", + "employee_id": employee.pk, + "ldap_result_code": (connection.result or {}).get("result"), + }, + ) + + if not employee.is_ldap_managed: + employee.is_ldap_managed = True + employee._skip_ldap_sync = True + employee.save(update_fields=["is_ldap_managed"]) + + desired_is_active = employee.is_active and employee.is_actually_active + changes = self._ldap_employee_changes( + employee, ldap_user, desired_is_active, get_ldap_str + ) + if changes: + service.update_user(employee, changes) + + expected_base = self._ldap_employee_expected_base(employee, desired_is_active) + if expected_base: + current_parts = ldap_user.dn.split(",", 1) + current_base = current_parts[1] if len(current_parts) > 1 else "" + if current_base.lower() != expected_base.lower(): + self._move_ldap_employee( + request, employee, ldap_user, expected_base, desired_is_active + ) + + try: + fresh_user = _resolve_ldap_user_for_employee(employee) + if fresh_user is None: + raise LdapUser.DoesNotExist + fresh_dn = fresh_user.dn + except LdapUser.DoesNotExist: + fresh_dn = ldap_user.dn + LdapSyncState.objects.update_or_create( + model="employee", + object_pk=str(employee.pk), + defaults={"ldap_dn": fresh_dn, "last_sync_dir": "django"}, + ) + return "updated" if changes else "current" + + @staticmethod + def _ldap_employee_changes(employee, ldap_user, desired_is_active, get_ldap_str): + changes = {} + field_pairs = ( + ("first_name", "given_name", employee.first_name), + ("last_name", "sn", employee.last_name), + ("email", "mail", employee.email), + ) + for target_field, ldap_field, expected_value in field_pairs: + if get_ldap_str(getattr(ldap_user, ldap_field)) != expected_value: + changes[target_field] = expected_value + + ldap_phone = get_ldap_str(ldap_user.telephone_number or ldap_user.mobile) + if employee.phone_number and ldap_phone != employee.phone_number: + changes["phone_number"] = employee.phone_number + try: + from employees.ldap.utils.ldap_utils import _uac_is_active + + ldap_is_active = _uac_is_active(ldap_user.user_account_control) + except Exception: + ldap_is_active = None + if ldap_is_active != desired_is_active: + changes["is_active"] = desired_is_active + return changes + + @staticmethod + def _ldap_employee_expected_base(employee, desired_is_active): + from django.conf import settings as ldap_settings + + if not desired_is_active: + return getattr(ldap_settings, "LDAP_DISMISSED_BASE", None) + department = employee.departments.first() + if department is None: + return None + sync_state = LdapSyncState.objects.filter( + model="department", object_pk=str(department.pk) + ).first() + if sync_state and sync_state.ldap_dn: + return sync_state.ldap_dn + departments_base = getattr(ldap_settings, "LDAP_DEPARTMENTS_BASE", "") + return f"OU={department.name},{departments_base}" if departments_base else None + + def _move_ldap_employee( + self, request, employee, ldap_user, expected_base, desired_is_active + ): + import logging + + logger = logging.getLogger(__name__) + try: + ldap_user.move_to(expected_base) + logger.info( + "LDAP employee moved", + extra={ + "event": "ldap.admin.employee.moved", + "employee_id": employee.pk, + }, ) - if updated_count > 0: self.message_user( request, - f"🔄 Обновлено в LDAP: {updated_count} пользователей", - level=messages.SUCCESS, - ) - if success_count > 0: - self.message_user( - request, - f"✓ Уже синхронизировано: {success_count} пользователей", + f"📦 {employee.email}: перемещён в {expected_base}", level=messages.INFO, ) - if error_count > 0: - self.message_user( - request, f"❌ Ошибок: {error_count}", level=messages.WARNING + if desired_is_active: + self._add_ldap_employee_to_department_group( + employee, ldap_user, expected_base + ) + except Exception as error: + logger.warning( + "LDAP employee move failed", + extra={ + "event": "ldap.admin.employee_move.failed", + "employee_id": employee.pk, + "error_type": type(error).__name__, + }, + exc_info=True, ) + self.message_user( + request, + f"⚠️ {employee.email}: не удалось переместить в " + f"{expected_base}: {error}", + level=messages.WARNING, + ) + + @staticmethod + def _add_ldap_employee_to_department_group(employee, ldap_user, expected_base): + import logging + + from employees.ldap.orm_models import LdapOrganizationalUnit + + logger = logging.getLogger(__name__) + try: + organizational_unit = LdapOrganizationalUnit.objects.get(dn=expected_base) + organizational_unit.ensure_department_group().add_member(ldap_user.dn) + except Exception as error: + logger.warning( + "LDAP department group membership update failed", + extra={ + "event": "ldap.admin.department_group_membership.failed", + "employee_id": employee.pk, + "error_type": type(error).__name__, + }, + exc_info=True, + ) + + def _create_ldap_employee(self, request, employee, service): + import logging + import secrets + + from employees.ldap.domain.dtos import DirectoryUserDTO + from employees.ldap.utils.phone_utils import normalize_phone + + logger = logging.getLogger(__name__) + logger.info( + "LDAP admin user creation started", + extra={ + "event": "ldap.admin.employee_create.started", + "employee_id": employee.pk, + }, + ) + temporary_password = secrets.token_urlsafe(16) + desired_is_active = employee.is_active and employee.is_actually_active + department_dn = self._ldap_employee_expected_base(employee, desired_is_active) + phone_e164 = ( + normalize_phone(employee.phone_number) if employee.phone_number else None + ) + service.create_user( + DirectoryUserDTO( + first_name=employee.first_name, + last_name=employee.last_name, + email=employee.email, + phone_e164=phone_e164, + department_dn=department_dn, + group_cns=[], + initial_password=temporary_password, + is_active=desired_is_active, + ) + ) + logger.info( + "LDAP admin user created", + extra={ + "event": "ldap.admin.employee_create.completed", + "employee_id": employee.pk, + }, + ) + employee.is_ldap_managed = True + employee.save(update_fields=["is_ldap_managed"]) + self.message_user( + request, + f"✅ Создан LDAP пользователь для {employee.email}. " + f"Временный пароль: {temporary_password}", + level=messages.SUCCESS, + ) + return "created" + + def _report_employee_ldap_sync(self, request, counts): + summaries = ( + ("created", "✅ Создано в LDAP", messages.SUCCESS), + ("updated", "🔄 Обновлено в LDAP", messages.SUCCESS), + ("current", "✓ Уже синхронизировано", messages.INFO), + ("error", "❌ Ошибок", messages.WARNING), + ) + for key, label, level in summaries: + if counts[key]: + self.message_user( + request, f"{label}: {counts[key]} пользователей", level=level + ) @admin.action( description=("🔄 Синхронизировать LDAP → Django (обновить из Active Directory)") @@ -1458,8 +1312,8 @@ class DepartmentAdmin(admin.ModelAdmin): if not obj.pk: return "-" - from django.utils.html import format_html from django.urls import reverse + from django.utils.html import format_html try: sync_state = LdapSyncState.objects.filter( @@ -1535,8 +1389,8 @@ class DepartmentAdmin(admin.ModelAdmin): if not obj.pk: return "-" - from django.utils.html import format_html from django.urls import reverse + from django.utils.html import format_html try: sync_state = LdapSyncState.objects.filter( @@ -1721,194 +1575,180 @@ class DepartmentAdmin(admin.ModelAdmin): description=("🔄 Синхронизировать Django → LDAP (Django как источник истины)") ) def sync_from_django_to_ldap(self, request, queryset): - """Синхронизирует выбранные отделы из Django Department в LDAP OU.""" + """Синхронизирует выбранные отделы из Django в LDAP OU.""" from employees.ldap.orm_models import LdapOrganizationalUnit - from employees.ldap.infrastructure.connections import _ldap - from django.conf import settings - success_count = 0 - error_count = 0 - created_count = 0 + counts = {"success": 0, "error": 0, "created": 0} warnings = [] - - for dept in queryset: - if not dept.name or not dept.name.strip(): - warnings.append(f"⚠️ Department #{dept.pk}: пустое название") - error_count += 1 + for department in queryset: + if not department.name or not department.name.strip(): + warnings.append(f"⚠️ Department #{department.pk}: пустое название") + counts["error"] += 1 continue - - # Ищем соответствующую OU - sync_state = LdapSyncState.objects.filter( - model="department", - object_pk=str(dept.pk), - ).first() - - ldap_ou = None - - if sync_state and sync_state.ldap_dn: - try: - ldap_ou = LdapOrganizationalUnit.objects.get(dn=sync_state.ldap_dn) - except LdapOrganizationalUnit.DoesNotExist: - pass - - if not ldap_ou: - # Пытаемся найти по имени - try: - ldap_ou = LdapOrganizationalUnit.objects.get(ou=dept.name) - except LdapOrganizationalUnit.DoesNotExist: - # OU не найдена - создаём через низкоуровневый LDAP - try: - with _ldap() as conn: - base = getattr(settings, "LDAP_DEPARTMENTS_BASE", "") - if not base: - raise RuntimeError( - "LDAP_DEPARTMENTS_BASE not configured" - ) - - ou_dn = f"OU={dept.name},{base}" - - # Проверяем существование - from ldap3 import BASE - - ok = conn.search( - ou_dn, - "(objectClass=organizationalUnit)", - search_scope=BASE, - ) - - if not ok or not conn.entries: - # Создаём OU - ok = conn.add(ou_dn, ["top", "organizationalUnit"]) - if not ok: - raise RuntimeError( - f"LDAP add OU failed: {conn.result}" - ) - created_count += 1 - - # Получаем созданную OU через ORM - try: - ldap_ou = LdapOrganizationalUnit.objects.get(dn=ou_dn) - except LdapOrganizationalUnit.DoesNotExist: - raise RuntimeError( - f"OU был создан, но не найден по DN: {ou_dn}" - ) - - except Exception as e: - error_count += 1 - self.message_user( - request, - f"Ош ибка создания OU для {dept.name}: {e}", - level=messages.ERROR, - ) - continue - except LdapOrganizationalUnit.MultipleObjectsReturned: - error_count += 1 - warnings.append( - f"⚠️ {dept.name}: найдено несколько OU с таким именем" - ) - continue - try: - # Обновляем LDAP OU из Django Department - ldap_ou.description = dept.description or "" - - # Обновляем managedBy - if dept.head: - head_sync = LdapSyncState.objects.filter( - model="employee", - object_pk=str(dept.head.pk), - ).first() - if head_sync and head_sync.ldap_dn: - ldap_ou.managed_by = head_sync.ldap_dn - else: - warnings.append( - f"⚠️ {dept.name}: руководитель {dept.head} не имеет LDAP DN" - ) - else: - ldap_ou.managed_by = "" - - ldap_ou.save() - - # Создаём/обновляем группу отдела DEP_* - # и синхронизируем участников - try: - dep_group = ldap_ou.ensure_department_group() - - # Собираем DN активных сотрудников отдела - from employees.models import EmployeeDepartment - - active_links = EmployeeDepartment.objects.filter( - department=dept, - is_active=True, - ).select_related("employee") - - member_dns = [] - for link in active_links: - if not ( - link.employee.is_active and link.employee.is_actually_active - ): - continue - emp_sync = LdapSyncState.objects.filter( - model="employee", - object_pk=str(link.employee_id), - ).first() - if emp_sync and emp_sync.ldap_dn: - member_dns.append(emp_sync.ldap_dn) - - result = dep_group.sync_members(member_dns) - if result["added"] or result["removed"]: - self.message_user( - request, - f"👥 {dept.name}: группа DEP_* — " - f"+{result['added']}/−{result['removed']}", - level=messages.INFO, - ) - except Exception as grp_err: - warnings.append(f"⚠️ {dept.name}: группа DEP_*: {grp_err}") - - LdapSyncState.objects.update_or_create( - model="department", - object_pk=str(dept.pk), - defaults={ - "ldap_dn": ldap_ou.dn, - "last_sync_dir": "django", - }, + organizational_unit, created = self._find_or_create_department_ou( + department ) - success_count += 1 - except Exception as e: - error_count += 1 + counts["created"] += int(created) + except LdapOrganizationalUnit.MultipleObjectsReturned: + counts["error"] += 1 + warnings.append( + f"⚠️ {department.name}: найдено несколько OU с таким именем" + ) + continue + except Exception as error: + counts["error"] += 1 self.message_user( request, - f"Ошибка синхронизации {dept.name}: {e}", + f"Ошибка создания OU для {department.name}: {error}", + level=messages.ERROR, + ) + continue + + try: + self._update_ldap_department( + request, department, organizational_unit, warnings + ) + counts["success"] += 1 + except Exception as error: + counts["error"] += 1 + self.message_user( + request, + f"Ошибка синхронизации {department.name}: {error}", level=messages.ERROR, ) - for w in warnings[:10]: - self.message_user(request, w, level=messages.WARNING) + self._report_department_ldap_sync(request, counts, warnings) + + @staticmethod + def _find_or_create_department_ou(department): + from employees.ldap.orm_models import LdapOrganizationalUnit + + sync_state = LdapSyncState.objects.filter( + model="department", object_pk=str(department.pk) + ).first() + if sync_state and sync_state.ldap_dn: + organizational_unit = LdapOrganizationalUnit.objects.filter( + dn=sync_state.ldap_dn + ).first() + if organizational_unit is not None: + return organizational_unit, False + try: + return LdapOrganizationalUnit.objects.get(ou=department.name), False + except LdapOrganizationalUnit.DoesNotExist: + return DepartmentAdmin._create_department_ou(department), True + + @staticmethod + def _create_department_ou(department): + from django.conf import settings + from ldap3 import BASE + + from employees.ldap.infrastructure.connections import _ldap + from employees.ldap.orm_models import LdapOrganizationalUnit + + base_dn = getattr(settings, "LDAP_DEPARTMENTS_BASE", "") + if not base_dn: + raise RuntimeError("LDAP_DEPARTMENTS_BASE not configured") + organizational_unit_dn = f"OU={department.name},{base_dn}" + with _ldap() as connection: + exists = connection.search( + organizational_unit_dn, + "(objectClass=organizationalUnit)", + search_scope=BASE, + ) + if not exists or not connection.entries: + created = connection.add( + organizational_unit_dn, ["top", "organizationalUnit"] + ) + if not created: + raise RuntimeError(f"LDAP add OU failed: {connection.result}") + try: + return LdapOrganizationalUnit.objects.get(dn=organizational_unit_dn) + except LdapOrganizationalUnit.DoesNotExist as error: + raise RuntimeError( + f"OU был создан, но не найден по DN: {organizational_unit_dn}" + ) from error + + def _update_ldap_department( + self, request, department, organizational_unit, warnings + ): + organizational_unit.description = department.description or "" + if department.head: + head_sync = LdapSyncState.objects.filter( + model="employee", object_pk=str(department.head.pk) + ).first() + if head_sync and head_sync.ldap_dn: + organizational_unit.managed_by = head_sync.ldap_dn + else: + warnings.append( + f"⚠️ {department.name}: руководитель " + f"{department.head} не имеет LDAP DN" + ) + else: + organizational_unit.managed_by = "" + organizational_unit.save() + + try: + group = organizational_unit.ensure_department_group() + result = group.sync_members(self._department_member_dns(department)) + if result["added"] or result["removed"]: + self.message_user( + request, + f"👥 {department.name}: группа DEP_* — " + f"+{result['added']}/−{result['removed']}", + level=messages.INFO, + ) + except Exception as error: + warnings.append(f"⚠️ {department.name}: группа DEP_*: {error}") + + LdapSyncState.objects.update_or_create( + model="department", + object_pk=str(department.pk), + defaults={ + "ldap_dn": organizational_unit.dn, + "last_sync_dir": "django", + }, + ) + + @staticmethod + def _department_member_dns(department): + from employees.models import EmployeeDepartment + + links = EmployeeDepartment.objects.filter( + department=department, is_active=True + ).select_related("employee") + member_dns = [] + for link in links: + if not (link.employee.is_active and link.employee.is_actually_active): + continue + sync_state = LdapSyncState.objects.filter( + model="employee", object_pk=str(link.employee_id) + ).first() + if sync_state and sync_state.ldap_dn: + member_dns.append(sync_state.ldap_dn) + return member_dns + + def _report_department_ldap_sync(self, request, counts, warnings): + for warning in warnings[:10]: + self.message_user(request, warning, level=messages.WARNING) if len(warnings) > 10: self.message_user( request, f"...и ещё {len(warnings) - 10} предупреждений", level=messages.WARNING, ) - if created_count: - self.message_user( - request, - f"Создано новых OU в LDAP: {created_count}", - level=messages.SUCCESS, - ) - if success_count: - self.message_user( - request, - f"Успешно синхронизировано: {success_count} отделов (Django → LDAP)", - level=messages.SUCCESS, - ) - if error_count: - self.message_user( - request, - f"Ошибок: {error_count}", - level=messages.WARNING, - ) + summaries = ( + ("created", "Создано новых OU в LDAP", messages.SUCCESS), + ( + "success", + "Успешно синхронизировано отделов (Django → LDAP)", + messages.SUCCESS, + ), + ("error", "Ошибок", messages.WARNING), + ) + for key, label, level in summaries: + if counts[key]: + self.message_user(request, f"{label}: {counts[key]}", level=level) @admin.action(description="🔍 Показать различия Django ↔ LDAP") def show_sync_diff(self, request, queryset): diff --git a/backend/employees/apps.py b/backend/employees/apps.py index e593ae2f..83604b2a 100644 --- a/backend/employees/apps.py +++ b/backend/employees/apps.py @@ -42,8 +42,10 @@ class EmployeesConfig(AppConfig): name = "employees" def ready(self): + # Миграции импортируют поля django-ldapdb даже когда запись в LDAP + # отключена, поэтому совместимость с Django 5 нужна в любом режиме. + _ensure_ldapdb_timezone_compat() if getattr(settings, "LDAP_WRITE_ENABLED", False): - _ensure_ldapdb_timezone_compat() self._patch_ldapdb_compiler() from . import signals # noqa: F401 @@ -57,8 +59,8 @@ class EmployeesConfig(AppConfig): на безопасный getattr(..., 'attname', None). """ try: - from ldapdb.backends.ldap.compiler import SQLCompiler from django.db.models import aggregates + from ldapdb.backends.ldap.compiler import SQLCompiler from ldapdb.models.fields import ListField original_results_iter = SQLCompiler.results_iter @@ -83,9 +85,7 @@ class EmployeesConfig(AppConfig): tuple_expected=tuple_expected, chunked_fetch=chunked_fetch, **( - {"chunk_size": chunk_size} - if chunk_size is not None - else {} + {"chunk_size": chunk_size} if chunk_size is not None else {} ), ) except ldap_lib.NO_SUCH_OBJECT: @@ -138,8 +138,7 @@ class EmployeesConfig(AppConfig): pos = 0 for dn, attrs in vals: if (self.query.low_mark and pos < self.query.low_mark) or ( - self.query.high_mark is not None - and pos >= self.query.high_mark + self.query.high_mark is not None and pos >= self.query.high_mark ): pos += 1 continue @@ -178,9 +177,7 @@ class EmployeesConfig(AppConfig): pos += 1 SQLCompiler.results_iter = safe_results_iter - SQLCompiler._safe_results_iter_fallback = ( - _safe_results_iter_fallback - ) + SQLCompiler._safe_results_iter_fallback = _safe_results_iter_fallback except ImportError: # ldapdb не установлен - это нормально для тестов без LDAP diff --git a/backend/employees/ldap/__init__.py b/backend/employees/ldap/__init__.py index 7b014d6f..b86811c4 100644 --- a/backend/employees/ldap/__init__.py +++ b/backend/employees/ldap/__init__.py @@ -44,46 +44,46 @@ from importlib import import_module -# Сервисы (прямой доступ) -from .services.user_service import UserService -from .services.group_service import GroupService -from .services.department_service import DepartmentService -from .services.position_service import PositionService -from .services.sync_service import SyncService - # Конфигурация -from .config import SyncConfig, SyncMode, SyncScope, DISABLED_FLAG - -# Ошибки -from .errors import ( - DirectoryServiceError, - DirectoryLdapError, - DirectoryConnectionError, - DirectoryDbError, - DirectoryGroupError, -) +from .config import DISABLED_FLAG, SyncConfig, SyncMode, SyncScope # DTO (из domain слоя) from .domain.dtos import ( - DirectoryUserDTO, DirectoryDepartmentDTO, + DirectoryUserDTO, LdapPersonDTO, ) -# Утилиты работы с группами -from .utils.group_utils_orm import sync_user_groups_by_cns_orm - -# ORM модели -from .orm_models import ( - LdapUser, - LdapGroup, - LdapOrganizationalUnitGroup, - LdapOrganizationalUnit, +# Ошибки +from .errors import ( + DirectoryConnectionError, + DirectoryDbError, + DirectoryGroupError, + DirectoryLdapError, + DirectoryServiceError, ) # Подключения from .infrastructure.connections import LdapConfig, _ldap +# ORM модели +from .orm_models import ( + LdapGroup, + LdapOrganizationalUnit, + LdapOrganizationalUnitGroup, + LdapUser, +) +from .services.department_service import DepartmentService +from .services.group_service import GroupService +from .services.position_service import PositionService +from .services.sync_service import SyncService + +# Сервисы (прямой доступ) +from .services.user_service import UserService + +# Утилиты работы с группами +from .utils.group_utils_orm import sync_user_groups_by_cns_orm + # Админка (автоматическая регистрация) import_module("employees.ldap.admin") @@ -102,34 +102,28 @@ __all__ = [ "PositionService", "SyncService", "export_users", - # Конфигурация "SyncConfig", "SyncMode", "SyncScope", "DISABLED_FLAG", - # Ошибки "DirectoryServiceError", "DirectoryLdapError", "DirectoryConnectionError", "DirectoryDbError", "DirectoryGroupError", - # DTO "DirectoryUserDTO", "DirectoryDepartmentDTO", "LdapPersonDTO", - # Утилиты "sync_user_groups_by_cns_orm", - # ORM "LdapUser", "LdapGroup", "LdapOrganizationalUnitGroup", "LdapOrganizationalUnit", - # Подключения "LdapConfig", "_ldap", diff --git a/backend/employees/ldap/admin.py b/backend/employees/ldap/admin.py index f47284c3..eb0c2215 100644 --- a/backend/employees/ldap/admin.py +++ b/backend/employees/ldap/admin.py @@ -10,15 +10,16 @@ import base64 from urllib.parse import quote from django.contrib import admin, messages -from django.utils.html import format_html from django.urls import reverse +from django.utils.html import format_html from employees.models import Employee, LdapSyncState + from .orm_models import ( - LdapUser, LdapGroup, - LdapOrganizationalUnitGroup, LdapOrganizationalUnit, + LdapOrganizationalUnitGroup, + LdapUser, ) from .utils.ldap_utils import get_ldap_str @@ -198,9 +199,7 @@ class LdapUserAdmin(admin.ModelAdmin): for part in parts: if part.startswith("CN="): html_parts.append( - format_html( - '{}', part - ) + format_html('{}', part) ) elif part.startswith("OU="): html_parts.append( @@ -220,9 +219,7 @@ class LdapUserAdmin(admin.ModelAdmin): def member_of_display(self, obj): """Список групп с форматированием.""" if not obj.member_of: - return format_html( - 'Не состоит в группах' - ) + return format_html('Не состоит в группах') groups_html = [] for group_dn in obj.member_of[:10]: # Ограничиваем первыми 10 @@ -294,27 +291,19 @@ class LdapUserAdmin(admin.ModelAdmin): sync_state.updated_at.strftime("%Y-%m-%d %H:%M"), ) except LdapSyncState.DoesNotExist: - return format_html( - '⚠️ Нет записи' - ) + return format_html('⚠️ Нет записи') sync_status.short_description = "Синхронизация" def account_status(self, obj): """Статус учетной записи (активна/заблокирована).""" # UAC флаг ACCOUNTDISABLE = 0x2 (2) - is_disabled = bool( - obj.user_account_control and (obj.user_account_control & 2) - ) + is_disabled = bool(obj.user_account_control and (obj.user_account_control & 2)) if is_disabled: - return format_html( - '🔒 Заблокирована' - ) + return format_html('🔒 Заблокирована') else: - return format_html( - '✅ Активна' - ) + return format_html('✅ Активна') account_status.short_description = "Статус" @@ -434,10 +423,7 @@ class LdapUserAdmin(admin.ModelAdmin): # Actions для синхронизации @admin.action( - description=( - "🔄 Синхронизировать LDAP → Django " - "(LDAP как источник истины)" - ) + description=("🔄 Синхронизировать LDAP → Django (LDAP как источник истины)") ) def sync_from_ldap_to_django(self, request, queryset): """Синхронизирует выбранных пользователей из LDAP в Django. @@ -454,9 +440,7 @@ class LdapUserAdmin(admin.ModelAdmin): if not ldap_user.employee_number: error_count += 1 warnings.append( - f"⚠️ { - ldap_user.cn - }: отсутствует employeeNumber (связь с Django)" + f"⚠️ {ldap_user.cn}: отсутствует employeeNumber (связь с Django)" ) continue @@ -480,9 +464,7 @@ class LdapUserAdmin(admin.ModelAdmin): emp = Employee.objects.get(pk=int(ldap_user.employee_number)) # Обновляем Django из LDAP - emp.first_name = ( - get_ldap_str(ldap_user.given_name) or emp.first_name - ) + emp.first_name = get_ldap_str(ldap_user.given_name) or emp.first_name emp.last_name = get_ldap_str(ldap_user.sn) or emp.last_name emp.email = get_ldap_str(ldap_user.mail) or emp.email ldap_phone = get_ldap_str( @@ -528,15 +510,10 @@ class LdapUserAdmin(admin.ModelAdmin): level=messages.SUCCESS, ) if error_count: - self.message_user( - request, f"Ошибок: {error_count}", level=messages.WARNING - ) + self.message_user(request, f"Ошибок: {error_count}", level=messages.WARNING) @admin.action( - description=( - "🔄 Синхронизировать Django → LDAP " - "(Django как источник истины)" - ) + description=("🔄 Синхронизировать Django → LDAP (Django как источник истины)") ) def sync_from_django_to_ldap(self, request, queryset): """Синхронизирует выбранных пользователей из Django в LDAP. @@ -557,9 +534,7 @@ class LdapUserAdmin(admin.ModelAdmin): if not ldap_user.employee_number: error_count += 1 warnings.append( - f"⚠️ { - ldap_user.cn - }: отсутствует employeeNumber (связь с Django)" + f"⚠️ {ldap_user.cn}: отсутствует employeeNumber (связь с Django)" ) continue @@ -620,9 +595,7 @@ class LdapUserAdmin(admin.ModelAdmin): level=messages.SUCCESS, ) if error_count: - self.message_user( - request, f"Ошибок: {error_count}", level=messages.WARNING - ) + self.message_user(request, f"Ошибок: {error_count}", level=messages.WARNING) @admin.action(description="🔑 Изменить пароль выбранных пользователей") def change_password_action(self, request, queryset): @@ -649,20 +622,15 @@ class LdapUserAdmin(admin.ModelAdmin): class PasswordForm(forms.Form): new_password = forms.CharField( label="Новый пароль", - widget=forms.PasswordInput( - attrs={"autocomplete": "new-password"} - ), + widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}), min_length=7, help_text=( - "Минимум 7 символов, должен соответствовать " - "политике AD" + "Минимум 7 символов, должен соответствовать политике AD" ), ) confirm_password = forms.CharField( label="Подтверждение пароля", - widget=forms.PasswordInput( - attrs={"autocomplete": "new-password"} - ), + widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}), ) def clean(self): @@ -706,9 +674,7 @@ class LdapUserAdmin(admin.ModelAdmin): return if new_password != confirm_password: - self.message_user( - request, "❌ Пароли не совпадают", level=messages.ERROR - ) + self.message_user(request, "❌ Пароли не совпадают", level=messages.ERROR) return if len(new_password) < 7: @@ -731,9 +697,7 @@ class LdapUserAdmin(admin.ModelAdmin): # Обновляем Django Employee если есть связь if ldap_user.employee_number: try: - emp = Employee.objects.get( - pk=int(ldap_user.employee_number) - ) + emp = Employee.objects.get(pk=int(ldap_user.employee_number)) emp.set_password(new_password) emp.save(update_fields=["password"]) except (Employee.DoesNotExist, ValueError): @@ -785,18 +749,13 @@ class LdapUserAdmin(admin.ModelAdmin): ldap_first = get_ldap_str(ldap_user.given_name) if ldap_first != emp.first_name: user_diffs.append( - ( - f"Имя: LDAP='{ldap_first}' " - f"vs Django='{emp.first_name}'" - ) + (f"Имя: LDAP='{ldap_first}' vs Django='{emp.first_name}'") ) ldap_last = get_ldap_str(ldap_user.sn) if ldap_last != emp.last_name: user_diffs.append( - f"Фамилия: LDAP='{ldap_last}' vs Django='{ - emp.last_name - }'" + f"Фамилия: LDAP='{ldap_last}' vs Django='{emp.last_name}'" ) ldap_email = get_ldap_str(ldap_user.mail) @@ -810,17 +769,14 @@ class LdapUserAdmin(admin.ModelAdmin): ) if ldap_phone and ldap_phone != emp.phone_number: user_diffs.append( - f"Телефон: LDAP='{ldap_phone}' vs Django='{ - emp.phone_number - }'" + f"Телефон: LDAP='{ldap_phone}' vs Django='{emp.phone_number}'" ) if user_diffs: diffs.append(f"{ldap_user.cn}: " + ", ".join(user_diffs)) except Employee.DoesNotExist: diffs.append( - f"{ldap_user.cn}: Employee ID " - f"{ldap_user.employee_number} не найден" + f"{ldap_user.cn}: Employee ID {ldap_user.employee_number} не найден" ) if diffs: @@ -832,8 +788,7 @@ class LdapUserAdmin(admin.ModelAdmin): else: self.message_user( request, - "Различий не найдено. Все выбранные пользователи " - "синхронизированы.", + "Различий не найдено. Все выбранные пользователи синхронизированы.", level=messages.SUCCESS, ) @@ -841,11 +796,12 @@ class LdapUserAdmin(admin.ModelAdmin): def user_change_password(self, request, dn_encoded): """Форма смены пароля для конкретного LDAP пользователя.""" - from django import forms - from django.template.response import TemplateResponse - from django.http import HttpResponseRedirect from urllib.parse import unquote + from django import forms + from django.http import HttpResponseRedirect + from django.template.response import TemplateResponse + # Декодируем DN dn = unquote(dn_encoded) @@ -862,20 +818,13 @@ class LdapUserAdmin(admin.ModelAdmin): class PasswordChangeForm(forms.Form): password1 = forms.CharField( label="Новый пароль", - widget=forms.PasswordInput( - attrs={"autocomplete": "new-password"} - ), + widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}), min_length=7, - help_text=( - "Минимум 7 символов, должен соответствовать " - "политике AD" - ), + help_text=("Минимум 7 символов, должен соответствовать политике AD"), ) password2 = forms.CharField( label="Подтверждение пароля", - widget=forms.PasswordInput( - attrs={"autocomplete": "new-password"} - ), + widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}), ) def clean(self): @@ -898,9 +847,7 @@ class LdapUserAdmin(admin.ModelAdmin): # Синхронизируем с Django Employee if user.employee_number: try: - emp = Employee.objects.get( - pk=int(user.employee_number) - ) + emp = Employee.objects.get(pk=int(user.employee_number)) emp.set_password(new_password) emp.save(update_fields=["password"]) except (Employee.DoesNotExist, ValueError): @@ -1253,9 +1200,7 @@ class LdapOrganizationalUnitAdmin(admin.ModelAdmin): dept.pk, dept.name, dept.head or "—", - sync_state.updated_at.strftime("%Y-%m-%d %H:%M:%S") - if sync_state - else "—", + sync_state.updated_at.strftime("%Y-%m-%d %H:%M:%S") if sync_state else "—", ) django_department_info.short_description = "Связь с Django" @@ -1263,10 +1208,7 @@ class LdapOrganizationalUnitAdmin(admin.ModelAdmin): # --- Actions --- @admin.action( - description=( - "🔄 Синхронизировать LDAP → Django " - "(LDAP как источник истины)" - ) + description=("🔄 Синхронизировать LDAP → Django (LDAP как источник истины)") ) def sync_from_ldap_to_django(self, request, queryset): """Синхронизирует выбранные OU из LDAP в Django Department.""" @@ -1290,9 +1232,7 @@ class LdapOrganizationalUnitAdmin(admin.ModelAdmin): # Создаём Department dept = Department(name=ou_name) - dept.description = ( - get_ldap_str(ldap_ou.description) or dept.description - ) + dept.description = get_ldap_str(ldap_ou.description) or dept.description # Пытаемся найти руководителя mb = get_ldap_str(ldap_ou.managed_by) @@ -1348,10 +1288,7 @@ class LdapOrganizationalUnitAdmin(admin.ModelAdmin): if success_count: self.message_user( request, - ( - f"Успешно синхронизировано: {success_count} OU " - "(LDAP → Django)" - ), + (f"Успешно синхронизировано: {success_count} OU (LDAP → Django)"), level=messages.SUCCESS, ) if error_count: @@ -1362,10 +1299,7 @@ class LdapOrganizationalUnitAdmin(admin.ModelAdmin): ) @admin.action( - description=( - "🔄 Синхронизировать Django → LDAP " - "(Django как источник истины)" - ) + description=("🔄 Синхронизировать Django → LDAP (Django как источник истины)") ) def sync_from_django_to_ldap(self, request, queryset): """Синхронизирует выбранные OU из Django Department в LDAP.""" @@ -1442,10 +1376,7 @@ class LdapOrganizationalUnitAdmin(admin.ModelAdmin): if success_count: self.message_user( request, - ( - f"Успешно синхронизировано: {success_count} OU " - "(Django → LDAP)" - ), + (f"Успешно синхронизировано: {success_count} OU (Django → LDAP)"), level=messages.SUCCESS, ) if error_count: @@ -1464,9 +1395,7 @@ class LdapOrganizationalUnitAdmin(admin.ModelAdmin): for ldap_ou in ldap_ous: dept = self._find_django_department(ldap_ou) if not dept: - diffs.append( - f"{ldap_ou.ou}: не найден связанный Django Department" - ) + diffs.append(f"{ldap_ou.ou}: не найден связанный Django Department") continue ou_diffs = [] @@ -1488,14 +1417,10 @@ class LdapOrganizationalUnitAdmin(admin.ModelAdmin): head_dn = head_sync.ldap_dn if head_sync else None if mb != head_dn: ou_diffs.append( - f"Руководитель: LDAP='{mb or '—'}' vs Django='{ - dept.head - }'" + f"Руководитель: LDAP='{mb or '—'}' vs Django='{dept.head}'" ) elif mb: - ou_diffs.append( - f"Руководитель: LDAP='{mb}' vs Django='не назначен'" - ) + ou_diffs.append(f"Руководитель: LDAP='{mb}' vs Django='не назначен'") if ou_diffs: diffs.append(f"{ldap_ou.ou}: " + ", ".join(ou_diffs)) @@ -1760,7 +1685,7 @@ class LdapOrganizationalUnitGroupAdmin(admin.ModelAdmin): ) return - from employees.models import EmployeeDepartment, Department + from employees.models import Department, EmployeeDepartment for group in groups: cn = get_ldap_str(group.cn) or "" @@ -1784,9 +1709,7 @@ class LdapOrganizationalUnitGroupAdmin(admin.ModelAdmin): member_dns = [] for link in active_links: - if not ( - link.employee.is_active and link.employee.is_actually_active - ): + if not (link.employee.is_active and link.employee.is_actually_active): continue emp_sync = LdapSyncState.objects.filter( model="employee", @@ -2225,9 +2148,7 @@ class LdapGroupAdmin(admin.ModelAdmin): level=messages.ERROR, ) - sync_groups_from_ldap.short_description = ( - "⬇️ LDAP → Django (создать Django Group)" - ) + sync_groups_from_ldap.short_description = "⬇️ LDAP → Django (создать Django Group)" def sync_groups_to_ldap(self, request, queryset): """Синхронизирует Django Groups → LDAP. @@ -2235,6 +2156,7 @@ class LdapGroupAdmin(admin.ModelAdmin): Отсутствующие группы создаются после проверки LDAP. """ from django.contrib.auth.models import Group as DjangoGroup + from employees.ldap.services.group_service import GroupService svc = GroupService() @@ -2281,8 +2203,7 @@ class LdapGroupAdmin(admin.ModelAdmin): self.message_user( request, - f"🔄 Django → LDAP: создано {created}, " - f"уже существовало {already}", + f"🔄 Django → LDAP: создано {created}, уже существовало {already}", level=messages.SUCCESS, ) else: @@ -2295,9 +2216,7 @@ class LdapGroupAdmin(admin.ModelAdmin): if sync: self.message_user( request, - f"✅ {group.cn}: связана с Django Group pk={ - sync.object_pk - }", + f"✅ {group.cn}: связана с Django Group pk={sync.object_pk}", level=messages.INFO, ) else: @@ -2307,9 +2226,7 @@ class LdapGroupAdmin(admin.ModelAdmin): level=messages.WARNING, ) - sync_groups_to_ldap.short_description = ( - "⬆️ Django → LDAP (создать LDAP Group)" - ) + sync_groups_to_ldap.short_description = "⬆️ Django → LDAP (создать LDAP Group)" # --- Кастомные поля для отображения --- @@ -2338,9 +2255,7 @@ class LdapGroupAdmin(admin.ModelAdmin): dn = obj.dn or "" if "OU=Groups" in dn: - return format_html( - '🌐 Глобальная' - ) + return format_html('🌐 Глобальная') elif cn.startswith("ROLE_"): # Извлекаем название отдела из DN parts = dn.split(",") @@ -2355,9 +2270,7 @@ class LdapGroupAdmin(admin.ModelAdmin): ) return format_html('🎭 Роль') elif cn.startswith("DEP_"): - return format_html( - '🏢 Группа отдела' - ) + return format_html('🏢 Группа отдела') else: return format_html('👥 Группа') @@ -2484,9 +2397,7 @@ class LdapGroupAdmin(admin.ModelAdmin): """Список групп, в которых состоит эта группа.""" member_of = obj.member_of or [] if not member_of: - return format_html( - 'Не состоит в группах' - ) + return format_html('Не состоит в группах') html = '
' html += '