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 += '
'
diff --git a/backend/employees/ldap/domain/__init__.py b/backend/employees/ldap/domain/__init__.py
index 9dcb5a51..c9dcd9d8 100644
--- a/backend/employees/ldap/domain/__init__.py
+++ b/backend/employees/ldap/domain/__init__.py
@@ -4,8 +4,8 @@
"""
from .dtos import (
- DirectoryUserDTO,
DirectoryDepartmentDTO,
+ DirectoryUserDTO,
LdapPersonDTO,
_entry_to_dto,
_extract_ldap_attrs,
diff --git a/backend/employees/ldap/domain/dtos.py b/backend/employees/ldap/domain/dtos.py
index 784c238a..de3850a5 100644
--- a/backend/employees/ldap/domain/dtos.py
+++ b/backend/employees/ldap/domain/dtos.py
@@ -143,9 +143,7 @@ def _resolve_email(email: str, username: str) -> str:
return ""
-def _resolve_name(
- given: str, sn: str, display: str, email: str
-) -> Tuple[str, str]:
+def _resolve_name(given: str, sn: str, display: str, email: str) -> Tuple[str, str]:
"""Нормализует имя/фамилию с fallback на displayName или email.
Args:
diff --git a/backend/employees/ldap/infrastructure/connections.py b/backend/employees/ldap/infrastructure/connections.py
index 59e5a694..8ade60b8 100644
--- a/backend/employees/ldap/infrastructure/connections.py
+++ b/backend/employees/ldap/infrastructure/connections.py
@@ -4,6 +4,7 @@
LDAP-серверу.
"""
+import logging
import ssl
from contextlib import contextmanager
from dataclasses import dataclass
@@ -15,6 +16,8 @@ from ldap3.core.exceptions import LDAPBindError, LDAPSocketOpenError
from ..errors import DirectoryConnectionError
+logger = logging.getLogger(__name__)
+
@dataclass(frozen=True)
class LdapConfig:
@@ -138,8 +141,15 @@ def _ldap(
conn.unbind()
elif hasattr(conn, "close"):
conn.close()
- except Exception:
- pass
+ except Exception as exc:
+ logger.warning(
+ "LDAP connection cleanup failed",
+ extra={
+ "event": "ldap.connection.cleanup_failed",
+ "error_type": type(exc).__name__,
+ },
+ exc_info=True,
+ )
# Псевдоним для обратной совместимости
diff --git a/backend/employees/ldap/mixins.py b/backend/employees/ldap/mixins.py
index 435de3c4..40619b4e 100644
--- a/backend/employees/ldap/mixins.py
+++ b/backend/employees/ldap/mixins.py
@@ -269,9 +269,10 @@ class LdapSyncStateMixin:
def _update_sync_state(self):
"""Обновляет или создаёт запись LdapSyncState для этого объекта."""
- from employees.models import LdapSyncState
from django.utils import timezone
+ from employees.models import LdapSyncState
+
# Получаем Django PK из LDAP атрибута
django_pk = getattr(self, self._sync_pk_field, None)
if not django_pk:
diff --git a/backend/employees/ldap/orm_models.py b/backend/employees/ldap/orm_models.py
index 30d0048f..4e1b8701 100644
--- a/backend/employees/ldap/orm_models.py
+++ b/backend/employees/ldap/orm_models.py
@@ -479,9 +479,7 @@ class LdapGroup(ModifyDnMixin, LdapModel):
from .infrastructure.connections import _ldap
with _ldap() as conn:
- ok = conn.modify(
- self.dn, {"member": [(MODIFY_DELETE, [member_dn])]}
- )
+ ok = conn.modify(self.dn, {"member": [(MODIFY_DELETE, [member_dn])]})
if not ok:
result_str = str(conn.result)
if "noSuchAttribute" not in result_str:
@@ -501,9 +499,7 @@ class LdapGroup(ModifyDnMixin, LdapModel):
with _ldap() as conn:
if to_remove:
- conn.modify(
- self.dn, {"member": [(MODIFY_DELETE, list(to_remove))]}
- )
+ conn.modify(self.dn, {"member": [(MODIFY_DELETE, list(to_remove))]})
if to_add:
conn.modify(self.dn, {"member": [(MODIFY_ADD, list(to_add))]})
@@ -589,9 +585,10 @@ class LdapOrganizationalUnitGroup(ModifyDnMixin, LdapModel):
if member_dn in current:
return
- from .infrastructure.connections import _ldap
from ldap3 import MODIFY_ADD
+ from .infrastructure.connections import _ldap
+
with _ldap() as conn:
ok = conn.modify(self.dn, {"member": [(MODIFY_ADD, [member_dn])]})
if not ok:
@@ -608,13 +605,12 @@ class LdapOrganizationalUnitGroup(ModifyDnMixin, LdapModel):
Raises:
RuntimeError: Если операция не удалась.
"""
- from .infrastructure.connections import _ldap
from ldap3 import MODIFY_DELETE
+ from .infrastructure.connections import _ldap
+
with _ldap() as conn:
- ok = conn.modify(
- self.dn, {"member": [(MODIFY_DELETE, [member_dn])]}
- )
+ ok = conn.modify(self.dn, {"member": [(MODIFY_DELETE, [member_dn])]})
if not ok:
result_str = str(conn.result)
if "noSuchAttribute" not in result_str:
@@ -637,14 +633,13 @@ class LdapOrganizationalUnitGroup(ModifyDnMixin, LdapModel):
to_add = desired - current
to_remove = current - desired
- from .infrastructure.connections import _ldap
from ldap3 import MODIFY_ADD, MODIFY_DELETE
+ from .infrastructure.connections import _ldap
+
with _ldap() as conn:
if to_remove:
- conn.modify(
- self.dn, {"member": [(MODIFY_DELETE, list(to_remove))]}
- )
+ conn.modify(self.dn, {"member": [(MODIFY_DELETE, list(to_remove))]})
if to_add:
conn.modify(self.dn, {"member": [(MODIFY_ADD, list(to_add))]})
@@ -781,9 +776,7 @@ class LdapOrganizationalUnit(ModifyDnMixin, LdapModel):
if not ok:
res = str(conn.result)
if "entryAlreadyExists" not in res:
- raise RuntimeError(
- f"Failed to create dept group: {conn.result}"
- )
+ raise RuntimeError(f"Failed to create dept group: {conn.result}")
return LdapOrganizationalUnitGroup.objects.get(dn=group_dn)
diff --git a/backend/employees/ldap/repositories/__init__.py b/backend/employees/ldap/repositories/__init__.py
index 71ae5b71..fac3cf69 100644
--- a/backend/employees/ldap/repositories/__init__.py
+++ b/backend/employees/ldap/repositories/__init__.py
@@ -3,20 +3,20 @@
Содержит классы и функции для работы с данными (LDAP и Django ORM).
"""
+from .employee_repository import (
+ bind_user_department,
+ find_user_for_dto,
+ get_stale_employee_ids,
+ load_users_index,
+)
from .ldap_repository import LdapRepository
from .sync_state_repository import (
+ bulk_delete_for_employees,
+ delete_for_employee,
+ get_employees_with_dn,
get_or_create,
get_state,
touch,
- get_employees_with_dn,
- delete_for_employee,
- bulk_delete_for_employees,
-)
-from .employee_repository import (
- load_users_index,
- find_user_for_dto,
- bind_user_department,
- get_stale_employee_ids,
)
__all__ = [
diff --git a/backend/employees/ldap/repositories/employee_repository.py b/backend/employees/ldap/repositories/employee_repository.py
index 9c17213d..b7bd4bb9 100644
--- a/backend/employees/ldap/repositories/employee_repository.py
+++ b/backend/employees/ldap/repositories/employee_repository.py
@@ -8,7 +8,6 @@ from __future__ import annotations
from typing import Dict, Iterable, Optional, Set, Tuple
-
from employees.models import (
Department,
Employee,
@@ -38,9 +37,7 @@ def load_users_index(
by_email: Dict[str, Employee] = {}
if emails_lower:
- qs = Employee.objects.filter(email__in=list(emails_lower)).only(
- "id", "email"
- )
+ qs = Employee.objects.filter(email__in=list(emails_lower)).only("id", "email")
for u in qs:
if u.email:
by_email[u.email.lower()] = u
@@ -107,9 +104,9 @@ def bind_user_department(user: Employee, dn: str) -> None:
dept_name = extract_department_from_dn(dn)
if dept_name:
dept_obj, _ = Department.objects.get_or_create(name=dept_name)
- EmployeeDepartment.objects.filter(
- employee=user, is_active=True
- ).exclude(department=dept_obj).update(is_active=False)
+ EmployeeDepartment.objects.filter(employee=user, is_active=True).exclude(
+ department=dept_obj
+ ).update(is_active=False)
link, made = EmployeeDepartment.objects.get_or_create(
employee=user,
department=dept_obj,
@@ -148,9 +145,7 @@ def get_stale_employee_ids(
.exclude(ldap_guid="")
)
if seen_dns:
- stale_states = stale_states.exclude(ldap_dn__in=seen_dns).exclude(
- ldap_dn=""
- )
+ stale_states = stale_states.exclude(ldap_dn__in=seen_dns).exclude(ldap_dn="")
return list(stale_states.values_list("object_pk", flat=True))
diff --git a/backend/employees/ldap/repositories/ldap_repository.py b/backend/employees/ldap/repositories/ldap_repository.py
index 07b80f4f..24d1c69e 100644
--- a/backend/employees/ldap/repositories/ldap_repository.py
+++ b/backend/employees/ldap/repositories/ldap_repository.py
@@ -28,9 +28,7 @@ class LdapRepository:
"""
self.conn = conn
- def read_attrs(
- self, dn: str, attrs: List[str]
- ) -> Dict[str, Optional[str]]:
+ def read_attrs(self, dn: str, attrs: List[str]) -> Dict[str, Optional[str]]:
"""Читает значения указанных LDAP-атрибутов у записи по DN.
Args:
@@ -45,9 +43,7 @@ class LdapRepository:
"""
if not isinstance(dn, str) or not dn.strip():
raise TypeError("dn должен быть непустой строкой")
- if not isinstance(attrs, list) or not all(
- isinstance(a, str) for a in attrs
- ):
+ if not isinstance(attrs, list) or not all(isinstance(a, str) for a in attrs):
raise TypeError("attrs должен быть списком строк")
out: Dict[str, Optional[str]] = {a: None for a in attrs}
@@ -106,9 +102,7 @@ class LdapRepository:
raise TypeError("attributes must be a mapping")
if not all(isinstance(k, str) and k.strip() for k in attributes.keys()):
raise TypeError("All attribute names must be non-empty strings")
- if not all(
- (v is None) or isinstance(v, str) for v in attributes.values()
- ):
+ if not all((v is None) or isinstance(v, str) for v in attributes.values()):
raise TypeError("All attribute values must be strings or None")
# (attr1=val1) OR (attr2=val2) ...
@@ -164,9 +158,7 @@ class LdapRepository:
if not changes or not do_write:
return
if not self.conn.modify(dn, changes=changes):
- raise RuntimeError(
- f"LDAP modify failed for {dn}: {self.conn.result}"
- )
+ raise RuntimeError(f"LDAP modify failed for {dn}: {self.conn.result}")
def ensure_container_exists(self, base_dn: str) -> None:
"""Проверяет, что контейнер (OU) существует.
diff --git a/backend/employees/ldap/repositories/sync_state_repository.py b/backend/employees/ldap/repositories/sync_state_repository.py
index 354f774f..fab29eb3 100644
--- a/backend/employees/ldap/repositories/sync_state_repository.py
+++ b/backend/employees/ldap/repositories/sync_state_repository.py
@@ -15,9 +15,7 @@ from django.utils import timezone
from employees.models import Employee, LdapSyncState
-def get_or_create(
- model: str, object_pk: str
-) -> tuple[LdapSyncState, bool]:
+def get_or_create(model: str, object_pk: str) -> tuple[LdapSyncState, bool]:
"""Получить или создать запись состояния синхронизации.
Args:
@@ -27,9 +25,7 @@ def get_or_create(
Returns:
Кортеж (state, created).
"""
- return LdapSyncState.objects.get_or_create(
- model=model, object_pk=str(object_pk)
- )
+ return LdapSyncState.objects.get_or_create(model=model, object_pk=str(object_pk))
def get_state(model: str, object_pk: str) -> Optional[LdapSyncState]:
@@ -43,9 +39,7 @@ def get_state(model: str, object_pk: str) -> Optional[LdapSyncState]:
LdapSyncState или None, если не найдено.
"""
try:
- return LdapSyncState.objects.get(
- model=model, object_pk=str(object_pk)
- )
+ return LdapSyncState.objects.get(model=model, object_pk=str(object_pk))
except LdapSyncState.DoesNotExist:
return None
@@ -106,9 +100,7 @@ def delete_for_employee(emp_pk: int | str) -> None:
Args:
emp_pk: PK сотрудника.
"""
- LdapSyncState.objects.filter(
- model="employee", object_pk=str(emp_pk)
- ).delete()
+ LdapSyncState.objects.filter(model="employee", object_pk=str(emp_pk)).delete()
def bulk_delete_for_employees(emp_pks: list[int | str]) -> None:
@@ -118,9 +110,7 @@ def bulk_delete_for_employees(emp_pks: list[int | str]) -> None:
emp_pks: Список PK сотрудников.
"""
str_pks = [str(pk) for pk in emp_pks]
- LdapSyncState.objects.filter(
- model="employee", object_pk__in=str_pks
- ).delete()
+ LdapSyncState.objects.filter(model="employee", object_pk__in=str_pks).delete()
__all__ = [
diff --git a/backend/employees/ldap/services/__init__.py b/backend/employees/ldap/services/__init__.py
index e1a8f781..6e62c85a 100644
--- a/backend/employees/ldap/services/__init__.py
+++ b/backend/employees/ldap/services/__init__.py
@@ -23,33 +23,31 @@
# Базовый класс и константы
from .base_service import BaseService
from .constants import (
- UserAccountControl,
GroupType,
- LdapFilter,
LdapAttribute,
- LdapObjectClass,
LdapErrorCode,
+ LdapFilter,
+ LdapObjectClass,
SyncDirection,
+ UserAccountControl,
group_type_value,
)
# Основные сервисы (существующие - для обратной совместимости)
from .department_service import DepartmentService
-from .user_service import UserService
from .group_service import GroupService
from .position_service import PositionService
from .sync_service import SyncService
-
-# Подсервисы для UserService
-from .user_password_service import UserPasswordService
from .user_login_service import UserLoginService
from .user_mapper_service import UserMapperService
+# Подсервисы для UserService
+from .user_password_service import UserPasswordService
+from .user_service import UserService
__all__ = [
# Базовые компоненты
"BaseService",
-
# Константы
"UserAccountControl",
"GroupType",
@@ -59,14 +57,12 @@ __all__ = [
"LdapErrorCode",
"SyncDirection",
"group_type_value",
-
# Основные сервисы
"DepartmentService",
"UserService",
"GroupService",
"PositionService",
"SyncService",
-
# Подсервисы
"UserPasswordService",
"UserLoginService",
diff --git a/backend/employees/ldap/services/constants.py b/backend/employees/ldap/services/constants.py
index 64a010c9..7d31014f 100644
--- a/backend/employees/ldap/services/constants.py
+++ b/backend/employees/ldap/services/constants.py
@@ -4,8 +4,7 @@
строк и конфигурационных значений, используемых в сервисах LDAP.
"""
-from enum import IntEnum, Enum
-
+from enum import Enum, IntEnum
# ==================== UAC Constants ==================== #
@@ -109,8 +108,7 @@ class LdapFilter(str, Enum):
"(&(objectClass=group)(groupType:1.2.840.113556.1.4.803:=2147483648))"
)
DISTRIBUTION_GROUPS = (
- "(&(objectClass=group)"
- "(!(groupType:1.2.840.113556.1.4.803:=2147483648)))"
+ "(&(objectClass=group)(!(groupType:1.2.840.113556.1.4.803:=2147483648)))"
)
@@ -190,9 +188,7 @@ class LdapErrorCode(str, Enum):
class PasswordError(str, Enum):
"""Коды ошибок паролей AD."""
- COMPLEXITY_VIOLATION = (
- "0000052D" # Пароль не соответствует политике сложности
- )
+ COMPLEXITY_VIOLATION = "0000052D" # Пароль не соответствует политике сложности
PASSWORD_TOO_SHORT = "00000523" # Пароль слишком короткий
PASSWORD_IN_HISTORY = "0000052C" # Пароль уже использовался
@@ -212,9 +208,7 @@ class SyncDirection(str, Enum):
# ==================== Helper Functions ==================== #
-def group_type_value(
- scope: str = "global", security_enabled: bool = True
-) -> int:
+def group_type_value(scope: str = "global", security_enabled: bool = True) -> int:
"""Вычисляет значение groupType для создания группы.
Args:
diff --git a/backend/employees/ldap/services/department_service.py b/backend/employees/ldap/services/department_service.py
index e412c741..06e6b1ac 100644
--- a/backend/employees/ldap/services/department_service.py
+++ b/backend/employees/ldap/services/department_service.py
@@ -242,8 +242,16 @@ class DepartmentService(BaseService):
if dept and dept.ldap_group_dn:
try:
self._load_ou_group(dept.ldap_group_dn).delete()
- except Exception:
- pass
+ except Exception as error:
+ self._logger.warning(
+ "LDAP department group cleanup failed",
+ extra={
+ "event": "ldap.department.group_cleanup_failed",
+ "department_id": object_pk,
+ "error_type": type(error).__name__,
+ },
+ exc_info=True,
+ )
self._delete_department_ou(resolved_dept_dn)
@@ -295,8 +303,17 @@ class DepartmentService(BaseService):
if grp_dn:
try:
self._remove_ou_group_member(grp_dn, emp_dn)
- except Exception:
- pass
+ except Exception as error:
+ self._logger.warning(
+ "LDAP department membership removal failed",
+ extra={
+ "event": "ldap.department.membership_remove_failed",
+ "employee_id": employee.pk,
+ "department_id": department.pk,
+ "error_type": type(error).__name__,
+ },
+ exc_info=True,
+ )
target_base = get_base_dn_for_employee(employee)
emp_dn = self._move_employee_to_base_dn(
@@ -496,8 +513,16 @@ class DepartmentService(BaseService):
try:
if dept.ldap_group_dn:
self._load_ou_group(dept.ldap_group_dn).delete()
- except Exception:
- pass
+ except Exception as error:
+ self._logger.warning(
+ "LDAP department group deletion failed",
+ extra={
+ "event": "ldap.department.group_delete_failed",
+ "department_id": dept.pk,
+ "error_type": type(error).__name__,
+ },
+ exc_info=True,
+ )
if dept_dn:
try:
@@ -619,8 +644,17 @@ class DepartmentService(BaseService):
grp_dn = (dept.ldap_group_dn or "").strip()
if grp_dn and emp_dn:
self._remove_ou_group_member(grp_dn, emp_dn)
- except Exception:
- pass
+ except Exception as error:
+ self._logger.warning(
+ "LDAP member group cleanup failed",
+ extra={
+ "event": "ldap.department.member_group_cleanup_failed",
+ "employee_id": employee.pk,
+ "department_id": dept.pk,
+ "error_type": type(error).__name__,
+ },
+ exc_info=True,
+ )
if emp_dn:
try:
@@ -767,8 +801,16 @@ class DepartmentService(BaseService):
except Exception as e:
try:
self._load_ou_group(group_dn).delete()
- except Exception:
- pass
+ except Exception as cleanup_error:
+ self._logger.warning(
+ "LDAP role group rollback failed",
+ extra={
+ "event": "ldap.role.group_rollback_failed",
+ "department_id": department.pk,
+ "error_type": type(cleanup_error).__name__,
+ },
+ exc_info=True,
+ )
raise DirectoryDbError(str(e)) from e
return role
@@ -889,8 +931,16 @@ class DepartmentService(BaseService):
if role_group_dn:
try:
self._load_ou_group(role_group_dn).delete()
- except Exception:
- pass
+ except Exception as error:
+ self._logger.warning(
+ "LDAP role group deletion failed",
+ extra={
+ "event": "ldap.role.group_delete_failed",
+ "department_id": department.pk,
+ "error_type": type(error).__name__,
+ },
+ exc_info=True,
+ )
try:
dept_dn = self._get_department_dn(department)
@@ -991,8 +1041,17 @@ class DepartmentService(BaseService):
# Откатываем LDAP — удаляем из группы
try:
self._sync_role_membership(employee, role, add=False)
- except Exception:
- pass # Best effort rollback
+ except Exception as rollback_error:
+ self._logger.warning(
+ "LDAP role membership rollback failed",
+ extra={
+ "event": "ldap.role.membership_rollback_failed",
+ "employee_id": employee.pk,
+ "role_id": role.pk,
+ "error_type": type(rollback_error).__name__,
+ },
+ exc_info=True,
+ )
raise DirectoryDbError(str(e)) from e
return assignment
diff --git a/backend/employees/ldap/services/group_service.py b/backend/employees/ldap/services/group_service.py
index 109d9fc8..9b3c423b 100644
--- a/backend/employees/ldap/services/group_service.py
+++ b/backend/employees/ldap/services/group_service.py
@@ -19,15 +19,15 @@ from django.core.cache import cache
from django.utils import timezone
from ldap3 import SUBTREE, Connection
-from .base_service import BaseService
-from .constants import (
- LdapFilter,
- LdapAttribute,
-)
from ..infrastructure.connections import _ldap
from ..orm_models import LdapGroup, LdapUser
-from ..utils.text_utils import esc_rdn
from ..utils.ldap_utils import get_guid_str
+from ..utils.text_utils import esc_rdn
+from .base_service import BaseService
+from .constants import (
+ LdapAttribute,
+ LdapFilter,
+)
class GroupService(BaseService):
diff --git a/backend/employees/ldap/services/position_service.py b/backend/employees/ldap/services/position_service.py
index 9e555d30..9d616f08 100644
--- a/backend/employees/ldap/services/position_service.py
+++ b/backend/employees/ldap/services/position_service.py
@@ -16,12 +16,12 @@ from typing import Set
from django.conf import settings
from ldap3 import BASE, SUBTREE, Connection
-from employees.models import Employee, Position, LdapSyncState
+from employees.models import Employee, LdapSyncState, Position
from ..errors import DirectoryServiceError
from ..repositories.ldap_repository import ensure_container_exists
-from ..utils.text_utils import esc_filter, esc_rdn
from ..utils.ldap_utils import group_type
+from ..utils.text_utils import esc_filter, esc_rdn
from .base_service import BaseService
@@ -123,8 +123,17 @@ class PositionService(BaseService):
# вложения можно обновлять best-effort, не обязательно каждый раз
try:
self._reconcile_position_nesting(conn, position)
- except Exception:
- pass
+ except Exception as error:
+ self._logger.warning(
+ "LDAP position nesting reconciliation failed",
+ extra={
+ "event": "ldap.position.nesting_reconcile_failed",
+ "employee_id": employee.pk,
+ "position_id": position.pk,
+ "error_type": type(error).__name__,
+ },
+ exc_info=True,
+ )
def unassign_position(self, employee: Employee, position: Position) -> None:
"""
diff --git a/backend/employees/ldap/services/sync_service.py b/backend/employees/ldap/services/sync_service.py
index d969fd12..34af6134 100644
--- a/backend/employees/ldap/services/sync_service.py
+++ b/backend/employees/ldap/services/sync_service.py
@@ -22,17 +22,17 @@ from django.utils import timezone
from ...models import Department, Employee, LdapSyncState
from ..config import SyncConfig
-from ..infrastructure.connections import _ldap
from ..domain.dtos import (
LdapPersonDTO,
_entry_to_dto,
)
+from ..infrastructure.connections import _ldap
from ..utils.ldap_utils import _paged_search, get_attr_str
from .base_service import BaseService
from .constants import SyncDirection
-from .user_service import UserService
from .department_service import DepartmentService
from .group_service import GroupService
+from .user_service import UserService
logger = logging.getLogger(__name__)
@@ -367,10 +367,10 @@ class SyncService(BaseService):
# Индексируем существующих пользователей
from ..repositories.employee_repository import (
- _load_existing_users_index,
- _find_user_for_dto,
_bind_user_department,
_cleanup_absent_users,
+ _find_user_for_dto,
+ _load_existing_users_index,
)
from ..repositories.sync_state_repository import _touch_sync_state
diff --git a/backend/employees/ldap/services/user_login_service.py b/backend/employees/ldap/services/user_login_service.py
index 14b66aa2..98e84c17 100644
--- a/backend/employees/ldap/services/user_login_service.py
+++ b/backend/employees/ldap/services/user_login_service.py
@@ -11,12 +11,12 @@
import logging
from typing import Optional, Tuple
+
from django.conf import settings
-from .base_service import BaseService
from ..orm_models import LdapUser
from ..utils.ldap_utils import build_logins_for_user
-
+from .base_service import BaseService
logger = logging.getLogger(__name__)
diff --git a/backend/employees/ldap/services/user_mapper_service.py b/backend/employees/ldap/services/user_mapper_service.py
index 0af2d27f..508c5600 100644
--- a/backend/employees/ldap/services/user_mapper_service.py
+++ b/backend/employees/ldap/services/user_mapper_service.py
@@ -9,14 +9,14 @@
import logging
from typing import Any, Dict, List, Optional
+
from django.conf import settings
-from .base_service import BaseService
-from .constants import UserAccountControl, LdapObjectClass
from ..domain.dtos import DirectoryUserDTO
from ..orm_models import LdapUser
from ..utils.image_utils import normalize_avatar_to_jpeg
-
+from .base_service import BaseService
+from .constants import LdapObjectClass, UserAccountControl
logger = logging.getLogger(__name__)
diff --git a/backend/employees/ldap/services/user_password_service.py b/backend/employees/ldap/services/user_password_service.py
index 02903d8d..c17053aa 100644
--- a/backend/employees/ldap/services/user_password_service.py
+++ b/backend/employees/ldap/services/user_password_service.py
@@ -9,12 +9,12 @@
import logging
from typing import Optional
+
from ldap3 import Connection
+from ..errors import DirectoryLdapError
from .base_service import BaseService
from .constants import PasswordError
-from ..errors import DirectoryLdapError
-
logger = logging.getLogger(__name__)
diff --git a/backend/employees/ldap/services/user_service.py b/backend/employees/ldap/services/user_service.py
index d0a3bbb2..e1e89f31 100644
--- a/backend/employees/ldap/services/user_service.py
+++ b/backend/employees/ldap/services/user_service.py
@@ -26,23 +26,23 @@ from ..errors import (
DirectoryLdapError,
DirectoryServiceError,
)
-from ..orm_models import LdapUser
-from ..utils.group_utils_orm import sync_user_groups_by_cns_orm
from ..infrastructure.connections import _ldap
+from ..orm_models import LdapUser
from ..repositories.ldap_repository import (
ensure_container_exists,
)
+from ..utils.group_utils_orm import sync_user_groups_by_cns_orm
from ..utils.ldap_utils import cn_candidates, get_ldap_str
from ..utils.text_utils import esc_rdn
from .base_service import BaseService
from .constants import (
- UserAccountControl,
LdapErrorCode,
SyncDirection,
+ UserAccountControl,
)
-from .user_password_service import UserPasswordService
from .user_login_service import UserLoginService
from .user_mapper_service import UserMapperService
+from .user_password_service import UserPasswordService
logger = logging.getLogger(__name__)
@@ -577,8 +577,8 @@ class UserService(BaseService):
обновление пользователя.
"""
from ...models import Position
- from .position_service import PositionService
from .group_service import GroupService
+ from .position_service import PositionService
# Определяем новую должность
if new_position is None and new_position_id is not None:
diff --git a/backend/employees/ldap/tests/conftest.py b/backend/employees/ldap/tests/conftest.py
index b2f0b8ef..cf2bfd90 100644
--- a/backend/employees/ldap/tests/conftest.py
+++ b/backend/employees/ldap/tests/conftest.py
@@ -3,22 +3,22 @@ Pytest конфигурация и фикстуры для тестирован
"""
import os
+from unittest.mock import Mock, patch
import pytest
-from unittest.mock import Mock, patch
from django.contrib.auth.models import Group as DjangoGroup
-from employees.models import Employee, Department, Position, LdapSyncState
-from employees.ldap.domain.dtos import DirectoryUserDTO, DirectoryDepartmentDTO
-
+from employees.ldap.domain.dtos import DirectoryDepartmentDTO, DirectoryUserDTO
+from employees.models import Department, Employee, LdapSyncState, Position
# ==================== Mock LDAP Connection ====================
+
@pytest.fixture
def mock_ldap_connection():
"""Mock LDAP соединения для изоляции от реального AD."""
conn = Mock()
- conn.result = {'description': 'success'}
+ conn.result = {"description": "success"}
conn.entries = []
# Mock базовых операций
@@ -40,11 +40,11 @@ def mock_ldap_connection():
def mock_ldap_context(mock_ldap_connection):
"""Mock контекстного менеджера _ldap() во всех сервисах."""
targets = [
- 'employees.ldap.infrastructure.connections._ldap',
- 'employees.ldap.services.group_service._ldap',
- 'employees.ldap.services.department_service._ldap',
- 'employees.ldap.services.user_service._ldap',
- 'employees.ldap.services.position_service._ldap',
+ "employees.ldap.infrastructure.connections._ldap",
+ "employees.ldap.services.group_service._ldap",
+ "employees.ldap.services.department_service._ldap",
+ "employees.ldap.services.user_service._ldap",
+ "employees.ldap.services.position_service._ldap",
]
patches = []
for target in targets:
@@ -63,6 +63,7 @@ def mock_ldap_context(mock_ldap_connection):
# ==================== Django Models Fixtures ====================
+
@pytest.fixture
def sample_employee(db):
"""Создает тестового сотрудника."""
@@ -70,31 +71,28 @@ def sample_employee(db):
first_name="Иван",
last_name="Иванов",
email="ivanov@example.com",
- is_active=True
+ is_active=True,
)
@pytest.fixture
def sample_department(db):
"""Создает тестовый отдел."""
- return Department.objects.create(
- name="IT Отдел",
- description="IT Department"
- )
+ return Department.objects.create(name="IT Отдел", description="IT Department")
@pytest.fixture
def sample_position(db):
"""Создает тестовую должность (без LDAP синхронизации)."""
- from employees.signals.ldap.position import sync_position_to_ldap_on_save
from django.db.models.signals import post_save
+ from employees.signals.ldap.position import sync_position_to_ldap_on_save
+
# Отключаем LDAP синхронизацию при создании Position
post_save.disconnect(sync_position_to_ldap_on_save, sender=Position)
try:
pos = Position.objects.create(
- name="Разработчик",
- description="Backend разработчик"
+ name="Разработчик", description="Backend разработчик"
)
finally:
post_save.connect(sync_position_to_ldap_on_save, sender=Position)
@@ -111,14 +109,15 @@ def sample_django_group(db):
def sample_ldap_sync_state(db, sample_employee):
"""Создает тестовую запись синхронизации."""
return LdapSyncState.objects.create(
- model='employee',
+ model="employee",
object_pk=str(sample_employee.id),
- ldap_dn='CN=Иван Иванов,OU=Users,DC=example,DC=com'
+ ldap_dn="CN=Иван Иванов,OU=Users,DC=example,DC=com",
)
# ==================== DTO Fixtures ====================
+
@pytest.fixture
def sample_user_dto():
"""Создает тестовый DTO для пользователя."""
@@ -131,7 +130,7 @@ def sample_user_dto():
group_cns=["Developers", "Users"],
initial_password="Test123456!",
avatar_bytes=None,
- is_active=True
+ is_active=True,
)
@@ -139,19 +138,18 @@ def sample_user_dto():
def sample_department_dto():
"""Создает тестовый DTO для отдела."""
return DirectoryDepartmentDTO(
- name="IT Отдел",
- description="IT Department",
- head=None
+ name="IT Отдел", description="IT Department", head=None
)
# ==================== Mock Repositories ====================
+
@pytest.fixture
def mock_ldap_repository():
"""Mock LdapRepository."""
repo = Mock()
- repo.read_attrs = Mock(return_value={'cn': ['Test User']})
+ repo.read_attrs = Mock(return_value={"cn": ["Test User"]})
repo.is_taken = Mock(return_value=False)
repo.modify_attrs = Mock(return_value=True)
repo.ensure_container_exists = Mock(return_value=True)
@@ -183,6 +181,7 @@ def mock_sync_state_repo_functions():
# ==================== Mock Services ====================
+
@pytest.fixture
def mock_user_service():
"""Mock UserService для тестирования других сервисов."""
@@ -191,10 +190,10 @@ def mock_user_service():
service.update_user = Mock()
service.delete_user = Mock()
service._get_employee_dn = Mock(
- return_value='CN=Test User,OU=Users,DC=example,DC=com'
+ return_value="CN=Test User,OU=Users,DC=example,DC=com"
)
service._move_user_to_base = Mock(
- return_value='CN=Test User,OU=NewBase,DC=example,DC=com'
+ return_value="CN=Test User,OU=NewBase,DC=example,DC=com"
)
return service
@@ -203,7 +202,7 @@ def mock_user_service():
def mock_group_service():
"""Mock GroupService для тестирования других сервисов."""
service = Mock()
- dn = 'CN=Test Group,OU=Groups,DC=example,DC=com'
+ dn = "CN=Test Group,OU=Groups,DC=example,DC=com"
service.create = Mock(return_value=dn)
service.delete = Mock()
service.rename = Mock()
@@ -228,7 +227,7 @@ def mock_department_service():
service.remove_member = Mock()
service.set_head = Mock()
service._get_department_dn = Mock(
- return_value='OU=Test Department,DC=example,DC=com'
+ return_value="OU=Test Department,DC=example,DC=com"
)
return service
@@ -246,45 +245,45 @@ def mock_position_service():
# ==================== LDAP Entry Mocks ====================
+
@pytest.fixture
def mock_ldap_entry():
"""Mock LDAP entry объекта."""
entry = Mock()
- entry.entry_dn = 'CN=Test User,OU=Users,DC=example,DC=com'
+ entry.entry_dn = "CN=Test User,OU=Users,DC=example,DC=com"
entry.cn = Mock()
- entry.cn.value = 'Test User'
+ entry.cn.value = "Test User"
entry.mail = Mock()
- entry.mail.value = 'test@example.com'
+ entry.mail.value = "test@example.com"
entry.objectGUID = Mock()
# Mock GUID (16 bytes)
- guid_bytes = (
- b'\x01\x02\x03\x04\x05\x06\x07\x08'
- b'\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10'
- )
+ guid_bytes = b"\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10"
entry.objectGUID.value = guid_bytes
return entry
# ==================== Settings Overrides ====================
+
@pytest.fixture
def ldap_test_settings(settings):
"""Тестовые настройки LDAP."""
- settings.LDAP_HOST = 'ldaps://test.example.com:636'
- settings.LDAP_BASE_DN = 'DC=example,DC=com'
- settings.LDAP_BIND_DN = 'CN=TestUser,DC=example,DC=com'
+ settings.LDAP_HOST = "ldaps://test.example.com:636"
+ settings.LDAP_BASE_DN = "DC=example,DC=com"
+ settings.LDAP_BIND_DN = "CN=TestUser,DC=example,DC=com"
settings.LDAP_BIND_PASSWORD = os.getenv(
- 'LDAP_BIND_PASSWORD',
- 'test-ldap-bind-password',
+ "LDAP_BIND_PASSWORD",
+ "test-ldap-bind-password",
)
- settings.LDAP_GROUPS_BASE = 'OU=Groups,DC=example,DC=com'
- settings.LDAP_POSITIONS_BASE = 'OU=Positions,DC=example,DC=com'
- settings.LDAP_UPN_SUFFIX = '@example.com'
+ settings.LDAP_GROUPS_BASE = "OU=Groups,DC=example,DC=com"
+ settings.LDAP_POSITIONS_BASE = "OU=Positions,DC=example,DC=com"
+ settings.LDAP_UPN_SUFFIX = "@example.com"
return settings
# ==================== Helper Functions ====================
+
def create_mock_ldap_entry(dn, attributes=None):
"""
Создает mock LDAP entry с заданными атрибутами.
diff --git a/backend/employees/ldap/tests/fixtures/sample_data.py b/backend/employees/ldap/tests/fixtures/sample_data.py
index 7e56564a..693d379c 100644
--- a/backend/employees/ldap/tests/fixtures/sample_data.py
+++ b/backend/employees/ldap/tests/fixtures/sample_data.py
@@ -5,8 +5,7 @@
в тестах вместо создания их каждый раз заново.
"""
-from employees.ldap.domain.dtos import DirectoryUserDTO, DirectoryDepartmentDTO
-
+from employees.ldap.domain.dtos import DirectoryDepartmentDTO, DirectoryUserDTO
# ==================== Тестовые пользователи ====================
@@ -19,7 +18,7 @@ SAMPLE_USERS = [
phone="+79001234567",
password="TestPass123!",
avatar=None,
- groups=["Developers", "Users"]
+ groups=["Developers", "Users"],
),
DirectoryUserDTO(
first_name="Мария",
@@ -29,7 +28,7 @@ SAMPLE_USERS = [
phone="+79007654321",
password="TestPass456!",
avatar=None,
- groups=["HR", "Users"]
+ groups=["HR", "Users"],
),
DirectoryUserDTO(
first_name="Алексей",
@@ -39,7 +38,7 @@ SAMPLE_USERS = [
phone="+79009876543",
password="TestPass789!",
avatar=None,
- groups=["Management", "Users"]
+ groups=["Management", "Users"],
),
]
@@ -47,20 +46,12 @@ SAMPLE_USERS = [
# ==================== Тестовые отделы ====================
SAMPLE_DEPARTMENTS = [
- DirectoryDepartmentDTO(
- name="IT Отдел",
- parent=None,
- head=None
- ),
- DirectoryDepartmentDTO(
- name="HR Отдел",
- parent=None,
- head=None
- ),
+ DirectoryDepartmentDTO(name="IT Отдел", parent=None, head=None),
+ DirectoryDepartmentDTO(name="HR Отдел", parent=None, head=None),
DirectoryDepartmentDTO(
name="Отдел разработки",
parent=None, # Будет установлен в тестах как IT Отдел
- head=None
+ head=None,
),
]
@@ -68,26 +59,26 @@ SAMPLE_DEPARTMENTS = [
# ==================== Тестовые LDAP DN ====================
SAMPLE_DNS = {
- 'users': [
- 'CN=Иван Иванов,OU=Users,DC=example,DC=com',
- 'CN=Мария Петрова,OU=Users,DC=example,DC=com',
- 'CN=Алексей Сидоров,OU=Users,DC=example,DC=com',
+ "users": [
+ "CN=Иван Иванов,OU=Users,DC=example,DC=com",
+ "CN=Мария Петрова,OU=Users,DC=example,DC=com",
+ "CN=Алексей Сидоров,OU=Users,DC=example,DC=com",
],
- 'departments': [
- 'OU=IT,DC=example,DC=com',
- 'OU=HR,DC=example,DC=com',
- 'OU=Development,OU=IT,DC=example,DC=com',
+ "departments": [
+ "OU=IT,DC=example,DC=com",
+ "OU=HR,DC=example,DC=com",
+ "OU=Development,OU=IT,DC=example,DC=com",
],
- 'groups': [
- 'CN=Developers,OU=Groups,DC=example,DC=com',
- 'CN=HR,OU=Groups,DC=example,DC=com',
- 'CN=Management,OU=Groups,DC=example,DC=com',
- 'CN=Users,OU=Groups,DC=example,DC=com',
+ "groups": [
+ "CN=Developers,OU=Groups,DC=example,DC=com",
+ "CN=HR,OU=Groups,DC=example,DC=com",
+ "CN=Management,OU=Groups,DC=example,DC=com",
+ "CN=Users,OU=Groups,DC=example,DC=com",
],
- 'positions': [
- 'CN=POS_1,OU=Positions,DC=example,DC=com',
- 'CN=POS_2,OU=Positions,DC=example,DC=com',
- 'CN=POS_3,OU=Positions,DC=example,DC=com',
+ "positions": [
+ "CN=POS_1,OU=Positions,DC=example,DC=com",
+ "CN=POS_2,OU=Positions,DC=example,DC=com",
+ "CN=POS_3,OU=Positions,DC=example,DC=com",
],
}
@@ -95,34 +86,34 @@ SAMPLE_DNS = {
# ==================== Тестовые LDAP атрибуты ====================
SAMPLE_LDAP_ATTRIBUTES = {
- 'user': {
- 'cn': 'Иван Иванов',
- 'sAMAccountName': 'ivanov',
- 'userPrincipalName': 'ivanov@example.com',
- 'givenName': 'Иван',
- 'sn': 'Иванов',
- 'displayName': 'Иван Петрович Иванов',
- 'mail': 'ivanov@example.com',
- 'telephoneNumber': '+79001234567',
- 'userAccountControl': 512, # Normal account
- 'objectClass': ['top', 'person', 'organizationalPerson', 'user'],
+ "user": {
+ "cn": "Иван Иванов",
+ "sAMAccountName": "ivanov",
+ "userPrincipalName": "ivanov@example.com",
+ "givenName": "Иван",
+ "sn": "Иванов",
+ "displayName": "Иван Петрович Иванов",
+ "mail": "ivanov@example.com",
+ "telephoneNumber": "+79001234567",
+ "userAccountControl": 512, # Normal account
+ "objectClass": ["top", "person", "organizationalPerson", "user"],
},
- 'group': {
- 'cn': 'Developers',
- 'sAMAccountName': 'Developers',
- 'description': 'Development team',
- 'member': [
- 'CN=Иван Иванов,OU=Users,DC=example,DC=com',
- 'CN=Мария Петрова,OU=Users,DC=example,DC=com',
+ "group": {
+ "cn": "Developers",
+ "sAMAccountName": "Developers",
+ "description": "Development team",
+ "member": [
+ "CN=Иван Иванов,OU=Users,DC=example,DC=com",
+ "CN=Мария Петрова,OU=Users,DC=example,DC=com",
],
- 'objectClass': ['top', 'group'],
- 'groupType': -2147483646, # Global security group
+ "objectClass": ["top", "group"],
+ "groupType": -2147483646, # Global security group
},
- 'ou': {
- 'ou': 'IT',
- 'name': 'IT',
- 'description': 'IT Department',
- 'objectClass': ['top', 'organizationalUnit'],
+ "ou": {
+ "ou": "IT",
+ "name": "IT",
+ "description": "IT Department",
+ "objectClass": ["top", "organizationalUnit"],
},
}
@@ -130,47 +121,40 @@ SAMPLE_LDAP_ATTRIBUTES = {
# ==================== Тестовые LDAP фильтры ====================
SAMPLE_LDAP_FILTERS = {
- 'all_users': '(&(objectClass=user)(objectCategory=person))',
- 'active_users': (
- '(&(objectClass=user)(objectCategory=person)'
- '(!(userAccountControl:1.2.840.113556.1.4.803:=2)))'
+ "all_users": "(&(objectClass=user)(objectCategory=person))",
+ "active_users": (
+ "(&(objectClass=user)(objectCategory=person)"
+ "(!(userAccountControl:1.2.840.113556.1.4.803:=2)))"
),
- 'all_groups': '(objectClass=group)',
- 'group_by_name': '(&(objectClass=group)(cn={name}))',
- 'user_by_login': (
- '(&(objectClass=user)(objectCategory=person)'
- '(sAMAccountName={login}))'
- ),
- 'groups_with_member': (
- '(&(objectClass=group)(member={dn}))'
+ "all_groups": "(objectClass=group)",
+ "group_by_name": "(&(objectClass=group)(cn={name}))",
+ "user_by_login": (
+ "(&(objectClass=user)(objectCategory=person)(sAMAccountName={login}))"
),
+ "groups_with_member": ("(&(objectClass=group)(member={dn}))"),
}
# ==================== Тестовые настройки ====================
TEST_LDAP_SETTINGS = {
- 'LDAP_HOST': 'ldaps://test.example.com:636',
- 'LDAP_BASE_DN': 'DC=example,DC=com',
- 'LDAP_BIND_DN': 'CN=ServiceAccount,DC=example,DC=com',
- 'LDAP_BIND_PASSWORD': 'TestPassword123!',
- 'LDAP_USERS_BASE': 'OU=Users,DC=example,DC=com',
- 'LDAP_GROUPS_BASE': 'OU=Groups,DC=example,DC=com',
- 'LDAP_POSITIONS_BASE': 'OU=Positions,DC=example,DC=com',
- 'LDAP_UPN_SUFFIX': '@example.com',
- 'LDAP_CONNECT_TIMEOUT': 5,
- 'LDAP_RECEIVE_TIMEOUT': 10,
+ "LDAP_HOST": "ldaps://test.example.com:636",
+ "LDAP_BASE_DN": "DC=example,DC=com",
+ "LDAP_BIND_DN": "CN=ServiceAccount,DC=example,DC=com",
+ "LDAP_BIND_PASSWORD": "TestPassword123!",
+ "LDAP_USERS_BASE": "OU=Users,DC=example,DC=com",
+ "LDAP_GROUPS_BASE": "OU=Groups,DC=example,DC=com",
+ "LDAP_POSITIONS_BASE": "OU=Positions,DC=example,DC=com",
+ "LDAP_UPN_SUFFIX": "@example.com",
+ "LDAP_CONNECT_TIMEOUT": 5,
+ "LDAP_RECEIVE_TIMEOUT": 10,
}
# ==================== Вспомогательные функции ====================
-def create_test_user_dto(
- first_name="Test",
- last_name="User",
- email=None,
- **kwargs
-):
+
+def create_test_user_dto(first_name="Test", last_name="User", email=None, **kwargs):
"""
Создает тестовый DirectoryUserDTO с настраиваемыми параметрами.
@@ -190,19 +174,15 @@ def create_test_user_dto(
first_name=first_name,
last_name=last_name,
email=email,
- password=kwargs.get('password', 'TestPass123!'),
- patronymic=kwargs.get('patronymic', 'Тестович'),
- phone=kwargs.get('phone', '+79001234567'),
- avatar=kwargs.get('avatar'),
- groups=kwargs.get('groups', ['Users'])
+ password=kwargs.get("password", "TestPass123!"),
+ patronymic=kwargs.get("patronymic", "Тестович"),
+ phone=kwargs.get("phone", "+79001234567"),
+ avatar=kwargs.get("avatar"),
+ groups=kwargs.get("groups", ["Users"]),
)
-def create_test_department_dto(
- name="Test Department",
- short_name=None,
- **kwargs
-):
+def create_test_department_dto(name="Test Department", short_name=None, **kwargs):
"""
Создает тестовый DirectoryDepartmentDTO с настраиваемыми параметрами.
@@ -215,12 +195,10 @@ def create_test_department_dto(
DirectoryDepartmentDTO
"""
if short_name is None:
- short_name = ''.join([w[0] for w in name.split()])
+ short_name = "".join([w[0] for w in name.split()])
return DirectoryDepartmentDTO(
- name=name,
- parent=kwargs.get('parent'),
- head=kwargs.get('head')
+ name=name, parent=kwargs.get("parent"), head=kwargs.get("head")
)
@@ -249,7 +227,7 @@ def create_test_ldap_entry(dn, attributes=None):
return entry
-def generate_test_dns(count=10, ou='Users'):
+def generate_test_dns(count=10, ou="Users"):
"""
Генерирует список тестовых DN.
@@ -260,10 +238,7 @@ def generate_test_dns(count=10, ou='Users'):
Returns:
List[str]: Список DN
"""
- return [
- f'CN=User{i},OU={ou},DC=example,DC=com'
- for i in range(1, count + 1)
- ]
+ return [f"CN=User{i},OU={ou},DC=example,DC=com" for i in range(1, count + 1)]
def get_sample_user_attributes(index=0):
@@ -278,31 +253,31 @@ def get_sample_user_attributes(index=0):
"""
users = [
{
- 'cn': 'Иван Иванов',
- 'sAMAccountName': 'ivanov',
- 'userPrincipalName': 'ivanov@example.com',
- 'givenName': 'Иван',
- 'sn': 'Иванов',
- 'mail': 'ivanov@example.com',
- 'telephoneNumber': '+79001234567',
+ "cn": "Иван Иванов",
+ "sAMAccountName": "ivanov",
+ "userPrincipalName": "ivanov@example.com",
+ "givenName": "Иван",
+ "sn": "Иванов",
+ "mail": "ivanov@example.com",
+ "telephoneNumber": "+79001234567",
},
{
- 'cn': 'Мария Петрова',
- 'sAMAccountName': 'petrova',
- 'userPrincipalName': 'petrova@example.com',
- 'givenName': 'Мария',
- 'sn': 'Петрова',
- 'mail': 'petrova@example.com',
- 'telephoneNumber': '+79007654321',
+ "cn": "Мария Петрова",
+ "sAMAccountName": "petrova",
+ "userPrincipalName": "petrova@example.com",
+ "givenName": "Мария",
+ "sn": "Петрова",
+ "mail": "petrova@example.com",
+ "telephoneNumber": "+79007654321",
},
{
- 'cn': 'Алексей Сидоров',
- 'sAMAccountName': 'sidorov',
- 'userPrincipalName': 'sidorov@example.com',
- 'givenName': 'Алексей',
- 'sn': 'Сидоров',
- 'mail': 'sidorov@example.com',
- 'telephoneNumber': '+79009876543',
+ "cn": "Алексей Сидоров",
+ "sAMAccountName": "sidorov",
+ "userPrincipalName": "sidorov@example.com",
+ "givenName": "Алексей",
+ "sn": "Сидоров",
+ "mail": "sidorov@example.com",
+ "telephoneNumber": "+79009876543",
},
]
diff --git a/backend/employees/ldap/tests/test_ldap_connection.py b/backend/employees/ldap/tests/test_ldap_connection.py
index c97f2ba1..375fb652 100644
--- a/backend/employees/ldap/tests/test_ldap_connection.py
+++ b/backend/employees/ldap/tests/test_ldap_connection.py
@@ -3,6 +3,7 @@
"""
import pytest
+
from employees.ldap.infrastructure.connections import _ldap
@@ -13,12 +14,12 @@ def test_ldap_connection():
with _ldap() as conn:
# Пробуем простой поиск базового DN
conn.search(
- search_base='DC=robotail,DC=local',
- search_filter='(objectClass=*)',
- search_scope='BASE',
- attributes=['*']
+ search_base="DC=robotail,DC=local",
+ search_filter="(objectClass=*)",
+ search_scope="BASE",
+ attributes=["*"],
)
- assert conn.result['description'] == 'success'
+ assert conn.result["description"] == "success"
print("\n✓ LDAP подключение успешно")
print(f"✓ Сервер: {conn.server}")
print(f"✓ Пользователь: {conn.user}")
@@ -26,5 +27,5 @@ def test_ldap_connection():
pytest.fail(f"Не удалось подключиться к LDAP: {e}")
-if __name__ == '__main__':
+if __name__ == "__main__":
test_ldap_connection()
diff --git a/backend/employees/ldap/tests/unit/test_department_service.py b/backend/employees/ldap/tests/unit/test_department_service.py
index 8dab01d6..d5a0e401 100644
--- a/backend/employees/ldap/tests/unit/test_department_service.py
+++ b/backend/employees/ldap/tests/unit/test_department_service.py
@@ -2,9 +2,10 @@
Unit тесты для DepartmentService.
"""
-import pytest
from unittest.mock import Mock, patch
+import pytest
+
from employees.ldap.services.department_service import DepartmentService
diff --git a/backend/employees/ldap/tests/unit/test_group_service.py b/backend/employees/ldap/tests/unit/test_group_service.py
index 192de920..828cbc30 100644
--- a/backend/employees/ldap/tests/unit/test_group_service.py
+++ b/backend/employees/ldap/tests/unit/test_group_service.py
@@ -2,9 +2,10 @@
Unit тесты для GroupService.
"""
-import pytest
from unittest.mock import Mock, patch
+import pytest
+
from employees.ldap.services.group_service import GroupService
@@ -13,10 +14,7 @@ class TestGroupServiceCreate:
@pytest.mark.django_db
def test_create_group_success(
- self,
- mock_ldap_context,
- mock_ldap_connection,
- mock_ldap_repository
+ self, mock_ldap_context, mock_ldap_connection, mock_ldap_repository
):
"""Тест успешного создания группы."""
# Arrange
@@ -26,7 +24,7 @@ class TestGroupServiceCreate:
# Act — ORM создаёт через LdapGroup.objects.create()
# mock_ldap_context патчит _ldap() для внутренних вызовов
- with patch('employees.ldap.services.group_service.LdapGroup'):
+ with patch("employees.ldap.services.group_service.LdapGroup"):
dn = service.create(group_name, parent_dn=base_dn)
# Assert
@@ -35,10 +33,7 @@ class TestGroupServiceCreate:
@pytest.mark.django_db
def test_create_group_with_description(
- self,
- mock_ldap_context,
- mock_ldap_connection,
- mock_ldap_repository
+ self, mock_ldap_context, mock_ldap_connection, mock_ldap_repository
):
"""Тест создания группы с описанием."""
# Arrange
@@ -48,12 +43,8 @@ class TestGroupServiceCreate:
description = "Development team"
# Act — ORM создаёт через LdapGroup.objects.create()
- with patch('employees.ldap.services.group_service.LdapGroup') as MockLdapGroup:
- service.create(
- group_name,
- parent_dn=base_dn,
- description=description
- )
+ with patch("employees.ldap.services.group_service.LdapGroup") as MockLdapGroup:
+ service.create(group_name, parent_dn=base_dn, description=description)
# Assert
MockLdapGroup.objects.create.assert_called_once()
@@ -64,10 +55,7 @@ class TestGroupServiceDelete:
@pytest.mark.django_db
def test_delete_group(
- self,
- mock_ldap_context,
- mock_ldap_connection,
- mock_ldap_repository
+ self, mock_ldap_context, mock_ldap_connection, mock_ldap_repository
):
"""Тест удаления группы через ORM."""
# Arrange
@@ -75,9 +63,7 @@ class TestGroupServiceDelete:
group_dn = "CN=Test Group,OU=Groups,DC=example,DC=com"
mock_group = Mock()
- with patch(
- 'employees.ldap.services.group_service.LdapGroup'
- ) as MockLdapGroup:
+ with patch("employees.ldap.services.group_service.LdapGroup") as MockLdapGroup:
MockLdapGroup.objects.get.return_value = mock_group
# Act
@@ -93,10 +79,7 @@ class TestGroupServiceModify:
@pytest.mark.django_db
def test_rename_group(
- self,
- mock_ldap_context,
- mock_ldap_connection,
- mock_ldap_repository
+ self, mock_ldap_context, mock_ldap_connection, mock_ldap_repository
):
"""Тест переименования группы."""
# Arrange
@@ -113,10 +96,7 @@ class TestGroupServiceModify:
@pytest.mark.django_db
def test_set_description(
- self,
- mock_ldap_context,
- mock_ldap_connection,
- mock_ldap_repository
+ self, mock_ldap_context, mock_ldap_connection, mock_ldap_repository
):
"""Тест изменения описания группы."""
# Arrange
@@ -125,13 +105,10 @@ class TestGroupServiceModify:
description = "Updated description"
# Act
- with patch('employees.ldap.services.group_service.LdapGroup') as MockLdapGroup:
+ with patch("employees.ldap.services.group_service.LdapGroup") as MockLdapGroup:
mock_group = Mock()
MockLdapGroup.objects.get.return_value = mock_group
- service.set_description(
- group_dn,
- description
- )
+ service.set_description(group_dn, description)
# Assert
mock_group.save.assert_called_once()
@@ -142,10 +119,7 @@ class TestGroupServiceMembers:
@pytest.mark.django_db
def test_add_members(
- self,
- mock_ldap_context,
- mock_ldap_connection,
- mock_ldap_repository
+ self, mock_ldap_context, mock_ldap_connection, mock_ldap_repository
):
"""Тест добавления членов в группу."""
# Arrange
@@ -153,7 +127,7 @@ class TestGroupServiceMembers:
group_dn = "CN=Test,OU=Groups,DC=example,DC=com"
member_dns = [
"CN=User1,OU=Users,DC=example,DC=com",
- "CN=User2,OU=Users,DC=example,DC=com"
+ "CN=User2,OU=Users,DC=example,DC=com",
]
# Mock ORM
@@ -169,9 +143,7 @@ class TestGroupServiceMembers:
mock_group.list_members.side_effect = list_members
mock_group.add_member.side_effect = add_member
- with patch(
- 'employees.ldap.services.group_service.LdapGroup'
- ) as MockLdapGroup:
+ with patch("employees.ldap.services.group_service.LdapGroup") as MockLdapGroup:
MockLdapGroup.objects.get.return_value = mock_group
# Act
@@ -185,10 +157,7 @@ class TestGroupServiceMembers:
@pytest.mark.django_db
def test_remove_members(
- self,
- mock_ldap_context,
- mock_ldap_connection,
- mock_ldap_repository
+ self, mock_ldap_context, mock_ldap_connection, mock_ldap_repository
):
"""Тест удаления членов из группы."""
# Arrange
@@ -209,9 +178,7 @@ class TestGroupServiceMembers:
mock_group.list_members.side_effect = list_members
mock_group.remove_member.side_effect = remove_member
- with patch(
- 'employees.ldap.services.group_service.LdapGroup'
- ) as MockLdapGroup:
+ with patch("employees.ldap.services.group_service.LdapGroup") as MockLdapGroup:
MockLdapGroup.objects.get.return_value = mock_group
# Act
@@ -223,10 +190,7 @@ class TestGroupServiceMembers:
@pytest.mark.django_db
def test_replace_members(
- self,
- mock_ldap_context,
- mock_ldap_connection,
- mock_ldap_repository
+ self, mock_ldap_context, mock_ldap_connection, mock_ldap_repository
):
"""Тест замены всех членов группы."""
# Arrange
@@ -236,15 +200,13 @@ class TestGroupServiceMembers:
# Mock ORM — группа с текущими участниками
mock_group = Mock()
- old_members = ['CN=OldUser,OU=Users,DC=example,DC=com']
+ old_members = ["CN=OldUser,OU=Users,DC=example,DC=com"]
mock_group.list_members.return_value = list(old_members)
mock_group.sync_members.return_value = {
"added": member_dns,
"removed": old_members,
}
- with patch(
- 'employees.ldap.services.group_service.LdapGroup'
- ) as MockLdapGroup:
+ with patch("employees.ldap.services.group_service.LdapGroup") as MockLdapGroup:
MockLdapGroup.objects.get.return_value = mock_group
# Act
@@ -256,10 +218,7 @@ class TestGroupServiceMembers:
@pytest.mark.django_db
def test_list_members(
- self,
- mock_ldap_context,
- mock_ldap_connection,
- mock_ldap_repository
+ self, mock_ldap_context, mock_ldap_connection, mock_ldap_repository
):
"""Тест получения списка членов группы."""
# Arrange
@@ -267,15 +226,13 @@ class TestGroupServiceMembers:
group_dn = "CN=Test,OU=Groups,DC=example,DC=com"
expected_members = [
"CN=User1,OU=Users,DC=example,DC=com",
- "CN=User2,OU=Users,DC=example,DC=com"
+ "CN=User2,OU=Users,DC=example,DC=com",
]
# Mock ORM
mock_group = Mock()
mock_group.member = expected_members
- with patch(
- 'employees.ldap.services.group_service.LdapGroup'
- ) as MockLdapGroup:
+ with patch("employees.ldap.services.group_service.LdapGroup") as MockLdapGroup:
MockLdapGroup.objects.get.return_value = mock_group
# Act
@@ -291,10 +248,7 @@ class TestGroupServiceSearch:
@pytest.mark.django_db
def test_find_dn(
- self,
- mock_ldap_context,
- mock_ldap_connection,
- mock_ldap_repository
+ self, mock_ldap_context, mock_ldap_connection, mock_ldap_repository
):
"""Тест поиска DN группы по имени."""
# Arrange
@@ -302,7 +256,7 @@ class TestGroupServiceSearch:
group_name = "Test Group"
# Act — find_dn использует ORM (LdapGroup.objects.filter)
- with patch('employees.ldap.services.group_service.LdapGroup') as MockLdapGroup:
+ with patch("employees.ldap.services.group_service.LdapGroup") as MockLdapGroup:
mock_group = Mock()
mock_group.dn = "CN=Test Group,OU=Groups,DC=example,DC=com"
MockLdapGroup.objects.filter.return_value.first.return_value = mock_group
@@ -314,10 +268,7 @@ class TestGroupServiceSearch:
@pytest.mark.django_db
def test_groups_with_member(
- self,
- mock_ldap_context,
- mock_ldap_connection,
- mock_ldap_repository
+ self, mock_ldap_context, mock_ldap_connection, mock_ldap_repository
):
"""Тест поиска групп, в которых состоит пользователь."""
# Arrange
@@ -325,11 +276,11 @@ class TestGroupServiceSearch:
member_dn = "CN=User,OU=Users,DC=example,DC=com"
# Act — groups_with_member использует ORM (LdapUser.objects.get)
- with patch('employees.ldap.services.group_service.LdapUser') as MockLdapUser:
+ with patch("employees.ldap.services.group_service.LdapUser") as MockLdapUser:
mock_user = Mock()
mock_user.member_of = [
"CN=Group1,OU=Groups,DC=example,DC=com",
- "CN=Group2,OU=Groups,DC=example,DC=com"
+ "CN=Group2,OU=Groups,DC=example,DC=com",
]
MockLdapUser.objects.get.return_value = mock_user
@@ -348,7 +299,7 @@ class TestGroupServiceSync:
mock_ldap_context,
mock_ldap_connection,
mock_ldap_repository,
- sample_django_group
+ sample_django_group,
):
"""Тест синхронизации каталога групп из AD в Django."""
# Arrange
@@ -360,18 +311,15 @@ class TestGroupServiceSync:
mock_entry.cn.value = "Developers"
mock_ldap_connection.entries = [mock_entry]
- with patch('django.contrib.auth.models.Group.objects') as mock_groups:
+ with patch("django.contrib.auth.models.Group.objects") as mock_groups:
mock_groups.all.return_value = [sample_django_group]
- with patch('django.core.cache.cache') as mock_cache:
+ with patch("django.core.cache.cache") as mock_cache:
mock_cache.get.return_value = 0
mock_cache.add.return_value = True
# Act
- result = service.sync_catalog(
- throttle_seconds=0,
- delete_absent=False
- )
+ result = service.sync_catalog(throttle_seconds=0, delete_absent=False)
# Assert
# Проверяем, что метод отработал
diff --git a/backend/employees/ldap/tests/unit/test_position_service.py b/backend/employees/ldap/tests/unit/test_position_service.py
index 764599ab..bf3b624a 100644
--- a/backend/employees/ldap/tests/unit/test_position_service.py
+++ b/backend/employees/ldap/tests/unit/test_position_service.py
@@ -2,12 +2,13 @@
Unit тесты для PositionService.
"""
-import pytest
from unittest.mock import Mock, patch
+
+import pytest
from django.conf import settings
-from employees.models import Position, Employee
from employees.ldap.services.position_service import PositionService
+from employees.models import Employee, Position
class TestPositionServiceReconcile:
@@ -20,7 +21,7 @@ class TestPositionServiceReconcile:
mock_ldap_connection,
mock_group_service,
mock_user_service,
- sample_position
+ sample_position,
):
"""Тест синхронизации должности."""
# Arrange
@@ -30,20 +31,16 @@ class TestPositionServiceReconcile:
mock_queryset = Mock()
mock_queryset.values_list.return_value = [1, 2, 3]
- with patch.object(
- Employee.objects,
- 'filter',
- return_value=mock_queryset
- ):
+ with patch.object(Employee.objects, "filter", return_value=mock_queryset):
with patch.object(
mock_user_service,
- '_employee_ids_to_dns',
- return_value=['CN=User,OU=Users,DC=example,DC=com']
+ "_employee_ids_to_dns",
+ return_value=["CN=User,OU=Users,DC=example,DC=com"],
):
with patch.object(
service,
- '_ensure_position_group',
- return_value='CN=POS_1,OU=Positions,DC=example,DC=com'
+ "_ensure_position_group",
+ return_value="CN=POS_1,OU=Positions,DC=example,DC=com",
):
# Act
service.reconcile_position(sample_position)
@@ -58,7 +55,7 @@ class TestPositionServiceReconcile:
mock_ldap_connection,
mock_group_service,
mock_user_service,
- sample_position
+ sample_position,
):
"""Тест синхронизации вложенности должностей."""
# Arrange
@@ -66,8 +63,7 @@ class TestPositionServiceReconcile:
# Создаем родительскую должность
parent_position = Position.objects.create(
- name="Senior Developer",
- description="Senior position"
+ name="Senior Developer", description="Senior position"
)
# Используем patch для groups.all() вместо прямого присваивания
@@ -75,32 +71,23 @@ class TestPositionServiceReconcile:
mock_group_queryset.__iter__ = Mock(return_value=iter([]))
with patch.object(
- sample_position.groups,
- 'all',
- return_value=mock_group_queryset
+ sample_position.groups, "all", return_value=mock_group_queryset
):
sample_position.parent = parent_position
- pos_dn = (
- f'CN=POS_{sample_position.id},OU=Positions,DC=example,DC=com'
- )
- parent_dn = (
- f'CN=POS_{parent_position.id},OU=Positions,DC=example,DC=com'
- )
+ pos_dn = f"CN=POS_{sample_position.id},OU=Positions,DC=example,DC=com"
+ parent_dn = f"CN=POS_{parent_position.id},OU=Positions,DC=example,DC=com"
# Mock поиск текущих групп
mock_ldap_connection.search.return_value = False
mock_ldap_connection.entries = []
with patch.object(
- service,
- '_ensure_position_group',
- side_effect=[pos_dn, parent_dn]
+ service, "_ensure_position_group", side_effect=[pos_dn, parent_dn]
):
# Act
service._reconcile_position_nesting(
- mock_ldap_connection,
- sample_position
+ mock_ldap_connection, sample_position
)
# Assert - проверяем, что метод отработал
@@ -112,28 +99,18 @@ class TestPositionServiceAssign:
@pytest.mark.django_db
def test_assign_position(
- self,
- mock_group_service,
- mock_user_service,
- sample_position,
- sample_employee
+ self, mock_group_service, mock_user_service, sample_position, sample_employee
):
"""Тест назначения должности сотруднику."""
# Arrange
service = PositionService(mock_group_service, mock_user_service)
- pos_dn = f'CN=POS_{sample_position.id},OU=Positions,DC=example,DC=com'
- user_dn = 'CN=User,OU=Users,DC=example,DC=com'
+ pos_dn = f"CN=POS_{sample_position.id},OU=Positions,DC=example,DC=com"
+ user_dn = "CN=User,OU=Users,DC=example,DC=com"
- with patch.object(
- service,
- '_ensure_position_group',
- return_value=pos_dn
- ):
+ with patch.object(service, "_ensure_position_group", return_value=pos_dn):
with patch.object(
- mock_user_service,
- '_get_employee_dn',
- return_value=user_dn
+ mock_user_service, "_get_employee_dn", return_value=user_dn
):
# Act
service.assign_position(sample_employee, sample_position)
@@ -148,28 +125,18 @@ class TestPositionServiceAssign:
@pytest.mark.django_db
def test_unassign_position(
- self,
- mock_group_service,
- mock_user_service,
- sample_position,
- sample_employee
+ self, mock_group_service, mock_user_service, sample_position, sample_employee
):
"""Тест снятия должности с сотрудника."""
# Arrange
service = PositionService(mock_group_service, mock_user_service)
- pos_dn = f'CN=POS_{sample_position.id},OU=Positions,DC=example,DC=com'
- user_dn = 'CN=User,OU=Users,DC=example,DC=com'
+ pos_dn = f"CN=POS_{sample_position.id},OU=Positions,DC=example,DC=com"
+ user_dn = "CN=User,OU=Users,DC=example,DC=com"
- with patch.object(
- service,
- '_ensure_position_group',
- return_value=pos_dn
- ):
+ with patch.object(service, "_ensure_position_group", return_value=pos_dn):
with patch.object(
- mock_user_service,
- '_get_employee_dn',
- return_value=user_dn
+ mock_user_service, "_get_employee_dn", return_value=user_dn
):
# Act
service.unassign_position(sample_employee, sample_position)
@@ -191,13 +158,13 @@ class TestPositionServiceDelete:
mock_ldap_connection,
mock_group_service,
mock_user_service,
- sample_position
+ sample_position,
):
"""Тест удаления группы должности из AD."""
# Arrange
service = PositionService(mock_group_service, mock_user_service)
- pos_dn = f'CN=POS_{sample_position.id},OU=Positions,DC=example,DC=com'
+ pos_dn = f"CN=POS_{sample_position.id},OU=Positions,DC=example,DC=com"
sample_position.ldap_group_dn = pos_dn
# Mock: нет родительских групп
@@ -205,9 +172,7 @@ class TestPositionServiceDelete:
# Mock ORM LdapGroup.objects.get().delete()
mock_group = Mock()
- with patch(
- 'employees.ldap.orm_models.LdapGroup'
- ) as MockLdapGroup:
+ with patch("employees.ldap.orm_models.LdapGroup") as MockLdapGroup:
MockLdapGroup.objects.get.return_value = mock_group
# Act
@@ -224,7 +189,7 @@ class TestPositionServiceDelete:
mock_ldap_connection,
mock_group_service,
mock_user_service,
- sample_position
+ sample_position,
):
"""Тест удаления несуществующей группы должности."""
# Arrange
@@ -232,9 +197,7 @@ class TestPositionServiceDelete:
# ldap_group_dn пустой — сервис должен выйти раньше
sample_position.ldap_group_dn = ""
- with patch(
- 'employees.ldap.orm_models.LdapGroup'
- ) as MockLdapGroup:
+ with patch("employees.ldap.orm_models.LdapGroup") as MockLdapGroup:
# Act
service.delete_position_group(sample_position)
@@ -247,10 +210,7 @@ class TestPositionServiceHelpers:
@pytest.mark.django_db
def test_positions_base(
- self,
- mock_group_service,
- mock_user_service,
- ldap_test_settings
+ self, mock_group_service, mock_user_service, ldap_test_settings
):
"""Тест получения базового DN для должностей."""
# Arrange
@@ -260,7 +220,7 @@ class TestPositionServiceHelpers:
base = service._positions_base()
# Assert
- assert 'OU=Positions' in base
+ assert "OU=Positions" in base
assert ldap_test_settings.LDAP_BASE_DN in base
@pytest.mark.django_db
@@ -270,16 +230,14 @@ class TestPositionServiceHelpers:
mock_ldap_connection,
mock_ldap_repository,
mock_group_service,
- mock_user_service
+ mock_user_service,
):
"""Тест создания базового OU для должностей."""
# Arrange
service = PositionService(mock_group_service, mock_user_service)
# Mock LdapRepository в месте использования
- with patch(
- 'employees.ldap.services.position_service.ensure_container_exists'
- ):
+ with patch("employees.ldap.services.position_service.ensure_container_exists"):
# Act
result = service._ensure_positions_base(mock_ldap_connection)
@@ -293,17 +251,14 @@ class TestPositionServiceHelpers:
mock_ldap_connection,
mock_group_service,
mock_user_service,
- sample_position
+ sample_position,
):
"""Тест создания/получения группы должности."""
# Arrange
service = PositionService(mock_group_service, mock_user_service)
expected_name = f"POS_{sample_position.name}"
- expected_dn = (
- f'CN={expected_name},'
- f'{settings.LDAP_POSITIONS_BASE}'
- )
+ expected_dn = f"CN={expected_name},{settings.LDAP_POSITIONS_BASE}"
# Mock: saved_dn существует и найден в LDAP
sample_position.ldap_group_dn = expected_dn
@@ -315,14 +270,9 @@ class TestPositionServiceHelpers:
mock_ldap_connection.entries = [mock_entry]
# Mock LdapRepository
- with patch(
- 'employees.ldap.services.position_service.ensure_container_exists'
- ):
+ with patch("employees.ldap.services.position_service.ensure_container_exists"):
# Act
- dn = service._ensure_position_group(
- mock_ldap_connection,
- sample_position
- )
+ dn = service._ensure_position_group(mock_ldap_connection, sample_position)
# Assert
assert expected_name in dn
@@ -335,7 +285,7 @@ class TestPositionServiceHelpers:
mock_ldap_connection,
mock_group_service,
mock_user_service,
- sample_position
+ sample_position,
):
"""Тест создания новой группы должности."""
# Arrange
@@ -352,14 +302,9 @@ class TestPositionServiceHelpers:
# Mock ensure_container_exists + conn.add (low-level create)
mock_ldap_connection.add.return_value = True
- with patch(
- 'employees.ldap.services.position_service.ensure_container_exists'
- ):
+ with patch("employees.ldap.services.position_service.ensure_container_exists"):
# Act
- dn = service._ensure_position_group(
- mock_ldap_connection,
- sample_position
- )
+ dn = service._ensure_position_group(mock_ldap_connection, sample_position)
# Assert
assert expected_name in dn
diff --git a/backend/employees/ldap/tests/unit/test_user_service.py b/backend/employees/ldap/tests/unit/test_user_service.py
index 332631b7..813dbb10 100644
--- a/backend/employees/ldap/tests/unit/test_user_service.py
+++ b/backend/employees/ldap/tests/unit/test_user_service.py
@@ -2,9 +2,10 @@
Unit тесты для UserService.
"""
-import pytest
from unittest.mock import Mock
+import pytest
+
from employees.ldap.services.user_service import UserService
@@ -27,20 +28,20 @@ class TestUserServiceDelete:
mock_ldap_repository,
mock_employee_repo_functions,
mock_sync_state_repo_functions,
- sample_employee
+ sample_employee,
):
"""Тест удаления без DN (пропускаем soft-disable)."""
# Arrange
service = UserService(
mock_ldap_repository,
mock_employee_repo_functions,
- mock_sync_state_repo_functions
+ mock_sync_state_repo_functions,
)
# Mock: DN не найден
mock_sync_state_repo_functions.get_or_create.return_value = (
Mock(ldap_dn=None),
- False
+ False,
)
# Mock delete для Employee
@@ -66,14 +67,14 @@ class TestUserServiceHelpers:
mock_employee_repo_functions,
mock_sync_state_repo_functions,
sample_employee,
- sample_ldap_sync_state
+ sample_ldap_sync_state,
):
"""Тест конвертации ID сотрудников в DN."""
# Arrange
service = UserService(
mock_ldap_repository,
mock_employee_repo_functions,
- mock_sync_state_repo_functions
+ mock_sync_state_repo_functions,
)
mock_sync_state_repo_functions.get_employees_with_dn.return_value = [
@@ -94,19 +95,19 @@ class TestUserServiceHelpers:
mock_employee_repo_functions,
mock_sync_state_repo_functions,
sample_employee,
- sample_ldap_sync_state
+ sample_ldap_sync_state,
):
"""Тест получения DN сотрудника."""
# Arrange
service = UserService(
mock_ldap_repository,
mock_employee_repo_functions,
- mock_sync_state_repo_functions
+ mock_sync_state_repo_functions,
)
mock_sync_state_repo_functions.get_or_create.return_value = (
sample_ldap_sync_state,
- False
+ False,
)
# Act
@@ -124,18 +125,18 @@ class TestUserServiceHelpers:
mock_employee_repo_functions,
mock_sync_state_repo_functions,
sample_employee,
- sample_ldap_sync_state
+ sample_ldap_sync_state,
):
"""Тест перемещения пользователя в другой OU."""
# Arrange
service = UserService(
mock_ldap_repository,
mock_employee_repo_functions,
- mock_sync_state_repo_functions
+ mock_sync_state_repo_functions,
)
- old_dn = 'CN=Test,OU=OldBase,DC=example,DC=com'
- new_base = 'OU=NewBase,DC=example,DC=com'
+ old_dn = "CN=Test,OU=OldBase,DC=example,DC=com"
+ new_base = "OU=NewBase,DC=example,DC=com"
# Mock conn.modify_dn для low-level перемещения
mock_ldap_connection.modify_dn.return_value = True
@@ -144,9 +145,9 @@ class TestUserServiceHelpers:
new_dn = service._move_user_to_base(mock_ldap_connection, old_dn, new_base)
# Assert
- assert new_dn == f'CN=Test,{new_base}'
+ assert new_dn == f"CN=Test,{new_base}"
mock_ldap_connection.modify_dn.assert_called_once_with(
- old_dn, 'CN=Test', new_superior=new_base
+ old_dn, "CN=Test", new_superior=new_base
)
diff --git a/backend/employees/ldap/utils/__init__.py b/backend/employees/ldap/utils/__init__.py
index 4c2cc7ae..0ac66149 100644
--- a/backend/employees/ldap/utils/__init__.py
+++ b/backend/employees/ldap/utils/__init__.py
@@ -4,41 +4,40 @@
"""
# Текстовые утилиты
-from .text_utils import translit_to_ascii, esc_filter, esc_rdn
-
-# LDAP утилиты
-from .ldap_utils import (
- _first,
- _uac_is_active,
- _paged_search,
- get_attr_str,
- get_guid_str,
- _ldap_pick_phone,
- group_type,
- cn_candidates,
- make_base_login,
- fallback_login_from_email,
- ensure_unique_login,
- build_logins_for_user,
-)
-
# DN утилиты
from .dn_utils import (
- extract_department_from_dn,
- rewrite_dn_suffix,
- _target_department_ou_dn,
_ensure_user_dn,
_move_to_department,
+ _target_department_ou_dn,
+ extract_department_from_dn,
+ rewrite_dn_suffix,
)
-# Утилиты изображений
-from .image_utils import normalize_avatar_to_jpeg
-
# Утилиты групп (ORM)
from .group_utils_orm import sync_user_groups_by_cns_orm
+# Утилиты изображений
+from .image_utils import normalize_avatar_to_jpeg
+
+# LDAP утилиты
+from .ldap_utils import (
+ _first,
+ _ldap_pick_phone,
+ _paged_search,
+ _uac_is_active,
+ build_logins_for_user,
+ cn_candidates,
+ ensure_unique_login,
+ fallback_login_from_email,
+ get_attr_str,
+ get_guid_str,
+ group_type,
+ make_base_login,
+)
+
# Телефон
from .phone_utils import normalize_phone
+from .text_utils import esc_filter, esc_rdn, translit_to_ascii
__all__ = [
# Текстовые
diff --git a/backend/employees/ldap/utils/dn_utils.py b/backend/employees/ldap/utils/dn_utils.py
index ff8aaf8f..c4f5eb70 100644
--- a/backend/employees/ldap/utils/dn_utils.py
+++ b/backend/employees/ldap/utils/dn_utils.py
@@ -10,9 +10,10 @@ from typing import Optional, Sequence
from django.conf import settings
from ldap3 import BASE, SUBTREE, Connection
-from ..errors import DirectoryLdapError, DirectoryServiceError
from employees.models import Employee, LdapSyncState
+from ..errors import DirectoryLdapError, DirectoryServiceError
+
def extract_department_from_dn(
dn: str, *, departments_anchor: str = "OU=Departments"
@@ -47,9 +48,7 @@ def extract_department_from_dn(
# fallback: искать просто 'OU=Departments' без хвоста
try:
idx = next(
- i
- for i, p in enumerate(parts)
- if p.strip().lower() == "ou=departments"
+ i for i, p in enumerate(parts) if p.strip().lower() == "ou=departments"
)
except StopIteration:
return None
@@ -158,8 +157,7 @@ def _ensure_user_dn(conn: Connection, emp: Employee) -> str:
if cn_part.upper().startswith("CN="):
cn_val = cn_part[3:]
filters.append(
- f"(&(objectCategory=person)(objectClass=user)"
- f"(cn={esc_filter(cn_val)}))"
+ f"(&(objectCategory=person)(objectClass=user)(cn={esc_filter(cn_val)}))"
)
if emp.first_name and emp.last_name:
@@ -178,9 +176,7 @@ def _ensure_user_dn(conn: Connection, emp: Employee) -> str:
for flt in filters:
for base in bases:
if (
- conn.search(
- base, flt, SUBTREE, attributes=["distinguishedName"]
- )
+ conn.search(base, flt, SUBTREE, attributes=["distinguishedName"])
and conn.entries
):
return str(conn.entries[0].entry_dn)
@@ -256,9 +252,7 @@ def _move_to_department(conn: Connection, emp_dn: str, dept: str) -> str:
new_superior=dept_dn,
)
if not res:
- err = getattr(conn, "last_error", None) or getattr(
- conn, "result", None
- )
+ err = getattr(conn, "last_error", None) or getattr(conn, "result", None)
raise RuntimeError(err)
except Exception as e:
raise DirectoryLdapError(f"LDAP modifyDN (move) failed: {e}") from e
diff --git a/backend/employees/ldap/utils/group_utils_orm.py b/backend/employees/ldap/utils/group_utils_orm.py
index 7cf79e05..1dbb9e7b 100644
--- a/backend/employees/ldap/utils/group_utils_orm.py
+++ b/backend/employees/ldap/utils/group_utils_orm.py
@@ -11,7 +11,8 @@ from typing import Iterable
from django.conf import settings
from employees.models import Employee
-from ..orm_models import LdapUser, LdapGroup
+
+from ..orm_models import LdapGroup, LdapUser
logger = logging.getLogger(__name__)
diff --git a/backend/employees/ldap/utils/image_utils.py b/backend/employees/ldap/utils/image_utils.py
index 1917136b..3a8a5788 100644
--- a/backend/employees/ldap/utils/image_utils.py
+++ b/backend/employees/ldap/utils/image_utils.py
@@ -176,9 +176,7 @@ def normalize_avatar_to_jpeg(
return best
if max(working.size) <= 1:
- raise RuntimeError(
- "avatar: невозможно уложить JPEG в заданный лимит"
- )
+ raise RuntimeError("avatar: невозможно уложить JPEG в заданный лимит")
next_size = tuple(max(1, int(side * 0.75)) for side in working.size)
working = working.resize(next_size, Image.Resampling.LANCZOS)
diff --git a/backend/employees/ldap/utils/text_utils.py b/backend/employees/ldap/utils/text_utils.py
index 59b5d6b9..4fd7a91c 100644
--- a/backend/employees/ldap/utils/text_utils.py
+++ b/backend/employees/ldap/utils/text_utils.py
@@ -7,7 +7,6 @@ from typing import Optional
from ldap3.utils.conv import escape_filter_chars
-
# Словарь для транслитерации русских символов в латиницу
_RU2LAT = {
"а": "a",
diff --git a/backend/employees/management/commands/create_test_calendar_users.py b/backend/employees/management/commands/create_test_calendar_users.py
index 2d747338..1df8a939 100644
--- a/backend/employees/management/commands/create_test_calendar_users.py
+++ b/backend/employees/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()
@@ -28,9 +29,7 @@ class Command(BaseCommand):
# Удаление существующих данных если указан флаг --clean
if options["clean"]:
self.stdout.write(
- self.style.WARNING(
- "\nУдаление существующих тестовых данных...\n"
- )
+ self.style.WARNING("\nУдаление существующих тестовых данных...\n")
)
# Список тестовых пользователей для удаления
@@ -40,8 +39,8 @@ class Command(BaseCommand):
try:
user = User.objects.get(username=username)
# Сначала удаляем события в календарях пользователя
- from schedule.models import CalendarRelation
from django.contrib.contenttypes.models import ContentType
+ from schedule.models import CalendarRelation
ct = ContentType.objects.get_for_model(User)
calendar_relations = CalendarRelation.objects.filter(
@@ -64,9 +63,7 @@ class Command(BaseCommand):
self.style.SUCCESS(f" Удален пользователь: {username}")
)
except User.DoesNotExist:
- self.stdout.write(
- f" Пользователь {username} не найден (пропуск)"
- )
+ self.stdout.write(f" Пользователь {username} не найден (пропуск)")
# Удаляем календари по slug (если они остались)
calendar_slugs = [
@@ -138,9 +135,7 @@ class Command(BaseCommand):
user.save()
self.stdout.write(
self.style.SUCCESS(
- f"Создан пользователь: {user.get_full_name()} ({
- user.username
- })"
+ f"Создан пользователь: {user.get_full_name()} ({user.username})"
)
)
else:
@@ -189,15 +184,11 @@ class Command(BaseCommand):
)
# Создаем CalendarRelation с owner
owner = created_users[cal_data["owner_idx"]]
- calendar.create_relation(
- owner, distinction="owner", inheritable=True
- )
+ calendar.create_relation(owner, distinction="owner", inheritable=True)
self.stdout.write(f" Владелец: {owner.get_full_name()}")
else:
self.stdout.write(
- self.style.WARNING(
- f"Календарь уже существует: {calendar.name}"
- )
+ self.style.WARNING(f"Календарь уже существует: {calendar.name}")
)
created_calendars.append(calendar)
@@ -277,12 +268,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": "Работа над функционалом",
},
{
@@ -377,9 +364,7 @@ class Command(BaseCommand):
},
)
if created:
- self.stdout.write(
- f" Событие: {event.title} ({event.calendar.name})"
- )
+ self.stdout.write(f" Событие: {event.title} ({event.calendar.name})")
self.stdout.write("\n" + "=" * 60)
self.stdout.write(self.style.SUCCESS("Готово! Создано:"))
@@ -389,7 +374,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/employees/management/commands/ensure_dismissed_ou.py b/backend/employees/management/commands/ensure_dismissed_ou.py
index 89084ee9..8559c496 100644
--- a/backend/employees/management/commands/ensure_dismissed_ou.py
+++ b/backend/employees/management/commands/ensure_dismissed_ou.py
@@ -71,8 +71,7 @@ class Command(BaseCommand):
if not force:
self.stdout.write(
self.style.SUCCESS(
- "✓ OU=Dismissed уже существует: "
- f"{dismissed_base}"
+ f"✓ OU=Dismissed уже существует: {dismissed_base}"
)
)
entry = conn.entries[0]
@@ -82,9 +81,7 @@ class Command(BaseCommand):
else None
)
if current_desc:
- self.stdout.write(
- f" Текущее описание: {current_desc}"
- )
+ self.stdout.write(f" Текущее описание: {current_desc}")
self.stdout.write("")
self.stdout.write(
"Используйте --force для пересоздания (ОСТОРОЖНО!)"
@@ -112,9 +109,7 @@ class Command(BaseCommand):
ok = conn.delete(dismissed_base)
if not ok:
- raise CommandError(
- f"Не удалось удалить OU: {conn.result}"
- )
+ raise CommandError(f"Не удалось удалить OU: {conn.result}")
self.stdout.write(
self.style.SUCCESS("✓ Существующая OU удалена")
)
@@ -142,8 +137,6 @@ class Command(BaseCommand):
)
except ImportError as e:
- raise CommandError(
- f"Не удалось импортировать LDAP модули: {e}"
- ) from e
+ raise CommandError(f"Не удалось импортировать LDAP модули: {e}") from e
except Exception as e:
raise CommandError(f"Ошибка при работе с LDAP: {e}") from e
diff --git a/backend/employees/management/commands/ldap_queue.py b/backend/employees/management/commands/ldap_queue.py
index 0684e049..d2d739b5 100644
--- a/backend/employees/management/commands/ldap_queue.py
+++ b/backend/employees/management/commands/ldap_queue.py
@@ -28,26 +28,19 @@ class Command(BaseCommand):
subparsers.add_parser("status", help="Показать статистику очереди")
- retry_parser = subparsers.add_parser(
- "retry", help="Запустить повтор операций"
- )
+ retry_parser = subparsers.add_parser("retry", help="Запустить повтор операций")
retry_parser.add_argument(
"--failed",
action="store_true",
help="Включить failed-записи (сбросить счётчик попыток)",
)
- cleanup_parser = subparsers.add_parser(
- "cleanup", help="Очистить старые записи"
- )
+ cleanup_parser = subparsers.add_parser("cleanup", help="Очистить старые записи")
cleanup_parser.add_argument(
"--days",
type=int,
default=30,
- help=(
- "Удалять completed/failed записи старше N дней "
- "(по умолчанию 30)"
- ),
+ help=("Удалять completed/failed записи старше N дней (по умолчанию 30)"),
)
def handle(self, *args, **options):
@@ -95,9 +88,7 @@ class Command(BaseCommand):
qs = LdapSyncQueue.objects.filter(status=LdapSyncQueue.Status.PENDING)
if include_failed:
- failed_qs = LdapSyncQueue.objects.filter(
- status=LdapSyncQueue.Status.FAILED
- )
+ failed_qs = LdapSyncQueue.objects.filter(status=LdapSyncQueue.Status.FAILED)
reset_count = failed_qs.count()
# Сбрасываем failed → pending
failed_qs.update(
@@ -105,20 +96,14 @@ class Command(BaseCommand):
attempts=0,
next_retry_at=None,
)
- self.stdout.write(
- f"Сброшено {reset_count} failed-записей → pending"
- )
- qs = LdapSyncQueue.objects.filter(
- status=LdapSyncQueue.Status.PENDING
- )
+ self.stdout.write(f"Сброшено {reset_count} failed-записей → pending")
+ qs = LdapSyncQueue.objects.filter(status=LdapSyncQueue.Status.PENDING)
pks = list(qs.values_list("pk", flat=True))
for pk in pks:
process_ldap_queue_item.delay(pk)
- self.stdout.write(
- self.style.SUCCESS(f"Запущено {len(pks)} задач в Celery")
- )
+ self.stdout.write(self.style.SUCCESS(f"Запущено {len(pks)} задач в Celery"))
def _cleanup(self, days: int):
from employees.models import LdapSyncQueue
@@ -131,6 +116,4 @@ class Command(BaseCommand):
],
updated_at__lt=cutoff,
).delete()
- self.stdout.write(
- self.style.SUCCESS(f"Удалено {deleted} старых записей")
- )
+ self.stdout.write(self.style.SUCCESS(f"Удалено {deleted} старых записей"))
diff --git a/backend/employees/management/commands/mailtest.py b/backend/employees/management/commands/mailtest.py
index e9a20f45..0ffc2935 100644
--- a/backend/employees/management/commands/mailtest.py
+++ b/backend/employees/management/commands/mailtest.py
@@ -1,5 +1,6 @@
-from django.core.management.base import BaseCommand, CommandError
from django.conf import settings
+from django.core.management.base import BaseCommand, CommandError
+
from common.emails import send_templated_mail
@@ -20,7 +21,8 @@ class Command(BaseCommand):
)
self.stdout.write(
self.style.SUCCESS(
- f"Письмо отправлено ({sent}). Бэкенд: {
- settings.EMAIL_BACKEND}"))
+ f"Письмо отправлено ({sent}). Бэкенд: {settings.EMAIL_BACKEND}"
+ )
+ )
except Exception as e:
raise CommandError(f"Ошибка отправки: {e}")
diff --git a/backend/employees/management/commands/make_brand_assets.py b/backend/employees/management/commands/make_brand_assets.py
index 4864afe5..0f59443c 100644
--- a/backend/employees/management/commands/make_brand_assets.py
+++ b/backend/employees/management/commands/make_brand_assets.py
@@ -1,7 +1,8 @@
from pathlib import Path
from typing import List, Tuple
-from django.core.management.base import BaseCommand, CommandError
+
from django.conf import settings
+from django.core.management.base import BaseCommand, CommandError
try:
from PIL import Image
@@ -23,9 +24,7 @@ class Command(BaseCommand):
Args:
parser: Аргумент-парсер Django management.
"""
- parser.add_argument(
- "--src", default=None, help="Путь в static к исходному PNG"
- )
+ parser.add_argument("--src", default=None, help="Путь в static к исходному PNG")
parser.add_argument(
"--out-dir", default="img", help="Каталог внутри static для вывода"
)
diff --git a/backend/employees/management/commands/optimize_avatars.py b/backend/employees/management/commands/optimize_avatars.py
index e023f352..b98556fa 100644
--- a/backend/employees/management/commands/optimize_avatars.py
+++ b/backend/employees/management/commands/optimize_avatars.py
@@ -7,37 +7,39 @@ Management команда для очистки и оптимизации ава
"""
import os
-from django.core.management.base import BaseCommand
-from django.core.files.base import ContentFile
+
from django.conf import settings
-from employees.models import Employee
+from django.core.files.base import ContentFile
+from django.core.management.base import BaseCommand
+
from common.image_utils import compress_avatar
+from employees.models import Employee
class Command(BaseCommand):
- help = 'Очистка неиспользуемых аватаров и оптимизация существующих'
+ help = "Очистка неиспользуемых аватаров и оптимизация существующих"
def add_arguments(self, parser):
parser.add_argument(
- '--compress',
- action='store_true',
- help='Сжать существующие аватары',
+ "--compress",
+ action="store_true",
+ help="Сжать существующие аватары",
)
parser.add_argument(
- '--cleanup',
- action='store_true',
- help='Удалить неиспользуемые файлы',
+ "--cleanup",
+ action="store_true",
+ help="Удалить неиспользуемые файлы",
)
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']
- do_compress = options['compress']
- do_cleanup = options['cleanup']
+ dry_run = options["dry_run"]
+ do_compress = options["compress"]
+ do_cleanup = options["cleanup"]
# Если не указаны опции, делаем всё
if not do_compress and not do_cleanup:
@@ -46,36 +48,32 @@ class Command(BaseCommand):
if dry_run:
self.stdout.write(
- self.style.WARNING(
- 'РЕЖИМ ПРОСМОТРА - изменения не применяются'
- )
+ self.style.WARNING("РЕЖИМ ПРОСМОТРА - изменения не применяются")
)
# Получаем все используемые файлы аватаров
- employees = Employee.objects.exclude(avatar='').exclude(avatar=None)
+ employees = Employee.objects.exclude(avatar="").exclude(avatar=None)
used_files = set()
for emp in employees:
if emp.avatar:
try:
used_files.add(os.path.basename(emp.avatar.path))
- except Exception:
- pass
+ except (OSError, ValueError):
+ continue
- self.stdout.write(
- f'Найдено {len(used_files)} используемых аватаров'
- )
+ self.stdout.write(f"Найдено {len(used_files)} используемых аватаров")
# Очистка неиспользуемых файлов
if do_cleanup:
- self.stdout.write('\n=== ОЧИСТКА НЕИСПОЛЬЗУЕМЫХ ФАЙЛОВ ===')
+ self.stdout.write("\n=== ОЧИСТКА НЕИСПОЛЬЗУЕМЫХ ФАЙЛОВ ===")
# Используем абсолютный путь из settings
- avatar_dir = os.path.join(settings.MEDIA_ROOT, 'users', 'avatars')
+ avatar_dir = os.path.join(settings.MEDIA_ROOT, "users", "avatars")
if not os.path.exists(avatar_dir):
self.stdout.write(
- self.style.WARNING(f'Директория {avatar_dir} не найдена')
+ self.style.WARNING(f"Директория {avatar_dir} не найдена")
)
else:
deleted_count = 0
@@ -91,9 +89,7 @@ class Command(BaseCommand):
size_mb = os.path.getsize(filepath) / (1024 * 1024)
freed_mb += size_mb
- self.stdout.write(
- f' Удаление: {filename} ({size_mb:.2f} MB)'
- )
+ self.stdout.write(f" Удаление: {filename} ({size_mb:.2f} MB)")
if not dry_run:
try:
@@ -101,21 +97,19 @@ class Command(BaseCommand):
deleted_count += 1
except Exception as e:
self.stdout.write(
- self.style.ERROR(
- f' Ошибка удаления: {e}'
- )
+ self.style.ERROR(f" Ошибка удаления: {e}")
)
self.stdout.write(
self.style.SUCCESS(
- f'\nУдалено файлов: {deleted_count}, '
- f'освобождено: {freed_mb:.2f} MB'
+ f"\nУдалено файлов: {deleted_count}, "
+ f"освобождено: {freed_mb:.2f} MB"
)
)
# Сжатие существующих аватаров
if do_compress:
- self.stdout.write('\n=== СЖАТИЕ СУЩЕСТВУЮЩИХ АВАТАРОВ ===')
+ self.stdout.write("\n=== СЖАТИЕ СУЩЕСТВУЮЩИХ АВАТАРОВ ===")
compressed_count = 0
saved_mb = 0
@@ -128,7 +122,7 @@ class Command(BaseCommand):
original_size = os.path.getsize(original_path)
# Читаем оригинальный файл
- with open(original_path, 'rb') as f:
+ with open(original_path, "rb") as f:
original_data = f.read()
# Сжимаем
@@ -142,42 +136,38 @@ class Command(BaseCommand):
saved_mb += saved
self.stdout.write(
- f' Сжатие: {emp.get_full_name()} '
- f'({original_size / 1024:.1f}KB → '
- f'{compressed_size / 1024:.1f}KB, '
- f'экономия {saved * 1024:.1f}KB)'
+ f" Сжатие: {emp.get_full_name()} "
+ f"({original_size / 1024:.1f}KB → "
+ f"{compressed_size / 1024:.1f}KB, "
+ f"экономия {saved * 1024:.1f}KB)"
)
if not dry_run:
# Сохраняем сжатый файл
filename = os.path.basename(original_path)
- if not filename.lower().endswith('.jpg'):
- filename = filename.rsplit('.', 1)[0] + '.jpg'
+ if not filename.lower().endswith(".jpg"):
+ filename = filename.rsplit(".", 1)[0] + ".jpg"
emp.avatar.save(
- filename,
- ContentFile(compressed_data),
- save=True
+ filename, ContentFile(compressed_data), save=True
)
compressed_count += 1
except Exception as e:
self.stdout.write(
self.style.ERROR(
- f' Ошибка сжатия для {emp.get_full_name()}: {e}'
+ f" Ошибка сжатия для {emp.get_full_name()}: {e}"
)
)
self.stdout.write(
self.style.SUCCESS(
- f'\nСжато файлов: {compressed_count}, '
- f'сэкономлено: {saved_mb:.2f} MB'
+ f"\nСжато файлов: {compressed_count}, "
+ f"сэкономлено: {saved_mb:.2f} MB"
)
)
if dry_run:
self.stdout.write(
- self.style.WARNING(
- '\nДля применения изменений запустите без --dry-run'
- )
+ self.style.WARNING("\nДля применения изменений запустите без --dry-run")
)
diff --git a/backend/employees/management/commands/recalculate_secret_skills.py b/backend/employees/management/commands/recalculate_secret_skills.py
index 16d097d5..5001efae 100644
--- a/backend/employees/management/commands/recalculate_secret_skills.py
+++ b/backend/employees/management/commands/recalculate_secret_skills.py
@@ -9,7 +9,6 @@ from employees.services.secret_skill_settings import get_secret_skills_start_dat
from employees.services.secret_skills import recalculate_attendance_secret_skills
from employees.services.task_execution_skill import refresh_task_execution_skills
-
RANK_LABELS = {
"none": "без ранга",
"bronze": "бронза",
diff --git a/backend/employees/management/commands/seed_test_data.py b/backend/employees/management/commands/seed_test_data.py
index 5983c931..1edd2407 100644
--- a/backend/employees/management/commands/seed_test_data.py
+++ b/backend/employees/management/commands/seed_test_data.py
@@ -3,12 +3,14 @@ Management команда для заполнения БД тестовыми д
Использование: python manage.py seed_test_data [--admin-username USERNAME]
"""
-from django.core.management.base import BaseCommand
-from django.contrib.auth import get_user_model
-from django.db import transaction
-from employees.models import Department, Position, Employee, Skill
-from datetime import datetime
import random
+from datetime import datetime
+
+from django.contrib.auth import get_user_model
+from django.core.management.base import BaseCommand
+from django.db import transaction
+
+from employees.models import Department, Employee, Position, Skill
User = get_user_model()
@@ -42,9 +44,7 @@ class Command(BaseCommand):
clear_data = options.get("clear", False)
if clear_data:
- self.stdout.write(
- self.style.WARNING("Очистка существующих данных...")
- )
+ self.stdout.write(self.style.WARNING("Очистка существующих данных..."))
Employee.objects.all().delete()
Position.objects.all().delete()
Department.objects.all().delete()
@@ -74,8 +74,7 @@ class Command(BaseCommand):
admin_user.save()
self.stdout.write(
self.style.SUCCESS(
- f"✓ Создан новый администратор: {admin_email}\n"
- f" Пароль: admin123"
+ f"✓ Создан новый администратор: {admin_email}\n Пароль: admin123"
)
)
else:
@@ -94,9 +93,7 @@ class Command(BaseCommand):
is_staff=False, is_superuser=False
)
self.stdout.write(
- self.style.SUCCESS(
- f"✓ {admin_email} - единственный администратор\n"
- )
+ self.style.SUCCESS(f"✓ {admin_email} - единственный администратор\n")
)
# 2. Создаем отделы
@@ -169,9 +166,7 @@ class Command(BaseCommand):
self.stdout.write(f" {status}: {name}")
# 5. Создаем сотрудников
- self.stdout.write(
- self.style.SUCCESS("\n5. Создание тестовых сотрудников")
- )
+ self.stdout.write(self.style.SUCCESS("\n5. Создание тестовых сотрудников"))
# Обновляем администратора - добавляем должность
admin_user.position = positions[0] # Директор
diff --git a/backend/employees/management/commands/sync_birthdays.py b/backend/employees/management/commands/sync_birthdays.py
index ce5e9342..5c6f0be3 100644
--- a/backend/employees/management/commands/sync_birthdays.py
+++ b/backend/employees/management/commands/sync_birthdays.py
@@ -14,10 +14,7 @@ from employees.services import BulkSyncBirthdaysService
class Command(BaseCommand):
- help = (
- "Синхронизирует события дней рождений всех сотрудников "
- "с django-scheduler"
- )
+ help = "Синхронизирует события дней рождений всех сотрудников с django-scheduler"
def add_arguments(self, parser):
parser.add_argument(
@@ -31,9 +28,7 @@ class Command(BaseCommand):
if dry_run:
self.stdout.write(
- self.style.WARNING(
- "DRY-RUN режим: изменения не будут применены"
- )
+ self.style.WARNING("DRY-RUN режим: изменения не будут применены")
)
self.stdout.write("Запуск синхронизации дней рождений...")
@@ -50,21 +45,15 @@ class Command(BaseCommand):
if dry_run:
self.stdout.write(
- self.style.WARNING(
- "\nИзменения НЕ применены (dry-run режим)"
- )
+ self.style.WARNING("\nИзменения НЕ применены (dry-run режим)")
)
else:
self.stdout.write(
- self.style.SUCCESS(
- "\n✅ Синхронизация завершена успешно!"
- )
+ self.style.SUCCESS("\n✅ Синхронизация завершена успешно!")
)
except Exception as e:
- self.stdout.write(
- self.style.ERROR(f"\n❌ Ошибка при синхронизации: {e}")
- )
+ self.stdout.write(self.style.ERROR(f"\n❌ Ошибка при синхронизации: {e}"))
raise
def _print_results(self, result):
@@ -85,9 +74,7 @@ class Command(BaseCommand):
)
if result["skipped"] > 0:
- self.stdout.write(
- self.style.WARNING(f" ⚠ Пропущено: {result['skipped']}")
- )
+ self.stdout.write(self.style.WARNING(f" ⚠ Пропущено: {result['skipped']}"))
if result["errors"]:
self.stdout.write(
@@ -96,8 +83,7 @@ class Command(BaseCommand):
for error in result["errors"][:5]: # Показать первые 5 ошибок
self.stdout.write(
self.style.ERROR(
- f" - Employee ID {error['employee_id']}: "
- f"{error['error']}"
+ f" - Employee ID {error['employee_id']}: {error['error']}"
)
)
diff --git a/backend/employees/management/commands/sync_directory.py b/backend/employees/management/commands/sync_directory.py
index 89c415f0..d5c88ac1 100644
--- a/backend/employees/management/commands/sync_directory.py
+++ b/backend/employees/management/commands/sync_directory.py
@@ -1,8 +1,8 @@
from __future__ import annotations
-
from django.conf import settings
from django.core.management.base import BaseCommand
+
from employees.ldap.config import SyncConfig
from employees.ldap.services.sync_service import (
SyncService,
@@ -54,8 +54,7 @@ class Command(BaseCommand):
"--show-changes",
action="store_true",
help=(
- "Показать подробный список изменений "
- "(создание/обновление/удаление)."
+ "Показать подробный список изменений (создание/обновление/удаление)."
),
)
@@ -100,9 +99,7 @@ class Command(BaseCommand):
# Пользователи
if scope in ("all", "users"):
- self.stdout.write(
- self.style.NOTICE("[LDAP] Импорт пользователей...")
- )
+ self.stdout.write(self.style.NOTICE("[LDAP] Импорт пользователей..."))
created_u, updated_u, deleted_u = svc.import_users(cfg)
total_changes += created_u + updated_u + deleted_u
deleted_label = "к удалению" if cfg.dry_run else "удалено"
diff --git a/backend/employees/management/commands/sync_employee_ids_to_ldap.py b/backend/employees/management/commands/sync_employee_ids_to_ldap.py
index f5cdf68e..4429e2c4 100644
--- a/backend/employees/management/commands/sync_employee_ids_to_ldap.py
+++ b/backend/employees/management/commands/sync_employee_ids_to_ldap.py
@@ -12,8 +12,8 @@ from typing import Optional
from django.conf import settings
from django.core.management.base import BaseCommand
-from employees.models import Employee, LdapSyncState
from employees.ldap.orm_models import LdapUser
+from employees.models import Employee, LdapSyncState
logger = logging.getLogger(__name__)
@@ -58,20 +58,14 @@ class Command(BaseCommand):
user_id: Optional[int] = options["user_id"]
verbose: int = options.get("verbosity", 1)
- employee_id_attr = getattr(
- settings, "LDAP_EMPLOYEE_ID_ATTR", "employeeNumber"
- )
+ employee_id_attr = getattr(settings, "LDAP_EMPLOYEE_ID_ATTR", "employeeNumber")
if dry_run:
self.stdout.write(
- self.style.WARNING(
- "=== DRY-RUN режим (изменения НЕ применяются) ==="
- )
+ self.style.WARNING("=== DRY-RUN режим (изменения НЕ применяются) ===")
)
else:
- self.stdout.write(
- self.style.SUCCESS("=== ПРИМЕНЕНИЕ изменений в LDAP ===")
- )
+ self.stdout.write(self.style.SUCCESS("=== ПРИМЕНЕНИЕ изменений в LDAP ==="))
# Собираем пользователей с DN в LdapSyncState
sync_states = LdapSyncState.objects.filter(
@@ -83,14 +77,10 @@ class Command(BaseCommand):
sync_states = sync_states.filter(object_pk=user_id)
if not sync_states.exists():
- self.stdout.write(
- self.style.WARNING("Нет пользователей с LDAP DN.")
- )
+ self.stdout.write(self.style.WARNING("Нет пользователей с LDAP DN."))
return
- self.stdout.write(
- f"Найдено записей LdapSyncState: {sync_states.count()}"
- )
+ self.stdout.write(f"Найдено записей LdapSyncState: {sync_states.count()}")
updated = 0
skipped_already_set = 0
@@ -107,9 +97,7 @@ class Command(BaseCommand):
except Employee.DoesNotExist:
if verbose:
self.stdout.write(
- self.style.WARNING(
- f" [SKIP] pk={pk}: Employee не найден в БД"
- )
+ self.style.WARNING(f" [SKIP] pk={pk}: Employee не найден в БД")
)
skipped_no_employee += 1
continue
@@ -120,17 +108,14 @@ class Command(BaseCommand):
current_value = ldap_user.employee_number or None
except LdapUser.DoesNotExist:
self.stdout.write(
- self.style.ERROR(
- f" [ERROR] pk={pk}, DN={dn}: не найден в LDAP"
- )
+ self.style.ERROR(f" [ERROR] pk={pk}, DN={dn}: не найден в LDAP")
)
errors += 1
continue
except Exception as e:
self.stdout.write(
self.style.ERROR(
- f" [ERROR] pk={pk}, DN={dn}: "
- f"не удалось прочитать — {e}"
+ f" [ERROR] pk={pk}, DN={dn}: не удалось прочитать — {e}"
)
)
errors += 1
@@ -204,7 +189,5 @@ class Command(BaseCommand):
if dry_run and updated > 0:
self.stdout.write("")
self.stdout.write(
- self.style.WARNING(
- "Для применения изменений запустите с --no-dry-run"
- )
+ self.style.WARNING("Для применения изменений запустите с --no-dry-run")
)
diff --git a/backend/employees/migrations/0001_initial.py b/backend/employees/migrations/0001_initial.py
index 2d892d4a..5ffebcd1 100644
--- a/backend/employees/migrations/0001_initial.py
+++ b/backend/employees/migrations/0001_initial.py
@@ -2,70 +2,224 @@
import django.db.models.deletion
import django.utils.timezone
-import employees.models
import phonenumber_field.modelfields
from django.conf import settings
from django.db import migrations, models
+import employees.models
+
class Migration(migrations.Migration):
-
initial = True
dependencies = [
- ('auth', '0012_alter_user_first_name_max_length'),
+ ("auth", "0012_alter_user_first_name_max_length"),
]
operations = [
migrations.CreateModel(
- name='Employee',
+ name="Employee",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('password', models.CharField(max_length=128, verbose_name='password')),
- ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
- ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
- ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
- ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
- ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
- ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
- ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
- ('gender', models.PositiveSmallIntegerField(choices=[(1, 'Мужской'), (2, 'Женский'), (0, 'Не указан')], default=0, verbose_name='Пол')),
- ('avatar', models.ImageField(upload_to='users/avatars', verbose_name='Фото')),
- ('phone_number', phonenumber_field.modelfields.PhoneNumberField(max_length=100, region=None, unique=True, verbose_name='Номер телефона')),
- ('patronymic', models.CharField(blank=True, max_length=100, verbose_name='Отчество')),
- ('birth_date', models.DateField(blank=True, null=True, verbose_name='Дата рождения')),
- ('position', models.CharField(max_length=255, verbose_name='Должность')),
- ('email', models.EmailField(max_length=254, unique=True, verbose_name='email address')),
- ('telegram', models.CharField(blank=True, max_length=100, verbose_name='Telegram')),
- ('whatsapp', phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, region=None, verbose_name='WhatsApp')),
- ('created_at', models.DateTimeField(auto_now_add=True)),
- ('updated_at', models.DateTimeField(auto_now=True)),
- ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
- ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("password", models.CharField(max_length=128, verbose_name="password")),
+ (
+ "last_login",
+ models.DateTimeField(
+ blank=True, null=True, verbose_name="last login"
+ ),
+ ),
+ (
+ "is_superuser",
+ models.BooleanField(
+ default=False,
+ help_text="Designates that this user has all permissions without explicitly assigning them.",
+ verbose_name="superuser status",
+ ),
+ ),
+ (
+ "first_name",
+ models.CharField(
+ blank=True, max_length=150, verbose_name="first name"
+ ),
+ ),
+ (
+ "last_name",
+ models.CharField(
+ blank=True, max_length=150, verbose_name="last name"
+ ),
+ ),
+ (
+ "is_staff",
+ models.BooleanField(
+ default=False,
+ help_text="Designates whether the user can log into this admin site.",
+ verbose_name="staff status",
+ ),
+ ),
+ (
+ "is_active",
+ models.BooleanField(
+ default=True,
+ help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
+ verbose_name="active",
+ ),
+ ),
+ (
+ "date_joined",
+ models.DateTimeField(
+ default=django.utils.timezone.now, verbose_name="date joined"
+ ),
+ ),
+ (
+ "gender",
+ models.PositiveSmallIntegerField(
+ choices=[(1, "Мужской"), (2, "Женский"), (0, "Не указан")],
+ default=0,
+ verbose_name="Пол",
+ ),
+ ),
+ (
+ "avatar",
+ models.ImageField(upload_to="users/avatars", verbose_name="Фото"),
+ ),
+ (
+ "phone_number",
+ phonenumber_field.modelfields.PhoneNumberField(
+ max_length=100,
+ region=None,
+ unique=True,
+ verbose_name="Номер телефона",
+ ),
+ ),
+ (
+ "patronymic",
+ models.CharField(
+ blank=True, max_length=100, verbose_name="Отчество"
+ ),
+ ),
+ (
+ "birth_date",
+ models.DateField(
+ blank=True, null=True, verbose_name="Дата рождения"
+ ),
+ ),
+ (
+ "position",
+ models.CharField(max_length=255, verbose_name="Должность"),
+ ),
+ (
+ "email",
+ models.EmailField(
+ max_length=254, unique=True, verbose_name="email address"
+ ),
+ ),
+ (
+ "telegram",
+ models.CharField(
+ blank=True, max_length=100, verbose_name="Telegram"
+ ),
+ ),
+ (
+ "whatsapp",
+ phonenumber_field.modelfields.PhoneNumberField(
+ blank=True, max_length=128, region=None, verbose_name="WhatsApp"
+ ),
+ ),
+ ("created_at", models.DateTimeField(auto_now_add=True)),
+ ("updated_at", models.DateTimeField(auto_now=True)),
+ (
+ "groups",
+ models.ManyToManyField(
+ blank=True,
+ help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
+ related_name="user_set",
+ related_query_name="user",
+ to="auth.group",
+ verbose_name="groups",
+ ),
+ ),
+ (
+ "user_permissions",
+ models.ManyToManyField(
+ blank=True,
+ help_text="Specific permissions for this user.",
+ related_name="user_set",
+ related_query_name="user",
+ to="auth.permission",
+ verbose_name="user permissions",
+ ),
+ ),
],
options={
- 'verbose_name': 'Сотрудник',
- 'verbose_name_plural': 'Сотрудники',
- 'ordering': ('last_name', 'first_name'),
+ "verbose_name": "Сотрудник",
+ "verbose_name_plural": "Сотрудники",
+ "ordering": ("last_name", "first_name"),
},
managers=[
- ('objects', employees.models.EmployeeManager()),
+ ("objects", employees.models.EmployeeManager()),
],
),
migrations.CreateModel(
- name='EmployeeAction',
+ name="EmployeeAction",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('action', models.CharField(choices=[('hired', 'Принят'), ('dismissed', 'Уволен'), ('on_leave', 'В отпуске'), ('returned_from_leave', 'Вернулся из отпуска'), ('on_maternity', 'В декрете'), ('returned_from_maternity', 'Вернулся из декрета'), ('transferred', 'Переведен'), ('rehired', 'Восстановлен')], max_length=32, verbose_name='Кадровое событие')),
- ('date', models.DateTimeField(verbose_name='Дата действия')),
- ('comment', models.TextField(blank=True, verbose_name='Комментарий/причина')),
- ('extra', models.JSONField(blank=True, null=True, verbose_name='Дополнительно')),
- ('employee', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='actions', to=settings.AUTH_USER_MODEL)),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "action",
+ models.CharField(
+ choices=[
+ ("hired", "Принят"),
+ ("dismissed", "Уволен"),
+ ("on_leave", "В отпуске"),
+ ("returned_from_leave", "Вернулся из отпуска"),
+ ("on_maternity", "В декрете"),
+ ("returned_from_maternity", "Вернулся из декрета"),
+ ("transferred", "Переведен"),
+ ("rehired", "Восстановлен"),
+ ],
+ max_length=32,
+ verbose_name="Кадровое событие",
+ ),
+ ),
+ ("date", models.DateTimeField(verbose_name="Дата действия")),
+ (
+ "comment",
+ models.TextField(blank=True, verbose_name="Комментарий/причина"),
+ ),
+ (
+ "extra",
+ models.JSONField(
+ blank=True, null=True, verbose_name="Дополнительно"
+ ),
+ ),
+ (
+ "employee",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="actions",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
],
options={
- 'verbose_name': 'Кадровое событие',
- 'verbose_name_plural': 'Кадровые события',
- 'ordering': ['-date'],
+ "verbose_name": "Кадровое событие",
+ "verbose_name_plural": "Кадровые события",
+ "ordering": ["-date"],
},
),
]
diff --git a/backend/employees/migrations/0002_skill_remove_employee_position_employee_wechat_and_more.py b/backend/employees/migrations/0002_skill_remove_employee_position_employee_wechat_and_more.py
index 73640381..8b7569e8 100644
--- a/backend/employees/migrations/0002_skill_remove_employee_position_employee_wechat_and_more.py
+++ b/backend/employees/migrations/0002_skill_remove_employee_position_employee_wechat_and_more.py
@@ -7,117 +7,246 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('auth', '0012_alter_user_first_name_max_length'),
- ('employees', '0001_initial'),
+ ("auth", "0012_alter_user_first_name_max_length"),
+ ("employees", "0001_initial"),
]
operations = [
migrations.CreateModel(
- name='Skill',
+ name="Skill",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('name', models.CharField(max_length=100, unique=True)),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("name", models.CharField(max_length=100, unique=True)),
],
options={
- 'verbose_name': 'Навык',
- 'verbose_name_plural': 'Навыки',
+ "verbose_name": "Навык",
+ "verbose_name_plural": "Навыки",
},
),
migrations.RemoveField(
- model_name='employee',
- name='position',
+ model_name="employee",
+ name="position",
),
migrations.AddField(
- model_name='employee',
- name='wechat',
- field=phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, region=None, verbose_name='WeChat'),
+ model_name="employee",
+ name="wechat",
+ field=phonenumber_field.modelfields.PhoneNumberField(
+ blank=True, max_length=128, region=None, verbose_name="WeChat"
+ ),
),
migrations.AlterField(
- model_name='employee',
- name='avatar',
- field=models.ImageField(blank=True, null=True, upload_to='users/avatars', verbose_name='Фото'),
+ model_name="employee",
+ name="avatar",
+ field=models.ImageField(
+ blank=True, null=True, upload_to="users/avatars", verbose_name="Фото"
+ ),
),
migrations.AlterField(
- model_name='employee',
- name='gender',
- field=models.PositiveSmallIntegerField(blank=True, choices=[(1, 'Мужской'), (2, 'Женский'), (0, 'Не указан')], default=0, verbose_name='Пол'),
+ model_name="employee",
+ name="gender",
+ field=models.PositiveSmallIntegerField(
+ blank=True,
+ choices=[(1, "Мужской"), (2, "Женский"), (0, "Не указан")],
+ default=0,
+ verbose_name="Пол",
+ ),
),
migrations.CreateModel(
- name='Absence',
+ name="Absence",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('type', models.CharField(choices=[('vacation', 'Отпуск'), ('sick_leave', 'Больничный'), ('other', 'Другое')], max_length=32, verbose_name='Тип отсутствия')),
- ('date_from', models.DateField(verbose_name='Дата начала')),
- ('date_to', models.DateField(verbose_name='Дата окончания')),
- ('comment', models.TextField(blank=True, verbose_name='Комментарий')),
- ('status', models.CharField(choices=[('pending', 'На рассмотрении'), ('approved', 'Утверждено'), ('rejected', 'Отклонено')], default='pending', max_length=16, verbose_name='Статус')),
- ('employee', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='absences', to=settings.AUTH_USER_MODEL)),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "type",
+ models.CharField(
+ choices=[
+ ("vacation", "Отпуск"),
+ ("sick_leave", "Больничный"),
+ ("other", "Другое"),
+ ],
+ max_length=32,
+ verbose_name="Тип отсутствия",
+ ),
+ ),
+ ("date_from", models.DateField(verbose_name="Дата начала")),
+ ("date_to", models.DateField(verbose_name="Дата окончания")),
+ ("comment", models.TextField(blank=True, verbose_name="Комментарий")),
+ (
+ "status",
+ models.CharField(
+ choices=[
+ ("pending", "На рассмотрении"),
+ ("approved", "Утверждено"),
+ ("rejected", "Отклонено"),
+ ],
+ default="pending",
+ max_length=16,
+ verbose_name="Статус",
+ ),
+ ),
+ (
+ "employee",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="absences",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
],
options={
- 'verbose_name': 'Отсутствие сотрудника',
- 'verbose_name_plural': 'Отсутствия сотрудников',
+ "verbose_name": "Отсутствие сотрудника",
+ "verbose_name_plural": "Отсутствия сотрудников",
},
),
migrations.CreateModel(
- name='Department',
+ name="Department",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('name', models.CharField(max_length=255, unique=True, verbose_name='Название отдела')),
- ('description', models.TextField(blank=True, verbose_name='Описание')),
- ('created_at', models.DateTimeField(auto_now_add=True)),
- ('head', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='headed_departments', to=settings.AUTH_USER_MODEL, verbose_name='Руководитель отдела')),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "name",
+ models.CharField(
+ max_length=255, unique=True, verbose_name="Название отдела"
+ ),
+ ),
+ ("description", models.TextField(blank=True, verbose_name="Описание")),
+ ("created_at", models.DateTimeField(auto_now_add=True)),
+ (
+ "head",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="headed_departments",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Руководитель отдела",
+ ),
+ ),
],
options={
- 'verbose_name': 'Отдел',
- 'verbose_name_plural': 'Отделы',
+ "verbose_name": "Отдел",
+ "verbose_name_plural": "Отделы",
},
),
migrations.CreateModel(
- name='Education',
+ name="Education",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('institution', models.CharField(max_length=255)),
- ('degree', models.CharField(max_length=100)),
- ('graduation_year', models.IntegerField()),
- ('employee', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='educations', to=settings.AUTH_USER_MODEL)),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("institution", models.CharField(max_length=255)),
+ ("degree", models.CharField(max_length=100)),
+ ("graduation_year", models.IntegerField()),
+ (
+ "employee",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="educations",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
],
options={
- 'verbose_name': 'Образование',
- 'verbose_name_plural': 'Образование',
+ "verbose_name": "Образование",
+ "verbose_name_plural": "Образование",
},
),
migrations.CreateModel(
- name='EmployeePosition',
+ name="EmployeePosition",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('title', models.CharField(max_length=255, verbose_name='Название должности')),
- ('date_from', models.DateField(verbose_name='Дата начала должности')),
- ('date_to', models.DateField(blank=True, null=True, verbose_name='Дата окончания должности')),
- ('department', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='employees', to='employees.department')),
- ('employee', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='positions', to=settings.AUTH_USER_MODEL)),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "title",
+ models.CharField(max_length=255, verbose_name="Название должности"),
+ ),
+ ("date_from", models.DateField(verbose_name="Дата начала должности")),
+ (
+ "date_to",
+ models.DateField(
+ blank=True, null=True, verbose_name="Дата окончания должности"
+ ),
+ ),
+ (
+ "department",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="employees",
+ to="employees.department",
+ ),
+ ),
+ (
+ "employee",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="positions",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
],
options={
- 'verbose_name': 'Должность сотрудника',
- 'verbose_name_plural': 'Должности сотрудников',
+ "verbose_name": "Должность сотрудника",
+ "verbose_name_plural": "Должности сотрудников",
},
),
migrations.AddField(
- model_name='employee',
- name='skills',
- field=models.ManyToManyField(blank=True, related_name='employees', to='employees.skill'),
+ model_name="employee",
+ name="skills",
+ field=models.ManyToManyField(
+ blank=True, related_name="employees", to="employees.skill"
+ ),
),
migrations.AddIndex(
- model_name='employee',
- index=models.Index(fields=['last_name', 'first_name'], name='employees_e_last_na_99a4c0_idx'),
+ model_name="employee",
+ index=models.Index(
+ fields=["last_name", "first_name"],
+ name="employees_e_last_na_99a4c0_idx",
+ ),
),
migrations.AddIndex(
- model_name='employee',
- index=models.Index(fields=['email'], name='employees_e_email_8f5bbc_idx'),
+ model_name="employee",
+ index=models.Index(fields=["email"], name="employees_e_email_8f5bbc_idx"),
),
migrations.AddIndex(
- model_name='employee',
- index=models.Index(fields=['phone_number'], name='employees_e_phone_n_b0f1e7_idx'),
+ model_name="employee",
+ index=models.Index(
+ fields=["phone_number"], name="employees_e_phone_n_b0f1e7_idx"
+ ),
),
]
diff --git a/backend/employees/migrations/0003_employee_sms_activation_code_and_more.py b/backend/employees/migrations/0003_employee_sms_activation_code_and_more.py
index 2f3066a2..76c33aee 100644
--- a/backend/employees/migrations/0003_employee_sms_activation_code_and_more.py
+++ b/backend/employees/migrations/0003_employee_sms_activation_code_and_more.py
@@ -4,20 +4,19 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0002_skill_remove_employee_position_employee_wechat_and_more'),
+ ("employees", "0002_skill_remove_employee_position_employee_wechat_and_more"),
]
operations = [
migrations.AddField(
- model_name='employee',
- name='sms_activation_code',
+ model_name="employee",
+ name="sms_activation_code",
field=models.CharField(blank=True, max_length=6, null=True),
),
migrations.AlterField(
- model_name='employee',
- name='is_active',
+ model_name="employee",
+ name="is_active",
field=models.BooleanField(default=False),
),
]
diff --git a/backend/employees/migrations/0004_alter_employee_wechat.py b/backend/employees/migrations/0004_alter_employee_wechat.py
index 0a4c55c2..5b488f25 100644
--- a/backend/employees/migrations/0004_alter_employee_wechat.py
+++ b/backend/employees/migrations/0004_alter_employee_wechat.py
@@ -4,15 +4,14 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0003_employee_sms_activation_code_and_more'),
+ ("employees", "0003_employee_sms_activation_code_and_more"),
]
operations = [
migrations.AlterField(
- model_name='employee',
- name='wechat',
- field=models.CharField(blank=True, max_length=100, verbose_name='WeChat'),
+ model_name="employee",
+ name="wechat",
+ field=models.CharField(blank=True, max_length=100, verbose_name="WeChat"),
),
]
diff --git a/backend/employees/migrations/0005_alter_department_options_employeedepartment.py b/backend/employees/migrations/0005_alter_department_options_employeedepartment.py
index 42cc8fd8..6184c3a0 100644
--- a/backend/employees/migrations/0005_alter_department_options_employeedepartment.py
+++ b/backend/employees/migrations/0005_alter_department_options_employeedepartment.py
@@ -6,30 +6,49 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0004_alter_employee_wechat'),
+ ("employees", "0004_alter_employee_wechat"),
]
operations = [
migrations.AlterModelOptions(
- name='department',
+ name="department",
options={},
),
migrations.CreateModel(
- name='EmployeeDepartment',
+ name="EmployeeDepartment",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('role', models.CharField(blank=True, max_length=100)),
- ('date_from', models.DateField(blank=True, null=True)),
- ('date_to', models.DateField(blank=True, null=True)),
- ('is_active', models.BooleanField(default=True)),
- ('department', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='employees.department')),
- ('employee', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("role", models.CharField(blank=True, max_length=100)),
+ ("date_from", models.DateField(blank=True, null=True)),
+ ("date_to", models.DateField(blank=True, null=True)),
+ ("is_active", models.BooleanField(default=True)),
+ (
+ "department",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to="employees.department",
+ ),
+ ),
+ (
+ "employee",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
],
options={
- 'verbose_name': 'Отдел',
- 'verbose_name_plural': 'Отделы',
+ "verbose_name": "Отдел",
+ "verbose_name_plural": "Отделы",
},
),
]
diff --git a/backend/employees/migrations/0006_alter_employeedepartment_options_and_more.py b/backend/employees/migrations/0006_alter_employeedepartment_options_and_more.py
index 8a5059d0..b228a060 100644
--- a/backend/employees/migrations/0006_alter_employeedepartment_options_and_more.py
+++ b/backend/employees/migrations/0006_alter_employeedepartment_options_and_more.py
@@ -6,28 +6,38 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0005_alter_department_options_employeedepartment'),
+ ("employees", "0005_alter_department_options_employeedepartment"),
]
operations = [
migrations.AlterModelOptions(
- name='employeedepartment',
- options={'verbose_name': 'Принадлежность к отделу', 'verbose_name_plural': 'Принадлежности к отделам'},
+ name="employeedepartment",
+ options={
+ "verbose_name": "Принадлежность к отделу",
+ "verbose_name_plural": "Принадлежности к отделам",
+ },
),
migrations.AlterField(
- model_name='employeedepartment',
- name='department',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='employeedepartment', to='employees.department'),
+ model_name="employeedepartment",
+ name="department",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="employeedepartment",
+ to="employees.department",
+ ),
),
migrations.AlterField(
- model_name='employeedepartment',
- name='employee',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='employeedepartment', to=settings.AUTH_USER_MODEL),
+ model_name="employeedepartment",
+ name="employee",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="employeedepartment",
+ to=settings.AUTH_USER_MODEL,
+ ),
),
migrations.AlterUniqueTogether(
- name='employeedepartment',
- unique_together={('employee', 'department', 'date_from')},
+ name="employeedepartment",
+ unique_together={("employee", "department", "date_from")},
),
]
diff --git a/backend/employees/migrations/0007_alter_absence_options_alter_department_options_and_more.py b/backend/employees/migrations/0007_alter_absence_options_alter_department_options_and_more.py
index 8f16c60b..4cbb9c80 100644
--- a/backend/employees/migrations/0007_alter_absence_options_alter_department_options_and_more.py
+++ b/backend/employees/migrations/0007_alter_absence_options_alter_department_options_and_more.py
@@ -7,252 +7,369 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0006_alter_employeedepartment_options_and_more'),
+ ("employees", "0006_alter_employeedepartment_options_and_more"),
]
operations = [
migrations.AlterModelOptions(
- name='absence',
- options={'verbose_name': 'Employee absence', 'verbose_name_plural': 'Employee absences'},
+ name="absence",
+ options={
+ "verbose_name": "Employee absence",
+ "verbose_name_plural": "Employee absences",
+ },
),
migrations.AlterModelOptions(
- name='department',
- options={'verbose_name': 'Department', 'verbose_name_plural': 'Departments'},
+ name="department",
+ options={
+ "verbose_name": "Department",
+ "verbose_name_plural": "Departments",
+ },
),
migrations.AlterModelOptions(
- name='education',
- options={'verbose_name': 'Education', 'verbose_name_plural': 'Educations'},
+ name="education",
+ options={"verbose_name": "Education", "verbose_name_plural": "Educations"},
),
migrations.AlterModelOptions(
- name='employee',
- options={'ordering': ('last_name', 'first_name'), 'verbose_name': 'Employee', 'verbose_name_plural': 'Employees'},
+ name="employee",
+ options={
+ "ordering": ("last_name", "first_name"),
+ "verbose_name": "Employee",
+ "verbose_name_plural": "Employees",
+ },
),
migrations.AlterModelOptions(
- name='employeeaction',
- options={'ordering': ['-date'], 'verbose_name': 'Personnel action', 'verbose_name_plural': 'Personnel actions'},
+ name="employeeaction",
+ options={
+ "ordering": ["-date"],
+ "verbose_name": "Personnel action",
+ "verbose_name_plural": "Personnel actions",
+ },
),
migrations.AlterModelOptions(
- name='employeedepartment',
- options={'verbose_name': 'Membership in department', 'verbose_name_plural': 'Memberships in departments'},
+ name="employeedepartment",
+ options={
+ "verbose_name": "Membership in department",
+ "verbose_name_plural": "Memberships in departments",
+ },
),
migrations.AlterModelOptions(
- name='employeeposition',
- options={'verbose_name': 'Employee position', 'verbose_name_plural': 'Employee positions'},
+ name="employeeposition",
+ options={
+ "verbose_name": "Employee position",
+ "verbose_name_plural": "Employee positions",
+ },
),
migrations.AlterModelOptions(
- name='skill',
- options={'verbose_name': 'Skill', 'verbose_name_plural': 'Skills'},
+ name="skill",
+ options={"verbose_name": "Skill", "verbose_name_plural": "Skills"},
),
migrations.AlterField(
- model_name='absence',
- name='comment',
- field=models.TextField(blank=True, verbose_name='Comment'),
+ model_name="absence",
+ name="comment",
+ field=models.TextField(blank=True, verbose_name="Comment"),
),
migrations.AlterField(
- model_name='absence',
- name='date_from',
- field=models.DateField(verbose_name='Start date'),
+ model_name="absence",
+ name="date_from",
+ field=models.DateField(verbose_name="Start date"),
),
migrations.AlterField(
- model_name='absence',
- name='date_to',
- field=models.DateField(verbose_name='End date'),
+ model_name="absence",
+ name="date_to",
+ field=models.DateField(verbose_name="End date"),
),
migrations.AlterField(
- model_name='absence',
- name='employee',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='absences', to=settings.AUTH_USER_MODEL, verbose_name='Employee'),
+ model_name="absence",
+ name="employee",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="absences",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Employee",
+ ),
),
migrations.AlterField(
- model_name='absence',
- name='status',
- field=models.CharField(choices=[('pending', 'Pending'), ('approved', 'Approved'), ('rejected', 'Rejected')], default='pending', max_length=16, verbose_name='Status'),
+ model_name="absence",
+ name="status",
+ field=models.CharField(
+ choices=[
+ ("pending", "Pending"),
+ ("approved", "Approved"),
+ ("rejected", "Rejected"),
+ ],
+ default="pending",
+ max_length=16,
+ verbose_name="Status",
+ ),
),
migrations.AlterField(
- model_name='absence',
- name='type',
- field=models.CharField(choices=[('vacation', 'Vacation'), ('sick_leave', 'Sick leave'), ('other', 'Other')], max_length=32, verbose_name='Absence type'),
+ model_name="absence",
+ name="type",
+ field=models.CharField(
+ choices=[
+ ("vacation", "Vacation"),
+ ("sick_leave", "Sick leave"),
+ ("other", "Other"),
+ ],
+ max_length=32,
+ verbose_name="Absence type",
+ ),
),
migrations.AlterField(
- model_name='department',
- name='created_at',
- field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'),
+ model_name="department",
+ name="created_at",
+ field=models.DateTimeField(auto_now_add=True, verbose_name="Created at"),
),
migrations.AlterField(
- model_name='department',
- name='description',
- field=models.TextField(blank=True, verbose_name='Description'),
+ model_name="department",
+ name="description",
+ field=models.TextField(blank=True, verbose_name="Description"),
),
migrations.AlterField(
- model_name='department',
- name='head',
- field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='headed_departments', to=settings.AUTH_USER_MODEL, verbose_name='Department head'),
+ model_name="department",
+ name="head",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="headed_departments",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Department head",
+ ),
),
migrations.AlterField(
- model_name='department',
- name='name',
- field=models.CharField(max_length=255, unique=True, verbose_name='Department name'),
+ model_name="department",
+ name="name",
+ field=models.CharField(
+ max_length=255, unique=True, verbose_name="Department name"
+ ),
),
migrations.AlterField(
- model_name='education',
- name='degree',
- field=models.CharField(max_length=100, verbose_name='Degree'),
+ model_name="education",
+ name="degree",
+ field=models.CharField(max_length=100, verbose_name="Degree"),
),
migrations.AlterField(
- model_name='education',
- name='employee',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='educations', to=settings.AUTH_USER_MODEL, verbose_name='Employee'),
+ model_name="education",
+ name="employee",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="educations",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Employee",
+ ),
),
migrations.AlterField(
- model_name='education',
- name='graduation_year',
- field=models.IntegerField(verbose_name='Graduation year'),
+ model_name="education",
+ name="graduation_year",
+ field=models.IntegerField(verbose_name="Graduation year"),
),
migrations.AlterField(
- model_name='education',
- name='institution',
- field=models.CharField(max_length=255, verbose_name='Institution'),
+ model_name="education",
+ name="institution",
+ field=models.CharField(max_length=255, verbose_name="Institution"),
),
migrations.AlterField(
- model_name='employee',
- name='avatar',
- field=models.ImageField(blank=True, null=True, upload_to='users/avatars', verbose_name='Photo'),
+ model_name="employee",
+ name="avatar",
+ field=models.ImageField(
+ blank=True, null=True, upload_to="users/avatars", verbose_name="Photo"
+ ),
),
migrations.AlterField(
- model_name='employee',
- name='birth_date',
- field=models.DateField(blank=True, null=True, verbose_name='Date of birth'),
+ model_name="employee",
+ name="birth_date",
+ field=models.DateField(blank=True, null=True, verbose_name="Date of birth"),
),
migrations.AlterField(
- model_name='employee',
- name='created_at',
- field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'),
+ model_name="employee",
+ name="created_at",
+ field=models.DateTimeField(auto_now_add=True, verbose_name="Created at"),
),
migrations.AlterField(
- model_name='employee',
- name='email',
- field=models.EmailField(max_length=254, unique=True, verbose_name='Email address'),
+ model_name="employee",
+ name="email",
+ field=models.EmailField(
+ max_length=254, unique=True, verbose_name="Email address"
+ ),
),
migrations.AlterField(
- model_name='employee',
- name='gender',
- field=models.PositiveSmallIntegerField(blank=True, choices=[(1, 'Мужской'), (2, 'Женский'), (0, 'Не указан')], default=0, verbose_name='Gender'),
+ model_name="employee",
+ name="gender",
+ field=models.PositiveSmallIntegerField(
+ blank=True,
+ choices=[(1, "Мужской"), (2, "Женский"), (0, "Не указан")],
+ default=0,
+ verbose_name="Gender",
+ ),
),
migrations.AlterField(
- model_name='employee',
- name='is_active',
- field=models.BooleanField(default=False, verbose_name='Is active'),
+ model_name="employee",
+ name="is_active",
+ field=models.BooleanField(default=False, verbose_name="Is active"),
),
migrations.AlterField(
- model_name='employee',
- name='patronymic',
- field=models.CharField(blank=True, max_length=100, verbose_name='Patronymic'),
+ model_name="employee",
+ name="patronymic",
+ field=models.CharField(
+ blank=True, max_length=100, verbose_name="Patronymic"
+ ),
),
migrations.AlterField(
- model_name='employee',
- name='phone_number',
- field=phonenumber_field.modelfields.PhoneNumberField(max_length=100, region=None, unique=True, verbose_name='Phone number'),
+ model_name="employee",
+ name="phone_number",
+ field=phonenumber_field.modelfields.PhoneNumberField(
+ max_length=100, region=None, unique=True, verbose_name="Phone number"
+ ),
),
migrations.AlterField(
- model_name='employee',
- name='skills',
- field=models.ManyToManyField(blank=True, related_name='employees', to='employees.skill', verbose_name='Skills'),
+ model_name="employee",
+ name="skills",
+ field=models.ManyToManyField(
+ blank=True,
+ related_name="employees",
+ to="employees.skill",
+ verbose_name="Skills",
+ ),
),
migrations.AlterField(
- model_name='employee',
- name='sms_activation_code',
- field=models.CharField(blank=True, max_length=6, null=True, verbose_name='SMS activation code'),
+ model_name="employee",
+ name="sms_activation_code",
+ field=models.CharField(
+ blank=True, max_length=6, null=True, verbose_name="SMS activation code"
+ ),
),
migrations.AlterField(
- model_name='employee',
- name='updated_at',
- field=models.DateTimeField(auto_now=True, verbose_name='Updated at'),
+ model_name="employee",
+ name="updated_at",
+ field=models.DateTimeField(auto_now=True, verbose_name="Updated at"),
),
migrations.AlterField(
- model_name='employeeaction',
- name='action',
- field=models.CharField(choices=[('hired', 'Принят'), ('dismissed', 'Уволен'), ('on_leave', 'В отпуске'), ('returned_from_leave', 'Вернулся из отпуска'), ('on_maternity', 'В декрете'), ('returned_from_maternity', 'Вернулся из декрета'), ('transferred', 'Переведен'), ('rehired', 'Восстановлен')], max_length=32, verbose_name='Personnel action'),
+ model_name="employeeaction",
+ name="action",
+ field=models.CharField(
+ choices=[
+ ("hired", "Принят"),
+ ("dismissed", "Уволен"),
+ ("on_leave", "В отпуске"),
+ ("returned_from_leave", "Вернулся из отпуска"),
+ ("on_maternity", "В декрете"),
+ ("returned_from_maternity", "Вернулся из декрета"),
+ ("transferred", "Переведен"),
+ ("rehired", "Восстановлен"),
+ ],
+ max_length=32,
+ verbose_name="Personnel action",
+ ),
),
migrations.AlterField(
- model_name='employeeaction',
- name='comment',
- field=models.TextField(blank=True, verbose_name='Comment / Reason'),
+ model_name="employeeaction",
+ name="comment",
+ field=models.TextField(blank=True, verbose_name="Comment / Reason"),
),
migrations.AlterField(
- model_name='employeeaction',
- name='date',
- field=models.DateTimeField(verbose_name='Action date'),
+ model_name="employeeaction",
+ name="date",
+ field=models.DateTimeField(verbose_name="Action date"),
),
migrations.AlterField(
- model_name='employeeaction',
- name='employee',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='actions', to=settings.AUTH_USER_MODEL, verbose_name='Employee'),
+ model_name="employeeaction",
+ name="employee",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="actions",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Employee",
+ ),
),
migrations.AlterField(
- model_name='employeeaction',
- name='extra',
- field=models.JSONField(blank=True, null=True, verbose_name='Extra'),
+ model_name="employeeaction",
+ name="extra",
+ field=models.JSONField(blank=True, null=True, verbose_name="Extra"),
),
migrations.AlterField(
- model_name='employeedepartment',
- name='date_from',
- field=models.DateField(blank=True, null=True, verbose_name='Date from'),
+ model_name="employeedepartment",
+ name="date_from",
+ field=models.DateField(blank=True, null=True, verbose_name="Date from"),
),
migrations.AlterField(
- model_name='employeedepartment',
- name='date_to',
- field=models.DateField(blank=True, null=True, verbose_name='Date to'),
+ model_name="employeedepartment",
+ name="date_to",
+ field=models.DateField(blank=True, null=True, verbose_name="Date to"),
),
migrations.AlterField(
- model_name='employeedepartment',
- name='department',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='employeedepartment', to='employees.department', verbose_name='Department'),
+ model_name="employeedepartment",
+ name="department",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="employeedepartment",
+ to="employees.department",
+ verbose_name="Department",
+ ),
),
migrations.AlterField(
- model_name='employeedepartment',
- name='employee',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='employeedepartment', to=settings.AUTH_USER_MODEL, verbose_name='Employee'),
+ model_name="employeedepartment",
+ name="employee",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="employeedepartment",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Employee",
+ ),
),
migrations.AlterField(
- model_name='employeedepartment',
- name='is_active',
- field=models.BooleanField(default=True, verbose_name='Is active'),
+ model_name="employeedepartment",
+ name="is_active",
+ field=models.BooleanField(default=True, verbose_name="Is active"),
),
migrations.AlterField(
- model_name='employeedepartment',
- name='role',
- field=models.CharField(blank=True, max_length=100, verbose_name='Role in department'),
+ model_name="employeedepartment",
+ name="role",
+ field=models.CharField(
+ blank=True, max_length=100, verbose_name="Role in department"
+ ),
),
migrations.AlterField(
- model_name='employeeposition',
- name='date_from',
- field=models.DateField(verbose_name='Start date'),
+ model_name="employeeposition",
+ name="date_from",
+ field=models.DateField(verbose_name="Start date"),
),
migrations.AlterField(
- model_name='employeeposition',
- name='date_to',
- field=models.DateField(blank=True, null=True, verbose_name='End date'),
+ model_name="employeeposition",
+ name="date_to",
+ field=models.DateField(blank=True, null=True, verbose_name="End date"),
),
migrations.AlterField(
- model_name='employeeposition',
- name='department',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='employees', to='employees.department', verbose_name='Department'),
+ model_name="employeeposition",
+ name="department",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="employees",
+ to="employees.department",
+ verbose_name="Department",
+ ),
),
migrations.AlterField(
- model_name='employeeposition',
- name='employee',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='positions', to=settings.AUTH_USER_MODEL, verbose_name='Employee'),
+ model_name="employeeposition",
+ name="employee",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="positions",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Employee",
+ ),
),
migrations.AlterField(
- model_name='employeeposition',
- name='title',
- field=models.CharField(max_length=255, verbose_name='Position title'),
+ model_name="employeeposition",
+ name="title",
+ field=models.CharField(max_length=255, verbose_name="Position title"),
),
migrations.AlterField(
- model_name='skill',
- name='name',
- field=models.CharField(max_length=100, unique=True, verbose_name='Skill name'),
+ model_name="skill",
+ name="name",
+ field=models.CharField(
+ max_length=100, unique=True, verbose_name="Skill name"
+ ),
),
]
diff --git a/backend/employees/migrations/0008_alter_absence_options_alter_department_options_and_more.py b/backend/employees/migrations/0008_alter_absence_options_alter_department_options_and_more.py
index 4d38cf8b..377c4c44 100644
--- a/backend/employees/migrations/0008_alter_absence_options_alter_department_options_and_more.py
+++ b/backend/employees/migrations/0008_alter_absence_options_alter_department_options_and_more.py
@@ -7,252 +7,353 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0007_alter_absence_options_alter_department_options_and_more'),
+ ("employees", "0007_alter_absence_options_alter_department_options_and_more"),
]
operations = [
migrations.AlterModelOptions(
- name='absence',
- options={'verbose_name': 'Отсутствие сотрудника', 'verbose_name_plural': 'Отсутствия сотрудников'},
+ name="absence",
+ options={
+ "verbose_name": "Отсутствие сотрудника",
+ "verbose_name_plural": "Отсутствия сотрудников",
+ },
),
migrations.AlterModelOptions(
- name='department',
- options={'verbose_name': 'Отдел', 'verbose_name_plural': 'Отделы'},
+ name="department",
+ options={"verbose_name": "Отдел", "verbose_name_plural": "Отделы"},
),
migrations.AlterModelOptions(
- name='education',
- options={'verbose_name': 'Образование', 'verbose_name_plural': 'Образование'},
+ name="education",
+ options={
+ "verbose_name": "Образование",
+ "verbose_name_plural": "Образование",
+ },
),
migrations.AlterModelOptions(
- name='employee',
- options={'ordering': ('last_name', 'first_name'), 'verbose_name': 'Сотрудник', 'verbose_name_plural': 'Сотрудники'},
+ name="employee",
+ options={
+ "ordering": ("last_name", "first_name"),
+ "verbose_name": "Сотрудник",
+ "verbose_name_plural": "Сотрудники",
+ },
),
migrations.AlterModelOptions(
- name='employeeaction',
- options={'ordering': ['-date'], 'verbose_name': 'Кадровое событие', 'verbose_name_plural': 'Кадровые события'},
+ name="employeeaction",
+ options={
+ "ordering": ["-date"],
+ "verbose_name": "Кадровое событие",
+ "verbose_name_plural": "Кадровые события",
+ },
),
migrations.AlterModelOptions(
- name='employeedepartment',
- options={'verbose_name': 'Принадлежность к отделу', 'verbose_name_plural': 'Принадлежности к отделам'},
+ name="employeedepartment",
+ options={
+ "verbose_name": "Принадлежность к отделу",
+ "verbose_name_plural": "Принадлежности к отделам",
+ },
),
migrations.AlterModelOptions(
- name='employeeposition',
- options={'verbose_name': 'Должность сотрудника', 'verbose_name_plural': 'Должности сотрудников'},
+ name="employeeposition",
+ options={
+ "verbose_name": "Должность сотрудника",
+ "verbose_name_plural": "Должности сотрудников",
+ },
),
migrations.AlterModelOptions(
- name='skill',
- options={'verbose_name': 'Навык', 'verbose_name_plural': 'Навыки'},
+ name="skill",
+ options={"verbose_name": "Навык", "verbose_name_plural": "Навыки"},
),
migrations.AlterField(
- model_name='absence',
- name='comment',
- field=models.TextField(blank=True, verbose_name='Комментарий'),
+ model_name="absence",
+ name="comment",
+ field=models.TextField(blank=True, verbose_name="Комментарий"),
),
migrations.AlterField(
- model_name='absence',
- name='date_from',
- field=models.DateField(verbose_name='Дата начала'),
+ model_name="absence",
+ name="date_from",
+ field=models.DateField(verbose_name="Дата начала"),
),
migrations.AlterField(
- model_name='absence',
- name='date_to',
- field=models.DateField(verbose_name='Дата окончания'),
+ model_name="absence",
+ name="date_to",
+ field=models.DateField(verbose_name="Дата окончания"),
),
migrations.AlterField(
- model_name='absence',
- name='employee',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='absences', to=settings.AUTH_USER_MODEL),
+ model_name="absence",
+ name="employee",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="absences",
+ to=settings.AUTH_USER_MODEL,
+ ),
),
migrations.AlterField(
- model_name='absence',
- name='status',
- field=models.CharField(choices=[('pending', 'На рассмотрении'), ('approved', 'Утверждено'), ('rejected', 'Отклонено')], default='pending', max_length=16, verbose_name='Статус'),
+ model_name="absence",
+ name="status",
+ field=models.CharField(
+ choices=[
+ ("pending", "На рассмотрении"),
+ ("approved", "Утверждено"),
+ ("rejected", "Отклонено"),
+ ],
+ default="pending",
+ max_length=16,
+ verbose_name="Статус",
+ ),
),
migrations.AlterField(
- model_name='absence',
- name='type',
- field=models.CharField(choices=[('vacation', 'Отпуск'), ('sick_leave', 'Больничный'), ('other', 'Другое')], max_length=32, verbose_name='Тип отсутствия'),
+ model_name="absence",
+ name="type",
+ field=models.CharField(
+ choices=[
+ ("vacation", "Отпуск"),
+ ("sick_leave", "Больничный"),
+ ("other", "Другое"),
+ ],
+ max_length=32,
+ verbose_name="Тип отсутствия",
+ ),
),
migrations.AlterField(
- model_name='department',
- name='created_at',
+ model_name="department",
+ name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterField(
- model_name='department',
- name='description',
- field=models.TextField(blank=True, verbose_name='Описание'),
+ model_name="department",
+ name="description",
+ field=models.TextField(blank=True, verbose_name="Описание"),
),
migrations.AlterField(
- model_name='department',
- name='head',
- field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='headed_departments', to=settings.AUTH_USER_MODEL, verbose_name='Руководитель отдела'),
+ model_name="department",
+ name="head",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="headed_departments",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Руководитель отдела",
+ ),
),
migrations.AlterField(
- model_name='department',
- name='name',
- field=models.CharField(max_length=255, unique=True, verbose_name='Название отдела'),
+ model_name="department",
+ name="name",
+ field=models.CharField(
+ max_length=255, unique=True, verbose_name="Название отдела"
+ ),
),
migrations.AlterField(
- model_name='education',
- name='degree',
+ model_name="education",
+ name="degree",
field=models.CharField(max_length=100),
),
migrations.AlterField(
- model_name='education',
- name='employee',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='educations', to=settings.AUTH_USER_MODEL),
+ model_name="education",
+ name="employee",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="educations",
+ to=settings.AUTH_USER_MODEL,
+ ),
),
migrations.AlterField(
- model_name='education',
- name='graduation_year',
+ model_name="education",
+ name="graduation_year",
field=models.IntegerField(),
),
migrations.AlterField(
- model_name='education',
- name='institution',
+ model_name="education",
+ name="institution",
field=models.CharField(max_length=255),
),
migrations.AlterField(
- model_name='employee',
- name='avatar',
- field=models.ImageField(blank=True, null=True, upload_to='users/avatars', verbose_name='Фото'),
+ model_name="employee",
+ name="avatar",
+ field=models.ImageField(
+ blank=True, null=True, upload_to="users/avatars", verbose_name="Фото"
+ ),
),
migrations.AlterField(
- model_name='employee',
- name='birth_date',
- field=models.DateField(blank=True, null=True, verbose_name='Дата рождения'),
+ model_name="employee",
+ name="birth_date",
+ field=models.DateField(blank=True, null=True, verbose_name="Дата рождения"),
),
migrations.AlterField(
- model_name='employee',
- name='created_at',
+ model_name="employee",
+ name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterField(
- model_name='employee',
- name='email',
- field=models.EmailField(max_length=254, unique=True, verbose_name='email address'),
+ model_name="employee",
+ name="email",
+ field=models.EmailField(
+ max_length=254, unique=True, verbose_name="email address"
+ ),
),
migrations.AlterField(
- model_name='employee',
- name='gender',
- field=models.PositiveSmallIntegerField(blank=True, choices=[(1, 'Мужской'), (2, 'Женский'), (0, 'Не указан')], default=0, verbose_name='Пол'),
+ model_name="employee",
+ name="gender",
+ field=models.PositiveSmallIntegerField(
+ blank=True,
+ choices=[(1, "Мужской"), (2, "Женский"), (0, "Не указан")],
+ default=0,
+ verbose_name="Пол",
+ ),
),
migrations.AlterField(
- model_name='employee',
- name='is_active',
+ model_name="employee",
+ name="is_active",
field=models.BooleanField(default=False),
),
migrations.AlterField(
- model_name='employee',
- name='patronymic',
- field=models.CharField(blank=True, max_length=100, verbose_name='Отчество'),
+ model_name="employee",
+ name="patronymic",
+ field=models.CharField(blank=True, max_length=100, verbose_name="Отчество"),
),
migrations.AlterField(
- model_name='employee',
- name='phone_number',
- field=phonenumber_field.modelfields.PhoneNumberField(max_length=100, region=None, unique=True, verbose_name='Номер телефона'),
+ model_name="employee",
+ name="phone_number",
+ field=phonenumber_field.modelfields.PhoneNumberField(
+ max_length=100, region=None, unique=True, verbose_name="Номер телефона"
+ ),
),
migrations.AlterField(
- model_name='employee',
- name='skills',
- field=models.ManyToManyField(blank=True, related_name='employees', to='employees.skill'),
+ model_name="employee",
+ name="skills",
+ field=models.ManyToManyField(
+ blank=True, related_name="employees", to="employees.skill"
+ ),
),
migrations.AlterField(
- model_name='employee',
- name='sms_activation_code',
+ model_name="employee",
+ name="sms_activation_code",
field=models.CharField(blank=True, max_length=6, null=True),
),
migrations.AlterField(
- model_name='employee',
- name='updated_at',
+ model_name="employee",
+ name="updated_at",
field=models.DateTimeField(auto_now=True),
),
migrations.AlterField(
- model_name='employeeaction',
- name='action',
- field=models.CharField(choices=[('hired', 'Принят'), ('dismissed', 'Уволен'), ('on_leave', 'В отпуске'), ('returned_from_leave', 'Вернулся из отпуска'), ('on_maternity', 'В декрете'), ('returned_from_maternity', 'Вернулся из декрета'), ('transferred', 'Переведен'), ('rehired', 'Восстановлен')], max_length=32, verbose_name='Кадровое событие'),
+ model_name="employeeaction",
+ name="action",
+ field=models.CharField(
+ choices=[
+ ("hired", "Принят"),
+ ("dismissed", "Уволен"),
+ ("on_leave", "В отпуске"),
+ ("returned_from_leave", "Вернулся из отпуска"),
+ ("on_maternity", "В декрете"),
+ ("returned_from_maternity", "Вернулся из декрета"),
+ ("transferred", "Переведен"),
+ ("rehired", "Восстановлен"),
+ ],
+ max_length=32,
+ verbose_name="Кадровое событие",
+ ),
),
migrations.AlterField(
- model_name='employeeaction',
- name='comment',
- field=models.TextField(blank=True, verbose_name='Комментарий/причина'),
+ model_name="employeeaction",
+ name="comment",
+ field=models.TextField(blank=True, verbose_name="Комментарий/причина"),
),
migrations.AlterField(
- model_name='employeeaction',
- name='date',
- field=models.DateTimeField(verbose_name='Дата действия'),
+ model_name="employeeaction",
+ name="date",
+ field=models.DateTimeField(verbose_name="Дата действия"),
),
migrations.AlterField(
- model_name='employeeaction',
- name='employee',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='actions', to=settings.AUTH_USER_MODEL),
+ model_name="employeeaction",
+ name="employee",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="actions",
+ to=settings.AUTH_USER_MODEL,
+ ),
),
migrations.AlterField(
- model_name='employeeaction',
- name='extra',
- field=models.JSONField(blank=True, null=True, verbose_name='Дополнительно'),
+ model_name="employeeaction",
+ name="extra",
+ field=models.JSONField(blank=True, null=True, verbose_name="Дополнительно"),
),
migrations.AlterField(
- model_name='employeedepartment',
- name='date_from',
+ model_name="employeedepartment",
+ name="date_from",
field=models.DateField(blank=True, null=True),
),
migrations.AlterField(
- model_name='employeedepartment',
- name='date_to',
+ model_name="employeedepartment",
+ name="date_to",
field=models.DateField(blank=True, null=True),
),
migrations.AlterField(
- model_name='employeedepartment',
- name='department',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='employeedepartment', to='employees.department'),
+ model_name="employeedepartment",
+ name="department",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="employeedepartment",
+ to="employees.department",
+ ),
),
migrations.AlterField(
- model_name='employeedepartment',
- name='employee',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='employeedepartment', to=settings.AUTH_USER_MODEL),
+ model_name="employeedepartment",
+ name="employee",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="employeedepartment",
+ to=settings.AUTH_USER_MODEL,
+ ),
),
migrations.AlterField(
- model_name='employeedepartment',
- name='is_active',
+ model_name="employeedepartment",
+ name="is_active",
field=models.BooleanField(default=True),
),
migrations.AlterField(
- model_name='employeedepartment',
- name='role',
+ model_name="employeedepartment",
+ name="role",
field=models.CharField(blank=True, max_length=100),
),
migrations.AlterField(
- model_name='employeeposition',
- name='date_from',
- field=models.DateField(verbose_name='Дата начала должности'),
+ model_name="employeeposition",
+ name="date_from",
+ field=models.DateField(verbose_name="Дата начала должности"),
),
migrations.AlterField(
- model_name='employeeposition',
- name='date_to',
- field=models.DateField(blank=True, null=True, verbose_name='Дата окончания должности'),
+ model_name="employeeposition",
+ name="date_to",
+ field=models.DateField(
+ blank=True, null=True, verbose_name="Дата окончания должности"
+ ),
),
migrations.AlterField(
- model_name='employeeposition',
- name='department',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='employees', to='employees.department'),
+ model_name="employeeposition",
+ name="department",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="employees",
+ to="employees.department",
+ ),
),
migrations.AlterField(
- model_name='employeeposition',
- name='employee',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='positions', to=settings.AUTH_USER_MODEL),
+ model_name="employeeposition",
+ name="employee",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="positions",
+ to=settings.AUTH_USER_MODEL,
+ ),
),
migrations.AlterField(
- model_name='employeeposition',
- name='title',
- field=models.CharField(max_length=255, verbose_name='Название должности'),
+ model_name="employeeposition",
+ name="title",
+ field=models.CharField(max_length=255, verbose_name="Название должности"),
),
migrations.AlterField(
- model_name='skill',
- name='name',
+ model_name="skill",
+ name="name",
field=models.CharField(max_length=100, unique=True),
),
]
diff --git a/backend/employees/migrations/0009_alter_employeedepartment_date_from_and_more.py b/backend/employees/migrations/0009_alter_employeedepartment_date_from_and_more.py
index e97f92e0..d4d3340f 100644
--- a/backend/employees/migrations/0009_alter_employeedepartment_date_from_and_more.py
+++ b/backend/employees/migrations/0009_alter_employeedepartment_date_from_and_more.py
@@ -6,35 +6,48 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0008_alter_absence_options_alter_department_options_and_more'),
+ ("employees", "0008_alter_absence_options_alter_department_options_and_more"),
]
operations = [
migrations.AlterField(
- model_name='employeedepartment',
- name='date_from',
- field=models.DateField(blank=True, null=True, verbose_name='Дата начала работы'),
+ model_name="employeedepartment",
+ name="date_from",
+ field=models.DateField(
+ blank=True, null=True, verbose_name="Дата начала работы"
+ ),
),
migrations.AlterField(
- model_name='employeedepartment',
- name='date_to',
- field=models.DateField(blank=True, null=True, verbose_name='Дата окончания работы'),
+ model_name="employeedepartment",
+ name="date_to",
+ field=models.DateField(
+ blank=True, null=True, verbose_name="Дата окончания работы"
+ ),
),
migrations.AlterField(
- model_name='employeedepartment',
- name='employee',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='departments_links', to=settings.AUTH_USER_MODEL),
+ model_name="employeedepartment",
+ name="employee",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="departments_links",
+ to=settings.AUTH_USER_MODEL,
+ ),
),
migrations.AlterField(
- model_name='employeedepartment',
- name='role',
- field=models.CharField(blank=True, max_length=100, verbose_name='Роль в отделе'),
+ model_name="employeedepartment",
+ name="role",
+ field=models.CharField(
+ blank=True, max_length=100, verbose_name="Роль в отделе"
+ ),
),
migrations.AlterField(
- model_name='employeeposition',
- name='department',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='positions', to='employees.department'),
+ model_name="employeeposition",
+ name="department",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="positions",
+ to="employees.department",
+ ),
),
]
diff --git a/backend/employees/migrations/0010_historicalemployeeaction_historicalemployeeposition.py b/backend/employees/migrations/0010_historicalemployeeaction_historicalemployeeposition.py
index a3b7bd01..2b0fa761 100644
--- a/backend/employees/migrations/0010_historicalemployeeaction_historicalemployeeposition.py
+++ b/backend/employees/migrations/0010_historicalemployeeaction_historicalemployeeposition.py
@@ -7,55 +7,154 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0009_alter_employeedepartment_date_from_and_more'),
+ ("employees", "0009_alter_employeedepartment_date_from_and_more"),
]
operations = [
migrations.CreateModel(
- name='HistoricalEmployeeAction',
+ name="HistoricalEmployeeAction",
fields=[
- ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
- ('action', models.CharField(choices=[('hired', 'Принят'), ('dismissed', 'Уволен'), ('on_leave', 'В отпуске'), ('returned_from_leave', 'Вернулся из отпуска'), ('on_maternity', 'В декрете'), ('returned_from_maternity', 'Вернулся из декрета'), ('transferred', 'Переведен'), ('rehired', 'Восстановлен')], max_length=32, verbose_name='Кадровое событие')),
- ('date', models.DateTimeField(verbose_name='Дата действия')),
- ('comment', models.TextField(blank=True, verbose_name='Комментарий/причина')),
- ('extra', models.JSONField(blank=True, null=True, verbose_name='Дополнительно')),
- ('history_id', models.AutoField(primary_key=True, serialize=False)),
- ('history_date', models.DateTimeField(db_index=True)),
- ('history_change_reason', models.CharField(max_length=100, null=True)),
- ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
- ('employee', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL)),
- ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
+ (
+ "id",
+ models.BigIntegerField(
+ auto_created=True, blank=True, db_index=True, verbose_name="ID"
+ ),
+ ),
+ (
+ "action",
+ models.CharField(
+ choices=[
+ ("hired", "Принят"),
+ ("dismissed", "Уволен"),
+ ("on_leave", "В отпуске"),
+ ("returned_from_leave", "Вернулся из отпуска"),
+ ("on_maternity", "В декрете"),
+ ("returned_from_maternity", "Вернулся из декрета"),
+ ("transferred", "Переведен"),
+ ("rehired", "Восстановлен"),
+ ],
+ max_length=32,
+ verbose_name="Кадровое событие",
+ ),
+ ),
+ ("date", models.DateTimeField(verbose_name="Дата действия")),
+ (
+ "comment",
+ models.TextField(blank=True, verbose_name="Комментарий/причина"),
+ ),
+ (
+ "extra",
+ models.JSONField(
+ blank=True, null=True, verbose_name="Дополнительно"
+ ),
+ ),
+ ("history_id", models.AutoField(primary_key=True, serialize=False)),
+ ("history_date", models.DateTimeField(db_index=True)),
+ ("history_change_reason", models.CharField(max_length=100, null=True)),
+ (
+ "history_type",
+ models.CharField(
+ choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
+ max_length=1,
+ ),
+ ),
+ (
+ "employee",
+ models.ForeignKey(
+ blank=True,
+ db_constraint=False,
+ null=True,
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "history_user",
+ models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="+",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
],
options={
- 'verbose_name': 'historical Кадровое событие',
- 'verbose_name_plural': 'historical Кадровые события',
- 'ordering': ('-history_date', '-history_id'),
- 'get_latest_by': ('history_date', 'history_id'),
+ "verbose_name": "historical Кадровое событие",
+ "verbose_name_plural": "historical Кадровые события",
+ "ordering": ("-history_date", "-history_id"),
+ "get_latest_by": ("history_date", "history_id"),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
- name='HistoricalEmployeePosition',
+ name="HistoricalEmployeePosition",
fields=[
- ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
- ('title', models.CharField(max_length=255, verbose_name='Название должности')),
- ('date_from', models.DateField(verbose_name='Дата начала должности')),
- ('date_to', models.DateField(blank=True, null=True, verbose_name='Дата окончания должности')),
- ('history_id', models.AutoField(primary_key=True, serialize=False)),
- ('history_date', models.DateTimeField(db_index=True)),
- ('history_change_reason', models.CharField(max_length=100, null=True)),
- ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
- ('department', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='employees.department')),
- ('employee', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL)),
- ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
+ (
+ "id",
+ models.BigIntegerField(
+ auto_created=True, blank=True, db_index=True, verbose_name="ID"
+ ),
+ ),
+ (
+ "title",
+ models.CharField(max_length=255, verbose_name="Название должности"),
+ ),
+ ("date_from", models.DateField(verbose_name="Дата начала должности")),
+ (
+ "date_to",
+ models.DateField(
+ blank=True, null=True, verbose_name="Дата окончания должности"
+ ),
+ ),
+ ("history_id", models.AutoField(primary_key=True, serialize=False)),
+ ("history_date", models.DateTimeField(db_index=True)),
+ ("history_change_reason", models.CharField(max_length=100, null=True)),
+ (
+ "history_type",
+ models.CharField(
+ choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
+ max_length=1,
+ ),
+ ),
+ (
+ "department",
+ models.ForeignKey(
+ blank=True,
+ db_constraint=False,
+ null=True,
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ to="employees.department",
+ ),
+ ),
+ (
+ "employee",
+ models.ForeignKey(
+ blank=True,
+ db_constraint=False,
+ null=True,
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "history_user",
+ models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="+",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
],
options={
- 'verbose_name': 'historical Должность сотрудника',
- 'verbose_name_plural': 'historical Должности сотрудников',
- 'ordering': ('-history_date', '-history_id'),
- 'get_latest_by': ('history_date', 'history_id'),
+ "verbose_name": "historical Должность сотрудника",
+ "verbose_name_plural": "historical Должности сотрудников",
+ "ordering": ("-history_date", "-history_id"),
+ "get_latest_by": ("history_date", "history_id"),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
diff --git a/backend/employees/migrations/0011_department_head_appointed_at_and_more.py b/backend/employees/migrations/0011_department_head_appointed_at_and_more.py
index 107f95dd..9c7cd7ae 100644
--- a/backend/employees/migrations/0011_department_head_appointed_at_and_more.py
+++ b/backend/employees/migrations/0011_department_head_appointed_at_and_more.py
@@ -4,39 +4,66 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0010_historicalemployeeaction_historicalemployeeposition'),
+ ("employees", "0010_historicalemployeeaction_historicalemployeeposition"),
]
operations = [
migrations.AddField(
- model_name='department',
- name='head_appointed_at',
- field=models.DateTimeField(auto_now=True, verbose_name='Дата назначения руководителя'),
+ model_name="department",
+ name="head_appointed_at",
+ field=models.DateTimeField(
+ auto_now=True, verbose_name="Дата назначения руководителя"
+ ),
),
migrations.AddField(
- model_name='employee',
- name='email_activation_code',
+ model_name="employee",
+ name="email_activation_code",
field=models.CharField(blank=True, max_length=6, null=True),
),
migrations.AddField(
- model_name='employee',
- name='email_verified',
- field=models.BooleanField(default=False, verbose_name='Email подтверждён'),
+ model_name="employee",
+ name="email_verified",
+ field=models.BooleanField(default=False, verbose_name="Email подтверждён"),
),
migrations.AlterField(
- model_name='employeeaction',
- name='action',
- field=models.CharField(choices=[('hired', 'Принят'), ('dismissed', 'Уволен'), ('on_leave', 'В отпуске'), ('returned_from_leave', 'Вернулся из отпуска'), ('on_maternity', 'В декрете'), ('returned_from_maternity', 'Вернулся из декрета'), ('transferred', 'Переведен'), ('rehired', 'Восстановлен')], max_length=50, verbose_name='Кадровое событие'),
+ model_name="employeeaction",
+ name="action",
+ field=models.CharField(
+ choices=[
+ ("hired", "Принят"),
+ ("dismissed", "Уволен"),
+ ("on_leave", "В отпуске"),
+ ("returned_from_leave", "Вернулся из отпуска"),
+ ("on_maternity", "В декрете"),
+ ("returned_from_maternity", "Вернулся из декрета"),
+ ("transferred", "Переведен"),
+ ("rehired", "Восстановлен"),
+ ],
+ max_length=50,
+ verbose_name="Кадровое событие",
+ ),
),
migrations.AlterField(
- model_name='historicalemployeeaction',
- name='action',
- field=models.CharField(choices=[('hired', 'Принят'), ('dismissed', 'Уволен'), ('on_leave', 'В отпуске'), ('returned_from_leave', 'Вернулся из отпуска'), ('on_maternity', 'В декрете'), ('returned_from_maternity', 'Вернулся из декрета'), ('transferred', 'Переведен'), ('rehired', 'Восстановлен')], max_length=50, verbose_name='Кадровое событие'),
+ model_name="historicalemployeeaction",
+ name="action",
+ field=models.CharField(
+ choices=[
+ ("hired", "Принят"),
+ ("dismissed", "Уволен"),
+ ("on_leave", "В отпуске"),
+ ("returned_from_leave", "Вернулся из отпуска"),
+ ("on_maternity", "В декрете"),
+ ("returned_from_maternity", "Вернулся из декрета"),
+ ("transferred", "Переведен"),
+ ("rehired", "Восстановлен"),
+ ],
+ max_length=50,
+ verbose_name="Кадровое событие",
+ ),
),
migrations.AlterUniqueTogether(
- name='employeedepartment',
- unique_together={('employee', 'department')},
+ name="employeedepartment",
+ unique_together={("employee", "department")},
),
]
diff --git a/backend/employees/migrations/0012_positionrole_and_more.py b/backend/employees/migrations/0012_positionrole_and_more.py
index 21e0a815..a3b88a36 100644
--- a/backend/employees/migrations/0012_positionrole_and_more.py
+++ b/backend/employees/migrations/0012_positionrole_and_more.py
@@ -5,83 +5,138 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('auth', '0012_alter_user_first_name_max_length'),
- ('employees', '0011_department_head_appointed_at_and_more'),
+ ("auth", "0012_alter_user_first_name_max_length"),
+ ("employees", "0011_department_head_appointed_at_and_more"),
]
operations = [
migrations.CreateModel(
- name='PositionRole',
+ name="PositionRole",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('name', models.CharField(max_length=150, unique=True)),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("name", models.CharField(max_length=150, unique=True)),
],
),
migrations.AlterUniqueTogether(
- name='employeedepartment',
+ name="employeedepartment",
unique_together=set(),
),
migrations.RemoveField(
- model_name='employeeposition',
- name='department',
+ model_name="employeeposition",
+ name="department",
),
migrations.RemoveField(
- model_name='historicalemployeeposition',
- name='department',
+ model_name="historicalemployeeposition",
+ name="department",
),
migrations.CreateModel(
- name='DepartmentRole',
+ name="DepartmentRole",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('name', models.CharField(max_length=150)),
- ('department', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='roles', to='employees.department')),
- ('permissions', models.ManyToManyField(blank=True, to='auth.permission')),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("name", models.CharField(max_length=150)),
+ (
+ "department",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="roles",
+ to="employees.department",
+ ),
+ ),
+ (
+ "permissions",
+ models.ManyToManyField(blank=True, to="auth.permission"),
+ ),
],
),
migrations.AlterField(
- model_name='employeedepartment',
- name='role',
- field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='members', to='employees.departmentrole'),
+ model_name="employeedepartment",
+ name="role",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="members",
+ to="employees.departmentrole",
+ ),
),
migrations.AddIndex(
- model_name='employeedepartment',
- index=models.Index(fields=['department', 'is_active'], name='employees_e_departm_40dcd8_idx'),
+ model_name="employeedepartment",
+ index=models.Index(
+ fields=["department", "is_active"],
+ name="employees_e_departm_40dcd8_idx",
+ ),
),
migrations.AddIndex(
- model_name='employeedepartment',
- index=models.Index(fields=['employee', 'is_active'], name='employees_e_employe_e4fe19_idx'),
+ model_name="employeedepartment",
+ index=models.Index(
+ fields=["employee", "is_active"], name="employees_e_employe_e4fe19_idx"
+ ),
),
migrations.AddConstraint(
- model_name='employeedepartment',
- constraint=models.UniqueConstraint(fields=('employee', 'department'), name='uniq_employee_department'),
+ model_name="employeedepartment",
+ constraint=models.UniqueConstraint(
+ fields=("employee", "department"), name="uniq_employee_department"
+ ),
),
migrations.AddField(
- model_name='positionrole',
- name='permissions',
- field=models.ManyToManyField(blank=True, to='auth.permission'),
+ model_name="positionrole",
+ name="permissions",
+ field=models.ManyToManyField(blank=True, to="auth.permission"),
),
migrations.AddField(
- model_name='employeeposition',
- name='position_role',
- field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='employee_positions', to='employees.positionrole'),
+ model_name="employeeposition",
+ name="position_role",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="employee_positions",
+ to="employees.positionrole",
+ ),
),
migrations.AddField(
- model_name='historicalemployeeposition',
- name='position_role',
- field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='employees.positionrole'),
+ model_name="historicalemployeeposition",
+ name="position_role",
+ field=models.ForeignKey(
+ blank=True,
+ db_constraint=False,
+ null=True,
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ to="employees.positionrole",
+ ),
),
migrations.AddIndex(
- model_name='employeeposition',
- index=models.Index(fields=['employee'], name='employees_e_employe_720802_idx'),
+ model_name="employeeposition",
+ index=models.Index(
+ fields=["employee"], name="employees_e_employe_720802_idx"
+ ),
),
migrations.AddIndex(
- model_name='employeeposition',
- index=models.Index(fields=['date_from', 'date_to'], name='employees_e_date_fr_599bdf_idx'),
+ model_name="employeeposition",
+ index=models.Index(
+ fields=["date_from", "date_to"], name="employees_e_date_fr_599bdf_idx"
+ ),
),
migrations.AlterUniqueTogether(
- name='departmentrole',
- unique_together={('department', 'name')},
+ name="departmentrole",
+ unique_together={("department", "name")},
),
]
diff --git a/backend/employees/migrations/0013_alter_employeeposition_employee.py b/backend/employees/migrations/0013_alter_employeeposition_employee.py
index b767a0d9..34e9bdf1 100644
--- a/backend/employees/migrations/0013_alter_employeeposition_employee.py
+++ b/backend/employees/migrations/0013_alter_employeeposition_employee.py
@@ -6,15 +6,18 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0012_positionrole_and_more'),
+ ("employees", "0012_positionrole_and_more"),
]
operations = [
migrations.AlterField(
- model_name='employeeposition',
- name='employee',
- field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='position', to=settings.AUTH_USER_MODEL),
+ model_name="employeeposition",
+ name="employee",
+ field=models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="position",
+ to=settings.AUTH_USER_MODEL,
+ ),
),
]
diff --git a/backend/employees/migrations/0014_remove_positionrole_permissions_and_more.py b/backend/employees/migrations/0014_remove_positionrole_permissions_and_more.py
index 2aad3441..3e44ddf4 100644
--- a/backend/employees/migrations/0014_remove_positionrole_permissions_and_more.py
+++ b/backend/employees/migrations/0014_remove_positionrole_permissions_and_more.py
@@ -5,39 +5,53 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('auth', '0012_alter_user_first_name_max_length'),
- ('employees', '0013_alter_employeeposition_employee'),
+ ("auth", "0012_alter_user_first_name_max_length"),
+ ("employees", "0013_alter_employeeposition_employee"),
]
operations = [
migrations.RemoveField(
- model_name='positionrole',
- name='permissions',
+ model_name="positionrole",
+ name="permissions",
),
migrations.RemoveField(
- model_name='historicalemployeeposition',
- name='position_role',
+ model_name="historicalemployeeposition",
+ name="position_role",
),
migrations.RemoveField(
- model_name='employeeposition',
- name='position_role',
+ model_name="employeeposition",
+ name="position_role",
),
migrations.AddField(
- model_name='employeeposition',
- name='group',
- field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='employee_permissions', to='auth.group', verbose_name='Группа прав (по должности)'),
+ model_name="employeeposition",
+ name="group",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="employee_permissions",
+ to="auth.group",
+ verbose_name="Группа прав (по должности)",
+ ),
),
migrations.AddField(
- model_name='historicalemployeeposition',
- name='group',
- field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='auth.group', verbose_name='Группа прав (по должности)'),
+ model_name="historicalemployeeposition",
+ name="group",
+ field=models.ForeignKey(
+ blank=True,
+ db_constraint=False,
+ null=True,
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ to="auth.group",
+ verbose_name="Группа прав (по должности)",
+ ),
),
migrations.DeleteModel(
- name='Absence',
+ name="Absence",
),
migrations.DeleteModel(
- name='PositionRole',
+ name="PositionRole",
),
]
diff --git a/backend/employees/migrations/0015_remove_historicalemployeeposition_employee_and_more.py b/backend/employees/migrations/0015_remove_historicalemployeeposition_employee_and_more.py
index 168f901e..8fcb0558 100644
--- a/backend/employees/migrations/0015_remove_historicalemployeeposition_employee_and_more.py
+++ b/backend/employees/migrations/0015_remove_historicalemployeeposition_employee_and_more.py
@@ -7,68 +7,119 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('auth', '0012_alter_user_first_name_max_length'),
- ('employees', '0014_remove_positionrole_permissions_and_more'),
+ ("auth", "0012_alter_user_first_name_max_length"),
+ ("employees", "0014_remove_positionrole_permissions_and_more"),
]
operations = [
migrations.RemoveField(
- model_name='historicalemployeeposition',
- name='employee',
+ model_name="historicalemployeeposition",
+ name="employee",
),
migrations.RemoveField(
- model_name='historicalemployeeposition',
- name='group',
+ model_name="historicalemployeeposition",
+ name="group",
),
migrations.RemoveField(
- model_name='historicalemployeeposition',
- name='history_user',
+ model_name="historicalemployeeposition",
+ name="history_user",
),
migrations.CreateModel(
- name='HistoricalPosition',
+ name="HistoricalPosition",
fields=[
- ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
- ('name', models.CharField(db_index=True, max_length=255, verbose_name='Название должности')),
- ('description', models.TextField(blank=True, verbose_name='Описание')),
- ('history_id', models.AutoField(primary_key=True, serialize=False)),
- ('history_date', models.DateTimeField(db_index=True)),
- ('history_change_reason', models.CharField(max_length=100, null=True)),
- ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
- ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
+ (
+ "id",
+ models.BigIntegerField(
+ auto_created=True, blank=True, db_index=True, verbose_name="ID"
+ ),
+ ),
+ (
+ "name",
+ models.CharField(
+ db_index=True, max_length=255, verbose_name="Название должности"
+ ),
+ ),
+ ("description", models.TextField(blank=True, verbose_name="Описание")),
+ ("history_id", models.AutoField(primary_key=True, serialize=False)),
+ ("history_date", models.DateTimeField(db_index=True)),
+ ("history_change_reason", models.CharField(max_length=100, null=True)),
+ (
+ "history_type",
+ models.CharField(
+ choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
+ max_length=1,
+ ),
+ ),
+ (
+ "history_user",
+ models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="+",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
],
options={
- 'verbose_name': 'historical Должность',
- 'verbose_name_plural': 'historical Должности',
- 'ordering': ('-history_date', '-history_id'),
- 'get_latest_by': ('history_date', 'history_id'),
+ "verbose_name": "historical Должность",
+ "verbose_name_plural": "historical Должности",
+ "ordering": ("-history_date", "-history_id"),
+ "get_latest_by": ("history_date", "history_id"),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
- name='Position',
+ name="Position",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('name', models.CharField(max_length=255, unique=True, verbose_name='Название должности')),
- ('description', models.TextField(blank=True, verbose_name='Описание')),
- ('groups', models.ManyToManyField(blank=True, related_name='positions', to='auth.group', verbose_name='Группы прав')),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "name",
+ models.CharField(
+ max_length=255, unique=True, verbose_name="Название должности"
+ ),
+ ),
+ ("description", models.TextField(blank=True, verbose_name="Описание")),
+ (
+ "groups",
+ models.ManyToManyField(
+ blank=True,
+ related_name="positions",
+ to="auth.group",
+ verbose_name="Группы прав",
+ ),
+ ),
],
options={
- 'verbose_name': 'Должность',
- 'verbose_name_plural': 'Должности',
- 'ordering': ['name'],
+ "verbose_name": "Должность",
+ "verbose_name_plural": "Должности",
+ "ordering": ["name"],
},
),
migrations.AddField(
- model_name='employee',
- name='position',
- field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='employees', to='employees.position', verbose_name='Должность'),
+ model_name="employee",
+ name="position",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="employees",
+ to="employees.position",
+ verbose_name="Должность",
+ ),
),
migrations.DeleteModel(
- name='EmployeePosition',
+ name="EmployeePosition",
),
migrations.DeleteModel(
- name='HistoricalEmployeePosition',
+ name="HistoricalEmployeePosition",
),
]
diff --git a/backend/employees/migrations/0016_alter_department_options_and_more.py b/backend/employees/migrations/0016_alter_department_options_and_more.py
index bd5232f8..9a61cc7f 100644
--- a/backend/employees/migrations/0016_alter_department_options_and_more.py
+++ b/backend/employees/migrations/0016_alter_department_options_and_more.py
@@ -4,23 +4,35 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0015_remove_historicalemployeeposition_employee_and_more'),
+ ("employees", "0015_remove_historicalemployeeposition_employee_and_more"),
]
operations = [
migrations.AlterModelOptions(
- name='department',
- options={'permissions': [('change_department_head', 'Can change department head'), ('assign_department_role', 'Can assign role within department'), ('manage_department', 'Can manage department')], 'verbose_name': 'Отдел', 'verbose_name_plural': 'Отделы'},
+ name="department",
+ options={
+ "permissions": [
+ ("change_department_head", "Can change department head"),
+ ("assign_department_role", "Can assign role within department"),
+ ("manage_department", "Can manage department"),
+ ],
+ "verbose_name": "Отдел",
+ "verbose_name_plural": "Отделы",
+ },
),
migrations.AlterModelOptions(
- name='departmentrole',
- options={'verbose_name': 'Роль в отделе', 'verbose_name_plural': 'Роли в отделе'},
+ name="departmentrole",
+ options={
+ "verbose_name": "Роль в отделе",
+ "verbose_name_plural": "Роли в отделе",
+ },
),
migrations.AlterField(
- model_name='department',
- name='head_appointed_at',
- field=models.DateTimeField(blank=True, null=True, verbose_name='Дата назначения руководителя'),
+ model_name="department",
+ name="head_appointed_at",
+ field=models.DateTimeField(
+ blank=True, null=True, verbose_name="Дата назначения руководителя"
+ ),
),
]
diff --git a/backend/employees/migrations/0017_employee_employee_email_lower_idx.py b/backend/employees/migrations/0017_employee_employee_email_lower_idx.py
index 080ee64b..2705807e 100644
--- a/backend/employees/migrations/0017_employee_employee_email_lower_idx.py
+++ b/backend/employees/migrations/0017_employee_employee_email_lower_idx.py
@@ -5,15 +5,17 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('auth', '0012_alter_user_first_name_max_length'),
- ('employees', '0016_alter_department_options_and_more'),
+ ("auth", "0012_alter_user_first_name_max_length"),
+ ("employees", "0016_alter_department_options_and_more"),
]
operations = [
migrations.AddIndex(
- model_name='employee',
- index=models.Index(django.db.models.functions.text.Lower('email'), name='employee_email_lower_idx'),
+ model_name="employee",
+ index=models.Index(
+ django.db.models.functions.text.Lower("email"),
+ name="employee_email_lower_idx",
+ ),
),
]
diff --git a/backend/employees/migrations/0018_alter_position_options.py b/backend/employees/migrations/0018_alter_position_options.py
index c2aec874..cc0f15f1 100644
--- a/backend/employees/migrations/0018_alter_position_options.py
+++ b/backend/employees/migrations/0018_alter_position_options.py
@@ -4,14 +4,20 @@ from django.db import migrations
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0017_employee_employee_email_lower_idx'),
+ ("employees", "0017_employee_employee_email_lower_idx"),
]
operations = [
migrations.AlterModelOptions(
- name='position',
- options={'ordering': ['name'], 'permissions': [('assign_position_groups', 'Can assign auth groups to positions')], 'verbose_name': 'Должность', 'verbose_name_plural': 'Должности'},
+ name="position",
+ options={
+ "ordering": ["name"],
+ "permissions": [
+ ("assign_position_groups", "Can assign auth groups to positions")
+ ],
+ "verbose_name": "Должность",
+ "verbose_name_plural": "Должности",
+ },
),
]
diff --git a/backend/employees/migrations/0019_delete_education.py b/backend/employees/migrations/0019_delete_education.py
index 45e37217..6853306b 100644
--- a/backend/employees/migrations/0019_delete_education.py
+++ b/backend/employees/migrations/0019_delete_education.py
@@ -4,13 +4,12 @@ from django.db import migrations
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0018_alter_position_options'),
+ ("employees", "0018_alter_position_options"),
]
operations = [
migrations.DeleteModel(
- name='Education',
+ name="Education",
),
]
diff --git a/backend/employees/migrations/0020_alter_department_options_alter_employee_options_and_more.py b/backend/employees/migrations/0020_alter_department_options_alter_employee_options_and_more.py
index 5ce4c6f5..81111087 100644
--- a/backend/employees/migrations/0020_alter_department_options_alter_employee_options_and_more.py
+++ b/backend/employees/migrations/0020_alter_department_options_alter_employee_options_and_more.py
@@ -4,22 +4,46 @@ from django.db import migrations
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0019_delete_education'),
+ ("employees", "0019_delete_education"),
]
operations = [
migrations.AlterModelOptions(
- name='department',
- options={'permissions': [('change_department_head', 'Может изменять руководителя отдела'), ('assign_department_role', 'Может назначать роли в рамках отдела'), ('manage_department', 'Может управлять отделом')], 'verbose_name': 'Отдел', 'verbose_name_plural': 'Отделы'},
+ name="department",
+ options={
+ "permissions": [
+ ("change_department_head", "Может изменять руководителя отдела"),
+ ("assign_department_role", "Может назначать роли в рамках отдела"),
+ ("manage_department", "Может управлять отделом"),
+ ],
+ "verbose_name": "Отдел",
+ "verbose_name_plural": "Отделы",
+ },
),
migrations.AlterModelOptions(
- name='employee',
- options={'ordering': ('last_name', 'first_name'), 'permissions': [('assign_employee_skills', 'Может назначать навыки сотрудникам')], 'verbose_name': 'Сотрудник', 'verbose_name_plural': 'Сотрудники'},
+ name="employee",
+ options={
+ "ordering": ("last_name", "first_name"),
+ "permissions": [
+ ("assign_employee_skills", "Может назначать навыки сотрудникам")
+ ],
+ "verbose_name": "Сотрудник",
+ "verbose_name_plural": "Сотрудники",
+ },
),
migrations.AlterModelOptions(
- name='position',
- options={'ordering': ['name'], 'permissions': [('assign_position_groups', 'Может назначать группы аутентификации для должностей')], 'verbose_name': 'Должность', 'verbose_name_plural': 'Должности'},
+ name="position",
+ options={
+ "ordering": ["name"],
+ "permissions": [
+ (
+ "assign_position_groups",
+ "Может назначать группы аутентификации для должностей",
+ )
+ ],
+ "verbose_name": "Должность",
+ "verbose_name_plural": "Должности",
+ },
),
]
diff --git a/backend/employees/migrations/0021_alter_employee_options.py b/backend/employees/migrations/0021_alter_employee_options.py
index d3b43021..2a17bd2b 100644
--- a/backend/employees/migrations/0021_alter_employee_options.py
+++ b/backend/employees/migrations/0021_alter_employee_options.py
@@ -4,14 +4,20 @@ from django.db import migrations
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0020_alter_department_options_alter_employee_options_and_more'),
+ ("employees", "0020_alter_department_options_alter_employee_options_and_more"),
]
operations = [
migrations.AlterModelOptions(
- name='employee',
- options={'ordering': ('last_name', 'first_name'), 'permissions': [('manage_employee_skills', 'Может управлять навыками сотрудников')], 'verbose_name': 'Сотрудник', 'verbose_name_plural': 'Сотрудники'},
+ name="employee",
+ options={
+ "ordering": ("last_name", "first_name"),
+ "permissions": [
+ ("manage_employee_skills", "Может управлять навыками сотрудников")
+ ],
+ "verbose_name": "Сотрудник",
+ "verbose_name_plural": "Сотрудники",
+ },
),
]
diff --git a/backend/employees/migrations/0022_departmentpermission_and_more.py b/backend/employees/migrations/0022_departmentpermission_and_more.py
index ef22a1f4..a84814fc 100644
--- a/backend/employees/migrations/0022_departmentpermission_and_more.py
+++ b/backend/employees/migrations/0022_departmentpermission_and_more.py
@@ -4,27 +4,47 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0021_alter_employee_options'),
+ ("employees", "0021_alter_employee_options"),
]
operations = [
migrations.CreateModel(
- name='DepartmentPermission',
+ name="DepartmentPermission",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('code', models.CharField(choices=[('manage_department', 'Управлять отделом'), ('change_department_head', 'Назначать руководителя'), ('assign_department_role', 'Назначать роли участникам')], max_length=64, unique=True)),
- ('name', models.CharField(max_length=150)),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "code",
+ models.CharField(
+ choices=[
+ ("manage_department", "Управлять отделом"),
+ ("change_department_head", "Назначать руководителя"),
+ ("assign_department_role", "Назначать роли участникам"),
+ ],
+ max_length=64,
+ unique=True,
+ ),
+ ),
+ ("name", models.CharField(max_length=150)),
],
),
migrations.RemoveField(
- model_name='departmentrole',
- name='permissions',
+ model_name="departmentrole",
+ name="permissions",
),
migrations.AddField(
- model_name='departmentrole',
- name='scoped_permissions',
- field=models.ManyToManyField(blank=True, related_name='roles', to='employees.departmentpermission'),
+ model_name="departmentrole",
+ name="scoped_permissions",
+ field=models.ManyToManyField(
+ blank=True, related_name="roles", to="employees.departmentpermission"
+ ),
),
]
diff --git a/backend/employees/migrations/0023_alter_departmentrole_options_and_more.py b/backend/employees/migrations/0023_alter_departmentrole_options_and_more.py
index e74bb717..0fa221de 100644
--- a/backend/employees/migrations/0023_alter_departmentrole_options_and_more.py
+++ b/backend/employees/migrations/0023_alter_departmentrole_options_and_more.py
@@ -5,38 +5,51 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0022_departmentpermission_and_more'),
+ ("employees", "0022_departmentpermission_and_more"),
]
operations = [
migrations.AlterModelOptions(
- name='departmentrole',
- options={'ordering': ('name', 'department', 'id'), 'verbose_name': 'Роль в отделе', 'verbose_name_plural': 'Роли в отделе'},
+ name="departmentrole",
+ options={
+ "ordering": ("name", "department", "id"),
+ "verbose_name": "Роль в отделе",
+ "verbose_name_plural": "Роли в отделе",
+ },
),
migrations.RemoveConstraint(
- model_name='employeedepartment',
- name='uniq_employee_department',
+ model_name="employeedepartment",
+ name="uniq_employee_department",
),
migrations.RemoveIndex(
- model_name='employeedepartment',
- name='employees_e_departm_40dcd8_idx',
+ model_name="employeedepartment",
+ name="employees_e_departm_40dcd8_idx",
),
migrations.RemoveIndex(
- model_name='employeedepartment',
- name='employees_e_employe_e4fe19_idx',
+ model_name="employeedepartment",
+ name="employees_e_employe_e4fe19_idx",
),
migrations.AddIndex(
- model_name='employeedepartment',
- index=models.Index(fields=['department_id', 'is_active', 'employee_id'], name='employees_e_departm_81ade2_idx'),
+ model_name="employeedepartment",
+ index=models.Index(
+ fields=["department_id", "is_active", "employee_id"],
+ name="employees_e_departm_81ade2_idx",
+ ),
),
migrations.AddIndex(
- model_name='employeedepartment',
- index=models.Index(fields=['employee_id', 'department_id'], name='employees_e_employe_b4b0e7_idx'),
+ model_name="employeedepartment",
+ index=models.Index(
+ fields=["employee_id", "department_id"],
+ name="employees_e_employe_b4b0e7_idx",
+ ),
),
migrations.AddConstraint(
- model_name='employeedepartment',
- constraint=models.UniqueConstraint(deferrable=django.db.models.constraints.Deferrable['DEFERRED'], fields=('department_id', 'employee_id'), name='uniq_employee_per_department'),
+ model_name="employeedepartment",
+ constraint=models.UniqueConstraint(
+ deferrable=django.db.models.constraints.Deferrable["DEFERRED"],
+ fields=("department_id", "employee_id"),
+ name="uniq_employee_per_department",
+ ),
),
]
diff --git a/backend/employees/migrations/0024_alter_departmentpermission_code.py b/backend/employees/migrations/0024_alter_departmentpermission_code.py
index 891ef84d..39f037cb 100644
--- a/backend/employees/migrations/0024_alter_departmentpermission_code.py
+++ b/backend/employees/migrations/0024_alter_departmentpermission_code.py
@@ -4,15 +4,23 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0023_alter_departmentrole_options_and_more'),
+ ("employees", "0023_alter_departmentrole_options_and_more"),
]
operations = [
migrations.AlterField(
- model_name='departmentpermission',
- name='code',
- field=models.CharField(choices=[('manage_department', 'Управлять отделом'), ('change_department_head', 'Назначать руководителя'), ('assign_department_role', 'Назначать роли участникам'), ('manage_department_events', 'Управлять календарём отдела')], max_length=64, unique=True),
+ model_name="departmentpermission",
+ name="code",
+ field=models.CharField(
+ choices=[
+ ("manage_department", "Управлять отделом"),
+ ("change_department_head", "Назначать руководителя"),
+ ("assign_department_role", "Назначать роли участникам"),
+ ("manage_department_events", "Управлять календарём отдела"),
+ ],
+ max_length=64,
+ unique=True,
+ ),
),
]
diff --git a/backend/employees/migrations/0025_alter_departmentpermission_code.py b/backend/employees/migrations/0025_alter_departmentpermission_code.py
index 61b4b24f..fee3098f 100644
--- a/backend/employees/migrations/0025_alter_departmentpermission_code.py
+++ b/backend/employees/migrations/0025_alter_departmentpermission_code.py
@@ -4,15 +4,28 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0024_alter_departmentpermission_code'),
+ ("employees", "0024_alter_departmentpermission_code"),
]
operations = [
migrations.AlterField(
- model_name='departmentpermission',
- name='code',
- field=models.CharField(choices=[('manage_department', 'Управлять отделом'), ('change_department_head', 'Назначать руководителя'), ('assign_department_role', 'Назначать роли участникам'), ('manage_department_events', 'Управлять календарём отдела'), ('publish_department_post', 'Публиковать новости на странице отдела'), ('manage_department_feed', 'Редактировать публикации отдела')], max_length=64, unique=True),
+ model_name="departmentpermission",
+ name="code",
+ field=models.CharField(
+ choices=[
+ ("manage_department", "Управлять отделом"),
+ ("change_department_head", "Назначать руководителя"),
+ ("assign_department_role", "Назначать роли участникам"),
+ ("manage_department_events", "Управлять календарём отдела"),
+ (
+ "publish_department_post",
+ "Публиковать новости на странице отдела",
+ ),
+ ("manage_department_feed", "Редактировать публикации отдела"),
+ ],
+ max_length=64,
+ unique=True,
+ ),
),
]
diff --git a/backend/employees/migrations/0026_alter_departmentpermission_code.py b/backend/employees/migrations/0026_alter_departmentpermission_code.py
index 5fb3ab93..99861ecb 100644
--- a/backend/employees/migrations/0026_alter_departmentpermission_code.py
+++ b/backend/employees/migrations/0026_alter_departmentpermission_code.py
@@ -4,15 +4,30 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0025_alter_departmentpermission_code'),
+ ("employees", "0025_alter_departmentpermission_code"),
]
operations = [
migrations.AlterField(
- model_name='departmentpermission',
- name='code',
- field=models.CharField(choices=[('manage_department', 'Управлять отделом'), ('change_department_head', 'Назначать руководителя'), ('assign_department_role', 'Назначать роли участникам'), ('manage_department_events', 'Управлять календарём отдела'), ('publish_department_post', 'Публиковать новости на странице отдела'), ('manage_department_feed', 'Редактировать публикации отдела'), ('view_requestcomment', 'Просмотр комментариев по заявлениям'), ('add_requestcomment', 'Добавление коментариев по заявлениям')], max_length=64, unique=True),
+ model_name="departmentpermission",
+ name="code",
+ field=models.CharField(
+ choices=[
+ ("manage_department", "Управлять отделом"),
+ ("change_department_head", "Назначать руководителя"),
+ ("assign_department_role", "Назначать роли участникам"),
+ ("manage_department_events", "Управлять календарём отдела"),
+ (
+ "publish_department_post",
+ "Публиковать новости на странице отдела",
+ ),
+ ("manage_department_feed", "Редактировать публикации отдела"),
+ ("view_requestcomment", "Просмотр комментариев по заявлениям"),
+ ("add_requestcomment", "Добавление коментариев по заявлениям"),
+ ],
+ max_length=64,
+ unique=True,
+ ),
),
]
diff --git a/backend/employees/migrations/0027_employee_last_ldap_modify_ts_and_more.py b/backend/employees/migrations/0027_employee_last_ldap_modify_ts_and_more.py
index e0320725..70df79ea 100644
--- a/backend/employees/migrations/0027_employee_last_ldap_modify_ts_and_more.py
+++ b/backend/employees/migrations/0027_employee_last_ldap_modify_ts_and_more.py
@@ -4,30 +4,46 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0026_alter_departmentpermission_code'),
+ ("employees", "0026_alter_departmentpermission_code"),
]
operations = [
migrations.AddField(
- model_name='employee',
- name='last_ldap_modify_ts',
- field=models.CharField(blank=True, max_length=64, null=True, verbose_name='LDAP modify ts'),
+ model_name="employee",
+ name="last_ldap_modify_ts",
+ field=models.CharField(
+ blank=True, max_length=64, null=True, verbose_name="LDAP modify ts"
+ ),
),
migrations.AddField(
- model_name='employee',
- name='last_ldap_sync_at',
- field=models.DateTimeField(blank=True, null=True, verbose_name='Последняя успешная запись в LDAP'),
+ model_name="employee",
+ name="last_ldap_sync_at",
+ field=models.DateTimeField(
+ blank=True, null=True, verbose_name="Последняя успешная запись в LDAP"
+ ),
),
migrations.AddField(
- model_name='employee',
- name='ldap_dn',
- field=models.CharField(blank=True, help_text='Текущий DN записи в каталоге (для отладки/трассировки).', max_length=512, null=True, verbose_name='LDAP DN'),
+ model_name="employee",
+ name="ldap_dn",
+ field=models.CharField(
+ blank=True,
+ help_text="Текущий DN записи в каталоге (для отладки/трассировки).",
+ max_length=512,
+ null=True,
+ verbose_name="LDAP DN",
+ ),
),
migrations.AddField(
- model_name='employee',
- name='ldap_guid',
- field=models.CharField(blank=True, help_text='Устойчивый ID из LDAP (entryUUID/objectGUID в hex/строке).', max_length=64, null=True, unique=True, verbose_name='LDAP GUID'),
+ model_name="employee",
+ name="ldap_guid",
+ field=models.CharField(
+ blank=True,
+ help_text="Устойчивый ID из LDAP (entryUUID/objectGUID в hex/строке).",
+ max_length=64,
+ null=True,
+ unique=True,
+ verbose_name="LDAP GUID",
+ ),
),
]
diff --git a/backend/employees/migrations/0028_employee_is_ldap_managed.py b/backend/employees/migrations/0028_employee_is_ldap_managed.py
index f2aba413..05b90c27 100644
--- a/backend/employees/migrations/0028_employee_is_ldap_managed.py
+++ b/backend/employees/migrations/0028_employee_is_ldap_managed.py
@@ -4,15 +4,14 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0027_employee_last_ldap_modify_ts_and_more'),
+ ("employees", "0027_employee_last_ldap_modify_ts_and_more"),
]
operations = [
migrations.AddField(
- model_name='employee',
- name='is_ldap_managed',
- field=models.BooleanField(default=False, verbose_name='Управляется LDAP'),
+ model_name="employee",
+ name="is_ldap_managed",
+ field=models.BooleanField(default=False, verbose_name="Управляется LDAP"),
),
]
diff --git a/backend/employees/migrations/0029_ldapsyncstate.py b/backend/employees/migrations/0029_ldapsyncstate.py
index f21bc9a4..d9ce1c53 100644
--- a/backend/employees/migrations/0029_ldapsyncstate.py
+++ b/backend/employees/migrations/0029_ldapsyncstate.py
@@ -4,30 +4,42 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0028_employee_is_ldap_managed'),
+ ("employees", "0028_employee_is_ldap_managed"),
]
operations = [
migrations.CreateModel(
- name='LdapSyncState',
+ name="LdapSyncState",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('model', models.CharField(db_index=True, max_length=32)),
- ('object_pk', models.CharField(db_index=True, max_length=64)),
- ('ldap_dn', models.TextField(blank=True, default='')),
- ('ldap_guid', models.CharField(blank=True, db_index=True, max_length=64, null=True)),
- ('last_ldap_modify_ts', models.DateTimeField(blank=True, null=True)),
- ('last_django_modify_ts', models.DateTimeField(blank=True, null=True)),
- ('last_sync_dir', models.CharField(default='ldap', max_length=8)),
- ('data_hash', models.CharField(blank=True, max_length=64, null=True)),
- ('updated_at', models.DateTimeField(auto_now=True)),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("model", models.CharField(db_index=True, max_length=32)),
+ ("object_pk", models.CharField(db_index=True, max_length=64)),
+ ("ldap_dn", models.TextField(blank=True, default="")),
+ (
+ "ldap_guid",
+ models.CharField(
+ blank=True, db_index=True, max_length=64, null=True
+ ),
+ ),
+ ("last_ldap_modify_ts", models.DateTimeField(blank=True, null=True)),
+ ("last_django_modify_ts", models.DateTimeField(blank=True, null=True)),
+ ("last_sync_dir", models.CharField(default="ldap", max_length=8)),
+ ("data_hash", models.CharField(blank=True, max_length=64, null=True)),
+ ("updated_at", models.DateTimeField(auto_now=True)),
],
options={
- 'verbose_name': 'Состояние синхронизации LDAP',
- 'verbose_name_plural': 'Состояния синхронизации LDAP',
- 'unique_together': {('model', 'object_pk')},
+ "verbose_name": "Состояние синхронизации LDAP",
+ "verbose_name_plural": "Состояния синхронизации LDAP",
+ "unique_together": {("model", "object_pk")},
},
),
]
diff --git a/backend/employees/migrations/0030_remove_employee_last_ldap_modify_ts_and_more.py b/backend/employees/migrations/0030_remove_employee_last_ldap_modify_ts_and_more.py
index c5f00627..82be4d7e 100644
--- a/backend/employees/migrations/0030_remove_employee_last_ldap_modify_ts_and_more.py
+++ b/backend/employees/migrations/0030_remove_employee_last_ldap_modify_ts_and_more.py
@@ -4,26 +4,25 @@ from django.db import migrations
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0029_ldapsyncstate'),
+ ("employees", "0029_ldapsyncstate"),
]
operations = [
migrations.RemoveField(
- model_name='employee',
- name='last_ldap_modify_ts',
+ model_name="employee",
+ name="last_ldap_modify_ts",
),
migrations.RemoveField(
- model_name='employee',
- name='last_ldap_sync_at',
+ model_name="employee",
+ name="last_ldap_sync_at",
),
migrations.RemoveField(
- model_name='employee',
- name='ldap_dn',
+ model_name="employee",
+ name="ldap_dn",
),
migrations.RemoveField(
- model_name='employee',
- name='ldap_guid',
+ model_name="employee",
+ name="ldap_guid",
),
]
diff --git a/backend/employees/migrations/0031_department_ldap_group_dn_and_more.py b/backend/employees/migrations/0031_department_ldap_group_dn_and_more.py
index 0a8aebe9..bef96414 100644
--- a/backend/employees/migrations/0031_department_ldap_group_dn_and_more.py
+++ b/backend/employees/migrations/0031_department_ldap_group_dn_and_more.py
@@ -4,30 +4,53 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0030_remove_employee_last_ldap_modify_ts_and_more'),
+ ("employees", "0030_remove_employee_last_ldap_modify_ts_and_more"),
]
operations = [
migrations.AddField(
- model_name='department',
- name='ldap_group_dn',
- field=models.CharField(blank=True, default='', help_text='Напр.: CN=DEP_it,OU=Department,OU=company,DC=robotail,DC=local', max_length=512, verbose_name='DN агрегаторной группы отдела в AD (DEP_*)'),
+ model_name="department",
+ name="ldap_group_dn",
+ field=models.CharField(
+ blank=True,
+ default="",
+ help_text="Напр.: CN=DEP_it,OU=Department,OU=company,DC=robotail,DC=local",
+ max_length=512,
+ verbose_name="DN агрегаторной группы отдела в AD (DEP_*)",
+ ),
),
migrations.AddField(
- model_name='departmentrole',
- name='ldap_group_dn',
- field=models.CharField(blank=True, default='', help_text='Напр.: CN=ROLE_it__oncall,OU=Role,OU=company,DC=robotail,DC=local', max_length=512, verbose_name='DN агрегаторной группы роли в AD (ROLE_*)'),
+ model_name="departmentrole",
+ name="ldap_group_dn",
+ field=models.CharField(
+ blank=True,
+ default="",
+ help_text="Напр.: CN=ROLE_it__oncall,OU=Role,OU=company,DC=robotail,DC=local",
+ max_length=512,
+ verbose_name="DN агрегаторной группы роли в AD (ROLE_*)",
+ ),
),
migrations.AddField(
- model_name='historicalposition',
- name='ldap_group_dn',
- field=models.CharField(blank=True, default='', help_text='Напр.: CN=POS_engineer,OU=Position,OU=company,DC=robotail,DC=local', max_length=512, verbose_name='DN агрегаторной группы должности в AD (POS_*)'),
+ model_name="historicalposition",
+ name="ldap_group_dn",
+ field=models.CharField(
+ blank=True,
+ default="",
+ help_text="Напр.: CN=POS_engineer,OU=Position,OU=company,DC=robotail,DC=local",
+ max_length=512,
+ verbose_name="DN агрегаторной группы должности в AD (POS_*)",
+ ),
),
migrations.AddField(
- model_name='position',
- name='ldap_group_dn',
- field=models.CharField(blank=True, default='', help_text='Напр.: CN=POS_engineer,OU=Position,OU=company,DC=robotail,DC=local', max_length=512, verbose_name='DN агрегаторной группы должности в AD (POS_*)'),
+ model_name="position",
+ name="ldap_group_dn",
+ field=models.CharField(
+ blank=True,
+ default="",
+ help_text="Напр.: CN=POS_engineer,OU=Position,OU=company,DC=robotail,DC=local",
+ max_length=512,
+ verbose_name="DN агрегаторной группы должности в AD (POS_*)",
+ ),
),
]
diff --git a/backend/employees/migrations/0032_remove_employeedepartment_uniq_employee_per_department_and_more.py b/backend/employees/migrations/0032_remove_employeedepartment_uniq_employee_per_department_and_more.py
index 3cb87c11..78947d9d 100644
--- a/backend/employees/migrations/0032_remove_employeedepartment_uniq_employee_per_department_and_more.py
+++ b/backend/employees/migrations/0032_remove_employeedepartment_uniq_employee_per_department_and_more.py
@@ -4,23 +4,42 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0031_department_ldap_group_dn_and_more'),
+ ("employees", "0031_department_ldap_group_dn_and_more"),
]
operations = [
migrations.RemoveConstraint(
- model_name='employeedepartment',
- name='uniq_employee_per_department',
+ model_name="employeedepartment",
+ name="uniq_employee_per_department",
),
migrations.AlterField(
- model_name='departmentpermission',
- name='code',
- field=models.CharField(choices=[('manage_department', 'Управлять отделом'), ('change_department_head', 'Назначать руководителя'), ('assign_department_role', 'Назначать роли участникам'), ('manage_department_events', 'Управлять календарём отдела'), ('publish_department_post', 'Публиковать новости на странице отдела'), ('manage_department_feed', 'Редактировать публикации отдела'), ('view_requestcomment', 'Просмотр комментариев по заявлениям'), ('add_requestcomment', 'Добавление коментариев по заявлениям'), ('view_request', 'Просмотр заявлений отдела'), ('can_process_requests', 'Рассмотрение заявлений отдела')], max_length=64, unique=True),
+ model_name="departmentpermission",
+ name="code",
+ field=models.CharField(
+ choices=[
+ ("manage_department", "Управлять отделом"),
+ ("change_department_head", "Назначать руководителя"),
+ ("assign_department_role", "Назначать роли участникам"),
+ ("manage_department_events", "Управлять календарём отдела"),
+ (
+ "publish_department_post",
+ "Публиковать новости на странице отдела",
+ ),
+ ("manage_department_feed", "Редактировать публикации отдела"),
+ ("view_requestcomment", "Просмотр комментариев по заявлениям"),
+ ("add_requestcomment", "Добавление коментариев по заявлениям"),
+ ("view_request", "Просмотр заявлений отдела"),
+ ("can_process_requests", "Рассмотрение заявлений отдела"),
+ ],
+ max_length=64,
+ unique=True,
+ ),
),
migrations.AddConstraint(
- model_name='employeedepartment',
- constraint=models.UniqueConstraint(fields=('employee_id',), name='uniq_department_per_employee'),
+ model_name="employeedepartment",
+ constraint=models.UniqueConstraint(
+ fields=("employee_id",), name="uniq_department_per_employee"
+ ),
),
]
diff --git a/backend/employees/migrations/0033_remove_employeedepartment_uniq_department_per_employee_and_more.py b/backend/employees/migrations/0033_remove_employeedepartment_uniq_department_per_employee_and_more.py
index fa1e7ca4..40d24977 100644
--- a/backend/employees/migrations/0033_remove_employeedepartment_uniq_department_per_employee_and_more.py
+++ b/backend/employees/migrations/0033_remove_employeedepartment_uniq_department_per_employee_and_more.py
@@ -5,18 +5,24 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0032_remove_employeedepartment_uniq_employee_per_department_and_more'),
+ (
+ "employees",
+ "0032_remove_employeedepartment_uniq_employee_per_department_and_more",
+ ),
]
operations = [
migrations.RemoveConstraint(
- model_name='employeedepartment',
- name='uniq_department_per_employee',
+ model_name="employeedepartment",
+ name="uniq_department_per_employee",
),
migrations.AddConstraint(
- model_name='employeedepartment',
- constraint=models.UniqueConstraint(deferrable=django.db.models.constraints.Deferrable['DEFERRED'], fields=('department_id', 'employee_id'), name='uniq_employee_per_department'),
+ model_name="employeedepartment",
+ constraint=models.UniqueConstraint(
+ deferrable=django.db.models.constraints.Deferrable["DEFERRED"],
+ fields=("department_id", "employee_id"),
+ name="uniq_employee_per_department",
+ ),
),
]
diff --git a/backend/employees/migrations/0034_alter_employee_telegram.py b/backend/employees/migrations/0034_alter_employee_telegram.py
index 2f44f7a3..c6a818ad 100644
--- a/backend/employees/migrations/0034_alter_employee_telegram.py
+++ b/backend/employees/migrations/0034_alter_employee_telegram.py
@@ -4,15 +4,22 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0033_remove_employeedepartment_uniq_department_per_employee_and_more'),
+ (
+ "employees",
+ "0033_remove_employeedepartment_uniq_department_per_employee_and_more",
+ ),
]
operations = [
migrations.AlterField(
- model_name='employee',
- name='telegram',
- field=models.CharField(blank=True, help_text='Ваш Telegram Chat ID для получения уведомлений (найдите через @userinfobot)', max_length=100, verbose_name='Telegram Chat ID'),
+ model_name="employee",
+ name="telegram",
+ field=models.CharField(
+ blank=True,
+ help_text="Ваш Telegram Chat ID для получения уведомлений (найдите через @userinfobot)",
+ max_length=100,
+ verbose_name="Telegram Chat ID",
+ ),
),
]
diff --git a/backend/employees/migrations/0035_add_manage_equipment_permission.py b/backend/employees/migrations/0035_add_manage_equipment_permission.py
index ca4a20c2..964e2672 100644
--- a/backend/employees/migrations/0035_add_manage_equipment_permission.py
+++ b/backend/employees/migrations/0035_add_manage_equipment_permission.py
@@ -5,25 +5,22 @@ from django.db import migrations
def add_manage_equipment_permission(apps, schema_editor):
"""Добавляет скоуп-право для управления оборудованием отдела."""
- DepartmentPermission = apps.get_model('employees', 'DepartmentPermission')
+ DepartmentPermission = apps.get_model("employees", "DepartmentPermission")
DepartmentPermission.objects.get_or_create(
- code='manage_department_equipment',
- defaults={'name': 'Управлять оборудованием отдела'}
+ code="manage_department_equipment",
+ defaults={"name": "Управлять оборудованием отдела"},
)
def remove_manage_equipment_permission(apps, schema_editor):
"""Удаляет скоуп-право."""
- DepartmentPermission = apps.get_model('employees', 'DepartmentPermission')
- DepartmentPermission.objects.filter(
- code='manage_department_equipment'
- ).delete()
+ DepartmentPermission = apps.get_model("employees", "DepartmentPermission")
+ DepartmentPermission.objects.filter(code="manage_department_equipment").delete()
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0034_alter_employee_telegram'),
+ ("employees", "0034_alter_employee_telegram"),
]
operations = [
diff --git a/backend/employees/migrations/0035_create_role_assignment.py b/backend/employees/migrations/0035_create_role_assignment.py
index bd7bcd01..801aabc0 100644
--- a/backend/employees/migrations/0035_create_role_assignment.py
+++ b/backend/employees/migrations/0035_create_role_assignment.py
@@ -6,28 +6,85 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0034_alter_employee_telegram'),
+ ("employees", "0034_alter_employee_telegram"),
]
operations = [
migrations.CreateModel(
- name='RoleAssignment',
+ name="RoleAssignment",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('assigned_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата назначения')),
- ('is_active', models.BooleanField(db_index=True, default=True, verbose_name='Активно')),
- ('assigned_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_roles', to=settings.AUTH_USER_MODEL, verbose_name='Назначил')),
- ('employee', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='role_assignments', to=settings.AUTH_USER_MODEL, verbose_name='Сотрудник')),
- ('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assignments', to='employees.departmentrole', verbose_name='Роль')),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "assigned_at",
+ models.DateTimeField(
+ auto_now_add=True, verbose_name="Дата назначения"
+ ),
+ ),
+ (
+ "is_active",
+ models.BooleanField(
+ db_index=True, default=True, verbose_name="Активно"
+ ),
+ ),
+ (
+ "assigned_by",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="assigned_roles",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Назначил",
+ ),
+ ),
+ (
+ "employee",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="role_assignments",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Сотрудник",
+ ),
+ ),
+ (
+ "role",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="assignments",
+ to="employees.departmentrole",
+ verbose_name="Роль",
+ ),
+ ),
],
options={
- 'verbose_name': 'Назначение роли',
- 'verbose_name_plural': 'Назначения ролей',
- 'ordering': ['-assigned_at'],
- 'indexes': [models.Index(fields=['role', 'is_active'], name='employees_r_role_id_215f2d_idx'), models.Index(fields=['employee', 'is_active'], name='employees_r_employe_5d7a2a_idx')],
- 'constraints': [models.UniqueConstraint(fields=('employee', 'role'), name='uniq_employee_role_assignment')],
+ "verbose_name": "Назначение роли",
+ "verbose_name_plural": "Назначения ролей",
+ "ordering": ["-assigned_at"],
+ "indexes": [
+ models.Index(
+ fields=["role", "is_active"],
+ name="employees_r_role_id_215f2d_idx",
+ ),
+ models.Index(
+ fields=["employee", "is_active"],
+ name="employees_r_employe_5d7a2a_idx",
+ ),
+ ],
+ "constraints": [
+ models.UniqueConstraint(
+ fields=("employee", "role"),
+ name="uniq_employee_role_assignment",
+ )
+ ],
},
),
]
diff --git a/backend/employees/migrations/0036_migrate_role_assignments.py b/backend/employees/migrations/0036_migrate_role_assignments.py
index 93ce72c5..9ec43f2d 100644
--- a/backend/employees/migrations/0036_migrate_role_assignments.py
+++ b/backend/employees/migrations/0036_migrate_role_assignments.py
@@ -6,13 +6,13 @@ from django.db import migrations
def migrate_role_data(apps, schema_editor):
"""Переносит назначения ролей из EmployeeDepartment.role в RoleAssignment."""
- EmployeeDepartment = apps.get_model('employees', 'EmployeeDepartment')
- RoleAssignment = apps.get_model('employees', 'RoleAssignment')
+ EmployeeDepartment = apps.get_model("employees", "EmployeeDepartment")
+ RoleAssignment = apps.get_model("employees", "RoleAssignment")
# Находим все связи с назначенными ролями
links_with_roles = EmployeeDepartment.objects.filter(
role__isnull=False
- ).select_related('employee', 'role')
+ ).select_related("employee", "role")
created_count = 0
for link in links_with_roles:
@@ -21,27 +21,28 @@ def migrate_role_data(apps, schema_editor):
employee=link.employee,
role=link.role,
defaults={
- 'is_active': link.is_active,
- 'assigned_by': None, # Исторические данные без информации о назначителе
- }
+ "is_active": link.is_active,
+ "assigned_by": None, # Исторические данные без информации о назначителе
+ },
)
if created:
created_count += 1
if created_count > 0:
- print(f"Migrated {created_count} role assignments from EmployeeDepartment to RoleAssignment")
+ print(
+ f"Migrated {created_count} role assignments from EmployeeDepartment to RoleAssignment"
+ )
def reverse_migration(apps, schema_editor):
"""Откат: удаляем все RoleAssignment (данные сохранены в EmployeeDepartment.role)."""
- RoleAssignment = apps.get_model('employees', 'RoleAssignment')
+ RoleAssignment = apps.get_model("employees", "RoleAssignment")
RoleAssignment.objects.all().delete()
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0035_create_role_assignment'),
+ ("employees", "0035_create_role_assignment"),
]
operations = [
diff --git a/backend/employees/migrations/0037_alter_employee_options.py b/backend/employees/migrations/0037_alter_employee_options.py
index ac75e072..626be0a7 100644
--- a/backend/employees/migrations/0037_alter_employee_options.py
+++ b/backend/employees/migrations/0037_alter_employee_options.py
@@ -4,14 +4,24 @@ from django.db import migrations
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0036_migrate_role_assignments'),
+ ("employees", "0036_migrate_role_assignments"),
]
operations = [
migrations.AlterModelOptions(
- name='employee',
- options={'ordering': ('last_name', 'first_name'), 'permissions': [('manage_employee_skills', 'Может управлять навыками сотрудников'), ('view_ldap_info', 'Может просматривать LDAP информацию сотрудников')], 'verbose_name': 'Сотрудник', 'verbose_name_plural': 'Сотрудники'},
+ name="employee",
+ options={
+ "ordering": ("last_name", "first_name"),
+ "permissions": [
+ ("manage_employee_skills", "Может управлять навыками сотрудников"),
+ (
+ "view_ldap_info",
+ "Может просматривать LDAP информацию сотрудников",
+ ),
+ ],
+ "verbose_name": "Сотрудник",
+ "verbose_name_plural": "Сотрудники",
+ },
),
]
diff --git a/backend/employees/migrations/0038_employee_username.py b/backend/employees/migrations/0038_employee_username.py
index 692e526a..728b2c9e 100644
--- a/backend/employees/migrations/0038_employee_username.py
+++ b/backend/employees/migrations/0038_employee_username.py
@@ -4,15 +4,20 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0037_alter_employee_options'),
+ ("employees", "0037_alter_employee_options"),
]
operations = [
migrations.AddField(
- model_name='employee',
- name='username',
- field=models.CharField(blank=True, help_text='Кэшированный LDAP логин сотрудника', max_length=150, null=True, verbose_name='LDAP Username (кэш)'),
+ model_name="employee",
+ name="username",
+ field=models.CharField(
+ blank=True,
+ help_text="Кэшированный LDAP логин сотрудника",
+ max_length=150,
+ null=True,
+ verbose_name="LDAP Username (кэш)",
+ ),
),
]
diff --git a/backend/employees/migrations/0039_employee_employees_e_usernam_4d3ddc_idx.py b/backend/employees/migrations/0039_employee_employees_e_usernam_4d3ddc_idx.py
index 0634845e..112d2449 100644
--- a/backend/employees/migrations/0039_employee_employees_e_usernam_4d3ddc_idx.py
+++ b/backend/employees/migrations/0039_employee_employees_e_usernam_4d3ddc_idx.py
@@ -4,15 +4,16 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('auth', '0012_alter_user_first_name_max_length'),
- ('employees', '0038_employee_username'),
+ ("auth", "0012_alter_user_first_name_max_length"),
+ ("employees", "0038_employee_username"),
]
operations = [
migrations.AddIndex(
- model_name='employee',
- index=models.Index(fields=['username'], name='employees_e_usernam_4d3ddc_idx'),
+ model_name="employee",
+ index=models.Index(
+ fields=["username"], name="employees_e_usernam_4d3ddc_idx"
+ ),
),
]
diff --git a/backend/employees/migrations/0040_merge_20260227_1639.py b/backend/employees/migrations/0040_merge_20260227_1639.py
index 1b9bc50b..ce32e1c2 100644
--- a/backend/employees/migrations/0040_merge_20260227_1639.py
+++ b/backend/employees/migrations/0040_merge_20260227_1639.py
@@ -4,11 +4,9 @@ from django.db import migrations
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0035_add_manage_equipment_permission'),
- ('employees', '0039_employee_employees_e_usernam_4d3ddc_idx'),
+ ("employees", "0035_add_manage_equipment_permission"),
+ ("employees", "0039_employee_employees_e_usernam_4d3ddc_idx"),
]
- operations = [
- ]
+ operations = []
diff --git a/backend/employees/migrations/0041_alter_departmentpermission_code.py b/backend/employees/migrations/0041_alter_departmentpermission_code.py
index 88f5c391..4803300e 100644
--- a/backend/employees/migrations/0041_alter_departmentpermission_code.py
+++ b/backend/employees/migrations/0041_alter_departmentpermission_code.py
@@ -4,15 +4,33 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0040_merge_20260227_1639'),
+ ("employees", "0040_merge_20260227_1639"),
]
operations = [
migrations.AlterField(
- model_name='departmentpermission',
- name='code',
- field=models.CharField(choices=[('manage_department', 'Управлять отделом'), ('change_department_head', 'Назначать руководителя'), ('assign_department_role', 'Назначать роли участникам'), ('manage_department_events', 'Управлять календарём отдела'), ('publish_department_post', 'Публиковать новости на странице отдела'), ('manage_department_feed', 'Редактировать публикации отдела'), ('view_requestcomment', 'Просмотр комментариев по заявлениям'), ('add_requestcomment', 'Добавление коментариев по заявлениям'), ('view_request', 'Просмотр заявлений отдела'), ('can_process_requests', 'Рассмотрение заявлений отдела'), ('manage_department_equipment', 'Управлять оборудованием отдела')], max_length=64, unique=True),
+ model_name="departmentpermission",
+ name="code",
+ field=models.CharField(
+ choices=[
+ ("manage_department", "Управлять отделом"),
+ ("change_department_head", "Назначать руководителя"),
+ ("assign_department_role", "Назначать роли участникам"),
+ ("manage_department_events", "Управлять календарём отдела"),
+ (
+ "publish_department_post",
+ "Публиковать новости на странице отдела",
+ ),
+ ("manage_department_feed", "Редактировать публикации отдела"),
+ ("view_requestcomment", "Просмотр комментариев по заявлениям"),
+ ("add_requestcomment", "Добавление коментариев по заявлениям"),
+ ("view_request", "Просмотр заявлений отдела"),
+ ("can_process_requests", "Рассмотрение заявлений отдела"),
+ ("manage_department_equipment", "Управлять оборудованием отдела"),
+ ],
+ max_length=64,
+ unique=True,
+ ),
),
]
diff --git a/backend/employees/migrations/0042_ldapsyncqueue.py b/backend/employees/migrations/0042_ldapsyncqueue.py
index fdd6f79e..e29b5a9b 100644
--- a/backend/employees/migrations/0042_ldapsyncqueue.py
+++ b/backend/employees/migrations/0042_ldapsyncqueue.py
@@ -1,99 +1,272 @@
# Generated by Django 5.2.4 on 2026-03-19 19:53
-import employees.ldap.mixins
import ldapdb.models.fields
from django.db import migrations, models
+import employees.ldap.mixins
+
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0041_alter_departmentpermission_code'),
+ ("employees", "0041_alter_departmentpermission_code"),
]
operations = [
migrations.CreateModel(
- name='LdapGroup',
+ name="LdapGroup",
fields=[
- ('dn', ldapdb.models.fields.CharField(max_length=200, primary_key=True, serialize=False)),
- ('cn', ldapdb.models.fields.CharField(db_column='cn', max_length=200)),
- ('object_guid', ldapdb.models.fields.CharField(db_column='objectGUID', max_length=200)),
- ('sam_account_name', ldapdb.models.fields.CharField(db_column='sAMAccountName', max_length=200)),
- ('description', ldapdb.models.fields.CharField(blank=True, db_column='description', max_length=200)),
- ('member', ldapdb.models.fields.ListField(blank=True, db_column='member')),
- ('member_of', ldapdb.models.fields.ListField(blank=True, db_column='memberOf')),
- ('when_created', ldapdb.models.fields.DateTimeField(db_column='whenCreated')),
- ('when_changed', ldapdb.models.fields.DateTimeField(db_column='whenChanged')),
+ (
+ "dn",
+ ldapdb.models.fields.CharField(
+ max_length=200, primary_key=True, serialize=False
+ ),
+ ),
+ ("cn", ldapdb.models.fields.CharField(db_column="cn", max_length=200)),
+ (
+ "object_guid",
+ ldapdb.models.fields.CharField(
+ db_column="objectGUID", max_length=200
+ ),
+ ),
+ (
+ "sam_account_name",
+ ldapdb.models.fields.CharField(
+ db_column="sAMAccountName", max_length=200
+ ),
+ ),
+ (
+ "description",
+ ldapdb.models.fields.CharField(
+ blank=True, db_column="description", max_length=200
+ ),
+ ),
+ (
+ "member",
+ ldapdb.models.fields.ListField(blank=True, db_column="member"),
+ ),
+ (
+ "member_of",
+ ldapdb.models.fields.ListField(blank=True, db_column="memberOf"),
+ ),
+ (
+ "when_created",
+ ldapdb.models.fields.DateTimeField(db_column="whenCreated"),
+ ),
+ (
+ "when_changed",
+ ldapdb.models.fields.DateTimeField(db_column="whenChanged"),
+ ),
],
options={
- 'managed': False,
+ "managed": False,
},
bases=(employees.ldap.mixins.ModifyDnMixin, models.Model),
),
migrations.CreateModel(
- name='LdapOrganizationalUnit',
+ name="LdapOrganizationalUnit",
fields=[
- ('dn', ldapdb.models.fields.CharField(max_length=200, primary_key=True, serialize=False)),
- ('ou', ldapdb.models.fields.CharField(db_column='ou', max_length=200)),
- ('object_guid', ldapdb.models.fields.CharField(db_column='objectGUID', max_length=200)),
- ('description', ldapdb.models.fields.CharField(blank=True, db_column='description', max_length=200)),
- ('managed_by', ldapdb.models.fields.CharField(blank=True, db_column='managedBy', max_length=200)),
- ('when_created', ldapdb.models.fields.DateTimeField(db_column='whenCreated')),
- ('when_changed', ldapdb.models.fields.DateTimeField(db_column='whenChanged')),
+ (
+ "dn",
+ ldapdb.models.fields.CharField(
+ max_length=200, primary_key=True, serialize=False
+ ),
+ ),
+ ("ou", ldapdb.models.fields.CharField(db_column="ou", max_length=200)),
+ (
+ "object_guid",
+ ldapdb.models.fields.CharField(
+ db_column="objectGUID", max_length=200
+ ),
+ ),
+ (
+ "description",
+ ldapdb.models.fields.CharField(
+ blank=True, db_column="description", max_length=200
+ ),
+ ),
+ (
+ "managed_by",
+ ldapdb.models.fields.CharField(
+ blank=True, db_column="managedBy", max_length=200
+ ),
+ ),
+ (
+ "when_created",
+ ldapdb.models.fields.DateTimeField(db_column="whenCreated"),
+ ),
+ (
+ "when_changed",
+ ldapdb.models.fields.DateTimeField(db_column="whenChanged"),
+ ),
],
options={
- 'managed': False,
+ "managed": False,
},
bases=(employees.ldap.mixins.ModifyDnMixin, models.Model),
),
migrations.CreateModel(
- name='LdapUser',
+ name="LdapUser",
fields=[
- ('dn', ldapdb.models.fields.CharField(max_length=200, primary_key=True, serialize=False)),
- ('cn', ldapdb.models.fields.CharField(db_column='cn', max_length=200)),
- ('object_guid', ldapdb.models.fields.CharField(db_column='objectGUID', max_length=200)),
- ('sam_account_name', ldapdb.models.fields.CharField(db_column='sAMAccountName', max_length=200)),
- ('user_principal_name', ldapdb.models.fields.CharField(db_column='userPrincipalName', max_length=200)),
- ('given_name', ldapdb.models.fields.CharField(db_column='givenName', max_length=200)),
- ('sn', ldapdb.models.fields.CharField(db_column='sn', max_length=200)),
- ('display_name', ldapdb.models.fields.CharField(db_column='displayName', max_length=200)),
- ('mail', ldapdb.models.fields.CharField(db_column='mail', max_length=200)),
- ('telephone_number', ldapdb.models.fields.CharField(blank=True, db_column='telephoneNumber', max_length=200)),
- ('mobile', ldapdb.models.fields.CharField(blank=True, db_column='mobile', max_length=200)),
- ('user_account_control', ldapdb.models.fields.IntegerField(db_column='userAccountControl')),
- ('description', ldapdb.models.fields.CharField(blank=True, db_column='description', max_length=200)),
- ('thumbnail_photo', ldapdb.models.fields.ImageField(blank=True, db_column='thumbnailPhoto')),
- ('employee_number', ldapdb.models.fields.CharField(blank=True, db_column='employeeNumber', max_length=200)),
- ('member_of', ldapdb.models.fields.ListField(blank=True, db_column='memberOf')),
- ('when_created', ldapdb.models.fields.DateTimeField(db_column='whenCreated')),
- ('when_changed', ldapdb.models.fields.DateTimeField(db_column='whenChanged')),
+ (
+ "dn",
+ ldapdb.models.fields.CharField(
+ max_length=200, primary_key=True, serialize=False
+ ),
+ ),
+ ("cn", ldapdb.models.fields.CharField(db_column="cn", max_length=200)),
+ (
+ "object_guid",
+ ldapdb.models.fields.CharField(
+ db_column="objectGUID", max_length=200
+ ),
+ ),
+ (
+ "sam_account_name",
+ ldapdb.models.fields.CharField(
+ db_column="sAMAccountName", max_length=200
+ ),
+ ),
+ (
+ "user_principal_name",
+ ldapdb.models.fields.CharField(
+ db_column="userPrincipalName", max_length=200
+ ),
+ ),
+ (
+ "given_name",
+ ldapdb.models.fields.CharField(
+ db_column="givenName", max_length=200
+ ),
+ ),
+ ("sn", ldapdb.models.fields.CharField(db_column="sn", max_length=200)),
+ (
+ "display_name",
+ ldapdb.models.fields.CharField(
+ db_column="displayName", max_length=200
+ ),
+ ),
+ (
+ "mail",
+ ldapdb.models.fields.CharField(db_column="mail", max_length=200),
+ ),
+ (
+ "telephone_number",
+ ldapdb.models.fields.CharField(
+ blank=True, db_column="telephoneNumber", max_length=200
+ ),
+ ),
+ (
+ "mobile",
+ ldapdb.models.fields.CharField(
+ blank=True, db_column="mobile", max_length=200
+ ),
+ ),
+ (
+ "user_account_control",
+ ldapdb.models.fields.IntegerField(db_column="userAccountControl"),
+ ),
+ (
+ "description",
+ ldapdb.models.fields.CharField(
+ blank=True, db_column="description", max_length=200
+ ),
+ ),
+ (
+ "thumbnail_photo",
+ ldapdb.models.fields.ImageField(
+ blank=True, db_column="thumbnailPhoto"
+ ),
+ ),
+ (
+ "employee_number",
+ ldapdb.models.fields.CharField(
+ blank=True, db_column="employeeNumber", max_length=200
+ ),
+ ),
+ (
+ "member_of",
+ ldapdb.models.fields.ListField(blank=True, db_column="memberOf"),
+ ),
+ (
+ "when_created",
+ ldapdb.models.fields.DateTimeField(db_column="whenCreated"),
+ ),
+ (
+ "when_changed",
+ ldapdb.models.fields.DateTimeField(db_column="whenChanged"),
+ ),
],
options={
- 'managed': False,
+ "managed": False,
},
- bases=(employees.ldap.mixins.LdapSyncStateMixin, employees.ldap.mixins.ModifyDnMixin, models.Model),
+ bases=(
+ employees.ldap.mixins.LdapSyncStateMixin,
+ employees.ldap.mixins.ModifyDnMixin,
+ models.Model,
+ ),
),
migrations.CreateModel(
- name='LdapSyncQueue',
+ name="LdapSyncQueue",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('operation', models.CharField(db_index=True, help_text='Имя операции (employee_save, department_delete, group_m2m и т.д.)', max_length=64)),
- ('model_name', models.CharField(db_index=True, max_length=32)),
- ('object_pk', models.CharField(db_index=True, max_length=64)),
- ('payload', models.JSONField(default=dict, help_text='JSON с аргументами для повтора операции')),
- ('status', models.CharField(choices=[('pending', 'Ожидает'), ('in_progress', 'Выполняется'), ('completed', 'Завершено'), ('failed', 'Ошибка (исчерпаны попытки)')], db_index=True, default='pending', max_length=16)),
- ('attempts', models.PositiveIntegerField(default=0)),
- ('max_attempts', models.PositiveIntegerField(default=5)),
- ('next_retry_at', models.DateTimeField(blank=True, db_index=True, null=True)),
- ('last_error', models.TextField(blank=True, default='')),
- ('created_at', models.DateTimeField(auto_now_add=True)),
- ('updated_at', models.DateTimeField(auto_now=True)),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "operation",
+ models.CharField(
+ db_index=True,
+ help_text="Имя операции (employee_save, department_delete, group_m2m и т.д.)",
+ max_length=64,
+ ),
+ ),
+ ("model_name", models.CharField(db_index=True, max_length=32)),
+ ("object_pk", models.CharField(db_index=True, max_length=64)),
+ (
+ "payload",
+ models.JSONField(
+ default=dict,
+ help_text="JSON с аргументами для повтора операции",
+ ),
+ ),
+ (
+ "status",
+ models.CharField(
+ choices=[
+ ("pending", "Ожидает"),
+ ("in_progress", "Выполняется"),
+ ("completed", "Завершено"),
+ ("failed", "Ошибка (исчерпаны попытки)"),
+ ],
+ db_index=True,
+ default="pending",
+ max_length=16,
+ ),
+ ),
+ ("attempts", models.PositiveIntegerField(default=0)),
+ ("max_attempts", models.PositiveIntegerField(default=5)),
+ (
+ "next_retry_at",
+ models.DateTimeField(blank=True, db_index=True, null=True),
+ ),
+ ("last_error", models.TextField(blank=True, default="")),
+ ("created_at", models.DateTimeField(auto_now_add=True)),
+ ("updated_at", models.DateTimeField(auto_now=True)),
],
options={
- 'verbose_name': 'Очередь LDAP синхронизации',
- 'verbose_name_plural': 'Очередь LDAP синхронизации',
- 'ordering': ['created_at'],
- 'indexes': [models.Index(fields=['status', 'next_retry_at'], name='ldap_queue_retry_idx')],
+ "verbose_name": "Очередь LDAP синхронизации",
+ "verbose_name_plural": "Очередь LDAP синхронизации",
+ "ordering": ["created_at"],
+ "indexes": [
+ models.Index(
+ fields=["status", "next_retry_at"], name="ldap_queue_retry_idx"
+ )
+ ],
},
),
]
diff --git a/backend/employees/migrations/0043_ldaporganizationalunitgroup_approvalauthority_and_more.py b/backend/employees/migrations/0043_ldaporganizationalunitgroup_approvalauthority_and_more.py
index 64188ec7..71af4b61 100644
--- a/backend/employees/migrations/0043_ldaporganizationalunitgroup_approvalauthority_and_more.py
+++ b/backend/employees/migrations/0043_ldaporganizationalunitgroup_approvalauthority_and_more.py
@@ -6,52 +6,204 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0042_ldapsyncqueue'),
+ ("employees", "0042_ldapsyncqueue"),
]
operations = [
migrations.CreateModel(
- name='ApprovalAuthority',
+ name="ApprovalAuthority",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('code', models.CharField(max_length=64, unique=True, verbose_name='Код')),
- ('name', models.CharField(max_length=150, verbose_name='Название')),
- ('description', models.TextField(blank=True, verbose_name='Описание')),
- ('scope', models.CharField(choices=[('company', 'Компания'), ('department', 'Отдел')], default='company', max_length=20, verbose_name='Область действия')),
- ('level', models.PositiveSmallIntegerField(db_index=True, default=100, help_text='Чем выше число, тем выше уровень эскалации.', verbose_name='Уровень согласования')),
- ('resolution_strategy', models.CharField(choices=[('assignment', 'По явному назначению'), ('department_head', 'По руководителю отдела')], default='assignment', max_length=20, verbose_name='Стратегия определения согласующего')),
- ('is_active', models.BooleanField(db_index=True, default=True, verbose_name='Активно')),
- ('position', models.ForeignKey(blank=True, help_text='Опционально: используется как fallback, если нет активного назначения.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='approval_authorities', to='employees.position', verbose_name='Базовая должность')),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "code",
+ models.CharField(max_length=64, unique=True, verbose_name="Код"),
+ ),
+ ("name", models.CharField(max_length=150, verbose_name="Название")),
+ ("description", models.TextField(blank=True, verbose_name="Описание")),
+ (
+ "scope",
+ models.CharField(
+ choices=[("company", "Компания"), ("department", "Отдел")],
+ default="company",
+ max_length=20,
+ verbose_name="Область действия",
+ ),
+ ),
+ (
+ "level",
+ models.PositiveSmallIntegerField(
+ db_index=True,
+ default=100,
+ help_text="Чем выше число, тем выше уровень эскалации.",
+ verbose_name="Уровень согласования",
+ ),
+ ),
+ (
+ "resolution_strategy",
+ models.CharField(
+ choices=[
+ ("assignment", "По явному назначению"),
+ ("department_head", "По руководителю отдела"),
+ ],
+ default="assignment",
+ max_length=20,
+ verbose_name="Стратегия определения согласующего",
+ ),
+ ),
+ (
+ "is_active",
+ models.BooleanField(
+ db_index=True, default=True, verbose_name="Активно"
+ ),
+ ),
+ (
+ "position",
+ models.ForeignKey(
+ blank=True,
+ help_text="Опционально: используется как fallback, если нет активного назначения.",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="approval_authorities",
+ to="employees.position",
+ verbose_name="Базовая должность",
+ ),
+ ),
],
options={
- 'verbose_name': 'Роль согласования',
- 'verbose_name_plural': 'Роли согласования',
- 'ordering': ('level', 'name', 'id'),
+ "verbose_name": "Роль согласования",
+ "verbose_name_plural": "Роли согласования",
+ "ordering": ("level", "name", "id"),
},
),
migrations.CreateModel(
- name='ApprovalAuthorityAssignment',
+ name="ApprovalAuthorityAssignment",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('priority', models.PositiveIntegerField(db_index=True, default=100, help_text='Меньшее значение имеет более высокий приоритет.', verbose_name='Приоритет')),
- ('date_from', models.DateField(blank=True, null=True, verbose_name='Дата начала')),
- ('date_to', models.DateField(blank=True, null=True, verbose_name='Дата окончания')),
- ('is_primary', models.BooleanField(db_index=True, default=False, verbose_name='Основное назначение')),
- ('is_active', models.BooleanField(db_index=True, default=True, verbose_name='Активно')),
- ('assigned_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата назначения')),
- ('assigned_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_approval_authorities', to=settings.AUTH_USER_MODEL, verbose_name='Назначил')),
- ('authority', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assignments', to='employees.approvalauthority', verbose_name='Роль согласования')),
- ('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='approval_authority_assignments', to='employees.department', verbose_name='Отдел')),
- ('employee', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='approval_authority_assignments', to=settings.AUTH_USER_MODEL, verbose_name='Сотрудник')),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "priority",
+ models.PositiveIntegerField(
+ db_index=True,
+ default=100,
+ help_text="Меньшее значение имеет более высокий приоритет.",
+ verbose_name="Приоритет",
+ ),
+ ),
+ (
+ "date_from",
+ models.DateField(blank=True, null=True, verbose_name="Дата начала"),
+ ),
+ (
+ "date_to",
+ models.DateField(
+ blank=True, null=True, verbose_name="Дата окончания"
+ ),
+ ),
+ (
+ "is_primary",
+ models.BooleanField(
+ db_index=True, default=False, verbose_name="Основное назначение"
+ ),
+ ),
+ (
+ "is_active",
+ models.BooleanField(
+ db_index=True, default=True, verbose_name="Активно"
+ ),
+ ),
+ (
+ "assigned_at",
+ models.DateTimeField(
+ auto_now_add=True, verbose_name="Дата назначения"
+ ),
+ ),
+ (
+ "assigned_by",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="assigned_approval_authorities",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Назначил",
+ ),
+ ),
+ (
+ "authority",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="assignments",
+ to="employees.approvalauthority",
+ verbose_name="Роль согласования",
+ ),
+ ),
+ (
+ "department",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="approval_authority_assignments",
+ to="employees.department",
+ verbose_name="Отдел",
+ ),
+ ),
+ (
+ "employee",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="approval_authority_assignments",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Сотрудник",
+ ),
+ ),
],
options={
- 'verbose_name': 'Назначение роли согласования',
- 'verbose_name_plural': 'Назначения ролей согласования',
- 'ordering': ('authority', 'department', '-is_primary', 'priority', 'id'),
- 'indexes': [models.Index(fields=['authority', 'is_active', 'department'], name='employees_a_authori_a9c9f4_idx'), models.Index(fields=['employee', 'is_active'], name='employees_a_employe_5199d0_idx'), models.Index(fields=['authority', 'department', 'priority'], name='employees_a_authori_1883a2_idx')],
- 'constraints': [models.UniqueConstraint(fields=('authority', 'employee', 'department'), name='uniq_approval_authority_assignment')],
+ "verbose_name": "Назначение роли согласования",
+ "verbose_name_plural": "Назначения ролей согласования",
+ "ordering": (
+ "authority",
+ "department",
+ "-is_primary",
+ "priority",
+ "id",
+ ),
+ "indexes": [
+ models.Index(
+ fields=["authority", "is_active", "department"],
+ name="employees_a_authori_a9c9f4_idx",
+ ),
+ models.Index(
+ fields=["employee", "is_active"],
+ name="employees_a_employe_5199d0_idx",
+ ),
+ models.Index(
+ fields=["authority", "department", "priority"],
+ name="employees_a_authori_1883a2_idx",
+ ),
+ ],
+ "constraints": [
+ models.UniqueConstraint(
+ fields=("authority", "employee", "department"),
+ name="uniq_approval_authority_assignment",
+ )
+ ],
},
),
]
diff --git a/backend/employees/migrations/0044_remove_approval_authority_models.py b/backend/employees/migrations/0044_remove_approval_authority_models.py
index 931d9607..4be4ff85 100644
--- a/backend/employees/migrations/0044_remove_approval_authority_models.py
+++ b/backend/employees/migrations/0044_remove_approval_authority_models.py
@@ -4,16 +4,15 @@ from django.db import migrations
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0043_ldaporganizationalunitgroup_approvalauthority_and_more'),
+ ("employees", "0043_ldaporganizationalunitgroup_approvalauthority_and_more"),
]
operations = [
migrations.DeleteModel(
- name='ApprovalAuthorityAssignment',
+ name="ApprovalAuthorityAssignment",
),
migrations.DeleteModel(
- name='ApprovalAuthority',
+ name="ApprovalAuthority",
),
]
diff --git a/backend/employees/migrations/0045_ldaporganizationalunitgroup.py b/backend/employees/migrations/0045_ldaporganizationalunitgroup.py
index 265d9771..3771d640 100644
--- a/backend/employees/migrations/0045_ldaporganizationalunitgroup.py
+++ b/backend/employees/migrations/0045_ldaporganizationalunitgroup.py
@@ -1,31 +1,58 @@
# Generated by Django 5.2.4 on 2026-03-29 23:58
-import employees.ldap.mixins
import ldapdb.models.fields
from django.db import migrations, models
+import employees.ldap.mixins
+
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0044_remove_approval_authority_models'),
+ ("employees", "0044_remove_approval_authority_models"),
]
operations = [
migrations.CreateModel(
- name='LdapOrganizationalUnitGroup',
+ name="LdapOrganizationalUnitGroup",
fields=[
- ('dn', ldapdb.models.fields.CharField(max_length=200, primary_key=True, serialize=False)),
- ('cn', ldapdb.models.fields.CharField(db_column='cn', max_length=200)),
- ('sam_account_name', ldapdb.models.fields.CharField(db_column='sAMAccountName', max_length=200)),
- ('description', ldapdb.models.fields.CharField(blank=True, db_column='description', max_length=200)),
- ('member', ldapdb.models.fields.ListField(blank=True, db_column='member')),
- ('member_of', ldapdb.models.fields.ListField(blank=True, db_column='memberOf')),
- ('when_created', ldapdb.models.fields.DateTimeField(db_column='whenCreated')),
- ('when_changed', ldapdb.models.fields.DateTimeField(db_column='whenChanged')),
+ (
+ "dn",
+ ldapdb.models.fields.CharField(
+ max_length=200, primary_key=True, serialize=False
+ ),
+ ),
+ ("cn", ldapdb.models.fields.CharField(db_column="cn", max_length=200)),
+ (
+ "sam_account_name",
+ ldapdb.models.fields.CharField(
+ db_column="sAMAccountName", max_length=200
+ ),
+ ),
+ (
+ "description",
+ ldapdb.models.fields.CharField(
+ blank=True, db_column="description", max_length=200
+ ),
+ ),
+ (
+ "member",
+ ldapdb.models.fields.ListField(blank=True, db_column="member"),
+ ),
+ (
+ "member_of",
+ ldapdb.models.fields.ListField(blank=True, db_column="memberOf"),
+ ),
+ (
+ "when_created",
+ ldapdb.models.fields.DateTimeField(db_column="whenCreated"),
+ ),
+ (
+ "when_changed",
+ ldapdb.models.fields.DateTimeField(db_column="whenChanged"),
+ ),
],
options={
- 'managed': False,
+ "managed": False,
},
bases=(employees.ldap.mixins.ModifyDnMixin, models.Model),
),
diff --git a/backend/employees/migrations/0046_alter_ldapsyncqueue_operation.py b/backend/employees/migrations/0046_alter_ldapsyncqueue_operation.py
index f48ee5f7..89074e3d 100644
--- a/backend/employees/migrations/0046_alter_ldapsyncqueue_operation.py
+++ b/backend/employees/migrations/0046_alter_ldapsyncqueue_operation.py
@@ -4,15 +4,18 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0045_ldaporganizationalunitgroup'),
+ ("employees", "0045_ldaporganizationalunitgroup"),
]
operations = [
migrations.AlterField(
- model_name='ldapsyncqueue',
- name='operation',
- field=models.CharField(db_index=True, help_text='Имя операции: employee_save, department_delete, group_m2m и т.д.', max_length=64),
+ model_name="ldapsyncqueue",
+ name="operation",
+ field=models.CharField(
+ db_index=True,
+ help_text="Имя операции: employee_save, department_delete, group_m2m и т.д.",
+ max_length=64,
+ ),
),
]
diff --git a/backend/employees/migrations/0047_alter_departmentpermission_code.py b/backend/employees/migrations/0047_alter_departmentpermission_code.py
index 43a4df6b..25c08417 100644
--- a/backend/employees/migrations/0047_alter_departmentpermission_code.py
+++ b/backend/employees/migrations/0047_alter_departmentpermission_code.py
@@ -11,16 +11,29 @@ def remove_obsolete_department_permissions(apps, schema_editor):
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0046_alter_ldapsyncqueue_operation'),
+ ("employees", "0046_alter_ldapsyncqueue_operation"),
]
operations = [
migrations.AlterField(
- model_name='departmentpermission',
- name='code',
- field=models.CharField(choices=[('manage_department', 'Управлять отделом'), ('change_department_head', 'Назначать руководителя'), ('assign_department_role', 'Назначать роли участникам'), ('manage_department_feed', 'Редактировать публикации отдела'), ('view_requestcomment', 'Просмотр комментариев по заявлениям'), ('add_requestcomment', 'Добавление коментариев по заявлениям'), ('view_request', 'Просмотр заявлений отдела'), ('can_process_requests', 'Рассмотрение заявлений отдела'), ('manage_department_equipment', 'Управлять оборудованием отдела')], max_length=64, unique=True),
+ model_name="departmentpermission",
+ name="code",
+ field=models.CharField(
+ choices=[
+ ("manage_department", "Управлять отделом"),
+ ("change_department_head", "Назначать руководителя"),
+ ("assign_department_role", "Назначать роли участникам"),
+ ("manage_department_feed", "Редактировать публикации отдела"),
+ ("view_requestcomment", "Просмотр комментариев по заявлениям"),
+ ("add_requestcomment", "Добавление коментариев по заявлениям"),
+ ("view_request", "Просмотр заявлений отдела"),
+ ("can_process_requests", "Рассмотрение заявлений отдела"),
+ ("manage_department_equipment", "Управлять оборудованием отдела"),
+ ],
+ max_length=64,
+ unique=True,
+ ),
),
migrations.RunPython(
remove_obsolete_department_permissions,
diff --git a/backend/employees/migrations/0048_alter_employeeaction_action_and_more.py b/backend/employees/migrations/0048_alter_employeeaction_action_and_more.py
index 9c3a7d45..e3839d7a 100644
--- a/backend/employees/migrations/0048_alter_employeeaction_action_and_more.py
+++ b/backend/employees/migrations/0048_alter_employeeaction_action_and_more.py
@@ -4,20 +4,53 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0047_alter_departmentpermission_code'),
+ ("employees", "0047_alter_departmentpermission_code"),
]
operations = [
migrations.AlterField(
- model_name='employeeaction',
- name='action',
- field=models.CharField(choices=[('hired', 'Принят'), ('dismissed', 'Уволен'), ('on_leave', 'В отпуске'), ('returned_from_leave', 'Вернулся из отпуска'), ('on_sick_leave', 'На больничном'), ('returned_from_sick_leave', 'Вернулся с больничного'), ('on_day_off', 'В отгуле'), ('returned_from_day_off', 'Вернулся из отгула'), ('on_maternity', 'В декрете'), ('returned_from_maternity', 'Вернулся из декрета'), ('transferred', 'Переведен'), ('rehired', 'Восстановлен')], max_length=50, verbose_name='Кадровое событие'),
+ model_name="employeeaction",
+ name="action",
+ field=models.CharField(
+ choices=[
+ ("hired", "Принят"),
+ ("dismissed", "Уволен"),
+ ("on_leave", "В отпуске"),
+ ("returned_from_leave", "Вернулся из отпуска"),
+ ("on_sick_leave", "На больничном"),
+ ("returned_from_sick_leave", "Вернулся с больничного"),
+ ("on_day_off", "В отгуле"),
+ ("returned_from_day_off", "Вернулся из отгула"),
+ ("on_maternity", "В декрете"),
+ ("returned_from_maternity", "Вернулся из декрета"),
+ ("transferred", "Переведен"),
+ ("rehired", "Восстановлен"),
+ ],
+ max_length=50,
+ verbose_name="Кадровое событие",
+ ),
),
migrations.AlterField(
- model_name='historicalemployeeaction',
- name='action',
- field=models.CharField(choices=[('hired', 'Принят'), ('dismissed', 'Уволен'), ('on_leave', 'В отпуске'), ('returned_from_leave', 'Вернулся из отпуска'), ('on_sick_leave', 'На больничном'), ('returned_from_sick_leave', 'Вернулся с больничного'), ('on_day_off', 'В отгуле'), ('returned_from_day_off', 'Вернулся из отгула'), ('on_maternity', 'В декрете'), ('returned_from_maternity', 'Вернулся из декрета'), ('transferred', 'Переведен'), ('rehired', 'Восстановлен')], max_length=50, verbose_name='Кадровое событие'),
+ model_name="historicalemployeeaction",
+ name="action",
+ field=models.CharField(
+ choices=[
+ ("hired", "Принят"),
+ ("dismissed", "Уволен"),
+ ("on_leave", "В отпуске"),
+ ("returned_from_leave", "Вернулся из отпуска"),
+ ("on_sick_leave", "На больничном"),
+ ("returned_from_sick_leave", "Вернулся с больничного"),
+ ("on_day_off", "В отгуле"),
+ ("returned_from_day_off", "Вернулся из отгула"),
+ ("on_maternity", "В декрете"),
+ ("returned_from_maternity", "Вернулся из декрета"),
+ ("transferred", "Переведен"),
+ ("rehired", "Восстановлен"),
+ ],
+ max_length=50,
+ verbose_name="Кадровое событие",
+ ),
),
]
diff --git a/backend/employees/migrations/0050_employeeaction_date_to.py b/backend/employees/migrations/0050_employeeaction_date_to.py
index 57322e80..2b999281 100644
--- a/backend/employees/migrations/0050_employeeaction_date_to.py
+++ b/backend/employees/migrations/0050_employeeaction_date_to.py
@@ -4,7 +4,6 @@ from datetime import timedelta
from django.db import migrations, models
-
TEMPORARY_ACTION_RETURN_MAP = {
"on_leave": "returned_from_leave",
"on_sick_leave": "returned_from_sick_leave",
diff --git a/backend/employees/migrations/0053_cleanup_department_role_permissions.py b/backend/employees/migrations/0053_cleanup_department_role_permissions.py
index d251560f..af0fb64b 100644
--- a/backend/employees/migrations/0053_cleanup_department_role_permissions.py
+++ b/backend/employees/migrations/0053_cleanup_department_role_permissions.py
@@ -2,7 +2,6 @@
from django.db import migrations, models
-
OBSOLETE_REQUEST_PERMISSION_CODES = [
"view_request",
"view_requestcomment",
@@ -21,7 +20,6 @@ def cleanup_department_role_permissions(apps, schema_editor):
class Migration(migrations.Migration):
-
dependencies = [
("employees", "0052_employee_attendance_aliases"),
]
diff --git a/backend/employees/migrations/0054_employee_id_guest_range_guard.py b/backend/employees/migrations/0054_employee_id_guest_range_guard.py
index 80d823dd..bdaeeb7e 100644
--- a/backend/employees/migrations/0054_employee_id_guest_range_guard.py
+++ b/backend/employees/migrations/0054_employee_id_guest_range_guard.py
@@ -1,6 +1,5 @@
from django.db import migrations, models
-
GUEST_ID_MIN = 900_000_000_000_000
EMPLOYEE_ID_MAX = GUEST_ID_MIN - 1
@@ -9,9 +8,7 @@ def cap_employee_sequence(apps, schema_editor):
if schema_editor.connection.vendor != "postgresql":
return
with schema_editor.connection.cursor() as cursor:
- cursor.execute(
- "SELECT pg_get_serial_sequence('employees_employee', 'id')"
- )
+ cursor.execute("SELECT pg_get_serial_sequence('employees_employee', 'id')")
row = cursor.fetchone()
sequence_name = row[0] if row else None
if sequence_name:
@@ -22,9 +19,7 @@ def uncap_employee_sequence(apps, schema_editor):
if schema_editor.connection.vendor != "postgresql":
return
with schema_editor.connection.cursor() as cursor:
- cursor.execute(
- "SELECT pg_get_serial_sequence('employees_employee', 'id')"
- )
+ cursor.execute("SELECT pg_get_serial_sequence('employees_employee', 'id')")
row = cursor.fetchone()
sequence_name = row[0] if row else None
if sequence_name:
@@ -32,7 +27,6 @@ def uncap_employee_sequence(apps, schema_editor):
class Migration(migrations.Migration):
-
dependencies = [
("employees", "0053_cleanup_department_role_permissions"),
]
diff --git a/backend/employees/migrations/0056_employee_hidden_in_ui.py b/backend/employees/migrations/0056_employee_hidden_in_ui.py
index 5e3eb7cb..1dce5a23 100644
--- a/backend/employees/migrations/0056_employee_hidden_in_ui.py
+++ b/backend/employees/migrations/0056_employee_hidden_in_ui.py
@@ -4,15 +4,18 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0055_employee_email_activation_sent_at'),
+ ("employees", "0055_employee_email_activation_sent_at"),
]
operations = [
migrations.AddField(
- model_name='employee',
- name='is_hidden_in_ui',
- field=models.BooleanField(default=False, help_text='Сотрудник сможет входить и пользоваться своими правами, но не будет отображаться в каталогах, поиске и селекторах сотрудников.', verbose_name='Скрыт в интерфейсе'),
+ model_name="employee",
+ name="is_hidden_in_ui",
+ field=models.BooleanField(
+ default=False,
+ help_text="Сотрудник сможет входить и пользоваться своими правами, но не будет отображаться в каталогах, поиске и селекторах сотрудников.",
+ verbose_name="Скрыт в интерфейсе",
+ ),
),
]
diff --git a/backend/employees/migrations/0061_secretskillsettings.py b/backend/employees/migrations/0061_secretskillsettings.py
index f9a44dc4..16ad0208 100644
--- a/backend/employees/migrations/0061_secretskillsettings.py
+++ b/backend/employees/migrations/0061_secretskillsettings.py
@@ -40,7 +40,10 @@ class Migration(migrations.Migration):
verbose_name="Начало расчёта секретных навыков",
),
),
- ("updated_at", models.DateTimeField(auto_now=True, verbose_name="Обновлено")),
+ (
+ "updated_at",
+ models.DateTimeField(auto_now=True, verbose_name="Обновлено"),
+ ),
],
options={
"verbose_name": "Настройки секретных навыков",
diff --git a/backend/employees/models.py b/backend/employees/models.py
index 08b9a469..c079e26d 100644
--- a/backend/employees/models.py
+++ b/backend/employees/models.py
@@ -1,7 +1,6 @@
# backend\employees\models.py
from datetime import date
-from common.emails import send_templated_mail
from django.contrib.auth.models import AbstractUser, BaseUserManager, Group
from django.core.exceptions import ValidationError
from django.db import IntegrityError, models, transaction
@@ -11,6 +10,8 @@ from django.utils.crypto import get_random_string
from phonenumber_field.modelfields import PhoneNumberField
from simple_history.models import HistoricalRecords
+from common.emails import send_templated_mail
+
from .constants import (
ACTION_CHOICES,
ACTION_DISMISSED,
diff --git a/backend/employees/rules.py b/backend/employees/rules.py
index fb6ccbd1..8fc73ffe 100644
--- a/backend/employees/rules.py
+++ b/backend/employees/rules.py
@@ -9,7 +9,6 @@ import rules
from employees.positions import employee_position_search_text
-
# -----------------------------------------------------------------------------
# ПРЕДИКАТЫ (predicates)
# -----------------------------------------------------------------------------
@@ -96,9 +95,7 @@ rules.add_rule(
rules.add_rule("employees.delete_employee", is_superuser | is_hr_staff)
# Просмотр списка всех сотрудников
-rules.add_rule(
- "employees.view_all_employees", is_superuser | is_staff | is_hr_staff
-)
+rules.add_rule("employees.view_all_employees", is_superuser | is_staff | is_hr_staff)
# Просмотр отчётов по персоналу (только HR и руководители)
rules.add_rule(
diff --git a/backend/employees/services/__init__.py b/backend/employees/services/__init__.py
index 200a33ae..bd668917 100644
--- a/backend/employees/services/__init__.py
+++ b/backend/employees/services/__init__.py
@@ -1,14 +1,15 @@
"""
Сервисы для работы с сотрудниками.
"""
+
from .birthday_events import (
- UpsertBirthdayEventService,
+ BulkSyncBirthdaysService,
DeleteBirthdayEventService,
- BulkSyncBirthdaysService
+ UpsertBirthdayEventService,
)
__all__ = [
- 'UpsertBirthdayEventService',
- 'DeleteBirthdayEventService',
- 'BulkSyncBirthdaysService',
+ "UpsertBirthdayEventService",
+ "DeleteBirthdayEventService",
+ "BulkSyncBirthdaysService",
]
diff --git a/backend/employees/services/birthday_events.py b/backend/employees/services/birthday_events.py
index 90224e8d..ddeb8888 100644
--- a/backend/employees/services/birthday_events.py
+++ b/backend/employees/services/birthday_events.py
@@ -7,11 +7,11 @@ import calendar as calendar_lib
from datetime import datetime, timedelta
from django.utils import timezone
-from service_objects.services import Service
+from schedule.models import Calendar, Event, Rule
from service_objects.fields import ModelField
+from service_objects.services import Service
from employees.models import Employee
-from schedule.models import Calendar, Event, Rule
# Константы
BIRTHDAY_COLOR = "#FFC107" # Жёлтый/золотой цвет для дней рождения
@@ -52,13 +52,13 @@ class UpsertBirthdayEventService(Service):
birthday_calendar = self._get_or_create_birthday_calendar()
# Создаем или обновляем событие
- event, created = self._upsert_birthday_event(
- employee, birthday_calendar
- )
+ event, created = self._upsert_birthday_event(employee, birthday_calendar)
return {"success": True, "event": event, "created": created}
- def _should_have_birthday_event(self, employee: Employee) -> tuple[bool, str | None]:
+ def _should_have_birthday_event(
+ self, employee: Employee
+ ) -> tuple[bool, str | None]:
"""Проверить, должен ли сотрудник попадать в календарь ДР."""
if not employee.birth_date:
return False, "no_birth_date"
@@ -116,9 +116,7 @@ class UpsertBirthdayEventService(Service):
existing_event.title = title
existing_event.start = start_date
existing_event.end = end_date
- existing_event.calendar = (
- calendar # Переносим в общий календарь birthdays
- )
+ existing_event.calendar = calendar # Переносим в общий календарь birthdays
existing_event.rule = rule
existing_event.end_recurring_period = None # Бесконечное повторение
existing_event.color_event = BIRTHDAY_COLOR
@@ -139,9 +137,7 @@ class UpsertBirthdayEventService(Service):
rule=rule,
end_recurring_period=None, # Бесконечное повторение
color_event=BIRTHDAY_COLOR,
- description=f"Автоматическое событие: день рождения {
- str(employee)
- }",
+ description=f"Автоматическое событие: день рождения {str(employee)}",
)
return event, True # Создано
@@ -215,9 +211,7 @@ class BulkSyncBirthdaysService(Service):
for employee in employees:
results["total"] += 1
try:
- result = UpsertBirthdayEventService.execute(
- {"employee": employee}
- )
+ result = UpsertBirthdayEventService.execute({"employee": employee})
if result["success"]:
if result.get("created"):
@@ -229,8 +223,6 @@ class BulkSyncBirthdaysService(Service):
else:
results["skipped"] += 1
except Exception as e:
- results["errors"].append(
- {"employee_id": employee.pk, "error": str(e)}
- )
+ results["errors"].append({"employee_id": employee.pk, "error": str(e)})
return results
diff --git a/backend/employees/services/personnel_state.py b/backend/employees/services/personnel_state.py
index 57978829..0913fbf5 100644
--- a/backend/employees/services/personnel_state.py
+++ b/backend/employees/services/personnel_state.py
@@ -8,9 +8,9 @@ from django.utils import timezone
from employees.constants import (
ACTION_DISMISSED,
+ ACTION_LABELS,
ACTION_REMOTE,
ACTION_WORKING,
- ACTION_LABELS,
ACTIVATING_MARKER_ACTIONS,
MARKER_ACTIONS,
PERMANENT_ACTIONS,
@@ -213,9 +213,7 @@ def _resolve_permanent_action(
return last_permanent
restored_state = [
- action
- for action in permanent_actions[:-1]
- if action.action != ACTION_DISMISSED
+ action for action in permanent_actions[:-1] if action.action != ACTION_DISMISSED
]
return restored_state[-1] if restored_state else None
@@ -299,7 +297,9 @@ def _short_day_off_intervals(
else:
extra = action.extra if isinstance(action.extra, dict) else {}
try:
- start = time.fromisoformat(str(extra.get("starts_at", "")).split("T")[-1])
+ start = time.fromisoformat(
+ str(extra.get("starts_at", "")).split("T")[-1]
+ )
end = time.fromisoformat(str(extra.get("ends_at", "")).split("T")[-1])
except ValueError:
continue
diff --git a/backend/employees/services/request_actions.py b/backend/employees/services/request_actions.py
index 21b544e2..84b84054 100644
--- a/backend/employees/services/request_actions.py
+++ b/backend/employees/services/request_actions.py
@@ -72,10 +72,7 @@ def create_request_action(
if legacy.source_request_id is None:
legacy.source_request = request
updates.append("source_request")
- if (
- not isinstance(legacy.extra, dict)
- or legacy.extra.get("request_id") is None
- ):
+ if not isinstance(legacy.extra, dict) or legacy.extra.get("request_id") is None:
current_extra = legacy.extra if isinstance(legacy.extra, dict) else {}
legacy.extra = {**current_extra, "request_id": request.id}
updates.append("extra")
diff --git a/backend/employees/services/secret_skills.py b/backend/employees/services/secret_skills.py
index 552e1050..721faaf1 100644
--- a/backend/employees/services/secret_skills.py
+++ b/backend/employees/services/secret_skills.py
@@ -759,12 +759,8 @@ def calculate_personnel_secret_metrics( # noqa: C901
resilience_state.update(
anti_streak=sick_count,
penalty_levels=penalty_levels,
- effective_rank=RANK_CODES[
- max(earned_rank_index - penalty_levels, 0)
- ],
- recovery_streak=(
- recovery_days_since_last_sick % 90 if penalty_levels else 0
- ),
+ effective_rank=RANK_CODES[max(earned_rank_index - penalty_levels, 0)],
+ recovery_streak=(recovery_days_since_last_sick % 90 if penalty_levels else 0),
last_demotion_date=(
recent_sick_dates[-1].isoformat() if penalty_levels else ""
),
diff --git a/backend/employees/services/skill_progress.py b/backend/employees/services/skill_progress.py
index 91198266..6b1536d4 100644
--- a/backend/employees/services/skill_progress.py
+++ b/backend/employees/services/skill_progress.py
@@ -18,7 +18,6 @@ from employees.models import (
SkillExperienceEvent,
)
-
SKILL_RANKS = (
{"code": "none", "label": "Без ранга", "threshold": 0},
{"code": "bronze", "label": "Бронза", "threshold": 100},
diff --git a/backend/employees/services/task_execution_skill.py b/backend/employees/services/task_execution_skill.py
index be2ad74e..2109f398 100644
--- a/backend/employees/services/task_execution_skill.py
+++ b/backend/employees/services/task_execution_skill.py
@@ -265,9 +265,7 @@ def calculate_task_execution_metrics(
employee_id=employee_id,
status=TaskExecutionCycle.Status.PENDING,
**(
- {"completed_at_snapshot__date__gte": since}
- if since is not None
- else {}
+ {"completed_at_snapshot__date__gte": since} if since is not None else {}
),
).count(),
"violations_last_60": violations_last_60,
@@ -424,9 +422,7 @@ def refresh_task_execution_skills( # noqa: C901
cycle_employees = TaskExecutionCycle.objects.exclude(employee_id=None)
if since is not None:
- cycle_employees = cycle_employees.filter(
- completed_at_snapshot__date__gte=since
- )
+ cycle_employees = cycle_employees.filter(completed_at_snapshot__date__gte=since)
open_tasks = Task.objects.filter(
completed_at__isnull=True,
assignee__isnull=False,
@@ -447,9 +443,7 @@ def refresh_task_execution_skills( # noqa: C901
)
rank_changes = 0
for employee_id in employee_ids:
- rank_changes += len(
- recalculate_task_execution_skill(employee_id, since=since)
- )
+ rank_changes += len(recalculate_task_execution_skill(employee_id, since=since))
return {
"captured_cycles": len(missing_task_ids),
"changed_cycles": changed_cycles,
diff --git a/backend/employees/signals/birthday.py b/backend/employees/signals/birthday.py
index b55763bd..893df0e9 100644
--- a/backend/employees/signals/birthday.py
+++ b/backend/employees/signals/birthday.py
@@ -17,11 +17,10 @@ from django.dispatch import receiver
from employees.constants import ACTIVATING_MARKER_ACTIONS, PERMANENT_ACTIONS
from employees.models import Employee
from employees.services import (
- UpsertBirthdayEventService,
DeleteBirthdayEventService,
+ UpsertBirthdayEventService,
)
-
logger = logging.getLogger(__name__)
diff --git a/backend/employees/signals/ldap/__init__.py b/backend/employees/signals/ldap/__init__.py
index 300f0229..448c14c1 100644
--- a/backend/employees/signals/ldap/__init__.py
+++ b/backend/employees/signals/ldap/__init__.py
@@ -1,5 +1,5 @@
"""LDAP signals для автоматической синхронизации моделей с Active Directory."""
-from . import employee, department, group, position, role
+from . import department, employee, group, position, role
-__all__ = ['employee', 'department', 'group', 'position', 'role']
+__all__ = ["employee", "department", "group", "position", "role"]
diff --git a/backend/employees/signals/ldap/_queue.py b/backend/employees/signals/ldap/_queue.py
index abb9ed58..8177f5e3 100644
--- a/backend/employees/signals/ldap/_queue.py
+++ b/backend/employees/signals/ldap/_queue.py
@@ -24,8 +24,16 @@ def _enqueue(operation: str, model_name: str, object_pk, payload: dict):
from employees.tasks import process_ldap_queue_item
process_ldap_queue_item.delay(item.pk)
- except Exception:
- pass # Celery недоступен — periodic task подберёт позже
+ except Exception as exc:
+ logger.warning(
+ "Immediate LDAP queue dispatch failed; periodic retry will handle it",
+ extra={
+ "event": "ldap.queue.dispatch_failed",
+ "queue_id": item.pk,
+ "error_type": type(exc).__name__,
+ },
+ exc_info=True,
+ )
logger.info(
"LDAP operation enqueued",
diff --git a/backend/employees/signals/ldap/employee.py b/backend/employees/signals/ldap/employee.py
index 3d577277..718dfc36 100644
--- a/backend/employees/signals/ldap/employee.py
+++ b/backend/employees/signals/ldap/employee.py
@@ -141,8 +141,16 @@ def sync_employee_to_ldap_on_save(sender, instance, created, **kwargs):
if hasattr(avatar_file, "seek"):
avatar_file.seek(0)
changes["avatar_bytes"] = avatar_file.read()
- except Exception:
- pass
+ except Exception as exc:
+ logger.warning(
+ "LDAP avatar payload could not be read",
+ extra={
+ "event": "ldap.employee.avatar_read_failed",
+ "employee_id": instance.pk,
+ "error_type": type(exc).__name__,
+ },
+ exc_info=True,
+ )
for field in [
"first_name",
diff --git a/backend/employees/tasks.py b/backend/employees/tasks.py
index 5afdc9b2..8981ea35 100644
--- a/backend/employees/tasks.py
+++ b/backend/employees/tasks.py
@@ -183,6 +183,7 @@ def _execute_department_member(payload: dict) -> None:
def _execute_group_save(payload: dict) -> None:
"""Повторяет синхронизацию Group → LDAP."""
from django.contrib.auth.models import Group
+
from employees.ldap import GroupService
from employees.models import LdapSyncState
@@ -264,10 +265,10 @@ def _execute_position_save(payload: dict) -> None:
def _execute_position_delete(payload: dict) -> None:
"""Повторяет удаление Position POS-группы из LDAP."""
- from employees.ldap import PositionService
-
from types import SimpleNamespace
+ from employees.ldap import PositionService
+
pos_stub = SimpleNamespace(
pk=payload["object_pk"],
id=payload["object_pk"],
diff --git a/backend/employees/utils.py b/backend/employees/utils.py
index cdddaa6c..88f2df49 100644
--- a/backend/employees/utils.py
+++ b/backend/employees/utils.py
@@ -229,11 +229,7 @@ def _build_links_for_dept(dept: Department, serializer) -> list[dict]:
for link in qs:
emp_data = serializer(link.employee).data # содержит display_name
seen_employee_ids.add(link.employee_id)
- role = (
- {"id": link.role_id, "name": link.role.name}
- if link.role_id
- else None
- )
+ role = {"id": link.role_id, "name": link.role.name} if link.role_id else None
links.append(
{
"employee": emp_data,
@@ -302,9 +298,7 @@ def _build_links_for_dept(dept: Department, serializer) -> list[dict]:
)
# гарантируем присутствие head
- if dept.head_id and all(
- item["employee"]["id"] != dept.head_id for item in links
- ):
+ if dept.head_id and all(item["employee"]["id"] != dept.head_id for item in links):
head_data = serializer(dept.head).data
links.insert(
0,
@@ -524,11 +518,7 @@ def resolve_chat_participants(chat):
)
# 5. Legacy: department field (DEPRECATED, для обратной совместимости)
- if (
- chat.type == "department"
- and hasattr(chat, "department")
- and chat.department_id
- ):
+ if chat.type == "department" and hasattr(chat, "department") and chat.department_id:
employee_ids = EmployeeDepartment.objects.filter(
department_id=chat.department_id, is_active=True
).values_list("employee_id", flat=True)
diff --git a/backend/eusrr_backend/__init__.py b/backend/eusrr_backend/__init__.py
index eeabe4f0..564fba98 100644
--- a/backend/eusrr_backend/__init__.py
+++ b/backend/eusrr_backend/__init__.py
@@ -1,7 +1,8 @@
"""
Инициализация проекта EUSRR
"""
+
# Это гарантирует, что приложение Celery загружается при старте Django
from .celery import app as celery_app
-__all__ = ('celery_app',)
+__all__ = ("celery_app",)
diff --git a/backend/eusrr_backend/asgi.py b/backend/eusrr_backend/asgi.py
index b17a79ff..267ce57d 100644
--- a/backend/eusrr_backend/asgi.py
+++ b/backend/eusrr_backend/asgi.py
@@ -31,8 +31,6 @@ else:
application = ProtocolTypeRouter(
{
"http": django_asgi_app,
- "websocket": WebSocketLoggingMiddleware(
- websocket_application
- ),
+ "websocket": WebSocketLoggingMiddleware(websocket_application),
}
)
diff --git a/backend/eusrr_backend/auth_backends.py b/backend/eusrr_backend/auth_backends.py
index 63b75cbb..ea543861 100644
--- a/backend/eusrr_backend/auth_backends.py
+++ b/backend/eusrr_backend/auth_backends.py
@@ -3,7 +3,7 @@ from __future__ import annotations
import logging
import re
-from typing import Any, List, Optional, Tuple
+from typing import Any, List, Optional
from django.conf import settings
from django.contrib.auth import get_user_model
@@ -11,11 +11,12 @@ from django.contrib.auth.backends import ModelBackend
from django.contrib.auth.models import Permission
from django.http import HttpRequest
from django.utils import timezone
+from ldap3 import SUBTREE, Connection, Server
+from ldap3.core.exceptions import LDAPBindError, LDAPSocketOpenError
+
from employees.ldap.utils.text_utils import esc_filter
from employees.models import Department, Employee, EmployeeDepartment
from employees.utils import _normalize_phone
-from ldap3 import SUBTREE, Connection, Server
-from ldap3.core.exceptions import LDAPBindError, LDAPSocketOpenError
logger = logging.getLogger(__name__)
@@ -181,79 +182,98 @@ class LDAP3Backend(ModelBackend):
password: Optional[str] = None,
**kwargs: Any,
):
- """Аутентифицирует пользователя через LDAP/AD.
-
- Логика:
- 1) Валидирует входные данные и настройки.
- 2) Service bind -> поиск записи пользователя (DN)
- по идентификатору/телефону.
- 3) User bind -> проверка пароля.
- 4) Поиск/создание локального пользователя
- и лёгкая синхронизация профиля.
-
- Args:
- request: Django HttpRequest или None.
- username: Идентификатор (может быть email/телефон/uid).
- password: Пароль пользователя.
- **kwargs: Могут содержать email/login/USERNAME_FIELD.
-
- Returns:
- User | None: Аутентифицированный пользователь или None при неудаче.
-
- Raises:
- None: Метод перехватывает ошибки LDAP/сети
- и возвращает None при сбое.
- """
- identifier = (
- (username or "")
- or (kwargs.get("email") or "")
- or (kwargs.get("login") or "")
- or (kwargs.get(getattr(UserModel, "USERNAME_FIELD", "email")) or "")
- ).strip()
-
+ """Аутентифицирует существующего локального пользователя через LDAP/AD."""
+ identifier = self._authentication_identifier(username, kwargs)
+ ldap_enabled = getattr(settings, "LDAP_ENABLED", False)
logger.debug(
"LDAP authentication attempt received",
extra={
"event": "authentication.ldap.attempted",
"identifier_provided": bool(identifier),
"password_provided": bool(password),
- "ldap_enabled": getattr(settings, "LDAP_ENABLED", False),
+ "ldap_enabled": ldap_enabled,
},
)
-
- if (
- not identifier
- or not password
- or not getattr(settings, "LDAP_ENABLED", False)
- ):
+ if not identifier or not password or not ldap_enabled:
logger.debug(
"LDAP authentication skipped because prerequisites are missing",
extra={
"event": "authentication.ldap.skipped",
"identifier_provided": bool(identifier),
"password_provided": bool(password),
- "ldap_enabled": getattr(settings, "LDAP_ENABLED", False),
+ "ldap_enabled": ldap_enabled,
"reason": "prerequisite_missing",
},
)
return None
+ search_result = self._search_ldap_user(identifier)
+ if search_result is None:
+ return None
+ server, entry, user_dn, phone_attrs = search_result
+ if not self._validate_ldap_password(server, user_dn, password):
+ return None
+
+ profile = self._read_ldap_profile(entry, phone_attrs)
+ user = self._find_local_ldap_user(profile["email"], profile["phone_e164"])
+ if user is None:
+ logger.info(
+ "LDAP authentication denied because no local account exists",
+ extra={
+ "event": "authentication.ldap.denied",
+ "reason": "local_account_missing",
+ "auto_create_enabled": False,
+ },
+ )
+ return None
+ if not self.user_can_authenticate(user):
+ logger.info(
+ "LDAP authentication denied by local account policy",
+ extra={
+ "event": "authentication.ldap.denied",
+ "user_id": getattr(user, "id", None),
+ "reason": "local_account_policy",
+ },
+ )
+ return None
+
+ self._sync_local_ldap_profile(user, entry, user_dn, profile)
+ if not self.user_can_authenticate(user):
+ logger.info(
+ "LDAP authentication denied by local policy after profile sync",
+ extra={
+ "event": "authentication.ldap.denied",
+ "user_id": getattr(user, "id", None),
+ "reason": "post_sync_local_account_policy",
+ },
+ )
+ return None
+
+ logger.info(
+ "LDAP authentication succeeded",
+ extra={
+ "event": "authentication.ldap.succeeded",
+ "user_id": getattr(user, "id", None),
+ },
+ )
+ return user
+
+ @staticmethod
+ def _authentication_identifier(username: Optional[str], kwargs: dict) -> str:
+ return (
+ (username or "")
+ or (kwargs.get("email") or "")
+ or (kwargs.get("login") or "")
+ or (kwargs.get(getattr(UserModel, "USERNAME_FIELD", "email")) or "")
+ ).strip()
+
+ def _search_ldap_user(
+ self, identifier: str
+ ) -> tuple[Server, Any, str, tuple[str, ...]] | None:
uri = getattr(settings, "LDAP_URI", "")
user_base = getattr(settings, "LDAP_USERS_AUTH", None) or getattr(
settings, "LDAP_USER_BASE", None
)
- bind_dn = getattr(settings, "LDAP_BIND_DN", "")
- bind_pw = getattr(settings, "LDAP_BIND_PASSWORD", "")
-
- base_tpl = getattr(
- settings,
- "LDAP_USER_FILTER",
- "(|(uid={username})(mail={username}))",
- )
- phone_attrs: Tuple[str, ...] = tuple(
- getattr(settings, "LDAP_PHONE_ATTRS", ("mobile", "telephoneNumber"))
- )
-
if not uri or not user_base:
logger.warning(
"LDAP authentication unavailable because configuration is incomplete",
@@ -265,114 +285,15 @@ class LDAP3Backend(ModelBackend):
)
return None
- raw = identifier
- is_email = _looks_like_email(raw)
- e164 = _normalize_phone(raw)
- logger.debug(
- "LDAP identifier parsed",
- extra={
- "event": "authentication.ldap.identifier_parsed",
- "identifier_type": "email" if is_email else "other",
- "phone_normalized": bool(e164),
- },
+ phone_attrs = tuple(
+ getattr(settings, "LDAP_PHONE_ATTRS", ("mobile", "telephoneNumber"))
)
-
- # --- Service bind & search ---
- # Важно: используем get_info=NONE для скорости/минимума утечек.
- server = Server(uri) # get_info=NONE по умолчанию
- svc_conn: Optional[Connection] = None
+ ldap_filter = self._authentication_filter(identifier, phone_attrs)
+ server = Server(uri)
+ connection: Optional[Connection] = None
try:
- logger.debug(
- "LDAP service bind started",
- extra={"event": "authentication.ldap.service_bind_started"},
- )
- if bind_dn and bind_pw:
- svc_conn = Connection(
- server,
- user=bind_dn,
- password=bind_pw,
- auto_bind=True,
- receive_timeout=30,
- )
- logger.debug(
- "LDAP service bind completed",
- extra={
- "event": "authentication.ldap.service_bind_completed",
- "bind_mode": "configured_dn",
- },
- )
- else:
- svc_conn = Connection(server, auto_bind=True, receive_timeout=30)
- logger.debug(
- "LDAP service bind completed",
- extra={
- "event": "authentication.ldap.service_bind_completed",
- "bind_mode": "anonymous_or_simple",
- },
- )
-
- # Базовый объектный фильтр AD
- base_object_filter = getattr(
- settings,
- "LDAP_OBJECT_FILTER",
- "(&(objectCategory=person)(objectClass=user))",
- )
-
- # Фильтр по идентификатору
- if "{username}" in base_tpl:
- id_filter = base_tpl.format(username=esc_filter(raw))
- logger.debug(
- "LDAP configured identifier filter selected",
- extra={
- "event": "authentication.ldap.filter_selected",
- "filter_type": "configured",
- },
- )
- else:
- base_object_filter = base_tpl # админ дал базовый объектный фильтр
- if is_email:
- id_attrs = getattr(
- settings,
- "LDAP_LOGIN_MAIL_ATTRS",
- ("mail", "userPrincipalName"),
- )
- else:
- id_attrs = getattr(
- settings,
- "LDAP_LOGIN_UID_ATTRS",
- ("uid", "sAMAccountName"),
- )
- id_filter = (
- "(|" + "".join(f"({a}={esc_filter(raw)})" for a in id_attrs) + ")"
- )
- logger.debug(
- "LDAP default identifier filter selected",
- extra={
- "event": "authentication.ldap.filter_selected",
- "filter_type": "default",
- },
- )
-
- phone_filter = ""
- if e164:
- phone_filter = (
- "(|"
- + "".join(f"({attr}={esc_filter(e164)})" for attr in phone_attrs)
- + ")"
- )
- logger.debug(
- "LDAP phone filter enabled",
- extra={"event": "authentication.ldap.phone_filter_enabled"},
- )
-
- # (& base_object (| id_filter phone_filter?))
- if phone_filter:
- ldap_filter = f"(&{base_object_filter}(|{id_filter}{phone_filter}))"
- else:
- ldap_filter = f"(&{base_object_filter}{id_filter})"
-
- # AD атрибуты для поиска
- ok = svc_conn.search(
+ connection = self._service_connection(server)
+ ok = connection.search(
search_base=user_base,
search_filter=ldap_filter,
search_scope=SUBTREE,
@@ -390,7 +311,7 @@ class LDAP3Backend(ModelBackend):
],
size_limit=1,
)
- entries_count = len(svc_conn.entries) if svc_conn else 0
+ entries_count = len(connection.entries)
logger.debug(
"LDAP authentication search completed",
extra={
@@ -399,7 +320,7 @@ class LDAP3Backend(ModelBackend):
"entry_count": entries_count,
},
)
- if not ok or not svc_conn.entries:
+ if not ok or not connection.entries:
logger.info(
"LDAP authentication denied because directory entry was not found",
extra={
@@ -409,14 +330,13 @@ class LDAP3Backend(ModelBackend):
},
)
return None
-
- entry = svc_conn.entries[0]
- user_dn: str = str(entry.entry_dn)
+ entry = connection.entries[0]
+ user_dn = str(entry.entry_dn)
logger.debug(
"LDAP user entry found",
extra={"event": "authentication.ldap.entry_found"},
)
-
+ return server, entry, user_dn, phone_attrs
except (LDAPSocketOpenError, LDAPBindError) as error:
logger.error(
"LDAP service bind or search failed",
@@ -436,42 +356,132 @@ class LDAP3Backend(ModelBackend):
)
return None
finally:
- try:
- if svc_conn is not None:
- svc_conn.unbind()
- logger.debug(
- "LDAP service connection closed",
- extra={
- "event": "authentication.ldap.service_connection_closed"
- },
- )
- except Exception as error:
- logger.warning(
- "LDAP service unbind failed",
- extra={
- "event": "authentication.ldap.unbind_failed",
- "error_type": type(error).__name__,
- },
- )
+ self._close_service_connection(connection)
- # --- User bind (password check) ---
+ @staticmethod
+ def _service_connection(server: Server) -> Connection:
+ logger.debug(
+ "LDAP service bind started",
+ extra={"event": "authentication.ldap.service_bind_started"},
+ )
+ bind_dn = getattr(settings, "LDAP_BIND_DN", "")
+ bind_password = getattr(settings, "LDAP_BIND_PASSWORD", "")
+ if bind_dn and bind_password:
+ connection = Connection(
+ server,
+ user=bind_dn,
+ password=bind_password,
+ auto_bind=True,
+ receive_timeout=30,
+ )
+ bind_mode = "configured_dn"
+ else:
+ connection = Connection(server, auto_bind=True, receive_timeout=30)
+ bind_mode = "anonymous_or_simple"
+ logger.debug(
+ "LDAP service bind completed",
+ extra={
+ "event": "authentication.ldap.service_bind_completed",
+ "bind_mode": bind_mode,
+ },
+ )
+ return connection
+
+ @staticmethod
+ def _close_service_connection(connection: Optional[Connection]) -> None:
+ if connection is None:
+ return
+ try:
+ connection.unbind()
+ logger.debug(
+ "LDAP service connection closed",
+ extra={"event": "authentication.ldap.service_connection_closed"},
+ )
+ except Exception as error:
+ logger.warning(
+ "LDAP service unbind failed",
+ extra={
+ "event": "authentication.ldap.unbind_failed",
+ "error_type": type(error).__name__,
+ },
+ )
+
+ @staticmethod
+ def _authentication_filter(identifier: str, phone_attrs: tuple[str, ...]) -> str:
+ base_filter = getattr(
+ settings,
+ "LDAP_OBJECT_FILTER",
+ "(&(objectCategory=person)(objectClass=user))",
+ )
+ configured_filter = getattr(
+ settings, "LDAP_USER_FILTER", "(|(uid={username})(mail={username}))"
+ )
+ if "{username}" in configured_filter:
+ id_filter = configured_filter.format(username=esc_filter(identifier))
+ filter_type = "configured"
+ else:
+ base_filter = configured_filter
+ attrs_setting = (
+ "LDAP_LOGIN_MAIL_ATTRS"
+ if _looks_like_email(identifier)
+ else "LDAP_LOGIN_UID_ATTRS"
+ )
+ default_attrs = (
+ ("mail", "userPrincipalName")
+ if _looks_like_email(identifier)
+ else ("uid", "sAMAccountName")
+ )
+ identifier_attrs = getattr(settings, attrs_setting, default_attrs)
+ id_filter = (
+ "(|"
+ + "".join(
+ f"({attr}={esc_filter(identifier)})" for attr in identifier_attrs
+ )
+ + ")"
+ )
+ filter_type = "default"
+ logger.debug(
+ "LDAP identifier filter selected",
+ extra={
+ "event": "authentication.ldap.filter_selected",
+ "filter_type": filter_type,
+ },
+ )
+
+ phone = _normalize_phone(identifier)
+ if not phone:
+ return f"(&{base_filter}{id_filter})"
+ phone_filter = (
+ "(|"
+ + "".join(f"({attr}={esc_filter(phone)})" for attr in phone_attrs)
+ + ")"
+ )
+ logger.debug(
+ "LDAP phone filter enabled",
+ extra={"event": "authentication.ldap.phone_filter_enabled"},
+ )
+ return f"(&{base_filter}(|{id_filter}{phone_filter}))"
+
+ @staticmethod
+ def _validate_ldap_password(server: Server, user_dn: str, password: str) -> bool:
try:
logger.debug(
"LDAP user bind started",
extra={"event": "authentication.ldap.user_bind_started"},
)
- user_conn = Connection(
+ connection = Connection(
server,
user=user_dn,
password=password,
auto_bind=True,
receive_timeout=30,
)
- user_conn.unbind()
+ connection.unbind()
logger.debug(
"LDAP credentials validated",
extra={"event": "authentication.ldap.credentials_validated"},
)
+ return True
except LDAPBindError:
logger.info(
"LDAP authentication denied",
@@ -480,7 +490,7 @@ class LDAP3Backend(ModelBackend):
"reason": "invalid_credentials",
},
)
- return None
+ return False
except Exception as error:
logger.error(
"LDAP user bind failed",
@@ -489,16 +499,28 @@ class LDAP3Backend(ModelBackend):
"error_type": type(error).__name__,
},
)
- return None
+ return False
- # --- Read attributes ---
- get = self._attr
+ def _read_ldap_profile(
+ self, entry: Any, phone_attrs: tuple[str, ...]
+ ) -> dict[str, Optional[str]]:
first_name = (
- get(entry, getattr(settings, "LDAP_ATTR_GIVENNAME", "givenName")) or ""
+ self._attr(entry, getattr(settings, "LDAP_ATTR_GIVENNAME", "givenName"))
+ or ""
)
- last_name = get(entry, getattr(settings, "LDAP_ATTR_SN", "sn")) or ""
- email = (get(entry, getattr(settings, "LDAP_ATTR_MAIL", "mail")) or "").lower()
- ldap_username = (get(entry, "sAMAccountName") or "").strip()
+ last_name = self._attr(entry, getattr(settings, "LDAP_ATTR_SN", "sn")) or ""
+ email = (
+ self._attr(entry, getattr(settings, "LDAP_ATTR_MAIL", "mail")) or ""
+ ).lower()
+ ldap_username = (self._attr(entry, "sAMAccountName") or "").strip()
+ phone_e164 = None
+ for attr in (
+ *phone_attrs,
+ getattr(settings, "LDAP_ATTR_PHONE", "telephoneNumber"),
+ ):
+ value = self._attr(entry, attr)
+ if value and (phone_e164 := _normalize_phone(value)):
+ break
logger.debug(
"LDAP attributes read",
extra={
@@ -507,178 +529,107 @@ class LDAP3Backend(ModelBackend):
"has_last_name": bool(last_name),
"has_email": bool(email),
"has_username": bool(ldap_username),
+ "phone_normalized": bool(phone_e164),
},
)
+ return {
+ "first_name": first_name,
+ "last_name": last_name,
+ "email": email,
+ "ldap_username": ldap_username,
+ "phone_e164": phone_e164,
+ }
- phone_e164: Optional[str] = None
- for attr in list(phone_attrs) + [
- getattr(settings, "LDAP_ATTR_PHONE", "telephoneNumber")
- ]:
- val = get(entry, attr)
- if val:
- phone_e164 = _normalize_phone(val)
- logger.debug(
- "LDAP phone candidate processed",
- extra={
- "event": "authentication.ldap.phone_processed",
- "attribute": attr,
- "phone_normalized": bool(phone_e164),
- },
- )
- if phone_e164:
- break
-
- # --- Find or create local user ---
+ @staticmethod
+ def _find_local_ldap_user(email: str, phone_e164: Optional[str]):
user = None
if email and _model_has_field(UserModel, "email"):
user = UserModel.objects.filter(email__iexact=email).first()
if user is None and PHONE_FIELD and phone_e164:
user = UserModel.objects.filter(**{PHONE_FIELD: phone_e164}).first()
+ return user
- if user and not self.user_can_authenticate(user):
- logger.info(
- "LDAP authentication denied by local account policy",
+ def _sync_local_ldap_profile(
+ self, user, entry: Any, user_dn: str, profile: dict[str, Optional[str]]
+ ) -> None:
+ changed: List[str] = []
+ values = {
+ "username": profile["ldap_username"],
+ "first_name": profile["first_name"],
+ "last_name": profile["last_name"],
+ "email": profile["email"],
+ }
+ for field_name, value in values.items():
+ if not value or not _model_has_field(UserModel, field_name):
+ continue
+ current = getattr(user, field_name, "") or ""
+ if field_name == "email":
+ current = current.lower()
+ if current != value:
+ setattr(user, field_name, value)
+ changed.append(field_name)
+
+ if (
+ profile["email"]
+ and _model_has_field(UserModel, "email_verified")
+ and not getattr(user, "email_verified", False)
+ ):
+ user.email_verified = True
+ changed.append("email_verified")
+ for field_name in ("email_activation_code", "email_activation_sent_at"):
+ if _model_has_field(UserModel, field_name):
+ setattr(user, field_name, None)
+ changed.append(field_name)
+
+ phone_e164 = profile["phone_e164"]
+ if (
+ PHONE_FIELD
+ and phone_e164
+ and getattr(user, PHONE_FIELD, None) != phone_e164
+ ):
+ setattr(user, PHONE_FIELD, phone_e164)
+ changed.append(PHONE_FIELD)
+ if hasattr(user, "ldap_dn") and getattr(user, "ldap_dn") != user_dn:
+ user.ldap_dn = user_dn
+ changed.append("ldap_dn")
+
+ guid = self._attr(entry, "objectGUID")
+ if (
+ hasattr(user, "ldap_guid")
+ and guid
+ and getattr(user, "ldap_guid") != str(guid)
+ ):
+ user.ldap_guid = str(guid)
+ changed.append("ldap_guid")
+ if hasattr(user, "is_ldap_managed") and not getattr(
+ user, "is_ldap_managed", False
+ ):
+ user.is_ldap_managed = True
+ changed.append("is_ldap_managed")
+ if hasattr(user, "last_ldap_sync_at"):
+ user.last_ldap_sync_at = timezone.now()
+ changed.append("last_ldap_sync_at")
+
+ if changed:
+ updated_fields = sorted(set(changed))
+ user.save(update_fields=updated_fields)
+ logger.debug(
+ "Local LDAP user profile updated",
extra={
- "event": "authentication.ldap.denied",
+ "event": "authentication.ldap.profile_updated",
"user_id": getattr(user, "id", None),
- "reason": "local_account_policy",
+ "updated_fields": updated_fields,
+ "updated_field_count": len(updated_fields),
},
)
- return None
-
- # Auto-create отключен для LDAP3Backend (создание через API)
- if user is None:
- logger.info(
- "LDAP authentication denied because no local account exists",
+ else:
+ logger.debug(
+ "Local LDAP user profile is current",
extra={
- "event": "authentication.ldap.denied",
- "reason": "local_account_missing",
- "auto_create_enabled": False,
- },
- )
- return None
-
- # --- Light profile sync ---
- if user:
- changed: List[str] = []
-
- if (
- ldap_username
- and _model_has_field(UserModel, "username")
- and (getattr(user, "username", "") or "") != ldap_username
- ):
- user.username = ldap_username
- changed.append("username")
- if (
- _model_has_field(UserModel, "first_name")
- and user.first_name != first_name
- ):
- user.first_name = first_name
- changed.append("first_name")
- if _model_has_field(UserModel, "last_name") and user.last_name != last_name:
- user.last_name = last_name
- changed.append("last_name")
- if (
- email
- and _model_has_field(UserModel, "email")
- and (user.email or "").lower() != email
- ):
- user.email = email
- changed.append("email")
- if (
- email
- and _model_has_field(UserModel, "email_verified")
- and not getattr(user, "email_verified", False)
- ):
- user.email_verified = True
- changed.append("email_verified")
- if _model_has_field(UserModel, "email_activation_code"):
- user.email_activation_code = None
- changed.append("email_activation_code")
- if _model_has_field(UserModel, "email_activation_sent_at"):
- user.email_activation_sent_at = None
- changed.append("email_activation_sent_at")
- if (
- PHONE_FIELD
- and phone_e164
- and getattr(user, PHONE_FIELD, None) != phone_e164
- ):
- setattr(user, PHONE_FIELD, phone_e164)
- changed.append(PHONE_FIELD)
-
- if hasattr(user, "ldap_dn") and getattr(user, "ldap_dn") != user_dn:
- setattr(user, "ldap_dn", user_dn)
- changed.append("ldap_dn")
-
- guid = get(entry, "objectGUID")
- if (
- hasattr(user, "ldap_guid")
- and guid
- and getattr(user, "ldap_guid") != str(guid)
- ):
- # При необходимости замените str(guid) на корректную конверсию
- # GUID.
- setattr(user, "ldap_guid", str(guid))
- changed.append("ldap_guid")
-
- if hasattr(user, "is_ldap_managed") and not getattr(
- user, "is_ldap_managed", False
- ):
- setattr(user, "is_ldap_managed", True)
- changed.append("is_ldap_managed")
-
- if hasattr(user, "last_ldap_sync_at"):
- setattr(user, "last_ldap_sync_at", timezone.now())
- changed.append("last_ldap_sync_at")
-
- if changed:
- user.save(update_fields=list(set(changed)))
- logger.debug(
- "Local LDAP user profile updated",
- extra={
- "event": "authentication.ldap.profile_updated",
- "user_id": getattr(user, "id", None),
- "updated_fields": sorted(set(changed)),
- "updated_field_count": len(set(changed)),
- },
- )
- else:
- logger.debug(
- "Local LDAP user profile is current",
- extra={
- "event": "authentication.ldap.profile_unchanged",
- "user_id": getattr(user, "id", None),
- },
- )
-
- if not self.user_can_authenticate(user):
- logger.info(
- "LDAP authentication denied by local policy after profile sync",
- extra={
- "event": "authentication.ldap.denied",
- "user_id": getattr(user, "id", None),
- "reason": "post_sync_local_account_policy",
- },
- )
- return None
-
- logger.info(
- "LDAP authentication succeeded",
- extra={
- "event": "authentication.ldap.succeeded",
+ "event": "authentication.ldap.profile_unchanged",
"user_id": getattr(user, "id", None),
},
)
- return user
-
- logger.info(
- "LDAP authentication denied because no usable local account exists",
- extra={
- "event": "authentication.ldap.denied",
- "reason": "usable_local_account_missing",
- },
- )
- return None
@staticmethod
def _attr(entry: Any, name: str) -> Optional[str]:
diff --git a/backend/eusrr_backend/celery.py b/backend/eusrr_backend/celery.py
index a910030b..277c4d1f 100644
--- a/backend/eusrr_backend/celery.py
+++ b/backend/eusrr_backend/celery.py
@@ -18,7 +18,6 @@ from .logging_utils import (
resolve_client_ip,
)
-
logger = logging.getLogger("eusrr.celery")
_task_state = {}
_task_state_lock = Lock()
diff --git a/backend/eusrr_backend/context_processors.py b/backend/eusrr_backend/context_processors.py
index ec712a07..bcbad40b 100644
--- a/backend/eusrr_backend/context_processors.py
+++ b/backend/eusrr_backend/context_processors.py
@@ -1,4 +1,5 @@
from typing import Dict
+
from django.conf import settings
diff --git a/backend/eusrr_backend/logging_utils.py b/backend/eusrr_backend/logging_utils.py
index e6f16d9e..30ad9104 100644
--- a/backend/eusrr_backend/logging_utils.py
+++ b/backend/eusrr_backend/logging_utils.py
@@ -6,15 +6,14 @@ import contextvars
import json
import logging
import re
+from datetime import datetime, timezone
from ipaddress import ip_address
from time import monotonic
-from datetime import datetime, timezone
from typing import Any, Mapping
from uuid import uuid4
from django.conf import settings
-
_LOG_CONTEXT: contextvars.ContextVar[dict[str, Any]] = contextvars.ContextVar(
"eusrr_log_context", default={}
)
diff --git a/backend/eusrr_backend/middleware.py b/backend/eusrr_backend/middleware.py
index b2f650c9..64baf3e4 100644
--- a/backend/eusrr_backend/middleware.py
+++ b/backend/eusrr_backend/middleware.py
@@ -18,7 +18,6 @@ from .logging_utils import (
update_log_context,
)
-
logger = logging.getLogger("eusrr.http")
diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py
index 0acd4926..21f03ec6 100644
--- a/backend/eusrr_backend/settings.py
+++ b/backend/eusrr_backend/settings.py
@@ -3,9 +3,8 @@ import tempfile
from datetime import timedelta
from pathlib import Path
-from django.core.exceptions import ImproperlyConfigured
-
from celery.schedules import crontab
+from django.core.exceptions import ImproperlyConfigured
from dotenv import load_dotenv
load_dotenv()
@@ -31,9 +30,7 @@ ALLOWED_HOSTS = _split_env_list(os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1"
WEBSOCKET_ALLOW_QUERY_JWT = (
os.getenv("WEBSOCKET_ALLOW_QUERY_JWT", "false").lower() == "true"
)
-WEBSOCKET_ALLOWED_ORIGINS = _split_env_list(
- os.getenv("WEBSOCKET_ALLOWED_ORIGINS", "")
-)
+WEBSOCKET_ALLOWED_ORIGINS = _split_env_list(os.getenv("WEBSOCKET_ALLOWED_ORIGINS", ""))
INSTALLED_APPS = [
"daphne",
@@ -177,29 +174,15 @@ DATABASE_ROUTERS = ["eusrr_backend.db_routers.LdapRouter"]
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": (
- "django.contrib.auth.password_validation."
- "UserAttributeSimilarityValidator"
+ "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"
)
},
{
- "NAME": (
- "django.contrib.auth.password_validation."
- "MinimumLengthValidator"
- ),
+ "NAME": ("django.contrib.auth.password_validation.MinimumLengthValidator"),
"OPTIONS": {"min_length": 8},
},
- {
- "NAME": (
- "django.contrib.auth.password_validation."
- "CommonPasswordValidator"
- )
- },
- {
- "NAME": (
- "django.contrib.auth.password_validation."
- "NumericPasswordValidator"
- )
- },
+ {"NAME": ("django.contrib.auth.password_validation.CommonPasswordValidator")},
+ {"NAME": ("django.contrib.auth.password_validation.NumericPasswordValidator")},
]
LANGUAGE_CODE = "ru"
@@ -430,7 +413,9 @@ QR_LOGIN_REQUEST_TTL_SECONDS = int(os.getenv("QR_LOGIN_REQUEST_TTL_SECONDS", "12
# preferred; an inline value is supported for environments without secret files.
OIDC_PROVIDER_ENABLED = os.getenv("OIDC_PROVIDER_ENABLED", "false").lower() == "true"
OIDC_RSA_PRIVATE_KEY_PATH = os.getenv("OIDC_RSA_PRIVATE_KEY_PATH", "").strip()
-OIDC_RSA_PRIVATE_KEY = os.getenv("OIDC_RSA_PRIVATE_KEY", "").replace("\\n", "\n").strip()
+OIDC_RSA_PRIVATE_KEY = (
+ os.getenv("OIDC_RSA_PRIVATE_KEY", "").replace("\\n", "\n").strip()
+)
if OIDC_RSA_PRIVATE_KEY_PATH:
oidc_key_path = Path(OIDC_RSA_PRIVATE_KEY_PATH)
if not oidc_key_path.is_file():
@@ -483,9 +468,7 @@ OAUTH2_PROVIDER = {
"groups": "Корпоративные группы доступа",
},
"DEFAULT_SCOPES": ["openid", "profile", "email"],
- "ACCESS_TOKEN_EXPIRE_SECONDS": int(
- os.getenv("OIDC_ACCESS_TOKEN_SECONDS", "3600")
- ),
+ "ACCESS_TOKEN_EXPIRE_SECONDS": int(os.getenv("OIDC_ACCESS_TOKEN_SECONDS", "3600")),
"ID_TOKEN_EXPIRE_SECONDS": int(os.getenv("OIDC_ID_TOKEN_SECONDS", "600")),
"REFRESH_TOKEN_EXPIRE_SECONDS": timedelta(
seconds=int(os.getenv("OIDC_REFRESH_TOKEN_SECONDS", str(12 * 60 * 60)))
@@ -508,9 +491,10 @@ OAUTH2_PROVIDER = {
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
-SESSION_COOKIE_SECURE = os.getenv(
- "SESSION_COOKIE_SECURE", "true" if not DEBUG else "false"
-).lower() == "true"
+SESSION_COOKIE_SECURE = (
+ os.getenv("SESSION_COOKIE_SECURE", "true" if not DEBUG else "false").lower()
+ == "true"
+)
# -----------------------------------------------------------------------------
# NOTIFICATIONS
@@ -557,9 +541,7 @@ CHANNEL_LAYERS = {
# events; expiry bounds memory when clients disappear abruptly.
"capacity": int(os.getenv("CHANNEL_LAYER_CAPACITY", "1000")),
"expiry": int(os.getenv("CHANNEL_LAYER_EXPIRY", "60")),
- "group_expiry": int(
- os.getenv("CHANNEL_LAYER_GROUP_EXPIRY", "86400")
- ),
+ "group_expiry": int(os.getenv("CHANNEL_LAYER_GROUP_EXPIRY", "86400")),
},
}
}
@@ -854,24 +836,17 @@ CELERY_TASK_SOFT_TIME_LIMIT = 25 * 60 # Мягкий лимит - 25 минут
# Integration credentials are encrypted at rest. Set an explicit Fernet key in
# production; when omitted, a stable key is derived from DJANGO_SECRET_KEY.
COMMERCE_CREDENTIAL_KEY = os.getenv("COMMERCE_CREDENTIAL_KEY", "")
-COMMERCE_QUEUED_STALE_SECONDS = int(
- os.getenv("COMMERCE_QUEUED_STALE_SECONDS", "120")
-)
-COMMERCE_RUNNING_STALE_SECONDS = int(
- os.getenv("COMMERCE_RUNNING_STALE_SECONDS", "600")
-)
+COMMERCE_QUEUED_STALE_SECONDS = int(os.getenv("COMMERCE_QUEUED_STALE_SECONDS", "120"))
+COMMERCE_RUNNING_STALE_SECONDS = int(os.getenv("COMMERCE_RUNNING_STALE_SECONDS", "600"))
# Commerce imports walk large remote datasets and must not inherit the short
# global limits used by routine background tasks.
COMMERCE_TASK_SOFT_TIME_LIMIT = int(
os.getenv("COMMERCE_TASK_SOFT_TIME_LIMIT", str(90 * 60))
)
-COMMERCE_TASK_TIME_LIMIT = int(
- os.getenv("COMMERCE_TASK_TIME_LIMIT", str(120 * 60))
-)
+COMMERCE_TASK_TIME_LIMIT = int(os.getenv("COMMERCE_TASK_TIME_LIMIT", str(120 * 60)))
if COMMERCE_TASK_SOFT_TIME_LIMIT >= COMMERCE_TASK_TIME_LIMIT:
raise ValueError(
- "COMMERCE_TASK_SOFT_TIME_LIMIT must be lower than "
- "COMMERCE_TASK_TIME_LIMIT."
+ "COMMERCE_TASK_SOFT_TIME_LIMIT must be lower than COMMERCE_TASK_TIME_LIMIT."
)
MOYSKLAD_API_BASE_URL = os.getenv(
"MOYSKLAD_API_BASE_URL", "https://api.moysklad.ru/api/remap/1.2"
diff --git a/backend/eusrr_backend/settings_test.py b/backend/eusrr_backend/settings_test.py
index 3ff7ffac..8d3dea8d 100644
--- a/backend/eusrr_backend/settings_test.py
+++ b/backend/eusrr_backend/settings_test.py
@@ -12,11 +12,7 @@ os.environ.setdefault("LDAP_ENABLED", "false")
from .settings import * # noqa
# Используем InMemoryChannelLayer для тестов
-CHANNEL_LAYERS = {
- "default": {
- "BACKEND": "channels.layers.InMemoryChannelLayer"
- }
-}
+CHANNEL_LAYERS = {"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}}
CACHES = {
"default": {
@@ -43,5 +39,5 @@ REST_FRAMEWORK = {
# Celery - синхронное выполнение в тестах (без отдельного worker)
CELERY_TASK_ALWAYS_EAGER = True
CELERY_TASK_EAGER_PROPAGATES = True
-CELERY_BROKER_URL = 'memory://'
-CELERY_RESULT_BACKEND = 'cache+memory://'
+CELERY_BROKER_URL = "memory://"
+CELERY_RESULT_BACKEND = "cache+memory://"
diff --git a/backend/eusrr_backend/urls.py b/backend/eusrr_backend/urls.py
index 76bfeadf..dd9b1951 100644
--- a/backend/eusrr_backend/urls.py
+++ b/backend/eusrr_backend/urls.py
@@ -15,6 +15,4 @@ urlpatterns = [
# Публичные медиа файлы (без проверки прав)
# только в режиме разработки
if settings.DEBUG:
- urlpatterns += static(
- settings.MEDIA_URL, document_root=settings.MEDIA_ROOT
- )
+ urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
diff --git a/backend/eusrr_backend/wsgi.py b/backend/eusrr_backend/wsgi.py
index 2c5bfa36..6e3e3e59 100644
--- a/backend/eusrr_backend/wsgi.py
+++ b/backend/eusrr_backend/wsgi.py
@@ -11,6 +11,6 @@ import os
from django.core.wsgi import get_wsgi_application
-os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eusrr_backend.settings')
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "eusrr_backend.settings")
application = get_wsgi_application()
diff --git a/backend/feed/admin.py b/backend/feed/admin.py
index 0e825a7f..9829d9ff 100644
--- a/backend/feed/admin.py
+++ b/backend/feed/admin.py
@@ -1,4 +1,5 @@
from django.contrib import admin
+
from .models import Post
diff --git a/backend/feed/apps.py b/backend/feed/apps.py
index 1bf70b6f..ebff07de 100644
--- a/backend/feed/apps.py
+++ b/backend/feed/apps.py
@@ -2,8 +2,8 @@ from django.apps import AppConfig
class FeedConfig(AppConfig):
- default_auto_field = 'django.db.models.BigAutoField'
- name = 'feed'
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "feed"
def ready(self):
# Подключаем модуль уведомлений (signals регистрируются автоматически)
diff --git a/backend/feed/migrations/0001_initial.py b/backend/feed/migrations/0001_initial.py
index 12864785..3210e003 100644
--- a/backend/feed/migrations/0001_initial.py
+++ b/backend/feed/migrations/0001_initial.py
@@ -2,130 +2,345 @@
import django.core.validators
import django.db.models.deletion
-import feed.models
from django.conf import settings
from django.db import migrations, models
+import feed.models
+
class Migration(migrations.Migration):
-
initial = True
dependencies = [
- ('employees', '0019_delete_education'),
+ ("employees", "0019_delete_education"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
- name='Post',
+ name="Post",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('title', models.CharField(max_length=200, verbose_name='Заголовок')),
- ('body', models.TextField(verbose_name='Текст')),
- ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
- ('pinned', models.BooleanField(default=False, verbose_name='Закреплено')),
- ('type', models.CharField(choices=[('company', 'Новость компании'), ('department', 'Новость отдела'), ('employee', 'Публикация сотрудника')], max_length=20, verbose_name='Тип публикации')),
- ('image', models.ImageField(blank=True, null=True, upload_to='feed/images/%Y/%m/', verbose_name='Изображение')),
- ('attachment', models.FileField(blank=True, null=True, upload_to='feed/attachments/%Y/%m/', verbose_name='Вложение')),
- ('likes_count', models.PositiveIntegerField(db_index=True, default=0, verbose_name='Лайки')),
- ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='posts', to=settings.AUTH_USER_MODEL, verbose_name='Автор')),
- ('department', models.ForeignKey(blank=True, help_text='Для новостей отдела укажите отдел. Для других типов оставьте пустым.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='posts', to='employees.department', verbose_name='Отдел')),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("title", models.CharField(max_length=200, verbose_name="Заголовок")),
+ ("body", models.TextField(verbose_name="Текст")),
+ (
+ "created_at",
+ models.DateTimeField(auto_now_add=True, verbose_name="Создано"),
+ ),
+ (
+ "pinned",
+ models.BooleanField(default=False, verbose_name="Закреплено"),
+ ),
+ (
+ "type",
+ models.CharField(
+ choices=[
+ ("company", "Новость компании"),
+ ("department", "Новость отдела"),
+ ("employee", "Публикация сотрудника"),
+ ],
+ max_length=20,
+ verbose_name="Тип публикации",
+ ),
+ ),
+ (
+ "image",
+ models.ImageField(
+ blank=True,
+ null=True,
+ upload_to="feed/images/%Y/%m/",
+ verbose_name="Изображение",
+ ),
+ ),
+ (
+ "attachment",
+ models.FileField(
+ blank=True,
+ null=True,
+ upload_to="feed/attachments/%Y/%m/",
+ verbose_name="Вложение",
+ ),
+ ),
+ (
+ "likes_count",
+ models.PositiveIntegerField(
+ db_index=True, default=0, verbose_name="Лайки"
+ ),
+ ),
+ (
+ "author",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="posts",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Автор",
+ ),
+ ),
+ (
+ "department",
+ models.ForeignKey(
+ blank=True,
+ help_text="Для новостей отдела укажите отдел. Для других типов оставьте пустым.",
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="posts",
+ to="employees.department",
+ verbose_name="Отдел",
+ ),
+ ),
],
options={
- 'verbose_name': 'Публикация',
- 'verbose_name_plural': 'Публикации',
- 'ordering': ['-pinned', '-created_at'],
+ "verbose_name": "Публикация",
+ "verbose_name_plural": "Публикации",
+ "ordering": ["-pinned", "-created_at"],
},
),
migrations.CreateModel(
- name='Comment',
+ name="Comment",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('text', models.TextField(blank=True, default='', verbose_name='Комментарий')),
- ('image', models.ImageField(blank=True, null=True, upload_to='feed/comments/images/%Y/%m/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['jpg', 'jpeg', 'png', 'gif', 'webp']), feed.models.FileSizeValidator(10)], verbose_name='Изображение')),
- ('attachment', models.FileField(blank=True, null=True, upload_to='feed/comments/attachments/%Y/%m/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['pdf', 'doc', 'docx', 'xls', 'xlsx', 'txt', 'csv', 'png', 'jpg', 'jpeg', 'gif', 'zip', 'rar', '7z']), feed.models.FileSizeValidator(25)], verbose_name='Вложение')),
- ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
- ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to=settings.AUTH_USER_MODEL, verbose_name='Автор')),
- ('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='feed.post', verbose_name='Публикация')),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "text",
+ models.TextField(
+ blank=True, default="", verbose_name="Комментарий"
+ ),
+ ),
+ (
+ "image",
+ models.ImageField(
+ blank=True,
+ null=True,
+ upload_to="feed/comments/images/%Y/%m/",
+ validators=[
+ django.core.validators.FileExtensionValidator(
+ allowed_extensions=["jpg", "jpeg", "png", "gif", "webp"]
+ ),
+ feed.models.FileSizeValidator(10),
+ ],
+ verbose_name="Изображение",
+ ),
+ ),
+ (
+ "attachment",
+ models.FileField(
+ blank=True,
+ null=True,
+ upload_to="feed/comments/attachments/%Y/%m/",
+ validators=[
+ django.core.validators.FileExtensionValidator(
+ allowed_extensions=[
+ "pdf",
+ "doc",
+ "docx",
+ "xls",
+ "xlsx",
+ "txt",
+ "csv",
+ "png",
+ "jpg",
+ "jpeg",
+ "gif",
+ "zip",
+ "rar",
+ "7z",
+ ]
+ ),
+ feed.models.FileSizeValidator(25),
+ ],
+ verbose_name="Вложение",
+ ),
+ ),
+ (
+ "created_at",
+ models.DateTimeField(auto_now_add=True, verbose_name="Создано"),
+ ),
+ (
+ "author",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="comments",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Автор",
+ ),
+ ),
+ (
+ "post",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="comments",
+ to="feed.post",
+ verbose_name="Публикация",
+ ),
+ ),
],
options={
- 'verbose_name': 'Комментарий',
- 'verbose_name_plural': 'Комментарии',
- 'ordering': ['created_at'],
+ "verbose_name": "Комментарий",
+ "verbose_name_plural": "Комментарии",
+ "ordering": ["created_at"],
},
),
migrations.CreateModel(
- name='PostLike',
+ name="PostLike",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('created_at', models.DateTimeField(auto_now_add=True)),
- ('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='likes', to='feed.post', verbose_name='Пост')),
- ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='post_likes', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("created_at", models.DateTimeField(auto_now_add=True)),
+ (
+ "post",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="likes",
+ to="feed.post",
+ verbose_name="Пост",
+ ),
+ ),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="post_likes",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Пользователь",
+ ),
+ ),
],
options={
- 'verbose_name': 'Лайк',
- 'verbose_name_plural': 'Лайки',
+ "verbose_name": "Лайк",
+ "verbose_name_plural": "Лайки",
},
),
migrations.AddIndex(
- model_name='post',
- index=models.Index(fields=['type', 'created_at'], name='feed_post_type_b00891_idx'),
+ model_name="post",
+ index=models.Index(
+ fields=["type", "created_at"], name="feed_post_type_b00891_idx"
+ ),
),
migrations.AddIndex(
- model_name='post',
- index=models.Index(fields=['pinned', 'created_at'], name='feed_post_pinned_e86c9a_idx'),
+ model_name="post",
+ index=models.Index(
+ fields=["pinned", "created_at"], name="feed_post_pinned_e86c9a_idx"
+ ),
),
migrations.AddIndex(
- model_name='post',
- index=models.Index(fields=['created_at'], name='feed_post_created_ddd8c4_idx'),
+ model_name="post",
+ index=models.Index(
+ fields=["created_at"], name="feed_post_created_ddd8c4_idx"
+ ),
),
migrations.AddIndex(
- model_name='post',
- index=models.Index(fields=['department', '-created_at'], name='feed_post_departm_b35669_idx'),
+ model_name="post",
+ index=models.Index(
+ fields=["department", "-created_at"],
+ name="feed_post_departm_b35669_idx",
+ ),
),
migrations.AddIndex(
- model_name='post',
- index=models.Index(fields=['author', '-created_at'], name='feed_post_author__c854fa_idx'),
+ model_name="post",
+ index=models.Index(
+ fields=["author", "-created_at"], name="feed_post_author__c854fa_idx"
+ ),
),
migrations.AddConstraint(
- model_name='post',
- constraint=models.CheckConstraint(condition=models.Q(models.Q(('department__isnull', False), ('type', 'department')), models.Q(('type', 'department'), _negated=True), _connector='OR'), name='post_department_required_when_type_is_department'),
+ model_name="post",
+ constraint=models.CheckConstraint(
+ condition=models.Q(
+ models.Q(("department__isnull", False), ("type", "department")),
+ models.Q(("type", "department"), _negated=True),
+ _connector="OR",
+ ),
+ name="post_department_required_when_type_is_department",
+ ),
),
migrations.AddConstraint(
- model_name='post',
- constraint=models.CheckConstraint(condition=models.Q(('type', 'department'), ('department__isnull', True), _connector='OR'), name='post_department_null_when_type_not_department'),
+ model_name="post",
+ constraint=models.CheckConstraint(
+ condition=models.Q(
+ ("type", "department"),
+ ("department__isnull", True),
+ _connector="OR",
+ ),
+ name="post_department_null_when_type_not_department",
+ ),
),
migrations.AddConstraint(
- model_name='post',
- constraint=models.CheckConstraint(condition=models.Q(('likes_count__gte', 0)), name='post_likes_count_non_negative'),
+ model_name="post",
+ constraint=models.CheckConstraint(
+ condition=models.Q(("likes_count__gte", 0)),
+ name="post_likes_count_non_negative",
+ ),
),
migrations.AddIndex(
- model_name='comment',
- index=models.Index(fields=['created_at'], name='feed_commen_created_a85775_idx'),
+ model_name="comment",
+ index=models.Index(
+ fields=["created_at"], name="feed_commen_created_a85775_idx"
+ ),
),
migrations.AddIndex(
- model_name='comment',
- index=models.Index(fields=['post', 'created_at'], name='feed_commen_post_id_3fe3f3_idx'),
+ model_name="comment",
+ index=models.Index(
+ fields=["post", "created_at"], name="feed_commen_post_id_3fe3f3_idx"
+ ),
),
migrations.AddIndex(
- model_name='comment',
- index=models.Index(fields=['author', 'created_at'], name='feed_commen_author__b1fbf5_idx'),
+ model_name="comment",
+ index=models.Index(
+ fields=["author", "created_at"], name="feed_commen_author__b1fbf5_idx"
+ ),
),
migrations.AddConstraint(
- model_name='comment',
- constraint=models.CheckConstraint(condition=models.Q(models.Q(('text__isnull', False), models.Q(('text', ''), _negated=True)), ('image__isnull', False), ('attachment__isnull', False), _connector='OR'), name='comment_has_any_content_v3', violation_error_message='Нужно указать текст, изображение или файл.'),
+ model_name="comment",
+ constraint=models.CheckConstraint(
+ condition=models.Q(
+ models.Q(
+ ("text__isnull", False), models.Q(("text", ""), _negated=True)
+ ),
+ ("image__isnull", False),
+ ("attachment__isnull", False),
+ _connector="OR",
+ ),
+ name="comment_has_any_content_v3",
+ violation_error_message="Нужно указать текст, изображение или файл.",
+ ),
),
migrations.AddIndex(
- model_name='postlike',
- index=models.Index(fields=['user', 'created_at'], name='feed_postli_user_id_15e79d_idx'),
+ model_name="postlike",
+ index=models.Index(
+ fields=["user", "created_at"], name="feed_postli_user_id_15e79d_idx"
+ ),
),
migrations.AddIndex(
- model_name='postlike',
- index=models.Index(fields=['post', 'created_at'], name='feed_postli_post_id_2897d2_idx'),
+ model_name="postlike",
+ index=models.Index(
+ fields=["post", "created_at"], name="feed_postli_post_id_2897d2_idx"
+ ),
),
migrations.AddConstraint(
- model_name='postlike',
- constraint=models.UniqueConstraint(fields=('post', 'user'), name='uq_postlike_post_user'),
+ model_name="postlike",
+ constraint=models.UniqueConstraint(
+ fields=("post", "user"), name="uq_postlike_post_user"
+ ),
),
]
diff --git a/backend/feed/migrations/0002_alter_post_options.py b/backend/feed/migrations/0002_alter_post_options.py
index 323e839a..0613b647 100644
--- a/backend/feed/migrations/0002_alter_post_options.py
+++ b/backend/feed/migrations/0002_alter_post_options.py
@@ -4,14 +4,21 @@ from django.db import migrations
class Migration(migrations.Migration):
-
dependencies = [
- ('feed', '0001_initial'),
+ ("feed", "0001_initial"),
]
operations = [
migrations.AlterModelOptions(
- name='post',
- options={'ordering': ['-pinned', '-created_at'], 'permissions': [('publish_company_post', 'Can publish on main page'), ('publish_department_post', 'Can publish in department')], 'verbose_name': 'Публикация', 'verbose_name_plural': 'Публикации'},
+ name="post",
+ options={
+ "ordering": ["-pinned", "-created_at"],
+ "permissions": [
+ ("publish_company_post", "Can publish on main page"),
+ ("publish_department_post", "Can publish in department"),
+ ],
+ "verbose_name": "Публикация",
+ "verbose_name_plural": "Публикации",
+ },
),
]
diff --git a/backend/feed/migrations/0003_alter_post_options.py b/backend/feed/migrations/0003_alter_post_options.py
index 1ffeb370..a24fe223 100644
--- a/backend/feed/migrations/0003_alter_post_options.py
+++ b/backend/feed/migrations/0003_alter_post_options.py
@@ -4,14 +4,21 @@ from django.db import migrations
class Migration(migrations.Migration):
-
dependencies = [
- ('feed', '0002_alter_post_options'),
+ ("feed", "0002_alter_post_options"),
]
operations = [
migrations.AlterModelOptions(
- name='post',
- options={'ordering': ['-pinned', '-created_at'], 'permissions': [('publish_company_post', 'Может публиковать новости на главной'), ('publish_department_post', 'Может публиковать новости в отделах')], 'verbose_name': 'Публикация', 'verbose_name_plural': 'Публикации'},
+ name="post",
+ options={
+ "ordering": ["-pinned", "-created_at"],
+ "permissions": [
+ ("publish_company_post", "Может публиковать новости на главной"),
+ ("publish_department_post", "Может публиковать новости в отделах"),
+ ],
+ "verbose_name": "Публикация",
+ "verbose_name_plural": "Публикации",
+ },
),
]
diff --git a/backend/feed/migrations/0004_alter_post_options.py b/backend/feed/migrations/0004_alter_post_options.py
index 06fbdf35..e097ee49 100644
--- a/backend/feed/migrations/0004_alter_post_options.py
+++ b/backend/feed/migrations/0004_alter_post_options.py
@@ -4,14 +4,17 @@ from django.db import migrations
class Migration(migrations.Migration):
-
dependencies = [
- ('feed', '0003_alter_post_options'),
+ ("feed", "0003_alter_post_options"),
]
operations = [
migrations.AlterModelOptions(
- name='post',
- options={'ordering': ['-pinned', '-created_at'], 'verbose_name': 'Публикация', 'verbose_name_plural': 'Публикации'},
+ name="post",
+ options={
+ "ordering": ["-pinned", "-created_at"],
+ "verbose_name": "Публикация",
+ "verbose_name_plural": "Публикации",
+ },
),
]
diff --git a/backend/feed/migrations/0005_alter_post_department.py b/backend/feed/migrations/0005_alter_post_department.py
index fe7e7a7f..dac4d209 100644
--- a/backend/feed/migrations/0005_alter_post_department.py
+++ b/backend/feed/migrations/0005_alter_post_department.py
@@ -5,16 +5,23 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0029_ldapsyncstate'),
- ('feed', '0004_alter_post_options'),
+ ("employees", "0029_ldapsyncstate"),
+ ("feed", "0004_alter_post_options"),
]
operations = [
migrations.AlterField(
- model_name='post',
- name='department',
- field=models.ForeignKey(blank=True, help_text='Для новостей отдела укажите отдел. Для других типов оставьте пустым.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='posts', to='employees.department', verbose_name='Отдел'),
+ model_name="post",
+ name="department",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="Для новостей отдела укажите отдел. Для других типов оставьте пустым.",
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="posts",
+ to="employees.department",
+ verbose_name="Отдел",
+ ),
),
]
diff --git a/backend/feed/migrations/0006_migrate_comments_to_communications.py b/backend/feed/migrations/0006_migrate_comments_to_communications.py
index c2920b53..eba56f75 100644
--- a/backend/feed/migrations/0006_migrate_comments_to_communications.py
+++ b/backend/feed/migrations/0006_migrate_comments_to_communications.py
@@ -7,69 +7,76 @@ from django.utils import timezone
def migrate_comments_forward(apps, schema_editor):
"""Миграция feed.Comment → Communications.Message (ORM)"""
- Chat = apps.get_model('communications', 'Chat')
- Message = apps.get_model('communications', 'Message')
- MessageAttachment = apps.get_model('communications', 'MessageAttachment')
- Comment = apps.get_model('feed', 'Comment')
- Post = apps.get_model('feed', 'Post')
- ContentType = apps.get_model('contenttypes', 'ContentType')
+ Chat = apps.get_model("communications", "Chat")
+ Message = apps.get_model("communications", "Message")
+ MessageAttachment = apps.get_model("communications", "MessageAttachment")
+ Comment = apps.get_model("feed", "Comment")
+ Post = apps.get_model("feed", "Post")
+ ContentType = apps.get_model("contenttypes", "ContentType")
try:
- post_ct = ContentType.objects.get(app_label='feed', model='post')
+ post_ct = ContentType.objects.get(app_label="feed", model="post")
except ContentType.DoesNotExist:
return # Если ContentType не найден, пропускаем миграцию
# 1. Создаем чаты для постов с комментариями
- post_ids_with_comments = Comment.objects.values_list('post_id', flat=True).distinct()
+ post_ids_with_comments = Comment.objects.values_list(
+ "post_id", flat=True
+ ).distinct()
for post_id in post_ids_with_comments:
# Проверяем, не создан ли уже чат
if Chat.objects.filter(
- type='comments',
- context_content_type=post_ct,
- context_object_id=post_id
+ type="comments", context_content_type=post_ct, context_object_id=post_id
).exists():
continue
try:
post = Post.objects.get(id=post_id)
- chat_name = f"Комментарии: {post.title[:50]}" if hasattr(post, 'title') and post.title else "Комментарии"
+ chat_name = (
+ f"Комментарии: {post.title[:50]}"
+ if hasattr(post, "title") and post.title
+ else "Комментарии"
+ )
except Post.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=post_ct,
context_object_id=post_id,
created_at=timezone.now(),
is_main=False,
is_blocked=False,
include_all_users=False,
- extra_data={}
+ extra_data={},
)
# 2. Переносим комментарии
- for comment in Comment.objects.select_related('author', 'post').all():
+ for comment in Comment.objects.select_related("author", "post").all():
try:
chat = Chat.objects.get(
- type='comments',
+ type="comments",
context_content_type=post_ct,
- context_object_id=comment.post_id
+ context_object_id=comment.post_id,
)
except Chat.DoesNotExist:
continue
# Проверяем, не перенесен ли уже комментарий
if Message.objects.filter(
- chat=chat,
- author=comment.author,
- created_at=comment.created_at
+ chat=chat, author=comment.author, created_at=comment.created_at
).exists():
continue
@@ -81,7 +88,7 @@ def migrate_comments_forward(apps, schema_editor):
message = Message.objects.create(
chat=chat,
author=comment.author,
- content=comment.text or '',
+ content=comment.text or "",
created_at=comment.created_at,
is_system=False,
has_attachments=has_attachments,
@@ -90,63 +97,67 @@ def migrate_comments_forward(apps, schema_editor):
is_pinned=False,
is_forwarded=False,
is_cross_chat=False,
- system_type='',
+ system_type="",
system_metadata={
- 'legacy_comment_id': comment.id,
- 'legacy_model': 'feed.Comment'
- }
+ "legacy_comment_id": comment.id,
+ "legacy_model": "feed.Comment",
+ },
)
# 3. Переносим вложения
if has_image:
- file_name = str(comment.image).split('/')[-1] if '/' in str(comment.image) else str(comment.image)
+ file_name = (
+ str(comment.image).split("/")[-1]
+ if "/" in str(comment.image)
+ else str(comment.image)
+ )
MessageAttachment.objects.create(
message=message,
file=comment.image,
- file_type='image',
+ file_type="image",
file_name=file_name,
file_size=0,
- uploaded_at=comment.created_at
+ uploaded_at=comment.created_at,
)
if has_attachment:
- file_name = str(comment.attachment).split('/')[-1] if '/' in str(comment.attachment) else str(comment.attachment)
+ file_name = (
+ str(comment.attachment).split("/")[-1]
+ if "/" in str(comment.attachment)
+ else str(comment.attachment)
+ )
MessageAttachment.objects.create(
message=message,
file=comment.attachment,
- file_type='file',
+ file_type="file",
file_name=file_name,
file_size=0,
- uploaded_at=comment.created_at
+ uploaded_at=comment.created_at,
)
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:
- post_ct = ContentType.objects.get(app_label='feed', model='post')
- Chat.objects.filter(
- type='comments',
- context_content_type=post_ct
- ).delete()
+ post_ct = ContentType.objects.get(app_label="feed", model="post")
+ Chat.objects.filter(type="comments", context_content_type=post_ct).delete()
except ContentType.DoesNotExist:
pass
class Migration(migrations.Migration):
-
dependencies = [
- ('feed', '0005_alter_post_department'),
- ('communications', '0037_remove_department_type'), # Зафиксировали конкретную версию
- ('contenttypes', '__latest__'),
+ ("feed", "0005_alter_post_department"),
+ (
+ "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/feed/migrations/0007_delete_comment.py b/backend/feed/migrations/0007_delete_comment.py
index 95942d95..3b048ddb 100644
--- a/backend/feed/migrations/0007_delete_comment.py
+++ b/backend/feed/migrations/0007_delete_comment.py
@@ -4,13 +4,12 @@ from django.db import migrations
class Migration(migrations.Migration):
-
dependencies = [
- ('feed', '0006_migrate_comments_to_communications'),
+ ("feed", "0006_migrate_comments_to_communications"),
]
operations = [
migrations.DeleteModel(
- name='Comment',
+ name="Comment",
),
]
diff --git a/backend/feed/migrations/0008_alter_post_body.py b/backend/feed/migrations/0008_alter_post_body.py
index ee5187ee..5fa0495e 100644
--- a/backend/feed/migrations/0008_alter_post_body.py
+++ b/backend/feed/migrations/0008_alter_post_body.py
@@ -4,15 +4,14 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('feed', '0007_delete_comment'),
+ ("feed", "0007_delete_comment"),
]
operations = [
migrations.AlterField(
- model_name='post',
- name='body',
- field=models.TextField(blank=True, verbose_name='Текст'),
+ model_name="post",
+ name="body",
+ field=models.TextField(blank=True, verbose_name="Текст"),
),
]
diff --git a/backend/feed/migrations/0009_split_post_pins_by_scope.py b/backend/feed/migrations/0009_split_post_pins_by_scope.py
index f6457f0a..87cb8e3b 100644
--- a/backend/feed/migrations/0009_split_post_pins_by_scope.py
+++ b/backend/feed/migrations/0009_split_post_pins_by_scope.py
@@ -7,7 +7,6 @@ def copy_legacy_pin_to_global(apps, schema_editor):
class Migration(migrations.Migration):
-
dependencies = [
("feed", "0008_alter_post_body"),
]
diff --git a/backend/feed/migrations/0010_alter_post_pinned.py b/backend/feed/migrations/0010_alter_post_pinned.py
index aa841486..d30d25c5 100644
--- a/backend/feed/migrations/0010_alter_post_pinned.py
+++ b/backend/feed/migrations/0010_alter_post_pinned.py
@@ -4,15 +4,16 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('feed', '0009_split_post_pins_by_scope'),
+ ("feed", "0009_split_post_pins_by_scope"),
]
operations = [
migrations.AlterField(
- model_name='post',
- name='pinned',
- field=models.BooleanField(default=False, editable=False, verbose_name='Закреплено (legacy)'),
+ model_name="post",
+ name="pinned",
+ field=models.BooleanField(
+ default=False, editable=False, verbose_name="Закреплено (legacy)"
+ ),
),
]
diff --git a/backend/feed/models.py b/backend/feed/models.py
index 7588569b..3e57093c 100644
--- a/backend/feed/models.py
+++ b/backend/feed/models.py
@@ -47,9 +47,7 @@ class FileSizeValidator:
return None
def __eq__(self, other):
- return (
- isinstance(other, FileSizeValidator) and self.max_mb == other.max_mb
- )
+ return isinstance(other, FileSizeValidator) and self.max_mb == other.max_mb
class Post(models.Model):
@@ -67,8 +65,7 @@ class Post(models.Model):
related_name="posts",
verbose_name="Отдел",
help_text=(
- "Для новостей отдела укажите отдел. "
- "Для других типов оставьте пустым."
+ "Для новостей отдела укажите отдел. Для других типов оставьте пустым."
),
)
title = models.CharField("Заголовок", max_length=200)
@@ -87,9 +84,7 @@ class Post(models.Model):
"Закреплено в ленте отдела",
default=False,
)
- type = models.CharField(
- "Тип публикации", max_length=20, choices=TYPE_CHOICES
- )
+ type = models.CharField("Тип публикации", max_length=20, choices=TYPE_CHOICES)
image = models.ImageField(
"Изображение",
upload_to="feed/images/%Y/%m/",
@@ -171,13 +166,9 @@ class Post(models.Model):
old_attach = self.attachment
super().delete(*args, **kwargs)
if old_image:
- transaction.on_commit(
- lambda: old_image.storage.delete(old_image.name)
- )
+ transaction.on_commit(lambda: old_image.storage.delete(old_image.name))
if old_attach:
- transaction.on_commit(
- lambda: old_attach.storage.delete(old_attach.name)
- )
+ transaction.on_commit(lambda: old_attach.storage.delete(old_attach.name))
def save(self, *args, **kwargs):
skip_validation = kwargs.pop("skip_validation", False)
@@ -203,9 +194,7 @@ class Post(models.Model):
res = super().save(*args, **kwargs)
if old_image_name:
- transaction.on_commit(
- lambda: self.image.storage.delete(old_image_name)
- )
+ transaction.on_commit(lambda: self.image.storage.delete(old_image_name))
if old_attach_name:
transaction.on_commit(
lambda: self.attachment.storage.delete(old_attach_name)
diff --git a/backend/feed/notifications/__init__.py b/backend/feed/notifications/__init__.py
index 49ba99a7..2e63e073 100644
--- a/backend/feed/notifications/__init__.py
+++ b/backend/feed/notifications/__init__.py
@@ -15,12 +15,13 @@
notify_post_reaction(post, request.user)
"""
-from .config import NotificationVerbs, MessageTemplates, ActionURLs
-from .handlers import notify_post_reaction
-
# Импортируем handlers и signals для регистрации
-from . import handlers # noqa: F401
-from . import signals # noqa: F401
+from . import (
+ handlers, # noqa: F401
+ signals, # noqa: F401
+)
+from .config import ActionURLs, MessageTemplates, NotificationVerbs
+from .handlers import notify_post_reaction
__all__ = [
"NotificationVerbs",
diff --git a/backend/feed/notifications/config.py b/backend/feed/notifications/config.py
index 9c44b071..b8d5b3d8 100644
--- a/backend/feed/notifications/config.py
+++ b/backend/feed/notifications/config.py
@@ -11,9 +11,9 @@
class NotificationVerbs:
"""Глаголы (verbs) для уведомлений feed."""
- NEW_POST = 'feed_new_post'
- POST_COMMENT = 'feed_post_comment'
- POST_REACTION = 'feed_post_reaction'
+ NEW_POST = "feed_new_post"
+ POST_COMMENT = "feed_post_comment"
+ POST_REACTION = "feed_post_reaction"
class MessageTemplates:
@@ -22,27 +22,27 @@ class MessageTemplates:
@staticmethod
def new_post_company() -> str:
"""Шаблон для новости компании."""
- return 'Новая публикация компании'
+ return "Новая публикация компании"
@staticmethod
def new_post_department(department_name: str) -> str:
"""Шаблон для новости отдела."""
- return f'Новая публикация отдела {department_name}'
+ return f"Новая публикация отдела {department_name}"
@staticmethod
def new_post_employee(author_name: str) -> str:
"""Шаблон для личной публикации."""
- return f'Новая публикация от {author_name}'
+ return f"Новая публикация от {author_name}"
@staticmethod
def comment(author_name: str, comment_text: str) -> str:
"""Шаблон для комментария к публикации."""
- return f'{author_name} прокомментировал: {comment_text}'
+ return f"{author_name} прокомментировал: {comment_text}"
@staticmethod
def comment_title() -> str:
"""Заголовок для уведомления о комментарии."""
- return 'Новый комментарий к вашей публикации'
+ return "Новый комментарий к вашей публикации"
@staticmethod
def reaction(user_name: str, post_title: str) -> str:
@@ -52,7 +52,7 @@ class MessageTemplates:
@staticmethod
def reaction_title() -> str:
"""Заголовок для уведомления о реакции."""
- return 'Ваша публикация понравилась'
+ return "Ваша публикация понравилась"
class ActionURLs:
@@ -60,7 +60,7 @@ class ActionURLs:
# Пока все действия ведут на главную страницу
# TODO: реализовать прямые ссылки на публикации
- FEED_HOME = '/'
+ FEED_HOME = "/"
@staticmethod
def post_detail(post_id: int) -> str:
@@ -69,7 +69,7 @@ class ActionURLs:
TODO: реализовать отдельную страницу публикации
"""
- return f'/?post={post_id}' # Временное решение
+ return f"/?post={post_id}" # Временное решение
def truncate_text(text: str, max_length: int = 100) -> str:
@@ -84,10 +84,10 @@ def truncate_text(text: str, max_length: int = 100) -> str:
Обрезанный текст с многоточием если нужно
"""
if not text:
- return ''
+ return ""
text = text.strip()
if len(text) <= max_length:
return text
- return text[:max_length - 3] + '...'
+ return text[: max_length - 3] + "..."
diff --git a/backend/feed/notifications/handlers.py b/backend/feed/notifications/handlers.py
index f1cbf2c3..eb42176a 100644
--- a/backend/feed/notifications/handlers.py
+++ b/backend/feed/notifications/handlers.py
@@ -8,13 +8,14 @@
"""
from django.contrib.auth import get_user_model
+
from notifications.signals import notify
from ..constants import TYPE_COMPANY, TYPE_DEPARTMENT, TYPE_EMPLOYEE
from .config import (
- NotificationVerbs,
- MessageTemplates,
ActionURLs,
+ MessageTemplates,
+ NotificationVerbs,
)
Employee = get_user_model()
@@ -77,15 +78,13 @@ def notify_new_post(post):
description=post.title,
action_url=ActionURLs.FEED_HOME,
data={
- 'title': title,
- 'post_id': post.id,
- 'post_type': post.type,
- 'post_title': post.title,
- 'author_id': post.author.id,
- 'department_id': (
- post.department.id if post.department else None
- ),
- }
+ "title": title,
+ "post_id": post.id,
+ "post_type": post.type,
+ "post_title": post.title,
+ "author_id": post.author.id,
+ "department_id": (post.department.id if post.department else None),
+ },
)
@@ -113,12 +112,12 @@ def notify_post_reaction(post, user):
description=MessageTemplates.reaction(user_name, post.title),
action_url=ActionURLs.FEED_HOME,
data={
- 'title': MessageTemplates.reaction_title(),
- 'post_id': post.id,
- 'post_title': post.title,
- 'user_id': user.id,
- 'reaction_type': 'like',
- }
+ "title": MessageTemplates.reaction_title(),
+ "post_id": post.id,
+ "post_title": post.title,
+ "user_id": user.id,
+ "reaction_type": "like",
+ },
)
diff --git a/backend/feed/rules.py b/backend/feed/rules.py
index 3a8217fc..c6c5fea5 100644
--- a/backend/feed/rules.py
+++ b/backend/feed/rules.py
@@ -9,7 +9,6 @@ import rules
from employees.positions import employee_position_search_text
-
# -----------------------------------------------------------------------------
# ПРЕДИКАТЫ (predicates)
# -----------------------------------------------------------------------------
@@ -130,14 +129,10 @@ rules.add_rule(
rules.add_rule("feed.create_post", is_superuser | can_publish_posts)
# Изменение поста
-rules.add_rule(
- "feed.change_post", is_superuser | is_post_author | can_moderate_posts
-)
+rules.add_rule("feed.change_post", is_superuser | is_post_author | can_moderate_posts)
# Удаление поста
-rules.add_rule(
- "feed.delete_post", is_superuser | is_post_author | can_moderate_posts
-)
+rules.add_rule("feed.delete_post", is_superuser | is_post_author | can_moderate_posts)
# Публикация поста (изменение статуса draft → published)
rules.add_rule(
diff --git a/backend/finance/admin.py b/backend/finance/admin.py
index 5f8b5aee..5dbac5d0 100644
--- a/backend/finance/admin.py
+++ b/backend/finance/admin.py
@@ -20,8 +20,8 @@ from .models import (
PayrollStatement,
PayrollStatementAcknowledgement,
PayrollStatementLine,
- PayrollWorkSettings,
PayrollWorkRecord,
+ PayrollWorkSettings,
VacationPayInstruction,
)
from .payroll.access import has_payroll_permission, has_simple_admin_access
diff --git a/backend/finance/analytics/labor.py b/backend/finance/analytics/labor.py
index 56674bf3..4b3b3627 100644
--- a/backend/finance/analytics/labor.py
+++ b/backend/finance/analytics/labor.py
@@ -9,7 +9,6 @@ from finance.enums import ApprovalStatus, PayrollPeriodStatus, PayrollRunStatus
from finance.models import EmployeePayRate, PayrollPeriod, PayrollStatement
from finance.payroll.config import base_rate_code
-
ZERO = Decimal("0")
EXACT = "exact"
PRELIMINARY = "preliminary"
@@ -24,7 +23,9 @@ class PricedLabor:
def _period_for(day, periods):
- candidates = [period for period in periods if period.date_from <= day <= period.date_to]
+ candidates = [
+ period for period in periods if period.date_from <= day <= period.date_to
+ ]
if not candidates:
return None
return min(
@@ -131,7 +132,9 @@ def price_labor_allocations(allocations, currency: str) -> dict[int, PricedLabor
"""
allocations = list(allocations)
- known = [allocation for allocation in allocations if allocation.day.hours is not None]
+ known = [
+ allocation for allocation in allocations if allocation.day.hours is not None
+ ]
if not known:
return {}
@@ -148,8 +151,7 @@ def price_labor_allocations(allocations, currency: str) -> dict[int, PricedLabor
.order_by("date_from", "date_to", "pk")
)
period_by_allocation = {
- allocation.pk: _period_for(allocation.day.date, periods)
- for allocation in known
+ allocation.pk: _period_for(allocation.day.date, periods) for allocation in known
}
used_periods = {
period.pk: period
@@ -204,9 +206,7 @@ def price_labor_allocations(allocations, currency: str) -> dict[int, PricedLabor
continue
denominator = norm_hours[(period.pk, employee_id)]
- amount = _preliminary_amount(
- rates, statement, employee_id, allocation.day.date
- )
+ amount = _preliminary_amount(rates, statement, employee_id, allocation.day.date)
if amount is None or denominator <= 0:
result[allocation.pk] = PricedLabor(hours, None, UNPRICED)
else:
diff --git a/backend/finance/analytics/people.py b/backend/finance/analytics/people.py
index b4cfd1e9..bca76424 100644
--- a/backend/finance/analytics/people.py
+++ b/backend/finance/analytics/people.py
@@ -11,9 +11,10 @@ from employees.services.personnel_state import (
calculate_employee_tenure_days,
resolve_employee_personnel_state,
)
+from finance.analytics.models import ReportingEntry
from finance.enums import PayrollRunStatus
from finance.models import PayrollRun, PayrollStatement
-from finance.analytics.models import ReportingEntry
+
from .sales import ZERO, months
diff --git a/backend/finance/analytics/projects.py b/backend/finance/analytics/projects.py
index ebfe63a6..90fb2781 100644
--- a/backend/finance/analytics/projects.py
+++ b/backend/finance/analytics/projects.py
@@ -6,8 +6,8 @@ from django.db.models import Q
from django.utils import timezone
from commerce.models import ExternalProject, ProjectPayment
-from finance.analytics.models import ReportingEntry
from finance.analytics.labor import labor_cost_status, price_labor_allocations
+from finance.analytics.models import ReportingEntry
from finance.analytics.sales import ZERO, bounds, bucket, finish, months
from projects.models import Project, ProjectExternalBinding, TimeAllocation
@@ -244,21 +244,21 @@ def add_allocations(rows, monthly, allocations, currency):
labor = priced.get(allocation.pk)
if labor is None or labor.cost is None:
row["unpriced_labor_hours"] += hours
- monthly[allocation.day.date.strftime("%Y-%m")][
- "unpriced_labor_hours"
- ] += hours
+ monthly[allocation.day.date.strftime("%Y-%m")]["unpriced_labor_hours"] += (
+ hours
+ )
else:
row["labor_cost"] += labor.cost
monthly[allocation.day.date.strftime("%Y-%m")]["labor_cost"] += labor.cost
if labor.cost_status == "exact":
row["exact_labor_cost"] += labor.cost
row["exact_labor_hours"] += hours
- monthly[allocation.day.date.strftime("%Y-%m")][
- "exact_labor_cost"
- ] += labor.cost
- monthly[allocation.day.date.strftime("%Y-%m")][
- "exact_labor_hours"
- ] += hours
+ monthly[allocation.day.date.strftime("%Y-%m")]["exact_labor_cost"] += (
+ labor.cost
+ )
+ monthly[allocation.day.date.strftime("%Y-%m")]["exact_labor_hours"] += (
+ hours
+ )
else:
row["preliminary_labor_cost"] += labor.cost
row["preliminary_labor_hours"] += hours
diff --git a/backend/finance/migrations/0001_initial.py b/backend/finance/migrations/0001_initial.py
index 7523da05..2cdbfc6b 100644
--- a/backend/finance/migrations/0001_initial.py
+++ b/backend/finance/migrations/0001_initial.py
@@ -1,9 +1,10 @@
# Generated by Django 5.2.4 on 2026-07-17 12:13
+import uuid
+from decimal import Decimal
+
import django.core.validators
import django.db.models.deletion
-import uuid
-from decimal import Decimal
from django.conf import settings
from django.db import migrations, models
diff --git a/backend/finance/migrations/0003_seed_payroll_components.py b/backend/finance/migrations/0003_seed_payroll_components.py
index da00a76b..6e76b561 100644
--- a/backend/finance/migrations/0003_seed_payroll_components.py
+++ b/backend/finance/migrations/0003_seed_payroll_components.py
@@ -1,6 +1,5 @@
from django.db import migrations
-
COMPONENTS = (
("VACATION_PAY", "Отпускные", "earning", False, 20),
("BONUS", "Премия", "earning", False, 30),
diff --git a/backend/finance/migrations/0010_payrolldailyworkentry.py b/backend/finance/migrations/0010_payrolldailyworkentry.py
index e296569b..5fa1c4f5 100644
--- a/backend/finance/migrations/0010_payrolldailyworkentry.py
+++ b/backend/finance/migrations/0010_payrolldailyworkentry.py
@@ -1,40 +1,113 @@
# Generated by Django 5.2.4 on 2026-07-20 07:46
+from decimal import Decimal
+
import django.core.validators
import django.db.models.deletion
-from decimal import Decimal
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('finance', '0009_alter_payroll_input_sources'),
+ ("finance", "0009_alter_payroll_input_sources"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
- name='PayrollDailyWorkEntry',
+ name="PayrollDailyWorkEntry",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('work_date', models.DateField(verbose_name='Дата выработки')),
- ('target_points', models.DecimalField(decimal_places=4, max_digits=19, validators=[django.core.validators.MinValueValidator(Decimal('0.0001'))], verbose_name='Норма баллов за день')),
- ('actual_points', models.DecimalField(decimal_places=4, max_digits=19, validators=[django.core.validators.MinValueValidator(Decimal('0'))], verbose_name='Фактические баллы за день')),
- ('note', models.TextField(blank=True, verbose_name='Комментарий')),
- ('lock_version', models.PositiveIntegerField(default=0, verbose_name='Версия записи')),
- ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создана')),
- ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлена')),
- ('employee', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='payroll_daily_work_entries', to=settings.AUTH_USER_MODEL, verbose_name='Сотрудник')),
- ('period', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='daily_work_entries', to='finance.payrollperiod', verbose_name='Период')),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("work_date", models.DateField(verbose_name="Дата выработки")),
+ (
+ "target_points",
+ models.DecimalField(
+ decimal_places=4,
+ max_digits=19,
+ validators=[
+ django.core.validators.MinValueValidator(Decimal("0.0001"))
+ ],
+ verbose_name="Норма баллов за день",
+ ),
+ ),
+ (
+ "actual_points",
+ models.DecimalField(
+ decimal_places=4,
+ max_digits=19,
+ validators=[
+ django.core.validators.MinValueValidator(Decimal("0"))
+ ],
+ verbose_name="Фактические баллы за день",
+ ),
+ ),
+ ("note", models.TextField(blank=True, verbose_name="Комментарий")),
+ (
+ "lock_version",
+ models.PositiveIntegerField(
+ default=0, verbose_name="Версия записи"
+ ),
+ ),
+ (
+ "created_at",
+ models.DateTimeField(auto_now_add=True, verbose_name="Создана"),
+ ),
+ (
+ "updated_at",
+ models.DateTimeField(auto_now=True, verbose_name="Обновлена"),
+ ),
+ (
+ "employee",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="payroll_daily_work_entries",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Сотрудник",
+ ),
+ ),
+ (
+ "period",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="daily_work_entries",
+ to="finance.payrollperiod",
+ verbose_name="Период",
+ ),
+ ),
],
options={
- 'verbose_name': 'Ежедневная выработка',
- 'verbose_name_plural': 'Ежедневная выработка',
- 'ordering': ['-work_date', '-id'],
- 'indexes': [models.Index(fields=['period', 'employee', 'work_date'], name='pay_daily_period_employee_idx')],
- 'constraints': [models.CheckConstraint(condition=models.Q(('target_points__gt', 0)), name='pay_daily_target_positive'), models.CheckConstraint(condition=models.Q(('actual_points__gte', 0)), name='pay_daily_actual_nonnegative'), models.UniqueConstraint(fields=('period', 'employee', 'work_date'), name='pay_daily_employee_date_unique')],
+ "verbose_name": "Ежедневная выработка",
+ "verbose_name_plural": "Ежедневная выработка",
+ "ordering": ["-work_date", "-id"],
+ "indexes": [
+ models.Index(
+ fields=["period", "employee", "work_date"],
+ name="pay_daily_period_employee_idx",
+ )
+ ],
+ "constraints": [
+ models.CheckConstraint(
+ condition=models.Q(("target_points__gt", 0)),
+ name="pay_daily_target_positive",
+ ),
+ models.CheckConstraint(
+ condition=models.Q(("actual_points__gte", 0)),
+ name="pay_daily_actual_nonnegative",
+ ),
+ models.UniqueConstraint(
+ fields=("period", "employee", "work_date"),
+ name="pay_daily_employee_date_unique",
+ ),
+ ],
},
),
]
diff --git a/backend/finance/migrations/0011_payrollworksettings.py b/backend/finance/migrations/0011_payrollworksettings.py
index c01c65a0..3d8d224d 100644
--- a/backend/finance/migrations/0011_payrollworksettings.py
+++ b/backend/finance/migrations/0011_payrollworksettings.py
@@ -1,28 +1,54 @@
# Generated by Django 5.2.4 on 2026-07-20 08:11
-import django.core.validators
from decimal import Decimal
+
+import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('finance', '0010_payrolldailyworkentry'),
+ ("finance", "0010_payrolldailyworkentry"),
]
operations = [
migrations.CreateModel(
- name='PayrollWorkSettings',
+ name="PayrollWorkSettings",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('singleton', models.BooleanField(default=True, editable=False, unique=True)),
- ('daily_target_points', models.DecimalField(decimal_places=4, default=Decimal('5'), help_text='Единая дневная норма для всех сотрудников.', max_digits=19, validators=[django.core.validators.MinValueValidator(Decimal('0.0001'))], 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",
+ ),
+ ),
+ (
+ "singleton",
+ models.BooleanField(default=True, editable=False, unique=True),
+ ),
+ (
+ "daily_target_points",
+ models.DecimalField(
+ decimal_places=4,
+ default=Decimal("5"),
+ help_text="Единая дневная норма для всех сотрудников.",
+ max_digits=19,
+ validators=[
+ django.core.validators.MinValueValidator(Decimal("0.0001"))
+ ],
+ verbose_name="Дневная норма баллов",
+ ),
+ ),
+ (
+ "updated_at",
+ models.DateTimeField(auto_now=True, verbose_name="Обновлено"),
+ ),
],
options={
- 'verbose_name': 'Настройки выработки',
- 'verbose_name_plural': 'Настройки выработки',
+ "verbose_name": "Настройки выработки",
+ "verbose_name_plural": "Настройки выработки",
},
),
]
diff --git a/backend/finance/migrations/0012_payrollworkrecord_target_points_overridden.py b/backend/finance/migrations/0012_payrollworkrecord_target_points_overridden.py
index fff1bf3c..9944d803 100644
--- a/backend/finance/migrations/0012_payrollworkrecord_target_points_overridden.py
+++ b/backend/finance/migrations/0012_payrollworkrecord_target_points_overridden.py
@@ -17,16 +17,17 @@ def mark_all_targets_as_overridden(apps, schema_editor):
class Migration(migrations.Migration):
-
dependencies = [
- ('finance', '0011_payrollworksettings'),
+ ("finance", "0011_payrollworksettings"),
]
operations = [
migrations.AddField(
- model_name='payrollworkrecord',
- name='target_points_overridden',
- field=models.BooleanField(default=True, editable=False, verbose_name='Норма указана вручную'),
+ model_name="payrollworkrecord",
+ name="target_points_overridden",
+ field=models.BooleanField(
+ default=True, editable=False, verbose_name="Норма указана вручную"
+ ),
),
migrations.RunPython(
mark_daily_work_targets_as_automatic,
diff --git a/backend/finance/migrations/0013_alter_payrollauditevent_period_and_more.py b/backend/finance/migrations/0013_alter_payrollauditevent_period_and_more.py
index 379ce440..99659355 100644
--- a/backend/finance/migrations/0013_alter_payrollauditevent_period_and_more.py
+++ b/backend/finance/migrations/0013_alter_payrollauditevent_period_and_more.py
@@ -5,75 +5,152 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('finance', '0012_payrollworkrecord_target_points_overridden'),
+ ("finance", "0012_payrollworkrecord_target_points_overridden"),
]
operations = [
migrations.AlterField(
- model_name='payrollauditevent',
- name='period',
- field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='audit_events', to='finance.payrollperiod', verbose_name='Период'),
+ model_name="payrollauditevent",
+ name="period",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="audit_events",
+ to="finance.payrollperiod",
+ verbose_name="Период",
+ ),
),
migrations.AlterField(
- model_name='payrolldailyworkentry',
- name='period',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='daily_work_entries', to='finance.payrollperiod', verbose_name='Период'),
+ model_name="payrolldailyworkentry",
+ name="period",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="daily_work_entries",
+ to="finance.payrollperiod",
+ verbose_name="Период",
+ ),
),
migrations.AlterField(
- model_name='payrollinputline',
- name='period',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='input_lines', to='finance.payrollperiod', verbose_name='Период выплаты'),
+ model_name="payrollinputline",
+ name="period",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="input_lines",
+ to="finance.payrollperiod",
+ verbose_name="Период выплаты",
+ ),
),
migrations.AlterField(
- model_name='payrollinputline',
- name='relates_to_period',
- field=models.ForeignKey(blank=True, help_text='Заполняется для перерасчёта прошлого периода.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='retro_input_lines', to='finance.payrollperiod', verbose_name='Относится к периоду'),
+ model_name="payrollinputline",
+ name="relates_to_period",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="Заполняется для перерасчёта прошлого периода.",
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="retro_input_lines",
+ to="finance.payrollperiod",
+ verbose_name="Относится к периоду",
+ ),
),
migrations.AlterField(
- model_name='payrollinputline',
- name='reversal_of',
- field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='reversed_by', to='finance.payrollinputline', verbose_name='Сторнирует строку'),
+ model_name="payrollinputline",
+ name="reversal_of",
+ field=models.OneToOneField(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.RESTRICT,
+ related_name="reversed_by",
+ to="finance.payrollinputline",
+ verbose_name="Сторнирует строку",
+ ),
),
migrations.AlterField(
- model_name='payrollrun',
- name='period',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='runs', to='finance.payrollperiod', verbose_name='Период'),
+ model_name="payrollrun",
+ name="period",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="runs",
+ to="finance.payrollperiod",
+ verbose_name="Период",
+ ),
),
migrations.AlterField(
- model_name='payrollrun',
- name='supersedes',
- field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='superseded_by_runs', to='finance.payrollrun', verbose_name='Пересчитывает ревизию'),
+ model_name="payrollrun",
+ name="supersedes",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.RESTRICT,
+ related_name="superseded_by_runs",
+ to="finance.payrollrun",
+ verbose_name="Пересчитывает ревизию",
+ ),
),
migrations.AlterField(
- model_name='payrollstatement',
- name='run',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='statements', to='finance.payrollrun', verbose_name='Запуск'),
+ model_name="payrollstatement",
+ name="run",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="statements",
+ to="finance.payrollrun",
+ verbose_name="Запуск",
+ ),
),
migrations.AlterField(
- model_name='payrollstatement',
- name='supersedes',
- field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='superseded_by_statement', to='finance.payrollstatement', verbose_name='Заменяет листок'),
+ model_name="payrollstatement",
+ name="supersedes",
+ field=models.OneToOneField(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.RESTRICT,
+ related_name="superseded_by_statement",
+ to="finance.payrollstatement",
+ verbose_name="Заменяет листок",
+ ),
),
migrations.AlterField(
- model_name='payrollstatementacknowledgement',
- name='statement',
- field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='acknowledgement', to='finance.payrollstatement', verbose_name='Расчётный листок'),
+ model_name="payrollstatementacknowledgement",
+ name="statement",
+ field=models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="acknowledgement",
+ to="finance.payrollstatement",
+ verbose_name="Расчётный листок",
+ ),
),
migrations.AlterField(
- model_name='payrollstatementline',
- name='statement',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='finance.payrollstatement', verbose_name='Расчётный листок'),
+ model_name="payrollstatementline",
+ name="statement",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="lines",
+ to="finance.payrollstatement",
+ verbose_name="Расчётный листок",
+ ),
),
migrations.AlterField(
- model_name='payrollworkrecord',
- name='period',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='work_records', to='finance.payrollperiod', verbose_name='Период'),
+ model_name="payrollworkrecord",
+ name="period",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="work_records",
+ to="finance.payrollperiod",
+ verbose_name="Период",
+ ),
),
migrations.AlterField(
- model_name='payrollworkrecord',
- name='replaces',
- field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='replaced_by', to='finance.payrollworkrecord', verbose_name='Заменяет запись'),
+ model_name="payrollworkrecord",
+ name="replaces",
+ field=models.OneToOneField(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.RESTRICT,
+ related_name="replaced_by",
+ to="finance.payrollworkrecord",
+ verbose_name="Заменяет запись",
+ ),
),
]
diff --git a/backend/finance/migrations/0014_pay_rate_automatic_excess_price.py b/backend/finance/migrations/0014_pay_rate_automatic_excess_price.py
index 2d15bf05..cc9c66f1 100644
--- a/backend/finance/migrations/0014_pay_rate_automatic_excess_price.py
+++ b/backend/finance/migrations/0014_pay_rate_automatic_excess_price.py
@@ -1,7 +1,7 @@
from decimal import Decimal
-from django.db import migrations, models
import django.core.validators
+from django.db import migrations, models
def use_automatic_price_for_legacy_defaults(apps, schema_editor):
diff --git a/backend/finance/migrations/0016_payrollcellcommentthread.py b/backend/finance/migrations/0016_payrollcellcommentthread.py
index 9a0ec253..447228e7 100644
--- a/backend/finance/migrations/0016_payrollcellcommentthread.py
+++ b/backend/finance/migrations/0016_payrollcellcommentthread.py
@@ -7,27 +7,70 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('finance', '0015_alter_payrollperiod_created_by'),
+ ("finance", "0015_alter_payrollperiod_created_by"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
- name='PayrollCellCommentThread',
+ name="PayrollCellCommentThread",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('column_id', models.CharField(max_length=80, validators=[django.core.validators.RegexValidator(message='Некорректный идентификатор колонки расчётной таблицы.', regex='^[a-z][a-z0-9_]*(?::[A-Z][A-Z0-9_.-]{0,63})?$')], verbose_name='Колонка')),
- ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создан')),
- ('period', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cell_comment_threads', to='finance.payrollperiod', verbose_name='Расчётный период')),
- ('subject_employee', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='payroll_cell_comment_threads', to=settings.AUTH_USER_MODEL, verbose_name='Сотрудник в строке')),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "column_id",
+ models.CharField(
+ max_length=80,
+ validators=[
+ django.core.validators.RegexValidator(
+ message="Некорректный идентификатор колонки расчётной таблицы.",
+ regex="^[a-z][a-z0-9_]*(?::[A-Z][A-Z0-9_.-]{0,63})?$",
+ )
+ ],
+ verbose_name="Колонка",
+ ),
+ ),
+ (
+ "created_at",
+ models.DateTimeField(auto_now_add=True, verbose_name="Создан"),
+ ),
+ (
+ "period",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="cell_comment_threads",
+ to="finance.payrollperiod",
+ verbose_name="Расчётный период",
+ ),
+ ),
+ (
+ "subject_employee",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="payroll_cell_comment_threads",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Сотрудник в строке",
+ ),
+ ),
],
options={
- 'verbose_name': 'Обсуждение ячейки расчёта',
- 'verbose_name_plural': 'Обсуждения ячеек расчёта',
- 'ordering': ['period_id', 'subject_employee_id', 'column_id'],
- 'constraints': [models.UniqueConstraint(fields=('period', 'subject_employee', 'column_id'), name='pay_cell_comment_thread_unique')],
+ "verbose_name": "Обсуждение ячейки расчёта",
+ "verbose_name_plural": "Обсуждения ячеек расчёта",
+ "ordering": ["period_id", "subject_employee_id", "column_id"],
+ "constraints": [
+ models.UniqueConstraint(
+ fields=("period", "subject_employee", "column_id"),
+ name="pay_cell_comment_thread_unique",
+ )
+ ],
},
),
]
diff --git a/backend/finance/migrations/0017_payrollcarryover.py b/backend/finance/migrations/0017_payrollcarryover.py
index b6505c58..33a1e992 100644
--- a/backend/finance/migrations/0017_payrollcarryover.py
+++ b/backend/finance/migrations/0017_payrollcarryover.py
@@ -1,45 +1,167 @@
# Generated by Django 5.2.4 on 2026-08-04 11:09
+from decimal import Decimal
+
import django.core.validators
import django.db.models.deletion
-from decimal import Decimal
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('finance', '0016_payrollcellcommentthread'),
+ ("finance", "0016_payrollcellcommentthread"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
- name='PayrollCarryover',
+ name="PayrollCarryover",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('direction', models.CharField(choices=[('company_owes', 'Доплата сотруднику'), ('employee_owes', 'Долг сотрудника')], max_length=20, verbose_name='Вид остатка')),
- ('amount', models.DecimalField(decimal_places=2, max_digits=19, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))], verbose_name='Сумма')),
- ('reason', models.TextField(verbose_name='Основание')),
- ('status', models.CharField(choices=[('pending', 'Ожидает следующего периода'), ('transferred', 'Перенесено'), ('cancelled', 'Отменено')], default='pending', max_length=16, verbose_name='Статус')),
- ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создан')),
- ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлён')),
- ('transferred_at', models.DateTimeField(blank=True, null=True, verbose_name='Перенесён')),
- ('cancelled_at', models.DateTimeField(blank=True, null=True, verbose_name='Отменён')),
- ('cancelled_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='cancelled_payroll_carryovers', to=settings.AUTH_USER_MODEL, verbose_name='Отменил')),
- ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_payroll_carryovers', to=settings.AUTH_USER_MODEL, verbose_name='Создал')),
- ('employee', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='payroll_carryovers', to=settings.AUTH_USER_MODEL, verbose_name='Сотрудник')),
- ('generated_input_line', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='generated_from_carryover', to='finance.payrollinputline', verbose_name='Созданная операция')),
- ('source_period', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='outgoing_carryovers', to='finance.payrollperiod', verbose_name='Исходный период')),
- ('target_period', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='incoming_carryovers', to='finance.payrollperiod', verbose_name='Период переноса')),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "direction",
+ models.CharField(
+ choices=[
+ ("company_owes", "Доплата сотруднику"),
+ ("employee_owes", "Долг сотрудника"),
+ ],
+ max_length=20,
+ verbose_name="Вид остатка",
+ ),
+ ),
+ (
+ "amount",
+ models.DecimalField(
+ decimal_places=2,
+ max_digits=19,
+ validators=[
+ django.core.validators.MinValueValidator(Decimal("0.01"))
+ ],
+ verbose_name="Сумма",
+ ),
+ ),
+ ("reason", models.TextField(verbose_name="Основание")),
+ (
+ "status",
+ models.CharField(
+ choices=[
+ ("pending", "Ожидает следующего периода"),
+ ("transferred", "Перенесено"),
+ ("cancelled", "Отменено"),
+ ],
+ default="pending",
+ max_length=16,
+ verbose_name="Статус",
+ ),
+ ),
+ (
+ "created_at",
+ models.DateTimeField(auto_now_add=True, verbose_name="Создан"),
+ ),
+ (
+ "updated_at",
+ models.DateTimeField(auto_now=True, verbose_name="Обновлён"),
+ ),
+ (
+ "transferred_at",
+ models.DateTimeField(
+ blank=True, null=True, verbose_name="Перенесён"
+ ),
+ ),
+ (
+ "cancelled_at",
+ models.DateTimeField(blank=True, null=True, verbose_name="Отменён"),
+ ),
+ (
+ "cancelled_by",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="cancelled_payroll_carryovers",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Отменил",
+ ),
+ ),
+ (
+ "created_by",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="created_payroll_carryovers",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Создал",
+ ),
+ ),
+ (
+ "employee",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="payroll_carryovers",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Сотрудник",
+ ),
+ ),
+ (
+ "generated_input_line",
+ models.OneToOneField(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="generated_from_carryover",
+ to="finance.payrollinputline",
+ verbose_name="Созданная операция",
+ ),
+ ),
+ (
+ "source_period",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="outgoing_carryovers",
+ to="finance.payrollperiod",
+ verbose_name="Исходный период",
+ ),
+ ),
+ (
+ "target_period",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="incoming_carryovers",
+ to="finance.payrollperiod",
+ verbose_name="Период переноса",
+ ),
+ ),
],
options={
- 'verbose_name': 'Долг или доплата',
- 'verbose_name_plural': 'Долги и доплаты',
- 'ordering': ['-created_at', '-id'],
- 'indexes': [models.Index(fields=['status', 'source_period'], name='pay_carry_status_source_idx'), models.Index(fields=['target_period', 'employee'], name='pay_carry_target_employee_idx')],
- 'constraints': [models.CheckConstraint(condition=models.Q(('amount__gt', 0)), name='pay_carry_amount_positive')],
+ "verbose_name": "Долг или доплата",
+ "verbose_name_plural": "Долги и доплаты",
+ "ordering": ["-created_at", "-id"],
+ "indexes": [
+ models.Index(
+ fields=["status", "source_period"],
+ name="pay_carry_status_source_idx",
+ ),
+ models.Index(
+ fields=["target_period", "employee"],
+ name="pay_carry_target_employee_idx",
+ ),
+ ],
+ "constraints": [
+ models.CheckConstraint(
+ condition=models.Q(("amount__gt", 0)),
+ name="pay_carry_amount_positive",
+ )
+ ],
},
),
]
diff --git a/backend/finance/migrations/0018_financesettings.py b/backend/finance/migrations/0018_financesettings.py
index 719549aa..08b7e1c1 100644
--- a/backend/finance/migrations/0018_financesettings.py
+++ b/backend/finance/migrations/0018_financesettings.py
@@ -5,7 +5,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
("finance", "0017_payrollcarryover"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
@@ -32,7 +31,10 @@ class Migration(migrations.Migration):
verbose_name="Название",
),
),
- ("updated_at", models.DateTimeField(auto_now=True, verbose_name="Обновлено")),
+ (
+ "updated_at",
+ models.DateTimeField(auto_now=True, verbose_name="Обновлено"),
+ ),
(
"management_users",
models.ManyToManyField(
diff --git a/backend/finance/migrations/0022_payrollinputcopyconfiguration.py b/backend/finance/migrations/0022_payrollinputcopyconfiguration.py
index acd361a3..27dc8e7c 100644
--- a/backend/finance/migrations/0022_payrollinputcopyconfiguration.py
+++ b/backend/finance/migrations/0022_payrollinputcopyconfiguration.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):
@@ -22,7 +22,10 @@ class Migration(migrations.Migration):
verbose_name="ID",
),
),
- ("component_ids", models.JSONField(default=list, verbose_name="Виды операций")),
+ (
+ "component_ids",
+ models.JSONField(default=list, verbose_name="Виды операций"),
+ ),
(
"employee_scope",
models.CharField(
@@ -36,7 +39,10 @@ class Migration(migrations.Migration):
verbose_name="Охват сотрудников",
),
),
- ("employee_ids", models.JSONField(default=list, verbose_name="Выбранные сотрудники")),
+ (
+ "employee_ids",
+ models.JSONField(default=list, verbose_name="Выбранные сотрудники"),
+ ),
(
"existing_mode",
models.CharField(
@@ -53,7 +59,10 @@ class Migration(migrations.Migration):
verbose_name="Автоматически применять при создании периода",
),
),
- ("updated_at", models.DateTimeField(auto_now=True, verbose_name="Обновлена")),
+ (
+ "updated_at",
+ models.DateTimeField(auto_now=True, verbose_name="Обновлена"),
+ ),
(
"owner",
models.OneToOneField(
diff --git a/backend/finance/migrations/0023_payrollstatementpaymentconfirmation.py b/backend/finance/migrations/0023_payrollstatementpaymentconfirmation.py
index c6d00488..0c2f8bb7 100644
--- a/backend/finance/migrations/0023_payrollstatementpaymentconfirmation.py
+++ b/backend/finance/migrations/0023_payrollstatementpaymentconfirmation.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/finance/migrations/0026_alter_employeepayrate_source_and_more.py b/backend/finance/migrations/0026_alter_employeepayrate_source_and_more.py
index 4516da7d..404d7b86 100644
--- a/backend/finance/migrations/0026_alter_employeepayrate_source_and_more.py
+++ b/backend/finance/migrations/0026_alter_employeepayrate_source_and_more.py
@@ -1,39 +1,117 @@
# Generated by Django 5.2.4 on 2026-08-26 09:39
+from decimal import Decimal
+
import django.core.validators
import django.db.models.deletion
-from decimal import Decimal
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('finance', '0025_payroll_components_in_code'),
- ('requests_app', '0016_requestvacationterms'),
+ ("finance", "0025_payroll_components_in_code"),
+ ("requests_app", "0016_requestvacationterms"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
- name='VacationPayInstruction',
+ name="VacationPayInstruction",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('paid_days', models.PositiveSmallIntegerField(verbose_name='Оплачиваемые дни')),
- ('amount', models.DecimalField(blank=True, decimal_places=2, max_digits=19, null=True, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))], verbose_name='Сумма отпускных')),
- ('status', models.CharField(choices=[('pending_amount', 'Ожидает расчёта суммы'), ('draft', 'Черновик начисления'), ('posted', 'Передано в начисления'), ('voided', 'Отменено')], default='pending_amount', max_length=20, verbose_name='Статус')),
- ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
- ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')),
- ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_vacation_pay_instructions', to=settings.AUTH_USER_MODEL, verbose_name='Создал')),
- ('payroll_input_line', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vacation_pay_instruction', to='finance.payrollinputline', verbose_name='Строка начисления')),
- ('period', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vacation_pay_instructions', to='finance.payrollperiod', verbose_name='Расчётный период')),
- ('vacation_terms', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='finance_instruction', to='requests_app.requestvacationterms', verbose_name='Условия отпуска')),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "paid_days",
+ models.PositiveSmallIntegerField(verbose_name="Оплачиваемые дни"),
+ ),
+ (
+ "amount",
+ models.DecimalField(
+ blank=True,
+ decimal_places=2,
+ max_digits=19,
+ null=True,
+ validators=[
+ django.core.validators.MinValueValidator(Decimal("0.01"))
+ ],
+ verbose_name="Сумма отпускных",
+ ),
+ ),
+ (
+ "status",
+ models.CharField(
+ choices=[
+ ("pending_amount", "Ожидает расчёта суммы"),
+ ("draft", "Черновик начисления"),
+ ("posted", "Передано в начисления"),
+ ("voided", "Отменено"),
+ ],
+ default="pending_amount",
+ max_length=20,
+ verbose_name="Статус",
+ ),
+ ),
+ (
+ "created_at",
+ models.DateTimeField(auto_now_add=True, verbose_name="Создано"),
+ ),
+ (
+ "updated_at",
+ models.DateTimeField(auto_now=True, verbose_name="Обновлено"),
+ ),
+ (
+ "created_by",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="created_vacation_pay_instructions",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Создал",
+ ),
+ ),
+ (
+ "payroll_input_line",
+ models.OneToOneField(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="vacation_pay_instruction",
+ to="finance.payrollinputline",
+ verbose_name="Строка начисления",
+ ),
+ ),
+ (
+ "period",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="vacation_pay_instructions",
+ to="finance.payrollperiod",
+ verbose_name="Расчётный период",
+ ),
+ ),
+ (
+ "vacation_terms",
+ models.OneToOneField(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="finance_instruction",
+ to="requests_app.requestvacationterms",
+ verbose_name="Условия отпуска",
+ ),
+ ),
],
options={
- 'verbose_name': 'Поручение на отпускные',
- 'verbose_name_plural': 'Поручения на отпускные',
- 'ordering': ['-created_at', '-id'],
+ "verbose_name": "Поручение на отпускные",
+ "verbose_name_plural": "Поручения на отпускные",
+ "ordering": ["-created_at", "-id"],
},
),
]
diff --git a/backend/finance/migrations/0027_payrollcustomcomponent.py b/backend/finance/migrations/0027_payrollcustomcomponent.py
index e3b3e0ee..7251dece 100644
--- a/backend/finance/migrations/0027_payrollcustomcomponent.py
+++ b/backend/finance/migrations/0027_payrollcustomcomponent.py
@@ -2,36 +2,99 @@
import django.core.validators
import django.db.models.deletion
-import finance.models
from django.conf import settings
from django.db import migrations, models
+import finance.models
+
class Migration(migrations.Migration):
-
dependencies = [
- ('finance', '0026_alter_employeepayrate_source_and_more'),
+ ("finance", "0026_alter_employeepayrate_source_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
- name='PayrollCustomComponent',
+ name="PayrollCustomComponent",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('code', models.CharField(default=finance.models.generate_custom_payroll_component_code, editable=False, max_length=64, unique=True, validators=[django.core.validators.RegexValidator(message='Код должен начинаться с A-Z и содержать только A-Z, 0-9, _.-', regex='^[A-Z][A-Z0-9_.-]{0,63}$')], verbose_name='Код')),
- ('name', models.CharField(max_length=80, unique=True, verbose_name='Название')),
- ('kind', models.CharField(choices=[('earning', 'Начисление'), ('adjustment_credit', 'Коррекция +'), ('adjustment_debit', 'Коррекция −'), ('deduction', 'Удержание'), ('payment', 'Выплата')], max_length=24, verbose_name='Влияние на расчёт')),
- ('requires_reason', models.BooleanField(default=False, verbose_name='Требовать основание')),
- ('is_active', models.BooleanField(default=True, verbose_name='Активна')),
- ('display_order', models.PositiveIntegerField(default=900, verbose_name='Порядок')),
- ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создана')),
- ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_payroll_components', to=settings.AUTH_USER_MODEL, verbose_name='Создал')),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "code",
+ models.CharField(
+ default=finance.models.generate_custom_payroll_component_code,
+ editable=False,
+ max_length=64,
+ unique=True,
+ validators=[
+ django.core.validators.RegexValidator(
+ message="Код должен начинаться с A-Z и содержать только A-Z, 0-9, _.-",
+ regex="^[A-Z][A-Z0-9_.-]{0,63}$",
+ )
+ ],
+ verbose_name="Код",
+ ),
+ ),
+ (
+ "name",
+ models.CharField(
+ max_length=80, unique=True, verbose_name="Название"
+ ),
+ ),
+ (
+ "kind",
+ models.CharField(
+ choices=[
+ ("earning", "Начисление"),
+ ("adjustment_credit", "Коррекция +"),
+ ("adjustment_debit", "Коррекция −"),
+ ("deduction", "Удержание"),
+ ("payment", "Выплата"),
+ ],
+ max_length=24,
+ verbose_name="Влияние на расчёт",
+ ),
+ ),
+ (
+ "requires_reason",
+ models.BooleanField(
+ default=False, verbose_name="Требовать основание"
+ ),
+ ),
+ (
+ "is_active",
+ models.BooleanField(default=True, verbose_name="Активна"),
+ ),
+ (
+ "display_order",
+ models.PositiveIntegerField(default=900, verbose_name="Порядок"),
+ ),
+ (
+ "created_at",
+ models.DateTimeField(auto_now_add=True, verbose_name="Создана"),
+ ),
+ (
+ "created_by",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="created_payroll_components",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Создал",
+ ),
+ ),
],
options={
- 'verbose_name': 'Пользовательская зарплатная колонка',
- 'verbose_name_plural': 'Пользовательские зарплатные колонки',
- 'ordering': ['display_order', 'name', 'id'],
+ "verbose_name": "Пользовательская зарплатная колонка",
+ "verbose_name_plural": "Пользовательские зарплатные колонки",
+ "ordering": ["display_order", "name", "id"],
},
),
]
diff --git a/backend/finance/migrations/0028_payrollcustomcomponent_period_scope_and_more.py b/backend/finance/migrations/0028_payrollcustomcomponent_period_scope_and_more.py
index 305652f4..8176982f 100644
--- a/backend/finance/migrations/0028_payrollcustomcomponent_period_scope_and_more.py
+++ b/backend/finance/migrations/0028_payrollcustomcomponent_period_scope_and_more.py
@@ -5,20 +5,35 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('finance', '0027_payrollcustomcomponent'),
+ ("finance", "0027_payrollcustomcomponent"),
]
operations = [
migrations.AddField(
- model_name='payrollcustomcomponent',
- name='period_scope',
- field=models.CharField(choices=[('current_only', 'Только выбранный период'), ('current_and_future', 'Выбранный и будущие периоды'), ('all', 'Все периоды')], default='all', max_length=24, verbose_name='Периоды отображения'),
+ model_name="payrollcustomcomponent",
+ name="period_scope",
+ field=models.CharField(
+ choices=[
+ ("current_only", "Только выбранный период"),
+ ("current_and_future", "Выбранный и будущие периоды"),
+ ("all", "Все периоды"),
+ ],
+ default="all",
+ max_length=24,
+ verbose_name="Периоды отображения",
+ ),
),
migrations.AddField(
- model_name='payrollcustomcomponent',
- name='scope_period',
- field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='scoped_custom_components', to='finance.payrollperiod', verbose_name='Начальный период'),
+ model_name="payrollcustomcomponent",
+ name="scope_period",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="scoped_custom_components",
+ to="finance.payrollperiod",
+ verbose_name="Начальный период",
+ ),
),
]
diff --git a/backend/finance/migrations/0029_alter_payrollcustomcomponent_name.py b/backend/finance/migrations/0029_alter_payrollcustomcomponent_name.py
index 2fba19cb..7c79ce65 100644
--- a/backend/finance/migrations/0029_alter_payrollcustomcomponent_name.py
+++ b/backend/finance/migrations/0029_alter_payrollcustomcomponent_name.py
@@ -4,15 +4,14 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('finance', '0028_payrollcustomcomponent_period_scope_and_more'),
+ ("finance", "0028_payrollcustomcomponent_period_scope_and_more"),
]
operations = [
migrations.AlterField(
- model_name='payrollcustomcomponent',
- name='name',
- field=models.CharField(max_length=80, verbose_name='Название'),
+ model_name="payrollcustomcomponent",
+ name="name",
+ field=models.CharField(max_length=80, verbose_name="Название"),
),
]
diff --git a/backend/finance/migrations/0030_alter_payroll_component_kind_labels.py b/backend/finance/migrations/0030_alter_payroll_component_kind_labels.py
index 59b9aa79..7aa0636f 100644
--- a/backend/finance/migrations/0030_alter_payroll_component_kind_labels.py
+++ b/backend/finance/migrations/0030_alter_payroll_component_kind_labels.py
@@ -1,6 +1,5 @@
from django.db import migrations, models
-
KIND_CHOICES = [
("earning", "Начислить сотруднику"),
("adjustment_credit", "Положительная корректировка"),
@@ -11,7 +10,6 @@ KIND_CHOICES = [
class Migration(migrations.Migration):
-
dependencies = [
("finance", "0029_alter_payrollcustomcomponent_name"),
]
diff --git a/backend/finance/migrations/0031_payrolltablereviewmark.py b/backend/finance/migrations/0031_payrolltablereviewmark.py
index e9099f14..89fe9488 100644
--- a/backend/finance/migrations/0031_payrolltablereviewmark.py
+++ b/backend/finance/migrations/0031_payrolltablereviewmark.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 = [
("finance", "0030_alter_payroll_component_kind_labels"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
@@ -23,7 +22,10 @@ class Migration(migrations.Migration):
verbose_name="ID",
),
),
- ("created_at", models.DateTimeField(auto_now_add=True, verbose_name="Отмечено")),
+ (
+ "created_at",
+ models.DateTimeField(auto_now_add=True, verbose_name="Отмечено"),
+ ),
(
"employee",
models.ForeignKey(
diff --git a/backend/finance/migrations/0032_reportingentry.py b/backend/finance/migrations/0032_reportingentry.py
index 3c3b1776..acd83888 100644
--- a/backend/finance/migrations/0032_reportingentry.py
+++ b/backend/finance/migrations/0032_reportingentry.py
@@ -1,8 +1,9 @@
# Generated by Django 5.2.4 on 2026-09-07 06:31
+from decimal import Decimal
+
import django.core.validators
import django.db.models.deletion
-from decimal import Decimal
from django.conf import settings
from django.db import migrations, models
diff --git a/backend/finance/migrations/0033_reportingentry_project.py b/backend/finance/migrations/0033_reportingentry_project.py
index 819b0978..3841b584 100644
--- a/backend/finance/migrations/0033_reportingentry_project.py
+++ b/backend/finance/migrations/0033_reportingentry_project.py
@@ -5,16 +5,20 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('commerce', '0014_externalproject_projectpayment_and_more'),
- ('finance', '0032_reportingentry'),
+ ("commerce", "0014_externalproject_projectpayment_and_more"),
+ ("finance", "0032_reportingentry"),
]
operations = [
migrations.AddField(
- model_name='reportingentry',
- name='project',
- field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='commerce.externalproject'),
+ model_name="reportingentry",
+ name="project",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ to="commerce.externalproject",
+ ),
),
]
diff --git a/backend/finance/models.py b/backend/finance/models.py
index 683cf551..6470fafa 100644
--- a/backend/finance/models.py
+++ b/backend/finance/models.py
@@ -15,6 +15,7 @@ from django.core.validators import MinValueValidator, RegexValidator
from django.db import models, transaction
from django.db.models import F, Q
+from .analytics.models import ReportingEntry # noqa: F401
from .enums import (
ApprovalStatus,
InputSource,
@@ -26,7 +27,6 @@ from .enums import (
PayrollRunStatus,
VacationPayInstructionStatus,
)
-from .analytics.models import ReportingEntry # noqa: F401
currency_validator = RegexValidator(
regex=r"^[A-Z]{3}$",
@@ -413,7 +413,9 @@ class PayrollCarryover(models.Model):
)
if self.target_period.currency != self.source_period.currency:
raise ValidationError(
- {"target_period": "Валюты исходного и целевого периодов различаются."}
+ {
+ "target_period": "Валюты исходного и целевого периодов различаются."
+ }
)
def __str__(self):
diff --git a/backend/finance/payroll/__init__.py b/backend/finance/payroll/__init__.py
index 33cf4fa5..c537ff25 100644
--- a/backend/finance/payroll/__init__.py
+++ b/backend/finance/payroll/__init__.py
@@ -6,9 +6,9 @@ from .services import (
approve_pay_rate,
approve_run,
approve_work_record,
+ bulk_set_target_points,
calculate_period,
clear_component_input_lines,
- bulk_set_target_points,
close_period,
complete_period_workflow,
publish_run,
diff --git a/backend/finance/payroll/adapter.py b/backend/finance/payroll/adapter.py
index ea4c31c7..f9db9537 100644
--- a/backend/finance/payroll/adapter.py
+++ b/backend/finance/payroll/adapter.py
@@ -2,15 +2,7 @@
from __future__ import annotations
-from decimal import Context, Decimal, ROUND_HALF_UP, localcontext
-
-from payroll_core import (
- ExpectedTotals,
- InputLine,
- LineKind,
- PayrollPeriod as CorePayrollPeriod,
- PayrollRequest,
-)
+from decimal import ROUND_HALF_UP, Context, Decimal, localcontext
from finance.models import (
EmployeePayRate,
@@ -18,6 +10,15 @@ from finance.models import (
PayrollPeriod,
PayrollWorkRecord,
)
+from payroll_core import (
+ ExpectedTotals,
+ InputLine,
+ LineKind,
+ PayrollRequest,
+)
+from payroll_core import (
+ PayrollPeriod as CorePayrollPeriod,
+)
POINT_BASE_COMPONENT_CODES = frozenset(
{"BONUS", "CORRECTION_CREDIT", "CORRECTION_DEBIT"}
diff --git a/backend/finance/payroll/attendance.py b/backend/finance/payroll/attendance.py
index b5f86183..6d55aca7 100644
--- a/backend/finance/payroll/attendance.py
+++ b/backend/finance/payroll/attendance.py
@@ -22,8 +22,6 @@ from django.utils import timezone
from attendance.models import AttendanceRecord
from attendance.services import ABSENCE_ISSUE_MARKERS
from employees.constants import ACTION_REMOTE
-from payroll_core import PointPolicy
-
from finance.enums import (
ApprovalStatus,
InputSource,
@@ -36,6 +34,7 @@ from finance.models import (
PayrollWorkRecord,
PayrollWorkSettings,
)
+from payroll_core import PointPolicy
from .access import has_payroll_permission, has_simple_admin_access
from .config import build_rules
diff --git a/backend/finance/payroll/balances.py b/backend/finance/payroll/balances.py
index 45d1f4a1..8fc8c381 100644
--- a/backend/finance/payroll/balances.py
+++ b/backend/finance/payroll/balances.py
@@ -48,6 +48,5 @@ def opening_balances(
)
)
return {
- row["employee_id"]: row["debit_total"] - row["credit_total"]
- for row in rows
+ row["employee_id"]: row["debit_total"] - row["credit_total"] for row in rows
}
diff --git a/backend/finance/payroll/carryovers.py b/backend/finance/payroll/carryovers.py
index 8138cd09..e3387ab5 100644
--- a/backend/finance/payroll/carryovers.py
+++ b/backend/finance/payroll/carryovers.py
@@ -222,9 +222,7 @@ def cancel_carryover(carryover_id: int, *, actor):
line.voided_by = actor
line.voided_at = timezone.now()
line.void_reason = "Отменена запись долга или доплаты"
- line.save(
- update_fields=["status", "voided_by", "voided_at", "void_reason"]
- )
+ line.save(update_fields=["status", "voided_by", "voided_at", "void_reason"])
carryover.status = PayrollCarryoverStatus.CANCELLED
carryover.cancelled_by = actor
carryover.cancelled_at = timezone.now()
diff --git a/backend/finance/payroll/components.py b/backend/finance/payroll/components.py
index 1e9c46f2..cd175f1b 100644
--- a/backend/finance/payroll/components.py
+++ b/backend/finance/payroll/components.py
@@ -2,8 +2,7 @@
from dataclasses import dataclass
-from finance.enums import PayrollComponentKind
-from finance.enums import PayrollComponentPeriodScope
+from finance.enums import PayrollComponentKind, PayrollComponentPeriodScope
@dataclass(frozen=True, slots=True)
@@ -39,16 +38,42 @@ class PayrollComponentDefinition:
PAYROLL_COMPONENTS = (
- PayrollComponentDefinition("VACATION_PAY", "Отпускные", PayrollComponentKind.EARNING, False, 20),
- PayrollComponentDefinition("BONUS", "Премия", PayrollComponentKind.EARNING, False, 30),
- PayrollComponentDefinition("CORRECTION_CREDIT", "Положительная корректировка", PayrollComponentKind.ADJUSTMENT_CREDIT, True, 40),
- PayrollComponentDefinition("CORRECTION_DEBIT", "Отрицательная корректировка", PayrollComponentKind.ADJUSTMENT_DEBIT, True, 41),
- PayrollComponentDefinition("ONE_TIME_PAYMENT", "Разовое начисление", PayrollComponentKind.EARNING, True, 50),
- PayrollComponentDefinition("DEDUCTION", "Удержание", PayrollComponentKind.DEDUCTION, True, 60),
- PayrollComponentDefinition("ADVANCE", "Аванс", PayrollComponentKind.PAYMENT, False, 70),
- PayrollComponentDefinition("SALARY_PAYMENT", "Фактическая выплата", PayrollComponentKind.PAYMENT, True, 80),
+ PayrollComponentDefinition(
+ "VACATION_PAY", "Отпускные", PayrollComponentKind.EARNING, False, 20
+ ),
+ PayrollComponentDefinition(
+ "BONUS", "Премия", PayrollComponentKind.EARNING, False, 30
+ ),
+ PayrollComponentDefinition(
+ "CORRECTION_CREDIT",
+ "Положительная корректировка",
+ PayrollComponentKind.ADJUSTMENT_CREDIT,
+ True,
+ 40,
+ ),
+ PayrollComponentDefinition(
+ "CORRECTION_DEBIT",
+ "Отрицательная корректировка",
+ PayrollComponentKind.ADJUSTMENT_DEBIT,
+ True,
+ 41,
+ ),
+ PayrollComponentDefinition(
+ "ONE_TIME_PAYMENT", "Разовое начисление", PayrollComponentKind.EARNING, True, 50
+ ),
+ PayrollComponentDefinition(
+ "DEDUCTION", "Удержание", PayrollComponentKind.DEDUCTION, True, 60
+ ),
+ PayrollComponentDefinition(
+ "ADVANCE", "Аванс", PayrollComponentKind.PAYMENT, False, 70
+ ),
+ PayrollComponentDefinition(
+ "SALARY_PAYMENT", "Фактическая выплата", PayrollComponentKind.PAYMENT, True, 80
+ ),
)
-PAYROLL_COMPONENTS_BY_CODE = {component.code: component for component in PAYROLL_COMPONENTS}
+PAYROLL_COMPONENTS_BY_CODE = {
+ component.code: component for component in PAYROLL_COMPONENTS
+}
PAYROLL_COMPONENT_CODE_CHOICES = tuple(
(component.code, component.name) for component in PAYROLL_COMPONENTS
)
@@ -77,7 +102,9 @@ class _PayrollComponentCollection(tuple):
component
for component in self
if not all(
- getattr(component, key) in value if lookup == "in" else getattr(component, key) == value
+ getattr(component, key) in value
+ if lookup == "in"
+ else getattr(component, key) == value
for key, (lookup, value) in normalized.items()
)
)
@@ -131,7 +158,9 @@ def get_payroll_components(period=None) -> _PayrollComponentCollection:
.order_by("display_order", "name", "id")
)
if period is not None:
- custom = [component for component in custom if component.applies_to_period(period)]
+ custom = [
+ component for component in custom if component.applies_to_period(period)
+ ]
return _PayrollComponentCollection((*PAYROLL_COMPONENTS, *custom))
diff --git a/backend/finance/payroll/reporting.py b/backend/finance/payroll/reporting.py
index 03077034..850437e0 100644
--- a/backend/finance/payroll/reporting.py
+++ b/backend/finance/payroll/reporting.py
@@ -10,16 +10,12 @@ from decimal import Decimal
from django.contrib.auth import get_user_model
from django.db.models import Prefetch, Q
from django.utils import timezone
-from payroll_core import DeterministicPayrollCalculator
from attendance.models import AttendanceRecord
from attendance.services import ABSENCE_ISSUE_MARKERS
from employees.constants import ACTION_REMOTE, ACTIVATING_MARKER_ACTIONS
from employees.models import EmployeeAction
from employees.services.personnel_state import resolve_employee_personnel_state
-from requests_app.enums import RequestStatus
-from requests_app.models import Request
-
from finance.enums import ApprovalStatus, InputSource
from finance.models import (
EmployeePayRate,
@@ -30,21 +26,24 @@ from finance.models import (
PayrollWorkSettings,
)
from finance.payroll.components import get_payroll_components
+from payroll_core import DeterministicPayrollCalculator
+from requests_app.enums import RequestStatus
+from requests_app.models import Request
from .adapter import (
POINT_BASE_COMPONENT_CODES,
POINT_BASE_DEBIT_COMPONENT_CODES,
build_core_preview_request,
)
-from .balances import opening_balances
from .attendance import (
calculate_attendance_day_points,
calculate_attendance_points_projection,
)
+from .balances import opening_balances
from .config import base_rate_code, build_rules
from .work_norm import (
- calculate_period_personnel_points,
calculate_period_personnel_daily_points,
+ calculate_period_personnel_points,
calculate_period_target_points,
period_workdates,
resolve_employee_schedule,
@@ -295,16 +294,11 @@ def _attendance_alert(
target = Decimal(str(target_points)) if target_points not in (None, "") else None
actual = Decimal(str(actual_points)) if actual_points not in (None, "") else None
points_in_norm = bool(
- target is not None
- and actual is not None
- and target > 0
- and actual >= target
+ target is not None and actual is not None and target > 0 and actual >= target
)
ordered_dates = sorted(set(absence_dates))
return {
- "kind": (
- "compensated_absence" if points_in_norm else "unconfirmed_absence"
- ),
+ "kind": ("compensated_absence" if points_in_norm else "unconfirmed_absence"),
"severity": "warning" if points_in_norm else "danger",
"count": len(ordered_dates),
"dates": [item.isoformat() for item in ordered_dates],
@@ -739,8 +733,7 @@ def build_payroll_employee_point_breakdown(
if work_entries:
work_points_mode = "daily_entries"
work_points = {
- workdate: entry.actual_points
- for workdate, entry in work_entries.items()
+ workdate: entry.actual_points for workdate, entry in work_entries.items()
}
else:
work_points_mode = "unavailable"
@@ -771,8 +764,7 @@ def build_payroll_employee_point_breakdown(
if action_date is None:
continue
action_is_related = action_date == current or (
- action.date_to is not None
- and action_date <= current <= action.date_to
+ action.date_to is not None and action_date <= current <= action.date_to
)
if personnel_state.action_id == action.pk:
action_is_related = True
@@ -886,8 +878,7 @@ def build_payroll_employee_point_breakdown(
and current < first_employment_date
else {
"status": personnel_state.status,
- "label": personnel_state.label
- or "Обычный рабочий день",
+ "label": personnel_state.label or "Обычный рабочий день",
"expects_attendance": personnel_state.expects_attendance,
}
),
diff --git a/backend/finance/payroll/services.py b/backend/finance/payroll/services.py
index 3fd7ac7e..3bd62248 100644
--- a/backend/finance/payroll/services.py
+++ b/backend/finance/payroll/services.py
@@ -14,11 +14,6 @@ from django.db import IntegrityError, transaction
from django.db.models import F, Max, OuterRef, Q, Subquery, Sum
from django.utils import timezone
-from payroll_core import (
- DeterministicPayrollCalculator,
- PayrollValidationError,
-)
-
from finance.enums import (
ApprovalStatus,
InputSource,
@@ -36,26 +31,30 @@ from finance.models import (
PayrollStatement,
PayrollStatementAcknowledgement,
PayrollStatementLine,
- PayrollWorkSettings,
PayrollWorkRecord,
+ PayrollWorkSettings,
VacationPayInstruction,
)
+from payroll_core import (
+ DeterministicPayrollCalculator,
+ PayrollValidationError,
+)
-from .adapter import build_core_request, employee_snapshot
-from .balances import opening_balances
from .access import (
SELF_APPROVAL_OVERRIDE_PERMISSION,
can_self_approve_payroll,
has_payroll_permission,
has_simple_admin_access,
)
+from .adapter import build_core_request, employee_snapshot
+from .balances import opening_balances
+from .components import get_payroll_component
from .config import (
base_rate_code,
build_rules,
ruleset_not_effective_message,
ruleset_period_details,
)
-from .components import get_payroll_component
from .exceptions import PayrollOperationError, PayrollPermissionDenied
from .work_norm import calculate_period_target_points
diff --git a/backend/finance/payroll/work_norm.py b/backend/finance/payroll/work_norm.py
index a0915484..b6703bc9 100644
--- a/backend/finance/payroll/work_norm.py
+++ b/backend/finance/payroll/work_norm.py
@@ -11,11 +11,10 @@ from attendance.services import (
get_standard_work_schedule_payload,
)
from employees.constants import ACTIVATING_MARKER_ACTIONS
-from employees.services.personnel_state import resolve_employee_personnel_state
from employees.services.personnel_state import (
approved_absence_minutes_within_schedule,
+ resolve_employee_personnel_state,
)
-
from finance.models import PayrollPeriod, PayrollWorkSettings
QUANTUM = Decimal("0.0001")
@@ -141,7 +140,9 @@ def calculate_period_personnel_points(
if factor > 0:
attendance_factors.append(factor)
points = (
- period_target_points * sum(attendance_factors, Decimal("0")) / Decimal(len(workdates))
+ period_target_points
+ * sum(attendance_factors, Decimal("0"))
+ / Decimal(len(workdates))
if workdates
else Decimal("0")
).quantize(QUANTUM)
@@ -202,16 +203,15 @@ def _personnel_day_factor(state, schedule: dict) -> Decimal:
if not state.expects_attendance:
return Decimal("0")
try:
- expected_minutes = Decimal(str(schedule.get("expected_hours", 0))) * Decimal("60")
+ expected_minutes = Decimal(str(schedule.get("expected_hours", 0))) * Decimal(
+ "60"
+ )
except (TypeError, ValueError):
expected_minutes = Decimal("0")
if expected_minutes <= 0:
return Decimal("1")
- absence_minutes = Decimal(
- approved_absence_minutes_within_schedule(state, schedule)
- )
+ absence_minutes = Decimal(approved_absence_minutes_within_schedule(state, schedule))
return max(
- (expected_minutes - min(absence_minutes, expected_minutes))
- / expected_minutes,
+ (expected_minutes - min(absence_minutes, expected_minutes)) / expected_minutes,
Decimal("0"),
)
diff --git a/backend/finance/payroll/workbook_import.py b/backend/finance/payroll/workbook_import.py
index c14df3d5..8b109217 100644
--- a/backend/finance/payroll/workbook_import.py
+++ b/backend/finance/payroll/workbook_import.py
@@ -24,8 +24,8 @@ from finance.models import (
PayrollAuditEvent,
PayrollDailyWorkEntry,
PayrollPeriod,
- PayrollWorkSettings,
PayrollWorkRecord,
+ PayrollWorkSettings,
)
from .access import has_payroll_permission
diff --git a/backend/guests/apps.py b/backend/guests/apps.py
index 82ceede0..10308bf2 100644
--- a/backend/guests/apps.py
+++ b/backend/guests/apps.py
@@ -5,4 +5,3 @@ class GuestsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "guests"
verbose_name = "Гости"
-
diff --git a/backend/guests/constants.py b/backend/guests/constants.py
index 06e91d54..367eb42b 100644
--- a/backend/guests/constants.py
+++ b/backend/guests/constants.py
@@ -1,6 +1,5 @@
from django.db import models
-
GUEST_ID_MIN = 900_000_000_000_000
GUEST_ID_START = 900_000_000_000_001
GUEST_ID_MAX = 999_999_999_999_999
diff --git a/backend/guests/migrations/0001_initial.py b/backend/guests/migrations/0001_initial.py
index 5d7a529e..3df45bd5 100644
--- a/backend/guests/migrations/0001_initial.py
+++ b/backend/guests/migrations/0001_initial.py
@@ -1,7 +1,6 @@
+import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
-import django.db.models.deletion
-
GUEST_ID_START = 900_000_000_000_001
GUEST_ID_MAX = 999_999_999_999_999
@@ -31,7 +30,6 @@ def drop_guest_id_sequence(apps, schema_editor):
class Migration(migrations.Migration):
-
initial = True
dependencies = [
@@ -45,25 +43,98 @@ class Migration(migrations.Migration):
migrations.CreateModel(
name="Guest",
fields=[
- ("id", models.BigIntegerField(editable=False, primary_key=True, serialize=False)),
+ (
+ "id",
+ models.BigIntegerField(
+ editable=False, primary_key=True, serialize=False
+ ),
+ ),
("last_name", models.CharField(max_length=150, verbose_name="Фамилия")),
("first_name", models.CharField(max_length=150, verbose_name="Имя")),
- ("patronymic", models.CharField(blank=True, max_length=150, verbose_name="Отчество")),
- ("birth_date", models.DateField(blank=True, null=True, verbose_name="Дата рождения")),
- ("phone", models.CharField(blank=True, max_length=64, verbose_name="Телефон")),
- ("email", models.EmailField(blank=True, max_length=254, verbose_name="Email")),
- ("organization", models.CharField(blank=True, max_length=255, verbose_name="Организация")),
- ("position", models.CharField(blank=True, max_length=255, verbose_name="Должность")),
+ (
+ "patronymic",
+ models.CharField(
+ blank=True, max_length=150, verbose_name="Отчество"
+ ),
+ ),
+ (
+ "birth_date",
+ models.DateField(
+ blank=True, null=True, verbose_name="Дата рождения"
+ ),
+ ),
+ (
+ "phone",
+ models.CharField(blank=True, max_length=64, verbose_name="Телефон"),
+ ),
+ (
+ "email",
+ models.EmailField(blank=True, max_length=254, verbose_name="Email"),
+ ),
+ (
+ "organization",
+ models.CharField(
+ blank=True, max_length=255, verbose_name="Организация"
+ ),
+ ),
+ (
+ "position",
+ models.CharField(
+ blank=True, max_length=255, verbose_name="Должность"
+ ),
+ ),
("comment", models.TextField(blank=True, verbose_name="Комментарий")),
- ("is_active", models.BooleanField(default=True, verbose_name="Активен")),
- ("ldap_enabled", models.BooleanField(default=False, verbose_name="LDAP в Active OU")),
- ("ldap_username", models.CharField(blank=True, max_length=150, verbose_name="LDAP username")),
- ("ldap_upn", models.CharField(blank=True, max_length=255, verbose_name="LDAP UPN")),
- ("ldap_last_synced_at", models.DateTimeField(blank=True, null=True, verbose_name="Последняя LDAP синхронизация")),
- ("ldap_last_error", models.TextField(blank=True, verbose_name="Последняя LDAP ошибка")),
- ("created_at", models.DateTimeField(auto_now_add=True, verbose_name="Создано")),
- ("updated_at", models.DateTimeField(auto_now=True, verbose_name="Обновлено")),
- ("created_by", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="created_guests", to=settings.AUTH_USER_MODEL, verbose_name="Создал")),
+ (
+ "is_active",
+ models.BooleanField(default=True, verbose_name="Активен"),
+ ),
+ (
+ "ldap_enabled",
+ models.BooleanField(default=False, verbose_name="LDAP в Active OU"),
+ ),
+ (
+ "ldap_username",
+ models.CharField(
+ blank=True, max_length=150, verbose_name="LDAP username"
+ ),
+ ),
+ (
+ "ldap_upn",
+ models.CharField(
+ blank=True, max_length=255, verbose_name="LDAP UPN"
+ ),
+ ),
+ (
+ "ldap_last_synced_at",
+ models.DateTimeField(
+ blank=True,
+ null=True,
+ verbose_name="Последняя LDAP синхронизация",
+ ),
+ ),
+ (
+ "ldap_last_error",
+ models.TextField(blank=True, verbose_name="Последняя LDAP ошибка"),
+ ),
+ (
+ "created_at",
+ models.DateTimeField(auto_now_add=True, verbose_name="Создано"),
+ ),
+ (
+ "updated_at",
+ models.DateTimeField(auto_now=True, verbose_name="Обновлено"),
+ ),
+ (
+ "created_by",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="created_guests",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Создал",
+ ),
+ ),
],
options={
"verbose_name": "Гость",
@@ -74,55 +145,261 @@ class Migration(migrations.Migration):
migrations.CreateModel(
name="GuestVisit",
fields=[
- ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
- ("inviter_snapshot_name", models.CharField(blank=True, max_length=255, verbose_name="ФИО приглашающего на момент создания")),
- ("inviter_snapshot_email", models.EmailField(blank=True, max_length=254, verbose_name="Email приглашающего на момент создания")),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "inviter_snapshot_name",
+ models.CharField(
+ blank=True,
+ max_length=255,
+ verbose_name="ФИО приглашающего на момент создания",
+ ),
+ ),
+ (
+ "inviter_snapshot_email",
+ models.EmailField(
+ blank=True,
+ max_length=254,
+ verbose_name="Email приглашающего на момент создания",
+ ),
+ ),
("purpose", models.TextField(verbose_name="Цель приглашения")),
- ("visit_comment", models.TextField(blank=True, verbose_name="Комментарий заявителя")),
- ("admin_comment", models.TextField(blank=True, verbose_name="Комментарий администратора")),
- ("status", models.CharField(choices=[("draft", "Черновик"), ("pending", "На рассмотрении"), ("needs_info", "Требуется информация"), ("approved", "Одобрено"), ("rejected", "Отклонено"), ("cancelled", "Отменено"), ("expired", "Истекло"), ("revoked", "Отозвано")], db_index=True, default="draft", max_length=24, verbose_name="Статус")),
- ("access_starts_at", models.DateTimeField(blank=True, null=True, verbose_name="Начало доступа")),
- ("access_expires_at", models.DateTimeField(blank=True, null=True, verbose_name="Окончание доступа")),
- ("all_day", models.BooleanField(default=True, verbose_name="Полные сутки")),
- ("unlimited", models.BooleanField(default=False, verbose_name="Бессрочно")),
- ("submitted_at", models.DateTimeField(blank=True, null=True, verbose_name="Отправлено на рассмотрение")),
- ("decided_at", models.DateTimeField(blank=True, null=True, verbose_name="Решение принято")),
- ("decision_comment", models.TextField(blank=True, verbose_name="Комментарий к решению")),
+ (
+ "visit_comment",
+ models.TextField(blank=True, verbose_name="Комментарий заявителя"),
+ ),
+ (
+ "admin_comment",
+ models.TextField(
+ blank=True, verbose_name="Комментарий администратора"
+ ),
+ ),
+ (
+ "status",
+ models.CharField(
+ choices=[
+ ("draft", "Черновик"),
+ ("pending", "На рассмотрении"),
+ ("needs_info", "Требуется информация"),
+ ("approved", "Одобрено"),
+ ("rejected", "Отклонено"),
+ ("cancelled", "Отменено"),
+ ("expired", "Истекло"),
+ ("revoked", "Отозвано"),
+ ],
+ db_index=True,
+ default="draft",
+ max_length=24,
+ verbose_name="Статус",
+ ),
+ ),
+ (
+ "access_starts_at",
+ models.DateTimeField(
+ blank=True, null=True, verbose_name="Начало доступа"
+ ),
+ ),
+ (
+ "access_expires_at",
+ models.DateTimeField(
+ blank=True, null=True, verbose_name="Окончание доступа"
+ ),
+ ),
+ (
+ "all_day",
+ models.BooleanField(default=True, verbose_name="Полные сутки"),
+ ),
+ (
+ "unlimited",
+ models.BooleanField(default=False, verbose_name="Бессрочно"),
+ ),
+ (
+ "submitted_at",
+ models.DateTimeField(
+ blank=True, null=True, verbose_name="Отправлено на рассмотрение"
+ ),
+ ),
+ (
+ "decided_at",
+ models.DateTimeField(
+ blank=True, null=True, verbose_name="Решение принято"
+ ),
+ ),
+ (
+ "decision_comment",
+ models.TextField(blank=True, verbose_name="Комментарий к решению"),
+ ),
("cancelled_at", models.DateTimeField(blank=True, null=True)),
("cancel_reason", models.TextField(blank=True)),
("revoked_at", models.DateTimeField(blank=True, null=True)),
("revoke_reason", models.TextField(blank=True)),
("expired_at", models.DateTimeField(blank=True, null=True)),
("inviter_inactive", models.BooleanField(db_index=True, default=False)),
- ("created_at", models.DateTimeField(auto_now_add=True, verbose_name="Создано")),
- ("updated_at", models.DateTimeField(auto_now=True, verbose_name="Обновлено")),
- ("cancelled_by", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="cancelled_guest_visits", to=settings.AUTH_USER_MODEL)),
- ("decided_by", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="decided_guest_visits", to=settings.AUTH_USER_MODEL, verbose_name="Решение принял")),
- ("documents", models.ManyToManyField(blank=True, related_name="guest_visits", to="documents.document", verbose_name="Документы")),
- ("guest", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="visits", to="guests.guest", verbose_name="Гость")),
- ("host_department", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="guest_visits", to="employees.department", verbose_name="Отдел приглашающего")),
- ("inviter", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="guest_visits", to=settings.AUTH_USER_MODEL, verbose_name="Приглашающий")),
- ("revoked_by", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="revoked_guest_visits", to=settings.AUTH_USER_MODEL)),
+ (
+ "created_at",
+ models.DateTimeField(auto_now_add=True, verbose_name="Создано"),
+ ),
+ (
+ "updated_at",
+ models.DateTimeField(auto_now=True, verbose_name="Обновлено"),
+ ),
+ (
+ "cancelled_by",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="cancelled_guest_visits",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "decided_by",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="decided_guest_visits",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Решение принял",
+ ),
+ ),
+ (
+ "documents",
+ models.ManyToManyField(
+ blank=True,
+ related_name="guest_visits",
+ to="documents.document",
+ verbose_name="Документы",
+ ),
+ ),
+ (
+ "guest",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="visits",
+ to="guests.guest",
+ verbose_name="Гость",
+ ),
+ ),
+ (
+ "host_department",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="guest_visits",
+ to="employees.department",
+ verbose_name="Отдел приглашающего",
+ ),
+ ),
+ (
+ "inviter",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="guest_visits",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Приглашающий",
+ ),
+ ),
+ (
+ "revoked_by",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="revoked_guest_visits",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
],
options={
"verbose_name": "Гостевой визит",
"verbose_name_plural": "Гостевые визиты",
"ordering": ["-created_at"],
- "permissions": [("view_all_guestvisit", "Может просматривать все гостевые визиты"), ("decide_guestvisit", "Может принимать решения по гостевым визитам"), ("manage_guestaccount", "Может управлять гостевыми учетками")],
+ "permissions": [
+ ("view_all_guestvisit", "Может просматривать все гостевые визиты"),
+ (
+ "decide_guestvisit",
+ "Может принимать решения по гостевым визитам",
+ ),
+ ("manage_guestaccount", "Может управлять гостевыми учетками"),
+ ],
},
),
migrations.CreateModel(
name="GuestVisitEvent",
fields=[
- ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
- ("event_type", models.CharField(choices=[("created", "Создано"), ("submitted", "Отправлено"), ("needs_info_requested", "Запрошена информация"), ("info_provided", "Информация предоставлена"), ("approved", "Одобрено"), ("rejected", "Отклонено"), ("decision_changed", "Решение изменено"), ("cancelled", "Отменено"), ("revoked", "Отозвано"), ("expired", "Истекло"), ("ldap_created", "LDAP создан"), ("ldap_updated", "LDAP обновлен"), ("ldap_enabled", "LDAP перемещен в Active OU"), ("ldap_disabled", "LDAP перемещен в Deactivated OU"), ("ldap_failed", "Ошибка LDAP"), ("ldap_skipped", "LDAP пропущен"), ("document_attached", "Документ прикреплен"), ("document_removed", "Документ удален"), ("inviter_inactive_detected", "Приглашающий неактивен")], db_index=True, max_length=48, verbose_name="Тип события")),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "event_type",
+ models.CharField(
+ choices=[
+ ("created", "Создано"),
+ ("submitted", "Отправлено"),
+ ("needs_info_requested", "Запрошена информация"),
+ ("info_provided", "Информация предоставлена"),
+ ("approved", "Одобрено"),
+ ("rejected", "Отклонено"),
+ ("decision_changed", "Решение изменено"),
+ ("cancelled", "Отменено"),
+ ("revoked", "Отозвано"),
+ ("expired", "Истекло"),
+ ("ldap_created", "LDAP создан"),
+ ("ldap_updated", "LDAP обновлен"),
+ ("ldap_enabled", "LDAP перемещен в Active OU"),
+ ("ldap_disabled", "LDAP перемещен в Deactivated OU"),
+ ("ldap_failed", "Ошибка LDAP"),
+ ("ldap_skipped", "LDAP пропущен"),
+ ("document_attached", "Документ прикреплен"),
+ ("document_removed", "Документ удален"),
+ ("inviter_inactive_detected", "Приглашающий неактивен"),
+ ],
+ db_index=True,
+ max_length=48,
+ verbose_name="Тип события",
+ ),
+ ),
("from_status", models.CharField(blank=True, max_length=24)),
("to_status", models.CharField(blank=True, max_length=24)),
("comment", models.TextField(blank=True)),
("metadata", models.JSONField(blank=True, default=dict)),
("created_at", models.DateTimeField(auto_now_add=True)),
- ("actor", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="guest_visit_events", to=settings.AUTH_USER_MODEL, verbose_name="Автор события")),
- ("visit", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="events", to="guests.guestvisit", verbose_name="Визит")),
+ (
+ "actor",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="guest_visit_events",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Автор события",
+ ),
+ ),
+ (
+ "visit",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="events",
+ to="guests.guestvisit",
+ verbose_name="Визит",
+ ),
+ ),
],
options={
"verbose_name": "Событие гостевого визита",
@@ -130,16 +407,81 @@ class Migration(migrations.Migration):
"ordering": ["-created_at", "-id"],
},
),
- migrations.AddIndex(model_name="guest", index=models.Index(fields=["last_name", "first_name"], name="guest_name_idx")),
- migrations.AddIndex(model_name="guest", index=models.Index(fields=["email"], name="guest_email_idx")),
- migrations.AddIndex(model_name="guest", index=models.Index(fields=["phone"], name="guest_phone_idx")),
- migrations.AddIndex(model_name="guest", index=models.Index(fields=["ldap_enabled"], name="guest_ldap_enabled_idx")),
- migrations.AddConstraint(model_name="guest", constraint=models.CheckConstraint(condition=models.Q(("id__gte", GUEST_ID_START), ("id__lte", GUEST_ID_MAX)), name="guest_id_in_guest_range")),
- migrations.AddIndex(model_name="guestvisit", index=models.Index(fields=["status", "created_at"], name="guestvisit_status_created_idx")),
- migrations.AddIndex(model_name="guestvisit", index=models.Index(fields=["inviter", "created_at"], name="guestvisit_inviter_created_idx")),
- migrations.AddIndex(model_name="guestvisit", index=models.Index(fields=["access_starts_at", "access_expires_at"], name="guestvisit_access_idx")),
- migrations.AddIndex(model_name="guestvisit", index=models.Index(fields=["guest", "status"], name="guestvisit_guest_status_idx")),
- migrations.AddConstraint(model_name="guestvisit", constraint=models.CheckConstraint(condition=models.Q(("unlimited", True), ("access_expires_at__isnull", True), ("access_starts_at__isnull", True), ("access_starts_at__lt", models.F("access_expires_at")), _connector="OR"), name="guest_visit_access_range_valid")),
- migrations.AddIndex(model_name="guestvisitevent", index=models.Index(fields=["visit", "-created_at"], name="guestevent_visit_created_idx")),
- migrations.AddIndex(model_name="guestvisitevent", index=models.Index(fields=["event_type", "-created_at"], name="guestevent_type_created_idx")),
+ migrations.AddIndex(
+ model_name="guest",
+ index=models.Index(
+ fields=["last_name", "first_name"], name="guest_name_idx"
+ ),
+ ),
+ migrations.AddIndex(
+ model_name="guest",
+ index=models.Index(fields=["email"], name="guest_email_idx"),
+ ),
+ migrations.AddIndex(
+ model_name="guest",
+ index=models.Index(fields=["phone"], name="guest_phone_idx"),
+ ),
+ migrations.AddIndex(
+ model_name="guest",
+ index=models.Index(fields=["ldap_enabled"], name="guest_ldap_enabled_idx"),
+ ),
+ migrations.AddConstraint(
+ model_name="guest",
+ constraint=models.CheckConstraint(
+ condition=models.Q(
+ ("id__gte", GUEST_ID_START), ("id__lte", GUEST_ID_MAX)
+ ),
+ name="guest_id_in_guest_range",
+ ),
+ ),
+ migrations.AddIndex(
+ model_name="guestvisit",
+ index=models.Index(
+ fields=["status", "created_at"], name="guestvisit_status_created_idx"
+ ),
+ ),
+ migrations.AddIndex(
+ model_name="guestvisit",
+ index=models.Index(
+ fields=["inviter", "created_at"], name="guestvisit_inviter_created_idx"
+ ),
+ ),
+ migrations.AddIndex(
+ model_name="guestvisit",
+ index=models.Index(
+ fields=["access_starts_at", "access_expires_at"],
+ name="guestvisit_access_idx",
+ ),
+ ),
+ migrations.AddIndex(
+ model_name="guestvisit",
+ index=models.Index(
+ fields=["guest", "status"], name="guestvisit_guest_status_idx"
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="guestvisit",
+ constraint=models.CheckConstraint(
+ condition=models.Q(
+ ("unlimited", True),
+ ("access_expires_at__isnull", True),
+ ("access_starts_at__isnull", True),
+ ("access_starts_at__lt", models.F("access_expires_at")),
+ _connector="OR",
+ ),
+ name="guest_visit_access_range_valid",
+ ),
+ ),
+ migrations.AddIndex(
+ model_name="guestvisitevent",
+ index=models.Index(
+ fields=["visit", "-created_at"], name="guestevent_visit_created_idx"
+ ),
+ ),
+ migrations.AddIndex(
+ model_name="guestvisitevent",
+ index=models.Index(
+ fields=["event_type", "-created_at"], name="guestevent_type_created_idx"
+ ),
+ ),
]
diff --git a/backend/guests/migrations/0002_ldapguestuser.py b/backend/guests/migrations/0002_ldapguestuser.py
index edd059c6..680c1267 100644
--- a/backend/guests/migrations/0002_ldapguestuser.py
+++ b/backend/guests/migrations/0002_ldapguestuser.py
@@ -1,10 +1,10 @@
-import employees.ldap.mixins
import ldapdb.models.fields
from django.db import migrations, models
+import employees.ldap.mixins
+
class Migration(migrations.Migration):
-
dependencies = [
("guests", "0001_initial"),
]
diff --git a/backend/guests/migrations/0003_guest_avatar_ldapguestuser_thumbnail_photo.py b/backend/guests/migrations/0003_guest_avatar_ldapguestuser_thumbnail_photo.py
index 27f9a028..735df0ff 100644
--- a/backend/guests/migrations/0003_guest_avatar_ldapguestuser_thumbnail_photo.py
+++ b/backend/guests/migrations/0003_guest_avatar_ldapguestuser_thumbnail_photo.py
@@ -3,7 +3,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
("guests", "0002_ldapguestuser"),
]
diff --git a/backend/guests/migrations/0004_guest_blacklist_remove_ldap_enabled.py b/backend/guests/migrations/0004_guest_blacklist_remove_ldap_enabled.py
index 10710364..9528f0d6 100644
--- a/backend/guests/migrations/0004_guest_blacklist_remove_ldap_enabled.py
+++ b/backend/guests/migrations/0004_guest_blacklist_remove_ldap_enabled.py
@@ -20,7 +20,6 @@ def reverse_guest_state(apps, schema_editor):
class Migration(migrations.Migration):
-
dependencies = [
("guests", "0003_guest_avatar_ldapguestuser_thumbnail_photo"),
]
diff --git a/backend/guests/migrations/0005_guest_last_name_optional.py b/backend/guests/migrations/0005_guest_last_name_optional.py
index 87353ab0..78f263b6 100644
--- a/backend/guests/migrations/0005_guest_last_name_optional.py
+++ b/backend/guests/migrations/0005_guest_last_name_optional.py
@@ -2,7 +2,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
("guests", "0004_guest_blacklist_remove_ldap_enabled"),
]
diff --git a/backend/guests/migrations/0006_guest_document_folder_documents.py b/backend/guests/migrations/0006_guest_document_folder_documents.py
index 01d37feb..bb00e413 100644
--- a/backend/guests/migrations/0006_guest_document_folder_documents.py
+++ b/backend/guests/migrations/0006_guest_document_folder_documents.py
@@ -1,9 +1,8 @@
-from django.db import migrations, models
import django.db.models.deletion
+from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
("documents", "0015_delete_documentcomment"),
("filer", "0018_alter_file_options"),
diff --git a/backend/guests/migrations/0007_remove_guestvisit_admin_comment.py b/backend/guests/migrations/0007_remove_guestvisit_admin_comment.py
index 0d8fd1b5..29edc7cf 100644
--- a/backend/guests/migrations/0007_remove_guestvisit_admin_comment.py
+++ b/backend/guests/migrations/0007_remove_guestvisit_admin_comment.py
@@ -4,14 +4,13 @@ from django.db import migrations
class Migration(migrations.Migration):
-
dependencies = [
- ('guests', '0006_guest_document_folder_documents'),
+ ("guests", "0006_guest_document_folder_documents"),
]
operations = [
migrations.RemoveField(
- model_name='guestvisit',
- name='admin_comment',
+ model_name="guestvisit",
+ name="admin_comment",
),
]
diff --git a/backend/guests/migrations/0008_migrate_guest_comment_to_communications.py b/backend/guests/migrations/0008_migrate_guest_comment_to_communications.py
index 59cbe68a..3cf3a8ef 100644
--- a/backend/guests/migrations/0008_migrate_guest_comment_to_communications.py
+++ b/backend/guests/migrations/0008_migrate_guest_comment_to_communications.py
@@ -30,15 +30,18 @@ def migrate_guest_comments_forward(apps, schema_editor):
author_id = guest.created_by_id or fallback_author_id
if not author_id:
continue
- guest_name = " ".join(
- part
- for part in [
- guest.last_name,
- guest.first_name,
- guest.patronymic,
- ]
- if part
- ) or f"Гость {guest.id}"
+ guest_name = (
+ " ".join(
+ part
+ for part in [
+ guest.last_name,
+ guest.first_name,
+ guest.patronymic,
+ ]
+ if part
+ )
+ or f"Гость {guest.id}"
+ )
chat, _ = Chat.objects.get_or_create(
type="comments",
@@ -82,7 +85,6 @@ def migrate_guest_comments_forward(apps, schema_editor):
class Migration(migrations.Migration):
-
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("communications", "0039_alter_chat_context_object_id_bigint"),
diff --git a/backend/guests/models.py b/backend/guests/models.py
index df4ee3a4..900d3d82 100644
--- a/backend/guests/models.py
+++ b/backend/guests/models.py
@@ -9,7 +9,6 @@ from django.utils import timezone
from .constants import (
EDITABLE_BY_AUTHOR_STATUSES,
GUEST_ID_MAX,
- GUEST_ID_MIN,
GUEST_ID_START,
GuestVisitEventType,
GuestVisitStatus,
@@ -28,7 +27,9 @@ class Guest(models.Model):
birth_date = models.DateField("Дата рождения", null=True, blank=True)
phone = models.CharField("Телефон", max_length=64, blank=True)
email = models.EmailField("Email", blank=True)
- avatar = models.ImageField("Фото", upload_to="guests/avatars", blank=True, null=True)
+ avatar = models.ImageField(
+ "Фото", upload_to="guests/avatars", blank=True, null=True
+ )
organization = models.CharField("Организация", max_length=255, blank=True)
position = models.CharField("Должность", max_length=255, blank=True)
document_folder = models.ForeignKey(
@@ -89,16 +90,13 @@ class Guest(models.Model):
@property
def full_name(self) -> str:
return " ".join(
- part
- for part in [self.last_name, self.first_name, self.patronymic]
- if part
+ part for part in [self.last_name, self.first_name, self.patronymic] if part
).strip()
def _safe_document_folder_name(self) -> str:
raw = self.full_name or f"Гость {self.pk or ''}"
cleaned = "".join(
- "_" if char in {"/", "\\"} or ord(char) < 32 else char
- for char in raw
+ "_" if char in {"/", "\\"} or ord(char) < 32 else char for char in raw
).strip(" .")
return cleaned or f"Гость {self.pk}"
@@ -108,9 +106,11 @@ class Guest(models.Model):
candidate = base_name
counter = 2
- while Folder.objects.filter(parent=parent, name=candidate).exclude(
- id=exclude_id
- ).exists():
+ while (
+ Folder.objects.filter(parent=parent, name=candidate)
+ .exclude(id=exclude_id)
+ .exists()
+ ):
candidate = f"{base_name} ({counter})"
counter += 1
return candidate
@@ -305,7 +305,9 @@ class GuestVisit(models.Model):
fields=["access_starts_at", "access_expires_at"],
name="guestvisit_access_idx",
),
- models.Index(fields=["guest", "status"], name="guestvisit_guest_status_idx"),
+ models.Index(
+ fields=["guest", "status"], name="guestvisit_guest_status_idx"
+ ),
]
constraints = [
models.CheckConstraint(
@@ -348,10 +350,7 @@ class GuestVisit(models.Model):
return False
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
- return (
- self.inviter_id == user.id
- and self.status in EDITABLE_BY_AUTHOR_STATUSES
- )
+ return self.inviter_id == user.id and self.status in EDITABLE_BY_AUTHOR_STATUSES
class GuestVisitEvent(models.Model):
@@ -386,7 +385,9 @@ class GuestVisitEvent(models.Model):
verbose_name_plural = "События гостевых визитов"
ordering = ["-created_at", "-id"]
indexes = [
- models.Index(fields=["visit", "-created_at"], name="guestevent_visit_created_idx"),
+ models.Index(
+ fields=["visit", "-created_at"], name="guestevent_visit_created_idx"
+ ),
models.Index(
fields=["event_type", "-created_at"],
name="guestevent_type_created_idx",
diff --git a/backend/guests/permissions.py b/backend/guests/permissions.py
index e400dff4..4358a93b 100644
--- a/backend/guests/permissions.py
+++ b/backend/guests/permissions.py
@@ -1,6 +1,5 @@
from __future__ import annotations
-
GUEST_ADMIN_PERMS = (
"guests.view_all_guestvisit",
"guests.decide_guestvisit",
diff --git a/backend/guests/services.py b/backend/guests/services.py
index 80f68899..8124984b 100644
--- a/backend/guests/services.py
+++ b/backend/guests/services.py
@@ -29,8 +29,16 @@ def get_guest_admin_users():
User.objects.filter(
Q(is_staff=True)
| Q(is_superuser=True)
- | Q(user_permissions__codename__in=[p.split(".")[1] for p in GUEST_ADMIN_PERMS])
- | Q(groups__permissions__codename__in=[p.split(".")[1] for p in GUEST_ADMIN_PERMS])
+ | Q(
+ user_permissions__codename__in=[
+ p.split(".")[1] for p in GUEST_ADMIN_PERMS
+ ]
+ )
+ | Q(
+ groups__permissions__codename__in=[
+ p.split(".")[1] for p in GUEST_ADMIN_PERMS
+ ]
+ )
)
.filter(is_active=True)
.distinct()
@@ -139,10 +147,7 @@ def get_overlapping_guest_visit(
qs.filter(
Q(unlimited=True)
| (
- (
- Q(access_starts_at__isnull=True)
- | Q(access_starts_at__lt=expires_at)
- )
+ (Q(access_starts_at__isnull=True) | Q(access_starts_at__lt=expires_at))
& (
Q(access_expires_at__isnull=True)
| Q(access_expires_at__gt=starts_at)
@@ -182,9 +187,7 @@ def validate_guest_visit_period_not_past(
if unlimited:
return
if expires_at and expires_at <= timezone.now():
- raise ValueError(
- "Нельзя создать заявку с периодом доступа, который уже истек."
- )
+ raise ValueError("Нельзя создать заявку с периодом доступа, который уже истек.")
def has_unread_info_response_for_user(visit: GuestVisit, user) -> bool:
@@ -310,10 +313,14 @@ class GuestVisitWorkflow:
if visit.status == GuestVisitStatus.PENDING:
return visit
if visit.status not in {GuestVisitStatus.DRAFT, GuestVisitStatus.NEEDS_INFO}:
- raise ValueError("Отправить можно только черновик или заявку с уточнениями.")
+ raise ValueError(
+ "Отправить можно только черновик или заявку с уточнениями."
+ )
if not visit.purpose.strip():
raise ValueError("Укажите цель приглашения.")
- if not visit.unlimited and not (visit.access_starts_at and visit.access_expires_at):
+ if not visit.unlimited and not (
+ visit.access_starts_at and visit.access_expires_at
+ ):
raise ValueError("Укажите период доступа или бессрочный доступ.")
if (
getattr(settings, "GUESTS_REQUIRE_ID_DOCUMENT", False)
@@ -346,7 +353,9 @@ class GuestVisitWorkflow:
)
notify_guest_visit(
visit,
- "guest_visit_submitted" if event == GuestVisitEventType.SUBMITTED else "guest_visit_info_provided",
+ "guest_visit_submitted"
+ if event == GuestVisitEventType.SUBMITTED
+ else "guest_visit_info_provided",
get_guest_admin_users(),
actor=actor,
title="Гостевой визит на рассмотрении",
@@ -357,7 +366,9 @@ class GuestVisitWorkflow:
@classmethod
def request_info(cls, visit: GuestVisit, *, actor, comment: str) -> GuestVisit:
if visit.status != GuestVisitStatus.PENDING:
- raise ValueError("Запросить уточнения можно только по заявке на рассмотрении.")
+ raise ValueError(
+ "Запросить уточнения можно только по заявке на рассмотрении."
+ )
comment = comment.strip()
if not comment:
raise ValueError("Комментарий обязателен.")
@@ -387,7 +398,9 @@ class GuestVisitWorkflow:
@classmethod
def approve(cls, visit: GuestVisit, *, actor, comment: str = "") -> GuestVisit:
if visit.status not in {GuestVisitStatus.PENDING, GuestVisitStatus.REJECTED}:
- raise ValueError("Одобрить можно только заявку на рассмотрении или отклоненную заявку.")
+ raise ValueError(
+ "Одобрить можно только заявку на рассмотрении или отклоненную заявку."
+ )
if visit.inviter_inactive or not cls._inviter_is_active(visit):
raise ValueError("Нельзя одобрить заявку с неактивным приглашающим.")
validate_guest_visit_period_available(
@@ -436,7 +449,9 @@ class GuestVisitWorkflow:
GuestVisitStatus.NEEDS_INFO,
GuestVisitStatus.APPROVED,
}:
- raise ValueError("Отклонить можно только рассматриваемую или одобренную заявку.")
+ raise ValueError(
+ "Отклонить можно только рассматриваемую или одобренную заявку."
+ )
old_status = visit.status
visit.decided_by = actor
visit.decided_at = timezone.now()
@@ -503,7 +518,9 @@ class GuestVisitWorkflow:
}:
raise ValueError("Вернуть в работу можно только закрытую заявку.")
if visit.inviter_inactive or not cls._inviter_is_active(visit):
- raise ValueError("Нельзя вернуть в работу заявку с неактивным приглашающим.")
+ raise ValueError(
+ "Нельзя вернуть в работу заявку с неактивным приглашающим."
+ )
validate_guest_visit_period_available(
guest=visit.guest,
starts_at=visit.access_starts_at,
@@ -625,20 +642,24 @@ class GuestLdapService:
def _should_be_in_active_ou(self, guest: Guest) -> bool:
if guest.is_blacklisted:
return False
- return guest.visits.filter(
- status=GuestVisitStatus.APPROVED,
- inviter_inactive=False,
- inviter__is_active=True,
- ).filter(
- Q(unlimited=True)
- | (
- Q(access_starts_at__lte=timezone.now())
- & (
- Q(access_expires_at__isnull=True)
- | Q(access_expires_at__gt=timezone.now())
+ return (
+ guest.visits.filter(
+ status=GuestVisitStatus.APPROVED,
+ inviter_inactive=False,
+ inviter__is_active=True,
+ )
+ .filter(
+ Q(unlimited=True)
+ | (
+ Q(access_starts_at__lte=timezone.now())
+ & (
+ Q(access_expires_at__isnull=True)
+ | Q(access_expires_at__gt=timezone.now())
+ )
)
)
- ).exists()
+ .exists()
+ )
def _get_state_dn(self, guest: Guest) -> str:
return (
@@ -764,7 +785,7 @@ class GuestLdapService:
visit,
GuestVisitEventType.LDAP_FAILED,
comment=str(exc),
- )
+ )
if enqueue_on_error:
self.enqueue_guest_sync(guest, operation="guest_sync")
notify_visit = visit or guest.visits.order_by("-created_at").first()
diff --git a/backend/manage.py b/backend/manage.py
index 93246a42..03a157f2 100644
--- a/backend/manage.py
+++ b/backend/manage.py
@@ -1,12 +1,13 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
+
import os
import sys
def main():
"""Run administrative tasks."""
- os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eusrr_backend.settings')
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "eusrr_backend.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
@@ -18,5 +19,5 @@ def main():
execute_from_command_line(sys.argv)
-if __name__ == '__main__':
+if __name__ == "__main__":
main()
diff --git a/backend/notifications/admin.py b/backend/notifications/admin.py
index b78dd5a5..b7fe4608 100644
--- a/backend/notifications/admin.py
+++ b/backend/notifications/admin.py
@@ -5,6 +5,7 @@ Admin для упрощенной системы уведомлений
from django.contrib import admin
from django.utils.html import format_html
from django.utils.safestring import mark_safe
+
from .models import (
Notification,
NotificationDeliveryRouting,
@@ -184,9 +185,7 @@ class NotificationAdmin(admin.ModelAdmin):
notification.mark_as_read()
count += 1
- self.message_user(
- request, f"Отмечено как прочитанные: {count} уведомлений"
- )
+ self.message_user(request, f"Отмечено как прочитанные: {count} уведомлений")
@admin.action(description="Отметить как непрочитанные")
def mark_as_unread(self, request, queryset):
@@ -197,17 +196,13 @@ class NotificationAdmin(admin.ModelAdmin):
notification.mark_as_unread()
count += 1
- self.message_user(
- request, f"Отмечено как непрочитанные: {count} уведомлений"
- )
+ self.message_user(request, f"Отмечено как непрочитанные: {count} уведомлений")
@admin.action(description="Пометить как удаленные")
def mark_as_deleted(self, request, queryset):
"""Пометить как удаленные (мягкое удаление)"""
count = queryset.filter(deleted=False).update(deleted=True)
- self.message_user(
- request, f"Помечено как удаленные: {count} уведомлений"
- )
+ self.message_user(request, f"Помечено как удаленные: {count} уведомлений")
@admin.action(description="Восстановить удаленные")
def mark_as_not_deleted(self, request, queryset):
diff --git a/backend/notifications/api/__init__.py b/backend/notifications/api/__init__.py
index d1c4b5b6..2ba4899e 100644
--- a/backend/notifications/api/__init__.py
+++ b/backend/notifications/api/__init__.py
@@ -16,4 +16,4 @@ Endpoints:
- DELETE /api/v1/notifications/push/unsubscribe/ - отписаться от push
"""
-__all__ = ['views', 'urls']
+__all__ = ["views", "urls"]
diff --git a/backend/notifications/api/serializers.py b/backend/notifications/api/serializers.py
index 5048c523..417c6899 100644
--- a/backend/notifications/api/serializers.py
+++ b/backend/notifications/api/serializers.py
@@ -17,7 +17,8 @@ class NotificationItemSerializer(serializers.Serializer):
created_at = serializers.DateTimeField()
category = serializers.CharField()
action_url = serializers.CharField(
- required=False, allow_blank=True, allow_null=True)
+ required=False, allow_blank=True, allow_null=True
+ )
data = serializers.JSONField(required=False, allow_null=True)
verb = serializers.CharField()
description = serializers.CharField()
diff --git a/backend/notifications/api/urls.py b/backend/notifications/api/urls.py
index 3008d9ab..2df2fc01 100644
--- a/backend/notifications/api/urls.py
+++ b/backend/notifications/api/urls.py
@@ -5,6 +5,7 @@ URL routing для API v2 уведомлений.
"""
from django.urls import path
+
from . import views
app_name = "notifications_api_v1"
@@ -25,9 +26,7 @@ urlpatterns = [
path("/", views.delete_notification, name="delete"),
# Массовые операции
path("read-all/", views.mark_all_as_read, name="mark_all_read"),
- path(
- "category/read/", views.mark_category_as_read, name="mark_category_read"
- ),
+ path("category/read/", views.mark_category_as_read, name="mark_category_read"),
path("delete-all-read/", views.delete_all_read, name="delete_all_read"),
# Настройки каналов
path("preferences/", views.channel_preferences, name="preferences"),
diff --git a/backend/notifications/api/views.py b/backend/notifications/api/views.py
index ddb782a0..f012945a 100644
--- a/backend/notifications/api/views.py
+++ b/backend/notifications/api/views.py
@@ -7,17 +7,18 @@ API endpoints для системы уведомлений v2.0
- verb вместо category/type
- UserChannelPreferences для настроек каналов
"""
+
+import uuid
from datetime import datetime
from io import BytesIO
-import uuid
-from drf_spectacular.utils import OpenApiParameter, extend_schema
from django.core import signing
from django.core.files.storage import default_storage
from django.db import transaction
from django.db.models import Count, Q
from django.http import HttpResponse
from django.utils import timezone
+from drf_spectacular.utils import OpenApiParameter, extend_schema
from PIL import Image, ImageDraw, ImageOps, UnidentifiedImageError
from rest_framework import status
from rest_framework.decorators import (
@@ -36,29 +37,31 @@ from communications.services import (
ChatReadMarkerService,
)
+from ..cache import (
+ get_unread_count as get_cached_unread_count,
+)
+from ..cache import (
+ get_unread_summary,
+ invalidate_unread_summary,
+)
from ..models import (
Notification,
NotificationDeliveryRouting,
NotificationInstallation,
UserChannelPreferences,
)
-from ..routing import NOTIFICATION_CATEGORIES, normalize_routes
-from ..cache import (
- get_unread_count as get_cached_unread_count,
- get_unread_summary,
- invalidate_unread_summary,
-)
-from ..realtime import (
- send_notification_read_event,
- send_notifications_deleted_event,
- send_notifications_read_all_event,
-)
-from ..push_lifecycle import dismiss_notifications_push
from ..push_actions import (
load_push_avatar_token,
load_push_read_token,
load_push_reply_token,
)
+from ..push_lifecycle import dismiss_notifications_push
+from ..realtime import (
+ send_notification_read_event,
+ send_notifications_deleted_event,
+ send_notifications_read_all_event,
+)
+from ..routing import NOTIFICATION_CATEGORIES, normalize_routes
from .serializers import (
ChannelPreferencesSerializer,
CountResponseSerializer,
@@ -71,9 +74,9 @@ from .serializers import (
NotificationsListResponseSerializer,
StatusResponseSerializer,
SubscribePushRequestSerializer,
+ UnreadSummaryResponseSerializer,
UnsubscribePushRequestSerializer,
UpdateChannelPreferencesSerializer,
- UnreadSummaryResponseSerializer,
VapidPublicKeyResponseSerializer,
VerbTypesResponseSerializer,
)
@@ -82,19 +85,19 @@ from .serializers import (
def _format_time_or_none(value):
if value is None:
return None
- return value.strftime('%H:%M')
+ return value.strftime("%H:%M")
def _parse_time_or_none(value):
if not value:
return None
- return datetime.strptime(value, '%H:%M').time()
+ return datetime.strptime(value, "%H:%M").time()
def _actor_avatar_url(actor):
if actor is None:
return None
- avatar = getattr(actor, 'avatar', None)
+ avatar = getattr(actor, "avatar", None)
if not avatar:
return None
try:
@@ -115,7 +118,7 @@ def _actor_avatar_url(actor):
],
responses=NotificationsListResponseSerializer,
)
-@api_view(['GET'])
+@api_view(["GET"])
@permission_classes([IsAuthenticated])
def get_notifications(request):
"""
@@ -129,28 +132,33 @@ def get_notifications(request):
- search: поиск по описанию
"""
try:
- page = int(request.GET.get('page', 1))
+ page = int(request.GET.get("page", 1))
except (ValueError, TypeError):
page = 1
try:
- page_size = int(request.GET.get('page_size', 20))
+ page_size = int(request.GET.get("page_size", 20))
except (ValueError, TypeError):
page_size = 20
page = max(page, 1)
page_size = min(max(page_size, 1), 100)
- verb = request.GET.get('verb')
- unread_only = request.GET.get('unread_only', 'false').lower() == 'true'
- search = request.GET.get('search', '').strip()
+ verb = request.GET.get("verb")
+ unread_only = request.GET.get("unread_only", "false").lower() == "true"
+ search = request.GET.get("search", "").strip()
# Базовый queryset
- queryset = Notification.objects.filter(
- recipient=request.user,
- deleted=False,
- ).select_related(
- 'actor_content_type',
- ).prefetch_related(
- 'actor',
- ).order_by('-timestamp')
+ queryset = (
+ Notification.objects.filter(
+ recipient=request.user,
+ deleted=False,
+ )
+ .select_related(
+ "actor_content_type",
+ )
+ .prefetch_related(
+ "actor",
+ )
+ .order_by("-timestamp")
+ )
# Фильтры
if verb:
@@ -170,48 +178,50 @@ def get_notifications(request):
# Сериализация (совместимость с frontend)
data = {
- 'total': total,
- 'page': page,
- 'page_size': page_size,
- 'unread_count': total if unread_only else queryset.filter(unread=True).count(),
- 'notifications': [
+ "total": total,
+ "page": page,
+ "page_size": page_size,
+ "unread_count": total if unread_only else queryset.filter(unread=True).count(),
+ "notifications": [
{
- 'id': n.id,
+ "id": n.id,
# Frontend ожидает title, message, is_read, created_at
- 'title': (
- n.data['title']
- if n.data and 'title' in n.data
- else n.verb.replace('_', ' ').title()
+ "title": (
+ n.data["title"]
+ if n.data and "title" in n.data
+ else n.verb.replace("_", " ").title()
),
- 'message': n.description,
- 'short_message': (
- n.description[:100] + '...'
+ "message": n.description,
+ "short_message": (
+ n.description[:100] + "..."
if len(n.description) > 100
else n.description
),
- 'is_read': not n.unread,
- 'created_at': n.timestamp.isoformat(),
- 'category': n.verb,
- 'action_url': n.action_url,
- 'data': n.data,
+ "is_read": not n.unread,
+ "created_at": n.timestamp.isoformat(),
+ "category": n.verb,
+ "action_url": n.action_url,
+ "data": n.data,
# Дополнительные поля для обратной совместимости
- 'verb': n.verb,
- 'description': n.description,
- 'actor': {
- 'id': n.actor.id if n.actor else None,
- 'name': str(n.actor) if n.actor else 'Система',
- 'type': n.actor_content_type.model if n.actor else None,
- 'avatar': _actor_avatar_url(n.actor),
- } if n.actor else None,
- 'unread': n.unread,
- 'public': n.public,
- 'deleted': n.deleted,
- 'emailed': n.emailed,
- 'timestamp': n.timestamp.isoformat(),
- 'timesince': n.timesince,
+ "verb": n.verb,
+ "description": n.description,
+ "actor": {
+ "id": n.actor.id if n.actor else None,
+ "name": str(n.actor) if n.actor else "Система",
+ "type": n.actor_content_type.model if n.actor else None,
+ "avatar": _actor_avatar_url(n.actor),
+ }
+ if n.actor
+ else None,
+ "unread": n.unread,
+ "public": n.public,
+ "deleted": n.deleted,
+ "emailed": n.emailed,
+ "timestamp": n.timestamp.isoformat(),
+ "timesince": n.timesince,
}
for n in notifications
- ]
+ ],
}
return Response(data)
@@ -222,13 +232,13 @@ def get_notifications(request):
summary="Получить количество непрочитанных уведомлений",
responses=CountResponseSerializer,
)
-@api_view(['GET'])
+@api_view(["GET"])
@permission_classes([IsAuthenticated])
def get_unread_count(request):
"""Получить количество непрочитанных уведомлений"""
count = get_cached_unread_count(request.user.id)
- return Response({'count': count})
+ return Response({"count": count})
@extend_schema(
@@ -236,7 +246,7 @@ def get_unread_count(request):
summary="Получить сводку непрочитанных уведомлений по типам",
responses=UnreadSummaryResponseSerializer,
)
-@api_view(['GET'])
+@api_view(["GET"])
@permission_classes([IsAuthenticated])
def get_unread_summary_view(request):
"""Получить cached-сводку непрочитанных уведомлений по verb."""
@@ -249,14 +259,13 @@ def get_unread_summary_view(request):
request=None,
responses={200: StatusResponseSerializer, 404: StatusResponseSerializer},
)
-@api_view(['POST'])
+@api_view(["POST"])
@permission_classes([IsAuthenticated])
def mark_as_read(request, notification_id):
"""Отметить уведомление как прочитанное"""
try:
notification = Notification.objects.get(
- id=notification_id,
- recipient=request.user
+ id=notification_id, recipient=request.user
)
was_unread = notification.unread
notification.mark_as_read()
@@ -269,15 +278,12 @@ def mark_as_read(request, notification_id):
notification_ids=[notification_id],
)
- return Response({
- 'status': 'success',
- 'notification_id': notification_id
- })
+ return Response({"status": "success", "notification_id": notification_id})
except Notification.DoesNotExist:
return Response(
- {'status': 'error', 'message': 'Notification not found'},
- status=status.HTTP_404_NOT_FOUND
+ {"status": "error", "message": "Notification not found"},
+ status=status.HTTP_404_NOT_FOUND,
)
@@ -287,29 +293,25 @@ def mark_as_read(request, notification_id):
request=None,
responses={200: StatusResponseSerializer, 404: StatusResponseSerializer},
)
-@api_view(['POST'])
+@api_view(["POST"])
@permission_classes([IsAuthenticated])
def mark_as_unread(request, notification_id):
"""Отметить уведомление как непрочитанное"""
try:
notification = Notification.objects.get(
- id=notification_id,
- recipient=request.user
+ id=notification_id, recipient=request.user
)
was_read = not notification.unread
notification.mark_as_unread()
if was_read:
invalidate_unread_summary(request.user.id)
- return Response({
- 'status': 'success',
- 'notification_id': notification_id
- })
+ return Response({"status": "success", "notification_id": notification_id})
except Notification.DoesNotExist:
return Response(
- {'status': 'error', 'message': 'Notification not found'},
- status=status.HTTP_404_NOT_FOUND
+ {"status": "error", "message": "Notification not found"},
+ status=status.HTTP_404_NOT_FOUND,
)
@@ -319,11 +321,11 @@ def mark_as_unread(request, notification_id):
request=MarkAllAsReadRequestSerializer,
responses=StatusResponseSerializer,
)
-@api_view(['POST'])
+@api_view(["POST"])
@permission_classes([IsAuthenticated])
def mark_all_as_read(request):
"""Отметить все уведомления как прочитанные"""
- verb = request.data.get('verb') # Опционально - только определенный тип
+ verb = request.data.get("verb") # Опционально - только определенный тип
queryset = Notification.objects.filter(
recipient=request.user,
@@ -334,7 +336,7 @@ def mark_all_as_read(request):
if verb:
queryset = queryset.filter(verb=verb)
- notification_ids = list(queryset.values_list('id', flat=True))
+ notification_ids = list(queryset.values_list("id", flat=True))
count = queryset.mark_all_as_read()
if count:
invalidate_unread_summary(request.user.id)
@@ -350,10 +352,7 @@ def mark_all_as_read(request):
notification_ids=notification_ids,
)
- return Response({
- 'status': 'success',
- 'count': count
- })
+ return Response({"status": "success", "count": count})
@extend_schema(
@@ -362,7 +361,7 @@ def mark_all_as_read(request):
request=MarkCategoryAsReadRequestSerializer,
responses={200: StatusResponseSerializer, 400: StatusResponseSerializer},
)
-@api_view(['POST'])
+@api_view(["POST"])
@permission_classes([IsAuthenticated])
def mark_category_as_read(request):
"""
@@ -379,12 +378,12 @@ def mark_category_as_read(request):
"category": "Заявки"
}
"""
- verbs = request.data.get('verbs', [])
+ verbs = request.data.get("verbs", [])
if not verbs or not isinstance(verbs, list):
return Response(
- {'status': 'error', 'message': 'verbs array is required'},
- status=status.HTTP_400_BAD_REQUEST
+ {"status": "error", "message": "verbs array is required"},
+ status=status.HTTP_400_BAD_REQUEST,
)
# Отмечаем все уведомления с этими verb'ами
@@ -395,7 +394,7 @@ def mark_category_as_read(request):
verb__in=verbs,
)
- notification_ids = list(queryset.values_list('id', flat=True))
+ notification_ids = list(queryset.values_list("id", flat=True))
count = queryset.mark_all_as_read()
if count:
invalidate_unread_summary(request.user.id)
@@ -404,18 +403,14 @@ def mark_category_as_read(request):
send_notifications_read_all_event(
request.user.id,
notification_ids,
- category=request.data.get('category'),
+ category=request.data.get("category"),
)
dismiss_notifications_push(
user_id=request.user.id,
notification_ids=notification_ids,
)
- return Response({
- 'status': 'success',
- 'verbs': verbs,
- 'count': count
- })
+ return Response({"status": "success", "verbs": verbs, "count": count})
@extend_schema(
@@ -423,18 +418,17 @@ def mark_category_as_read(request):
summary="Удалить уведомление",
responses={200: StatusResponseSerializer, 404: StatusResponseSerializer},
)
-@api_view(['DELETE'])
+@api_view(["DELETE"])
@permission_classes([IsAuthenticated])
def delete_notification(request, notification_id):
"""Удалить уведомление (soft delete)"""
try:
notification = Notification.objects.get(
- id=notification_id,
- recipient=request.user
+ id=notification_id, recipient=request.user
)
was_unread = notification.unread
notification.deleted = True
- notification.save(update_fields=['deleted'])
+ notification.save(update_fields=["deleted"])
if was_unread:
invalidate_unread_summary(request.user.id)
dismiss_notifications_push(
@@ -443,12 +437,12 @@ def delete_notification(request, notification_id):
)
send_notifications_deleted_event(request.user.id, [notification_id])
- return Response({'status': 'success'})
+ return Response({"status": "success"})
except Notification.DoesNotExist:
return Response(
- {'status': 'error', 'message': 'Notification not found'},
- status=status.HTTP_404_NOT_FOUND
+ {"status": "error", "message": "Notification not found"},
+ status=status.HTTP_404_NOT_FOUND,
)
@@ -457,7 +451,7 @@ def delete_notification(request, notification_id):
summary="Удалить все прочитанные уведомления",
responses=StatusResponseSerializer,
)
-@api_view(['DELETE'])
+@api_view(["DELETE"])
@permission_classes([IsAuthenticated])
def delete_all_read(request):
"""Удалить все прочитанные уведомления"""
@@ -466,7 +460,7 @@ def delete_all_read(request):
unread=False,
deleted=False,
)
- notification_ids = list(queryset.values_list('id', flat=True))
+ notification_ids = list(queryset.values_list("id", flat=True))
count = queryset.update(deleted=True)
dismiss_notifications_push(
user_id=request.user.id,
@@ -475,10 +469,7 @@ def delete_all_read(request):
if notification_ids:
send_notifications_deleted_event(request.user.id, notification_ids)
- return Response({
- 'status': 'success',
- 'count': count
- })
+ return Response({"status": "success", "count": count})
@extend_schema(
@@ -486,7 +477,7 @@ def delete_all_read(request):
summary="Получить статистику по типам уведомлений",
responses=VerbTypesResponseSerializer,
)
-@api_view(['GET'])
+@api_view(["GET"])
@permission_classes([IsAuthenticated])
def get_verb_types(request):
"""
@@ -494,42 +485,44 @@ def get_verb_types(request):
Возвращает статистику по типам уведомлений пользователя.
"""
- verb_stats = Notification.objects.filter(
- recipient=request.user,
- deleted=False,
- ).values('verb').annotate(
- total=Count('id'),
- unread=Count('id', filter=Q(unread=True))
- ).order_by('-total')
+ verb_stats = (
+ Notification.objects.filter(
+ recipient=request.user,
+ deleted=False,
+ )
+ .values("verb")
+ .annotate(total=Count("id"), unread=Count("id", filter=Q(unread=True)))
+ .order_by("-total")
+ )
# Возвращаем verb как есть - фронтенд сам сделает перевод
data = [
{
- 'verb': item['verb'],
- 'name': item['verb'], # Фронтенд переведет
- 'total': item['total'],
- 'unread': item['unread'],
+ "verb": item["verb"],
+ "name": item["verb"], # Фронтенд переведет
+ "total": item["total"],
+ "unread": item["unread"],
}
for item in verb_stats
]
- return Response({'verb_types': data})
+ return Response({"verb_types": data})
@extend_schema(
- methods=['GET'],
+ methods=["GET"],
tags=["Notifications"],
summary="Получить настройки каналов уведомлений",
responses=ChannelPreferencesSerializer,
)
@extend_schema(
- methods=['PUT'],
+ methods=["PUT"],
tags=["Notifications"],
summary="Обновить настройки каналов уведомлений",
request=UpdateChannelPreferencesSerializer,
responses=StatusResponseSerializer,
)
-@api_view(['GET', 'PUT'])
+@api_view(["GET", "PUT"])
@permission_classes([IsAuthenticated])
def channel_preferences(request):
"""
@@ -549,71 +542,62 @@ def channel_preferences(request):
- disabled_verbs: Список отключенных типов (array)
"""
# Получаем или создаем настройки
- prefs, _created = UserChannelPreferences.objects.get_or_create(
- user=request.user
- )
+ prefs, _created = UserChannelPreferences.objects.get_or_create(user=request.user)
def _normalize_email_frequency(value):
- if value == 'disabled':
- return 'never'
+ if value == "disabled":
+ return "never"
return value
- if request.method == 'GET':
+ if request.method == "GET":
data = {
- 'web_enabled': prefs.web_enabled,
- 'email_enabled': prefs.email_enabled,
- 'email_frequency': _normalize_email_frequency(
- prefs.email_frequency
- ),
- 'push_enabled': prefs.push_enabled,
- 'dnd_enabled': prefs.dnd_enabled,
- 'dnd_start_time': _format_time_or_none(prefs.dnd_start_time),
- 'dnd_end_time': _format_time_or_none(prefs.dnd_end_time),
- 'disabled_verbs': prefs.disabled_verbs,
+ "web_enabled": prefs.web_enabled,
+ "email_enabled": prefs.email_enabled,
+ "email_frequency": _normalize_email_frequency(prefs.email_frequency),
+ "push_enabled": prefs.push_enabled,
+ "dnd_enabled": prefs.dnd_enabled,
+ "dnd_start_time": _format_time_or_none(prefs.dnd_start_time),
+ "dnd_end_time": _format_time_or_none(prefs.dnd_end_time),
+ "disabled_verbs": prefs.disabled_verbs,
}
return Response(data)
- if request.method == 'PUT':
+ if request.method == "PUT":
# Обновляем настройки
- if 'web_enabled' in request.data:
- prefs.web_enabled = request.data.get('web_enabled')
+ if "web_enabled" in request.data:
+ prefs.web_enabled = request.data.get("web_enabled")
- if 'email_enabled' in request.data:
- prefs.email_enabled = request.data.get('email_enabled')
+ if "email_enabled" in request.data:
+ prefs.email_enabled = request.data.get("email_enabled")
- if 'email_frequency' in request.data:
- frequency = _normalize_email_frequency(
- request.data.get('email_frequency')
- )
- if frequency in ['instant', 'daily', 'weekly', 'never']:
+ if "email_frequency" in request.data:
+ frequency = _normalize_email_frequency(request.data.get("email_frequency"))
+ if frequency in ["instant", "daily", "weekly", "never"]:
prefs.email_frequency = frequency
- if 'push_enabled' in request.data:
- prefs.push_enabled = request.data.get('push_enabled')
+ if "push_enabled" in request.data:
+ prefs.push_enabled = request.data.get("push_enabled")
- if 'dnd_enabled' in request.data:
- prefs.dnd_enabled = request.data.get('dnd_enabled')
+ if "dnd_enabled" in request.data:
+ prefs.dnd_enabled = request.data.get("dnd_enabled")
- if 'dnd_start_time' in request.data:
- time_str = request.data.get('dnd_start_time')
+ if "dnd_start_time" in request.data:
+ time_str = request.data.get("dnd_start_time")
prefs.dnd_start_time = _parse_time_or_none(time_str)
- if 'dnd_end_time' in request.data:
- time_str = request.data.get('dnd_end_time')
+ if "dnd_end_time" in request.data:
+ time_str = request.data.get("dnd_end_time")
prefs.dnd_end_time = _parse_time_or_none(time_str)
- if 'disabled_verbs' in request.data:
- prefs.disabled_verbs = request.data.get('disabled_verbs')
+ if "disabled_verbs" in request.data:
+ prefs.disabled_verbs = request.data.get("disabled_verbs")
prefs.save()
- return Response({
- 'status': 'success',
- 'message': 'Preferences updated'
- })
+ return Response({"status": "success", "message": "Preferences updated"})
return Response(
- {'status': 'error', 'message': 'Method not allowed'},
+ {"status": "error", "message": "Method not allowed"},
status=status.HTTP_405_METHOD_NOT_ALLOWED,
)
@@ -626,7 +610,7 @@ def channel_preferences(request):
503: VapidPublicKeyResponseSerializer,
},
)
-@api_view(['GET'])
+@api_view(["GET"])
@permission_classes([IsAuthenticated])
def get_vapid_public_key(request):
"""
@@ -637,33 +621,30 @@ def get_vapid_public_key(request):
"""
from django.conf import settings
- vapid_key = getattr(settings, 'VAPID_PUBLIC_KEY', None)
+ vapid_key = getattr(settings, "VAPID_PUBLIC_KEY", None)
if not vapid_key:
return Response(
{
- 'status': 'error',
- 'message': 'VAPID keys not configured',
- 'vapid_public_key': None
+ "status": "error",
+ "message": "VAPID keys not configured",
+ "vapid_public_key": None,
},
- status=status.HTTP_503_SERVICE_UNAVAILABLE
+ status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
- return Response({
- 'status': 'success',
- 'vapid_public_key': vapid_key
- })
+ return Response({"status": "success", "vapid_public_key": vapid_key})
-@api_view(['POST'])
+@api_view(["POST"])
@authentication_classes([])
@permission_classes([AllowAny])
def push_mark_chat_read(request):
"""Mark the pushed chat message as read using a short-lived signed token."""
- token = request.data.get('token')
+ token = request.data.get("token")
if not isinstance(token, str) or not token:
return Response(
- {'status': 'error', 'message': 'token is required'},
+ {"status": "error", "message": "token is required"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -671,24 +652,24 @@ def push_mark_chat_read(request):
payload = load_push_read_token(token)
except signing.SignatureExpired:
return Response(
- {'status': 'error', 'message': 'token expired'},
+ {"status": "error", "message": "token expired"},
status=status.HTTP_410_GONE,
)
except signing.BadSignature:
return Response(
- {'status': 'error', 'message': 'invalid token'},
+ {"status": "error", "message": "invalid token"},
status=status.HTTP_400_BAD_REQUEST,
)
try:
- notification = Notification.objects.select_related('recipient').get(
- id=int(payload['notification_id']),
- recipient_id=int(payload['user_id']),
+ notification = Notification.objects.select_related("recipient").get(
+ id=int(payload["notification_id"]),
+ recipient_id=int(payload["user_id"]),
deleted=False,
)
- chat = Chat.objects.get(id=int(payload['chat_id']))
+ chat = Chat.objects.get(id=int(payload["chat_id"]))
message = Message.objects.get(
- id=int(payload['message_id']),
+ id=int(payload["message_id"]),
chat=chat,
is_deleted=False,
)
@@ -701,19 +682,16 @@ def push_mark_chat_read(request):
Message.DoesNotExist,
):
return Response(
- {'status': 'error', 'message': 'push target not found'},
+ {"status": "error", "message": "push target not found"},
status=status.HTTP_404_NOT_FOUND,
)
- notification_data = (
- notification.data if isinstance(notification.data, dict) else {}
- )
- if (
- str(notification_data.get('chat_id')) != str(chat.id)
- or str(notification_data.get('message_id')) != str(message.id)
- ):
+ notification_data = notification.data if isinstance(notification.data, dict) else {}
+ if str(notification_data.get("chat_id")) != str(chat.id) or str(
+ notification_data.get("message_id")
+ ) != str(message.id):
return Response(
- {'status': 'error', 'message': 'push target mismatch'},
+ {"status": "error", "message": "push target mismatch"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -726,29 +704,31 @@ def push_mark_chat_read(request):
user=notification.recipient,
chat_id=chat.id,
)
- return Response({
- 'status': 'success',
- 'chat_id': chat.id,
- 'message_id': message.id,
- 'notifications_read': read_result.count,
- })
+ return Response(
+ {
+ "status": "success",
+ "chat_id": chat.id,
+ "message_id": message.id,
+ "notifications_read": read_result.count,
+ }
+ )
-@api_view(['POST'])
+@api_view(["POST"])
@authentication_classes([])
@permission_classes([AllowAny])
def push_reply_to_chat(request):
"""Send a one-time reply entered in Chromium's native notification UI."""
- token = request.data.get('token')
- content = request.data.get('reply')
+ token = request.data.get("token")
+ content = request.data.get("reply")
if not isinstance(token, str) or not token:
return Response(
- {'status': 'error', 'message': 'token is required'},
+ {"status": "error", "message": "token is required"},
status=status.HTTP_400_BAD_REQUEST,
)
if not isinstance(content, str) or not content.strip():
return Response(
- {'status': 'error', 'message': 'reply is required'},
+ {"status": "error", "message": "reply is required"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -756,12 +736,12 @@ def push_reply_to_chat(request):
payload = load_push_reply_token(token)
except signing.SignatureExpired:
return Response(
- {'status': 'error', 'message': 'token expired'},
+ {"status": "error", "message": "token expired"},
status=status.HTTP_410_GONE,
)
except signing.BadSignature:
return Response(
- {'status': 'error', 'message': 'invalid token'},
+ {"status": "error", "message": "invalid token"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -769,34 +749,32 @@ def push_reply_to_chat(request):
with transaction.atomic():
notification = (
Notification.objects.select_for_update()
- .select_related('recipient')
+ .select_related("recipient")
.get(
- id=int(payload['notification_id']),
- recipient_id=int(payload['user_id']),
+ id=int(payload["notification_id"]),
+ recipient_id=int(payload["user_id"]),
deleted=False,
)
)
- chat = Chat.objects.get(id=int(payload['chat_id']))
+ chat = Chat.objects.get(id=int(payload["chat_id"]))
source_message = Message.objects.get(
- id=int(payload['message_id']),
+ id=int(payload["message_id"]),
chat=chat,
is_deleted=False,
)
notification_data = (
notification.data if isinstance(notification.data, dict) else {}
)
- if (
- str(notification_data.get('chat_id')) != str(chat.id)
- or str(notification_data.get('message_id'))
- != str(source_message.id)
- ):
+ if str(notification_data.get("chat_id")) != str(chat.id) or str(
+ notification_data.get("message_id")
+ ) != str(source_message.id):
return Response(
- {'status': 'error', 'message': 'push target mismatch'},
+ {"status": "error", "message": "push target mismatch"},
status=status.HTTP_400_BAD_REQUEST,
)
- if notification_data.get('push_reply_message_id'):
+ if notification_data.get("push_reply_message_id"):
return Response(
- {'status': 'error', 'message': 'reply already sent'},
+ {"status": "error", "message": "reply already sent"},
status=status.HTTP_409_CONFLICT,
)
@@ -808,9 +786,9 @@ def push_reply_to_chat(request):
)
notification.data = {
**notification_data,
- 'push_reply_message_id': reply_message.id,
+ "push_reply_message_id": reply_message.id,
}
- notification.save(update_fields=['data'])
+ notification.save(update_fields=["data"])
except (
KeyError,
TypeError,
@@ -820,12 +798,12 @@ def push_reply_to_chat(request):
Message.DoesNotExist,
):
return Response(
- {'status': 'error', 'message': 'push target not found'},
+ {"status": "error", "message": "push target not found"},
status=status.HTTP_404_NOT_FOUND,
)
except ChatQuickReplyError:
return Response(
- {'status': 'error', 'message': 'reply is not allowed'},
+ {"status": "error", "message": "reply is not allowed"},
status=status.HTTP_403_FORBIDDEN,
)
@@ -840,30 +818,30 @@ def push_reply_to_chat(request):
)
return Response(
{
- 'status': 'success',
- 'chat_id': chat.id,
- 'message_id': reply_message.id,
- 'reply_to_id': source_message.id,
+ "status": "success",
+ "chat_id": chat.id,
+ "message_id": reply_message.id,
+ "reply_to_id": source_message.id,
},
status=status.HTTP_201_CREATED,
)
-@api_view(['GET'])
+@api_view(["GET"])
@authentication_classes([])
@permission_classes([AllowAny])
def push_avatar(request):
"""Return a signed avatar as a transparent circular PNG for native push UI."""
- token = request.query_params.get('token', '')
+ token = request.query_params.get("token", "")
try:
payload = load_push_avatar_token(token)
- path = payload['path']
+ path = payload["path"]
if not isinstance(path, str) or not path:
- raise signing.BadSignature('missing path')
+ raise signing.BadSignature("missing path")
- with default_storage.open(path, 'rb') as avatar_file:
+ with default_storage.open(path, "rb") as avatar_file:
with Image.open(avatar_file) as source:
- source = ImageOps.exif_transpose(source).convert('RGBA')
+ source = ImageOps.exif_transpose(source).convert("RGBA")
image = ImageOps.fit(
source,
(192, 192),
@@ -874,16 +852,16 @@ def push_avatar(request):
except (signing.BadSignature, KeyError, OSError, UnidentifiedImageError):
return Response(status=status.HTTP_404_NOT_FOUND)
- mask = Image.new('L', image.size, 0)
+ mask = Image.new("L", image.size, 0)
ImageDraw.Draw(mask).ellipse(
(0, 0, image.width - 1, image.height - 1),
fill=255,
)
image.putalpha(mask)
output = BytesIO()
- image.save(output, format='PNG', optimize=True)
- response = HttpResponse(output.getvalue(), content_type='image/png')
- response['Cache-Control'] = 'public, max-age=2592000, immutable'
+ image.save(output, format="PNG", optimize=True)
+ response = HttpResponse(output.getvalue(), content_type="image/png")
+ response["Cache-Control"] = "public, max-age=2592000, immutable"
return response
@@ -893,7 +871,7 @@ def push_avatar(request):
request=SubscribePushRequestSerializer,
responses={200: StatusResponseSerializer, 400: StatusResponseSerializer},
)
-@api_view(['POST'])
+@api_view(["POST"])
@permission_classes([IsAuthenticated])
def subscribe_push(request):
"""
@@ -909,41 +887,44 @@ def subscribe_push(request):
serializer = SubscribePushRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
payload = serializer.validated_data
- endpoint = payload['endpoint']
- keys = payload['keys']
- device_name = payload.get('device_name', 'unknown')
- app_kind = payload.get('app_kind', NotificationInstallation.APP_MAIN)
+ endpoint = payload["endpoint"]
+ keys = payload["keys"]
+ device_name = payload.get("device_name", "unknown")
+ app_kind = payload.get("app_kind", NotificationInstallation.APP_MAIN)
generated_id = uuid.uuid5(uuid.NAMESPACE_URL, endpoint)
- installation_id = payload.get('installation_id', generated_id)
- device_group_id = payload.get('device_group_id', generated_id)
+ installation_id = payload.get("installation_id", generated_id)
+ device_group_id = payload.get("device_group_id", generated_id)
# Регистрируем устройство
device, created = WebPushDevice.objects.update_or_create(
registration_id=endpoint,
defaults={
- 'user': request.user,
- 'p256dh': keys.get('p256dh', ''),
- 'auth': keys.get('auth', ''),
+ "user": request.user,
+ "p256dh": keys.get("p256dh", ""),
+ "auth": keys.get("auth", ""),
# django-push-notifications keeps a legacy browser code in a
# varchar(10). The human-readable device name belongs to
# NotificationInstallation.device_name and may be much longer.
- 'browser': _web_push_browser(request),
- 'active': True,
- }
+ "browser": _web_push_browser(request),
+ "active": True,
+ },
)
NotificationInstallation.objects.filter(endpoint=endpoint).exclude(
user=request.user,
).update(endpoint=None, push_device_id=None, active=False)
- installation = NotificationInstallation.objects.filter(
- user=request.user,
- endpoint=endpoint,
- ).first() or NotificationInstallation.objects.filter(
- user=request.user,
- device_group_id=device_group_id,
- app_kind=app_kind,
- ).first()
+ installation = (
+ NotificationInstallation.objects.filter(
+ user=request.user,
+ endpoint=endpoint,
+ ).first()
+ or NotificationInstallation.objects.filter(
+ user=request.user,
+ device_group_id=device_group_id,
+ app_kind=app_kind,
+ ).first()
+ )
if installation is None:
installation = NotificationInstallation(
user=request.user,
@@ -960,7 +941,7 @@ def subscribe_push(request):
installation.endpoint = endpoint
installation.push_device_id = device.id
installation.device_name = device_name
- installation.installed = payload.get('installed', installation.installed)
+ installation.installed = payload.get("installed", installation.installed)
installation.active = True
installation.last_seen_at = timezone.now()
installation.save()
@@ -972,18 +953,18 @@ def subscribe_push(request):
prefs.push_enabled = True
prefs.save(update_fields=["push_enabled"])
- return Response({
- 'status': 'success',
- 'message': (
- 'Push subscription created'
- if created
- else 'Push subscription updated'
- ),
- 'created': created,
- 'device_id': device.id,
- 'installation_id': str(installation.installation_id),
- 'app_kind': installation.app_kind,
- })
+ return Response(
+ {
+ "status": "success",
+ "message": (
+ "Push subscription created" if created else "Push subscription updated"
+ ),
+ "created": created,
+ "device_id": device.id,
+ "installation_id": str(installation.installation_id),
+ "app_kind": installation.app_kind,
+ }
+ )
@extend_schema(
@@ -992,7 +973,7 @@ def subscribe_push(request):
request=InstallationHeartbeatRequestSerializer,
responses={200: StatusResponseSerializer},
)
-@api_view(['POST'])
+@api_view(["POST"])
@permission_classes([IsAuthenticated])
def installation_heartbeat(request):
serializer = InstallationHeartbeatRequestSerializer(data=request.data)
@@ -1001,114 +982,119 @@ def installation_heartbeat(request):
installation, created = NotificationInstallation.objects.update_or_create(
user=request.user,
- device_group_id=payload['device_group_id'],
- app_kind=payload['app_kind'],
+ device_group_id=payload["device_group_id"],
+ app_kind=payload["app_kind"],
defaults={
- 'installation_id': payload['installation_id'],
- 'device_name': payload.get('device_name', ''),
- 'installed': payload.get('installed', True),
- 'active': True,
- 'last_seen_at': timezone.now(),
+ "installation_id": payload["installation_id"],
+ "device_name": payload.get("device_name", ""),
+ "installed": payload.get("installed", True),
+ "active": True,
+ "last_seen_at": timezone.now(),
},
)
- return Response({
- 'status': 'success',
- 'created': created,
- 'installation': _serialize_installation(installation),
- })
+ return Response(
+ {
+ "status": "success",
+ "created": created,
+ "installation": _serialize_installation(installation),
+ }
+ )
def _serialize_installation(installation):
return {
- 'installation_id': str(installation.installation_id),
- 'device_group_id': str(installation.device_group_id),
- 'app_kind': installation.app_kind,
- 'device_name': installation.device_name,
- 'installed': installation.installed,
- 'active': installation.active,
- 'push_ready': installation.push_ready,
- 'last_seen_at': installation.last_seen_at.isoformat(),
+ "installation_id": str(installation.installation_id),
+ "device_group_id": str(installation.device_group_id),
+ "app_kind": installation.app_kind,
+ "device_name": installation.device_name,
+ "installed": installation.installed,
+ "active": installation.active,
+ "push_ready": installation.push_ready,
+ "last_seen_at": installation.last_seen_at.isoformat(),
}
@extend_schema(
- methods=['GET'],
+ methods=["GET"],
tags=["Notifications"],
summary="Получить маршруты доставки по приложениям",
responses={200: DeliveryRoutingResponseSerializer},
)
@extend_schema(
- methods=['PUT'],
+ methods=["PUT"],
tags=["Notifications"],
summary="Обновить маршруты доставки по приложениям",
request=DeliveryRoutingRequestSerializer,
responses={200: DeliveryRoutingResponseSerializer},
)
-@api_view(['GET', 'PUT'])
+@api_view(["GET", "PUT"])
@permission_classes([IsAuthenticated])
def delivery_routing(request):
- if request.method == 'GET':
- raw_device_group_id = request.query_params.get('device_group_id')
+ if request.method == "GET":
+ raw_device_group_id = request.query_params.get("device_group_id")
try:
device_group_id = uuid.UUID(str(raw_device_group_id))
except (TypeError, ValueError, AttributeError):
return Response(
- {'detail': 'device_group_id is required'},
+ {"detail": "device_group_id is required"},
status=status.HTTP_400_BAD_REQUEST,
)
else:
serializer = DeliveryRoutingRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
- device_group_id = serializer.validated_data['device_group_id']
+ device_group_id = serializer.validated_data["device_group_id"]
routing, _created = NotificationDeliveryRouting.objects.get_or_create(
user=request.user,
device_group_id=device_group_id,
)
- if request.method == 'PUT':
- routes = normalize_routes(serializer.validated_data['routes'])
+ if request.method == "PUT":
+ routes = normalize_routes(serializer.validated_data["routes"])
messenger_requested = any(
NotificationInstallation.APP_MESSENGER in destinations
for destinations in routes.values()
)
- if messenger_requested and not NotificationInstallation.objects.filter(
- user=request.user,
- device_group_id=device_group_id,
- app_kind=NotificationInstallation.APP_MESSENGER,
- active=True,
- installed=True,
- endpoint__isnull=False,
- ).exists():
+ if (
+ messenger_requested
+ and not NotificationInstallation.objects.filter(
+ user=request.user,
+ device_group_id=device_group_id,
+ app_kind=NotificationInstallation.APP_MESSENGER,
+ active=True,
+ installed=True,
+ endpoint__isnull=False,
+ ).exists()
+ ):
return Response(
{
- 'detail': (
- 'Сначала завершите установку мессенджера и '
- 'разрешите ему уведомления.'
+ "detail": (
+ "Сначала завершите установку мессенджера и "
+ "разрешите ему уведомления."
)
},
status=status.HTTP_409_CONFLICT,
)
routing.routes = routes
- routing.save(update_fields=['routes', 'updated_at'])
+ routing.save(update_fields=["routes", "updated_at"])
installations = NotificationInstallation.objects.filter(
user=request.user,
device_group_id=device_group_id,
active=True,
)
- return Response({
- 'device_group_id': str(device_group_id),
- 'categories': [
- {'key': key, 'label': label}
- for key, label in NOTIFICATION_CATEGORIES
- ],
- 'routes': normalize_routes(routing.routes),
- 'installations': [
- _serialize_installation(installation)
- for installation in installations
- ],
- })
+ return Response(
+ {
+ "device_group_id": str(device_group_id),
+ "categories": [
+ {"key": key, "label": label} for key, label in NOTIFICATION_CATEGORIES
+ ],
+ "routes": normalize_routes(routing.routes),
+ "installations": [
+ _serialize_installation(installation) for installation in installations
+ ],
+ }
+ )
@extend_schema(
@@ -1117,7 +1103,7 @@ def delivery_routing(request):
request=DisconnectInstallationRequestSerializer,
responses={200: StatusResponseSerializer},
)
-@api_view(['DELETE'])
+@api_view(["DELETE"])
@permission_classes([IsAuthenticated])
def disconnect_installation(request):
from push_notifications.models import WebPushDevice
@@ -1127,12 +1113,12 @@ def disconnect_installation(request):
payload = serializer.validated_data
installations = NotificationInstallation.objects.filter(
user=request.user,
- device_group_id=payload['device_group_id'],
- app_kind=payload['app_kind'],
+ device_group_id=payload["device_group_id"],
+ app_kind=payload["app_kind"],
)
endpoints = list(
installations.exclude(endpoint__isnull=True).values_list(
- 'endpoint',
+ "endpoint",
flat=True,
)
)
@@ -1150,35 +1136,34 @@ def disconnect_installation(request):
routing, _created = NotificationDeliveryRouting.objects.get_or_create(
user=request.user,
- device_group_id=payload['device_group_id'],
+ device_group_id=payload["device_group_id"],
)
routes = normalize_routes(routing.routes)
- if payload['app_kind'] == NotificationInstallation.APP_MESSENGER:
+ if payload["app_kind"] == NotificationInstallation.APP_MESSENGER:
routes = {
- category: (
- [app for app in destinations if app != 'messenger']
- or ['main']
- )
+ category: ([app for app in destinations if app != "messenger"] or ["main"])
for category, destinations in routes.items()
}
routing.routes = routes
- routing.save(update_fields=['routes', 'updated_at'])
+ routing.save(update_fields=["routes", "updated_at"])
- return Response({
- 'status': 'success',
- 'routes': routes,
- 'disconnected': installations.count(),
- })
+ return Response(
+ {
+ "status": "success",
+ "routes": routes,
+ "disconnected": installations.count(),
+ }
+ )
@extend_schema(
- methods=['POST', 'DELETE'],
+ methods=["POST", "DELETE"],
tags=["Notifications"],
summary="Отписаться от Web Push",
request=UnsubscribePushRequestSerializer,
responses=StatusResponseSerializer,
)
-@api_view(['POST', 'DELETE'])
+@api_view(["POST", "DELETE"])
@permission_classes([IsAuthenticated])
def unsubscribe_push(request):
"""Отписка от Web Push уведомлений"""
@@ -1186,8 +1171,8 @@ def unsubscribe_push(request):
serializer = UnsubscribePushRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
- endpoint = serializer.validated_data.get('endpoint')
- device_group_id = serializer.validated_data.get('device_group_id')
+ endpoint = serializer.validated_data.get("endpoint")
+ device_group_id = serializer.validated_data.get("device_group_id")
if endpoint:
NotificationInstallation.objects.filter(
@@ -1199,8 +1184,7 @@ def unsubscribe_push(request):
last_seen_at=timezone.now(),
)
deleted_count, _ = WebPushDevice.objects.filter(
- user=request.user,
- registration_id=endpoint
+ user=request.user, registration_id=endpoint
).delete()
elif device_group_id:
# Logout is scoped to the current physical device. A user may still
@@ -1211,7 +1195,7 @@ def unsubscribe_push(request):
)
endpoints = list(
installations.exclude(endpoint__isnull=True).values_list(
- 'endpoint',
+ "endpoint",
flat=True,
)
)
@@ -1227,9 +1211,7 @@ def unsubscribe_push(request):
)
else:
# Удалить все устройства пользователя
- deleted_count, _ = WebPushDevice.objects.filter(
- user=request.user
- ).delete()
+ deleted_count, _ = WebPushDevice.objects.filter(user=request.user).delete()
has_active_devices = WebPushDevice.objects.filter(
user=request.user,
@@ -1243,22 +1225,24 @@ def unsubscribe_push(request):
prefs.push_enabled = False
prefs.save(update_fields=["push_enabled"])
- return Response({
- 'status': 'success',
- 'message': f'Deleted {deleted_count} push subscription(s)',
- 'deleted_count': deleted_count
- })
+ return Response(
+ {
+ "status": "success",
+ "message": f"Deleted {deleted_count} push subscription(s)",
+ "deleted_count": deleted_count,
+ }
+ )
def _web_push_browser(request):
"""Return a value accepted by WebPushDevice.browser (max length 10)."""
- user_agent = request.META.get('HTTP_USER_AGENT', '').lower()
- if 'firefox' in user_agent:
- return 'FIREFOX'
- if 'edg/' in user_agent or 'edge/' in user_agent:
- return 'EDGE'
- if 'opr/' in user_agent or 'opera' in user_agent:
- return 'OPERA'
+ user_agent = request.META.get("HTTP_USER_AGENT", "").lower()
+ if "firefox" in user_agent:
+ return "FIREFOX"
+ if "edg/" in user_agent or "edge/" in user_agent:
+ return "EDGE"
+ if "opr/" in user_agent or "opera" in user_agent:
+ return "OPERA"
# The library has no Safari choice. For modern HTTPS endpoints this
# legacy field is not used to construct the delivery URL.
- return 'CHROME'
+ return "CHROME"
diff --git a/backend/notifications/apps.py b/backend/notifications/apps.py
index 054c4ff8..878683e6 100644
--- a/backend/notifications/apps.py
+++ b/backend/notifications/apps.py
@@ -2,9 +2,9 @@ from django.apps import AppConfig
class NotificationsConfig(AppConfig):
- default_auto_field = 'django.db.models.BigAutoField'
- name = 'notifications'
- verbose_name = 'Уведомления'
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "notifications"
+ verbose_name = "Уведомления"
def ready(self):
# Загружаем сигналы (notify.send API)
diff --git a/backend/notifications/cache.py b/backend/notifications/cache.py
index 4dc4c972..e5cad9c5 100644
--- a/backend/notifications/cache.py
+++ b/backend/notifications/cache.py
@@ -1,9 +1,10 @@
-from django.core.cache import cache
-from django.db.models import Count
-from django.db.models import Q
+import logging
+from django.core.cache import cache
+from django.db.models import Count, Q
UNREAD_SUMMARY_TIMEOUT = 60
+logger = logging.getLogger(__name__)
def unread_summary_cache_key(user_id):
@@ -20,7 +21,9 @@ def _coerce_positive_int(value):
def _build_procurement_request_counts(user_id):
from django.contrib.contenttypes.models import ContentType
+
from procurement.models import ProcurementRequest
+
from .models import Notification
request_content_type = ContentType.objects.get_for_model(
@@ -34,10 +37,7 @@ def _build_procurement_request_counts(user_id):
unread=True,
deleted=False,
)
- .filter(
- Q(verb__startswith="procurement_")
- | Q(verb__startswith="equipment_")
- )
+ .filter(Q(verb__startswith="procurement_") | Q(verb__startswith="equipment_"))
.only(
"data",
"target_content_type_id",
@@ -74,6 +74,7 @@ def _build_procurement_request_counts(user_id):
def _build_employee_request_counts(user_id):
from django.contrib.contenttypes.models import ContentType
+
from requests_app.models import Request as EmployeeRequest
from .models import Notification
@@ -269,10 +270,7 @@ def build_unread_summary(user_id):
.annotate(unread=Count("id"))
.order_by()
)
- verbs = [
- {"verb": row["verb"], "unread": row["unread"]}
- for row in rows
- ]
+ verbs = [{"verb": row["verb"], "unread": row["unread"]} for row in rows]
regulation_counts = _build_regulation_counts(user_id)
return {
"total": sum(item["unread"] for item in verbs),
@@ -301,8 +299,15 @@ def get_unread_summary(user_id):
summary = build_unread_summary(user_id)
try:
cache.set(key, summary, UNREAD_SUMMARY_TIMEOUT)
- except Exception:
- pass
+ except Exception as exc:
+ logger.warning(
+ "Unread notification summary could not be cached",
+ extra={
+ "event": "notifications.unread_summary.cache_set_failed",
+ "user_id": user_id,
+ "error_type": type(exc).__name__,
+ },
+ )
return summary
@@ -316,5 +321,12 @@ def invalidate_unread_summary(user_id):
try:
cache.delete(unread_summary_cache_key(user_id))
- except Exception:
- pass
+ except Exception as exc:
+ logger.warning(
+ "Unread notification summary cache could not be invalidated",
+ extra={
+ "event": "notifications.unread_summary.cache_delete_failed",
+ "user_id": user_id,
+ "error_type": type(exc).__name__,
+ },
+ )
diff --git a/backend/notifications/channels.py b/backend/notifications/channels.py
index 3a951f66..6543adcd 100644
--- a/backend/notifications/channels.py
+++ b/backend/notifications/channels.py
@@ -13,10 +13,11 @@ TODO: Добавить integration тесты:
- Отключенные verb'ы (is_verb_enabled)
"""
+import logging
+
from django.db import transaction
from django.db.models.signals import post_save
from django.dispatch import receiver
-import logging
logger = logging.getLogger(__name__)
@@ -91,9 +92,9 @@ def route_notification_to_channels(sender, instance, created, **kwargs):
# Импортируем задачи
from .tasks import (
- send_websocket_notification,
send_email_notification,
send_push_notification,
+ send_websocket_notification,
)
# Проверяем режим "Не беспокоить"
@@ -153,7 +154,9 @@ def send_email_digest(user, frequency="daily"):
"""
try:
from datetime import timedelta
+
from django.utils import timezone
+
from .models import Notification
# Определяем период
diff --git a/backend/notifications/chat_suppression.py b/backend/notifications/chat_suppression.py
index fc8fdba8..8ecab276 100644
--- a/backend/notifications/chat_suppression.py
+++ b/backend/notifications/chat_suppression.py
@@ -3,7 +3,6 @@
from communications.presence import user_is_viewing_chat
from communications.services import ChatNotificationReadService
-
CHAT_NOTIFICATION_VERBS = {
"chat_new_message",
"chat_mention",
diff --git a/backend/notifications/config.py b/backend/notifications/config.py
index 5f3ccf8f..8bdc1ddf 100644
--- a/backend/notifications/config.py
+++ b/backend/notifications/config.py
@@ -7,7 +7,6 @@
from django.conf import settings
-
# Базовая конфигурация по умолчанию
DEFAULT_CONFIG = {
# === Каналы доставки ===
diff --git a/backend/notifications/management/commands/cleanup_push_subscriptions.py b/backend/notifications/management/commands/cleanup_push_subscriptions.py
index 0da83048..6186d2a5 100644
--- a/backend/notifications/management/commands/cleanup_push_subscriptions.py
+++ b/backend/notifications/management/commands/cleanup_push_subscriptions.py
@@ -2,9 +2,11 @@
Management команда для очистки устаревших Web Push подписок
"""
+from datetime import timedelta
+
from django.core.management.base import BaseCommand
from django.utils import timezone
-from datetime import timedelta
+
from notifications.models import WebPushSubscription
@@ -55,9 +57,7 @@ class Command(BaseCommand):
self.stdout.write("📊 Статистика до очистки:")
self.stdout.write(f" Всего подписок: {total_before}")
self.stdout.write(f" Неактивных старше {days} дней: {inactive_count}")
- self.stdout.write(
- f" С 10+ ошибками старше {days} дней: {high_error_count}"
- )
+ self.stdout.write(f" С 10+ ошибками старше {days} дней: {high_error_count}")
if not dry_run:
# Удаляем неактивные
diff --git a/backend/notifications/management/commands/create_notification_types.py b/backend/notifications/management/commands/create_notification_types.py
index 0bb80e23..cefea93b 100644
--- a/backend/notifications/management/commands/create_notification_types.py
+++ b/backend/notifications/management/commands/create_notification_types.py
@@ -1,4 +1,5 @@
from django.core.management.base import BaseCommand
+
from notifications.models import NotificationCategory, NotificationType
@@ -148,9 +149,7 @@ class Command(BaseCommand):
{
"code": "document_related",
"name": "Документ связан",
- "description": (
- "Ваш документ связан с другим документом"
- ),
+ "description": ("Ваш документ связан с другим документом"),
"priority": "low",
"default_channels": {
"web": True,
@@ -425,9 +424,7 @@ class Command(BaseCommand):
{
"code": "profile_new_login",
"name": "Вход из нового места",
- "description": (
- "Обнаружен вход из нового места/устройства"
- ),
+ "description": ("Обнаружен вход из нового места/устройства"),
"priority": "high",
"default_channels": {
"web": True,
@@ -568,30 +565,26 @@ class Command(BaseCommand):
# Создать типы для категории
for type_data in cat_data["types"]:
- notification_type, created = (
- NotificationType.objects.update_or_create(
- code=type_data["code"],
- defaults={
- "category": category,
- "name": type_data["name"],
- "description": type_data.get("description", ""),
- "priority": type_data.get("priority", "normal"),
- "default_channels": type_data.get(
- "default_channels", {"web": True}
- ),
- "is_groupable": type_data.get("is_groupable", True),
- "grouping_window_minutes": type_data.get(
- "grouping_window_minutes", 5
- ),
- "is_required": type_data.get("is_required", False),
- },
- )
+ notification_type, created = NotificationType.objects.update_or_create(
+ code=type_data["code"],
+ defaults={
+ "category": category,
+ "name": type_data["name"],
+ "description": type_data.get("description", ""),
+ "priority": type_data.get("priority", "normal"),
+ "default_channels": type_data.get(
+ "default_channels", {"web": True}
+ ),
+ "is_groupable": type_data.get("is_groupable", True),
+ "grouping_window_minutes": type_data.get(
+ "grouping_window_minutes", 5
+ ),
+ "is_required": type_data.get("is_required", False),
+ },
)
if created:
created_types += 1
- self.stdout.write(
- f" → Создан тип: {notification_type.name}"
- )
+ self.stdout.write(f" → Создан тип: {notification_type.name}")
self.stdout.write("\n")
self.stdout.write(
diff --git a/backend/notifications/management/commands/notification_stats.py b/backend/notifications/management/commands/notification_stats.py
index e2e66163..78084aa8 100644
--- a/backend/notifications/management/commands/notification_stats.py
+++ b/backend/notifications/management/commands/notification_stats.py
@@ -2,9 +2,10 @@
Management команда для показа статистики уведомлений
"""
-from django.core.management.base import BaseCommand
from django.contrib.auth import get_user_model
+from django.core.management.base import BaseCommand
from django.db.models import Count, Q
+
from notifications.models import (
Notification,
NotificationType,
@@ -22,12 +23,8 @@ class Command(BaseCommand):
# Общая статистика уведомлений
total_notifications = Notification.objects.count()
- unread_notifications = Notification.objects.filter(
- is_read=False
- ).count()
- archived_notifications = Notification.objects.filter(
- is_archived=True
- ).count()
+ unread_notifications = Notification.objects.filter(is_read=False).count()
+ archived_notifications = Notification.objects.filter(is_archived=True).count()
self.stdout.write("📧 Уведомления:")
self.stdout.write(f" Всего: {total_notifications}")
@@ -38,19 +35,14 @@ class Command(BaseCommand):
self.stdout.write("\n📁 По категориям:")
category_stats = (
Notification.objects.values("notification_type__category__name")
- .annotate(
- count=Count("id"), unread=Count("id", filter=Q(is_read=False))
- )
+ .annotate(count=Count("id"), unread=Count("id", filter=Q(is_read=False)))
.order_by("-count")
)
for stat in category_stats[:10]:
- category_name = (
- stat["notification_type__category__name"] or "Unknown"
- )
+ category_name = stat["notification_type__category__name"] or "Unknown"
self.stdout.write(
- f" {category_name}: {stat['count']} "
- f"(непрочитанных: {stat['unread']})"
+ f" {category_name}: {stat['count']} (непрочитанных: {stat['unread']})"
)
# Статистика по типам (топ 10)
@@ -69,9 +61,7 @@ class Command(BaseCommand):
self.stdout.write("\n👥 Топ-10 пользователей по уведомлениям:")
user_stats = (
Notification.objects.values("recipient__email")
- .annotate(
- count=Count("id"), unread=Count("id", filter=Q(is_read=False))
- )
+ .annotate(count=Count("id"), unread=Count("id", filter=Q(is_read=False)))
.order_by("-count")[:10]
)
@@ -85,9 +75,7 @@ class Command(BaseCommand):
self.stdout.write("\n📱 Web Push подписки:")
total_subs = WebPushSubscription.objects.count()
active_subs = WebPushSubscription.objects.filter(is_active=True).count()
- error_subs = WebPushSubscription.objects.filter(
- error_count__gt=0
- ).count()
+ error_subs = WebPushSubscription.objects.filter(error_count__gt=0).count()
self.stdout.write(f" Всего: {total_subs}")
self.stdout.write(f" Активных: {active_subs}")
diff --git a/backend/notifications/management/commands/send_pending_notifications.py b/backend/notifications/management/commands/send_pending_notifications.py
index bdacbb1a..d547d4dc 100644
--- a/backend/notifications/management/commands/send_pending_notifications.py
+++ b/backend/notifications/management/commands/send_pending_notifications.py
@@ -14,6 +14,7 @@ DEPRECATED V2.0:
"""
import logging
+
from django.core.management.base import BaseCommand
logger = logging.getLogger(__name__)
diff --git a/backend/notifications/migrations/0001_initial.py b/backend/notifications/migrations/0001_initial.py
index a911acfe..ac711266 100644
--- a/backend/notifications/migrations/0001_initial.py
+++ b/backend/notifications/migrations/0001_initial.py
@@ -6,141 +6,560 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
initial = True
dependencies = [
- ('contenttypes', '0002_remove_content_type_name'),
+ ("contenttypes", "0002_remove_content_type_name"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
- name='NotificationCategory',
+ name="NotificationCategory",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('code', models.CharField(choices=[('communications', 'Коммуникации'), ('documents', 'Документы'), ('requests', 'Заявления'), ('calendar', 'Календарь'), ('department', 'Отдел'), ('profile', 'Профиль'), ('feed', 'Новости'), ('system', 'Система')], max_length=50, unique=True, verbose_name='Код')),
- ('name', models.CharField(max_length=100, verbose_name='Название')),
- ('description', models.TextField(blank=True, verbose_name='Описание')),
- ('icon', models.CharField(default='bi-bell', help_text='Bootstrap icon класс', max_length=50, verbose_name='Иконка')),
- ('color', models.CharField(default='primary', help_text='Bootstrap цвет', max_length=20, 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",
+ ),
+ ),
+ (
+ "code",
+ models.CharField(
+ choices=[
+ ("communications", "Коммуникации"),
+ ("documents", "Документы"),
+ ("requests", "Заявления"),
+ ("calendar", "Календарь"),
+ ("department", "Отдел"),
+ ("profile", "Профиль"),
+ ("feed", "Новости"),
+ ("system", "Система"),
+ ],
+ max_length=50,
+ unique=True,
+ verbose_name="Код",
+ ),
+ ),
+ ("name", models.CharField(max_length=100, verbose_name="Название")),
+ ("description", models.TextField(blank=True, verbose_name="Описание")),
+ (
+ "icon",
+ models.CharField(
+ default="bi-bell",
+ help_text="Bootstrap icon класс",
+ max_length=50,
+ verbose_name="Иконка",
+ ),
+ ),
+ (
+ "color",
+ models.CharField(
+ default="primary",
+ help_text="Bootstrap цвет",
+ max_length=20,
+ verbose_name="Цвет",
+ ),
+ ),
+ (
+ "is_active",
+ models.BooleanField(default=True, verbose_name="Активна"),
+ ),
+ ("order", models.IntegerField(default=0, verbose_name="Порядок")),
],
options={
- 'verbose_name': 'Категория уведомлений',
- 'verbose_name_plural': 'Категории уведомлений',
- 'db_table': 'notifications_category',
- 'ordering': ['order', 'name'],
+ "verbose_name": "Категория уведомлений",
+ "verbose_name_plural": "Категории уведомлений",
+ "db_table": "notifications_category",
+ "ordering": ["order", "name"],
},
),
migrations.CreateModel(
- name='NotificationType',
+ name="NotificationType",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('code', models.CharField(help_text='Уникальный код типа, например: chat_new_message', max_length=100, unique=True, verbose_name='Код')),
- ('name', models.CharField(max_length=200, verbose_name='Название')),
- ('description', models.TextField(blank=True, verbose_name='Описание')),
- ('default_enabled', models.BooleanField(default=True, verbose_name='Включено по умолчанию')),
- ('default_channels', models.JSONField(default=dict, help_text='{"web": true, "email": false, "telegram": false}', verbose_name='Каналы по умолчанию')),
- ('priority', models.CharField(choices=[('low', 'Низкий'), ('normal', 'Обычный'), ('high', 'Высокий'), ('urgent', 'Срочный')], default='normal', max_length=20, verbose_name='Приоритет')),
- ('is_required', models.BooleanField(default=False, help_text='Нельзя отключить (например, системные уведомления)', verbose_name='Обязательное')),
- ('is_groupable', models.BooleanField(default=True, help_text='Объединять несколько похожих уведомлений', verbose_name='Можно группировать')),
- ('grouping_window_minutes', models.IntegerField(default=5, help_text='Группировать уведомления за последние N минут', 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='Обновлен')),
- ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='types', to='notifications.notificationcategory', verbose_name='Категория')),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "code",
+ models.CharField(
+ help_text="Уникальный код типа, например: chat_new_message",
+ max_length=100,
+ unique=True,
+ verbose_name="Код",
+ ),
+ ),
+ ("name", models.CharField(max_length=200, verbose_name="Название")),
+ ("description", models.TextField(blank=True, verbose_name="Описание")),
+ (
+ "default_enabled",
+ models.BooleanField(
+ default=True, verbose_name="Включено по умолчанию"
+ ),
+ ),
+ (
+ "default_channels",
+ models.JSONField(
+ default=dict,
+ help_text='{"web": true, "email": false, "telegram": false}',
+ verbose_name="Каналы по умолчанию",
+ ),
+ ),
+ (
+ "priority",
+ models.CharField(
+ choices=[
+ ("low", "Низкий"),
+ ("normal", "Обычный"),
+ ("high", "Высокий"),
+ ("urgent", "Срочный"),
+ ],
+ default="normal",
+ max_length=20,
+ verbose_name="Приоритет",
+ ),
+ ),
+ (
+ "is_required",
+ models.BooleanField(
+ default=False,
+ help_text="Нельзя отключить (например, системные уведомления)",
+ verbose_name="Обязательное",
+ ),
+ ),
+ (
+ "is_groupable",
+ models.BooleanField(
+ default=True,
+ help_text="Объединять несколько похожих уведомлений",
+ verbose_name="Можно группировать",
+ ),
+ ),
+ (
+ "grouping_window_minutes",
+ models.IntegerField(
+ default=5,
+ help_text="Группировать уведомления за последние N минут",
+ 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="Обновлен"),
+ ),
+ (
+ "category",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="types",
+ to="notifications.notificationcategory",
+ verbose_name="Категория",
+ ),
+ ),
],
options={
- 'verbose_name': 'Тип уведомления',
- 'verbose_name_plural': 'Типы уведомлений',
- 'db_table': 'notifications_type',
- 'ordering': ['category__order', 'name'],
+ "verbose_name": "Тип уведомления",
+ "verbose_name_plural": "Типы уведомлений",
+ "db_table": "notifications_type",
+ "ordering": ["category__order", "name"],
},
),
migrations.CreateModel(
- name='NotificationTemplate',
+ name="NotificationTemplate",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('channel', models.CharField(choices=[('web', 'Веб'), ('email', 'Email'), ('telegram', 'Telegram'), ('whatsapp', 'WhatsApp'), ('wechat', 'WeChat')], max_length=20, verbose_name='Канал')),
- ('title_template', models.TextField(help_text='Django template синтаксис', verbose_name='Шаблон заголовка')),
- ('message_template', models.TextField(help_text='Django template синтаксис', verbose_name='Шаблон сообщения')),
- ('html_template', models.TextField(blank=True, help_text='Для email', verbose_name='HTML шаблон')),
- ('action_button_template', models.CharField(blank=True, max_length=200, 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='Обновлен')),
- ('notification_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='templates', to='notifications.notificationtype', verbose_name='Тип уведомления')),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "channel",
+ models.CharField(
+ choices=[
+ ("web", "Веб"),
+ ("email", "Email"),
+ ("telegram", "Telegram"),
+ ("whatsapp", "WhatsApp"),
+ ("wechat", "WeChat"),
+ ],
+ max_length=20,
+ verbose_name="Канал",
+ ),
+ ),
+ (
+ "title_template",
+ models.TextField(
+ help_text="Django template синтаксис",
+ verbose_name="Шаблон заголовка",
+ ),
+ ),
+ (
+ "message_template",
+ models.TextField(
+ help_text="Django template синтаксис",
+ verbose_name="Шаблон сообщения",
+ ),
+ ),
+ (
+ "html_template",
+ models.TextField(
+ blank=True, help_text="Для email", verbose_name="HTML шаблон"
+ ),
+ ),
+ (
+ "action_button_template",
+ models.CharField(
+ blank=True,
+ max_length=200,
+ 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="Обновлен"),
+ ),
+ (
+ "notification_type",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="templates",
+ to="notifications.notificationtype",
+ verbose_name="Тип уведомления",
+ ),
+ ),
],
options={
- 'verbose_name': 'Шаблон уведомления',
- 'verbose_name_plural': 'Шаблоны уведомлений',
- 'db_table': 'notifications_template',
- 'unique_together': {('notification_type', 'channel')},
+ "verbose_name": "Шаблон уведомления",
+ "verbose_name_plural": "Шаблоны уведомлений",
+ "db_table": "notifications_template",
+ "unique_together": {("notification_type", "channel")},
},
),
migrations.CreateModel(
- name='Notification',
+ name="Notification",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('title', models.CharField(max_length=255, verbose_name='Заголовок')),
- ('message', models.TextField(verbose_name='Сообщение')),
- ('short_message', models.CharField(blank=True, help_text='Для превью', max_length=150, verbose_name='Краткое сообщение')),
- ('object_id', models.PositiveIntegerField(blank=True, null=True, verbose_name='ID объекта')),
- ('metadata', models.JSONField(blank=True, default=dict, help_text='Дополнительные данные для рендеринга', verbose_name='Метаданные')),
- ('action_url', models.CharField(blank=True, max_length=500, verbose_name='Ссылка действия')),
- ('action_text', models.CharField(default='Посмотреть', max_length=100, verbose_name='Текст кнопки')),
- ('is_read', models.BooleanField(db_index=True, default=False, verbose_name='Прочитано')),
- ('read_at', models.DateTimeField(blank=True, null=True, verbose_name='Прочитано когда')),
- ('is_archived', models.BooleanField(default=False, verbose_name='В архиве')),
- ('archived_at', models.DateTimeField(blank=True, null=True, verbose_name='Заархивировано когда')),
- ('sent_web', models.BooleanField(default=False, verbose_name='Отправлено на сайт')),
- ('sent_email', models.BooleanField(default=False, verbose_name='Отправлено на email')),
- ('sent_telegram', models.BooleanField(default=False, verbose_name='Отправлено в Telegram')),
- ('sent_whatsapp', models.BooleanField(default=False, verbose_name='Отправлено в WhatsApp')),
- ('sent_wechat', models.BooleanField(default=False, verbose_name='Отправлено в WeChat')),
- ('sent_at', models.DateTimeField(blank=True, null=True, verbose_name='Отправлено когда')),
- ('group_key', models.CharField(blank=True, db_index=True, help_text='Для объединения похожих уведомлений', max_length=255, verbose_name='Ключ группировки')),
- ('is_grouped', models.BooleanField(default=False, verbose_name='Сгруппировано')),
- ('grouped_count', models.IntegerField(default=1, help_text='Сколько уведомлений объединено', verbose_name='Количество в группе')),
- ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='Создано')),
- ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')),
- ('content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='Тип объекта')),
- ('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL, verbose_name='Получатель')),
- ('notification_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='notifications.notificationtype', verbose_name='Тип')),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("title", models.CharField(max_length=255, verbose_name="Заголовок")),
+ ("message", models.TextField(verbose_name="Сообщение")),
+ (
+ "short_message",
+ models.CharField(
+ blank=True,
+ help_text="Для превью",
+ max_length=150,
+ verbose_name="Краткое сообщение",
+ ),
+ ),
+ (
+ "object_id",
+ models.PositiveIntegerField(
+ blank=True, null=True, verbose_name="ID объекта"
+ ),
+ ),
+ (
+ "metadata",
+ models.JSONField(
+ blank=True,
+ default=dict,
+ help_text="Дополнительные данные для рендеринга",
+ verbose_name="Метаданные",
+ ),
+ ),
+ (
+ "action_url",
+ models.CharField(
+ blank=True, max_length=500, verbose_name="Ссылка действия"
+ ),
+ ),
+ (
+ "action_text",
+ models.CharField(
+ default="Посмотреть",
+ max_length=100,
+ verbose_name="Текст кнопки",
+ ),
+ ),
+ (
+ "is_read",
+ models.BooleanField(
+ db_index=True, default=False, verbose_name="Прочитано"
+ ),
+ ),
+ (
+ "read_at",
+ models.DateTimeField(
+ blank=True, null=True, verbose_name="Прочитано когда"
+ ),
+ ),
+ (
+ "is_archived",
+ models.BooleanField(default=False, verbose_name="В архиве"),
+ ),
+ (
+ "archived_at",
+ models.DateTimeField(
+ blank=True, null=True, verbose_name="Заархивировано когда"
+ ),
+ ),
+ (
+ "sent_web",
+ models.BooleanField(
+ default=False, verbose_name="Отправлено на сайт"
+ ),
+ ),
+ (
+ "sent_email",
+ models.BooleanField(
+ default=False, verbose_name="Отправлено на email"
+ ),
+ ),
+ (
+ "sent_telegram",
+ models.BooleanField(
+ default=False, verbose_name="Отправлено в Telegram"
+ ),
+ ),
+ (
+ "sent_whatsapp",
+ models.BooleanField(
+ default=False, verbose_name="Отправлено в WhatsApp"
+ ),
+ ),
+ (
+ "sent_wechat",
+ models.BooleanField(
+ default=False, verbose_name="Отправлено в WeChat"
+ ),
+ ),
+ (
+ "sent_at",
+ models.DateTimeField(
+ blank=True, null=True, verbose_name="Отправлено когда"
+ ),
+ ),
+ (
+ "group_key",
+ models.CharField(
+ blank=True,
+ db_index=True,
+ help_text="Для объединения похожих уведомлений",
+ max_length=255,
+ verbose_name="Ключ группировки",
+ ),
+ ),
+ (
+ "is_grouped",
+ models.BooleanField(default=False, verbose_name="Сгруппировано"),
+ ),
+ (
+ "grouped_count",
+ models.IntegerField(
+ default=1,
+ help_text="Сколько уведомлений объединено",
+ verbose_name="Количество в группе",
+ ),
+ ),
+ (
+ "created_at",
+ models.DateTimeField(
+ auto_now_add=True, db_index=True, verbose_name="Создано"
+ ),
+ ),
+ (
+ "updated_at",
+ models.DateTimeField(auto_now=True, verbose_name="Обновлено"),
+ ),
+ (
+ "content_type",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ to="contenttypes.contenttype",
+ verbose_name="Тип объекта",
+ ),
+ ),
+ (
+ "recipient",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="notifications",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Получатель",
+ ),
+ ),
+ (
+ "notification_type",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to="notifications.notificationtype",
+ verbose_name="Тип",
+ ),
+ ),
],
options={
- 'verbose_name': 'Уведомление',
- 'verbose_name_plural': 'Уведомления',
- 'db_table': 'notifications_notification',
- 'ordering': ['-created_at'],
- 'indexes': [models.Index(fields=['recipient', '-created_at'], name='notificatio_recipie_a972ce_idx'), models.Index(fields=['recipient', 'is_read'], name='notificatio_recipie_4e3567_idx'), models.Index(fields=['notification_type', '-created_at'], name='notificatio_notific_c4ec61_idx'), models.Index(fields=['group_key', '-created_at'], name='notificatio_group_k_c495c5_idx')],
+ "verbose_name": "Уведомление",
+ "verbose_name_plural": "Уведомления",
+ "db_table": "notifications_notification",
+ "ordering": ["-created_at"],
+ "indexes": [
+ models.Index(
+ fields=["recipient", "-created_at"],
+ name="notificatio_recipie_a972ce_idx",
+ ),
+ models.Index(
+ fields=["recipient", "is_read"],
+ name="notificatio_recipie_4e3567_idx",
+ ),
+ models.Index(
+ fields=["notification_type", "-created_at"],
+ name="notificatio_notific_c4ec61_idx",
+ ),
+ models.Index(
+ fields=["group_key", "-created_at"],
+ name="notificatio_group_k_c495c5_idx",
+ ),
+ ],
},
),
migrations.CreateModel(
- name='UserNotificationSettings',
+ name="UserNotificationSettings",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('is_enabled', models.BooleanField(default=True, verbose_name='Включено')),
- ('send_web', models.BooleanField(default=True, verbose_name='Отправлять на сайт')),
- ('send_email', models.BooleanField(default=False, verbose_name='Отправлять на email')),
- ('send_telegram', models.BooleanField(default=False, verbose_name='Отправлять в Telegram')),
- ('send_whatsapp', models.BooleanField(default=False, verbose_name='Отправлять в WhatsApp')),
- ('send_wechat', models.BooleanField(default=False, verbose_name='Отправлять в WeChat')),
- ('quiet_hours_enabled', models.BooleanField(default=False, verbose_name='Тихий режим включен')),
- ('quiet_start_time', models.TimeField(blank=True, help_text='Например, 22:00', null=True, verbose_name='Начало тихого режима')),
- ('quiet_end_time', models.TimeField(blank=True, help_text='Например, 08:00', null=True, verbose_name='Конец тихого режима')),
- ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
- ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')),
- ('notification_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='notifications.notificationtype', verbose_name='Тип уведомления')),
- ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notification_settings', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "is_enabled",
+ models.BooleanField(default=True, verbose_name="Включено"),
+ ),
+ (
+ "send_web",
+ models.BooleanField(
+ default=True, verbose_name="Отправлять на сайт"
+ ),
+ ),
+ (
+ "send_email",
+ models.BooleanField(
+ default=False, verbose_name="Отправлять на email"
+ ),
+ ),
+ (
+ "send_telegram",
+ models.BooleanField(
+ default=False, verbose_name="Отправлять в Telegram"
+ ),
+ ),
+ (
+ "send_whatsapp",
+ models.BooleanField(
+ default=False, verbose_name="Отправлять в WhatsApp"
+ ),
+ ),
+ (
+ "send_wechat",
+ models.BooleanField(
+ default=False, verbose_name="Отправлять в WeChat"
+ ),
+ ),
+ (
+ "quiet_hours_enabled",
+ models.BooleanField(
+ default=False, verbose_name="Тихий режим включен"
+ ),
+ ),
+ (
+ "quiet_start_time",
+ models.TimeField(
+ blank=True,
+ help_text="Например, 22:00",
+ null=True,
+ verbose_name="Начало тихого режима",
+ ),
+ ),
+ (
+ "quiet_end_time",
+ models.TimeField(
+ blank=True,
+ help_text="Например, 08:00",
+ null=True,
+ verbose_name="Конец тихого режима",
+ ),
+ ),
+ (
+ "created_at",
+ models.DateTimeField(auto_now_add=True, verbose_name="Создано"),
+ ),
+ (
+ "updated_at",
+ models.DateTimeField(auto_now=True, verbose_name="Обновлено"),
+ ),
+ (
+ "notification_type",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to="notifications.notificationtype",
+ verbose_name="Тип уведомления",
+ ),
+ ),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="notification_settings",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Пользователь",
+ ),
+ ),
],
options={
- 'verbose_name': 'Настройка уведомлений пользователя',
- 'verbose_name_plural': 'Настройки уведомлений пользователей',
- 'db_table': 'notifications_user_settings',
- 'unique_together': {('user', 'notification_type')},
+ "verbose_name": "Настройка уведомлений пользователя",
+ "verbose_name_plural": "Настройки уведомлений пользователей",
+ "db_table": "notifications_user_settings",
+ "unique_together": {("user", "notification_type")},
},
),
]
diff --git a/backend/notifications/migrations/0002_usernotificationsettings_email_frequency.py b/backend/notifications/migrations/0002_usernotificationsettings_email_frequency.py
index 0502050e..8d20116f 100644
--- a/backend/notifications/migrations/0002_usernotificationsettings_email_frequency.py
+++ b/backend/notifications/migrations/0002_usernotificationsettings_email_frequency.py
@@ -4,15 +4,24 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('notifications', '0001_initial'),
+ ("notifications", "0001_initial"),
]
operations = [
migrations.AddField(
- model_name='usernotificationsettings',
- name='email_frequency',
- field=models.CharField(choices=[('instant', 'Мгновенно'), ('daily', 'Ежедневный дайджест'), ('weekly', 'Еженедельный дайджест'), ('disabled', 'Отключено')], default='instant', max_length=20, verbose_name='Частота email рассылки'),
+ model_name="usernotificationsettings",
+ name="email_frequency",
+ field=models.CharField(
+ choices=[
+ ("instant", "Мгновенно"),
+ ("daily", "Ежедневный дайджест"),
+ ("weekly", "Еженедельный дайджест"),
+ ("disabled", "Отключено"),
+ ],
+ default="instant",
+ max_length=20,
+ verbose_name="Частота email рассылки",
+ ),
),
]
diff --git a/backend/notifications/migrations/0003_alter_usernotificationsettings_email_frequency_and_more.py b/backend/notifications/migrations/0003_alter_usernotificationsettings_email_frequency_and_more.py
index 5973423f..dbde3086 100644
--- a/backend/notifications/migrations/0003_alter_usernotificationsettings_email_frequency_and_more.py
+++ b/backend/notifications/migrations/0003_alter_usernotificationsettings_email_frequency_and_more.py
@@ -6,40 +6,137 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('notifications', '0002_usernotificationsettings_email_frequency'),
+ ("notifications", "0002_usernotificationsettings_email_frequency"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
- model_name='usernotificationsettings',
- name='email_frequency',
- field=models.CharField(choices=[('instant', 'Мгновенно'), ('daily', 'Ежедневный дайджест'), ('weekly', 'Еженедельный дайджест')], default='instant', help_text='Как часто отправлять уведомления на email (только если email включен)', max_length=20, verbose_name='Частота email рассылки'),
+ model_name="usernotificationsettings",
+ name="email_frequency",
+ field=models.CharField(
+ choices=[
+ ("instant", "Мгновенно"),
+ ("daily", "Ежедневный дайджест"),
+ ("weekly", "Еженедельный дайджест"),
+ ],
+ default="instant",
+ help_text="Как часто отправлять уведомления на email (только если email включен)",
+ max_length=20,
+ verbose_name="Частота email рассылки",
+ ),
),
migrations.CreateModel(
- name='TelegramUser',
+ name="TelegramUser",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('telegram_id', models.BigIntegerField(help_text='Уникальный ID пользователя в Telegram', unique=True, verbose_name='Telegram ID')),
- ('telegram_username', models.CharField(blank=True, help_text='@username пользователя в Telegram', max_length=100, null=True, verbose_name='Telegram Username')),
- ('first_name', models.CharField(blank=True, max_length=100, verbose_name='Имя в Telegram')),
- ('last_name', models.CharField(blank=True, max_length=100, verbose_name='Фамилия в Telegram')),
- ('link_code', models.CharField(blank=True, help_text='Временный код для привязки аккаунта', max_length=10, null=True, unique=True, verbose_name='Код привязки')),
- ('link_code_created_at', models.DateTimeField(blank=True, null=True, verbose_name='Код создан')),
- ('is_active', models.BooleanField(default=True, verbose_name='Активен')),
- ('is_blocked', models.BooleanField(default=False, help_text='Пользователь заблокировал бота', verbose_name='Заблокирован')),
- ('linked_at', models.DateTimeField(auto_now_add=True, verbose_name='Привязан')),
- ('last_interaction_at', models.DateTimeField(blank=True, null=True, verbose_name='Последнее взаимодействие')),
- ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')),
- ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='telegram_account', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "telegram_id",
+ models.BigIntegerField(
+ help_text="Уникальный ID пользователя в Telegram",
+ unique=True,
+ verbose_name="Telegram ID",
+ ),
+ ),
+ (
+ "telegram_username",
+ models.CharField(
+ blank=True,
+ help_text="@username пользователя в Telegram",
+ max_length=100,
+ null=True,
+ verbose_name="Telegram Username",
+ ),
+ ),
+ (
+ "first_name",
+ models.CharField(
+ blank=True, max_length=100, verbose_name="Имя в Telegram"
+ ),
+ ),
+ (
+ "last_name",
+ models.CharField(
+ blank=True, max_length=100, verbose_name="Фамилия в Telegram"
+ ),
+ ),
+ (
+ "link_code",
+ models.CharField(
+ blank=True,
+ help_text="Временный код для привязки аккаунта",
+ max_length=10,
+ null=True,
+ unique=True,
+ verbose_name="Код привязки",
+ ),
+ ),
+ (
+ "link_code_created_at",
+ models.DateTimeField(
+ blank=True, null=True, verbose_name="Код создан"
+ ),
+ ),
+ (
+ "is_active",
+ models.BooleanField(default=True, verbose_name="Активен"),
+ ),
+ (
+ "is_blocked",
+ models.BooleanField(
+ default=False,
+ help_text="Пользователь заблокировал бота",
+ verbose_name="Заблокирован",
+ ),
+ ),
+ (
+ "linked_at",
+ models.DateTimeField(auto_now_add=True, verbose_name="Привязан"),
+ ),
+ (
+ "last_interaction_at",
+ models.DateTimeField(
+ blank=True, null=True, verbose_name="Последнее взаимодействие"
+ ),
+ ),
+ (
+ "updated_at",
+ models.DateTimeField(auto_now=True, verbose_name="Обновлено"),
+ ),
+ (
+ "user",
+ models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="telegram_account",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Пользователь",
+ ),
+ ),
],
options={
- 'verbose_name': 'Telegram аккаунт',
- 'verbose_name_plural': 'Telegram аккаунты',
- 'db_table': 'notifications_telegram_user',
- 'indexes': [models.Index(fields=['telegram_id'], name='notificatio_telegra_4cbfaa_idx'), models.Index(fields=['link_code'], name='notificatio_link_co_58a9a9_idx'), models.Index(fields=['user'], name='notificatio_user_id_d2ff2e_idx')],
+ "verbose_name": "Telegram аккаунт",
+ "verbose_name_plural": "Telegram аккаунты",
+ "db_table": "notifications_telegram_user",
+ "indexes": [
+ models.Index(
+ fields=["telegram_id"], name="notificatio_telegra_4cbfaa_idx"
+ ),
+ models.Index(
+ fields=["link_code"], name="notificatio_link_co_58a9a9_idx"
+ ),
+ models.Index(
+ fields=["user"], name="notificatio_user_id_d2ff2e_idx"
+ ),
+ ],
},
),
]
diff --git a/backend/notifications/migrations/0004_alter_telegramuser_telegram_id.py b/backend/notifications/migrations/0004_alter_telegramuser_telegram_id.py
index 965c35d1..46335e2c 100644
--- a/backend/notifications/migrations/0004_alter_telegramuser_telegram_id.py
+++ b/backend/notifications/migrations/0004_alter_telegramuser_telegram_id.py
@@ -4,15 +4,23 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('notifications', '0003_alter_usernotificationsettings_email_frequency_and_more'),
+ (
+ "notifications",
+ "0003_alter_usernotificationsettings_email_frequency_and_more",
+ ),
]
operations = [
migrations.AlterField(
- model_name='telegramuser',
- name='telegram_id',
- field=models.BigIntegerField(blank=True, help_text='Уникальный ID пользователя в Telegram', null=True, unique=True, verbose_name='Telegram ID'),
+ model_name="telegramuser",
+ name="telegram_id",
+ field=models.BigIntegerField(
+ blank=True,
+ help_text="Уникальный ID пользователя в Telegram",
+ null=True,
+ unique=True,
+ verbose_name="Telegram ID",
+ ),
),
]
diff --git a/backend/notifications/migrations/0005_add_web_push_subscription.py b/backend/notifications/migrations/0005_add_web_push_subscription.py
index 8f4d2111..338334a7 100644
--- a/backend/notifications/migrations/0005_add_web_push_subscription.py
+++ b/backend/notifications/migrations/0005_add_web_push_subscription.py
@@ -6,35 +6,96 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('notifications', '0004_alter_telegramuser_telegram_id'),
+ ("notifications", "0004_alter_telegramuser_telegram_id"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
- name='WebPushSubscription',
+ name="WebPushSubscription",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('endpoint', models.URLField(help_text='URL для отправки push уведомлений', max_length=512, verbose_name='Push Endpoint')),
- ('auth_key', models.CharField(help_text='Ключ авторизации (subscription.keys.auth)', max_length=512, verbose_name='Auth Key')),
- ('p256dh_key', models.CharField(help_text='Ключ шифрования (subscription.keys.p256dh)', max_length=512, verbose_name='P256DH Key')),
- ('user_agent', models.TextField(blank=True, help_text='Браузер и ОС', verbose_name='User Agent')),
- ('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='Обновлена')),
- ('last_used_at', models.DateTimeField(blank=True, null=True, verbose_name='Последнее использование')),
- ('error_count', models.IntegerField(default=0, verbose_name='Счётчик ошибок')),
- ('last_error', models.TextField(blank=True, verbose_name='Последняя ошибка')),
- ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='web_push_subscriptions', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "endpoint",
+ models.URLField(
+ help_text="URL для отправки push уведомлений",
+ max_length=512,
+ verbose_name="Push Endpoint",
+ ),
+ ),
+ (
+ "auth_key",
+ models.CharField(
+ help_text="Ключ авторизации (subscription.keys.auth)",
+ max_length=512,
+ verbose_name="Auth Key",
+ ),
+ ),
+ (
+ "p256dh_key",
+ models.CharField(
+ help_text="Ключ шифрования (subscription.keys.p256dh)",
+ max_length=512,
+ verbose_name="P256DH Key",
+ ),
+ ),
+ (
+ "user_agent",
+ models.TextField(
+ blank=True, help_text="Браузер и ОС", verbose_name="User Agent"
+ ),
+ ),
+ (
+ "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="Обновлена"),
+ ),
+ (
+ "last_used_at",
+ models.DateTimeField(
+ blank=True, null=True, verbose_name="Последнее использование"
+ ),
+ ),
+ (
+ "error_count",
+ models.IntegerField(default=0, verbose_name="Счётчик ошибок"),
+ ),
+ (
+ "last_error",
+ models.TextField(blank=True, verbose_name="Последняя ошибка"),
+ ),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="web_push_subscriptions",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Пользователь",
+ ),
+ ),
],
options={
- 'verbose_name': 'Web Push подписка',
- 'verbose_name_plural': 'Web Push подписки',
- 'db_table': 'notifications_web_push_subscription',
- 'ordering': ['-created_at'],
- 'unique_together': {('user', 'endpoint')},
+ "verbose_name": "Web Push подписка",
+ "verbose_name_plural": "Web Push подписки",
+ "db_table": "notifications_web_push_subscription",
+ "ordering": ["-created_at"],
+ "unique_together": {("user", "endpoint")},
},
),
]
diff --git a/backend/notifications/migrations/0005_procurement_notification_types.py b/backend/notifications/migrations/0005_procurement_notification_types.py
index 6e26a8cb..95145742 100644
--- a/backend/notifications/migrations/0005_procurement_notification_types.py
+++ b/backend/notifications/migrations/0005_procurement_notification_types.py
@@ -7,108 +7,107 @@ from django.db import migrations
def create_procurement_notification_types(apps, schema_editor):
"""Создать категорию и типы уведомлений для закупок."""
- NotificationCategory = apps.get_model('notifications', 'NotificationCategory')
- NotificationType = apps.get_model('notifications', 'NotificationType')
+ NotificationCategory = apps.get_model("notifications", "NotificationCategory")
+ NotificationType = apps.get_model("notifications", "NotificationType")
# Создаём категорию
category, _ = NotificationCategory.objects.get_or_create(
- code='procurement',
+ code="procurement",
defaults={
- 'name': 'Закупки',
- 'description': 'Уведомления модуля закупок и инвентаризации',
- 'icon': 'bi-cart3',
- 'color': '#17a2b8',
- 'order': 60,
- }
+ "name": "Закупки",
+ "description": "Уведомления модуля закупок и инвентаризации",
+ "icon": "bi-cart3",
+ "color": "#17a2b8",
+ "order": 60,
+ },
)
# Типы уведомлений
notification_types = [
{
- 'code': 'procurement_new_request',
- 'name': 'Новая заявка на закупку',
- 'description': 'Уведомление о создании новой заявки на закупку',
- 'priority': 'normal',
- 'default_channels': {'web': True, 'email': True, 'telegram': False},
+ "code": "procurement_new_request",
+ "name": "Новая заявка на закупку",
+ "description": "Уведомление о создании новой заявки на закупку",
+ "priority": "normal",
+ "default_channels": {"web": True, "email": True, "telegram": False},
},
{
- 'code': 'procurement_pending_approval',
- 'name': 'Требуется согласование',
- 'description': 'Заявка ожидает вашего согласования',
- 'priority': 'high',
- 'default_channels': {'web': True, 'email': True, 'telegram': True},
+ "code": "procurement_pending_approval",
+ "name": "Требуется согласование",
+ "description": "Заявка ожидает вашего согласования",
+ "priority": "high",
+ "default_channels": {"web": True, "email": True, "telegram": True},
},
{
- 'code': 'procurement_approved',
- 'name': 'Заявка одобрена',
- 'description': 'Ваша заявка на закупку была одобрена',
- 'priority': 'normal',
- 'default_channels': {'web': True, 'email': True, 'telegram': False},
+ "code": "procurement_approved",
+ "name": "Заявка одобрена",
+ "description": "Ваша заявка на закупку была одобрена",
+ "priority": "normal",
+ "default_channels": {"web": True, "email": True, "telegram": False},
},
{
- 'code': 'procurement_rejected',
- 'name': 'Заявка отклонена',
- 'description': 'Ваша заявка на закупку была отклонена',
- 'priority': 'high',
- 'default_channels': {'web': True, 'email': True, 'telegram': True},
+ "code": "procurement_rejected",
+ "name": "Заявка отклонена",
+ "description": "Ваша заявка на закупку была отклонена",
+ "priority": "high",
+ "default_channels": {"web": True, "email": True, "telegram": True},
},
{
- 'code': 'procurement_stage_approved',
- 'name': 'Этап согласования пройден',
- 'description': 'Один из этапов согласования заявки завершён',
- 'priority': 'low',
- 'default_channels': {'web': True, 'email': False, 'telegram': False},
+ "code": "procurement_stage_approved",
+ "name": "Этап согласования пройден",
+ "description": "Один из этапов согласования заявки завершён",
+ "priority": "low",
+ "default_channels": {"web": True, "email": False, "telegram": False},
},
{
- 'code': 'procurement_completed',
- 'name': 'Закупка завершена',
- 'description': 'Закупка по заявке успешно завершена',
- 'priority': 'normal',
- 'default_channels': {'web': True, 'email': True, 'telegram': False},
+ "code": "procurement_completed",
+ "name": "Закупка завершена",
+ "description": "Закупка по заявке успешно завершена",
+ "priority": "normal",
+ "default_channels": {"web": True, "email": True, "telegram": False},
},
{
- 'code': 'equipment_transferred',
- 'name': 'Оборудование передано',
- 'description': 'Оборудование передано в ваш отдел или вам',
- 'priority': 'normal',
- 'default_channels': {'web': True, 'email': False, 'telegram': False},
+ "code": "equipment_transferred",
+ "name": "Оборудование передано",
+ "description": "Оборудование передано в ваш отдел или вам",
+ "priority": "normal",
+ "default_channels": {"web": True, "email": False, "telegram": False},
},
{
- 'code': 'equipment_maintenance',
- 'name': 'Обслуживание оборудования',
- 'description': 'Запланировано или завершено обслуживание оборудования',
- 'priority': 'low',
- 'default_channels': {'web': True, 'email': False, 'telegram': False},
+ "code": "equipment_maintenance",
+ "name": "Обслуживание оборудования",
+ "description": "Запланировано или завершено обслуживание оборудования",
+ "priority": "low",
+ "default_channels": {"web": True, "email": False, "telegram": False},
},
]
for nt_data in notification_types:
NotificationType.objects.get_or_create(
- code=nt_data['code'],
+ code=nt_data["code"],
defaults={
- 'category': category,
- 'name': nt_data['name'],
- 'description': nt_data['description'],
- 'priority': nt_data['priority'],
- 'default_channels': nt_data['default_channels'],
- }
+ "category": category,
+ "name": nt_data["name"],
+ "description": nt_data["description"],
+ "priority": nt_data["priority"],
+ "default_channels": nt_data["default_channels"],
+ },
)
def reverse_procurement_notification_types(apps, schema_editor):
"""Удалить типы уведомлений для закупок."""
- NotificationCategory = apps.get_model('notifications', 'NotificationCategory')
- NotificationType = apps.get_model('notifications', 'NotificationType')
+ NotificationCategory = apps.get_model("notifications", "NotificationCategory")
+ NotificationType = apps.get_model("notifications", "NotificationType")
- NotificationType.objects.filter(code__startswith='procurement_').delete()
- NotificationType.objects.filter(code__startswith='equipment_').delete()
- NotificationCategory.objects.filter(code='procurement').delete()
+ NotificationType.objects.filter(code__startswith="procurement_").delete()
+ NotificationType.objects.filter(code__startswith="equipment_").delete()
+ NotificationCategory.objects.filter(code="procurement").delete()
class Migration(migrations.Migration):
-
dependencies = [
- ('notifications', '0004_alter_telegramuser_telegram_id'),
+ ("notifications", "0004_alter_telegramuser_telegram_id"),
]
operations = [
diff --git a/backend/notifications/migrations/0006_add_device_name.py b/backend/notifications/migrations/0006_add_device_name.py
index 7fec502f..1add4545 100644
--- a/backend/notifications/migrations/0006_add_device_name.py
+++ b/backend/notifications/migrations/0006_add_device_name.py
@@ -4,15 +4,20 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('notifications', '0005_add_web_push_subscription'),
+ ("notifications", "0005_add_web_push_subscription"),
]
operations = [
migrations.AddField(
- model_name='webpushsubscription',
- name='device_name',
- field=models.CharField(blank=True, default='', help_text='iOS Device, Windows (Chrome), macOS (Safari), и т.д.', max_length=255, verbose_name='Название устройства'),
+ model_name="webpushsubscription",
+ name="device_name",
+ field=models.CharField(
+ blank=True,
+ default="",
+ help_text="iOS Device, Windows (Chrome), macOS (Safari), и т.д.",
+ max_length=255,
+ verbose_name="Название устройства",
+ ),
),
]
diff --git a/backend/notifications/migrations/0007_merge_20260227_1639.py b/backend/notifications/migrations/0007_merge_20260227_1639.py
index b9ac4cff..ce6179e3 100644
--- a/backend/notifications/migrations/0007_merge_20260227_1639.py
+++ b/backend/notifications/migrations/0007_merge_20260227_1639.py
@@ -4,11 +4,9 @@ from django.db import migrations
class Migration(migrations.Migration):
-
dependencies = [
- ('notifications', '0005_procurement_notification_types'),
- ('notifications', '0006_add_device_name'),
+ ("notifications", "0005_procurement_notification_types"),
+ ("notifications", "0006_add_device_name"),
]
- operations = [
- ]
+ operations = []
diff --git a/backend/notifications/migrations/0008_migrate_webpush_to_library.py b/backend/notifications/migrations/0008_migrate_webpush_to_library.py
index 5ad7b975..4108c184 100644
--- a/backend/notifications/migrations/0008_migrate_webpush_to_library.py
+++ b/backend/notifications/migrations/0008_migrate_webpush_to_library.py
@@ -5,15 +5,15 @@ from django.db import migrations
def migrate_webpush_subscriptions(apps, schema_editor):
"""Мигрировать существующие WebPushSubscription в WebPushDevice"""
- WebPushSubscription = apps.get_model('notifications', 'WebPushSubscription')
- WebPushDevice = apps.get_model('push_notifications', 'WebPushDevice')
+ WebPushSubscription = apps.get_model("notifications", "WebPushSubscription")
+ WebPushDevice = apps.get_model("push_notifications", "WebPushDevice")
migrated_count = 0
for subscription in WebPushSubscription.objects.filter(is_active=True):
try:
# Создаем WebPushDevice для каждой подписки
# browser поле ограничено 10 символами
- browser_name = (subscription.device_name or 'Unknown')[:10]
+ browser_name = (subscription.device_name or "Unknown")[:10]
WebPushDevice.objects.create(
user=subscription.user,
registration_id=subscription.endpoint,
@@ -26,21 +26,28 @@ def migrate_webpush_subscriptions(apps, schema_editor):
except Exception as e:
print(f"Error migrating subscription {subscription.id}: {e}")
- print(f"✅ Migrated {migrated_count} WebPush subscriptions to django-push-notifications")
+ print(
+ f"✅ Migrated {migrated_count} WebPush subscriptions to django-push-notifications"
+ )
def reverse_migration(apps, schema_editor):
"""Откат: удаляем все WebPushDevice"""
- WebPushDevice = apps.get_model('push_notifications', 'WebPushDevice')
+ WebPushDevice = apps.get_model("push_notifications", "WebPushDevice")
count = WebPushDevice.objects.all().delete()[0]
print(f"Deleted {count} WebPushDevice records")
class Migration(migrations.Migration):
-
dependencies = [
- ('notifications', '0007_merge_20260227_1639'), # Последняя миграция notifications
- ('push_notifications', '0010_alter_gcmdevice_options_and_more'), # Последняя миграция библиотеки
+ (
+ "notifications",
+ "0007_merge_20260227_1639",
+ ), # Последняя миграция notifications
+ (
+ "push_notifications",
+ "0010_alter_gcmdevice_options_and_more",
+ ), # Последняя миграция библиотеки
]
operations = [
diff --git a/backend/notifications/migrations/0009_add_document_related_notification_type.py b/backend/notifications/migrations/0009_add_document_related_notification_type.py
index 4326caef..8b44df95 100644
--- a/backend/notifications/migrations/0009_add_document_related_notification_type.py
+++ b/backend/notifications/migrations/0009_add_document_related_notification_type.py
@@ -7,64 +7,56 @@ def add_document_related_notification_type(apps, schema_editor):
"""
Добавляет новый тип уведомления для связанных документов.
"""
- NotificationCategory = apps.get_model('notifications', 'NotificationCategory')
- NotificationType = apps.get_model('notifications', 'NotificationType')
+ NotificationCategory = apps.get_model("notifications", "NotificationCategory")
+ NotificationType = apps.get_model("notifications", "NotificationType")
# Получаем категорию "Документы"
try:
- documents_category = NotificationCategory.objects.get(code='documents')
+ documents_category = NotificationCategory.objects.get(code="documents")
except NotificationCategory.DoesNotExist:
# Если категории нет, создаём её
documents_category = NotificationCategory.objects.create(
- code='documents',
- name='Документы',
- description='Уведомления о документах',
- icon='bi-file-earmark-text',
- color='primary',
+ code="documents",
+ name="Документы",
+ description="Уведомления о документах",
+ icon="bi-file-earmark-text",
+ color="primary",
is_active=True,
- order=2
+ order=2,
)
# Создаём новый тип уведомления (если его ещё нет)
NotificationType.objects.get_or_create(
- code='document_related',
+ code="document_related",
defaults={
- 'category': documents_category,
- 'name': 'Связанный документ',
- 'description': 'Уведомление о добавлении связанного документа',
- 'default_enabled': True,
- 'default_channels': {
- 'web': True,
- 'email': False,
- 'telegram': False
- },
- 'priority': 'normal',
- 'is_required': False,
- 'is_groupable': True,
- 'grouping_window_minutes': 10,
- 'is_active': True,
- }
+ "category": documents_category,
+ "name": "Связанный документ",
+ "description": "Уведомление о добавлении связанного документа",
+ "default_enabled": True,
+ "default_channels": {"web": True, "email": False, "telegram": False},
+ "priority": "normal",
+ "is_required": False,
+ "is_groupable": True,
+ "grouping_window_minutes": 10,
+ "is_active": True,
+ },
)
# Также добавим тип для ответов на комментарии (если нужен отдельный)
NotificationType.objects.get_or_create(
- code='document_comment_reply',
+ code="document_comment_reply",
defaults={
- 'category': documents_category,
- 'name': 'Ответ на комментарий',
- 'description': 'Уведомление об ответе на ваш комментарий к документу',
- 'default_enabled': True,
- 'default_channels': {
- 'web': True,
- 'email': False,
- 'telegram': False
- },
- 'priority': 'normal',
- 'is_required': False,
- 'is_groupable': True,
- 'grouping_window_minutes': 5,
- 'is_active': True,
- }
+ "category": documents_category,
+ "name": "Ответ на комментарий",
+ "description": "Уведомление об ответе на ваш комментарий к документу",
+ "default_enabled": True,
+ "default_channels": {"web": True, "email": False, "telegram": False},
+ "priority": "normal",
+ "is_required": False,
+ "is_groupable": True,
+ "grouping_window_minutes": 5,
+ "is_active": True,
+ },
)
@@ -72,22 +64,21 @@ def remove_document_related_notification_type(apps, schema_editor):
"""
Откат: удаляет добавленный тип уведомления.
"""
- NotificationType = apps.get_model('notifications', 'NotificationType')
+ NotificationType = apps.get_model("notifications", "NotificationType")
NotificationType.objects.filter(
- code__in=['document_related', 'document_comment_reply']
+ code__in=["document_related", "document_comment_reply"]
).delete()
class Migration(migrations.Migration):
-
dependencies = [
- ('notifications', '0008_migrate_webpush_to_library'),
+ ("notifications", "0008_migrate_webpush_to_library"),
]
operations = [
migrations.RunPython(
add_document_related_notification_type,
- remove_document_related_notification_type
+ remove_document_related_notification_type,
),
]
diff --git a/backend/notifications/migrations/0010_drop_old_tables.py b/backend/notifications/migrations/0010_drop_old_tables.py
index eef87cb3..8b9245a2 100644
--- a/backend/notifications/migrations/0010_drop_old_tables.py
+++ b/backend/notifications/migrations/0010_drop_old_tables.py
@@ -4,9 +4,8 @@ from django.db import migrations
class Migration(migrations.Migration):
-
dependencies = [
- ('notifications', '0009_add_document_related_notification_type'),
+ ("notifications", "0009_add_document_related_notification_type"),
]
operations = [
@@ -30,13 +29,13 @@ class Migration(migrations.Migration):
state_operations=[
# Удаляем модели из состояния Django
# Порядок важен: сначала связанные модели, потом основные
- migrations.DeleteModel(name='UserNotificationSettings'),
- migrations.DeleteModel(name='NotificationTemplate'),
- migrations.DeleteModel(name='TelegramUser'),
- migrations.DeleteModel(name='WebPushSubscription'),
- migrations.DeleteModel(name='Notification'),
- migrations.DeleteModel(name='NotificationType'),
- migrations.DeleteModel(name='NotificationCategory'),
+ migrations.DeleteModel(name="UserNotificationSettings"),
+ migrations.DeleteModel(name="NotificationTemplate"),
+ migrations.DeleteModel(name="TelegramUser"),
+ migrations.DeleteModel(name="WebPushSubscription"),
+ migrations.DeleteModel(name="Notification"),
+ migrations.DeleteModel(name="NotificationType"),
+ migrations.DeleteModel(name="NotificationCategory"),
],
),
]
diff --git a/backend/notifications/migrations/0011_initial.py b/backend/notifications/migrations/0011_initial.py
index d7ebb1f1..c203fb30 100644
--- a/backend/notifications/migrations/0011_initial.py
+++ b/backend/notifications/migrations/0011_initial.py
@@ -7,64 +7,298 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
initial = True
dependencies = [
- ('contenttypes', '0002_remove_content_type_name'),
- ('notifications', '0010_drop_old_tables'),
+ ("contenttypes", "0002_remove_content_type_name"),
+ ("notifications", "0010_drop_old_tables"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
- name='UserChannelPreferences',
+ name="UserChannelPreferences",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('web_enabled', models.BooleanField(default=True, help_text='Показывать в интерфейсе сайта', verbose_name='Веб уведомления')),
- ('email_enabled', models.BooleanField(default=False, verbose_name='Email уведомления')),
- ('push_enabled', models.BooleanField(default=False, help_text='Web Push для браузера', verbose_name='Push уведомления')),
- ('email_frequency', models.CharField(choices=[('instant', 'Мгновенно'), ('daily', 'Ежедневный дайджест'), ('weekly', 'Еженедельный дайджест'), ('never', 'Никогда')], default='instant', max_length=20, verbose_name='Частота email')),
- ('dnd_enabled', models.BooleanField(default=False, help_text='Отключить все уведомления в определенное время', verbose_name='Режим "Не беспокоить"')),
- ('dnd_start_time', models.TimeField(blank=True, help_text='Например, 22:00', null=True, verbose_name='Начало тихого режима')),
- ('dnd_end_time', models.TimeField(blank=True, help_text='Например, 08:00', null=True, verbose_name='Конец тихого режима')),
- ('disabled_verbs', models.JSONField(blank=True, default=list, help_text='Список verb для которых не показывать уведомления. Например: ["liked", "followed"]', verbose_name='Отключенные типы')),
- ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='channel_preferences', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "web_enabled",
+ models.BooleanField(
+ default=True,
+ help_text="Показывать в интерфейсе сайта",
+ verbose_name="Веб уведомления",
+ ),
+ ),
+ (
+ "email_enabled",
+ models.BooleanField(
+ default=False, verbose_name="Email уведомления"
+ ),
+ ),
+ (
+ "push_enabled",
+ models.BooleanField(
+ default=False,
+ help_text="Web Push для браузера",
+ verbose_name="Push уведомления",
+ ),
+ ),
+ (
+ "email_frequency",
+ models.CharField(
+ choices=[
+ ("instant", "Мгновенно"),
+ ("daily", "Ежедневный дайджест"),
+ ("weekly", "Еженедельный дайджест"),
+ ("never", "Никогда"),
+ ],
+ default="instant",
+ max_length=20,
+ verbose_name="Частота email",
+ ),
+ ),
+ (
+ "dnd_enabled",
+ models.BooleanField(
+ default=False,
+ help_text="Отключить все уведомления в определенное время",
+ verbose_name='Режим "Не беспокоить"',
+ ),
+ ),
+ (
+ "dnd_start_time",
+ models.TimeField(
+ blank=True,
+ help_text="Например, 22:00",
+ null=True,
+ verbose_name="Начало тихого режима",
+ ),
+ ),
+ (
+ "dnd_end_time",
+ models.TimeField(
+ blank=True,
+ help_text="Например, 08:00",
+ null=True,
+ verbose_name="Конец тихого режима",
+ ),
+ ),
+ (
+ "disabled_verbs",
+ models.JSONField(
+ blank=True,
+ default=list,
+ help_text='Список verb для которых не показывать уведомления. Например: ["liked", "followed"]',
+ verbose_name="Отключенные типы",
+ ),
+ ),
+ (
+ "user",
+ models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="channel_preferences",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Пользователь",
+ ),
+ ),
],
options={
- 'verbose_name': 'Настройки каналов пользователя',
- 'verbose_name_plural': 'Настройки каналов пользователей',
- 'db_table': 'notifications_userchannelpreferences',
+ "verbose_name": "Настройки каналов пользователя",
+ "verbose_name_plural": "Настройки каналов пользователей",
+ "db_table": "notifications_userchannelpreferences",
},
),
migrations.CreateModel(
- name='Notification',
+ name="Notification",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('actor_object_id', models.CharField(blank=True, max_length=255, null=True, verbose_name='ID актора')),
- ('verb', models.CharField(db_index=True, default='notification', help_text='Например: liked, commented, approved, mentioned', max_length=255, verbose_name='Действие')),
- ('action_object_object_id', models.CharField(blank=True, max_length=255, null=True, verbose_name='ID объекта действия')),
- ('target_object_id', models.CharField(blank=True, max_length=255, null=True, verbose_name='ID целевого объекта')),
- ('description', models.TextField(blank=True, help_text='Человекочитаемое описание уведомления', null=True, verbose_name='Описание')),
- ('action_url', models.CharField(blank=True, help_text='Куда перейти при клике на уведомление', max_length=500, null=True, verbose_name='URL действия')),
- ('data', models.JSONField(blank=True, default=dict, help_text='Любые метаданные для рендеринга или логики', verbose_name='Дополнительные данные')),
- ('unread', models.BooleanField(db_index=True, default=True, verbose_name='Непрочитано')),
- ('timestamp_read', models.DateTimeField(blank=True, null=True, verbose_name='Время прочтения')),
- ('public', models.BooleanField(db_index=True, default=True, help_text='Можно показывать другим пользователям', verbose_name='Публичное')),
- ('deleted', models.BooleanField(db_index=True, default=False, help_text='Мягкое удаление', verbose_name='Удалено')),
- ('emailed', models.BooleanField(db_index=True, default=False, verbose_name='Отправлено на email')),
- ('timestamp', models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='Создано')),
- ('action_object_content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notify_action_object', to='contenttypes.contenttype', verbose_name='Тип объекта действия')),
- ('actor_content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notify_actor', to='contenttypes.contenttype', verbose_name='Тип актора')),
- ('recipient', models.ForeignKey(help_text='Кому адресовано уведомление', on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL, verbose_name='Получатель')),
- ('target_content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notify_target', to='contenttypes.contenttype', verbose_name='Тип целевого объекта')),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "actor_object_id",
+ models.CharField(
+ blank=True, max_length=255, null=True, verbose_name="ID актора"
+ ),
+ ),
+ (
+ "verb",
+ models.CharField(
+ db_index=True,
+ default="notification",
+ help_text="Например: liked, commented, approved, mentioned",
+ max_length=255,
+ verbose_name="Действие",
+ ),
+ ),
+ (
+ "action_object_object_id",
+ models.CharField(
+ blank=True,
+ max_length=255,
+ null=True,
+ verbose_name="ID объекта действия",
+ ),
+ ),
+ (
+ "target_object_id",
+ models.CharField(
+ blank=True,
+ max_length=255,
+ null=True,
+ verbose_name="ID целевого объекта",
+ ),
+ ),
+ (
+ "description",
+ models.TextField(
+ blank=True,
+ help_text="Человекочитаемое описание уведомления",
+ null=True,
+ verbose_name="Описание",
+ ),
+ ),
+ (
+ "action_url",
+ models.CharField(
+ blank=True,
+ help_text="Куда перейти при клике на уведомление",
+ max_length=500,
+ null=True,
+ verbose_name="URL действия",
+ ),
+ ),
+ (
+ "data",
+ models.JSONField(
+ blank=True,
+ default=dict,
+ help_text="Любые метаданные для рендеринга или логики",
+ verbose_name="Дополнительные данные",
+ ),
+ ),
+ (
+ "unread",
+ models.BooleanField(
+ db_index=True, default=True, verbose_name="Непрочитано"
+ ),
+ ),
+ (
+ "timestamp_read",
+ models.DateTimeField(
+ blank=True, null=True, verbose_name="Время прочтения"
+ ),
+ ),
+ (
+ "public",
+ models.BooleanField(
+ db_index=True,
+ default=True,
+ help_text="Можно показывать другим пользователям",
+ verbose_name="Публичное",
+ ),
+ ),
+ (
+ "deleted",
+ models.BooleanField(
+ db_index=True,
+ default=False,
+ help_text="Мягкое удаление",
+ verbose_name="Удалено",
+ ),
+ ),
+ (
+ "emailed",
+ models.BooleanField(
+ db_index=True, default=False, verbose_name="Отправлено на email"
+ ),
+ ),
+ (
+ "timestamp",
+ models.DateTimeField(
+ db_index=True,
+ default=django.utils.timezone.now,
+ verbose_name="Создано",
+ ),
+ ),
+ (
+ "action_object_content_type",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="notify_action_object",
+ to="contenttypes.contenttype",
+ verbose_name="Тип объекта действия",
+ ),
+ ),
+ (
+ "actor_content_type",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="notify_actor",
+ to="contenttypes.contenttype",
+ verbose_name="Тип актора",
+ ),
+ ),
+ (
+ "recipient",
+ models.ForeignKey(
+ help_text="Кому адресовано уведомление",
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="notifications",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Получатель",
+ ),
+ ),
+ (
+ "target_content_type",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="notify_target",
+ to="contenttypes.contenttype",
+ verbose_name="Тип целевого объекта",
+ ),
+ ),
],
options={
- 'verbose_name': 'Уведомление',
- 'verbose_name_plural': 'Уведомления',
- 'db_table': 'notifications_notification_v2',
- 'ordering': ['-timestamp'],
- 'indexes': [models.Index(fields=['recipient', '-timestamp'], name='notificatio_recipie_91dc91_idx'), models.Index(fields=['recipient', 'unread', '-timestamp'], name='notificatio_recipie_9b2135_idx'), models.Index(fields=['verb', '-timestamp'], name='notificatio_verb_1060e9_idx'), models.Index(fields=['-timestamp'], name='notificatio_timesta_e0a5cf_idx')],
+ "verbose_name": "Уведомление",
+ "verbose_name_plural": "Уведомления",
+ "db_table": "notifications_notification_v2",
+ "ordering": ["-timestamp"],
+ "indexes": [
+ models.Index(
+ fields=["recipient", "-timestamp"],
+ name="notificatio_recipie_91dc91_idx",
+ ),
+ models.Index(
+ fields=["recipient", "unread", "-timestamp"],
+ name="notificatio_recipie_9b2135_idx",
+ ),
+ models.Index(
+ fields=["verb", "-timestamp"],
+ name="notificatio_verb_1060e9_idx",
+ ),
+ models.Index(
+ fields=["-timestamp"], name="notificatio_timesta_e0a5cf_idx"
+ ),
+ ],
},
),
]
diff --git a/backend/notifications/migrations/0012_split_regulation_notifications.py b/backend/notifications/migrations/0012_split_regulation_notifications.py
index 1ced4644..c3ee4bbe 100644
--- a/backend/notifications/migrations/0012_split_regulation_notifications.py
+++ b/backend/notifications/migrations/0012_split_regulation_notifications.py
@@ -1,6 +1,5 @@
from django.db import migrations
-
DOCUMENT_TO_REGULATION_VERBS = {
"document_ready": "regulation_ready",
"document_signed_all": "regulation_signed_all",
@@ -10,19 +9,14 @@ DOCUMENT_TO_REGULATION_VERBS = {
def _regulation_metadata(document):
- if (
- document.acknowledgement_required
- and not document.acknowledgement_for_all
- ):
+ if document.acknowledgement_required and not document.acknowledgement_for_all:
departments = document.acknowledgement_departments.all()
elif not document.sent_to_all:
departments = document.departments.all()
else:
departments = document.departments.none()
- department_rows = list(
- departments.order_by("name", "id").values("id", "name")
- )
+ department_rows = list(departments.order_by("name", "id").values("id", "name"))
if department_rows:
scope = "department"
elif document.sent_to_all:
@@ -34,9 +28,7 @@ def _regulation_metadata(document):
"is_regulation": True,
"regulation_scope": scope,
"regulation_department_ids": [row["id"] for row in department_rows],
- "regulation_department_names": [
- row["name"] for row in department_rows
- ],
+ "regulation_department_names": [row["name"] for row in department_rows],
}
@@ -72,9 +64,7 @@ def split_existing_regulation_notifications(apps, schema_editor):
document_id = data.get("document_id")
regulation = regulations.get(str(document_id))
if regulation is None:
- regulation = regulations.get(
- str(notification.action_object_object_id)
- )
+ regulation = regulations.get(str(notification.action_object_object_id))
if regulation is None:
continue
@@ -93,15 +83,15 @@ def split_existing_regulation_notifications(apps, schema_editor):
}
if notification.description:
notification.description = (
- notification.description
- .replace("загрузил документ", "опубликовал регламент")
+ notification.description.replace(
+ "загрузил документ", "опубликовал регламент"
+ )
.replace("Документ", "Регламент")
.replace("документом", "регламентом")
.replace("документ", "регламент")
)
notification.action_url = (
- "/documents?section=regulations"
- f"&document={resolved_document_id}"
+ f"/documents?section=regulations&document={resolved_document_id}"
)
pending.append(notification)
@@ -119,9 +109,7 @@ def split_existing_regulation_notifications(apps, schema_editor):
).iterator():
disabled_verbs = list(preferences.disabled_verbs or [])
changed = False
- for document_verb, regulation_verb in (
- DOCUMENT_TO_REGULATION_VERBS.items()
- ):
+ for document_verb, regulation_verb in DOCUMENT_TO_REGULATION_VERBS.items():
if (
document_verb in disabled_verbs
and regulation_verb not in disabled_verbs
@@ -148,14 +136,16 @@ def merge_regulation_notifications_back(apps, schema_editor):
)
reverse_verbs = {
regulation_verb: document_verb
- for document_verb, regulation_verb in (
- DOCUMENT_TO_REGULATION_VERBS.items()
- )
+ for document_verb, regulation_verb in (DOCUMENT_TO_REGULATION_VERBS.items())
}
pending = []
- for notification in Notification.objects.filter(
- verb__in=reverse_verbs,
- ).only("id", "verb", "data", "action_url").iterator():
+ for notification in (
+ Notification.objects.filter(
+ verb__in=reverse_verbs,
+ )
+ .only("id", "verb", "data", "action_url")
+ .iterator()
+ ):
data = notification.data if isinstance(notification.data, dict) else {}
for key in (
"is_regulation",
@@ -182,9 +172,7 @@ def merge_regulation_notifications_back(apps, schema_editor):
"disabled_verbs",
).iterator():
disabled_verbs = list(preferences.disabled_verbs or [])
- filtered_verbs = [
- verb for verb in disabled_verbs if verb not in reverse_verbs
- ]
+ filtered_verbs = [verb for verb in disabled_verbs if verb not in reverse_verbs]
if filtered_verbs != disabled_verbs:
preferences.disabled_verbs = filtered_verbs
preferences_to_update.append(preferences)
diff --git a/backend/notifications/migrations/0013_notificationdeliveryrouting_notificationinstallation.py b/backend/notifications/migrations/0013_notificationdeliveryrouting_notificationinstallation.py
index 6a27ab38..aa741128 100644
--- a/backend/notifications/migrations/0013_notificationdeliveryrouting_notificationinstallation.py
+++ b/backend/notifications/migrations/0013_notificationdeliveryrouting_notificationinstallation.py
@@ -1,56 +1,120 @@
# Generated by Django 5.2.4 on 2026-08-05 11:44
+import uuid
+
import django.db.models.deletion
import django.utils.timezone
-import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('notifications', '0012_split_regulation_notifications'),
+ ("notifications", "0012_split_regulation_notifications"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
- name='NotificationDeliveryRouting',
+ name="NotificationDeliveryRouting",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('device_group_id', models.UUIDField(db_index=True)),
- ('routes', models.JSONField(blank=True, default=dict)),
- ('created_at', models.DateTimeField(auto_now_add=True)),
- ('updated_at', models.DateTimeField(auto_now=True)),
- ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notification_delivery_routings', to=settings.AUTH_USER_MODEL)),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("device_group_id", models.UUIDField(db_index=True)),
+ ("routes", models.JSONField(blank=True, default=dict)),
+ ("created_at", models.DateTimeField(auto_now_add=True)),
+ ("updated_at", models.DateTimeField(auto_now=True)),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="notification_delivery_routings",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
],
options={
- 'db_table': 'notifications_delivery_routing',
- 'constraints': [models.UniqueConstraint(fields=('user', 'device_group_id'), name='notifications_unique_routing_per_device_group')],
+ "db_table": "notifications_delivery_routing",
+ "constraints": [
+ models.UniqueConstraint(
+ fields=("user", "device_group_id"),
+ name="notifications_unique_routing_per_device_group",
+ )
+ ],
},
),
migrations.CreateModel(
- name='NotificationInstallation',
+ name="NotificationInstallation",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('installation_id', models.UUIDField(default=uuid.uuid4)),
- ('device_group_id', models.UUIDField(db_index=True)),
- ('app_kind', models.CharField(choices=[('main', 'Основное приложение'), ('messenger', 'Мессенджер')], max_length=20)),
- ('endpoint', models.URLField(blank=True, max_length=1000, null=True, unique=True)),
- ('push_device_id', models.PositiveBigIntegerField(blank=True, null=True, unique=True)),
- ('device_name', models.CharField(blank=True, max_length=255)),
- ('installed', models.BooleanField(default=False)),
- ('active', models.BooleanField(default=True)),
- ('last_seen_at', models.DateTimeField(default=django.utils.timezone.now)),
- ('created_at', models.DateTimeField(auto_now_add=True)),
- ('updated_at', models.DateTimeField(auto_now=True)),
- ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notification_installations', to=settings.AUTH_USER_MODEL)),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("installation_id", models.UUIDField(default=uuid.uuid4)),
+ ("device_group_id", models.UUIDField(db_index=True)),
+ (
+ "app_kind",
+ models.CharField(
+ choices=[
+ ("main", "Основное приложение"),
+ ("messenger", "Мессенджер"),
+ ],
+ max_length=20,
+ ),
+ ),
+ (
+ "endpoint",
+ models.URLField(
+ blank=True, max_length=1000, null=True, unique=True
+ ),
+ ),
+ (
+ "push_device_id",
+ models.PositiveBigIntegerField(blank=True, null=True, unique=True),
+ ),
+ ("device_name", models.CharField(blank=True, max_length=255)),
+ ("installed", models.BooleanField(default=False)),
+ ("active", models.BooleanField(default=True)),
+ (
+ "last_seen_at",
+ models.DateTimeField(default=django.utils.timezone.now),
+ ),
+ ("created_at", models.DateTimeField(auto_now_add=True)),
+ ("updated_at", models.DateTimeField(auto_now=True)),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="notification_installations",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
],
options={
- 'db_table': 'notifications_installation',
- 'ordering': ('-last_seen_at',),
- 'constraints': [models.UniqueConstraint(fields=('user', 'installation_id'), name='notifications_unique_installation_per_user'), models.UniqueConstraint(fields=('user', 'device_group_id', 'app_kind'), name='notifications_unique_app_per_device_group')],
+ "db_table": "notifications_installation",
+ "ordering": ("-last_seen_at",),
+ "constraints": [
+ models.UniqueConstraint(
+ fields=("user", "installation_id"),
+ name="notifications_unique_installation_per_user",
+ ),
+ models.UniqueConstraint(
+ fields=("user", "device_group_id", "app_kind"),
+ name="notifications_unique_app_per_device_group",
+ ),
+ ],
},
),
]
diff --git a/backend/notifications/models.py b/backend/notifications/models.py
index 2c2c50e1..25b57768 100644
--- a/backend/notifications/models.py
+++ b/backend/notifications/models.py
@@ -18,12 +18,12 @@ TODO: Добавить тесты для моделей:
import uuid
-from django.db import models
from django.conf import settings
-from django.utils import timezone
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
+from django.db import models
from django.db.models.query import QuerySet
+from django.utils import timezone
class NotificationQuerySet(QuerySet):
@@ -297,9 +297,7 @@ class UserChannelPreferences(models.Model):
help_text="Показывать в интерфейсе сайта",
)
- email_enabled = models.BooleanField(
- default=False, verbose_name="Email уведомления"
- )
+ email_enabled = models.BooleanField(default=False, verbose_name="Email уведомления")
push_enabled = models.BooleanField(
default=False,
@@ -352,7 +350,7 @@ class UserChannelPreferences(models.Model):
blank=True,
verbose_name="Отключенные типы",
help_text=(
- 'Список verb для которых не показывать уведомления. '
+ "Список verb для которых не показывать уведомления. "
'Например: ["liked", "followed"]'
),
)
@@ -383,11 +381,7 @@ class UserChannelPreferences(models.Model):
def is_in_dnd_period(self):
"""Проверить, находимся ли в режиме "Не беспокоить"""
- if (
- not self.dnd_enabled
- or not self.dnd_start_time
- or not self.dnd_end_time
- ):
+ if not self.dnd_enabled or not self.dnd_start_time or not self.dnd_end_time:
return False
now = timezone.now().time()
diff --git a/backend/notifications/push_actions.py b/backend/notifications/push_actions.py
index 13f39ab8..a835a4aa 100644
--- a/backend/notifications/push_actions.py
+++ b/backend/notifications/push_actions.py
@@ -1,6 +1,5 @@
from django.core import signing
-
PUSH_READ_SALT = "notifications.push.read.v1"
PUSH_REPLY_SALT = "notifications.push.reply.v1"
PUSH_AVATAR_SALT = "notifications.push.avatar.v1"
diff --git a/backend/notifications/push_lifecycle.py b/backend/notifications/push_lifecycle.py
index a460d618..06f97483 100644
--- a/backend/notifications/push_lifecycle.py
+++ b/backend/notifications/push_lifecycle.py
@@ -9,7 +9,6 @@ from notifications.cache import invalidate_unread_summary
from notifications.models import Notification
from notifications.realtime import send_notifications_deleted_event
-
PUSH_CONTROL_ID_BATCH_SIZE = 150
diff --git a/backend/notifications/routing.py b/backend/notifications/routing.py
index 754a879a..14bd971c 100644
--- a/backend/notifications/routing.py
+++ b/backend/notifications/routing.py
@@ -2,7 +2,6 @@ from __future__ import annotations
from collections.abc import Iterable
-
APP_MAIN = "main"
APP_MESSENGER = "messenger"
VALID_APPS = {APP_MAIN, APP_MESSENGER}
diff --git a/backend/notifications/senders/__init__.py b/backend/notifications/senders/__init__.py
index cc68a049..cf11d7e2 100644
--- a/backend/notifications/senders/__init__.py
+++ b/backend/notifications/senders/__init__.py
@@ -1,14 +1,15 @@
"""
Отправители уведомлений по различным каналам
"""
+
from .base import BaseNotificationSender
from .email import EmailNotificationSender
-from .websocket import WebSocketNotificationSender
from .push import PushNotificationSender
+from .websocket import WebSocketNotificationSender
__all__ = [
- 'BaseNotificationSender',
- 'EmailNotificationSender',
- 'WebSocketNotificationSender',
- 'PushNotificationSender',
+ "BaseNotificationSender",
+ "EmailNotificationSender",
+ "WebSocketNotificationSender",
+ "PushNotificationSender",
]
diff --git a/backend/notifications/senders/base.py b/backend/notifications/senders/base.py
index c1f20c82..63e8796c 100644
--- a/backend/notifications/senders/base.py
+++ b/backend/notifications/senders/base.py
@@ -2,9 +2,9 @@
Базовый класс для всех отправителей уведомлений
"""
+import logging
from abc import ABC, abstractmethod
from typing import Optional
-import logging
logger = logging.getLogger(__name__)
diff --git a/backend/notifications/senders/email.py b/backend/notifications/senders/email.py
index df67b372..1aa6bfd6 100644
--- a/backend/notifications/senders/email.py
+++ b/backend/notifications/senders/email.py
@@ -9,13 +9,14 @@ TODO: Создать email шаблоны:
"""
from django.conf import settings
-from django.template.loader import render_to_string
from django.core.mail import send_mail
+from django.template.loader import render_to_string
-from .base import BaseNotificationSender
from notifications import config
from notifications.models import Notification
+from .base import BaseNotificationSender
+
class EmailNotificationSender(BaseNotificationSender):
"""
diff --git a/backend/notifications/senders/push.py b/backend/notifications/senders/push.py
index 001fed46..14daaa97 100644
--- a/backend/notifications/senders/push.py
+++ b/backend/notifications/senders/push.py
@@ -10,16 +10,17 @@ from django.conf import settings
from django.utils import timezone
from push_notifications.models import WebPushDevice
-from .base import BaseNotificationSender
-from ..config import get
-from ..models import NotificationDeliveryRouting, NotificationInstallation
-from ..routing import APP_MAIN, APP_MESSENGER, normalize_routes, notification_category
from notifications.chat_suppression import suppress_for_active_chat
from notifications.push_actions import (
build_push_avatar_token,
build_push_read_token,
)
+from ..config import get
+from ..models import NotificationDeliveryRouting, NotificationInstallation
+from ..routing import APP_MAIN, APP_MESSENGER, normalize_routes, notification_category
+from .base import BaseNotificationSender
+
class PushNotificationSender(BaseNotificationSender):
"""
@@ -115,9 +116,7 @@ class PushNotificationSender(BaseNotificationSender):
chat_id = source_data.get("chat_id")
message_id = source_data.get("message_id")
preferences = self._chat_preferences(notification)
- show_preview = (
- preferences is None or preferences.show_notification_preview
- )
+ show_preview = preferences is None or preferences.show_notification_preview
actor_name = self._get_actor_name(notification)
title = self._build_title(notification, source_data)
@@ -143,8 +142,7 @@ class PushNotificationSender(BaseNotificationSender):
"tag": f"chat-{chat_id}" if chat_id else f"notification-{notification.id}",
"requireInteraction": False,
"silent": bool(
- preferences is not None
- and not preferences.notification_sound_enabled
+ preferences is not None and not preferences.notification_sound_enabled
),
"data": {
"url": notification.action_url or "/",
@@ -157,11 +155,7 @@ class PushNotificationSender(BaseNotificationSender):
},
}
- if (
- notification.verb in self.CHAT_NOTIFICATION_VERBS
- and chat_id
- and message_id
- ):
+ if notification.verb in self.CHAT_NOTIFICATION_VERBS and chat_id and message_id:
try:
read_token = build_push_read_token(
notification_id=notification.id,
@@ -173,15 +167,19 @@ class PushNotificationSender(BaseNotificationSender):
read_token = ""
if read_token:
- message_data["actions"] = [{
- "action": "mark-read",
- "type": "button",
- "title": "Прочитано",
- }]
- message_data["data"].update({
- "mark_read_url": "/api/v1/notifications/push/action/read/",
- "mark_read_token": read_token,
- })
+ message_data["actions"] = [
+ {
+ "action": "mark-read",
+ "type": "button",
+ "title": "Прочитано",
+ }
+ ]
+ message_data["data"].update(
+ {
+ "mark_read_url": "/api/v1/notifications/push/action/read/",
+ "mark_read_token": read_token,
+ }
+ )
actor_icon = (
self._get_actor_avatar_url(notification)
@@ -356,7 +354,9 @@ class PushNotificationSender(BaseNotificationSender):
failed_count += 1
device.refresh_from_db(fields=["active"])
if not device.active:
- self._deactivate_device(device, "provider rejected subscription")
+ self._deactivate_device(
+ device, "provider rejected subscription"
+ )
self.logger.warning(
"Push provider rejected delivery",
extra={
diff --git a/backend/notifications/senders/push_control.py b/backend/notifications/senders/push_control.py
index 04f9c3d8..aec08a91 100644
--- a/backend/notifications/senders/push_control.py
+++ b/backend/notifications/senders/push_control.py
@@ -8,7 +8,6 @@ from push_notifications.models import WebPushDevice
from notifications.models import Notification
from notifications.senders.push import PushNotificationSender
-
logger = logging.getLogger(__name__)
diff --git a/backend/notifications/senders/websocket.py b/backend/notifications/senders/websocket.py
index 4a02b4ed..c1fbb974 100644
--- a/backend/notifications/senders/websocket.py
+++ b/backend/notifications/senders/websocket.py
@@ -1,11 +1,13 @@
"""
WebSocket отправитель для realtime уведомлений
"""
-from channels.layers import get_channel_layer
+
from asgiref.sync import async_to_sync
+from channels.layers import get_channel_layer
+
+from notifications.chat_suppression import suppress_for_active_chat
from .base import BaseNotificationSender
-from notifications.chat_suppression import suppress_for_active_chat
class WebSocketNotificationSender(BaseNotificationSender):
@@ -47,40 +49,36 @@ class WebSocketNotificationSender(BaseNotificationSender):
# Формируем данные для фронтенда
message_data = {
- 'type': 'notification',
- 'id': notification.id,
- 'verb': notification.verb,
- 'description': notification.description,
- 'action_url': notification.action_url,
- 'timestamp': notification.timestamp.isoformat(),
- 'unread': notification.unread,
- 'silent': silent,
- 'data': notification.data,
+ "type": "notification",
+ "id": notification.id,
+ "verb": notification.verb,
+ "description": notification.description,
+ "action_url": notification.action_url,
+ "timestamp": notification.timestamp.isoformat(),
+ "unread": notification.unread,
+ "silent": silent,
+ "data": notification.data,
}
# Добавляем actor если есть
if notification.actor:
- message_data['actor'] = {
- 'type': notification.actor_content_type.model,
- 'id': notification.actor_object_id,
- 'str': str(notification.actor),
+ message_data["actor"] = {
+ "type": notification.actor_content_type.model,
+ "id": notification.actor_object_id,
+ "str": str(notification.actor),
}
# Добавляем target если есть
if notification.target:
- message_data['target'] = {
- 'type': notification.target_content_type.model,
- 'id': notification.target_object_id,
- 'str': str(notification.target),
+ message_data["target"] = {
+ "type": notification.target_content_type.model,
+ "id": notification.target_object_id,
+ "str": str(notification.target),
}
# Отправляем через Channels
async_to_sync(channel_layer.group_send)(
- user_channel,
- {
- 'type': 'notification_message',
- 'message': message_data
- }
+ user_channel, {"type": "notification_message", "message": message_data}
)
self.log_success(notification, f"user_{user_id}")
diff --git a/backend/notifications/signals.py b/backend/notifications/signals.py
index 1d7870f6..313af2a5 100644
--- a/backend/notifications/signals.py
+++ b/backend/notifications/signals.py
@@ -24,7 +24,6 @@ TODO: Добавить тесты для:
from django.dispatch import Signal
-
# Сигнал для создания уведомлений
notify = Signal()
@@ -49,10 +48,11 @@ def create_notification_handler(sender, **kwargs):
Возвращает:
Notification или список Notification
"""
- from .models import Notification
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
+ from .models import Notification
+
User = get_user_model()
# Извлекаем параметры
@@ -73,18 +73,14 @@ def create_notification_handler(sender, **kwargs):
raise ValueError("verb is required")
# Поддержка множественных получателей
- recipients = (
- recipient if isinstance(recipient, (list, tuple)) else [recipient]
- )
+ recipients = recipient if isinstance(recipient, (list, tuple)) else [recipient]
# Создаем уведомления
notifications = []
for recip in recipients:
if not isinstance(recip, User):
- raise ValueError(
- f"recipient must be User instance, got {type(recip)}"
- )
+ raise ValueError(f"recipient must be User instance, got {type(recip)}")
# Подготовка данных для GenericForeignKey
new_notification = Notification(
@@ -98,8 +94,8 @@ def create_notification_handler(sender, **kwargs):
# Actor
if sender:
- new_notification.actor_content_type = (
- ContentType.objects.get_for_model(sender)
+ new_notification.actor_content_type = ContentType.objects.get_for_model(
+ sender
)
new_notification.actor_object_id = sender.pk
@@ -112,8 +108,8 @@ def create_notification_handler(sender, **kwargs):
# Target
if target:
- new_notification.target_content_type = (
- ContentType.objects.get_for_model(target)
+ new_notification.target_content_type = ContentType.objects.get_for_model(
+ target
)
new_notification.target_object_id = target.pk
diff --git a/backend/notifications/tasks/base.py b/backend/notifications/tasks/base.py
index 8293b995..ec342359 100644
--- a/backend/notifications/tasks/base.py
+++ b/backend/notifications/tasks/base.py
@@ -2,9 +2,10 @@
Базовый класс для Celery задач отправки уведомлений
"""
-from abc import ABC, abstractmethod
-from celery import shared_task
import logging
+from abc import ABC, abstractmethod
+
+from celery import shared_task
logger = logging.getLogger(__name__)
diff --git a/backend/notifications/tasks/email.py b/backend/notifications/tasks/email.py
index 6de2b2ac..20216a28 100644
--- a/backend/notifications/tasks/email.py
+++ b/backend/notifications/tasks/email.py
@@ -4,9 +4,10 @@ Celery задачи для отправки email уведомлений
from celery import shared_task
-from .base import BaseNotificationTask
from notifications import config
+from .base import BaseNotificationTask
+
class EmailNotificationTask(BaseNotificationTask):
"""
@@ -60,11 +61,13 @@ class DigestEmailTask(BaseNotificationTask):
def execute(self, celery_task, user_id: int, frequency: str = "daily", **kwargs):
"""Переопределяем execute для работы с user_id вместо notification_id"""
try:
+ from datetime import timedelta
+
from django.contrib.auth import get_user_model
+ from django.utils import timezone
+
from notifications.models import Notification
from notifications.senders.email import EmailNotificationSender
- from django.utils import timezone
- from datetime import timedelta
User = get_user_model()
user = User.objects.get(id=user_id)
diff --git a/backend/notifications/tasks/push.py b/backend/notifications/tasks/push.py
index 464d53be..f89ddb01 100644
--- a/backend/notifications/tasks/push.py
+++ b/backend/notifications/tasks/push.py
@@ -1,9 +1,11 @@
"""
Celery задачи для отправки Web Push уведомлений
"""
-from .base import BaseNotificationTask
+
from notifications import config
+from .base import BaseNotificationTask
+
class PushNotificationTask(BaseNotificationTask):
"""
diff --git a/backend/notifications/tasks/websocket.py b/backend/notifications/tasks/websocket.py
index ec0e61c2..89b0b117 100644
--- a/backend/notifications/tasks/websocket.py
+++ b/backend/notifications/tasks/websocket.py
@@ -2,9 +2,10 @@
Celery задачи для отправки WebSocket уведомлений (realtime)
"""
-from .base import BaseNotificationTask
from notifications import config
+from .base import BaseNotificationTask
+
class WebSocketNotificationTask(BaseNotificationTask):
"""
diff --git a/backend/notifications/urls.py b/backend/notifications/urls.py
index 1445f4dc..434be187 100644
--- a/backend/notifications/urls.py
+++ b/backend/notifications/urls.py
@@ -5,17 +5,20 @@ URL routing для модуля notifications.
- API endpoints (из notifications.api.urls)
- Service Worker для Web Push уведомлений
"""
-from django.urls import path, include
-from notifications.api.urls import urlpatterns as api_urlpatterns, app_name
+
+from django.urls import include, path
+
+from notifications.api.urls import app_name
+from notifications.api.urls import urlpatterns as api_urlpatterns
+
from . import views
# Публичные URLs приложения (Service Worker должен быть в корне)
urlpatterns = [
# Service Worker для Web Push уведомлений
- path('sw.js', views.serve_service_worker, name='notifications_sw'),
-
+ path("sw.js", views.serve_service_worker, name="notifications_sw"),
# API endpoints
- path('api/', include((api_urlpatterns, app_name))),
+ path("api/", include((api_urlpatterns, app_name))),
]
-__all__ = ['urlpatterns', 'app_name']
+__all__ = ["urlpatterns", "app_name"]
diff --git a/backend/notifications/views.py b/backend/notifications/views.py
index 34706091..c4aca3d1 100644
--- a/backend/notifications/views.py
+++ b/backend/notifications/views.py
@@ -3,9 +3,11 @@ Views для приложения notifications.
Обслуживание Service Worker и других статических ресурсов.
"""
-from django.http import FileResponse, HttpResponse
+
from pathlib import Path
+from django.http import FileResponse, HttpResponse
+
def serve_service_worker(request):
"""
@@ -16,28 +18,22 @@ def serve_service_worker(request):
- Cache-Control: no-cache (всегда свежая версия)
- Service-Worker-Allowed: / (контроль всего домена)
"""
- sw_path = Path(__file__).parent / 'static' / 'notifications' / 'sw.js'
+ sw_path = Path(__file__).parent / "static" / "notifications" / "sw.js"
try:
response = FileResponse(
- open(sw_path, 'rb'),
- content_type='application/javascript; charset=utf-8',
- status=200
+ open(sw_path, "rb"),
+ content_type="application/javascript; charset=utf-8",
+ status=200,
)
# Отключаем кэширование для sw.js (всегда получаем свежую версию)
- response['Cache-Control'] = 'no-cache, no-store, must-revalidate'
- response['Pragma'] = 'no-cache'
- response['Expires'] = '0'
+ response["Cache-Control"] = "no-cache, no-store, must-revalidate"
+ response["Pragma"] = "no-cache"
+ response["Expires"] = "0"
# Service-Worker-Allowed позволяет контролировать весь домен
- response['Service-Worker-Allowed'] = '/'
+ response["Service-Worker-Allowed"] = "/"
return response
except FileNotFoundError:
- return HttpResponse(
- f"Service Worker not found: {sw_path}",
- status=500
- )
+ return HttpResponse(f"Service Worker not found: {sw_path}", status=500)
except Exception as e:
- return HttpResponse(
- f"Error serving Service Worker: {e}",
- status=500
- )
+ return HttpResponse(f"Error serving Service Worker: {e}", status=500)
diff --git a/backend/payroll_core/calculator.py b/backend/payroll_core/calculator.py
index 150bec6e..92c59415 100644
--- a/backend/payroll_core/calculator.py
+++ b/backend/payroll_core/calculator.py
@@ -322,9 +322,7 @@ class DeterministicPayrollCalculator:
payment_total=payments,
payable=net_pay - payments,
closing_balance=(
- opening_balance
- + manual_adjustment_debits
- - manual_adjustment_credits
+ opening_balance + manual_adjustment_debits - manual_adjustment_credits
),
)
@@ -342,22 +340,18 @@ class DeterministicPayrollCalculator:
(
line
for line in lines
- if line.line_id
- in {"system:point-excess", "system:point-adjustment"}
+ if line.line_id in {"system:point-excess", "system:point-adjustment"}
),
None,
)
point_amount = (
-point_line.amount
- if point_line is not None
- and point_line.kind is LineKind.ADJUSTMENT_DEBIT
+ if point_line is not None and point_line.kind is LineKind.ADJUSTMENT_DEBIT
else point_line.amount
if point_line is not None
else Decimal("0")
)
- gross_before_point_recalculation = (
- totals.gross_after_adjustments - point_amount
- )
+ gross_before_point_recalculation = totals.gross_after_adjustments - point_amount
checks = (
("point_amount", expected.point_amount, point_amount),
("gross", expected.gross, gross_before_point_recalculation),
@@ -395,8 +389,6 @@ class DeterministicPayrollCalculator:
request: PayrollRequest,
rules: PayrollRules,
) -> list[PayrollIssue]:
- issues: list[PayrollIssue] = []
-
if not isinstance(request, PayrollRequest):
return [
PayrollIssue(
@@ -414,6 +406,19 @@ class DeterministicPayrollCalculator:
)
]
+ issues: list[PayrollIssue] = []
+ period_is_valid = self._validate_request_fields(request, issues)
+ self._validate_rules(rules, request, period_is_valid, issues)
+ self._validate_point_metrics(request, rules, issues)
+ self._validate_input_lines(request, period_is_valid, issues)
+ self._validate_expected_totals(request, rules, issues)
+ return issues
+
+ def _validate_request_fields(
+ self,
+ request: PayrollRequest,
+ issues: list[PayrollIssue],
+ ) -> bool:
if not self._is_nonempty_string(request.employee_ref):
issues.append(
PayrollIssue(
@@ -422,11 +427,7 @@ class DeterministicPayrollCalculator:
message="Employee reference is required.",
)
)
- period_is_valid = self._validate_period(
- request.period,
- "period",
- issues,
- )
+ period_is_valid = self._validate_period(request.period, "period", issues)
if not isinstance(request.currency, str) or not _CURRENCY_RE.fullmatch(
request.currency
):
@@ -438,16 +439,10 @@ class DeterministicPayrollCalculator:
)
)
self._validate_decimal(
- request.base_accrual,
- "base_accrual",
- issues,
- minimum=Decimal("0"),
+ request.base_accrual, "base_accrual", issues, minimum=Decimal("0")
)
self._validate_decimal(
- request.opening_balance,
- "opening_balance",
- issues,
- minimum=None,
+ request.opening_balance, "opening_balance", issues, minimum=None
)
self._validate_decimal(
request.point_base_accrual,
@@ -465,10 +460,7 @@ class DeterministicPayrollCalculator:
)
)
self._validate_decimal(
- request.point_rate,
- "point_rate",
- issues,
- minimum=Decimal("0"),
+ request.point_rate, "point_rate", issues, minimum=Decimal("0")
)
if not isinstance(request.point_rate_overridden, bool):
issues.append(
@@ -478,7 +470,15 @@ class DeterministicPayrollCalculator:
message="Point rate override marker must be boolean.",
)
)
+ return period_is_valid
+ def _validate_rules(
+ self,
+ rules: PayrollRules,
+ request: PayrollRequest,
+ period_is_valid: bool,
+ issues: list[PayrollIssue],
+ ) -> None:
if not self._is_nonempty_string(rules.ruleset_id):
issues.append(
PayrollIssue(
@@ -495,9 +495,9 @@ class DeterministicPayrollCalculator:
message="Ruleset version is required.",
)
)
+
rules_from_valid = isinstance(rules.effective_from, date) and not isinstance(
- rules.effective_from,
- datetime,
+ rules.effective_from, datetime
)
rules_to_valid = rules.effective_to is None or (
isinstance(rules.effective_to, date)
@@ -532,14 +532,16 @@ class DeterministicPayrollCalculator:
message="Ruleset effective dates are invalid.",
)
)
- rules_outside_period = False
- if period_is_valid and rules_from_valid and rules_to_valid:
- rules_outside_period = (
+ if (
+ period_is_valid
+ and rules_from_valid
+ and rules_to_valid
+ and (
request.period.start < rules.effective_from
or rules.effective_to is not None
and request.period.end > rules.effective_to
)
- if rules_outside_period:
+ ):
issues.append(
PayrollIssue(
code="RULESET_NOT_EFFECTIVE",
@@ -579,6 +581,12 @@ class DeterministicPayrollCalculator:
exclusive_minimum=True,
)
+ def _validate_point_metrics(
+ self,
+ request: PayrollRequest,
+ rules: PayrollRules,
+ issues: list[PayrollIssue],
+ ) -> None:
if rules.point_policy in {
PointPolicy.EXCESS_ONLY,
PointPolicy.PROPORTIONAL_WITH_EXCESS,
@@ -596,34 +604,42 @@ class DeterministicPayrollCalculator:
issues,
minimum=Decimal("0"),
)
- elif rules.point_policy is PointPolicy.DISABLED:
- has_target = request.target_points is not None
- has_actual = request.actual_points is not None
- if has_target != has_actual:
- issues.append(
- PayrollIssue(
- code="INCOMPLETE_POINT_METRICS",
- field="target_points",
- message="Target and actual points must be supplied together.",
- )
- )
- if has_target:
- self._validate_decimal(
- request.target_points,
- "target_points",
- issues,
- minimum=Decimal("0"),
- exclusive_minimum=True,
- )
- if has_actual:
- self._validate_decimal(
- request.actual_points,
- "actual_points",
- issues,
- minimum=Decimal("0"),
- )
+ return
+ if rules.point_policy is not PointPolicy.DISABLED:
+ return
- seen_line_ids: set[str] = set()
+ has_target = request.target_points is not None
+ has_actual = request.actual_points is not None
+ if has_target != has_actual:
+ issues.append(
+ PayrollIssue(
+ code="INCOMPLETE_POINT_METRICS",
+ field="target_points",
+ message="Target and actual points must be supplied together.",
+ )
+ )
+ if has_target:
+ self._validate_decimal(
+ request.target_points,
+ "target_points",
+ issues,
+ minimum=Decimal("0"),
+ exclusive_minimum=True,
+ )
+ if has_actual:
+ self._validate_decimal(
+ request.actual_points,
+ "actual_points",
+ issues,
+ minimum=Decimal("0"),
+ )
+
+ def _validate_input_lines(
+ self,
+ request: PayrollRequest,
+ period_is_valid: bool,
+ issues: list[PayrollIssue],
+ ) -> None:
if not isinstance(request.lines, tuple):
issues.append(
PayrollIssue(
@@ -643,150 +659,186 @@ class DeterministicPayrollCalculator:
message=f"At most {self.MAX_INPUT_LINES} input lines are allowed.",
)
)
+
+ seen_line_ids: set[str] = set()
for index, line in enumerate(lines):
- prefix = f"lines[{index}]"
- if not isinstance(line, InputLine):
- issues.append(
- PayrollIssue(
- code="INVALID_COMPONENT",
- field=prefix,
- message="Every input line must be an InputLine instance.",
- )
- )
- continue
- line_id_is_valid = self._is_nonempty_string(line.line_id)
- if not line_id_is_valid:
- issues.append(
- PayrollIssue(
- code="MISSING_COMPONENT_ID",
- field=f"{prefix}.line_id",
- message="Input line identifier is required.",
- )
- )
- else:
- if line.line_id.startswith(_SYSTEM_LINE_PREFIX):
- issues.append(
- PayrollIssue(
- code="RESERVED_COMPONENT_ID",
- field=f"{prefix}.line_id",
- message="Input line uses a reserved system prefix.",
- )
- )
- if line.line_id in seen_line_ids:
- issues.append(
- PayrollIssue(
- code="DUPLICATE_COMPONENT_ID",
- field=f"{prefix}.line_id",
- message="Input line identifiers must be unique.",
- )
- )
- seen_line_ids.add(line.line_id)
-
- if not isinstance(line.code, str) or not _CODE_RE.fullmatch(line.code):
- issues.append(
- PayrollIssue(
- code="INVALID_COMPONENT_CODE",
- field=f"{prefix}.code",
- message="Component code must use canonical uppercase form.",
- )
- )
- if not self._is_nonempty_string(line.label):
- issues.append(
- PayrollIssue(
- code="MISSING_COMPONENT_LABEL",
- field=f"{prefix}.label",
- message="Input line label is required.",
- )
- )
- kind_is_valid = isinstance(line.kind, LineKind)
- if not kind_is_valid:
- issues.append(
- PayrollIssue(
- code="INVALID_COMPONENT_KIND",
- field=f"{prefix}.kind",
- message="Input line kind is not supported.",
- )
- )
- self._validate_decimal(
- line.amount,
- f"{prefix}.amount",
- issues,
- minimum=Decimal("0"),
+ self._validate_input_line(
+ line,
+ index=index,
+ request=request,
+ period_is_valid=period_is_valid,
+ seen_line_ids=seen_line_ids,
+ issues=issues,
)
- if not self._is_nonempty_string(line.source_ref):
- issues.append(
- PayrollIssue(
- code="MISSING_SOURCE_REF",
- field=f"{prefix}.source_ref",
- message="Every input line must identify its source.",
- )
- )
- if (
- kind_is_valid
- and line.kind
- in {
- LineKind.ADJUSTMENT_CREDIT,
- LineKind.ADJUSTMENT_DEBIT,
- }
- and not self._is_nonempty_string(line.reason)
- ):
- issues.append(
- PayrollIssue(
- code="MISSING_ADJUSTMENT_REASON",
- field=f"{prefix}.reason",
- message="Adjustment reason is required.",
- )
- )
- is_retro_valid = isinstance(line.is_retro, bool)
- if not is_retro_valid:
- issues.append(
- PayrollIssue(
- code="INVALID_RETRO_FLAG",
- field=f"{prefix}.is_retro",
- message="is_retro must be a boolean.",
- )
- )
- if line.is_retro is True and line.source_period is None:
- issues.append(
- PayrollIssue(
- code="MISSING_RETRO_SOURCE_PERIOD",
- field=f"{prefix}.source_period",
- message="Retro input must reference the affected period.",
- )
- )
- if line.source_period is not None:
- source_period_valid = self._validate_period(
- line.source_period,
- f"{prefix}.source_period",
- issues,
- )
- if is_retro_valid and line.is_retro is not True:
- issues.append(
- PayrollIssue(
- code="UNEXPECTED_SOURCE_PERIOD",
- field=f"{prefix}.source_period",
- message="A source period requires is_retro=True.",
- )
- )
- elif (
- is_retro_valid
- and line.is_retro is True
- and source_period_valid
- and period_is_valid
- and line.source_period.end >= request.period.start
- ):
- issues.append(
- PayrollIssue(
- code="INVALID_RETRO_SOURCE_PERIOD",
- field=f"{prefix}.source_period",
- message="Retro source period must end before this period.",
- )
- )
- if request.expected_totals is not None and not isinstance(
- request.expected_totals,
- ExpectedTotals,
+ def _validate_input_line(
+ self,
+ line: object,
+ *,
+ index: int,
+ request: PayrollRequest,
+ period_is_valid: bool,
+ seen_line_ids: set[str],
+ issues: list[PayrollIssue],
+ ) -> None:
+ prefix = f"lines[{index}]"
+ if not isinstance(line, InputLine):
+ issues.append(
+ PayrollIssue(
+ code="INVALID_COMPONENT",
+ field=prefix,
+ message="Every input line must be an InputLine instance.",
+ )
+ )
+ return
+
+ if not self._is_nonempty_string(line.line_id):
+ issues.append(
+ PayrollIssue(
+ code="MISSING_COMPONENT_ID",
+ field=f"{prefix}.line_id",
+ message="Input line identifier is required.",
+ )
+ )
+ else:
+ if line.line_id.startswith(_SYSTEM_LINE_PREFIX):
+ issues.append(
+ PayrollIssue(
+ code="RESERVED_COMPONENT_ID",
+ field=f"{prefix}.line_id",
+ message="Input line uses a reserved system prefix.",
+ )
+ )
+ if line.line_id in seen_line_ids:
+ issues.append(
+ PayrollIssue(
+ code="DUPLICATE_COMPONENT_ID",
+ field=f"{prefix}.line_id",
+ message="Input line identifiers must be unique.",
+ )
+ )
+ seen_line_ids.add(line.line_id)
+
+ if not isinstance(line.code, str) or not _CODE_RE.fullmatch(line.code):
+ issues.append(
+ PayrollIssue(
+ code="INVALID_COMPONENT_CODE",
+ field=f"{prefix}.code",
+ message="Component code must use canonical uppercase form.",
+ )
+ )
+ if not self._is_nonempty_string(line.label):
+ issues.append(
+ PayrollIssue(
+ code="MISSING_COMPONENT_LABEL",
+ field=f"{prefix}.label",
+ message="Input line label is required.",
+ )
+ )
+ kind_is_valid = isinstance(line.kind, LineKind)
+ if not kind_is_valid:
+ issues.append(
+ PayrollIssue(
+ code="INVALID_COMPONENT_KIND",
+ field=f"{prefix}.kind",
+ message="Input line kind is not supported.",
+ )
+ )
+ self._validate_decimal(
+ line.amount, f"{prefix}.amount", issues, minimum=Decimal("0")
+ )
+ if not self._is_nonempty_string(line.source_ref):
+ issues.append(
+ PayrollIssue(
+ code="MISSING_SOURCE_REF",
+ field=f"{prefix}.source_ref",
+ message="Every input line must identify its source.",
+ )
+ )
+ if (
+ kind_is_valid
+ and line.kind in {LineKind.ADJUSTMENT_CREDIT, LineKind.ADJUSTMENT_DEBIT}
+ and not self._is_nonempty_string(line.reason)
):
+ issues.append(
+ PayrollIssue(
+ code="MISSING_ADJUSTMENT_REASON",
+ field=f"{prefix}.reason",
+ message="Adjustment reason is required.",
+ )
+ )
+ self._validate_retro_line(
+ line,
+ prefix=prefix,
+ request=request,
+ period_is_valid=period_is_valid,
+ issues=issues,
+ )
+
+ def _validate_retro_line(
+ self,
+ line: InputLine,
+ *,
+ prefix: str,
+ request: PayrollRequest,
+ period_is_valid: bool,
+ issues: list[PayrollIssue],
+ ) -> None:
+ is_retro_valid = isinstance(line.is_retro, bool)
+ if not is_retro_valid:
+ issues.append(
+ PayrollIssue(
+ code="INVALID_RETRO_FLAG",
+ field=f"{prefix}.is_retro",
+ message="is_retro must be a boolean.",
+ )
+ )
+ if line.is_retro is True and line.source_period is None:
+ issues.append(
+ PayrollIssue(
+ code="MISSING_RETRO_SOURCE_PERIOD",
+ field=f"{prefix}.source_period",
+ message="Retro input must reference the affected period.",
+ )
+ )
+ if line.source_period is None:
+ return
+
+ source_period_valid = self._validate_period(
+ line.source_period, f"{prefix}.source_period", issues
+ )
+ if is_retro_valid and line.is_retro is not True:
+ issues.append(
+ PayrollIssue(
+ code="UNEXPECTED_SOURCE_PERIOD",
+ field=f"{prefix}.source_period",
+ message="A source period requires is_retro=True.",
+ )
+ )
+ elif (
+ is_retro_valid
+ and line.is_retro is True
+ and source_period_valid
+ and period_is_valid
+ and line.source_period.end >= request.period.start
+ ):
+ issues.append(
+ PayrollIssue(
+ code="INVALID_RETRO_SOURCE_PERIOD",
+ field=f"{prefix}.source_period",
+ message="Retro source period must end before this period.",
+ )
+ )
+
+ def _validate_expected_totals(
+ self,
+ request: PayrollRequest,
+ rules: PayrollRules,
+ issues: list[PayrollIssue],
+ ) -> None:
+ if request.expected_totals is None:
+ return
+ if not isinstance(request.expected_totals, ExpectedTotals):
issues.append(
PayrollIssue(
code="INVALID_EXPECTED_TOTALS",
@@ -794,27 +846,18 @@ class DeterministicPayrollCalculator:
message="Expected totals must be an ExpectedTotals instance.",
)
)
- elif request.expected_totals is not None:
- for field_name in (
- "point_amount",
- "gross",
- "recalculated_gross",
- "payable",
- ):
- self._validate_decimal(
- getattr(request.expected_totals, field_name),
- f"expected_totals.{field_name}",
- issues,
- minimum=(
- None
- if field_name == "point_amount"
- or field_name == "payable" and rules.allow_negative_payable
- else Decimal("0")
- ),
- allow_none=True,
- )
-
- return issues
+ return
+ for field_name in ("point_amount", "gross", "recalculated_gross", "payable"):
+ allow_negative = field_name == "point_amount" or (
+ field_name == "payable" and rules.allow_negative_payable
+ )
+ self._validate_decimal(
+ getattr(request.expected_totals, field_name),
+ f"expected_totals.{field_name}",
+ issues,
+ minimum=None if allow_negative else Decimal("0"),
+ allow_none=True,
+ )
@staticmethod
def _is_nonempty_string(value: object) -> bool:
diff --git a/backend/payroll_core/domain.py b/backend/payroll_core/domain.py
index c16f6a88..2372c231 100644
--- a/backend/payroll_core/domain.py
+++ b/backend/payroll_core/domain.py
@@ -2,7 +2,8 @@
from __future__ import annotations
-from dataclasses import dataclass, field as dataclass_field
+from dataclasses import dataclass
+from dataclasses import field as dataclass_field
from datetime import date
from decimal import Decimal
from enum import Enum
diff --git a/backend/payroll_core/exceptions.py b/backend/payroll_core/exceptions.py
index cde89971..b3355075 100644
--- a/backend/payroll_core/exceptions.py
+++ b/backend/payroll_core/exceptions.py
@@ -2,7 +2,8 @@
from __future__ import annotations
-from dataclasses import dataclass, field as dataclass_field
+from dataclasses import dataclass
+from dataclasses import field as dataclass_field
from typing import Mapping
diff --git a/backend/payroll_core/tests/test_calculator.py b/backend/payroll_core/tests/test_calculator.py
index bd340694..dd0b5ea7 100644
--- a/backend/payroll_core/tests/test_calculator.py
+++ b/backend/payroll_core/tests/test_calculator.py
@@ -1,10 +1,10 @@
from __future__ import annotations
-from dataclasses import replace
-from datetime import date, datetime
-from decimal import Decimal, ROUND_DOWN, getcontext, localcontext
import hashlib
import json
+from dataclasses import replace
+from datetime import date, datetime
+from decimal import ROUND_DOWN, Decimal, getcontext, localcontext
import pytest
@@ -159,9 +159,7 @@ def test_proportional_policy_records_a_signed_adjustment_below_target(period, ru
)
base_line = next(line for line in result.lines if line.code == "BASE")
- point_line = next(
- line for line in result.lines if line.code == "POINT_ADJUSTMENT"
- )
+ point_line = next(line for line in result.lines if line.code == "POINT_ADJUSTMENT")
assert base_line.amount == Decimal("90000.00")
assert point_line.kind is LineKind.ADJUSTMENT_DEBIT
assert point_line.amount == Decimal("11739.13")
@@ -189,9 +187,7 @@ def test_proportional_policy_keeps_full_base_and_pays_excess(period, rules):
)
base_line = next(line for line in result.lines if line.code == "BASE")
- point_line = next(
- line for line in result.lines if line.code == "POINT_ADJUSTMENT"
- )
+ point_line = next(line for line in result.lines if line.code == "POINT_ADJUSTMENT")
assert base_line.amount == Decimal("90000.00")
assert point_line.kind is LineKind.ADJUSTMENT_CREDIT
assert point_line.amount == Decimal("500.00")
@@ -223,9 +219,7 @@ def test_proportional_policy_matches_excel_bonus_basis(period, rules):
proportional_rules,
)
- point_line = next(
- line for line in result.lines if line.code == "POINT_ADJUSTMENT"
- )
+ point_line = next(line for line in result.lines if line.code == "POINT_ADJUSTMENT")
assert point_line.kind is LineKind.ADJUSTMENT_DEBIT
assert point_line.amount == Decimal("7272.73")
assert result.totals.gross_before_adjustments == Decimal("40000.00")
@@ -256,9 +250,7 @@ def test_automatic_excess_rate_keeps_unrounded_excel_formula(period, rules):
proportional_rules,
)
- point_line = next(
- line for line in result.lines if line.code == "POINT_ADJUSTMENT"
- )
+ point_line = next(line for line in result.lines if line.code == "POINT_ADJUSTMENT")
assert point_line.amount == Decimal("44727.27")
assert result.totals.gross_after_adjustments == Decimal("84727.27")
diff --git a/backend/payroll_core/tests/test_import_boundaries.py b/backend/payroll_core/tests/test_import_boundaries.py
index 41c212fd..54784e17 100644
--- a/backend/payroll_core/tests/test_import_boundaries.py
+++ b/backend/payroll_core/tests/test_import_boundaries.py
@@ -3,7 +3,6 @@ from __future__ import annotations
import ast
from pathlib import Path
-
ALLOWED_STDLIB_ROOTS = {
"__future__",
"dataclasses",
diff --git a/backend/procurement/admin.py b/backend/procurement/admin.py
index d3c96ca4..b124687b 100644
--- a/backend/procurement/admin.py
+++ b/backend/procurement/admin.py
@@ -1,7 +1,7 @@
from decimal import Decimal
-from django.contrib import admin
from django import forms
+from django.contrib import admin
from django.utils.html import format_html
from .models import (
@@ -135,9 +135,7 @@ class ProcurementSettingsAdmin(admin.ModelAdmin):
count = obj.available_processing_departments.count()
return count or "Все отделы"
- available_processing_departments_count.short_description = (
- "Доступные отделы"
- )
+ available_processing_departments_count.short_description = "Доступные отделы"
@admin.register(ProcurementRequest)
@@ -441,12 +439,8 @@ class EquipmentAdmin(admin.ModelAdmin):
def warranty_status(self, obj):
"""Статус гарантии."""
if obj.is_under_warranty:
- return format_html(
- '✓ На гарантии'
- )
- return format_html(
- '— Гарантия истекла'
- )
+ return format_html('✓ На гарантии')
+ return format_html('— Гарантия истекла')
warranty_status.short_description = "Гарантия"
@@ -501,6 +495,7 @@ class BudgetAdmin(admin.ModelAdmin):
def create_next_quarter_budgets(self, request, queryset):
"""Создать бюджеты на следующий квартал для всех отделов."""
from django.utils import timezone
+
from employees.models import Department
now = timezone.now()
diff --git a/backend/procurement/apps.py b/backend/procurement/apps.py
index 193b7996..dc58abaa 100644
--- a/backend/procurement/apps.py
+++ b/backend/procurement/apps.py
@@ -2,13 +2,14 @@ from django.apps import AppConfig
class ProcurementConfig(AppConfig):
- default_auto_field = 'django.db.models.BigAutoField'
- name = 'procurement'
- verbose_name = 'Закупки и инвентаризация'
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "procurement"
+ verbose_name = "Закупки и инвентаризация"
def ready(self):
"""Подключение сигналов при загрузке приложения."""
import procurement.notifications.signals # noqa: F401
- import procurement.signals # noqa: F401
+
# django-rules: регистрация предикатов и правил доступа
import procurement.rules # noqa: F401
+ import procurement.signals # noqa: F401
diff --git a/backend/procurement/constants.py b/backend/procurement/constants.py
index 6b1c2278..b482c0aa 100644
--- a/backend/procurement/constants.py
+++ b/backend/procurement/constants.py
@@ -8,73 +8,81 @@ from django.db import models
class ProcurementStatus(models.TextChoices):
"""Статусы заявки на закупку."""
- DRAFT = 'draft', 'Черновик'
- WAITING = 'waiting', 'Ожидает'
- PENDING = 'pending', 'На согласовании'
- APPROVED = 'approved', 'Одобрено'
- REJECTED = 'rejected', 'Отклонено'
- IN_PROGRESS = 'in_progress', 'В работе'
- COMPLETED = 'completed', 'Завершено'
- CANCELLED = 'cancelled', 'Отменено'
+
+ DRAFT = "draft", "Черновик"
+ WAITING = "waiting", "Ожидает"
+ PENDING = "pending", "На согласовании"
+ APPROVED = "approved", "Одобрено"
+ REJECTED = "rejected", "Отклонено"
+ IN_PROGRESS = "in_progress", "В работе"
+ COMPLETED = "completed", "Завершено"
+ CANCELLED = "cancelled", "Отменено"
class UrgencyLevel(models.TextChoices):
"""Уровень срочности заявки."""
- LOW = 'low', 'Низкая'
- MEDIUM = 'medium', 'Средняя'
- HIGH = 'high', 'Высокая'
- CRITICAL = 'critical', 'Критическая'
+
+ LOW = "low", "Низкая"
+ MEDIUM = "medium", "Средняя"
+ HIGH = "high", "Высокая"
+ CRITICAL = "critical", "Критическая"
class ProcurementFulfillmentStatus(models.TextChoices):
"""Рабочий статус исполнения заявки по позициям."""
- PENDING = 'pending', 'Ожидает обработки'
- PARTIALLY_ORDERED = 'partially_ordered', 'Заказано частично'
- ORDERED = 'ordered', 'Заказано'
- PARTIALLY_RECEIVED = 'partially_received', 'Прибыл частично'
- COMPLETED = 'completed', 'Выполнено'
- ISSUES = 'issues', 'Выполнено с проблемами'
+
+ PENDING = "pending", "Ожидает обработки"
+ PARTIALLY_ORDERED = "partially_ordered", "Заказано частично"
+ ORDERED = "ordered", "Заказано"
+ PARTIALLY_RECEIVED = "partially_received", "Прибыл частично"
+ COMPLETED = "completed", "Выполнено"
+ ISSUES = "issues", "Выполнено с проблемами"
class ProcurementItemExecutionStatus(models.TextChoices):
"""Рабочий статус отдельной позиции заявки."""
- PENDING = 'pending', 'Не выполнено'
- ORDERED = 'ordered', 'Заказано'
- REJECTED = 'rejected', 'Отказано'
- RECEIVED = 'received', 'Получено'
- COMPLETED_WITH_ISSUE = 'completed_with_issue', 'Выполнено с замечанием'
- EDITED = 'edited', 'Отредактировано'
- DEFECTIVE = 'defective', 'Брак / перезаказ'
+
+ PENDING = "pending", "Не выполнено"
+ ORDERED = "ordered", "Заказано"
+ REJECTED = "rejected", "Отказано"
+ RECEIVED = "received", "Получено"
+ COMPLETED_WITH_ISSUE = "completed_with_issue", "Выполнено с замечанием"
+ EDITED = "edited", "Отредактировано"
+ DEFECTIVE = "defective", "Брак / перезаказ"
-def get_default_approval_step_name(priority: int,
- resolver_type: str | None = None) -> str:
- if resolver_type == 'department_head':
- return 'Руководитель отдела'
- return f'Этап {priority}'
+def get_default_approval_step_name(
+ priority: int, resolver_type: str | None = None
+) -> str:
+ if resolver_type == "department_head":
+ return "Руководитель отдела"
+ return f"Этап {priority}"
class ApprovalStatus(models.TextChoices):
"""Статус согласования."""
- PENDING = 'pending', 'Ожидает решения'
- APPROVED = 'approved', 'Одобрено'
- REJECTED = 'rejected', 'Отклонено'
+
+ PENDING = "pending", "Ожидает решения"
+ APPROVED = "approved", "Одобрено"
+ REJECTED = "rejected", "Отклонено"
class EquipmentStatus(models.TextChoices):
"""Статус оборудования."""
- AVAILABLE = 'available', 'Доступно'
- IN_USE = 'in_use', 'В использовании'
- MAINTENANCE = 'maintenance', 'На обслуживании'
- REPAIR = 'repair', 'В ремонте'
- RETIRED = 'retired', 'Списано'
- LOST = 'lost', 'Утеряно'
+
+ AVAILABLE = "available", "Доступно"
+ IN_USE = "in_use", "В использовании"
+ MAINTENANCE = "maintenance", "На обслуживании"
+ REPAIR = "repair", "В ремонте"
+ RETIRED = "retired", "Списано"
+ LOST = "lost", "Утеряно"
class MaintenanceType(models.TextChoices):
"""Тип технического обслуживания."""
- INSPECTION = 'inspection', 'Осмотр/проверка'
- MAINTENANCE = 'maintenance', 'Плановое ТО'
- REPAIR = 'repair', 'Ремонт'
- UPGRADE = 'upgrade', 'Модернизация'
- CLEANING = 'cleaning', 'Чистка'
+
+ INSPECTION = "inspection", "Осмотр/проверка"
+ MAINTENANCE = "maintenance", "Плановое ТО"
+ REPAIR = "repair", "Ремонт"
+ UPGRADE = "upgrade", "Модернизация"
+ CLEANING = "cleaning", "Чистка"
diff --git a/backend/procurement/management/commands/create_next_quarter_budgets.py b/backend/procurement/management/commands/create_next_quarter_budgets.py
index 8bd09a8d..a8fe8095 100644
--- a/backend/procurement/management/commands/create_next_quarter_budgets.py
+++ b/backend/procurement/management/commands/create_next_quarter_budgets.py
@@ -21,30 +21,30 @@ from procurement.models import Budget
class Command(BaseCommand):
- help = 'Создать бюджеты на следующий квартал для всех отделов'
+ help = "Создать бюджеты на следующий квартал для всех отделов"
def add_arguments(self, parser):
parser.add_argument(
- '--year',
+ "--year",
type=int,
- help='Год для создания бюджетов (по умолчанию следующий)',
+ help="Год для создания бюджетов (по умолчанию следующий)",
)
parser.add_argument(
- '--quarter',
+ "--quarter",
type=int,
choices=[1, 2, 3, 4],
- help='Квартал (1-4)',
+ help="Квартал (1-4)",
)
parser.add_argument(
- '--dry-run',
- action='store_true',
- help='Показать что будет создано, но не создавать',
+ "--dry-run",
+ action="store_true",
+ help="Показать что будет создано, но не создавать",
)
parser.add_argument(
- '--default-amount',
+ "--default-amount",
type=float,
default=0.0,
- help='Сумма по умолчанию если нет текущего бюджета (₽)',
+ help="Сумма по умолчанию если нет текущего бюджета (₽)",
)
def handle(self, *args, **options):
@@ -52,9 +52,9 @@ class Command(BaseCommand):
current_quarter = (now.month - 1) // 3 + 1
# Определяем целевой квартал
- if options['year'] and options['quarter']:
- target_year = options['year']
- target_quarter = options['quarter']
+ if options["year"] and options["quarter"]:
+ target_year = options["year"]
+ target_quarter = options["quarter"]
else:
# По умолчанию - следующий квартал
if current_quarter == 4:
@@ -66,117 +66,97 @@ class Command(BaseCommand):
self.stdout.write(
self.style.SUCCESS(
- f'\n📊 Создание бюджетов на {target_year} Q{target_quarter}'
+ f"\n📊 Создание бюджетов на {target_year} Q{target_quarter}"
)
)
- if options['dry_run']:
+ if options["dry_run"]:
self.stdout.write(
- self.style.WARNING('🔍 DRY RUN - изменения не будут сохранены')
+ self.style.WARNING("🔍 DRY RUN - изменения не будут сохранены")
)
# Получаем все отделы
departments = Department.objects.all()
if not departments.exists():
- self.stdout.write(
- self.style.ERROR('❌ Не найдено ни одного отдела!')
- )
+ self.stdout.write(self.style.ERROR("❌ Не найдено ни одного отдела!"))
return
created_count = 0
skipped_count = 0
errors_count = 0
- default_amount = Decimal(str(options['default_amount']))
+ default_amount = Decimal(str(options["default_amount"]))
- self.stdout.write(f'\nВсего отделов: {departments.count()}\n')
+ self.stdout.write(f"\nВсего отделов: {departments.count()}\n")
for dept in departments:
# Проверяем существует ли уже бюджет
exists = Budget.objects.filter(
- department=dept,
- year=target_year,
- quarter=target_quarter
+ department=dept, year=target_year, quarter=target_quarter
).exists()
if exists:
- self.stdout.write(
- f'⏭️ {dept.name}: уже существует'
- )
+ self.stdout.write(f"⏭️ {dept.name}: уже существует")
skipped_count += 1
continue
# Пытаемся скопировать бюджет из текущего квартала
try:
current_budget = Budget.objects.get(
- department=dept,
- year=now.year,
- quarter=current_quarter
+ department=dept, year=now.year, quarter=current_quarter
)
allocated = current_budget.allocated_amount
- source = f'текущий Q{current_quarter}'
+ source = f"текущий Q{current_quarter}"
except Budget.DoesNotExist:
allocated = default_amount
- source = 'по умолчанию'
+ source = "по умолчанию"
# Создаём бюджет
- if not options['dry_run']:
+ if not options["dry_run"]:
try:
Budget.objects.create(
department=dept,
year=target_year,
quarter=target_quarter,
allocated_amount=allocated,
- spent_amount=Decimal('0.00')
+ spent_amount=Decimal("0.00"),
)
self.stdout.write(
self.style.SUCCESS(
- f'✅ {dept.name}: {allocated:,.0f}₽ '
- f'(из {source})'
+ f"✅ {dept.name}: {allocated:,.0f}₽ (из {source})"
)
)
created_count += 1
except Exception as e:
self.stdout.write(
- self.style.ERROR(
- f'❌ {dept.name}: ошибка - {str(e)}'
- )
+ self.style.ERROR(f"❌ {dept.name}: ошибка - {str(e)}")
)
errors_count += 1
else:
self.stdout.write(
- f'🔍 {dept.name}: будет создан с {allocated:,.0f}₽ '
- f'(из {source})'
+ f"🔍 {dept.name}: будет создан с {allocated:,.0f}₽ (из {source})"
)
created_count += 1
# Итоги
- self.stdout.write('\n' + '=' * 60)
+ self.stdout.write("\n" + "=" * 60)
- if options['dry_run']:
+ if options["dry_run"]:
self.stdout.write(
- self.style.SUCCESS(
- f'\n✨ Будет создано: {created_count} бюджетов'
- )
+ self.style.SUCCESS(f"\n✨ Будет создано: {created_count} бюджетов")
)
else:
self.stdout.write(
- self.style.SUCCESS(
- f'\n✨ Создано: {created_count} бюджетов'
- )
+ self.style.SUCCESS(f"\n✨ Создано: {created_count} бюджетов")
)
if skipped_count > 0:
self.stdout.write(
- self.style.WARNING(
- f'⏭️ Пропущено (уже существуют): {skipped_count}'
- )
+ self.style.WARNING(f"⏭️ Пропущено (уже существуют): {skipped_count}")
)
if errors_count > 0:
- self.stdout.write(
- self.style.ERROR(f'❌ Ошибок: {errors_count}')
- )
+ self.stdout.write(self.style.ERROR(f"❌ Ошибок: {errors_count}"))
- self.stdout.write('')
+ self.stdout.write("")
diff --git a/backend/procurement/media.py b/backend/procurement/media.py
index 78ce5ce2..e0ea0c54 100644
--- a/backend/procurement/media.py
+++ b/backend/procurement/media.py
@@ -6,7 +6,6 @@ from tasks.media import attachment_media_kind, render_attachment_thumbnail
from .models import ProcurementItemAttachment
-
logger = logging.getLogger(__name__)
diff --git a/backend/procurement/migrations/0001_initial.py b/backend/procurement/migrations/0001_initial.py
index 9bee3464..0c693b19 100644
--- a/backend/procurement/migrations/0001_initial.py
+++ b/backend/procurement/migrations/0001_initial.py
@@ -1,185 +1,717 @@
# Generated by Django 5.2.4 on 2025-12-08 12:20
+from decimal import Decimal
+
import django.core.validators
import django.db.models.deletion
-from decimal import Decimal
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
-
initial = True
dependencies = [
- ('employees', '0034_alter_employee_telegram'),
+ ("employees", "0034_alter_employee_telegram"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
- name='Supplier',
+ name="Supplier",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('name', models.CharField(max_length=255, verbose_name='Название')),
- ('contact_person', models.CharField(blank=True, max_length=255, verbose_name='Контактное лицо')),
- ('phone', models.CharField(blank=True, max_length=50, verbose_name='Телефон')),
- ('email', models.EmailField(blank=True, max_length=254, verbose_name='Email')),
- ('address', models.TextField(blank=True, verbose_name='Адрес')),
- ('website', models.URLField(blank=True, verbose_name='Веб-сайт')),
- ('inn', models.CharField(blank=True, help_text='ИНН организации (10 или 12 цифр)', max_length=12, verbose_name='ИНН')),
- ('rating', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=3, validators=[django.core.validators.MinValueValidator(Decimal('0.00'))], verbose_name='Рейтинг')),
- ('is_active', models.BooleanField(default=True, verbose_name='Активен')),
- ('notes', 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",
+ ),
+ ),
+ ("name", models.CharField(max_length=255, verbose_name="Название")),
+ (
+ "contact_person",
+ models.CharField(
+ blank=True, max_length=255, verbose_name="Контактное лицо"
+ ),
+ ),
+ (
+ "phone",
+ models.CharField(blank=True, max_length=50, verbose_name="Телефон"),
+ ),
+ (
+ "email",
+ models.EmailField(blank=True, max_length=254, verbose_name="Email"),
+ ),
+ ("address", models.TextField(blank=True, verbose_name="Адрес")),
+ ("website", models.URLField(blank=True, verbose_name="Веб-сайт")),
+ (
+ "inn",
+ models.CharField(
+ blank=True,
+ help_text="ИНН организации (10 или 12 цифр)",
+ max_length=12,
+ verbose_name="ИНН",
+ ),
+ ),
+ (
+ "rating",
+ models.DecimalField(
+ decimal_places=2,
+ default=Decimal("0.00"),
+ max_digits=3,
+ validators=[
+ django.core.validators.MinValueValidator(Decimal("0.00"))
+ ],
+ verbose_name="Рейтинг",
+ ),
+ ),
+ (
+ "is_active",
+ models.BooleanField(default=True, verbose_name="Активен"),
+ ),
+ ("notes", 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': 'Поставщики',
- 'ordering': ['name'],
+ "verbose_name": "Поставщик",
+ "verbose_name_plural": "Поставщики",
+ "ordering": ["name"],
},
),
migrations.CreateModel(
- name='EquipmentCategory',
+ name="EquipmentCategory",
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='Название')),
- ('description', models.TextField(blank=True, verbose_name='Описание')),
- ('icon', models.CharField(blank=True, help_text='Например: bi-laptop, bi-printer', max_length=50, verbose_name='Иконка Bootstrap')),
- ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
- ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='procurement.equipmentcategory', 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="Название"
+ ),
+ ),
+ ("description", models.TextField(blank=True, verbose_name="Описание")),
+ (
+ "icon",
+ models.CharField(
+ blank=True,
+ help_text="Например: bi-laptop, bi-printer",
+ max_length=50,
+ verbose_name="Иконка Bootstrap",
+ ),
+ ),
+ (
+ "created_at",
+ models.DateTimeField(auto_now_add=True, verbose_name="Создано"),
+ ),
+ (
+ "parent",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="children",
+ to="procurement.equipmentcategory",
+ verbose_name="Родительская категория",
+ ),
+ ),
],
options={
- 'verbose_name': 'Категория оборудования',
- 'verbose_name_plural': 'Категории оборудования',
- 'ordering': ['name'],
+ "verbose_name": "Категория оборудования",
+ "verbose_name_plural": "Категории оборудования",
+ "ordering": ["name"],
},
),
migrations.CreateModel(
- name='Equipment',
+ name="Equipment",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('name', models.CharField(max_length=255, verbose_name='Название')),
- ('inventory_number', models.CharField(help_text='Например: INV-2024-0001', max_length=50, unique=True, verbose_name='Инвентарный номер')),
- ('serial_number', models.CharField(blank=True, help_text='Серийный номер производителя', max_length=100, verbose_name='Серийный номер')),
- ('status', models.CharField(choices=[('available', 'Доступно'), ('in_use', 'В использовании'), ('maintenance', 'На обслуживании'), ('repair', 'В ремонте'), ('retired', 'Списано'), ('lost', 'Утеряно')], default='available', max_length=20, verbose_name='Статус')),
- ('location', models.CharField(blank=True, help_text='Например: Офис 3.14, Стеллаж А-5', max_length=255, verbose_name='Расположение')),
- ('purchase_date', models.DateField(verbose_name='Дата покупки')),
- ('warranty_until', models.DateField(blank=True, null=True, verbose_name='Гарантия до')),
- ('purchase_cost', models.DecimalField(decimal_places=2, max_digits=12, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))], verbose_name='Стоимость покупки')),
- ('notes', 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='Обновлено')),
- ('department', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='equipment', to='employees.department', verbose_name='Отдел')),
- ('responsible_person', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='responsible_equipment', to=settings.AUTH_USER_MODEL, verbose_name='Ответственный')),
- ('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='equipment', to='procurement.equipmentcategory', verbose_name='Категория')),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("name", models.CharField(max_length=255, verbose_name="Название")),
+ (
+ "inventory_number",
+ models.CharField(
+ help_text="Например: INV-2024-0001",
+ max_length=50,
+ unique=True,
+ verbose_name="Инвентарный номер",
+ ),
+ ),
+ (
+ "serial_number",
+ models.CharField(
+ blank=True,
+ help_text="Серийный номер производителя",
+ max_length=100,
+ verbose_name="Серийный номер",
+ ),
+ ),
+ (
+ "status",
+ models.CharField(
+ choices=[
+ ("available", "Доступно"),
+ ("in_use", "В использовании"),
+ ("maintenance", "На обслуживании"),
+ ("repair", "В ремонте"),
+ ("retired", "Списано"),
+ ("lost", "Утеряно"),
+ ],
+ default="available",
+ max_length=20,
+ verbose_name="Статус",
+ ),
+ ),
+ (
+ "location",
+ models.CharField(
+ blank=True,
+ help_text="Например: Офис 3.14, Стеллаж А-5",
+ max_length=255,
+ verbose_name="Расположение",
+ ),
+ ),
+ ("purchase_date", models.DateField(verbose_name="Дата покупки")),
+ (
+ "warranty_until",
+ models.DateField(blank=True, null=True, verbose_name="Гарантия до"),
+ ),
+ (
+ "purchase_cost",
+ models.DecimalField(
+ decimal_places=2,
+ max_digits=12,
+ validators=[
+ django.core.validators.MinValueValidator(Decimal("0.01"))
+ ],
+ verbose_name="Стоимость покупки",
+ ),
+ ),
+ ("notes", 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="Обновлено"),
+ ),
+ (
+ "department",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="equipment",
+ to="employees.department",
+ verbose_name="Отдел",
+ ),
+ ),
+ (
+ "responsible_person",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="responsible_equipment",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Ответственный",
+ ),
+ ),
+ (
+ "category",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="equipment",
+ to="procurement.equipmentcategory",
+ verbose_name="Категория",
+ ),
+ ),
],
options={
- 'verbose_name': 'Оборудование',
- 'verbose_name_plural': 'Оборудование',
- 'ordering': ['-created_at'],
+ "verbose_name": "Оборудование",
+ "verbose_name_plural": "Оборудование",
+ "ordering": ["-created_at"],
},
),
migrations.CreateModel(
- name='MaintenanceRecord',
+ name="MaintenanceRecord",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('date', models.DateField(verbose_name='Дата обслуживания')),
- ('type', models.CharField(choices=[('inspection', 'Осмотр/проверка'), ('maintenance', 'Плановое ТО'), ('repair', 'Ремонт'), ('upgrade', 'Модернизация'), ('cleaning', 'Чистка')], max_length=20, verbose_name='Тип обслуживания')),
- ('description', models.TextField(help_text='Что было сделано', verbose_name='Описание')),
- ('cost', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=10, validators=[django.core.validators.MinValueValidator(Decimal('0.00'))], verbose_name='Стоимость')),
- ('next_maintenance_date', models.DateField(blank=True, null=True, verbose_name='Следующее ТО')),
- ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
- ('equipment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='maintenance_history', to='procurement.equipment', verbose_name='Оборудование')),
- ('performed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='performed_maintenance', to=settings.AUTH_USER_MODEL, verbose_name='Выполнил')),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("date", models.DateField(verbose_name="Дата обслуживания")),
+ (
+ "type",
+ models.CharField(
+ choices=[
+ ("inspection", "Осмотр/проверка"),
+ ("maintenance", "Плановое ТО"),
+ ("repair", "Ремонт"),
+ ("upgrade", "Модернизация"),
+ ("cleaning", "Чистка"),
+ ],
+ max_length=20,
+ verbose_name="Тип обслуживания",
+ ),
+ ),
+ (
+ "description",
+ models.TextField(
+ help_text="Что было сделано", verbose_name="Описание"
+ ),
+ ),
+ (
+ "cost",
+ models.DecimalField(
+ decimal_places=2,
+ default=Decimal("0.00"),
+ max_digits=10,
+ validators=[
+ django.core.validators.MinValueValidator(Decimal("0.00"))
+ ],
+ verbose_name="Стоимость",
+ ),
+ ),
+ (
+ "next_maintenance_date",
+ models.DateField(
+ blank=True, null=True, verbose_name="Следующее ТО"
+ ),
+ ),
+ (
+ "created_at",
+ models.DateTimeField(auto_now_add=True, verbose_name="Создано"),
+ ),
+ (
+ "equipment",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="maintenance_history",
+ to="procurement.equipment",
+ verbose_name="Оборудование",
+ ),
+ ),
+ (
+ "performed_by",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="performed_maintenance",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Выполнил",
+ ),
+ ),
],
options={
- 'verbose_name': 'Запись обслуживания',
- 'verbose_name_plural': 'Записи обслуживания',
- 'ordering': ['-date'],
+ "verbose_name": "Запись обслуживания",
+ "verbose_name_plural": "Записи обслуживания",
+ "ordering": ["-date"],
},
),
migrations.CreateModel(
- name='ProcurementRequest',
+ name="ProcurementRequest",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('title', models.CharField(help_text='Краткое название заявки', max_length=255, verbose_name='Название')),
- ('description', models.TextField(help_text='Подробное описание и обоснование необходимости закупки', verbose_name='Описание')),
- ('status', models.CharField(choices=[('draft', 'Черновик'), ('pending', 'На согласовании'), ('approved', 'Одобрено'), ('rejected', 'Отклонено'), ('in_progress', 'В работе'), ('completed', 'Завершено'), ('cancelled', 'Отменено')], default='draft', max_length=20, verbose_name='Статус')),
- ('urgency', models.CharField(choices=[('low', 'Низкая'), ('medium', 'Средняя'), ('high', 'Высокая'), ('critical', 'Критическая')], default='medium', max_length=20, verbose_name='Срочность')),
- ('estimated_cost', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=12, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))], verbose_name='Предполагаемая стоимость')),
- ('actual_cost', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))], verbose_name='Фактическая стоимость')),
- ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
- ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')),
- ('submitted_at', models.DateTimeField(blank=True, null=True, verbose_name='Отправлено на согласование')),
- ('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='Завершено')),
- ('department', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='procurement_requests', to='employees.department', verbose_name='Отдел')),
- ('requestor', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='procurement_requests', to=settings.AUTH_USER_MODEL, verbose_name='Заявитель')),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "title",
+ models.CharField(
+ help_text="Краткое название заявки",
+ max_length=255,
+ verbose_name="Название",
+ ),
+ ),
+ (
+ "description",
+ models.TextField(
+ help_text="Подробное описание и обоснование необходимости закупки",
+ verbose_name="Описание",
+ ),
+ ),
+ (
+ "status",
+ models.CharField(
+ choices=[
+ ("draft", "Черновик"),
+ ("pending", "На согласовании"),
+ ("approved", "Одобрено"),
+ ("rejected", "Отклонено"),
+ ("in_progress", "В работе"),
+ ("completed", "Завершено"),
+ ("cancelled", "Отменено"),
+ ],
+ default="draft",
+ max_length=20,
+ verbose_name="Статус",
+ ),
+ ),
+ (
+ "urgency",
+ models.CharField(
+ choices=[
+ ("low", "Низкая"),
+ ("medium", "Средняя"),
+ ("high", "Высокая"),
+ ("critical", "Критическая"),
+ ],
+ default="medium",
+ max_length=20,
+ verbose_name="Срочность",
+ ),
+ ),
+ (
+ "estimated_cost",
+ models.DecimalField(
+ decimal_places=2,
+ default=Decimal("0.00"),
+ max_digits=12,
+ validators=[
+ django.core.validators.MinValueValidator(Decimal("0.01"))
+ ],
+ verbose_name="Предполагаемая стоимость",
+ ),
+ ),
+ (
+ "actual_cost",
+ models.DecimalField(
+ blank=True,
+ decimal_places=2,
+ max_digits=12,
+ null=True,
+ validators=[
+ django.core.validators.MinValueValidator(Decimal("0.01"))
+ ],
+ verbose_name="Фактическая стоимость",
+ ),
+ ),
+ (
+ "created_at",
+ models.DateTimeField(auto_now_add=True, verbose_name="Создано"),
+ ),
+ (
+ "updated_at",
+ models.DateTimeField(auto_now=True, verbose_name="Обновлено"),
+ ),
+ (
+ "submitted_at",
+ models.DateTimeField(
+ blank=True, null=True, verbose_name="Отправлено на согласование"
+ ),
+ ),
+ (
+ "completed_at",
+ models.DateTimeField(
+ blank=True, null=True, verbose_name="Завершено"
+ ),
+ ),
+ (
+ "department",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="procurement_requests",
+ to="employees.department",
+ verbose_name="Отдел",
+ ),
+ ),
+ (
+ "requestor",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="procurement_requests",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Заявитель",
+ ),
+ ),
],
options={
- 'verbose_name': 'Заявка на закупку',
- 'verbose_name_plural': 'Заявки на закупку',
- 'ordering': ['-created_at'],
- 'permissions': [('approve_procurementrequest', 'Can approve procurement requests'), ('view_all_requests', 'Can view all department requests'), ('execute_procurement', 'Can execute approved requests')],
+ "verbose_name": "Заявка на закупку",
+ "verbose_name_plural": "Заявки на закупку",
+ "ordering": ["-created_at"],
+ "permissions": [
+ ("approve_procurementrequest", "Can approve procurement requests"),
+ ("view_all_requests", "Can view all department requests"),
+ ("execute_procurement", "Can execute approved requests"),
+ ],
},
),
migrations.CreateModel(
- name='ProcurementItem',
+ name="ProcurementItem",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('name', models.CharField(help_text='Название товара или услуги', max_length=255, verbose_name='Название')),
- ('description', models.TextField(blank=True, help_text='Подробное описание, технические характеристики', verbose_name='Описание')),
- ('quantity', models.PositiveIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1)], verbose_name='Количество')),
- ('unit', models.CharField(default='шт', help_text='Например: шт, кг, л, упак, м', max_length=50, verbose_name='Единица измерения')),
- ('estimated_unit_price', models.DecimalField(decimal_places=2, max_digits=12, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))], verbose_name='Цена за единицу')),
- ('supplier_info', models.TextField(blank=True, help_text='Ссылки на товар, контакты поставщика', verbose_name='Информация о поставщике')),
- ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
- ('equipment', models.OneToOneField(blank=True, help_text='Связь с оборудованием после закупки', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='procurement_item', to='procurement.equipment', verbose_name='Оборудование')),
- ('request', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='procurement.procurementrequest', verbose_name='Заявка')),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "name",
+ models.CharField(
+ help_text="Название товара или услуги",
+ max_length=255,
+ verbose_name="Название",
+ ),
+ ),
+ (
+ "description",
+ models.TextField(
+ blank=True,
+ help_text="Подробное описание, технические характеристики",
+ verbose_name="Описание",
+ ),
+ ),
+ (
+ "quantity",
+ models.PositiveIntegerField(
+ default=1,
+ validators=[django.core.validators.MinValueValidator(1)],
+ verbose_name="Количество",
+ ),
+ ),
+ (
+ "unit",
+ models.CharField(
+ default="шт",
+ help_text="Например: шт, кг, л, упак, м",
+ max_length=50,
+ verbose_name="Единица измерения",
+ ),
+ ),
+ (
+ "estimated_unit_price",
+ models.DecimalField(
+ decimal_places=2,
+ max_digits=12,
+ validators=[
+ django.core.validators.MinValueValidator(Decimal("0.01"))
+ ],
+ verbose_name="Цена за единицу",
+ ),
+ ),
+ (
+ "supplier_info",
+ models.TextField(
+ blank=True,
+ help_text="Ссылки на товар, контакты поставщика",
+ verbose_name="Информация о поставщике",
+ ),
+ ),
+ (
+ "created_at",
+ models.DateTimeField(auto_now_add=True, verbose_name="Создано"),
+ ),
+ (
+ "equipment",
+ models.OneToOneField(
+ blank=True,
+ help_text="Связь с оборудованием после закупки",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="procurement_item",
+ to="procurement.equipment",
+ verbose_name="Оборудование",
+ ),
+ ),
+ (
+ "request",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="items",
+ to="procurement.procurementrequest",
+ verbose_name="Заявка",
+ ),
+ ),
],
options={
- 'verbose_name': 'Позиция заявки',
- 'verbose_name_plural': 'Позиции заявок',
- 'ordering': ['id'],
+ "verbose_name": "Позиция заявки",
+ "verbose_name_plural": "Позиции заявок",
+ "ordering": ["id"],
},
),
migrations.CreateModel(
- name='Budget',
+ name="Budget",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('year', models.PositiveIntegerField(help_text='Например: 2025', verbose_name='Год')),
- ('quarter', models.PositiveIntegerField(choices=[(1, '1 квартал'), (2, '2 квартал'), (3, '3 квартал'), (4, '4 квартал')], verbose_name='Квартал')),
- ('allocated_amount', models.DecimalField(decimal_places=2, max_digits=14, validators=[django.core.validators.MinValueValidator(Decimal('0.00'))], verbose_name='Выделено')),
- ('spent_amount', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=14, validators=[django.core.validators.MinValueValidator(Decimal('0.00'))], verbose_name='Потрачено')),
- ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
- ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')),
- ('department', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='budgets', to='employees.department', verbose_name='Отдел')),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "year",
+ models.PositiveIntegerField(
+ help_text="Например: 2025", verbose_name="Год"
+ ),
+ ),
+ (
+ "quarter",
+ models.PositiveIntegerField(
+ choices=[
+ (1, "1 квартал"),
+ (2, "2 квартал"),
+ (3, "3 квартал"),
+ (4, "4 квартал"),
+ ],
+ verbose_name="Квартал",
+ ),
+ ),
+ (
+ "allocated_amount",
+ models.DecimalField(
+ decimal_places=2,
+ max_digits=14,
+ validators=[
+ django.core.validators.MinValueValidator(Decimal("0.00"))
+ ],
+ verbose_name="Выделено",
+ ),
+ ),
+ (
+ "spent_amount",
+ models.DecimalField(
+ decimal_places=2,
+ default=Decimal("0.00"),
+ max_digits=14,
+ validators=[
+ django.core.validators.MinValueValidator(Decimal("0.00"))
+ ],
+ verbose_name="Потрачено",
+ ),
+ ),
+ (
+ "created_at",
+ models.DateTimeField(auto_now_add=True, verbose_name="Создано"),
+ ),
+ (
+ "updated_at",
+ models.DateTimeField(auto_now=True, verbose_name="Обновлено"),
+ ),
+ (
+ "department",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="budgets",
+ to="employees.department",
+ verbose_name="Отдел",
+ ),
+ ),
],
options={
- 'verbose_name': 'Бюджет',
- 'verbose_name_plural': 'Бюджеты',
- 'ordering': ['-year', '-quarter'],
- 'unique_together': {('department', 'year', 'quarter')},
+ "verbose_name": "Бюджет",
+ "verbose_name_plural": "Бюджеты",
+ "ordering": ["-year", "-quarter"],
+ "unique_together": {("department", "year", "quarter")},
},
),
migrations.CreateModel(
- name='Approval',
+ name="Approval",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('role', models.CharField(choices=[('department_head', 'Руководитель отдела'), ('finance_manager', 'Финансовый менеджер'), ('director', 'Генеральный директор')], help_text='Роль согласующего в процессе', max_length=20, verbose_name='Роль')),
- ('status', models.CharField(choices=[('pending', 'Ожидает решения'), ('approved', 'Одобрено'), ('rejected', 'Отклонено')], default='pending', max_length=20, verbose_name='Статус')),
- ('comment', 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='Обновлено')),
- ('approver', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='procurement_approvals', to=settings.AUTH_USER_MODEL, verbose_name='Согласующий')),
- ('request', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='approvals', to='procurement.procurementrequest', verbose_name='Заявка')),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "role",
+ models.CharField(
+ choices=[
+ ("department_head", "Руководитель отдела"),
+ ("finance_manager", "Финансовый менеджер"),
+ ("director", "Генеральный директор"),
+ ],
+ help_text="Роль согласующего в процессе",
+ max_length=20,
+ verbose_name="Роль",
+ ),
+ ),
+ (
+ "status",
+ models.CharField(
+ choices=[
+ ("pending", "Ожидает решения"),
+ ("approved", "Одобрено"),
+ ("rejected", "Отклонено"),
+ ],
+ default="pending",
+ max_length=20,
+ verbose_name="Статус",
+ ),
+ ),
+ ("comment", 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="Обновлено"),
+ ),
+ (
+ "approver",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="procurement_approvals",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Согласующий",
+ ),
+ ),
+ (
+ "request",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="approvals",
+ to="procurement.procurementrequest",
+ verbose_name="Заявка",
+ ),
+ ),
],
options={
- 'verbose_name': 'Согласование',
- 'verbose_name_plural': 'Согласования',
- 'ordering': ['created_at'],
- 'unique_together': {('request', 'role')},
+ "verbose_name": "Согласование",
+ "verbose_name_plural": "Согласования",
+ "ordering": ["created_at"],
+ "unique_together": {("request", "role")},
},
),
]
diff --git a/backend/procurement/migrations/0002_equipmenttransferlog.py b/backend/procurement/migrations/0002_equipmenttransferlog.py
index 79f8d256..6489178e 100644
--- a/backend/procurement/migrations/0002_equipmenttransferlog.py
+++ b/backend/procurement/migrations/0002_equipmenttransferlog.py
@@ -6,34 +6,125 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0034_alter_employee_telegram'),
- ('procurement', '0001_initial'),
+ ("employees", "0034_alter_employee_telegram"),
+ ("procurement", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
- name='EquipmentTransferLog',
+ name="EquipmentTransferLog",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('from_location', models.CharField(blank=True, max_length=255, verbose_name='Откуда')),
- ('to_location', models.CharField(blank=True, max_length=255, verbose_name='Куда')),
- ('transfer_type', models.CharField(choices=[('assignment', 'Назначение ответственного'), ('transfer', 'Перемещение'), ('return', 'Возврат')], default='transfer', max_length=20, verbose_name='Тип передачи')),
- ('reason', models.TextField(blank=True, verbose_name='Причина/комментарий')),
- ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата передачи')),
- ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transfers_created', to=settings.AUTH_USER_MODEL, verbose_name='Кто оформил')),
- ('equipment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transfer_logs', to='procurement.equipment', verbose_name='Оборудование')),
- ('from_department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transfers_out', to='employees.department', verbose_name='Из отдела')),
- ('from_person', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='equipment_given', to=settings.AUTH_USER_MODEL, verbose_name='От кого')),
- ('to_department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transfers_in', to='employees.department', verbose_name='В отдел')),
- ('to_person', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='equipment_received', to=settings.AUTH_USER_MODEL, verbose_name='Кому')),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "from_location",
+ models.CharField(blank=True, max_length=255, verbose_name="Откуда"),
+ ),
+ (
+ "to_location",
+ models.CharField(blank=True, max_length=255, verbose_name="Куда"),
+ ),
+ (
+ "transfer_type",
+ models.CharField(
+ choices=[
+ ("assignment", "Назначение ответственного"),
+ ("transfer", "Перемещение"),
+ ("return", "Возврат"),
+ ],
+ default="transfer",
+ max_length=20,
+ verbose_name="Тип передачи",
+ ),
+ ),
+ (
+ "reason",
+ models.TextField(blank=True, verbose_name="Причина/комментарий"),
+ ),
+ (
+ "created_at",
+ models.DateTimeField(
+ auto_now_add=True, verbose_name="Дата передачи"
+ ),
+ ),
+ (
+ "created_by",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="transfers_created",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Кто оформил",
+ ),
+ ),
+ (
+ "equipment",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="transfer_logs",
+ to="procurement.equipment",
+ verbose_name="Оборудование",
+ ),
+ ),
+ (
+ "from_department",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="transfers_out",
+ to="employees.department",
+ verbose_name="Из отдела",
+ ),
+ ),
+ (
+ "from_person",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="equipment_given",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="От кого",
+ ),
+ ),
+ (
+ "to_department",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="transfers_in",
+ to="employees.department",
+ verbose_name="В отдел",
+ ),
+ ),
+ (
+ "to_person",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="equipment_received",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Кому",
+ ),
+ ),
],
options={
- 'verbose_name': 'Лог передачи',
- 'verbose_name_plural': 'Логи передач',
- 'ordering': ['-created_at'],
+ "verbose_name": "Лог передачи",
+ "verbose_name_plural": "Логи передач",
+ "ordering": ["-created_at"],
},
),
]
diff --git a/backend/procurement/migrations/0003_remove_estimated_cost.py b/backend/procurement/migrations/0003_remove_estimated_cost.py
index d96fd618..45392176 100644
--- a/backend/procurement/migrations/0003_remove_estimated_cost.py
+++ b/backend/procurement/migrations/0003_remove_estimated_cost.py
@@ -4,14 +4,13 @@ from django.db import migrations
class Migration(migrations.Migration):
-
dependencies = [
- ('procurement', '0002_equipmenttransferlog'),
+ ("procurement", "0002_equipmenttransferlog"),
]
operations = [
migrations.RemoveField(
- model_name='procurementrequest',
- name='estimated_cost',
+ model_name="procurementrequest",
+ name="estimated_cost",
),
]
diff --git a/backend/procurement/migrations/0004_add_executor_and_started_at.py b/backend/procurement/migrations/0004_add_executor_and_started_at.py
index c1bbbf8e..9fdcefbc 100644
--- a/backend/procurement/migrations/0004_add_executor_and_started_at.py
+++ b/backend/procurement/migrations/0004_add_executor_and_started_at.py
@@ -6,21 +6,30 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('procurement', '0003_remove_estimated_cost'),
+ ("procurement", "0003_remove_estimated_cost"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
- model_name='procurementrequest',
- name='executor',
- field=models.ForeignKey(blank=True, help_text='Кто взял заявку в работу', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='executing_procurements', to=settings.AUTH_USER_MODEL, verbose_name='Исполнитель'),
+ model_name="procurementrequest",
+ name="executor",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="Кто взял заявку в работу",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="executing_procurements",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Исполнитель",
+ ),
),
migrations.AddField(
- model_name='procurementrequest',
- name='started_at',
- field=models.DateTimeField(blank=True, null=True, verbose_name='Взято в работу'),
+ model_name="procurementrequest",
+ name="started_at",
+ field=models.DateTimeField(
+ blank=True, null=True, verbose_name="Взято в работу"
+ ),
),
]
diff --git a/backend/procurement/migrations/0005_add_approval_route.py b/backend/procurement/migrations/0005_add_approval_route.py
index 67f72997..fa37821e 100644
--- a/backend/procurement/migrations/0005_add_approval_route.py
+++ b/backend/procurement/migrations/0005_add_approval_route.py
@@ -6,23 +6,50 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('procurement', '0004_add_executor_and_started_at'),
+ ("procurement", "0004_add_executor_and_started_at"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
- name='ApprovalRoute',
+ name="ApprovalRoute",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('role', models.CharField(choices=[('department_head', 'Руководитель отдела'), ('finance_manager', 'Финансовый менеджер'), ('director', 'Генеральный директор')], max_length=20, unique=True, verbose_name='Роль')),
- ('employee', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='approval_routes', to=settings.AUTH_USER_MODEL, verbose_name='Согласующий')),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "role",
+ models.CharField(
+ choices=[
+ ("department_head", "Руководитель отдела"),
+ ("finance_manager", "Финансовый менеджер"),
+ ("director", "Генеральный директор"),
+ ],
+ max_length=20,
+ unique=True,
+ verbose_name="Роль",
+ ),
+ ),
+ (
+ "employee",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="approval_routes",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Согласующий",
+ ),
+ ),
],
options={
- 'verbose_name': 'Маршрут согласования',
- 'verbose_name_plural': 'Маршруты согласования',
+ "verbose_name": "Маршрут согласования",
+ "verbose_name_plural": "Маршруты согласования",
},
),
]
diff --git a/backend/procurement/migrations/0006_add_approval_priorities.py b/backend/procurement/migrations/0006_add_approval_priorities.py
index 03da4618..af03a431 100644
--- a/backend/procurement/migrations/0006_add_approval_priorities.py
+++ b/backend/procurement/migrations/0006_add_approval_priorities.py
@@ -4,13 +4,13 @@ from django.db import migrations, models
def set_default_priorities(apps, schema_editor):
- Approval = apps.get_model('procurement', 'Approval')
- ApprovalRoute = apps.get_model('procurement', 'ApprovalRoute')
+ Approval = apps.get_model("procurement", "Approval")
+ ApprovalRoute = apps.get_model("procurement", "ApprovalRoute")
priorities = {
- 'department_head': 10,
- 'finance_manager': 20,
- 'director': 30,
+ "department_head": 10,
+ "finance_manager": 20,
+ "director": 30,
}
for role, priority in priorities.items():
@@ -19,29 +19,43 @@ def set_default_priorities(apps, schema_editor):
class Migration(migrations.Migration):
-
dependencies = [
- ('procurement', '0005_add_approval_route'),
+ ("procurement", "0005_add_approval_route"),
]
operations = [
migrations.AlterModelOptions(
- name='approval',
- options={'ordering': ['priority', 'created_at', 'id'], 'verbose_name': 'Согласование', 'verbose_name_plural': 'Согласования'},
+ name="approval",
+ options={
+ "ordering": ["priority", "created_at", "id"],
+ "verbose_name": "Согласование",
+ "verbose_name_plural": "Согласования",
+ },
),
migrations.AlterModelOptions(
- name='approvalroute',
- options={'ordering': ['priority', 'role', 'id'], 'verbose_name': 'Маршрут согласования', 'verbose_name_plural': 'Маршруты согласования'},
+ name="approvalroute",
+ options={
+ "ordering": ["priority", "role", "id"],
+ "verbose_name": "Маршрут согласования",
+ "verbose_name_plural": "Маршруты согласования",
+ },
),
migrations.AddField(
- model_name='approval',
- name='priority',
- field=models.PositiveSmallIntegerField(db_index=True, default=100, verbose_name='Приоритет'),
+ model_name="approval",
+ name="priority",
+ field=models.PositiveSmallIntegerField(
+ db_index=True, default=100, verbose_name="Приоритет"
+ ),
),
migrations.AddField(
- model_name='approvalroute',
- name='priority',
- field=models.PositiveSmallIntegerField(db_index=True, default=100, help_text='Чем меньше число, тем раньше этап в цепочке согласования.', verbose_name='Приоритет'),
+ model_name="approvalroute",
+ name="priority",
+ field=models.PositiveSmallIntegerField(
+ db_index=True,
+ default=100,
+ help_text="Чем меньше число, тем раньше этап в цепочке согласования.",
+ verbose_name="Приоритет",
+ ),
),
migrations.RunPython(set_default_priorities, migrations.RunPython.noop),
]
diff --git a/backend/procurement/migrations/0007_replace_roles_with_priority_steps.py b/backend/procurement/migrations/0007_replace_roles_with_priority_steps.py
index b38a8add..cecf4813 100644
--- a/backend/procurement/migrations/0007_replace_roles_with_priority_steps.py
+++ b/backend/procurement/migrations/0007_replace_roles_with_priority_steps.py
@@ -6,59 +6,84 @@ from django.db import migrations, models
def migrate_route_roles_to_resolver_types(apps, schema_editor):
- ApprovalRoute = apps.get_model('procurement', 'ApprovalRoute')
- ApprovalRoute.objects.filter(role='department_head').update(
- resolver_type='department_head',
+ ApprovalRoute = apps.get_model("procurement", "ApprovalRoute")
+ ApprovalRoute.objects.filter(role="department_head").update(
+ resolver_type="department_head",
employee=None,
)
class Migration(migrations.Migration):
-
dependencies = [
- ('procurement', '0006_add_approval_priorities'),
+ ("procurement", "0006_add_approval_priorities"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterModelOptions(
- name='approvalroute',
- options={'ordering': ['priority', 'id'], 'verbose_name': 'Маршрут согласования', 'verbose_name_plural': 'Маршруты согласования'},
+ name="approvalroute",
+ options={
+ "ordering": ["priority", "id"],
+ "verbose_name": "Маршрут согласования",
+ "verbose_name_plural": "Маршруты согласования",
+ },
),
migrations.AlterUniqueTogether(
- name='approval',
- unique_together={('request', 'priority')},
+ name="approval",
+ unique_together={("request", "priority")},
),
migrations.AddField(
- model_name='approvalroute',
- name='resolver_type',
- field=models.CharField(choices=[('department_head', 'Руководитель отдела'), ('fixed_employee', 'Конкретный сотрудник')], default='fixed_employee', max_length=20, verbose_name='Тип резолва'),
+ model_name="approvalroute",
+ name="resolver_type",
+ field=models.CharField(
+ choices=[
+ ("department_head", "Руководитель отдела"),
+ ("fixed_employee", "Конкретный сотрудник"),
+ ],
+ default="fixed_employee",
+ max_length=20,
+ verbose_name="Тип резолва",
+ ),
),
migrations.AlterField(
- model_name='approval',
- name='priority',
- field=models.PositiveSmallIntegerField(db_index=True, verbose_name='Приоритет'),
+ model_name="approval",
+ name="priority",
+ field=models.PositiveSmallIntegerField(
+ db_index=True, verbose_name="Приоритет"
+ ),
),
migrations.AlterField(
- model_name='approvalroute',
- name='employee',
- field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='approval_routes', to=settings.AUTH_USER_MODEL, verbose_name='Согласующий'),
+ model_name="approvalroute",
+ name="employee",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="approval_routes",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Согласующий",
+ ),
),
migrations.AlterField(
- model_name='approvalroute',
- name='priority',
- field=models.PositiveSmallIntegerField(db_index=True, help_text='Чем меньше число, тем раньше этап в цепочке согласования.', unique=True, verbose_name='Приоритет'),
+ model_name="approvalroute",
+ name="priority",
+ field=models.PositiveSmallIntegerField(
+ db_index=True,
+ help_text="Чем меньше число, тем раньше этап в цепочке согласования.",
+ unique=True,
+ verbose_name="Приоритет",
+ ),
),
migrations.RunPython(
migrate_route_roles_to_resolver_types,
migrations.RunPython.noop,
),
migrations.RemoveField(
- model_name='approvalroute',
- name='role',
+ model_name="approvalroute",
+ name="role",
),
migrations.RemoveField(
- model_name='approval',
- name='role',
+ model_name="approval",
+ name="role",
),
]
diff --git a/backend/procurement/migrations/0008_add_step_names.py b/backend/procurement/migrations/0008_add_step_names.py
index 6501e352..49bb978b 100644
--- a/backend/procurement/migrations/0008_add_step_names.py
+++ b/backend/procurement/migrations/0008_add_step_names.py
@@ -4,44 +4,52 @@ from django.db import migrations, models
def populate_step_names(apps, schema_editor):
- Approval = apps.get_model('procurement', 'Approval')
- ApprovalRoute = apps.get_model('procurement', 'ApprovalRoute')
+ Approval = apps.get_model("procurement", "Approval")
+ ApprovalRoute = apps.get_model("procurement", "ApprovalRoute")
default_names = {
- 10: 'Руководитель отдела',
- 20: 'Финансовый менеджер',
- 30: 'Генеральный директор',
+ 10: "Руководитель отдела",
+ 20: "Финансовый менеджер",
+ 30: "Генеральный директор",
}
route_names = {
- route.priority: (route.name or default_names.get(route.priority, f'Этап {route.priority}'))
+ route.priority: (
+ route.name or default_names.get(route.priority, f"Этап {route.priority}")
+ )
for route in ApprovalRoute.objects.all()
}
for approval in Approval.objects.all():
approval.step_name = route_names.get(
approval.priority,
- default_names.get(approval.priority, f'Этап {approval.priority}'),
+ default_names.get(approval.priority, f"Этап {approval.priority}"),
)
- approval.save(update_fields=['step_name'])
+ approval.save(update_fields=["step_name"])
class Migration(migrations.Migration):
-
dependencies = [
- ('procurement', '0007_replace_roles_with_priority_steps'),
+ ("procurement", "0007_replace_roles_with_priority_steps"),
]
operations = [
migrations.AddField(
- model_name='approval',
- name='step_name',
- field=models.CharField(blank=True, max_length=150, verbose_name='Название этапа'),
+ model_name="approval",
+ name="step_name",
+ field=models.CharField(
+ blank=True, max_length=150, verbose_name="Название этапа"
+ ),
),
migrations.AddField(
- model_name='approvalroute',
- name='name',
- field=models.CharField(blank=True, help_text='Необязательно. Если заполнено, будет показано в интерфейсе.', max_length=150, verbose_name='Название этапа'),
+ model_name="approvalroute",
+ name="name",
+ field=models.CharField(
+ blank=True,
+ help_text="Необязательно. Если заполнено, будет показано в интерфейсе.",
+ max_length=150,
+ verbose_name="Название этапа",
+ ),
),
migrations.RunPython(populate_step_names, migrations.RunPython.noop),
]
diff --git a/backend/procurement/migrations/0009_approvalroute_min_amount.py b/backend/procurement/migrations/0009_approvalroute_min_amount.py
index 5545cb52..5a6ebb2e 100644
--- a/backend/procurement/migrations/0009_approvalroute_min_amount.py
+++ b/backend/procurement/migrations/0009_approvalroute_min_amount.py
@@ -1,34 +1,42 @@
# Generated by Django 5.2.4 on 2026-03-28 00:35
-import django.core.validators
from decimal import Decimal
+
+import django.core.validators
from django.db import migrations, models
def backfill_legacy_min_amounts(apps, schema_editor):
- ApprovalRoute = apps.get_model('procurement', 'ApprovalRoute')
+ ApprovalRoute = apps.get_model("procurement", "ApprovalRoute")
ApprovalRoute.objects.filter(
priority=20,
min_amount__isnull=True,
- ).update(min_amount=Decimal('10000.00'))
+ ).update(min_amount=Decimal("10000.00"))
ApprovalRoute.objects.filter(
priority=30,
min_amount__isnull=True,
- ).update(min_amount=Decimal('50000.00'))
+ ).update(min_amount=Decimal("50000.00"))
class Migration(migrations.Migration):
-
dependencies = [
- ('procurement', '0008_add_step_names'),
+ ("procurement", "0008_add_step_names"),
]
operations = [
migrations.AddField(
- model_name='approvalroute',
- name='min_amount',
- field=models.DecimalField(blank=True, decimal_places=2, help_text='Пусто = этап обязателен для любой суммы. Иначе этап включается при сумме не меньше указанной.', max_digits=12, null=True, validators=[django.core.validators.MinValueValidator(Decimal('0.00'))], verbose_name='Минимальная сумма заявки'),
+ model_name="approvalroute",
+ name="min_amount",
+ field=models.DecimalField(
+ blank=True,
+ decimal_places=2,
+ help_text="Пусто = этап обязателен для любой суммы. Иначе этап включается при сумме не меньше указанной.",
+ max_digits=12,
+ null=True,
+ validators=[django.core.validators.MinValueValidator(Decimal("0.00"))],
+ verbose_name="Минимальная сумма заявки",
+ ),
),
migrations.RunPython(
backfill_legacy_min_amounts,
diff --git a/backend/procurement/migrations/0010_processing_department_and_execution_statuses.py b/backend/procurement/migrations/0010_processing_department_and_execution_statuses.py
index 02a1d08a..e084dc40 100644
--- a/backend/procurement/migrations/0010_processing_department_and_execution_statuses.py
+++ b/backend/procurement/migrations/0010_processing_department_and_execution_statuses.py
@@ -1,13 +1,13 @@
# Generated by Codex on 2026-05-22
-import django.core.validators
from decimal import Decimal
-from django.db import migrations, models
+
+import django.core.validators
import django.db.models.deletion
+from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
("employees", "0052_employee_attendance_aliases"),
("procurement", "0009_approvalroute_min_amount"),
@@ -71,9 +71,7 @@ class Migration(migrations.Migration):
decimal_places=2,
max_digits=12,
null=True,
- validators=[
- django.core.validators.MinValueValidator(Decimal("0.01"))
- ],
+ validators=[django.core.validators.MinValueValidator(Decimal("0.01"))],
verbose_name="Фактическая цена за единицу",
),
),
diff --git a/backend/procurement/migrations/0011_item_quantities_defective_optional_price.py b/backend/procurement/migrations/0011_item_quantities_defective_optional_price.py
index 930a043e..e69728c5 100644
--- a/backend/procurement/migrations/0011_item_quantities_defective_optional_price.py
+++ b/backend/procurement/migrations/0011_item_quantities_defective_optional_price.py
@@ -7,7 +7,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
("procurement", "0010_processing_department_and_execution_statuses"),
]
@@ -21,9 +20,7 @@ class Migration(migrations.Migration):
decimal_places=2,
max_digits=12,
null=True,
- validators=[
- django.core.validators.MinValueValidator(Decimal("0.01"))
- ],
+ validators=[django.core.validators.MinValueValidator(Decimal("0.01"))],
verbose_name="Цена за единицу",
),
),
diff --git a/backend/procurement/migrations/0012_expected_delivery_dates.py b/backend/procurement/migrations/0012_expected_delivery_dates.py
index 4ea188d4..8edf840c 100644
--- a/backend/procurement/migrations/0012_expected_delivery_dates.py
+++ b/backend/procurement/migrations/0012_expected_delivery_dates.py
@@ -22,7 +22,6 @@ def backwards(apps, schema_editor):
class Migration(migrations.Migration):
-
dependencies = [
("procurement", "0011_item_quantities_defective_optional_price"),
]
diff --git a/backend/procurement/migrations/0013_procurementrequestview.py b/backend/procurement/migrations/0013_procurementrequestview.py
index 05de5e63..57fad348 100644
--- a/backend/procurement/migrations/0013_procurementrequestview.py
+++ b/backend/procurement/migrations/0013_procurementrequestview.py
@@ -1,12 +1,11 @@
# Generated by Codex on 2026-06-08
+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 = [
("procurement", "0012_expected_delivery_dates"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
diff --git a/backend/procurement/migrations/0014_procurementsettings.py b/backend/procurement/migrations/0014_procurementsettings.py
index 9ff600d4..f5c8971c 100644
--- a/backend/procurement/migrations/0014_procurementsettings.py
+++ b/backend/procurement/migrations/0014_procurementsettings.py
@@ -1,7 +1,7 @@
# Generated by Codex on 2026-07-06
-from django.db import migrations, models
import django.db.models.deletion
+from django.db import migrations, models
def seed_procurement_settings(apps, schema_editor):
@@ -18,7 +18,6 @@ def seed_procurement_settings(apps, schema_editor):
class Migration(migrations.Migration):
-
dependencies = [
("procurement", "0013_procurementrequestview"),
]
diff --git a/backend/procurement/migrations/0015_procurementitemattachment.py b/backend/procurement/migrations/0015_procurementitemattachment.py
index b5f287d9..3066a4f7 100644
--- a/backend/procurement/migrations/0015_procurementitemattachment.py
+++ b/backend/procurement/migrations/0015_procurementitemattachment.py
@@ -1,35 +1,77 @@
# Generated by Django 5.2.4 on 2026-07-17 07:34
import django.db.models.deletion
-import procurement.models
from django.conf import settings
from django.db import migrations, models
+import procurement.models
+
class Migration(migrations.Migration):
-
dependencies = [
- ('procurement', '0014_procurementsettings'),
+ ("procurement", "0014_procurementsettings"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
- name='ProcurementItemAttachment',
+ name="ProcurementItemAttachment",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('file', models.FileField(max_length=500, upload_to=procurement.models.procurement_item_attachment_upload_to, verbose_name='Файл')),
- ('file_name', models.TextField(verbose_name='Имя файла')),
- ('file_size', models.BigIntegerField(default=0, verbose_name='Размер файла')),
- ('mime_type', models.CharField(blank=True, max_length=255, verbose_name='MIME-тип')),
- ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
- ('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='procurement.procurementitem', verbose_name='Позиция')),
- ('uploaded_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='procurement_item_attachments', to=settings.AUTH_USER_MODEL, verbose_name='Загрузил')),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "file",
+ models.FileField(
+ max_length=500,
+ upload_to=procurement.models.procurement_item_attachment_upload_to,
+ verbose_name="Файл",
+ ),
+ ),
+ ("file_name", models.TextField(verbose_name="Имя файла")),
+ (
+ "file_size",
+ models.BigIntegerField(default=0, verbose_name="Размер файла"),
+ ),
+ (
+ "mime_type",
+ models.CharField(
+ blank=True, max_length=255, verbose_name="MIME-тип"
+ ),
+ ),
+ (
+ "created_at",
+ models.DateTimeField(auto_now_add=True, verbose_name="Создано"),
+ ),
+ (
+ "item",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="attachments",
+ to="procurement.procurementitem",
+ verbose_name="Позиция",
+ ),
+ ),
+ (
+ "uploaded_by",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="procurement_item_attachments",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Загрузил",
+ ),
+ ),
],
options={
- 'verbose_name': 'Вложение позиции закупки',
- 'verbose_name_plural': 'Вложения позиций закупки',
- 'ordering': ['created_at', 'id'],
+ "verbose_name": "Вложение позиции закупки",
+ "verbose_name_plural": "Вложения позиций закупки",
+ "ordering": ["created_at", "id"],
},
),
]
diff --git a/backend/procurement/migrations/0016_procurementrequestactivity.py b/backend/procurement/migrations/0016_procurementrequestactivity.py
index 03aa082c..2a4d5018 100644
--- a/backend/procurement/migrations/0016_procurementrequestactivity.py
+++ b/backend/procurement/migrations/0016_procurementrequestactivity.py
@@ -58,30 +58,113 @@ def backfill_procurement_activity(apps, schema_editor):
class Migration(migrations.Migration):
-
dependencies = [
- ('procurement', '0015_procurementitemattachment'),
+ ("procurement", "0015_procurementitemattachment"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
- name='ProcurementRequestActivity',
+ name="ProcurementRequestActivity",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('action', models.CharField(choices=[('created', 'Создал заявку'), ('updated', 'Изменил заявку'), ('submitted', 'Отправил на согласование'), ('stage_approved', 'Согласовал этап'), ('stage_rejected', 'Отклонил этап согласования'), ('approved', 'Заявка согласована'), ('started', 'Взял заявку в работу'), ('executor_reassigned', 'Сменил исполнителя'), ('completed', 'Завершил заявку'), ('rejected', 'Отклонил заявку'), ('cancelled', 'Отменил заявку'), ('item_updated', 'Изменил исполнение позиции'), ('all_items_received', 'Отметил все позиции полученными'), ('attachment_added', 'Добавил файл'), ('attachment_removed', 'Удалил файл'), ('comment_added', 'Добавил комментарий'), ('comment_removed', 'Удалил комментарий'), ('arrival_notified', 'Уведомил о поступлении')], max_length=32, verbose_name='Действие')),
- ('object_kind', models.CharField(blank=True, choices=[('request', 'Заявка'), ('approval', 'Согласование'), ('item', 'Позиция'), ('attachment', 'Файл'), ('comment', 'Комментарий')], max_length=32, verbose_name='Тип объекта')),
- ('object_id', models.PositiveBigIntegerField(blank=True, null=True, verbose_name='ID объекта')),
- ('metadata', models.JSONField(blank=True, default=dict, verbose_name='Метаданные')),
- ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
- ('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='procurement_activities', to=settings.AUTH_USER_MODEL, verbose_name='Инициатор')),
- ('request', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activities', to='procurement.procurementrequest', verbose_name='Заявка')),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "action",
+ models.CharField(
+ choices=[
+ ("created", "Создал заявку"),
+ ("updated", "Изменил заявку"),
+ ("submitted", "Отправил на согласование"),
+ ("stage_approved", "Согласовал этап"),
+ ("stage_rejected", "Отклонил этап согласования"),
+ ("approved", "Заявка согласована"),
+ ("started", "Взял заявку в работу"),
+ ("executor_reassigned", "Сменил исполнителя"),
+ ("completed", "Завершил заявку"),
+ ("rejected", "Отклонил заявку"),
+ ("cancelled", "Отменил заявку"),
+ ("item_updated", "Изменил исполнение позиции"),
+ ("all_items_received", "Отметил все позиции полученными"),
+ ("attachment_added", "Добавил файл"),
+ ("attachment_removed", "Удалил файл"),
+ ("comment_added", "Добавил комментарий"),
+ ("comment_removed", "Удалил комментарий"),
+ ("arrival_notified", "Уведомил о поступлении"),
+ ],
+ max_length=32,
+ verbose_name="Действие",
+ ),
+ ),
+ (
+ "object_kind",
+ models.CharField(
+ blank=True,
+ choices=[
+ ("request", "Заявка"),
+ ("approval", "Согласование"),
+ ("item", "Позиция"),
+ ("attachment", "Файл"),
+ ("comment", "Комментарий"),
+ ],
+ max_length=32,
+ verbose_name="Тип объекта",
+ ),
+ ),
+ (
+ "object_id",
+ models.PositiveBigIntegerField(
+ blank=True, null=True, verbose_name="ID объекта"
+ ),
+ ),
+ (
+ "metadata",
+ models.JSONField(
+ blank=True, default=dict, verbose_name="Метаданные"
+ ),
+ ),
+ (
+ "created_at",
+ models.DateTimeField(auto_now_add=True, verbose_name="Создано"),
+ ),
+ (
+ "actor",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="procurement_activities",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Инициатор",
+ ),
+ ),
+ (
+ "request",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="activities",
+ to="procurement.procurementrequest",
+ verbose_name="Заявка",
+ ),
+ ),
],
options={
- 'verbose_name': 'История заявки на закупку',
- 'verbose_name_plural': 'История заявок на закупку',
- 'ordering': ['-created_at', '-id'],
- 'indexes': [models.Index(fields=['request', '-created_at'], name='procurement_request_4b22f4_idx')],
+ "verbose_name": "История заявки на закупку",
+ "verbose_name_plural": "История заявок на закупку",
+ "ordering": ["-created_at", "-id"],
+ "indexes": [
+ models.Index(
+ fields=["request", "-created_at"],
+ name="procurement_request_4b22f4_idx",
+ )
+ ],
},
),
migrations.RunPython(
diff --git a/backend/procurement/migrations/0017_add_item_defective_at.py b/backend/procurement/migrations/0017_add_item_defective_at.py
index 0a1bab32..6be826b6 100644
--- a/backend/procurement/migrations/0017_add_item_defective_at.py
+++ b/backend/procurement/migrations/0017_add_item_defective_at.py
@@ -4,33 +4,34 @@ from django.db import migrations, models
def backfill_defective_at(apps, schema_editor):
- ProcurementItem = apps.get_model('procurement', 'ProcurementItem')
+ ProcurementItem = apps.get_model("procurement", "ProcurementItem")
pending = []
queryset = ProcurementItem.objects.filter(
- execution_status='defective',
+ execution_status="defective",
defective_at__isnull=True,
- ).select_related('request')
+ ).select_related("request")
for item in queryset.iterator(chunk_size=500):
item.defective_at = item.request.updated_at
pending.append(item)
if len(pending) >= 500:
- ProcurementItem.objects.bulk_update(pending, ['defective_at'])
+ ProcurementItem.objects.bulk_update(pending, ["defective_at"])
pending.clear()
if pending:
- ProcurementItem.objects.bulk_update(pending, ['defective_at'])
+ ProcurementItem.objects.bulk_update(pending, ["defective_at"])
class Migration(migrations.Migration):
-
dependencies = [
- ('procurement', '0016_procurementrequestactivity'),
+ ("procurement", "0016_procurementrequestactivity"),
]
operations = [
migrations.AddField(
- model_name='procurementitem',
- name='defective_at',
- field=models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='Отмечено как брак'),
+ model_name="procurementitem",
+ name="defective_at",
+ field=models.DateTimeField(
+ blank=True, db_index=True, null=True, verbose_name="Отмечено как брак"
+ ),
),
migrations.RunPython(
backfill_defective_at,
diff --git a/backend/procurement/migrations/0018_procurementitemattachment_comment.py b/backend/procurement/migrations/0018_procurementitemattachment_comment.py
index d2982816..83bd0686 100644
--- a/backend/procurement/migrations/0018_procurementitemattachment_comment.py
+++ b/backend/procurement/migrations/0018_procurementitemattachment_comment.py
@@ -3,7 +3,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
("communications", "0039_alter_chat_context_object_id_bigint"),
("procurement", "0017_add_item_defective_at"),
diff --git a/backend/procurement/models.py b/backend/procurement/models.py
index 42f15aa4..d2019feb 100644
--- a/backend/procurement/models.py
+++ b/backend/procurement/models.py
@@ -22,6 +22,7 @@ from django.db import models
from django.db.models import Sum
from django.db.models.functions import Coalesce
from django.utils import timezone
+
from employees.models import Department
from .constants import (
@@ -198,9 +199,7 @@ class ProcurementRequest(models.Model):
def get_required_approval_priorities(self):
"""Возвращает приоритеты обязательных этапов из таблицы маршрутов."""
return list(
- self.get_required_approval_routes().values_list(
- "priority", flat=True
- )
+ self.get_required_approval_routes().values_list("priority", flat=True)
)
def get_required_approval_routes(self):
@@ -245,10 +244,14 @@ class ProcurementRequest(models.Model):
@property
def is_editable(self):
"""Можно ли редактировать заявку."""
- return self.status in [
- ProcurementStatus.DRAFT,
- ProcurementStatus.WAITING,
- ] and self.executor_id is None
+ return (
+ self.status
+ in [
+ ProcurementStatus.DRAFT,
+ ProcurementStatus.WAITING,
+ ]
+ and self.executor_id is None
+ )
def recalculate_fulfillment_status(self, save=True):
"""Пересчитать рабочий статус заявки по количествам позиций."""
@@ -256,7 +259,8 @@ class ProcurementRequest(models.Model):
if not items:
new_status = ProcurementFulfillmentStatus.PENDING
elif any(
- item.execution_status in {
+ item.execution_status
+ in {
ProcurementItemExecutionStatus.REJECTED,
ProcurementItemExecutionStatus.COMPLETED_WITH_ISSUE,
ProcurementItemExecutionStatus.EDITED,
@@ -270,12 +274,8 @@ class ProcurementRequest(models.Model):
fulfillment_target_quantity = sum(
item.fulfillment_target_quantity for item in items
)
- ordered_quantity = sum(
- item.effective_ordered_quantity for item in items
- )
- received_quantity = sum(
- item.effective_received_quantity for item in items
- )
+ ordered_quantity = sum(item.effective_ordered_quantity for item in items)
+ received_quantity = sum(item.effective_received_quantity for item in items)
if received_quantity >= fulfillment_target_quantity:
new_status = ProcurementFulfillmentStatus.COMPLETED
@@ -290,7 +290,8 @@ class ProcurementRequest(models.Model):
should_close = (
new_status == ProcurementFulfillmentStatus.COMPLETED
- and self.status in [
+ and self.status
+ in [
ProcurementStatus.WAITING,
ProcurementStatus.IN_PROGRESS,
]
@@ -773,8 +774,7 @@ class ApprovalRoute(models.Model):
@classmethod
def get_applicable_routes(cls, total_cost):
return cls.objects.filter(
- models.Q(min_amount__isnull=True)
- | models.Q(min_amount__lte=total_cost)
+ models.Q(min_amount__isnull=True) | models.Q(min_amount__lte=total_cost)
).order_by("priority", "id")
def __str__(self):
@@ -792,15 +792,11 @@ class ApprovalRoute(models.Model):
raise ValidationError(
{"employee": "Для статического этапа укажите сотрудника."}
)
- if (
- self.resolver_type == self.ResolverType.DEPARTMENT_HEAD
- and self.employee_id
- ):
+ if self.resolver_type == self.ResolverType.DEPARTMENT_HEAD and self.employee_id:
raise ValidationError(
{
"employee": (
- "Для этапа руководителя отдела "
- "сотрудник не указывается."
+ "Для этапа руководителя отдела сотрудник не указывается."
)
}
)
@@ -1012,9 +1008,7 @@ class MaintenanceRecord(models.Model):
related_name="performed_maintenance",
verbose_name="Выполнил",
)
- next_maintenance_date = models.DateField(
- "Следующее ТО", null=True, blank=True
- )
+ next_maintenance_date = models.DateField("Следующее ТО", null=True, blank=True)
created_at = models.DateTimeField("Создано", auto_now_add=True)
class Meta:
@@ -1203,9 +1197,7 @@ class Supplier(models.Model):
"""Поставщик товаров/услуг."""
name = models.CharField("Название", max_length=255)
- contact_person = models.CharField(
- "Контактное лицо", max_length=255, blank=True
- )
+ contact_person = models.CharField("Контактное лицо", max_length=255, blank=True)
phone = models.CharField("Телефон", max_length=50, blank=True)
email = models.EmailField("Email", blank=True)
address = models.TextField("Адрес", blank=True)
diff --git a/backend/procurement/notifications/__init__.py b/backend/procurement/notifications/__init__.py
index 0c36e608..f4685ac4 100644
--- a/backend/procurement/notifications/__init__.py
+++ b/backend/procurement/notifications/__init__.py
@@ -25,60 +25,56 @@ Usage:
"""
# Импорты для удобства и обратной совместимости
-from .config import NotificationVerbs, MessageTemplates, ActionURLs
-from .handlers import (
- notify_new_request,
- notify_approvers,
- notify_approver,
- notify_processing_department_request,
- notify_requestor,
- notify_request_approved,
- notify_request_rejected,
- notify_request_completed,
- notify_request_arrival,
- notify_request_in_progress,
- notify_executor_reassigned,
- notify_request_returned_to_processing,
- notify_request_cancelled,
- notify_stage_approved,
- notify_stage_rejected,
- notify_item_updated,
- notify_item_issue_reported,
- notify_request_comment,
- notify_item_comment,
-)
-
# Сигналы импортируются для регистрации через AppConfig.ready()
from . import signals # noqa: F401
-
+from .config import ActionURLs, MessageTemplates, NotificationVerbs
+from .handlers import (
+ notify_approver,
+ notify_approvers,
+ notify_executor_reassigned,
+ notify_item_comment,
+ notify_item_issue_reported,
+ notify_item_updated,
+ notify_new_request,
+ notify_processing_department_request,
+ notify_request_approved,
+ notify_request_arrival,
+ notify_request_cancelled,
+ notify_request_comment,
+ notify_request_completed,
+ notify_request_in_progress,
+ notify_request_rejected,
+ notify_request_returned_to_processing,
+ notify_requestor,
+ notify_stage_approved,
+ notify_stage_rejected,
+)
__all__ = [
# Config
- 'NotificationVerbs',
- 'MessageTemplates',
- 'ActionURLs',
-
+ "NotificationVerbs",
+ "MessageTemplates",
+ "ActionURLs",
# Handlers
- 'notify_new_request',
- 'notify_approvers',
- 'notify_approver',
- 'notify_processing_department_request',
- 'notify_requestor',
- 'notify_request_approved',
- 'notify_request_rejected',
- 'notify_request_completed',
- 'notify_request_arrival',
- 'notify_request_in_progress',
- 'notify_executor_reassigned',
- 'notify_request_returned_to_processing',
- 'notify_request_cancelled',
- 'notify_stage_approved',
- 'notify_stage_rejected',
- 'notify_item_updated',
- 'notify_item_issue_reported',
- 'notify_request_comment',
- 'notify_item_comment',
-
+ "notify_new_request",
+ "notify_approvers",
+ "notify_approver",
+ "notify_processing_department_request",
+ "notify_requestor",
+ "notify_request_approved",
+ "notify_request_rejected",
+ "notify_request_completed",
+ "notify_request_arrival",
+ "notify_request_in_progress",
+ "notify_executor_reassigned",
+ "notify_request_returned_to_processing",
+ "notify_request_cancelled",
+ "notify_stage_approved",
+ "notify_stage_rejected",
+ "notify_item_updated",
+ "notify_item_issue_reported",
+ "notify_request_comment",
+ "notify_item_comment",
# Signals module (для импорта в AppConfig)
- 'signals',
+ "signals",
]
diff --git a/backend/procurement/notifications/config.py b/backend/procurement/notifications/config.py
index 482812ab..7e5dc584 100644
--- a/backend/procurement/notifications/config.py
+++ b/backend/procurement/notifications/config.py
@@ -7,35 +7,37 @@
# ===== Типы уведомлений (Verbs) =====
+
class NotificationVerbs:
"""Типы уведомлений для заявок на закупку."""
# Новая заявка
- NEW_REQUEST = 'procurement_new_request'
- DEPARTMENT_REQUEST = 'procurement_department_request'
+ NEW_REQUEST = "procurement_new_request"
+ DEPARTMENT_REQUEST = "procurement_department_request"
# Согласование
- PENDING_APPROVAL = 'procurement_pending_approval'
- STAGE_APPROVED = 'procurement_stage_approved'
+ PENDING_APPROVAL = "procurement_pending_approval"
+ STAGE_APPROVED = "procurement_stage_approved"
# Статусы
- APPROVED = 'procurement_approved'
- REJECTED = 'procurement_rejected'
- IN_PROGRESS = 'procurement_in_progress'
- EXECUTOR_REASSIGNED = 'procurement_executor_reassigned'
- ARRIVAL_NOTICE = 'procurement_arrival_notice'
- COMPLETED = 'procurement_completed'
- CANCELLED = 'procurement_cancelled'
+ APPROVED = "procurement_approved"
+ REJECTED = "procurement_rejected"
+ IN_PROGRESS = "procurement_in_progress"
+ EXECUTOR_REASSIGNED = "procurement_executor_reassigned"
+ ARRIVAL_NOTICE = "procurement_arrival_notice"
+ COMPLETED = "procurement_completed"
+ CANCELLED = "procurement_cancelled"
# Позиции и комментарии
- ITEM_UPDATED = 'procurement_item_updated'
- ITEM_ISSUE_REPORTED = 'procurement_item_issue_reported'
- REQUEST_COMMENTED = 'procurement_request_commented'
- ITEM_COMMENTED = 'procurement_item_commented'
+ ITEM_UPDATED = "procurement_item_updated"
+ ITEM_ISSUE_REPORTED = "procurement_item_issue_reported"
+ REQUEST_COMMENTED = "procurement_request_commented"
+ ITEM_COMMENTED = "procurement_item_commented"
# ===== Шаблоны сообщений =====
+
class MessageTemplates:
"""Шаблоны сообщений для уведомлений."""
@@ -51,11 +53,8 @@ class MessageTemplates:
Returns:
tuple: (notification_title, description)
"""
- notification_title = 'Новая заявка на закупку'
- description = (
- f'{author_name} создал заявку "{title}" '
- f'на сумму {total_cost}₽'
- )
+ notification_title = "Новая заявка на закупку"
+ description = f'{author_name} создал заявку "{title}" на сумму {total_cost}₽'
return notification_title, description
@staticmethod
@@ -64,27 +63,23 @@ class MessageTemplates:
department_name: str,
author_name: str,
) -> tuple[str, str]:
- notification_title = 'Новая заявка на закупку'
+ notification_title = "Новая заявка на закупку"
description = (
- f'{author_name} направил заявку "{title}" в отдел '
- f'{department_name}.'
+ f'{author_name} направил заявку "{title}" в отдел {department_name}.'
)
return notification_title, description
@staticmethod
- def pending_approval(
- title: str, total_cost: float
- ) -> tuple[str, str]:
+ def pending_approval(title: str, total_cost: float) -> tuple[str, str]:
"""
Шаблон для запроса на согласование.
Returns:
tuple: (notification_title, description)
"""
- notification_title = 'Требуется согласование'
+ notification_title = "Требуется согласование"
description = (
- f'Заявка "{title}" ожидает вашего согласования. '
- f'Сумма: {total_cost}₽'
+ f'Заявка "{title}" ожидает вашего согласования. Сумма: {total_cost}₽'
)
return notification_title, description
@@ -96,7 +91,7 @@ class MessageTemplates:
Returns:
tuple: (notification_title, description)
"""
- notification_title = 'Этап согласования пройден'
+ notification_title = "Этап согласования пройден"
description = f'{approver_name} одобрил заявку "{title}".'
return notification_title, description
@@ -108,10 +103,9 @@ class MessageTemplates:
Returns:
tuple: (notification_title, description)
"""
- notification_title = 'Заявка одобрена'
+ notification_title = "Заявка одобрена"
description = (
- f'Ваша заявка "{title}" была одобрена. '
- f'Можно приступать к закупке.'
+ f'Ваша заявка "{title}" была одобрена. Можно приступать к закупке.'
)
return notification_title, description
@@ -123,16 +117,13 @@ class MessageTemplates:
Returns:
tuple: (notification_title, description)
"""
- notification_title = 'Заявка отклонена'
+ notification_title = "Заявка отклонена"
if comment:
- description = (
- f'Ваша заявка "{title}" была отклонена. '
- f'Причина: {comment}'
- )
+ description = f'Ваша заявка "{title}" была отклонена. Причина: {comment}'
else:
description = (
f'Ваша заявка "{title}" была отклонена. '
- f'Проверьте комментарии согласующих.'
+ f"Проверьте комментарии согласующих."
)
return notification_title, description
@@ -146,55 +137,38 @@ class MessageTemplates:
Returns:
tuple: (notification_title, description)
"""
- notification_title = 'Заявка отклонена'
- description = (
- f'{approver_name} отклонил заявку "{title}". '
- f'Причина: {comment}'
- )
+ notification_title = "Заявка отклонена"
+ description = f'{approver_name} отклонил заявку "{title}". Причина: {comment}'
return notification_title, description
@staticmethod
- def executor_reassigned(
- title: str, executor_name: str
- ) -> tuple[str, str]:
- notification_title = 'Заявку забрал другой сотрудник'
- description = (
- f'{executor_name} взял в работу заявку "{title}".'
- )
+ def executor_reassigned(title: str, executor_name: str) -> tuple[str, str]:
+ notification_title = "Заявку забрал другой сотрудник"
+ description = f'{executor_name} взял в работу заявку "{title}".'
return notification_title, description
@staticmethod
- def in_progress(
- title: str, executor_name: str
- ) -> tuple[str, str]:
+ def in_progress(title: str, executor_name: str) -> tuple[str, str]:
"""
Шаблон для взятия заявки в работу.
Returns:
tuple: (notification_title, description)
"""
- notification_title = 'Заявка взята в работу'
- description = (
- f'Заявка "{title}" взята в работу '
- f'пользователем {executor_name}.'
- )
+ notification_title = "Заявка взята в работу"
+ description = f'Заявка "{title}" взята в работу пользователем {executor_name}.'
return notification_title, description
@staticmethod
- def in_progress_requestor(
- title: str, executor_name: str
- ) -> tuple[str, str]:
+ def in_progress_requestor(title: str, executor_name: str) -> tuple[str, str]:
"""
Шаблон для создателя заявки о взятии в работу.
Returns:
tuple: (notification_title, description)
"""
- notification_title = 'Ваша заявка взята в работу'
- description = (
- f'Заявка "{title}" взята в работу '
- f'пользователем {executor_name}.'
- )
+ notification_title = "Ваша заявка взята в работу"
+ description = f'Заявка "{title}" взята в работу пользователем {executor_name}.'
return notification_title, description
@staticmethod
@@ -205,7 +179,7 @@ class MessageTemplates:
Returns:
tuple: (notification_title, description)
"""
- notification_title = 'Заявка завершена'
+ notification_title = "Заявка завершена"
description = f'Закупка по заявке "{title}" завершена.'
return notification_title, description
@@ -217,60 +191,46 @@ class MessageTemplates:
Returns:
tuple: (notification_title, description)
"""
- notification_title = 'Заявка завершена'
+ notification_title = "Заявка завершена"
description = f'Заявка "{title}" успешно завершена.'
return notification_title, description
@staticmethod
def arrival_notice(title: str) -> tuple[str, str]:
- notification_title = 'Поступление по закупке'
+ notification_title = "Поступление по закупке"
description = (
f'По заявке "{title}" есть поступление. '
- f'Проверьте позиции и подтвердите получение.'
+ f"Проверьте позиции и подтвердите получение."
)
return notification_title, description
@staticmethod
- def cancelled(title: str, reason: str = 'не указана') -> tuple[str, str]:
+ def cancelled(title: str, reason: str = "не указана") -> tuple[str, str]:
"""
Шаблон для отмены заявки.
Returns:
tuple: (notification_title, description)
"""
- notification_title = 'Заявка отменена'
- description = (
- f'Заявка "{title}" была отменена автором. '
- f'Причина: {reason}'
- )
+ notification_title = "Заявка отменена"
+ description = f'Заявка "{title}" была отменена автором. Причина: {reason}'
return notification_title, description
@staticmethod
- def item_updated(
- title: str, item_name: str, actor_name: str
- ) -> tuple[str, str]:
- notification_title = 'Позиция закупки изменена'
- description = (
- f'{actor_name} изменил позицию "{item_name}" '
- f'в заявке "{title}".'
- )
+ def item_updated(title: str, item_name: str, actor_name: str) -> tuple[str, str]:
+ notification_title = "Позиция закупки изменена"
+ description = f'{actor_name} изменил позицию "{item_name}" в заявке "{title}".'
return notification_title, description
@staticmethod
- def request_commented(
- title: str, actor_name: str
- ) -> tuple[str, str]:
- notification_title = 'Новый комментарий к закупке'
- description = (
- f'{actor_name} оставил комментарий к заявке "{title}".'
- )
+ def request_commented(title: str, actor_name: str) -> tuple[str, str]:
+ notification_title = "Новый комментарий к закупке"
+ description = f'{actor_name} оставил комментарий к заявке "{title}".'
return notification_title, description
@staticmethod
- def item_commented(
- title: str, item_name: str, actor_name: str
- ) -> tuple[str, str]:
- notification_title = 'Новый комментарий к позиции'
+ def item_commented(title: str, item_name: str, actor_name: str) -> tuple[str, str]:
+ notification_title = "Новый комментарий к позиции"
description = (
f'{actor_name} оставил комментарий к позиции "{item_name}" '
f'в заявке "{title}".'
@@ -280,12 +240,13 @@ class MessageTemplates:
# ===== URL для действий =====
+
class ActionURLs:
"""URL для переходов из уведомлений."""
- PROCUREMENT_LIST = '/procurement'
+ PROCUREMENT_LIST = "/procurement"
@staticmethod
def request_detail(request_id: int) -> str:
"""URL детальной страницы заявки."""
- return f'/procurement?request={request_id}'
+ return f"/procurement?request={request_id}"
diff --git a/backend/procurement/notifications/handlers.py b/backend/procurement/notifications/handlers.py
index bf9177c2..8a2514e5 100644
--- a/backend/procurement/notifications/handlers.py
+++ b/backend/procurement/notifications/handlers.py
@@ -6,9 +6,10 @@
"""
import logging
+
from notifications.signals import notify
-from .config import NotificationVerbs, MessageTemplates, ActionURLs
+from .config import ActionURLs, MessageTemplates, NotificationVerbs
logger = logging.getLogger(__name__)
diff --git a/backend/procurement/notifications/read_service.py b/backend/procurement/notifications/read_service.py
index 3844521c..8bdf48be 100644
--- a/backend/procurement/notifications/read_service.py
+++ b/backend/procurement/notifications/read_service.py
@@ -20,9 +20,8 @@ class ProcurementNotificationReadResult:
class ProcurementNotificationReadService:
"""Use case for reading notifications related to a procurement request."""
- procurement_verb_filter = (
- Q(verb__startswith="procurement_")
- | Q(verb__startswith="equipment_")
+ procurement_verb_filter = Q(verb__startswith="procurement_") | Q(
+ verb__startswith="equipment_"
)
def mark_request_notifications_as_read(
@@ -49,9 +48,7 @@ class ProcurementNotificationReadService:
unread=True,
deleted=False,
verb="procurement_item_commented",
- ).filter(
- Q(data__item_id=item_id) | Q(data__item_id=str(item_id))
- )
+ ).filter(Q(data__item_id=item_id) | Q(data__item_id=str(item_id)))
return self._mark_queryset_as_read(user_id=user.id, queryset=queryset)
def _mark_queryset_as_read(
diff --git a/backend/procurement/notifications/signals.py b/backend/procurement/notifications/signals.py
index df226e56..4e96974f 100644
--- a/backend/procurement/notifications/signals.py
+++ b/backend/procurement/notifications/signals.py
@@ -11,6 +11,7 @@ notify.send() → channels.py → Celery → WebSocket/Email/Push
"""
import logging
+
from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver
@@ -20,11 +21,11 @@ from .handlers import (
notify_approvers,
notify_processing_department_request,
notify_request_approved,
- notify_request_rejected,
+ notify_request_cancelled,
notify_request_completed,
notify_request_in_progress,
+ notify_request_rejected,
notify_request_returned_to_processing,
- notify_request_cancelled,
notify_stage_approved,
notify_stage_rejected,
)
diff --git a/backend/procurement/rules.py b/backend/procurement/rules.py
index ab528821..d571bb3a 100644
--- a/backend/procurement/rules.py
+++ b/backend/procurement/rules.py
@@ -9,7 +9,6 @@ import rules
from employees.positions import employee_position_search_text
-
# -----------------------------------------------------------------------------
# ПРЕДИКАТЫ (predicates)
# -----------------------------------------------------------------------------
@@ -184,9 +183,7 @@ rules.add_rule(
)
# Создание/изменение поставщика
-rules.add_rule(
- "procurement.manage_supplier", is_superuser | can_manage_suppliers
-)
+rules.add_rule("procurement.manage_supplier", is_superuser | can_manage_suppliers)
# Удаление поставщика
rules.add_rule("procurement.delete_supplier", is_superuser)
diff --git a/backend/procurement/services.py b/backend/procurement/services.py
index 85d8d3d6..a12be570 100644
--- a/backend/procurement/services.py
+++ b/backend/procurement/services.py
@@ -15,7 +15,6 @@ from procurement.constants import (
get_default_approval_step_name,
)
-
HTTP_LINK_PATTERN = re.compile(r"https?://[^\s<>\"']+", re.IGNORECASE)
TRAILING_LINK_PUNCTUATION = ".,;:!?)]}"
@@ -52,17 +51,17 @@ def _http_link_identity(value: str) -> str | None:
return None
parsed = urlsplit(normalized)
path = "" if parsed.path == "/" else parsed.path
- return urlunsplit((parsed.scheme, parsed.netloc, path, parsed.query, parsed.fragment))
+ return urlunsplit(
+ (parsed.scheme, parsed.netloc, path, parsed.query, parsed.fragment)
+ )
-def merge_comment_links(existing_links, comment_text: str) -> tuple[list[str], list[str]]:
+def merge_comment_links(
+ existing_links, comment_text: str
+) -> tuple[list[str], list[str]]:
"""Append unique HTTP links extracted from a position comment."""
- merged = [
- str(link).strip()
- for link in (existing_links or [])
- if str(link).strip()
- ]
+ merged = [str(link).strip() for link in (existing_links or []) if str(link).strip()]
identities = {
identity
for link in merged
@@ -282,9 +281,7 @@ class ProcurementApprovalResolver:
users_by_id = Employee.objects.in_bulk(approver_ids)
return [
- users_by_id[user_id]
- for user_id in approver_ids
- if user_id in users_by_id
+ users_by_id[user_id] for user_id in approver_ids if user_id in users_by_id
]
@classmethod
@@ -578,7 +575,8 @@ class ProcurementApprovalResolver:
step_name = (
step[2]
if step
- else route.name or get_default_approval_step_name(
+ else route.name
+ or get_default_approval_step_name(
route.priority,
resolver_type=route.resolver_type,
)
@@ -608,10 +606,7 @@ class ProcurementApprovalResolver:
"status": ApprovalStatus.PENDING,
},
)
- if (
- approval.approver_id != user.id
- or approval.status != ApprovalStatus.PENDING
- ):
+ if approval.approver_id != user.id or approval.status != ApprovalStatus.PENDING:
return None
return approval
@@ -621,8 +616,7 @@ class ProcurementApprovalResolver:
return False
return (
cls.get_available_approval(user, procurement_request) is not None
- or cls.get_available_higher_route(user, procurement_request)
- is not None
+ or cls.get_available_higher_route(user, procurement_request) is not None
)
@@ -662,9 +656,7 @@ class EquipmentService:
@classmethod
@transaction.atomic
- def assign_to_person(
- cls, equipment, person, location: Optional[str] = None
- ):
+ def assign_to_person(cls, equipment, person, location: Optional[str] = None):
"""
Назначает оборудование ответственному лицу.
"""
@@ -764,8 +756,9 @@ class EquipmentService:
Добавляет запись о техническом обслуживании.
"""
from decimal import Decimal
- from .models import MaintenanceRecord
+
from .constants import EquipmentStatus
+ from .models import MaintenanceRecord
if date is None:
date = timezone.now().date()
diff --git a/backend/procurement/signals.py b/backend/procurement/signals.py
index ec6e92d2..4e29201b 100644
--- a/backend/procurement/signals.py
+++ b/backend/procurement/signals.py
@@ -12,7 +12,6 @@ from .models import (
ProcurementRequest,
)
-
REQUEST_STATUS_ACTIONS = {
ProcurementStatus.PENDING: ProcurementActivityAction.SUBMITTED,
ProcurementStatus.APPROVED: ProcurementActivityAction.APPROVED,
diff --git a/backend/projects/admin.py b/backend/projects/admin.py
index cafbbbd3..ce9f2d40 100644
--- a/backend/projects/admin.py
+++ b/backend/projects/admin.py
@@ -1,13 +1,14 @@
from django.contrib import admin
+
from .models import (
+ AllocationReminder,
+ AllocationRevision,
+ DailyAllocation,
Project,
+ ProjectEvent,
+ ProjectExternalBinding,
ProjectMembership,
ProjectStage,
- ProjectExternalBinding,
- ProjectEvent,
- DailyAllocation,
- AllocationRevision,
- AllocationReminder,
)
diff --git a/backend/projects/migrations/0002_projectboardlink.py b/backend/projects/migrations/0002_projectboardlink.py
index 0b5cf612..6a8c98a6 100644
--- a/backend/projects/migrations/0002_projectboardlink.py
+++ b/backend/projects/migrations/0002_projectboardlink.py
@@ -6,26 +6,58 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('projects', '0001_initial'),
- ('tasks', '0042_alter_taskactivity_object_kind_and_more'),
+ ("projects", "0001_initial"),
+ ("tasks", "0042_alter_taskactivity_object_kind_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
- name='ProjectBoardLink',
+ name="ProjectBoardLink",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('created_at', models.DateTimeField(auto_now_add=True)),
- ('board', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_links', to='tasks.taskboard')),
- ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_project_board_links', to=settings.AUTH_USER_MODEL)),
- ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='board_links', to='projects.project')),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("created_at", models.DateTimeField(auto_now_add=True)),
+ (
+ "board",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="project_links",
+ to="tasks.taskboard",
+ ),
+ ),
+ (
+ "created_by",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="created_project_board_links",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "project",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="board_links",
+ to="projects.project",
+ ),
+ ),
],
options={
- 'ordering': ['board__name', 'board_id'],
- 'constraints': [models.UniqueConstraint(fields=('project', 'board'), name='project_board_link_unique')],
+ "ordering": ["board__name", "board_id"],
+ "constraints": [
+ models.UniqueConstraint(
+ fields=("project", "board"), name="project_board_link_unique"
+ )
+ ],
},
),
]
diff --git a/backend/projects/models/__init__.py b/backend/projects/models/__init__.py
index 8eee571d..7762f1e3 100644
--- a/backend/projects/models/__init__.py
+++ b/backend/projects/models/__init__.py
@@ -1,12 +1,12 @@
-from .project import Project, ProjectBoardLink, ProjectEvent, ProjectResource
-from .membership import ProjectMembership
-from .stage import ProjectStage
from .external_binding import ProjectExternalBinding
+from .membership import ProjectMembership
+from .project import Project, ProjectBoardLink, ProjectEvent, ProjectResource
+from .stage import ProjectStage
from .time_allocation import (
+ AllocationReminder,
+ AllocationRevision,
DailyAllocation,
TimeAllocation,
- AllocationRevision,
- AllocationReminder,
)
__all__ = [
diff --git a/backend/projects/permissions.py b/backend/projects/permissions.py
index 1b067927..8c543d60 100644
--- a/backend/projects/permissions.py
+++ b/backend/projects/permissions.py
@@ -1,4 +1,5 @@
from django.db.models import Q
+
from .models import Project
diff --git a/backend/projects/services/time_tracking.py b/backend/projects/services/time_tracking.py
index 10dbe96d..c35bfd9a 100644
--- a/backend/projects/services/time_tracking.py
+++ b/backend/projects/services/time_tracking.py
@@ -1,17 +1,19 @@
from datetime import timedelta
from decimal import Decimal
+
from django.contrib.auth import get_user_model
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
from rest_framework.exceptions import ValidationError
+
from attendance.models import AttendanceRecord
from projects.models import (
- DailyAllocation,
- TimeAllocation,
- AllocationRevision,
- Project,
AllocationReminder,
+ AllocationRevision,
+ DailyAllocation,
+ Project,
+ TimeAllocation,
)
diff --git a/backend/realtime/admin.py b/backend/realtime/admin.py
index b97a94f6..846f6b40 100644
--- a/backend/realtime/admin.py
+++ b/backend/realtime/admin.py
@@ -1,2 +1 @@
-
# Register your models here.
diff --git a/backend/realtime/apps.py b/backend/realtime/apps.py
index e7253a71..643c3f91 100644
--- a/backend/realtime/apps.py
+++ b/backend/realtime/apps.py
@@ -2,5 +2,5 @@ from django.apps import AppConfig
class RealtimeConfig(AppConfig):
- default_auto_field = 'django.db.models.BigAutoField'
- name = 'realtime'
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "realtime"
diff --git a/backend/realtime/consumers.py b/backend/realtime/consumers.py
index 40a0b0eb..4370ed98 100644
--- a/backend/realtime/consumers.py
+++ b/backend/realtime/consumers.py
@@ -22,9 +22,9 @@ from channels.generic.websocket import AsyncJsonWebsocketConsumer
from django.contrib.auth.models import AnonymousUser
from django.utils import timezone
+from commerce.realtime import COMMERCE_CONNECTIONS_GROUP
from communications.consumers import ChatConsumerMixin
from communications.services import ChatNotificationReadService
-from commerce.realtime import COMMERCE_CONNECTIONS_GROUP
from scheduling.realtime import (
MAX_CALENDAR_SUBSCRIPTIONS,
CalendarSubscriptionError,
diff --git a/backend/realtime/routing.py b/backend/realtime/routing.py
index e329a2d1..8b5fd8c2 100644
--- a/backend/realtime/routing.py
+++ b/backend/realtime/routing.py
@@ -3,6 +3,7 @@
WebSocket URL routing for real-time features.
All WebSocket connections are handled through this routing.
"""
+
from django.urls import re_path
from .consumers import UserConsumer
diff --git a/backend/requests_app/__init__.py b/backend/requests_app/__init__.py
index f6f84776..e68c6319 100644
--- a/backend/requests_app/__init__.py
+++ b/backend/requests_app/__init__.py
@@ -1 +1 @@
-default_app_config = 'requests_app.apps.RequestsAppConfig'
+default_app_config = "requests_app.apps.RequestsAppConfig"
diff --git a/backend/requests_app/application/statistics.py b/backend/requests_app/application/statistics.py
index 12fef39b..9342d5bd 100644
--- a/backend/requests_app/application/statistics.py
+++ b/backend/requests_app/application/statistics.py
@@ -7,7 +7,6 @@ from rest_framework.exceptions import ValidationError
from requests_app.enums import DAY_OFF_REQUEST_TYPES, RequestStatus, RequestType
from requests_app.models import Request as EmployeeRequest
-
UNPAID_VACATION_MARKERS = (
"за свой счет",
"за свой счёт",
diff --git a/backend/requests_app/application/vacation.py b/backend/requests_app/application/vacation.py
index 58cce2b0..288e6e9b 100644
--- a/backend/requests_app/application/vacation.py
+++ b/backend/requests_app/application/vacation.py
@@ -1,6 +1,6 @@
from __future__ import annotations
-from decimal import Decimal, ROUND_HALF_UP
+from decimal import ROUND_HALF_UP, Decimal
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import transaction
@@ -31,7 +31,6 @@ from requests_app.enums import (
)
from requests_app.models import Request, RequestVacationTerms
-
MONEY_QUANTUM = Decimal("0.01")
VACATION_AVERAGE_PERIOD_LIMIT = 3
VACATION_AVERAGE_EXCLUDED_CODES = frozenset({"VACATION_PAY", "ONE_TIME_PAYMENT"})
diff --git a/backend/requests_app/apps.py b/backend/requests_app/apps.py
index e1ffe72e..a11d7a71 100644
--- a/backend/requests_app/apps.py
+++ b/backend/requests_app/apps.py
@@ -2,8 +2,8 @@ from django.apps import AppConfig
class RequestsAppConfig(AppConfig):
- default_auto_field = 'django.db.models.BigAutoField'
- name = 'requests_app'
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "requests_app"
def ready(self):
# Подключаем модуль уведомлений (signals регистрируются автоматически)
diff --git a/backend/requests_app/constants.py b/backend/requests_app/constants.py
index e11d0077..59fa60cf 100644
--- a/backend/requests_app/constants.py
+++ b/backend/requests_app/constants.py
@@ -1,11 +1,9 @@
# backend\requests_app\constants.py
from django.conf import settings
-
SAFE_EXTENSIONS = ["pdf", "jpg", "jpeg", "png"]
_ALLOWED_EXTS_DOTTED = {
- e if str(e).startswith(".") else f".{e}"
- for e in SAFE_EXTENSIONS
+ e if str(e).startswith(".") else f".{e}" for e in SAFE_EXTENSIONS
}
SAFE_MIME_TYPES = {"application/pdf", "image/png", "image/jpeg"}
PAGINATE_MY = getattr(settings, "REQUESTS_PAGINATE_BY_MY", 15)
diff --git a/backend/requests_app/management/commands/migrate_requests_to_actions.py b/backend/requests_app/management/commands/migrate_requests_to_actions.py
index 97592734..ba25e94c 100644
--- a/backend/requests_app/management/commands/migrate_requests_to_actions.py
+++ b/backend/requests_app/management/commands/migrate_requests_to_actions.py
@@ -6,24 +6,22 @@ Management команда для миграции существующих за
"""
from datetime import datetime
+
from django.core.management.base import BaseCommand
from django.utils import timezone
-from requests_app.models import Request
-from requests_app.enums import RequestStatus
-from employees.signals import IMMEDIATE_ACTION_MAPPING, SCHEDULED_ACTION_MAPPING
from employees.services.request_actions import (
build_request_action_comment,
create_request_action,
get_existing_request_action,
)
+from employees.signals import IMMEDIATE_ACTION_MAPPING, SCHEDULED_ACTION_MAPPING
+from requests_app.enums import RequestStatus
+from requests_app.models import Request
class Command(BaseCommand):
- help = (
- "Создает EmployeeAction для всех одобренных заявок, "
- "у которых их еще нет"
- )
+ help = "Создает EmployeeAction для всех одобренных заявок, у которых их еще нет"
def add_arguments(self, parser):
parser.add_argument(
@@ -103,9 +101,7 @@ class Command(BaseCommand):
# Готовим extra данные
extra_data = {
- "approved_by": request.approver.id
- if request.approver
- else None,
+ "approved_by": request.approver.id if request.approver else None,
"migrated": True, # Помечаем что создано миграцией
"migration_date": timezone.now().isoformat(),
}
@@ -157,33 +153,23 @@ class Command(BaseCommand):
except Exception as e:
errors.append((request.id, str(e)))
self.stdout.write(
- self.style.ERROR(
- f" ✗ Failed: Request #{request.id} - {e}"
- )
+ self.style.ERROR(f" ✗ Failed: Request #{request.id} - {e}")
)
# Итоговая статистика
self.stdout.write("\n" + "=" * 70)
if dry_run:
- self.stdout.write(
- self.style.WARNING("DRY RUN - изменения не применены")
- )
+ self.stdout.write(self.style.WARNING("DRY RUN - изменения не применены"))
- self.stdout.write(
- self.style.SUCCESS(f"Обработано заявок: {total_requests}")
- )
+ self.stdout.write(self.style.SUCCESS(f"Обработано заявок: {total_requests}"))
if created_count:
verb = "Будет создано" if dry_run else "Создано"
- self.stdout.write(
- self.style.SUCCESS(f"{verb} событий: {created_count}")
- )
+ self.stdout.write(self.style.SUCCESS(f"{verb} событий: {created_count}"))
if skipped_count:
self.stdout.write(
- self.style.WARNING(
- f"Пропущено (уже существуют): {skipped_count}"
- )
+ self.style.WARNING(f"Пропущено (уже существуют): {skipped_count}")
)
if errors:
@@ -192,9 +178,7 @@ class Command(BaseCommand):
self.stdout.write(f" Request #{req_id}: {error}")
if not dry_run and created_count > 0:
- self.stdout.write(
- self.style.SUCCESS("\n✓ Миграция завершена успешно!")
- )
+ self.stdout.write(self.style.SUCCESS("\n✓ Миграция завершена успешно!"))
def _apply_effects(self, action):
"""Применяет эффекты кадрового события (как в EmployeeActionViewSet)."""
@@ -207,8 +191,6 @@ class Command(BaseCommand):
# Не критично если эффекты не применились
self.stdout.write(
self.style.WARNING(
- f" Warning: Could not apply effects for action #{
- action.id
- }: {e}"
+ f" Warning: Could not apply effects for action #{action.id}: {e}"
)
)
diff --git a/backend/requests_app/migrations/0001_initial.py b/backend/requests_app/migrations/0001_initial.py
index be2edd7d..bd31ef8d 100644
--- a/backend/requests_app/migrations/0001_initial.py
+++ b/backend/requests_app/migrations/0001_initial.py
@@ -6,7 +6,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
initial = True
dependencies = [
@@ -15,21 +14,69 @@ class Migration(migrations.Migration):
operations = [
migrations.CreateModel(
- name='Request',
+ name="Request",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('type', models.CharField(choices=[('vacation', 'Отпуск'), ('sick_leave', 'Больничный'), ('transfer', 'Перевод'), ('dismissal', 'Увольнение'), ('other', 'Другое')], max_length=32, verbose_name='Тип заявления')),
- ('date_from', models.DateField(blank=True, null=True, verbose_name='Дата начала')),
- ('date_to', models.DateField(blank=True, null=True, verbose_name='Дата окончания')),
- ('comment', models.TextField(blank=True, verbose_name='Комментарий')),
- ('status', models.CharField(choices=[('pending', 'На рассмотрении'), ('approved', 'Одобрено'), ('rejected', 'Отклонено')], default='pending', max_length=16, verbose_name='Статус')),
- ('created_at', models.DateTimeField(auto_now_add=True)),
- ('employee', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='requests', to=settings.AUTH_USER_MODEL)),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "type",
+ models.CharField(
+ choices=[
+ ("vacation", "Отпуск"),
+ ("sick_leave", "Больничный"),
+ ("transfer", "Перевод"),
+ ("dismissal", "Увольнение"),
+ ("other", "Другое"),
+ ],
+ max_length=32,
+ verbose_name="Тип заявления",
+ ),
+ ),
+ (
+ "date_from",
+ models.DateField(blank=True, null=True, verbose_name="Дата начала"),
+ ),
+ (
+ "date_to",
+ models.DateField(
+ blank=True, null=True, verbose_name="Дата окончания"
+ ),
+ ),
+ ("comment", models.TextField(blank=True, verbose_name="Комментарий")),
+ (
+ "status",
+ models.CharField(
+ choices=[
+ ("pending", "На рассмотрении"),
+ ("approved", "Одобрено"),
+ ("rejected", "Отклонено"),
+ ],
+ default="pending",
+ max_length=16,
+ verbose_name="Статус",
+ ),
+ ),
+ ("created_at", models.DateTimeField(auto_now_add=True)),
+ (
+ "employee",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="requests",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
],
options={
- 'verbose_name': 'Заявление',
- 'verbose_name_plural': 'Заявления',
- 'ordering': ['-created_at'],
+ "verbose_name": "Заявление",
+ "verbose_name_plural": "Заявления",
+ "ordering": ["-created_at"],
},
),
]
diff --git a/backend/requests_app/migrations/0002_requestcomment_request_approver_request_attachment_and_more.py b/backend/requests_app/migrations/0002_requestcomment_request_approver_request_attachment_and_more.py
index 6b4dd82b..22cfb276 100644
--- a/backend/requests_app/migrations/0002_requestcomment_request_approver_request_attachment_and_more.py
+++ b/backend/requests_app/migrations/0002_requestcomment_request_approver_request_attachment_and_more.py
@@ -6,100 +6,171 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0009_alter_employeedepartment_date_from_and_more'),
- ('requests_app', '0001_initial'),
+ ("employees", "0009_alter_employeedepartment_date_from_and_more"),
+ ("requests_app", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
- name='RequestComment',
+ name="RequestComment",
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='Создано')),
+ (
+ "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="Создано"),
+ ),
],
options={
- 'verbose_name': 'Комментарий к заявке',
- 'verbose_name_plural': 'Комментарии к заявкам',
- 'ordering': ['created_at'],
+ "verbose_name": "Комментарий к заявке",
+ "verbose_name_plural": "Комментарии к заявкам",
+ "ordering": ["created_at"],
},
),
migrations.AddField(
- model_name='request',
- name='approver',
- field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='approved_requests', to=settings.AUTH_USER_MODEL, verbose_name='Согласующий'),
+ model_name="request",
+ name="approver",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="approved_requests",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Согласующий",
+ ),
),
migrations.AddField(
- model_name='request',
- name='attachment',
- field=models.FileField(blank=True, null=True, upload_to='requests/attachments/%Y/%m/', verbose_name='Вложение'),
+ model_name="request",
+ name="attachment",
+ field=models.FileField(
+ blank=True,
+ null=True,
+ upload_to="requests/attachments/%Y/%m/",
+ verbose_name="Вложение",
+ ),
),
migrations.AddField(
- model_name='request',
- name='decided_at',
- field=models.DateTimeField(blank=True, null=True, verbose_name='Решение принято'),
+ model_name="request",
+ name="decided_at",
+ field=models.DateTimeField(
+ blank=True, null=True, verbose_name="Решение принято"
+ ),
),
migrations.AddField(
- model_name='request',
- name='department',
- field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='employees.department', verbose_name='Отдел'),
+ model_name="request",
+ name="department",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ to="employees.department",
+ verbose_name="Отдел",
+ ),
),
migrations.AddField(
- model_name='request',
- name='title',
- field=models.CharField(blank=True, max_length=200, verbose_name='Тема'),
+ model_name="request",
+ name="title",
+ field=models.CharField(blank=True, max_length=200, verbose_name="Тема"),
),
migrations.AddField(
- model_name='request',
- name='updated_at',
- field=models.DateTimeField(auto_now=True, verbose_name='Обновлено'),
+ model_name="request",
+ name="updated_at",
+ field=models.DateTimeField(auto_now=True, verbose_name="Обновлено"),
),
migrations.AlterField(
- model_name='request',
- name='created_at',
- field=models.DateTimeField(auto_now_add=True, verbose_name='Создано'),
+ model_name="request",
+ name="created_at",
+ field=models.DateTimeField(auto_now_add=True, verbose_name="Создано"),
),
migrations.AlterField(
- model_name='request',
- name='employee',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='requests', to=settings.AUTH_USER_MODEL, verbose_name='Автор'),
+ model_name="request",
+ name="employee",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="requests",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Автор",
+ ),
),
migrations.AlterField(
- model_name='request',
- name='status',
- field=models.CharField(choices=[('draft', 'Черновик'), ('pending', 'На рассмотрении'), ('approved', 'Одобрено'), ('rejected', 'Отклонено'), ('cancelled', 'Отменено')], default='pending', max_length=16, verbose_name='Статус'),
+ model_name="request",
+ name="status",
+ field=models.CharField(
+ choices=[
+ ("draft", "Черновик"),
+ ("pending", "На рассмотрении"),
+ ("approved", "Одобрено"),
+ ("rejected", "Отклонено"),
+ ("cancelled", "Отменено"),
+ ],
+ default="pending",
+ max_length=16,
+ verbose_name="Статус",
+ ),
),
migrations.AddIndex(
- model_name='request',
- index=models.Index(fields=['status', 'created_at'], name='requests_ap_status_63c030_idx'),
+ model_name="request",
+ index=models.Index(
+ fields=["status", "created_at"], name="requests_ap_status_63c030_idx"
+ ),
),
migrations.AddIndex(
- model_name='request',
- index=models.Index(fields=['type', 'created_at'], name='requests_ap_type_6ecad2_idx'),
+ model_name="request",
+ index=models.Index(
+ fields=["type", "created_at"], name="requests_ap_type_6ecad2_idx"
+ ),
),
migrations.AddIndex(
- model_name='request',
- index=models.Index(fields=['employee', 'created_at'], name='requests_ap_employe_4f9b37_idx'),
+ model_name="request",
+ index=models.Index(
+ fields=["employee", "created_at"], name="requests_ap_employe_4f9b37_idx"
+ ),
),
migrations.AddConstraint(
- model_name='request',
- constraint=models.CheckConstraint(condition=models.Q(('date_to__isnull', True), ('date_from__isnull', True), ('date_from__lte', models.F('date_to')), _connector='OR'), name='request_date_range_valid'),
+ model_name="request",
+ constraint=models.CheckConstraint(
+ condition=models.Q(
+ ("date_to__isnull", True),
+ ("date_from__isnull", True),
+ ("date_from__lte", models.F("date_to")),
+ _connector="OR",
+ ),
+ name="request_date_range_valid",
+ ),
),
migrations.AddField(
- model_name='requestcomment',
- name='author',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Автор'),
+ model_name="requestcomment",
+ name="author",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Автор",
+ ),
),
migrations.AddField(
- model_name='requestcomment',
- name='request',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='requests_app.request', verbose_name='Заявка'),
+ model_name="requestcomment",
+ name="request",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="comments",
+ to="requests_app.request",
+ verbose_name="Заявка",
+ ),
),
migrations.AddIndex(
- model_name='requestcomment',
- index=models.Index(fields=['created_at'], name='requests_ap_created_43572e_idx'),
+ model_name="requestcomment",
+ index=models.Index(
+ fields=["created_at"], name="requests_ap_created_43572e_idx"
+ ),
),
]
diff --git a/backend/requests_app/migrations/0003_alter_request_attachment_and_more.py b/backend/requests_app/migrations/0003_alter_request_attachment_and_more.py
index fefabecf..2e521efc 100644
--- a/backend/requests_app/migrations/0003_alter_request_attachment_and_more.py
+++ b/backend/requests_app/migrations/0003_alter_request_attachment_and_more.py
@@ -1,31 +1,57 @@
# Generated by Django 5.2.4 on 2025-08-12 06:01
import django.core.validators
-import requests_app.models
from django.conf import settings
from django.db import migrations, models
+import requests_app.models
+
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0009_alter_employeedepartment_date_from_and_more'),
- ('requests_app', '0002_requestcomment_request_approver_request_attachment_and_more'),
+ ("employees", "0009_alter_employeedepartment_date_from_and_more"),
+ (
+ "requests_app",
+ "0002_requestcomment_request_approver_request_attachment_and_more",
+ ),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
- model_name='request',
- name='attachment',
- field=models.FileField(blank=True, null=True, upload_to='requests/attachments/%Y/%m/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['pdf', 'jpg', 'jpeg', 'png', 'doc', 'docx']), requests_app.models.attachment_file_size], verbose_name='Вложение'),
+ model_name="request",
+ name="attachment",
+ field=models.FileField(
+ blank=True,
+ null=True,
+ upload_to="requests/attachments/%Y/%m/",
+ validators=[
+ django.core.validators.FileExtensionValidator(
+ allowed_extensions=["pdf", "jpg", "jpeg", "png", "doc", "docx"]
+ ),
+ requests_app.models.attachment_file_size,
+ ],
+ verbose_name="Вложение",
+ ),
),
migrations.AddIndex(
- model_name='request',
- index=models.Index(fields=['decided_at'], name='requests_ap_decided_1a4c7f_idx'),
+ model_name="request",
+ index=models.Index(
+ fields=["decided_at"], name="requests_ap_decided_1a4c7f_idx"
+ ),
),
migrations.AddConstraint(
- model_name='request',
- constraint=models.CheckConstraint(condition=models.Q(models.Q(('approver__isnull', False), ('status__in', ['approved', 'rejected'])), ('status__in', ['draft', 'pending', 'cancelled']), _connector='OR'), name='request_approver_required_on_decision'),
+ model_name="request",
+ constraint=models.CheckConstraint(
+ condition=models.Q(
+ models.Q(
+ ("approver__isnull", False),
+ ("status__in", ["approved", "rejected"]),
+ ),
+ ("status__in", ["draft", "pending", "cancelled"]),
+ _connector="OR",
+ ),
+ name="request_approver_required_on_decision",
+ ),
),
]
diff --git a/backend/requests_app/migrations/0004_remove_request_request_approver_required_on_decision_and_more.py b/backend/requests_app/migrations/0004_remove_request_request_approver_required_on_decision_and_more.py
index 6e95f4ee..b75458a5 100644
--- a/backend/requests_app/migrations/0004_remove_request_request_approver_required_on_decision_and_more.py
+++ b/backend/requests_app/migrations/0004_remove_request_request_approver_required_on_decision_and_more.py
@@ -1,39 +1,68 @@
# Generated by Django 5.2.4 on 2025-08-12 06:34
import django.core.validators
-import requests_app.models
from django.conf import settings
from django.db import migrations, models
+import requests_app.models
+
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0009_alter_employeedepartment_date_from_and_more'),
- ('requests_app', '0003_alter_request_attachment_and_more'),
+ ("employees", "0009_alter_employeedepartment_date_from_and_more"),
+ ("requests_app", "0003_alter_request_attachment_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RemoveConstraint(
- model_name='request',
- name='request_approver_required_on_decision',
+ model_name="request",
+ name="request_approver_required_on_decision",
),
migrations.RemoveIndex(
- model_name='request',
- name='requests_ap_decided_1a4c7f_idx',
+ model_name="request",
+ name="requests_ap_decided_1a4c7f_idx",
),
migrations.AlterField(
- model_name='request',
- name='attachment',
- field=models.FileField(blank=True, null=True, upload_to='requests/attachments/%Y/%m/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['pdf', 'jpg', 'jpeg', 'png']), requests_app.models.attachment_file_size, requests_app.models.attachment_mime_validator], verbose_name='Вложение'),
+ model_name="request",
+ name="attachment",
+ field=models.FileField(
+ blank=True,
+ null=True,
+ upload_to="requests/attachments/%Y/%m/",
+ validators=[
+ django.core.validators.FileExtensionValidator(
+ allowed_extensions=["pdf", "jpg", "jpeg", "png"]
+ ),
+ requests_app.models.attachment_file_size,
+ requests_app.models.attachment_mime_validator,
+ ],
+ verbose_name="Вложение",
+ ),
),
migrations.AddIndex(
- model_name='request',
- index=models.Index(condition=models.Q(('decided_at__isnull', False)), fields=['decided_at'], name='req_decided_idx'),
+ model_name="request",
+ index=models.Index(
+ condition=models.Q(("decided_at__isnull", False)),
+ fields=["decided_at"],
+ name="req_decided_idx",
+ ),
),
migrations.AddConstraint(
- model_name='request',
- constraint=models.CheckConstraint(condition=models.Q(models.Q(('approver__isnull', False), ('status__in', ['approved', 'rejected'])), models.Q(('approver__isnull', True), ('status__in', ['draft', 'pending', 'cancelled'])), _connector='OR'), name='request_approver_required_on_decision'),
+ model_name="request",
+ constraint=models.CheckConstraint(
+ condition=models.Q(
+ models.Q(
+ ("approver__isnull", False),
+ ("status__in", ["approved", "rejected"]),
+ ),
+ models.Q(
+ ("approver__isnull", True),
+ ("status__in", ["draft", "pending", "cancelled"]),
+ ),
+ _connector="OR",
+ ),
+ name="request_approver_required_on_decision",
+ ),
),
]
diff --git a/backend/requests_app/migrations/0005_alter_request_options.py b/backend/requests_app/migrations/0005_alter_request_options.py
index 70bb4b70..d55f680e 100644
--- a/backend/requests_app/migrations/0005_alter_request_options.py
+++ b/backend/requests_app/migrations/0005_alter_request_options.py
@@ -4,14 +4,24 @@ from django.db import migrations
class Migration(migrations.Migration):
-
dependencies = [
- ('requests_app', '0004_remove_request_request_approver_required_on_decision_and_more'),
+ (
+ "requests_app",
+ "0004_remove_request_request_approver_required_on_decision_and_more",
+ ),
]
operations = [
migrations.AlterModelOptions(
- name='request',
- options={'ordering': ['-created_at'], 'permissions': [('can_view_all_requests', 'Can view all requests'), ('can_process_requests', 'Can approve/reject requests')], 'verbose_name': 'Заявление', 'verbose_name_plural': 'Заявления'},
+ name="request",
+ options={
+ "ordering": ["-created_at"],
+ "permissions": [
+ ("can_view_all_requests", "Can view all requests"),
+ ("can_process_requests", "Can approve/reject requests"),
+ ],
+ "verbose_name": "Заявление",
+ "verbose_name_plural": "Заявления",
+ },
),
]
diff --git a/backend/requests_app/migrations/0006_alter_request_options.py b/backend/requests_app/migrations/0006_alter_request_options.py
index e9c88050..27863618 100644
--- a/backend/requests_app/migrations/0006_alter_request_options.py
+++ b/backend/requests_app/migrations/0006_alter_request_options.py
@@ -4,14 +4,21 @@ from django.db import migrations
class Migration(migrations.Migration):
-
dependencies = [
- ('requests_app', '0005_alter_request_options'),
+ ("requests_app", "0005_alter_request_options"),
]
operations = [
migrations.AlterModelOptions(
- name='request',
- options={'ordering': ['-created_at'], 'permissions': [('can_view_all_requests', 'Можно просмотреть все заявления'), ('can_process_requests', 'Может одобрять/отклонять заявления')], 'verbose_name': 'Заявление', 'verbose_name_plural': 'Заявления'},
+ name="request",
+ options={
+ "ordering": ["-created_at"],
+ "permissions": [
+ ("can_view_all_requests", "Можно просмотреть все заявления"),
+ ("can_process_requests", "Может одобрять/отклонять заявления"),
+ ],
+ "verbose_name": "Заявление",
+ "verbose_name_plural": "Заявления",
+ },
),
]
diff --git a/backend/requests_app/migrations/0007_add_recipients_and_departments.py b/backend/requests_app/migrations/0007_add_recipients_and_departments.py
index 2c29cd31..263087e4 100644
--- a/backend/requests_app/migrations/0007_add_recipients_and_departments.py
+++ b/backend/requests_app/migrations/0007_add_recipients_and_departments.py
@@ -6,37 +6,65 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0034_alter_employee_telegram'),
- ('requests_app', '0006_alter_request_options'),
+ ("employees", "0034_alter_employee_telegram"),
+ ("requests_app", "0006_alter_request_options"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
- model_name='request',
- name='cc_users',
- field=models.ManyToManyField(blank=True, help_text='Сотрудники в копии (уведомления без обязанности рассмотрения)', related_name='requests_cc', to=settings.AUTH_USER_MODEL, verbose_name='Копия (CC)'),
+ model_name="request",
+ name="cc_users",
+ field=models.ManyToManyField(
+ blank=True,
+ help_text="Сотрудники в копии (уведомления без обязанности рассмотрения)",
+ related_name="requests_cc",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Копия (CC)",
+ ),
),
migrations.AddField(
- model_name='request',
- name='departments',
- field=models.ManyToManyField(blank=True, help_text='Заявка будет видна всем уполномоченным сотрудникам этих отделов', related_name='received_requests', to='employees.department', verbose_name='Отделы-получатели'),
+ model_name="request",
+ name="departments",
+ field=models.ManyToManyField(
+ blank=True,
+ help_text="Заявка будет видна всем уполномоченным сотрудникам этих отделов",
+ related_name="received_requests",
+ to="employees.department",
+ verbose_name="Отделы-получатели",
+ ),
),
migrations.AddField(
- model_name='request',
- name='recipients',
- field=models.ManyToManyField(blank=True, help_text='Сотрудники, которым адресована заявка', related_name='received_requests', to=settings.AUTH_USER_MODEL, verbose_name='Получатели'),
+ model_name="request",
+ name="recipients",
+ field=models.ManyToManyField(
+ blank=True,
+ help_text="Сотрудники, которым адресована заявка",
+ related_name="received_requests",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Получатели",
+ ),
),
migrations.AddField(
- model_name='request',
- name='sent_to_all_department',
- field=models.BooleanField(default=False, help_text='Если включено, заявка видна всем в выбранных отделах', verbose_name='Всем сотрудникам отделов'),
+ model_name="request",
+ name="sent_to_all_department",
+ field=models.BooleanField(
+ default=False,
+ help_text="Если включено, заявка видна всем в выбранных отделах",
+ verbose_name="Всем сотрудникам отделов",
+ ),
),
migrations.AlterField(
- model_name='request',
- name='department',
- field=models.ForeignKey(blank=True, help_text='Устаревшее поле, используйте departments', null=True, on_delete=django.db.models.deletion.SET_NULL, to='employees.department', verbose_name='Основной отдел (устар.)'),
+ model_name="request",
+ name="department",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="Устаревшее поле, используйте departments",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ to="employees.department",
+ verbose_name="Основной отдел (устар.)",
+ ),
),
]
diff --git a/backend/requests_app/migrations/0008_migrate_department_to_departments.py b/backend/requests_app/migrations/0008_migrate_department_to_departments.py
index 71ca3f93..a7c09116 100644
--- a/backend/requests_app/migrations/0008_migrate_department_to_departments.py
+++ b/backend/requests_app/migrations/0008_migrate_department_to_departments.py
@@ -7,7 +7,7 @@ def migrate_department_to_departments(apps, schema_editor):
"""
Переносим данные из единичного department в ManyToMany departments
"""
- Request = apps.get_model('requests_app', 'Request')
+ Request = apps.get_model("requests_app", "Request")
migrated_count = 0
for request in Request.objects.filter(department__isnull=False):
@@ -22,23 +22,21 @@ def reverse_migration(apps, schema_editor):
"""
Обратная миграция: берем первый отдел из departments и ставим в department
"""
- Request = apps.get_model('requests_app', 'Request')
+ Request = apps.get_model("requests_app", "Request")
- for request in Request.objects.prefetch_related('departments').all():
+ for request in Request.objects.prefetch_related("departments").all():
if request.departments.exists() and not request.department:
request.department = request.departments.first()
- request.save(update_fields=['department'])
+ request.save(update_fields=["department"])
class Migration(migrations.Migration):
-
dependencies = [
- ('requests_app', '0007_add_recipients_and_departments'),
+ ("requests_app", "0007_add_recipients_and_departments"),
]
operations = [
migrations.RunPython(
- migrate_department_to_departments,
- reverse_code=reverse_migration
+ migrate_department_to_departments, reverse_code=reverse_migration
),
]
diff --git a/backend/requests_app/migrations/0009_alter_request_type.py b/backend/requests_app/migrations/0009_alter_request_type.py
index 1361513b..78d30453 100644
--- a/backend/requests_app/migrations/0009_alter_request_type.py
+++ b/backend/requests_app/migrations/0009_alter_request_type.py
@@ -4,15 +4,25 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('requests_app', '0008_migrate_department_to_departments'),
+ ("requests_app", "0008_migrate_department_to_departments"),
]
operations = [
migrations.AlterField(
- model_name='request',
- name='type',
- field=models.CharField(choices=[('vacation', 'Отпуск'), ('sick_leave', 'Больничный'), ('day_off', 'Отгул'), ('transfer', 'Перевод'), ('dismissal', 'Увольнение'), ('other', 'Другое')], max_length=32, verbose_name='Тип заявления'),
+ model_name="request",
+ name="type",
+ field=models.CharField(
+ choices=[
+ ("vacation", "Отпуск"),
+ ("sick_leave", "Больничный"),
+ ("day_off", "Отгул"),
+ ("transfer", "Перевод"),
+ ("dismissal", "Увольнение"),
+ ("other", "Другое"),
+ ],
+ max_length=32,
+ verbose_name="Тип заявления",
+ ),
),
]
diff --git a/backend/requests_app/migrations/0010_migrate_comments_to_communications.py b/backend/requests_app/migrations/0010_migrate_comments_to_communications.py
index 0f298c61..527d2e5f 100644
--- a/backend/requests_app/migrations/0010_migrate_comments_to_communications.py
+++ b/backend/requests_app/migrations/0010_migrate_comments_to_communications.py
@@ -7,51 +7,58 @@ from django.utils import timezone
def migrate_comments_forward(apps, schema_editor):
"""Миграция RequestComment → Communications.Message (ORM)"""
- Chat = apps.get_model('communications', 'Chat')
- Message = apps.get_model('communications', 'Message')
- RequestComment = apps.get_model('requests_app', 'RequestComment')
- ContentType = apps.get_model('contenttypes', 'ContentType')
+ Chat = apps.get_model("communications", "Chat")
+ Message = apps.get_model("communications", "Message")
+ RequestComment = apps.get_model("requests_app", "RequestComment")
+ ContentType = apps.get_model("contenttypes", "ContentType")
try:
- request_ct = ContentType.objects.get(app_label='requests_app', model='request')
+ request_ct = ContentType.objects.get(app_label="requests_app", model="request")
except ContentType.DoesNotExist:
return
# 1. Создаем чаты для заявок с комментариями
- request_ids_with_comments = RequestComment.objects.values_list('request_id', flat=True).distinct()
+ request_ids_with_comments = RequestComment.objects.values_list(
+ "request_id", flat=True
+ ).distinct()
for request_id in request_ids_with_comments:
if Chat.objects.filter(
- type='comments',
+ type="comments",
context_content_type=request_ct,
- context_object_id=request_id
+ context_object_id=request_id,
).exists():
continue
Chat.objects.create(
- type='comments',
+ type="comments",
name=f"Комментарии к заявке #{request_id}",
- 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=request_ct,
context_object_id=request_id,
created_at=timezone.now(),
is_main=False,
is_blocked=False,
include_all_users=False,
- extra_data={}
+ extra_data={},
)
# 2. Переносим комментарии
- for comment in RequestComment.objects.select_related('author').all():
+ for comment in RequestComment.objects.select_related("author").all():
try:
chat = Chat.objects.get(
- type='comments',
+ type="comments",
context_content_type=request_ct,
- context_object_id=comment.request_id
+ context_object_id=comment.request_id,
)
except Chat.DoesNotExist:
continue
@@ -60,14 +67,14 @@ def migrate_comments_forward(apps, schema_editor):
chat=chat,
author_id=comment.author_id,
created_at=comment.created_at,
- content=comment.text
+ content=comment.text,
).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,
@@ -76,40 +83,36 @@ def migrate_comments_forward(apps, schema_editor):
is_pinned=False,
is_forwarded=False,
is_cross_chat=False,
- system_type='',
+ system_type="",
system_metadata={
- 'legacy_comment_id': comment.id,
- 'legacy_model': 'requests_app.RequestComment'
- }
+ "legacy_comment_id": comment.id,
+ "legacy_model": "requests_app.RequestComment",
+ },
)
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:
- request_ct = ContentType.objects.get(app_label='requests_app', model='request')
- Chat.objects.filter(
- type='comments',
- context_content_type=request_ct
- ).delete()
+ request_ct = ContentType.objects.get(app_label="requests_app", model="request")
+ Chat.objects.filter(type="comments", context_content_type=request_ct).delete()
except ContentType.DoesNotExist:
pass
class Migration(migrations.Migration):
-
dependencies = [
- ('requests_app', '0009_alter_request_type'),
- ('communications', '0037_remove_department_type'), # Зафиксировали конкретную версию вместо __latest__
- ('contenttypes', '__latest__'),
+ ("requests_app", "0009_alter_request_type"),
+ (
+ "communications",
+ "0037_remove_department_type",
+ ), # Зафиксировали конкретную версию вместо __latest__
+ ("contenttypes", "__latest__"),
]
operations = [
- migrations.RunPython(
- migrate_comments_forward,
- migrate_comments_backward
- ),
+ migrations.RunPython(migrate_comments_forward, migrate_comments_backward),
]
diff --git a/backend/requests_app/migrations/0011_remove_requestcomment_model.py b/backend/requests_app/migrations/0011_remove_requestcomment_model.py
index 1a093667..b9b44231 100644
--- a/backend/requests_app/migrations/0011_remove_requestcomment_model.py
+++ b/backend/requests_app/migrations/0011_remove_requestcomment_model.py
@@ -4,13 +4,12 @@ from django.db import migrations
class Migration(migrations.Migration):
-
dependencies = [
- ('requests_app', '0010_migrate_comments_to_communications'),
+ ("requests_app", "0010_migrate_comments_to_communications"),
]
operations = [
migrations.DeleteModel(
- name='RequestComment',
+ name="RequestComment",
),
]
diff --git a/backend/requests_app/migrations/0012_add_maternity_request_type.py b/backend/requests_app/migrations/0012_add_maternity_request_type.py
index 6ca1eccf..71b572b4 100644
--- a/backend/requests_app/migrations/0012_add_maternity_request_type.py
+++ b/backend/requests_app/migrations/0012_add_maternity_request_type.py
@@ -2,7 +2,6 @@
from django.db import migrations, models
-
REQUEST_TYPE_CHOICES = [
("vacation", "Отпуск"),
("sick_leave", "Больничный"),
diff --git a/backend/requests_app/migrations/0013_alter_request_attachment.py b/backend/requests_app/migrations/0013_alter_request_attachment.py
index fb90105c..6bf0f210 100644
--- a/backend/requests_app/migrations/0013_alter_request_attachment.py
+++ b/backend/requests_app/migrations/0013_alter_request_attachment.py
@@ -1,20 +1,31 @@
# Generated by Django 5.2.4 on 2026-07-16 15:09
import django.core.validators
-import requests_app.models
from django.db import migrations, models
+import requests_app.models
+
class Migration(migrations.Migration):
-
dependencies = [
- ('requests_app', '0012_add_maternity_request_type'),
+ ("requests_app", "0012_add_maternity_request_type"),
]
operations = [
migrations.AlterField(
- model_name='request',
- name='attachment',
- field=models.FileField(blank=True, null=True, upload_to='requests/attachments/%Y/%m/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['pdf', 'jpg', 'jpeg', 'png']), requests_app.models.attachment_mime_validator], verbose_name='Вложение'),
+ model_name="request",
+ name="attachment",
+ field=models.FileField(
+ blank=True,
+ null=True,
+ upload_to="requests/attachments/%Y/%m/",
+ validators=[
+ django.core.validators.FileExtensionValidator(
+ allowed_extensions=["pdf", "jpg", "jpeg", "png"]
+ ),
+ requests_app.models.attachment_mime_validator,
+ ],
+ verbose_name="Вложение",
+ ),
),
]
diff --git a/backend/requests_app/migrations/0014_add_short_day_off.py b/backend/requests_app/migrations/0014_add_short_day_off.py
index 264e75b2..81b07df9 100644
--- a/backend/requests_app/migrations/0014_add_short_day_off.py
+++ b/backend/requests_app/migrations/0014_add_short_day_off.py
@@ -5,31 +5,59 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('employees', '0054_employee_id_guest_range_guard'),
- ('requests_app', '0013_alter_request_attachment'),
+ ("employees", "0054_employee_id_guest_range_guard"),
+ ("requests_app", "0013_alter_request_attachment"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
- model_name='request',
- name='time_from',
- field=models.TimeField(blank=True, null=True, verbose_name='Время начала'),
+ model_name="request",
+ name="time_from",
+ field=models.TimeField(blank=True, null=True, verbose_name="Время начала"),
),
migrations.AddField(
- model_name='request',
- name='time_to',
- field=models.TimeField(blank=True, null=True, verbose_name='Время окончания'),
+ model_name="request",
+ name="time_to",
+ field=models.TimeField(
+ blank=True, null=True, verbose_name="Время окончания"
+ ),
),
migrations.AlterField(
- model_name='request',
- name='type',
- field=models.CharField(choices=[('short_day_off', 'Короткий отгул'), ('vacation', 'Отпуск'), ('sick_leave', 'Больничный'), ('day_off', 'Отгул'), ('maternity', 'Декрет'), ('transfer', 'Перевод'), ('dismissal', 'Увольнение'), ('other', 'Другое')], max_length=32, verbose_name='Тип заявления'),
+ model_name="request",
+ name="type",
+ field=models.CharField(
+ choices=[
+ ("short_day_off", "Короткий отгул"),
+ ("vacation", "Отпуск"),
+ ("sick_leave", "Больничный"),
+ ("day_off", "Отгул"),
+ ("maternity", "Декрет"),
+ ("transfer", "Перевод"),
+ ("dismissal", "Увольнение"),
+ ("other", "Другое"),
+ ],
+ max_length=32,
+ verbose_name="Тип заявления",
+ ),
),
migrations.AddConstraint(
- model_name='request',
- constraint=models.CheckConstraint(condition=models.Q(('status', 'draft'), models.Q(('type', 'short_day_off'), _negated=True), models.Q(('date_from__isnull', False), ('date_to', models.F('date_from')), ('time_from__isnull', False), ('time_to__gt', models.F('time_from')), ('time_to__isnull', False)), _connector='OR'), name='request_short_day_off_interval_valid'),
+ model_name="request",
+ constraint=models.CheckConstraint(
+ condition=models.Q(
+ ("status", "draft"),
+ models.Q(("type", "short_day_off"), _negated=True),
+ models.Q(
+ ("date_from__isnull", False),
+ ("date_to", models.F("date_from")),
+ ("time_from__isnull", False),
+ ("time_to__gt", models.F("time_from")),
+ ("time_to__isnull", False),
+ ),
+ _connector="OR",
+ ),
+ name="request_short_day_off_interval_valid",
+ ),
),
]
diff --git a/backend/requests_app/migrations/0015_add_request_recipient_settings.py b/backend/requests_app/migrations/0015_add_request_recipient_settings.py
index ab695ba3..d94603ff 100644
--- a/backend/requests_app/migrations/0015_add_request_recipient_settings.py
+++ b/backend/requests_app/migrations/0015_add_request_recipient_settings.py
@@ -5,24 +5,50 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('requests_app', '0014_add_short_day_off'),
+ ("requests_app", "0014_add_short_day_off"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
- name='RequestSettings',
+ name="RequestSettings",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('name', models.CharField(default='Основные настройки', max_length=100, verbose_name='Название')),
- ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')),
- ('allowed_recipients', models.ManyToManyField(blank=True, help_text='Если список пуст, заявление можно отправить любому активному сотруднику. Заполните список, чтобы ограничить поле «Кому».', related_name='request_recipient_settings', to=settings.AUTH_USER_MODEL, verbose_name='Доступные получатели')),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "name",
+ models.CharField(
+ default="Основные настройки",
+ max_length=100,
+ verbose_name="Название",
+ ),
+ ),
+ (
+ "updated_at",
+ models.DateTimeField(auto_now=True, verbose_name="Обновлено"),
+ ),
+ (
+ "allowed_recipients",
+ models.ManyToManyField(
+ blank=True,
+ help_text="Если список пуст, заявление можно отправить любому активному сотруднику. Заполните список, чтобы ограничить поле «Кому».",
+ related_name="request_recipient_settings",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Доступные получатели",
+ ),
+ ),
],
options={
- 'verbose_name': 'Настройки заявлений',
- 'verbose_name_plural': 'Настройки заявлений',
+ "verbose_name": "Настройки заявлений",
+ "verbose_name_plural": "Настройки заявлений",
},
),
]
diff --git a/backend/requests_app/migrations/0016_requestvacationterms.py b/backend/requests_app/migrations/0016_requestvacationterms.py
index 448f78e4..3707d0a7 100644
--- a/backend/requests_app/migrations/0016_requestvacationterms.py
+++ b/backend/requests_app/migrations/0016_requestvacationterms.py
@@ -6,27 +6,76 @@ from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
- ('requests_app', '0015_add_request_recipient_settings'),
+ ("requests_app", "0015_add_request_recipient_settings"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
- name='RequestVacationTerms',
+ name="RequestVacationTerms",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('paid_days', models.PositiveSmallIntegerField(default=0, verbose_name='Оплачиваемые дни')),
- ('finance_status', models.CharField(choices=[('not_decided', 'Не решено'), ('skipped', 'Не передавать'), ('pending', 'Ожидает расчёта'), ('posted', 'Передано в начисления')], default='not_decided', max_length=16, verbose_name='Передача в финансы')),
- ('recorded_at', models.DateTimeField(auto_now_add=True, verbose_name='Зафиксировано')),
- ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')),
- ('recorded_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='recorded_vacation_terms', to=settings.AUTH_USER_MODEL, verbose_name='Условия зафиксировал')),
- ('request', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='vacation_terms', to='requests_app.request', verbose_name='Заявление')),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "paid_days",
+ models.PositiveSmallIntegerField(
+ default=0, verbose_name="Оплачиваемые дни"
+ ),
+ ),
+ (
+ "finance_status",
+ models.CharField(
+ choices=[
+ ("not_decided", "Не решено"),
+ ("skipped", "Не передавать"),
+ ("pending", "Ожидает расчёта"),
+ ("posted", "Передано в начисления"),
+ ],
+ default="not_decided",
+ max_length=16,
+ verbose_name="Передача в финансы",
+ ),
+ ),
+ (
+ "recorded_at",
+ models.DateTimeField(
+ auto_now_add=True, verbose_name="Зафиксировано"
+ ),
+ ),
+ (
+ "updated_at",
+ models.DateTimeField(auto_now=True, verbose_name="Обновлено"),
+ ),
+ (
+ "recorded_by",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="recorded_vacation_terms",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Условия зафиксировал",
+ ),
+ ),
+ (
+ "request",
+ models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="vacation_terms",
+ to="requests_app.request",
+ verbose_name="Заявление",
+ ),
+ ),
],
options={
- 'verbose_name': 'Условия отпуска',
- 'verbose_name_plural': 'Условия отпусков',
+ "verbose_name": "Условия отпуска",
+ "verbose_name_plural": "Условия отпусков",
},
),
]
diff --git a/backend/requests_app/notifications/__init__.py b/backend/requests_app/notifications/__init__.py
index 19bf2acd..7095678b 100644
--- a/backend/requests_app/notifications/__init__.py
+++ b/backend/requests_app/notifications/__init__.py
@@ -21,28 +21,24 @@ Usage:
"""
# Импорты для удобства и обратной совместимости
-from .config import NotificationVerbs, MessageTemplates, ActionURLs
-from .handlers import (
- notify_new_request,
- notify_status_change,
- notify_comment,
-)
-
# Сигналы импортируются для регистрации через AppConfig.ready()
from . import signals # noqa: F401
-
+from .config import ActionURLs, MessageTemplates, NotificationVerbs
+from .handlers import (
+ notify_comment,
+ notify_new_request,
+ notify_status_change,
+)
__all__ = [
# Constants
- 'NotificationVerbs',
- 'MessageTemplates',
- 'ActionURLs',
-
+ "NotificationVerbs",
+ "MessageTemplates",
+ "ActionURLs",
# Handlers
- 'notify_new_request',
- 'notify_status_change',
- 'notify_comment',
-
+ "notify_new_request",
+ "notify_status_change",
+ "notify_comment",
# Signals module (для импорта в AppConfig)
- 'signals',
+ "signals",
]
diff --git a/backend/requests_app/notifications/config.py b/backend/requests_app/notifications/config.py
index f289a72a..dd03e73b 100644
--- a/backend/requests_app/notifications/config.py
+++ b/backend/requests_app/notifications/config.py
@@ -7,23 +7,25 @@
# ===== Типы уведомлений (Verbs) =====
+
class NotificationVerbs:
"""Типы уведомлений для заявлений."""
# Новое заявление (универсальный для всех ролей получателей)
- REQUEST_NEW = 'request_new'
+ REQUEST_NEW = "request_new"
# Комментарий к заявлению
- REQUEST_COMMENT = 'request_comment'
+ REQUEST_COMMENT = "request_comment"
# Изменение статуса
- REQUEST_APPROVED = 'request_approved'
- REQUEST_REJECTED = 'request_rejected'
- REQUEST_STATUS_CHANGED = 'request_status_changed'
+ REQUEST_APPROVED = "request_approved"
+ REQUEST_REJECTED = "request_rejected"
+ REQUEST_STATUS_CHANGED = "request_status_changed"
# ===== Шаблоны сообщений =====
+
class MessageTemplates:
"""Шаблоны сообщений для уведомлений."""
@@ -39,7 +41,7 @@ class MessageTemplates:
"""
title = "Новое заявление"
message = (
- f'{author_name} адресовал вам заявление. '
+ f"{author_name} адресовал вам заявление. "
f'Тип: "{request_type}". {comment_preview}'
)
return title, message
@@ -56,7 +58,7 @@ class MessageTemplates:
"""
title = "Новое заявление"
message = (
- f'{author_name} поставил вас в копию заявления. '
+ f"{author_name} поставил вас в копию заявления. "
f'Тип: "{request_type}". {comment_preview}'
)
return title, message
@@ -73,7 +75,7 @@ class MessageTemplates:
"""
title = "Новое заявление"
message = (
- f'{author_name} отправил заявление вам на согласование. '
+ f"{author_name} отправил заявление вам на согласование. "
f'Тип: "{request_type}". {comment_preview}'
)
return title, message
@@ -90,7 +92,7 @@ class MessageTemplates:
"""
title = "Новое заявление"
message = (
- f'{author_name} направил заявление в отдел. '
+ f"{author_name} направил заявление в отдел. "
f'Тип: "{request_type}". {comment_preview}'
)
return title, message
@@ -108,9 +110,9 @@ class MessageTemplates:
Returns:
tuple: (title, description)
"""
- title = f'💬 Новый комментарий к заявлению от {employee_name}'
+ title = f"💬 Новый комментарий к заявлению от {employee_name}"
description = (
- f'{author_name} прокомментировал заявление '
+ f"{author_name} прокомментировал заявление "
f'"{request_type}": {comment_text[:100]}'
)
return title, description
@@ -125,10 +127,10 @@ class MessageTemplates:
Returns:
tuple: (title, message)
"""
- title = f'✅ Заявление одобрено: {request_type}'
+ title = f"✅ Заявление одобрено: {request_type}"
message = (
f'Заявление от {employee_name} "{request_type}" '
- f'одобрено пользователем {approver_name}'
+ f"одобрено пользователем {approver_name}"
)
return title, message
@@ -142,10 +144,10 @@ class MessageTemplates:
Returns:
tuple: (title, message)
"""
- title = f'❌ Заявление отклонено: {request_type}'
+ title = f"❌ Заявление отклонено: {request_type}"
message = (
f'Заявление от {employee_name} "{request_type}" '
- f'отклонено пользователем {approver_name}'
+ f"отклонено пользователем {approver_name}"
)
return title, message
@@ -162,22 +164,23 @@ class MessageTemplates:
Returns:
tuple: (title, message)
"""
- title = f'🔄 Статус заявления изменен: {request_type}'
+ title = f"🔄 Статус заявления изменен: {request_type}"
message = (
f'Статус заявления от {employee_name} "{request_type}" '
- f'изменен: {old_status} → {new_status}'
+ f"изменен: {old_status} → {new_status}"
)
return title, message
# ===== URL для действий =====
+
class ActionURLs:
"""URL для переходов из уведомлений."""
- REQUESTS_LIST = '/requests'
+ REQUESTS_LIST = "/requests"
@staticmethod
def request_detail(request_id: int) -> str:
"""URL детальной страницы заявления."""
- return f'/requests?request={request_id}'
+ return f"/requests?request={request_id}"
diff --git a/backend/requests_app/notifications/handlers.py b/backend/requests_app/notifications/handlers.py
index c151f053..3545ee72 100644
--- a/backend/requests_app/notifications/handlers.py
+++ b/backend/requests_app/notifications/handlers.py
@@ -6,10 +6,12 @@
"""
import logging
+
from django.contrib.auth import get_user_model
+
from notifications.signals import notify
-from .config import NotificationVerbs, MessageTemplates, ActionURLs
+from .config import ActionURLs, MessageTemplates, NotificationVerbs
Employee = get_user_model()
logger = logging.getLogger(__name__)
diff --git a/backend/requests_app/notifications/signals.py b/backend/requests_app/notifications/signals.py
index cf6bca33..e45123b5 100644
--- a/backend/requests_app/notifications/signals.py
+++ b/backend/requests_app/notifications/signals.py
@@ -9,6 +9,7 @@ Django сигналы для автоматической генерации у
"""
import logging
+
from django.db.models.signals import m2m_changed, post_save, pre_save
from django.dispatch import receiver
diff --git a/backend/requests_app/rules.py b/backend/requests_app/rules.py
index 5abcb123..69de9dc4 100644
--- a/backend/requests_app/rules.py
+++ b/backend/requests_app/rules.py
@@ -9,7 +9,6 @@ import rules
from employees.positions import employee_position_search_text
-
# -----------------------------------------------------------------------------
# ПРЕДИКАТЫ (predicates)
# -----------------------------------------------------------------------------
@@ -158,9 +157,7 @@ rules.add_rule(
# Удаление заявки (только автор и менеджеры)
rules.add_rule(
"requests_app.delete_request",
- is_superuser
- | (is_request_author & rules.always_allow)
- | can_manage_requests,
+ is_superuser | (is_request_author & rules.always_allow) | can_manage_requests,
)
# Назначение исполнителя
@@ -170,9 +167,7 @@ rules.add_rule(
)
# Изменение статуса заявки
-rules.add_rule(
- "requests_app.change_status", is_superuser | can_change_request_status
-)
+rules.add_rule("requests_app.change_status", is_superuser | can_change_request_status)
# Согласование заявки
rules.add_rule(
@@ -202,9 +197,7 @@ rules.add_rule(
)
# Просмотр статистики по заявкам
-rules.add_rule(
- "requests_app.view_statistics", is_superuser | can_manage_requests
-)
+rules.add_rule("requests_app.view_statistics", is_superuser | can_manage_requests)
# Примеры использования перенесены в проектную документацию.
diff --git a/backend/requests_app/tasks.py b/backend/requests_app/tasks.py
index 964105ae..620a1a89 100644
--- a/backend/requests_app/tasks.py
+++ b/backend/requests_app/tasks.py
@@ -22,8 +22,6 @@ def create_scheduled_action(self, request_id):
Args:
request_id: ID заявки
"""
- from .models import Request
- from .enums import RequestStatus
from employees.constants import (
ACTION_ON_DAY_OFF,
ACTION_ON_LEAVE,
@@ -35,6 +33,9 @@ def create_scheduled_action(self, request_id):
create_request_action,
)
+ from .enums import RequestStatus
+ from .models import Request
+
try:
request = Request.objects.get(id=request_id)
diff --git a/backend/requests_app/templatetags/req_tags.py b/backend/requests_app/templatetags/req_tags.py
index dc8e132f..8aff8148 100644
--- a/backend/requests_app/templatetags/req_tags.py
+++ b/backend/requests_app/templatetags/req_tags.py
@@ -1,6 +1,7 @@
-from django import template
from datetime import datetime
+from django import template
+
register = template.Library()
@@ -28,21 +29,21 @@ def format_iso_date(value, format_string="d.m.Y"):
try:
# Пробуем распарсить как полную ISO дату
- if 'T' in str(value):
- dt = datetime.fromisoformat(str(value).replace('Z', '+00:00'))
+ if "T" in str(value):
+ dt = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
else:
# Только дата без времени
- dt = datetime.strptime(str(value)[:10], '%Y-%m-%d')
+ dt = datetime.strptime(str(value)[:10], "%Y-%m-%d")
# Форматируем по шаблону Django
format_map = {
- 'd.m.Y': dt.strftime('%d.%m.%Y'),
- 'H:i': dt.strftime('%H:%M'),
- 'd.m.Y H:i': dt.strftime('%d.%m.%Y %H:%M'),
- 'Y-m-d': dt.strftime('%Y-%m-%d'),
+ "d.m.Y": dt.strftime("%d.%m.%Y"),
+ "H:i": dt.strftime("%H:%M"),
+ "d.m.Y H:i": dt.strftime("%d.%m.%Y %H:%M"),
+ "Y-m-d": dt.strftime("%Y-%m-%d"),
}
- return format_map.get(format_string, dt.strftime('%d.%m.%Y'))
+ return format_map.get(format_string, dt.strftime("%d.%m.%Y"))
except (ValueError, AttributeError):
# Если не удалось распарсить, возвращаем первые 10 символов
return str(value)[:10] if value else ""
diff --git a/backend/requests_app/tests/conftest.py b/backend/requests_app/tests/conftest.py
index 4ecaa8fe..bc2fd3e5 100644
--- a/backend/requests_app/tests/conftest.py
+++ b/backend/requests_app/tests/conftest.py
@@ -1,6 +1,7 @@
"""
Conftest для тестов requests_app - импортирует общие фикстуры.
"""
+
import pytest
@@ -15,25 +16,26 @@ def user_factory():
first_name: str = "Test",
last_name: str = "User",
active: bool = True,
- **extra
+ **extra,
):
import uuid
+
if not email:
email = f"test+{uuid.uuid4().hex[:8]}@example.com"
# Генерируем уникальные phone_number и username
unique_id = uuid.uuid4().hex[:8]
- if 'phone_number' not in extra:
- extra['phone_number'] = f"+7900{unique_id}"
- if 'username' not in extra:
- extra['username'] = f"user_{unique_id}"
+ if "phone_number" not in extra:
+ extra["phone_number"] = f"+7900{unique_id}"
+ if "username" not in extra:
+ extra["username"] = f"user_{unique_id}"
user = Employee.objects.create(
email=email,
first_name=first_name,
last_name=last_name,
is_active=active,
- **extra
+ **extra,
)
return user
diff --git a/backend/requests_app/tests/test_notifications.py b/backend/requests_app/tests/test_notifications.py
index 8c60426b..f45e5c90 100644
--- a/backend/requests_app/tests/test_notifications.py
+++ b/backend/requests_app/tests/test_notifications.py
@@ -7,16 +7,17 @@ Unit тесты для уведомлений модуля Requests
python manage.py test requests_app.tests.test_notifications
"""
-import pytest
-from django.test import TestCase
-from django.contrib.auth import get_user_model
from unittest.mock import patch
+import pytest
+from django.contrib.auth import get_user_model
+from django.test import TestCase
+
from communications.comments_helpers import create_comment
-from requests_app.models import Request
-from requests_app.notifications.handlers import notify_new_request
from employees.models import Department
from notifications.models import Notification
+from requests_app.models import Request
+from requests_app.notifications.handlers import notify_new_request
Employee = get_user_model()
@@ -29,41 +30,41 @@ class TestRequestNotifications(TestCase):
"""Подготовка тестовых данных"""
# Создаем тестовых пользователей
self.author = Employee.objects.create_user(
- username='test_author',
- email='author@test.com',
- phone_number='+79990000001',
- first_name='Иван',
- last_name='Автор',
+ username="test_author",
+ email="author@test.com",
+ phone_number="+79990000001",
+ first_name="Иван",
+ last_name="Автор",
email_verified=True,
send_activation_email=False,
)
self.recipient = Employee.objects.create_user(
- username='test_recipient',
- email='recipient@test.com',
- phone_number='+79990000002',
- first_name='Петр',
- last_name='Получатель',
+ username="test_recipient",
+ email="recipient@test.com",
+ phone_number="+79990000002",
+ first_name="Петр",
+ last_name="Получатель",
email_verified=True,
send_activation_email=False,
)
self.cc_user = Employee.objects.create_user(
- username='test_cc',
- email='cc@test.com',
- phone_number='+79990000003',
- first_name='Сидор',
- last_name='Копия',
+ username="test_cc",
+ email="cc@test.com",
+ phone_number="+79990000003",
+ first_name="Сидор",
+ last_name="Копия",
email_verified=True,
send_activation_email=False,
)
self.approver = Employee.objects.create_user(
- username='test_approver',
- email='approver@test.com',
- phone_number='+79990000004',
- first_name='Алексей',
- last_name='Согласующий',
+ username="test_approver",
+ email="approver@test.com",
+ phone_number="+79990000004",
+ first_name="Алексей",
+ last_name="Согласующий",
email_verified=True,
send_activation_email=False,
)
@@ -79,8 +80,7 @@ class TestRequestNotifications(TestCase):
# Создаем отдел
self.department = Department.objects.create(
- name='Тестовый отдел',
- head=self.approver
+ name="Тестовый отдел", head=self.approver
)
def test_new_request_notification_to_recipient(self):
@@ -88,10 +88,10 @@ class TestRequestNotifications(TestCase):
# Создаем заявление
request = Request.objects.create(
employee=self.author,
- type='vacation',
- comment='Отпуск с 1 по 10 июля',
- status='pending',
- department=self.department
+ type="vacation",
+ comment="Отпуск с 1 по 10 июля",
+ status="pending",
+ department=self.department,
)
# Устанавливаем получателя (триггерит сигнал)
@@ -99,8 +99,7 @@ class TestRequestNotifications(TestCase):
# Проверяем что уведомление создано
notifications = Notification.objects.filter(
- recipient=self.recipient,
- verb='request_new'
+ recipient=self.recipient, verb="request_new"
)
self.assertEqual(notifications.count(), 1)
@@ -108,25 +107,25 @@ class TestRequestNotifications(TestCase):
# Проверяем содержимое
self.assertEqual(
- notif.data['title'],
- 'Новое заявление',
+ notif.data["title"],
+ "Новое заявление",
)
self.assertIn(
- 'Иван Автор адресовал вам заявление',
+ "Иван Автор адресовал вам заявление",
notif.description,
)
- self.assertEqual(notif.data['request_id'], request.id)
- self.assertEqual(notif.data['is_primary_recipient'], True)
+ self.assertEqual(notif.data["request_id"], request.id)
+ self.assertEqual(notif.data["is_primary_recipient"], True)
self.assertTrue(notif.unread)
def test_new_request_notification_to_cc(self):
"""Тест: уведомление пользователю в копии"""
request = Request.objects.create(
employee=self.author,
- type='vacation',
- comment='Отпуск',
- status='pending',
- department=self.department
+ type="vacation",
+ comment="Отпуск",
+ status="pending",
+ department=self.department,
)
request.recipients.set([self.recipient])
@@ -136,20 +135,19 @@ class TestRequestNotifications(TestCase):
# Проверяем уведомление для CC
notifications = Notification.objects.filter(
- recipient=self.cc_user,
- verb='request_new'
+ recipient=self.cc_user, verb="request_new"
)
self.assertEqual(notifications.count(), 1)
notif = notifications.first()
- self.assertEqual(notif.data['is_cc'], True)
+ self.assertEqual(notif.data["is_cc"], True)
self.assertEqual(
- notif.data['title'],
- 'Новое заявление',
+ notif.data["title"],
+ "Новое заявление",
)
self.assertIn(
- 'Иван Автор поставил вас в копию заявления',
+ "Иван Автор поставил вас в копию заявления",
notif.description,
)
@@ -157,9 +155,9 @@ class TestRequestNotifications(TestCase):
"""Тест: уведомление согласующему при создании заявления"""
request = Request.objects.create(
employee=self.author,
- type='vacation',
- comment='Отпуск',
- status='pending',
+ type="vacation",
+ comment="Отпуск",
+ status="pending",
department=self.department,
)
request.approver = self.approver
@@ -169,26 +167,26 @@ class TestRequestNotifications(TestCase):
notif = Notification.objects.get(
recipient=self.approver,
- verb='request_new',
+ verb="request_new",
)
self.assertEqual(
- notif.data['title'],
- 'Новое заявление',
+ notif.data["title"],
+ "Новое заявление",
)
self.assertIn(
- 'Иван Автор отправил заявление вам на согласование',
+ "Иван Автор отправил заявление вам на согласование",
notif.description,
)
- self.assertEqual(notif.data['is_approver'], True)
+ self.assertEqual(notif.data["is_approver"], True)
def test_new_request_notification_to_department(self):
"""Тест: уведомление отделу при создании заявления"""
request = Request.objects.create(
employee=self.author,
- type='vacation',
- comment='Отпуск',
- status='pending',
+ type="vacation",
+ comment="Отпуск",
+ status="pending",
department=self.department,
)
@@ -197,28 +195,28 @@ class TestRequestNotifications(TestCase):
notif = Notification.objects.get(
recipient=self.approver,
- verb='request_new',
+ verb="request_new",
)
self.assertEqual(
- notif.data['title'],
- 'Новое заявление',
+ notif.data["title"],
+ "Новое заявление",
)
self.assertIn(
- 'Иван Автор направил заявление в отдел',
+ "Иван Автор направил заявление в отдел",
notif.description,
)
- self.assertEqual(notif.data['is_approver'], False)
+ self.assertEqual(notif.data["is_approver"], False)
def test_request_approval_notification(self):
"""Тест: уведомление об одобрении заявления"""
request = Request.objects.create(
employee=self.author,
- type='vacation',
- comment='Отпуск',
- status='pending',
+ type="vacation",
+ comment="Отпуск",
+ status="pending",
department=self.department,
- approver=self.approver
+ approver=self.approver,
)
request.recipients.set([self.recipient])
@@ -230,26 +228,25 @@ class TestRequestNotifications(TestCase):
# Проверяем уведомление автору
notifications = Notification.objects.filter(
- recipient=self.author,
- verb='request_approved'
+ recipient=self.author, verb="request_approved"
)
self.assertEqual(notifications.count(), 1)
notif = notifications.first()
- self.assertIn('одобрено', notif.data['title'])
- self.assertEqual(notif.data['new_status'], 'approved')
- self.assertEqual(notif.data['approver_id'], self.approver.id)
+ self.assertIn("одобрено", notif.data["title"])
+ self.assertEqual(notif.data["new_status"], "approved")
+ self.assertEqual(notif.data["approver_id"], self.approver.id)
def test_request_rejection_notification(self):
"""Тест: уведомление об отклонении заявления"""
request = Request.objects.create(
employee=self.author,
- type='vacation',
- comment='Отпуск',
- status='pending',
+ type="vacation",
+ comment="Отпуск",
+ status="pending",
department=self.department,
- approver=self.approver
+ approver=self.approver,
)
request.recipients.set([self.recipient])
@@ -259,24 +256,23 @@ class TestRequestNotifications(TestCase):
request.reject(self.approver)
notifications = Notification.objects.filter(
- recipient=self.author,
- verb='request_rejected'
+ recipient=self.author, verb="request_rejected"
)
self.assertEqual(notifications.count(), 1)
notif = notifications.first()
- self.assertIn('отклонено', notif.data['title'])
- self.assertEqual(notif.data['new_status'], 'rejected')
+ self.assertIn("отклонено", notif.data["title"])
+ self.assertEqual(notif.data["new_status"], "rejected")
def test_comment_notification(self):
"""Тест: уведомление о комментарии к заявлению"""
request = Request.objects.create(
employee=self.author,
- type='vacation',
- comment='Отпуск',
- status='pending',
- department=self.department
+ type="vacation",
+ comment="Отпуск",
+ status="pending",
+ department=self.department,
)
request.recipients.set([self.recipient])
@@ -284,32 +280,29 @@ class TestRequestNotifications(TestCase):
# Добавляем комментарий
comment = create_comment(
- obj=request,
- author=self.recipient,
- content='Нужна справка'
+ obj=request, author=self.recipient, content="Нужна справка"
)
# Проверяем уведомление автору
notifications = Notification.objects.filter(
- recipient=self.author,
- verb='commented'
+ recipient=self.author, verb="commented"
)
self.assertEqual(notifications.count(), 1)
notif = notifications.first()
- self.assertIn('комментарий', notif.data['title'])
- self.assertEqual(notif.data['message_id'], comment.id)
- self.assertIn('Нужна справка', notif.description)
+ self.assertIn("комментарий", notif.data["title"])
+ self.assertEqual(notif.data["message_id"], comment.id)
+ self.assertIn("Нужна справка", notif.description)
def test_no_notification_to_author_on_own_comment(self):
"""Тест: автор не получает уведомление о своем комментарии"""
request = Request.objects.create(
employee=self.author,
- type='vacation',
- comment='Отпуск',
- status='pending',
- department=self.department
+ type="vacation",
+ comment="Отпуск",
+ status="pending",
+ department=self.department,
)
request.recipients.set([self.recipient])
@@ -317,15 +310,12 @@ class TestRequestNotifications(TestCase):
# Автор комментирует свое заявление
create_comment(
- obj=request,
- author=self.author,
- content='Дополнительная информация'
+ obj=request, author=self.author, content="Дополнительная информация"
)
# Автор НЕ должен получить уведомление
notifications = Notification.objects.filter(
- recipient=self.author,
- verb='commented'
+ recipient=self.author, verb="commented"
)
self.assertEqual(notifications.count(), 0)
@@ -334,34 +324,32 @@ class TestRequestNotifications(TestCase):
"""Тест: уведомления всем получателям"""
request = Request.objects.create(
employee=self.author,
- type='vacation',
- comment='Отпуск',
- status='pending',
- department=self.department
+ type="vacation",
+ comment="Отпуск",
+ status="pending",
+ department=self.department,
)
# Множественные получатели
request.recipients.set([self.recipient, self.cc_user])
# Проверяем что оба получили уведомления
- notifications = Notification.objects.filter(
- verb='request_new'
- )
+ notifications = Notification.objects.filter(verb="request_new")
recipients = [n.recipient for n in notifications]
self.assertIn(self.recipient, recipients)
self.assertIn(self.cc_user, recipients)
- @patch('notifications.channels.route_notification_to_channels')
+ @patch("notifications.channels.route_notification_to_channels")
def test_channels_called_on_notification(self, mock_channels):
"""Тест: channels.py вызывается при создании уведомления"""
request = Request.objects.create(
employee=self.author,
- type='vacation',
- comment='Отпуск',
- status='pending',
- department=self.department
+ type="vacation",
+ comment="Отпуск",
+ status="pending",
+ department=self.department,
)
request.recipients.set([self.recipient])
@@ -369,8 +357,7 @@ class TestRequestNotifications(TestCase):
# Проверяем что channels.py был вызван
# (он должен вызываться автоматически через post_save)
notification = Notification.objects.filter(
- recipient=self.recipient,
- verb='request_new'
+ recipient=self.recipient, verb="request_new"
).first()
# Просто проверяем что уведомление создано
diff --git a/backend/requests_app/tests/test_signals.py b/backend/requests_app/tests/test_signals.py
index 5770f4c4..7c8d3e19 100644
--- a/backend/requests_app/tests/test_signals.py
+++ b/backend/requests_app/tests/test_signals.py
@@ -2,15 +2,16 @@
Тесты для автоматического создания EmployeeAction из заявок (signals).
"""
-import pytest
from datetime import date, time, timedelta
from unittest.mock import patch
+
+import pytest
from django.utils import timezone
-from requests_app.models import Request
-from requests_app.enums import RequestStatus
from employees.models import EmployeeAction
from employees.signals import IMMEDIATE_ACTION_MAPPING, SCHEDULED_ACTION_MAPPING
+from requests_app.enums import RequestStatus
+from requests_app.models import Request
@pytest.mark.django_db
@@ -157,7 +158,9 @@ class TestRequestSignals:
action="on_day_off",
source_request=request,
)
- assert timezone.localtime(action.date).time().replace(tzinfo=None) == time(14, 0)
+ assert timezone.localtime(action.date).time().replace(tzinfo=None) == time(
+ 14, 0
+ )
assert action.date_to == request_date
assert action.extra["partial_day"] is True
assert action.extra["duration_minutes"] == 150
diff --git a/backend/ruff.toml b/backend/ruff.toml
index 31e98e4e..5c669941 100644
--- a/backend/ruff.toml
+++ b/backend/ruff.toml
@@ -2,7 +2,14 @@ target-version = "py313"
line-length = 88
[lint]
-# API gate: syntax/import correctness plus the failure modes addressed by the
-# architecture audit. Guard-clause counts and framework callback signatures are
-# intentionally not treated as defects.
-select = ["E4", "E7", "E9", "F", "C901", "BLE001", "S110"]
+# Syntax, import hygiene and concrete failure modes. Ruff's formatter owns the
+# whitespace rules; import sorting is enforced explicitly.
+select = ["E4", "E7", "E9", "F", "I", "C901", "S110"]
+
+[lint.mccabe]
+max-complexity = 25
+
+[lint.per-file-ignores]
+# Standalone Django utilities must initialize the app registry before importing
+# project models, so those imports intentionally follow django.setup().
+"scripts/**/*.py" = ["E402"]
diff --git a/backend/scheduling/__init__.py b/backend/scheduling/__init__.py
index 07c2d5e4..f37c5670 100644
--- a/backend/scheduling/__init__.py
+++ b/backend/scheduling/__init__.py
@@ -7,4 +7,4 @@
- Правила доступа (django-rules)
"""
-default_app_config = 'scheduling.apps.SchedulingConfig'
+default_app_config = "scheduling.apps.SchedulingConfig"
diff --git a/backend/scheduling/apps.py b/backend/scheduling/apps.py
index 1427dbea..3af0dc2a 100644
--- a/backend/scheduling/apps.py
+++ b/backend/scheduling/apps.py
@@ -2,15 +2,16 @@
Конфигурация приложения scheduling.
Применяет патчи к django-scheduler и регистрирует сигналы.
"""
+
from django.apps import AppConfig
class SchedulingConfig(AppConfig):
"""Конфигурация приложения для интеграции с django-scheduler."""
- default_auto_field = 'django.db.models.BigAutoField'
- name = 'scheduling'
- verbose_name = 'Планирование и календарь'
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "scheduling"
+ verbose_name = "Планирование и календарь"
def ready(self):
"""
@@ -21,6 +22,7 @@ class SchedulingConfig(AppConfig):
"""
# Применяем патчи к django-scheduler
from scheduling import patch
+
patch.apply_patch()
# Регистрируем сигналы для уведомлений
diff --git a/backend/scheduling/migrations/0002_calendarbinding.py b/backend/scheduling/migrations/0002_calendarbinding.py
index a4b740a1..e301acfa 100644
--- a/backend/scheduling/migrations/0002_calendarbinding.py
+++ b/backend/scheduling/migrations/0002_calendarbinding.py
@@ -1,7 +1,7 @@
from collections import defaultdict
-from django.db import migrations, models
import django.db.models.deletion
+from django.db import migrations, models
def backfill_calendar_bindings(apps, schema_editor):
@@ -72,7 +72,6 @@ def backfill_calendar_bindings(apps, schema_editor):
class Migration(migrations.Migration):
-
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
("employees", "0047_alter_departmentpermission_code"),
@@ -159,9 +158,7 @@ class Migration(migrations.Migration):
),
migrations.AddIndex(
model_name="calendarbinding",
- index=models.Index(
- fields=["type"], name="calendar_binding_type_idx"
- ),
+ index=models.Index(fields=["type"], name="calendar_binding_type_idx"),
),
migrations.AddIndex(
model_name="calendarbinding",
diff --git a/backend/scheduling/migrations/__init__.py b/backend/scheduling/migrations/__init__.py
index a27c3f39..97cc23f7 100644
--- a/backend/scheduling/migrations/__init__.py
+++ b/backend/scheduling/migrations/__init__.py
@@ -15,7 +15,7 @@ class Migration(migrations.Migration):
"""
dependencies = [
- ('calendar_app', '0013_migrate_to_django_scheduler'),
+ ("calendar_app", "0013_migrate_to_django_scheduler"),
]
operations = [
diff --git a/backend/scheduling/models.py b/backend/scheduling/models.py
index 2c970b85..74b71330 100644
--- a/backend/scheduling/models.py
+++ b/backend/scheduling/models.py
@@ -50,9 +50,7 @@ class CalendarBinding(models.Model):
"context_object_id",
)
flags = models.JSONField(default=dict, blank=True, verbose_name="Флаги")
- extra_data = models.JSONField(
- default=dict, blank=True, verbose_name="Доп. данные"
- )
+ extra_data = models.JSONField(default=dict, blank=True, verbose_name="Доп. данные")
notify_event_created = models.BooleanField(
default=True,
verbose_name="Уведомлять о создании события",
diff --git a/backend/scheduling/notifications/__init__.py b/backend/scheduling/notifications/__init__.py
index 0649f4cf..d8581a8e 100644
--- a/backend/scheduling/notifications/__init__.py
+++ b/backend/scheduling/notifications/__init__.py
@@ -12,10 +12,10 @@
class NotificationVerbs:
"""Глаголы (verbs) для уведомлений событий календаря."""
- EVENT_CREATED = 'event_created'
- EVENT_CHANGED = 'event_changed'
- EVENT_CANCELLED = 'event_cancelled'
- EVENT_REMINDER = 'event_reminder' # Для будущей реализации через Celery
+ EVENT_CREATED = "event_created"
+ EVENT_CHANGED = "event_changed"
+ EVENT_CANCELLED = "event_cancelled"
+ EVENT_REMINDER = "event_reminder" # Для будущей реализации через Celery
class MessageTemplates:
@@ -23,9 +23,7 @@ class MessageTemplates:
@staticmethod
def event_created(
- event_title: str,
- event_date: str,
- calendar_name: str = None
+ event_title: str, event_date: str, calendar_name: str = None
) -> str:
"""Шаблон для нового события."""
if calendar_name:
@@ -38,25 +36,19 @@ class MessageTemplates:
@staticmethod
def event_created_title() -> str:
"""Заголовок для нового события."""
- return 'Новое событие в календаре'
+ return "Новое событие в календаре"
@staticmethod
- def event_changed(
- event_title: str,
- changes_text: str = None
- ) -> str:
+ def event_changed(event_title: str, changes_text: str = None) -> str:
"""Шаблон для изменения события."""
if changes_text:
- return (
- f'Событие "{event_title}" изменено. '
- f'Изменения: {changes_text}'
- )
+ return f'Событие "{event_title}" изменено. Изменения: {changes_text}'
return f'Событие "{event_title}" изменено'
@staticmethod
def event_changed_title() -> str:
"""Заголовок для изменения события."""
- return 'Событие изменено'
+ return "Событие изменено"
@staticmethod
def event_cancelled(event_title: str, event_date: str) -> str:
@@ -66,13 +58,13 @@ class MessageTemplates:
@staticmethod
def event_cancelled_title() -> str:
"""Заголовок для отмены события."""
- return 'Событие отменено'
+ return "Событие отменено"
class ActionURLs:
"""URL-адреса для действий с календарем."""
- CALENDAR = '/calendar'
+ CALENDAR = "/calendar"
@staticmethod
def event_detail(event_id: int) -> str:
@@ -81,22 +73,20 @@ class ActionURLs:
TODO: реализовать прямые ссылки на конкретные события
"""
- return f'/calendar?event={event_id}'
+ return f"/calendar?event={event_id}"
# ===== Константы для полей django-scheduler Event =====
FIELD_NAMES = {
- 'title': 'название',
- 'start': 'время начала',
- 'end': 'время окончания',
- 'description': 'описание',
- 'end_recurring_period': 'окончание повторения',
+ "title": "название",
+ "start": "время начала",
+ "end": "время окончания",
+ "description": "описание",
+ "end_recurring_period": "окончание повторения",
}
-IMPORTANT_FIELDS = [
- 'title', 'start', 'end', 'description'
-]
+IMPORTANT_FIELDS = ["title", "start", "end", "description"]
def format_datetime(dt) -> str:
@@ -110,8 +100,8 @@ def format_datetime(dt) -> str:
str: Отформатированная дата и время (например, "10.03.2026 14:30")
"""
if not dt:
- return ''
- return dt.strftime('%d.%m.%Y %H:%M')
+ return ""
+ return dt.strftime("%d.%m.%Y %H:%M")
def format_date(dt) -> str:
@@ -125,8 +115,8 @@ def format_date(dt) -> str:
str: Отформатированная дата (например, "10.03.2026")
"""
if not dt:
- return ''
- return dt.strftime('%d.%m.%Y')
+ return ""
+ return dt.strftime("%d.%m.%Y")
def format_changes(changed_fields: list[str]) -> str:
@@ -139,6 +129,4 @@ def format_changes(changed_fields: list[str]) -> str:
Returns:
str: Текстовое описание изменений через запятую
"""
- return ', '.join([
- FIELD_NAMES.get(f, f) for f in changed_fields
- ])
+ return ", ".join([FIELD_NAMES.get(f, f) for f in changed_fields])
diff --git a/backend/scheduling/notifications/config.py b/backend/scheduling/notifications/config.py
index 79a62767..d255db04 100644
--- a/backend/scheduling/notifications/config.py
+++ b/backend/scheduling/notifications/config.py
@@ -1,24 +1,25 @@
"""
Конфигурация уведомлений для django-scheduler.
"""
+
from . import (
- NotificationVerbs,
- MessageTemplates,
- ActionURLs,
FIELD_NAMES,
IMPORTANT_FIELDS,
- format_datetime,
- format_date,
+ ActionURLs,
+ MessageTemplates,
+ NotificationVerbs,
format_changes,
+ format_date,
+ format_datetime,
)
__all__ = [
- 'NotificationVerbs',
- 'MessageTemplates',
- 'ActionURLs',
- 'FIELD_NAMES',
- 'IMPORTANT_FIELDS',
- 'format_datetime',
- 'format_date',
- 'format_changes',
+ "NotificationVerbs",
+ "MessageTemplates",
+ "ActionURLs",
+ "FIELD_NAMES",
+ "IMPORTANT_FIELDS",
+ "format_datetime",
+ "format_date",
+ "format_changes",
]
diff --git a/backend/scheduling/notifications/handlers.py b/backend/scheduling/notifications/handlers.py
index e807764b..2ddee3cf 100644
--- a/backend/scheduling/notifications/handlers.py
+++ b/backend/scheduling/notifications/handlers.py
@@ -9,6 +9,7 @@
"""
import logging
+
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
@@ -60,10 +61,11 @@ def notify_event_created(event, creator=None):
try:
from notifications.signals import notify
+
from .config import (
- NotificationVerbs,
- MessageTemplates,
ActionURLs,
+ MessageTemplates,
+ NotificationVerbs,
format_date,
)
@@ -139,10 +141,11 @@ def notify_event_changed(event, changed_fields: list[str] = None, modifier=None)
try:
from notifications.signals import notify
+
from .config import (
- NotificationVerbs,
- MessageTemplates,
ActionURLs,
+ MessageTemplates,
+ NotificationVerbs,
format_changes,
)
@@ -215,10 +218,11 @@ def notify_event_cancelled(event, canceller=None):
try:
from notifications.signals import notify
+
from .config import (
- NotificationVerbs,
- MessageTemplates,
ActionURLs,
+ MessageTemplates,
+ NotificationVerbs,
format_date,
)
diff --git a/backend/scheduling/notifications/signals.py b/backend/scheduling/notifications/signals.py
index be80de5e..7fdb767e 100644
--- a/backend/scheduling/notifications/signals.py
+++ b/backend/scheduling/notifications/signals.py
@@ -8,6 +8,7 @@ Django signals для автоматической генерации уведо
"""
import logging
+
from django.db.models.signals import post_save, pre_delete, pre_save
from django.dispatch import receiver
@@ -23,7 +24,7 @@ def create_event_notifications(sender, instance, created, **kwargs):
- При создании - всем участникам календаря (через CalendarRelation)
- При изменении - участникам календаря (если изменились важные поля)
"""
- from .handlers import notify_event_created, notify_event_changed
+ from .handlers import notify_event_changed, notify_event_created
try:
if created:
diff --git a/backend/scheduling/realtime.py b/backend/scheduling/realtime.py
index 6cd78805..032ec668 100644
--- a/backend/scheduling/realtime.py
+++ b/backend/scheduling/realtime.py
@@ -217,9 +217,7 @@ def build_calendar_snapshot(*, user, subscription: CalendarSubscription) -> dict
for event in regular_events
]
- recurring_events = visible_events.filter(rule__isnull=False).order_by(
- "start", "id"
- )
+ recurring_events = visible_events.filter(rule__isnull=False).order_by("start", "id")
period = Period(recurring_events, subscription.start, subscription.end)
serialized_events.extend(
_serialize_occurrence(
@@ -270,9 +268,9 @@ def get_calendar_recipient_ids(
# Администраторы могут работать в scope=all без CalendarRelation.
recipient_ids.update(
- User.objects.filter(is_active=True).filter(
- models.Q(is_staff=True) | models.Q(is_superuser=True)
- ).values_list("id", flat=True)
+ User.objects.filter(is_active=True)
+ .filter(models.Q(is_staff=True) | models.Q(is_superuser=True))
+ .values_list("id", flat=True)
)
return set(
User.objects.filter(id__in=recipient_ids, is_active=True).values_list(
diff --git a/backend/scheduling/realtime_signals.py b/backend/scheduling/realtime_signals.py
index ab38b945..970c1056 100644
--- a/backend/scheduling/realtime_signals.py
+++ b/backend/scheduling/realtime_signals.py
@@ -104,9 +104,7 @@ def broadcast_calendar_relation_deleted(sender, instance, **kwargs):
@receiver([post_save, post_delete], sender="schedule.EventRelation")
def broadcast_event_relation_changed(sender, instance, **kwargs):
event = (
- Event.objects.select_related("calendar")
- .filter(pk=instance.event_id)
- .first()
+ Event.objects.select_related("calendar").filter(pk=instance.event_id).first()
)
if event is None:
return
diff --git a/backend/scheduling/rules.py b/backend/scheduling/rules.py
index d7d7bbad..17f1b9e0 100644
--- a/backend/scheduling/rules.py
+++ b/backend/scheduling/rules.py
@@ -11,7 +11,6 @@ https://github.com/dfunckt/django-rules
import rules
from django.contrib.contenttypes.models import ContentType
-
# -----------------------------------------------------------------------------
# ПРЕДИКАТЫ (predicates)
# -----------------------------------------------------------------------------
diff --git a/backend/scheduling/services.py b/backend/scheduling/services.py
index 512094b1..321ac1f8 100644
--- a/backend/scheduling/services.py
+++ b/backend/scheduling/services.py
@@ -4,9 +4,9 @@ from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.db import transaction
from django.db.models import Q, QuerySet
+from schedule.models import Calendar, CalendarRelation, Event, EventRelation
from employees.models import Department, EmployeeDepartment, RoleAssignment
-from schedule.models import Calendar, CalendarRelation, Event, EventRelation
from .models import CalendarBinding
@@ -28,9 +28,7 @@ def get_calendar_binding(calendar: Calendar | None) -> CalendarBinding | None:
def is_department_calendar(calendar: Calendar | None) -> bool:
binding = get_calendar_binding(calendar)
- return bool(
- binding and binding.type == CalendarBinding.BindingType.DEPARTMENT
- )
+ return bool(binding and binding.type == CalendarBinding.BindingType.DEPARTMENT)
def is_company_wide_calendar(calendar: Calendar | None) -> bool:
@@ -156,9 +154,7 @@ def _department_participant_ids(department: Department) -> set[int]:
return ids
-def _set_relation_role(
- calendar: Calendar, user_id: int, distinction: str
-) -> None:
+def _set_relation_role(calendar: Calendar, user_id: int, distinction: str) -> None:
user_ct = ContentType.objects.get_for_model(User)
relations = list(
CalendarRelation.objects.filter(
@@ -298,30 +294,36 @@ def delete_department_calendar(department_id: int) -> None:
binding = get_department_calendar_binding(department)
else:
dept_ct = ContentType.objects.get_for_model(Department)
- binding = CalendarBinding.objects.filter(
- type=CalendarBinding.BindingType.DEPARTMENT,
- context_content_type=dept_ct,
- context_object_id=department_id,
- ).select_related("calendar").first()
+ binding = (
+ CalendarBinding.objects.filter(
+ type=CalendarBinding.BindingType.DEPARTMENT,
+ context_content_type=dept_ct,
+ context_object_id=department_id,
+ )
+ .select_related("calendar")
+ .first()
+ )
if binding:
binding.calendar.delete()
-def user_relation_distinction(
- calendar: Calendar, user
-) -> str | None:
+def user_relation_distinction(calendar: Calendar, user) -> str | None:
if not getattr(user, "is_authenticated", False):
return None
if user.is_staff or user.is_superuser:
return "owner"
user_ct = ContentType.objects.get_for_model(User)
- relation = CalendarRelation.objects.filter(
- calendar=calendar,
- content_type=user_ct,
- object_id=user.id,
- ).order_by("id").first()
+ relation = (
+ CalendarRelation.objects.filter(
+ calendar=calendar,
+ content_type=user_ct,
+ object_id=user.id,
+ )
+ .order_by("id")
+ .first()
+ )
return relation.distinction if relation else None
diff --git a/backend/scheduling/signals.py b/backend/scheduling/signals.py
index cbffb533..f21c75b0 100644
--- a/backend/scheduling/signals.py
+++ b/backend/scheduling/signals.py
@@ -22,14 +22,10 @@ def sync_department_calendar_on_member_delete(sender, instance, **kwargs):
@receiver(post_save, sender=RoleAssignment)
-def sync_department_calendar_on_role_assignment_save(
- sender, instance, **kwargs
-):
+def sync_department_calendar_on_role_assignment_save(sender, instance, **kwargs):
sync_department_calendar_if_bound(instance.role.department)
@receiver(post_delete, sender=RoleAssignment)
-def sync_department_calendar_on_role_assignment_delete(
- sender, instance, **kwargs
-):
+def sync_department_calendar_on_role_assignment_delete(sender, instance, **kwargs):
sync_department_calendar_if_bound(instance.role.department)
diff --git a/backend/scripts/analyze_queries.py b/backend/scripts/analyze_queries.py
index 166e8ca0..97b1fcc1 100644
--- a/backend/scripts/analyze_queries.py
+++ b/backend/scripts/analyze_queries.py
@@ -10,29 +10,26 @@
import os
import sys
-import django
-from collections import defaultdict
from contextlib import contextmanager
+import django
+
# Setup Django
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eusrr_backend.settings')
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "eusrr_backend.settings")
django.setup()
from django.db import connection, reset_queries
from django.test import RequestFactory
-from django.contrib.auth.models import AnonymousUser
+
+from api.v1.employees.views.departments import DepartmentViewSet
# Import views after Django setup
-from api.v1.employees.views._helpers import _is_ldap_enabled, _ldap_try
from api.v1.employees.views.employees import EmployeeViewSet
-from api.v1.employees.views.actions import EmployeeActionViewSet
-from api.v1.employees.views.departments import DepartmentViewSet
-from api.v1.employees.views.roles import DepartmentRoleViewSet
from api.v1.employees.views.groups import GroupViewSet
-from api.v1.employees.views.skills import SkillViewSet
from api.v1.employees.views.positions import PositionViewSet
-from employees.models import Employee, Department, Skill, Position, Group
+from api.v1.employees.views.skills import SkillViewSet
+from employees.models import Department, Employee, Group
@contextmanager
@@ -41,44 +38,48 @@ def count_queries(label=""):
reset_queries()
yield
num_queries = len(connection.queries)
-
- print(f"\n{'='*70}")
+
+ print(f"\n{'=' * 70}")
print(f"📊 {label}")
- print(f"{'='*70}")
+ print(f"{'=' * 70}")
print(f"Запросов к БД: {num_queries}")
-
+
if num_queries > 0:
- print(f"\nВремя выполнения:")
- total_time = sum(float(q['time']) for q in connection.queries)
+ print("\nВремя выполнения:")
+ total_time = sum(float(q["time"]) for q in connection.queries)
print(f" Общее: {total_time:.4f}s")
- print(f" Среднее: {total_time/num_queries:.4f}s")
-
- print(f"\nСамые медленные запросы:")
- sorted_queries = sorted(connection.queries, key=lambda x: float(x['time']), reverse=True)
+ print(f" Среднее: {total_time / num_queries:.4f}s")
+
+ print("\nСамые медленные запросы:")
+ sorted_queries = sorted(
+ connection.queries, key=lambda x: float(x["time"]), reverse=True
+ )
for i, query in enumerate(sorted_queries[:3], 1):
- sql = query['sql'][:100] + "..." if len(query['sql']) > 100 else query['sql']
+ sql = (
+ query["sql"][:100] + "..." if len(query["sql"]) > 100 else query["sql"]
+ )
print(f" {i}. {query['time']}s: {sql}")
-
- print(f"\nВсе запросы:")
+
+ print("\nВсе запросы:")
for i, query in enumerate(connection.queries, 1):
- sql = query['sql'].replace('\n', ' ').replace(' ', ' ')
+ sql = query["sql"].replace("\n", " ").replace(" ", " ")
if len(sql) > 150:
sql = sql[:150] + "..."
print(f" {i:2d}. {query['time']}s: {sql}")
-
+
return num_queries
def create_test_user():
"""Создать тестового пользователя."""
user, created = Employee.objects.get_or_create(
- email='testuser@example.com',
+ email="testuser@example.com",
defaults={
- 'first_name': 'Test',
- 'last_name': 'User',
- 'is_active': True,
- 'is_staff': True,
- }
+ "first_name": "Test",
+ "last_name": "User",
+ "is_active": True,
+ "is_staff": True,
+ },
)
return user
@@ -87,156 +88,156 @@ def analyze_employee_viewset():
"""Анализ EmployeeViewSet."""
factory = RequestFactory()
user = create_test_user()
-
+
# Test list
with count_queries("GET /api/v1/employees/ (list)"):
- request = factory.get('/api/v1/employees/')
+ request = factory.get("/api/v1/employees/")
request.user = user
- view = EmployeeViewSet.as_view({'get': 'list'})
- response = view(request)
-
+ view = EmployeeViewSet.as_view({"get": "list"})
+ view(request)
+
# Test retrieve
with count_queries(f"GET /api/v1/employees/{user.id}/ (retrieve)"):
- request = factory.get(f'/api/v1/employees/{user.id}/')
+ request = factory.get(f"/api/v1/employees/{user.id}/")
request.user = user
- view = EmployeeViewSet.as_view({'get': 'retrieve'})
- response = view(request, pk=user.id)
-
+ view = EmployeeViewSet.as_view({"get": "retrieve"})
+ view(request, pk=user.id)
+
# Test me
with count_queries("GET /api/v1/employees/me/ (own profile)"):
- request = factory.get('/api/v1/employees/me/')
+ request = factory.get("/api/v1/employees/me/")
request.user = user
- view = EmployeeViewSet.as_view({'get': 'me'})
- response = view(request)
-
+ view = EmployeeViewSet.as_view({"get": "me"})
+ view(request)
+
# Test export_excel
with count_queries("GET /api/v1/employees/export-excel/ (export)"):
- request = factory.get('/api/v1/employees/export-excel/')
+ request = factory.get("/api/v1/employees/export-excel/")
request.user = user
- view = EmployeeViewSet.as_view({'get': 'export_excel'})
- response = view(request)
+ view = EmployeeViewSet.as_view({"get": "export_excel"})
+ view(request)
def analyze_department_viewset():
"""Анализ DepartmentViewSet."""
factory = RequestFactory()
user = create_test_user()
-
+
with count_queries("GET /api/v1/departments/ (list)"):
- request = factory.get('/api/v1/departments/')
+ request = factory.get("/api/v1/departments/")
request.user = user
- view = DepartmentViewSet.as_view({'get': 'list'})
- response = view(request)
-
+ view = DepartmentViewSet.as_view({"get": "list"})
+ view(request)
+
# Если есть отделы, тестируем retrieve
if Department.objects.exists():
dept = Department.objects.first()
with count_queries(f"GET /api/v1/departments/{dept.id}/ (retrieve)"):
- request = factory.get(f'/api/v1/departments/{dept.id}/')
+ request = factory.get(f"/api/v1/departments/{dept.id}/")
request.user = user
- view = DepartmentViewSet.as_view({'get': 'retrieve'})
- response = view(request, pk=dept.id)
+ view = DepartmentViewSet.as_view({"get": "retrieve"})
+ view(request, pk=dept.id)
def analyze_skill_viewset():
"""Анализ SkillViewSet."""
factory = RequestFactory()
user = create_test_user()
-
+
with count_queries("GET /api/v1/skills/ (list)"):
- request = factory.get('/api/v1/skills/')
+ request = factory.get("/api/v1/skills/")
request.user = user
- view = SkillViewSet.as_view({'get': 'list'})
- response = view(request)
+ view = SkillViewSet.as_view({"get": "list"})
+ view(request)
def analyze_group_viewset():
"""Анализ GroupViewSet."""
factory = RequestFactory()
user = create_test_user()
-
+
with count_queries("GET /api/v1/groups/ (list)"):
- request = factory.get('/api/v1/groups/')
+ request = factory.get("/api/v1/groups/")
request.user = user
- view = GroupViewSet.as_view({'get': 'list'})
- response = view(request)
-
+ view = GroupViewSet.as_view({"get": "list"})
+ view(request)
+
# Если есть группы, тестируем retrieve
if Group.objects.exists():
group = Group.objects.first()
with count_queries(f"GET /api/v1/groups/{group.id}/ (retrieve)"):
- request = factory.get(f'/api/v1/groups/{group.id}/')
+ request = factory.get(f"/api/v1/groups/{group.id}/")
request.user = user
- view = GroupViewSet.as_view({'get': 'retrieve'})
- response = view(request, pk=group.id)
+ view = GroupViewSet.as_view({"get": "retrieve"})
+ view(request, pk=group.id)
def analyze_position_viewset():
"""Анализ PositionViewSet."""
factory = RequestFactory()
user = create_test_user()
-
+
with count_queries("GET /api/v1/positions/ (list)"):
- request = factory.get('/api/v1/positions/')
+ request = factory.get("/api/v1/positions/")
request.user = user
- view = PositionViewSet.as_view({'get': 'list'})
- response = view(request)
+ view = PositionViewSet.as_view({"get": "list"})
+ view(request)
def print_summary(results):
"""Вывод сводки результатов."""
- print("\n" + "="*70)
+ print("\n" + "=" * 70)
print("📈 СВОДКА РЕЗУЛЬТАТОВ")
- print("="*70)
-
+ print("=" * 70)
+
for endpoint, count in results.items():
status = "✅" if count <= 5 else "⚠️" if count <= 10 else "🔴"
print(f"{status} {endpoint:50s} {count:3d} queries")
-
- print("\n" + "="*70)
+
+ print("\n" + "=" * 70)
print("Легенда:")
print(" ✅ Отлично (1-5 запросов)")
print(" ⚠️ Приемлемо (6-10 запросов)")
print(" 🔴 Требует оптимизации (>10 запросов)")
- print("="*70)
+ print("=" * 70)
def main():
"""Главная функция."""
print("\n" + "🔍 АНАЛИЗ ЗАПРОСОВ К БД В EMPLOYEES VIEWS ".center(70, "="))
- print("="*70)
-
- results = {}
-
+ print("=" * 70)
+
# Включаем отладку запросов
from django.conf import settings
+
settings.DEBUG = True
-
+
try:
print("\n🔹 Анализ EmployeeViewSet...")
analyze_employee_viewset()
-
+
print("\n🔹 Анализ DepartmentViewSet...")
analyze_department_viewset()
-
+
print("\n🔹 Анализ SkillViewSet...")
analyze_skill_viewset()
-
+
print("\n🔹 Анализ GroupViewSet...")
analyze_group_viewset()
-
+
print("\n🔹 Анализ PositionViewSet...")
analyze_position_viewset()
-
+
except Exception as e:
print(f"\n❌ Ошибка при анализе: {e}")
import traceback
+
traceback.print_exc()
-
- print("\n" + "="*70)
+
+ print("\n" + "=" * 70)
print("✨ Анализ завершен!")
- print("="*70)
-
+ print("=" * 70)
+
print("\n💡 Рекомендации:")
print(" 1. Используйте Django Debug Toolbar для детального анализа")
print(" 2. Добавьте select_related() для ForeignKey связей")
@@ -246,5 +247,5 @@ def main():
print("\n📚 Смотрите backend/docs/reports/DB_QUERIES_ANALYSIS.md")
-if __name__ == '__main__':
+if __name__ == "__main__":
main()
diff --git a/backend/scripts/create_test_calendar_users.py b/backend/scripts/create_test_calendar_users.py
index 96ec5b41..a993da87 100644
--- a/backend/scripts/create_test_calendar_users.py
+++ b/backend/scripts/create_test_calendar_users.py
@@ -3,17 +3,18 @@
Использование:
python manage.py shell < scripts/create_test_calendar_users.py
-
+
или
-
+
.venv/Scripts/python manage.py shell
>>> exec(open('scripts/create_test_calendar_users.py').read())
"""
-from django.contrib.auth import get_user_model
-from schedule.models import Calendar, Event, Rule
from datetime import datetime, timedelta
+
+from django.contrib.auth import get_user_model
from django.utils import timezone
+from schedule.models import Calendar, Event
User = get_user_model()
@@ -22,82 +23,84 @@ print("🚀 Создание тестовых пользователей и ка
# Создание пользователей
test_users = [
{
- 'username': 'anna_ivanova',
- 'email': 'anna@example.com',
- 'first_name': 'Анна',
- 'last_name': 'Иванова',
- 'password': 'test123',
+ "username": "anna_ivanova",
+ "email": "anna@example.com",
+ "first_name": "Анна",
+ "last_name": "Иванова",
+ "password": "test123",
},
{
- 'username': 'petr_petrov',
- 'email': 'petr@example.com',
- 'first_name': 'Пётр',
- 'last_name': 'Петров',
- 'password': 'test123',
+ "username": "petr_petrov",
+ "email": "petr@example.com",
+ "first_name": "Пётр",
+ "last_name": "Петров",
+ "password": "test123",
},
{
- 'username': 'maria_sidorova',
- 'email': 'maria@example.com',
- 'first_name': 'Мария',
- 'last_name': 'Сидорова',
- 'password': 'test123',
+ "username": "maria_sidorova",
+ "email": "maria@example.com",
+ "first_name": "Мария",
+ "last_name": "Сидорова",
+ "password": "test123",
},
]
created_users = []
for user_data in test_users:
user, created = User.objects.get_or_create(
- username=user_data['username'],
+ username=user_data["username"],
defaults={
- 'email': user_data['email'],
- 'first_name': user_data['first_name'],
- 'last_name': user_data['last_name'],
- }
+ "email": user_data["email"],
+ "first_name": user_data["first_name"],
+ "last_name": user_data["last_name"],
+ },
)
if created:
- user.set_password(user_data['password'])
+ user.set_password(user_data["password"])
user.save()
print(f"✅ Создан пользователь: {user.get_full_name()} ({user.username})")
else:
- print(f"ℹ️ Пользователь уже существует: {user.get_full_name()} ({user.username})")
+ print(
+ f"ℹ️ Пользователь уже существует: {user.get_full_name()} ({user.username})"
+ )
created_users.append(user)
# Создание календарей для каждого пользователя
calendar_data = [
{
- 'user': created_users[0],
- 'name': 'Рабочий календарь Анны',
- 'slug': 'anna-work',
+ "user": created_users[0],
+ "name": "Рабочий календарь Анны",
+ "slug": "anna-work",
},
{
- 'user': created_users[0],
- 'name': 'Личные дела Анны',
- 'slug': 'anna-personal',
+ "user": created_users[0],
+ "name": "Личные дела Анны",
+ "slug": "anna-personal",
},
{
- 'user': created_users[1],
- 'name': 'Проекты Петра',
- 'slug': 'petr-projects',
+ "user": created_users[1],
+ "name": "Проекты Петра",
+ "slug": "petr-projects",
},
{
- 'user': created_users[1],
- 'name': 'Встречи Петра',
- 'slug': 'petr-meetings',
+ "user": created_users[1],
+ "name": "Встречи Петра",
+ "slug": "petr-meetings",
},
{
- 'user': created_users[2],
- 'name': 'Календарь Марии',
- 'slug': 'maria-calendar',
+ "user": created_users[2],
+ "name": "Календарь Марии",
+ "slug": "maria-calendar",
},
]
created_calendars = []
for cal_data in calendar_data:
calendar, created = Calendar.objects.get_or_create(
- slug=cal_data['slug'],
+ slug=cal_data["slug"],
defaults={
- 'name': cal_data['name'],
- }
+ "name": cal_data["name"],
+ },
)
if created:
print(f"✅ Создан календарь: {calendar.name}")
@@ -112,114 +115,162 @@ today = now.date()
events_data = [
# События для Анны (календарь 0 - рабочий)
{
- 'calendar': created_calendars[0],
- 'title': 'Встреча с клиентом',
- 'start': datetime.combine(today + timedelta(days=1), datetime.min.time().replace(hour=10)),
- 'end': datetime.combine(today + timedelta(days=1), datetime.min.time().replace(hour=11)),
- 'description': 'Обсуждение проекта',
+ "calendar": created_calendars[0],
+ "title": "Встреча с клиентом",
+ "start": datetime.combine(
+ today + timedelta(days=1), datetime.min.time().replace(hour=10)
+ ),
+ "end": datetime.combine(
+ today + timedelta(days=1), datetime.min.time().replace(hour=11)
+ ),
+ "description": "Обсуждение проекта",
},
{
- 'calendar': created_calendars[0],
- 'title': 'Планерка команды',
- 'start': datetime.combine(today + timedelta(days=2), datetime.min.time().replace(hour=9)),
- 'end': datetime.combine(today + timedelta(days=2), datetime.min.time().replace(hour=10)),
- 'description': 'Еженедельная встреча',
+ "calendar": created_calendars[0],
+ "title": "Планерка команды",
+ "start": datetime.combine(
+ today + timedelta(days=2), datetime.min.time().replace(hour=9)
+ ),
+ "end": datetime.combine(
+ today + timedelta(days=2), datetime.min.time().replace(hour=10)
+ ),
+ "description": "Еженедельная встреча",
},
{
- 'calendar': created_calendars[0],
- 'title': 'Презентация результатов',
- 'start': datetime.combine(today + timedelta(days=5), datetime.min.time().replace(hour=14)),
- 'end': datetime.combine(today + timedelta(days=5), datetime.min.time().replace(hour=15, minute=30)),
- 'description': 'Демо для заказчика',
+ "calendar": created_calendars[0],
+ "title": "Презентация результатов",
+ "start": datetime.combine(
+ today + timedelta(days=5), datetime.min.time().replace(hour=14)
+ ),
+ "end": datetime.combine(
+ today + timedelta(days=5), datetime.min.time().replace(hour=15, minute=30)
+ ),
+ "description": "Демо для заказчика",
},
# События для Анны (календарь 1 - личный)
{
- 'calendar': created_calendars[1],
- 'title': 'Врач',
- 'start': datetime.combine(today + timedelta(days=3), datetime.min.time().replace(hour=16)),
- 'end': datetime.combine(today + timedelta(days=3), datetime.min.time().replace(hour=17)),
- 'description': 'Плановый осмотр',
+ "calendar": created_calendars[1],
+ "title": "Врач",
+ "start": datetime.combine(
+ today + timedelta(days=3), datetime.min.time().replace(hour=16)
+ ),
+ "end": datetime.combine(
+ today + timedelta(days=3), datetime.min.time().replace(hour=17)
+ ),
+ "description": "Плановый осмотр",
},
{
- 'calendar': created_calendars[1],
- 'title': 'Фитнес',
- 'start': datetime.combine(today + timedelta(days=4), datetime.min.time().replace(hour=19)),
- 'end': datetime.combine(today + timedelta(days=4), datetime.min.time().replace(hour=20)),
- 'description': 'Тренировка',
+ "calendar": created_calendars[1],
+ "title": "Фитнес",
+ "start": datetime.combine(
+ today + timedelta(days=4), datetime.min.time().replace(hour=19)
+ ),
+ "end": datetime.combine(
+ today + timedelta(days=4), datetime.min.time().replace(hour=20)
+ ),
+ "description": "Тренировка",
},
# События для Петра (календарь 2 - проекты)
{
- '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)),
- 'description': 'Работа над функционалом',
+ "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)),
+ "description": "Работа над функционалом",
},
{
- 'calendar': created_calendars[2],
- 'title': 'Code Review',
- 'start': datetime.combine(today + timedelta(days=1), datetime.min.time().replace(hour=15)),
- 'end': datetime.combine(today + timedelta(days=1), datetime.min.time().replace(hour=16)),
- 'description': 'Проверка кода коллег',
+ "calendar": created_calendars[2],
+ "title": "Code Review",
+ "start": datetime.combine(
+ today + timedelta(days=1), datetime.min.time().replace(hour=15)
+ ),
+ "end": datetime.combine(
+ today + timedelta(days=1), datetime.min.time().replace(hour=16)
+ ),
+ "description": "Проверка кода коллег",
},
# События для Петра (календарь 3 - встречи)
{
- 'calendar': created_calendars[3],
- 'title': 'Встреча с HR',
- 'start': datetime.combine(today + timedelta(days=2), datetime.min.time().replace(hour=11)),
- 'end': datetime.combine(today + timedelta(days=2), datetime.min.time().replace(hour=12)),
- 'description': 'Собеседование кандидата',
+ "calendar": created_calendars[3],
+ "title": "Встреча с HR",
+ "start": datetime.combine(
+ today + timedelta(days=2), datetime.min.time().replace(hour=11)
+ ),
+ "end": datetime.combine(
+ today + timedelta(days=2), datetime.min.time().replace(hour=12)
+ ),
+ "description": "Собеседование кандидата",
},
{
- 'calendar': created_calendars[3],
- 'title': '1-on-1 с руководителем',
- 'start': datetime.combine(today + timedelta(days=7), datetime.min.time().replace(hour=14)),
- 'end': datetime.combine(today + timedelta(days=7), datetime.min.time().replace(hour=15)),
- 'description': 'Обсуждение целей',
+ "calendar": created_calendars[3],
+ "title": "1-on-1 с руководителем",
+ "start": datetime.combine(
+ today + timedelta(days=7), datetime.min.time().replace(hour=14)
+ ),
+ "end": datetime.combine(
+ today + timedelta(days=7), datetime.min.time().replace(hour=15)
+ ),
+ "description": "Обсуждение целей",
},
# События для Марии (календарь 4)
{
- 'calendar': created_calendars[4],
- 'title': 'Обучение новых сотрудников',
- 'start': datetime.combine(today + timedelta(days=1), datetime.min.time().replace(hour=13)),
- 'end': datetime.combine(today + timedelta(days=1), datetime.min.time().replace(hour=16)),
- 'description': 'Вводный курс',
+ "calendar": created_calendars[4],
+ "title": "Обучение новых сотрудников",
+ "start": datetime.combine(
+ today + timedelta(days=1), datetime.min.time().replace(hour=13)
+ ),
+ "end": datetime.combine(
+ today + timedelta(days=1), datetime.min.time().replace(hour=16)
+ ),
+ "description": "Вводный курс",
},
{
- 'calendar': created_calendars[4],
- 'title': 'Подготовка отчета',
- 'start': datetime.combine(today + timedelta(days=6), datetime.min.time().replace(hour=10)),
- 'end': datetime.combine(today + timedelta(days=6), datetime.min.time().replace(hour=13)),
- 'description': 'Квартальный отчет',
+ "calendar": created_calendars[4],
+ "title": "Подготовка отчета",
+ "start": datetime.combine(
+ today + timedelta(days=6), datetime.min.time().replace(hour=10)
+ ),
+ "end": datetime.combine(
+ today + timedelta(days=6), datetime.min.time().replace(hour=13)
+ ),
+ "description": "Квартальный отчет",
},
]
print("\n📅 Создание событий...")
for event_data in events_data:
# Преобразуем datetime в timezone-aware
- start = timezone.make_aware(event_data['start']) if timezone.is_naive(event_data['start']) else event_data['start']
- end = timezone.make_aware(event_data['end']) if timezone.is_naive(event_data['end']) else event_data['end']
-
+ start = (
+ timezone.make_aware(event_data["start"])
+ if timezone.is_naive(event_data["start"])
+ else event_data["start"]
+ )
+ end = (
+ timezone.make_aware(event_data["end"])
+ if timezone.is_naive(event_data["end"])
+ else event_data["end"]
+ )
+
event, created = Event.objects.get_or_create(
- calendar=event_data['calendar'],
- title=event_data['title'],
+ calendar=event_data["calendar"],
+ title=event_data["title"],
start=start,
defaults={
- 'end': end,
- 'description': event_data.get('description', ''),
- }
+ "end": end,
+ "description": event_data.get("description", ""),
+ },
)
if created:
print(f" ✅ {event.title} ({event.calendar.name})")
else:
print(f" ℹ️ Событие уже существует: {event.title}")
-print("\n" + "="*60)
+print("\n" + "=" * 60)
print("✨ Готово! Создано:")
print(f" 👥 Пользователи: {len(created_users)}")
print(f" 📅 Календари: {len(created_calendars)}")
print(f" 🎯 События: {len(events_data)}")
-print("="*60)
+print("=" * 60)
print("\n🔑 Учетные данные для входа:")
for user_data in test_users:
print(f" {user_data['username']} / {user_data['password']}")
diff --git a/backend/scripts/create_test_users.py b/backend/scripts/create_test_users.py
index 3c6d8636..5276c9a6 100644
--- a/backend/scripts/create_test_users.py
+++ b/backend/scripts/create_test_users.py
@@ -2,6 +2,7 @@
Скрипт для создания тестовых пользователей в корпоративном портале.
Создает 100+ пользователей с разными ролями и отделами.
"""
+
import os
import sys
from pathlib import Path
@@ -10,100 +11,204 @@ from pathlib import Path
backend_dir = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(backend_dir))
-import django
-from datetime import date, timedelta
import random
+from datetime import date, timedelta
+
+import django
# Setup Django
-os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eusrr_backend.settings')
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "eusrr_backend.settings")
django.setup()
from django.contrib.auth import get_user_model
-from employees.models import Department, Position, EmployeeDepartment
+
+from employees.models import Department, EmployeeDepartment, Position
User = get_user_model()
# Списки для генерации данных
FIRST_NAMES = [
- 'Александр', 'Дмитрий', 'Максим', 'Сергей', 'Андрей', 'Алексей', 'Артем', 'Илья',
- 'Кирилл', 'Михаил', 'Иван', 'Даниил', 'Егор', 'Никита', 'Матвей', 'Роман',
- 'Владислав', 'Ярослав', 'Тимофей', 'Владимир', 'Павел', 'Николай', 'Денис', 'Олег',
- 'Анна', 'Мария', 'Елена', 'Ольга', 'Татьяна', 'Наталья', 'Екатерина', 'Ирина',
- 'Юлия', 'Светлана', 'Людмила', 'Галина', 'Анастасия', 'Дарья', 'Виктория', 'Валерия',
- 'Ксения', 'Полина', 'Алина', 'Вероника', 'София', 'Марина', 'Оксана', 'Алла'
+ "Александр",
+ "Дмитрий",
+ "Максим",
+ "Сергей",
+ "Андрей",
+ "Алексей",
+ "Артем",
+ "Илья",
+ "Кирилл",
+ "Михаил",
+ "Иван",
+ "Даниил",
+ "Егор",
+ "Никита",
+ "Матвей",
+ "Роман",
+ "Владислав",
+ "Ярослав",
+ "Тимофей",
+ "Владимир",
+ "Павел",
+ "Николай",
+ "Денис",
+ "Олег",
+ "Анна",
+ "Мария",
+ "Елена",
+ "Ольга",
+ "Татьяна",
+ "Наталья",
+ "Екатерина",
+ "Ирина",
+ "Юлия",
+ "Светлана",
+ "Людмила",
+ "Галина",
+ "Анастасия",
+ "Дарья",
+ "Виктория",
+ "Валерия",
+ "Ксения",
+ "Полина",
+ "Алина",
+ "Вероника",
+ "София",
+ "Марина",
+ "Оксана",
+ "Алла",
]
LAST_NAMES = [
- 'Иванов', 'Петров', 'Сидоров', 'Смирнов', 'Кузнецов', 'Попов', 'Васильев', 'Соколов',
- 'Михайлов', 'Новиков', 'Федоров', 'Морозов', 'Волков', 'Алексеев', 'Лебедев', 'Семенов',
- 'Егоров', 'Павлов', 'Козлов', 'Степанов', 'Николаев', 'Орлов', 'Андреев', 'Макаров',
- 'Никитин', 'Захаров', 'Зайцев', 'Соловьев', 'Борисов', 'Яковлев', 'Григорьев', 'Романов',
- 'Воробьев', 'Сергеев', 'Фролов', 'Дмитриев', 'Ковалев', 'Белов', 'Комаров', 'Жуков',
- 'Иванова', 'Петрова', 'Сидорова', 'Смирнова', 'Кузнецова', 'Попова', 'Васильева', 'Соколова'
+ "Иванов",
+ "Петров",
+ "Сидоров",
+ "Смирнов",
+ "Кузнецов",
+ "Попов",
+ "Васильев",
+ "Соколов",
+ "Михайлов",
+ "Новиков",
+ "Федоров",
+ "Морозов",
+ "Волков",
+ "Алексеев",
+ "Лебедев",
+ "Семенов",
+ "Егоров",
+ "Павлов",
+ "Козлов",
+ "Степанов",
+ "Николаев",
+ "Орлов",
+ "Андреев",
+ "Макаров",
+ "Никитин",
+ "Захаров",
+ "Зайцев",
+ "Соловьев",
+ "Борисов",
+ "Яковлев",
+ "Григорьев",
+ "Романов",
+ "Воробьев",
+ "Сергеев",
+ "Фролов",
+ "Дмитриев",
+ "Ковалев",
+ "Белов",
+ "Комаров",
+ "Жуков",
+ "Иванова",
+ "Петрова",
+ "Сидорова",
+ "Смирнова",
+ "Кузнецова",
+ "Попова",
+ "Васильева",
+ "Соколова",
]
PATRONYMICS = [
- 'Александрович', 'Дмитриевич', 'Максимович', 'Сергеевич', 'Андреевич', 'Алексеевич',
- 'Артемович', 'Ильич', 'Кириллович', 'Михайлович', 'Иванович', 'Даниилович',
- 'Александровна', 'Дмитриевна', 'Максимовна', 'Сергеевна', 'Андреевна', 'Алексеевна',
- 'Артемовна', 'Ильинична', 'Кирилловна', 'Михайловна', 'Ивановна', 'Данииловна'
+ "Александрович",
+ "Дмитриевич",
+ "Максимович",
+ "Сергеевич",
+ "Андреевич",
+ "Алексеевич",
+ "Артемович",
+ "Ильич",
+ "Кириллович",
+ "Михайлович",
+ "Иванович",
+ "Даниилович",
+ "Александровна",
+ "Дмитриевна",
+ "Максимовна",
+ "Сергеевна",
+ "Андреевна",
+ "Алексеевна",
+ "Артемовна",
+ "Ильинична",
+ "Кирилловна",
+ "Михайловна",
+ "Ивановна",
+ "Данииловна",
]
DEPARTMENTS_DATA = [
- {'name': 'Разработка', 'code': 'DEV'},
- {'name': 'Тестирование', 'code': 'QA'},
- {'name': 'Продажи', 'code': 'SALES'},
- {'name': 'Маркетинг', 'code': 'MKT'},
- {'name': 'HR', 'code': 'HR'},
- {'name': 'Финансы', 'code': 'FIN'},
- {'name': 'Бухгалтерия', 'code': 'ACC'},
- {'name': 'Логистика', 'code': 'LOG'},
- {'name': 'Производство', 'code': 'PROD'},
- {'name': 'Закупки', 'code': 'PURCH'},
+ {"name": "Разработка", "code": "DEV"},
+ {"name": "Тестирование", "code": "QA"},
+ {"name": "Продажи", "code": "SALES"},
+ {"name": "Маркетинг", "code": "MKT"},
+ {"name": "HR", "code": "HR"},
+ {"name": "Финансы", "code": "FIN"},
+ {"name": "Бухгалтерия", "code": "ACC"},
+ {"name": "Логистика", "code": "LOG"},
+ {"name": "Производство", "code": "PROD"},
+ {"name": "Закупки", "code": "PURCH"},
]
POSITIONS_DATA = [
- 'Младший специалист',
- 'Специалист',
- 'Старший специалист',
- 'Ведущий специалист',
- 'Менеджер',
- 'Старший менеджер',
- 'Руководитель группы',
- 'Руководитель отдела',
- 'Директор',
- 'Стажер',
+ "Младший специалист",
+ "Специалист",
+ "Старший специалист",
+ "Ведущий специалист",
+ "Менеджер",
+ "Старший менеджер",
+ "Руководитель группы",
+ "Руководитель отдела",
+ "Директор",
+ "Стажер",
]
def create_departments():
"""Создание отделов"""
- print('\n📁 Создание отделов...')
+ print("\n📁 Создание отделов...")
departments = []
for dept_data in DEPARTMENTS_DATA:
dept, created = Department.objects.get_or_create(
- name=dept_data['name'],
- defaults={'description': f'Отдел {dept_data["name"]}'}
+ name=dept_data["name"],
+ defaults={"description": f"Отдел {dept_data['name']}"},
)
departments.append(dept)
if created:
- print(f' ✅ Создан отдел: {dept.name}')
+ print(f" ✅ Создан отдел: {dept.name}")
else:
- print(f' ℹ️ Отдел существует: {dept.name}')
+ print(f" ℹ️ Отдел существует: {dept.name}")
return departments
def create_positions():
"""Создание должностей"""
- print('\n💼 Создание должностей...')
+ print("\n💼 Создание должностей...")
positions = []
for pos_name in POSITIONS_DATA:
- pos, created = Position.objects.get_or_create(
- name=pos_name
- )
+ pos, created = Position.objects.get_or_create(name=pos_name)
positions.append(pos)
if created:
- print(f' ✅ Создана должность: {pos.name}')
+ print(f" ✅ Создана должность: {pos.name}")
return positions
@@ -111,25 +216,53 @@ def generate_email(first_name, last_name, counter):
"""Генерация email"""
first = first_name.lower()
last = last_name.lower()
-
+
# Транслитерация
translit = {
- 'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'e',
- 'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'y', 'к': 'k', 'л': 'l', 'м': 'm',
- 'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u',
- 'ф': 'f', 'х': 'h', 'ц': 'ts', 'ч': 'ch', 'ш': 'sh', 'щ': 'sch', 'ъ': '',
- 'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya'
+ "а": "a",
+ "б": "b",
+ "в": "v",
+ "г": "g",
+ "д": "d",
+ "е": "e",
+ "ё": "e",
+ "ж": "zh",
+ "з": "z",
+ "и": "i",
+ "й": "y",
+ "к": "k",
+ "л": "l",
+ "м": "m",
+ "н": "n",
+ "о": "o",
+ "п": "p",
+ "р": "r",
+ "с": "s",
+ "т": "t",
+ "у": "u",
+ "ф": "f",
+ "х": "h",
+ "ц": "ts",
+ "ч": "ch",
+ "ш": "sh",
+ "щ": "sch",
+ "ъ": "",
+ "ы": "y",
+ "ь": "",
+ "э": "e",
+ "ю": "yu",
+ "я": "ya",
}
-
- first_en = ''.join(translit.get(c, c) for c in first)
- last_en = ''.join(translit.get(c, c) for c in last)
-
- return f'{first_en}.{last_en}{counter}@company.local'
+
+ first_en = "".join(translit.get(c, c) for c in first)
+ last_en = "".join(translit.get(c, c) for c in last)
+
+ return f"{first_en}.{last_en}{counter}@company.local"
def generate_phone():
"""Генерация номера телефона"""
- return f'+7{random.randint(900, 999)}{random.randint(1000000, 9999999)}'
+ return f"+7{random.randint(900, 999)}{random.randint(1000000, 9999999)}"
def generate_birthdate():
@@ -141,33 +274,33 @@ def generate_birthdate():
def create_users(count=120, departments=None, positions=None):
"""Создание пользователей"""
- print(f'\n👥 Создание {count} пользователей...')
-
+ print(f"\n👥 Создание {count} пользователей...")
+
if not departments:
departments = list(Department.objects.all())
if not positions:
positions = list(Position.objects.all())
-
+
created_count = 0
skipped_count = 0
-
+
for i in range(1, count + 1):
first_name = random.choice(FIRST_NAMES)
last_name = random.choice(LAST_NAMES)
patronymic = random.choice(PATRONYMICS)
-
+
email = generate_email(first_name, last_name, i)
-
+
# Проверяем, существует ли пользователь
if User.objects.filter(email=email).exists():
skipped_count += 1
continue
-
+
try:
# Создаем пользователя
user = User.objects.create_user(
email=email,
- password='test123', # Простой пароль для тестирования
+ password="test123", # Простой пароль для тестирования
first_name=first_name,
last_name=last_name,
patronymic=patronymic,
@@ -176,102 +309,99 @@ def create_users(count=120, departments=None, positions=None):
is_active=True,
email_verified=True,
is_staff=random.random() < 0.1, # 10% - staff
- send_activation_email=False
+ send_activation_email=False,
)
-
+
# Назначаем должность
position = random.choice(positions)
user.position = position
user.save()
-
+
# Назначаем отдел(ы)
# 70% - один отдел, 25% - два отдела, 5% - три отдела
dept_count = random.choices([1, 2, 3], weights=[70, 25, 5])[0]
- user_departments = random.sample(departments, min(dept_count, len(departments)))
-
+ user_departments = random.sample(
+ departments, min(dept_count, len(departments))
+ )
+
for dept in user_departments:
EmployeeDepartment.objects.create(
- employee=user,
- department=dept,
- is_active=True
+ employee=user, department=dept, is_active=True
)
-
+
created_count += 1
-
+
if created_count % 10 == 0:
- print(f' ✅ Создано {created_count} пользователей...')
-
+ print(f" ✅ Создано {created_count} пользователей...")
+
except Exception as e:
- print(f' ❌ Ошибка создания пользователя {email}: {e}')
+ print(f" ❌ Ошибка создания пользователя {email}: {e}")
skipped_count += 1
-
+
return created_count, skipped_count
def print_statistics():
"""Вывод статистики"""
- print('\n' + '=' * 60)
- print('📊 СТАТИСТИКА БАЗЫ ДАННЫХ')
- print('=' * 60)
-
+ print("\n" + "=" * 60)
+ print("📊 СТАТИСТИКА БАЗЫ ДАННЫХ")
+ print("=" * 60)
+
total_users = User.objects.count()
active_users = User.objects.filter(is_active=True).count()
staff_users = User.objects.filter(is_staff=True).count()
-
- print(f'\n👥 Пользователи:')
- print(f' Всего: {total_users}')
- print(f' Активных: {active_users}')
- print(f' Персонал: {staff_users}')
-
+
+ print("\n👥 Пользователи:")
+ print(f" Всего: {total_users}")
+ print(f" Активных: {active_users}")
+ print(f" Персонал: {staff_users}")
+
departments = Department.objects.all()
- print(f'\n📁 Отделы: {departments.count()}')
+ print(f"\n📁 Отделы: {departments.count()}")
for dept in departments:
emp_count = EmployeeDepartment.objects.filter(
- department=dept,
- is_active=True
+ department=dept, is_active=True
).count()
- print(f' • {dept.name}: {emp_count} сотрудников')
-
+ print(f" • {dept.name}: {emp_count} сотрудников")
+
positions = Position.objects.all()
- print(f'\n💼 Должности: {positions.count()}')
+ print(f"\n💼 Должности: {positions.count()}")
for pos in positions:
emp_count = User.objects.filter(position=pos).count()
if emp_count > 0:
- print(f' • {pos.name}: {emp_count} сотрудников')
-
- print('\n' + '=' * 60)
+ print(f" • {pos.name}: {emp_count} сотрудников")
+
+ print("\n" + "=" * 60)
def main():
- print('=' * 60)
- print('🚀 ГЕНЕРАЦИЯ ТЕСТОВЫХ ПОЛЬЗОВАТЕЛЕЙ')
- print('=' * 60)
-
+ print("=" * 60)
+ print("🚀 ГЕНЕРАЦИЯ ТЕСТОВЫХ ПОЛЬЗОВАТЕЛЕЙ")
+ print("=" * 60)
+
# Создаем отделы и должности
departments = create_departments()
positions = create_positions()
-
+
# Создаем пользователей
created, skipped = create_users(
- count=120,
- departments=departments,
- positions=positions
+ count=120, departments=departments, positions=positions
)
-
- print(f'\n✅ Создание завершено!')
- print(f' Создано: {created} пользователей')
+
+ print("\n✅ Создание завершено!")
+ print(f" Создано: {created} пользователей")
if skipped > 0:
- print(f' Пропущено: {skipped} (уже существуют)')
-
+ print(f" Пропущено: {skipped} (уже существуют)")
+
# Выводим статистику
print_statistics()
-
- print('\n💡 Данные для входа:')
- print(' Username: user001, user002, ... user120')
- print(' Password: test123')
- print(' Email: имя.фамилия1@company.local, и т.д.')
- print('\n' + '=' * 60)
+
+ print("\n💡 Данные для входа:")
+ print(" Username: user001, user002, ... user120")
+ print(" Password: test123")
+ print(" Email: имя.фамилия1@company.local, и т.д.")
+ print("\n" + "=" * 60)
-if __name__ == '__main__':
+if __name__ == "__main__":
main()
diff --git a/backend/scripts/diagnostic/check_chat_access.py b/backend/scripts/diagnostic/check_chat_access.py
index 49fcc124..7b870160 100644
--- a/backend/scripts/diagnostic/check_chat_access.py
+++ b/backend/scripts/diagnostic/check_chat_access.py
@@ -2,96 +2,122 @@
"""
Скрипт для проверки доступа к чатам
"""
+
import os
import sys
+
import django
# Настройка Django
sys.path.insert(0, os.path.dirname(__file__))
-os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eusrr_backend.settings')
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "eusrr_backend.settings")
django.setup()
from django.contrib.auth import get_user_model
-from communications.models import Chat, ChatMembership
from django.db.models import Q
+from communications.models import Chat, ChatMembership
+
User = get_user_model()
+
def check_chat_access():
print("=" * 60)
print("ПРОВЕРКА ДОСТУПА К ЧАТАМ")
print("=" * 60)
-
+
# Получаем текущего пользователя (первого активного)
users = User.objects.filter(is_active=True)
if not users.exists():
print("❌ Нет активных пользователей!")
return
-
+
print(f"\n📋 Всего активных пользователей: {users.count()}")
for user in users[:5]:
print(f" - {user.username} (ID: {user.id})")
-
+
# Проверяем чаты
print(f"\n📨 Всего чатов в системе: {Chat.objects.count()}")
-
+
for chat in Chat.objects.all()[:10]:
- print(f"\n{'='*60}")
+ print(f"\n{'=' * 60}")
print(f"📌 Чат ID: {chat.id}")
print(f" Тип: {chat.type}")
print(f" Название: {chat.name or '(без названия)'}")
print(f" Создан: {chat.created_at}")
-
+
if chat.type == "private":
participants = chat.participants.all()
- print(f" Участники (через participants): {[u.username for u in participants]}")
-
+ print(
+ f" Участники (через participants): {[u.username for u in participants]}"
+ )
+
if chat.type == "group" or chat.type == "channel":
# Для групповых чатов и каналов показываем участников
participants = chat.participants.all()
if participants.exists():
print(f" Участники: {[u.username for u in participants[:5]]}")
-
+
# Для каналов может быть привязан объект через GenericFK
if chat.context_object:
- print(f" Привязан к: {chat.context_object} (тип: {chat.context_content_type})")
-
+ print(
+ f" Привязан к: {chat.context_object} (тип: {chat.context_content_type})"
+ )
+
# Проверяем membership
memberships = ChatMembership.objects.filter(chat=chat)
if memberships.exists():
- print(f" Участники (через ChatMembership): {[m.user.username for m in memberships]}")
-
+ print(
+ f" Участники (через ChatMembership): {[m.user.username for m in memberships]}"
+ )
+
# Проверяем доступ для каждого пользователя
- print(f"\n Доступ пользователей к этому чату:")
+ print("\n Доступ пользователей к этому чату:")
for user in users[:3]:
- membership_chat_ids = ChatMembership.objects.filter(
- user=user
- ).values_list('chat_id', flat=True)
-
- has_access = Chat.objects.filter(
- Q(type="global")
- | Q(type="private", participants=user)
- | Q(type="group", participants=user)
- | Q(type="channel", participants=user)
- | Q(id__in=membership_chat_ids)
- ).filter(id=chat.id).exists()
-
+ membership_chat_ids = ChatMembership.objects.filter(user=user).values_list(
+ "chat_id", flat=True
+ )
+
+ has_access = (
+ Chat.objects.filter(
+ Q(type="global")
+ | Q(type="private", participants=user)
+ | Q(type="group", participants=user)
+ | Q(type="channel", participants=user)
+ | Q(id__in=membership_chat_ids)
+ )
+ .filter(id=chat.id)
+ .exists()
+ )
+
status = "✅" if has_access else "❌"
- print(f" {status} {user.username}: {'Доступ есть' if has_access else 'Доступа нет'}")
-
+ print(
+ f" {status} {user.username}: {'Доступ есть' if has_access else 'Доступа нет'}"
+ )
+
if not has_access:
# Детальная проверка
is_global = chat.type == "global"
- is_private = chat.type == "private" and chat.participants.filter(id=user.id).exists()
- is_group = chat.type == "group" and chat.participants.filter(id=user.id).exists()
- is_channel = chat.type == "channel" and chat.participants.filter(id=user.id).exists()
+ is_private = (
+ chat.type == "private"
+ and chat.participants.filter(id=user.id).exists()
+ )
+ is_group = (
+ chat.type == "group"
+ and chat.participants.filter(id=user.id).exists()
+ )
+ is_channel = (
+ chat.type == "channel"
+ and chat.participants.filter(id=user.id).exists()
+ )
is_member = chat.id in membership_chat_ids
-
+
print(f" - Глобальный: {is_global}")
print(f" - Личный участник: {is_private}")
print(f" - Группа: {is_group}")
print(f" - Канал: {is_channel}")
print(f" - Через membership: {is_member}")
+
if __name__ == "__main__":
check_chat_access()
diff --git a/backend/scripts/diagnostic/check_employee_id_attrs.py b/backend/scripts/diagnostic/check_employee_id_attrs.py
index 309230db..beb33e9b 100644
--- a/backend/scripts/diagnostic/check_employee_id_attrs.py
+++ b/backend/scripts/diagnostic/check_employee_id_attrs.py
@@ -10,6 +10,7 @@
Запуск:
.venv/Scripts/python backend/scripts/diagnostic/check_employee_id_attrs.py
"""
+
import os
import sys
from pathlib import Path
@@ -22,12 +23,14 @@ sys.path.insert(0, str(backend_path))
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "eusrr_backend.settings")
import django
+
django.setup()
-from django.conf import settings
-from ldap3 import Server, Connection, ALL, SUBTREE, Tls, MODIFY_REPLACE
import ssl
+from django.conf import settings
+from ldap3 import ALL, MODIFY_REPLACE, SUBTREE, Connection, Server, Tls
+
def get_ldap_config():
"""Получает конфигурацию LDAP из Django settings."""
@@ -45,7 +48,7 @@ def connect_ldap(cfg):
"""Создаёт подключение к LDAP."""
uri = cfg["uri"]
use_ssl = uri.startswith("ldaps://")
-
+
# Парсим хост и порт
if "://" in uri:
uri = uri.split("://", 1)[1]
@@ -55,29 +58,17 @@ def connect_ldap(cfg):
else:
host = uri
port = 636 if use_ssl else 389
-
+
tls = None
if use_ssl and cfg.get("ca_cert"):
- tls = Tls(
- ca_certs_file=cfg["ca_cert"],
- validate=ssl.CERT_REQUIRED
- )
+ tls = Tls(ca_certs_file=cfg["ca_cert"], validate=ssl.CERT_REQUIRED)
elif use_ssl:
tls = Tls(validate=ssl.CERT_NONE)
-
- server = Server(
- host,
- port=port,
- use_ssl=use_ssl,
- tls=tls,
- get_info=ALL
- )
-
+
+ server = Server(host, port=port, use_ssl=use_ssl, tls=tls, get_info=ALL)
+
conn = Connection(
- server,
- user=cfg["bind_dn"],
- password=cfg["bind_password"],
- auto_bind=True
+ server, user=cfg["bind_dn"], password=cfg["bind_password"], auto_bind=True
)
return conn, server
@@ -87,33 +78,33 @@ def check_schema_attributes(server):
print("\n" + "=" * 80)
print("1. ПРОВЕРКА СХЕМЫ AD")
print("=" * 80)
-
+
# Атрибуты которые нас интересуют
target_attrs = [
"employeeID",
- "employeeNumber",
+ "employeeNumber",
"employeeType",
"departmentNumber",
"uid",
"uidNumber",
]
-
+
schema_info = server.schema
if not schema_info:
print("⚠️ Не удалось получить информацию о схеме")
return {}
-
+
found_attrs = {}
-
+
for attr_name in target_attrs:
# Ищем в attribute types
attr_def = None
- if hasattr(schema_info, 'attribute_types'):
+ if hasattr(schema_info, "attribute_types"):
attr_def = schema_info.attribute_types.get(attr_name.lower())
if not attr_def:
# Попробуем без lower
attr_def = schema_info.attribute_types.get(attr_name)
-
+
if attr_def:
print(f"\n✅ {attr_name}:")
print(f" OID: {attr_def.oid}")
@@ -123,7 +114,7 @@ def check_schema_attributes(server):
else:
print(f"\n❌ {attr_name}: НЕ НАЙДЕН в схеме")
found_attrs[attr_name] = False
-
+
return found_attrs
@@ -134,9 +125,9 @@ def find_sample_user(conn, base_dn):
search_filter="(&(objectClass=user)(objectCategory=person))",
search_scope=SUBTREE,
attributes=["cn", "mail", "sAMAccountName", "employeeID", "employeeNumber"],
- size_limit=5
+ size_limit=5,
)
-
+
if conn.entries:
return conn.entries[0]
return None
@@ -147,16 +138,18 @@ def check_user_attributes(conn, base_dn):
print("\n" + "=" * 80)
print("2. ПРОВЕРКА АТРИБУТОВ НА ПОЛЬЗОВАТЕЛЯХ")
print("=" * 80)
-
+
user = find_sample_user(conn, base_dn)
if not user:
print("⚠️ Не найдено пользователей для проверки")
return
-
+
print(f"\nТестовый пользователь: {user.entry_dn}")
print(f"CN: {user.cn.value if hasattr(user, 'cn') else 'N/A'}")
- print(f"sAMAccountName: {user.sAMAccountName.value if hasattr(user, 'sAMAccountName') else 'N/A'}")
-
+ print(
+ f"sAMAccountName: {user.sAMAccountName.value if hasattr(user, 'sAMAccountName') else 'N/A'}"
+ )
+
# Проверяем текущие значения
for attr in ["employeeID", "employeeNumber"]:
if hasattr(user, attr) and user[attr].value:
@@ -170,54 +163,50 @@ def check_write_permission(conn, base_dn, cfg):
print("\n" + "=" * 80)
print("3. ПРОВЕРКА ПРАВ ЗАПИСИ")
print("=" * 80)
-
+
user = find_sample_user(conn, base_dn)
if not user:
print("⚠️ Не найдено пользователей для проверки записи")
return
-
+
user_dn = user.entry_dn
test_value = "TEST_12345"
-
+
print(f"\nПопытка записи employeeNumber='{test_value}' в {user_dn}")
print("⚠️ ВНИМАНИЕ: Это тестовая запись!")
-
+
# Спрашиваем подтверждение
answer = input("\nВыполнить тестовую запись? (yes/no): ").strip().lower()
if answer != "yes":
print("Отменено пользователем")
return
-
+
try:
result = conn.modify(
- user_dn,
- {"employeeNumber": [(MODIFY_REPLACE, [test_value])]}
+ user_dn, {"employeeNumber": [(MODIFY_REPLACE, [test_value])]}
)
-
+
if result:
print(f"✅ Запись успешна! employeeNumber = {test_value}")
-
+
# Читаем обратно для проверки
conn.search(
search_base=user_dn,
search_filter="(objectClass=*)",
- attributes=["employeeNumber"]
+ attributes=["employeeNumber"],
)
if conn.entries:
val = conn.entries[0]["employeeNumber"].value
print(f"✅ Проверка чтения: employeeNumber = {val}")
-
+
# Очищаем тестовое значение
clean = input("\nОчистить тестовое значение? (yes/no): ").strip().lower()
if clean == "yes":
- conn.modify(
- user_dn,
- {"employeeNumber": [(MODIFY_REPLACE, [])]}
- )
+ conn.modify(user_dn, {"employeeNumber": [(MODIFY_REPLACE, [])]})
print("✅ Значение очищено")
else:
print(f"❌ Ошибка записи: {conn.result}")
-
+
except Exception as e:
print(f"❌ Исключение при записи: {e}")
@@ -227,25 +216,25 @@ def list_all_id_attributes(conn, base_dn):
print("\n" + "=" * 80)
print("4. ВСЕ ID-ПОДОБНЫЕ АТРИБУТЫ")
print("=" * 80)
-
+
# Ищем пользователя со всеми атрибутами
conn.search(
search_base=base_dn,
search_filter="(&(objectClass=user)(objectCategory=person))",
search_scope=SUBTREE,
attributes=["*"],
- size_limit=1
+ size_limit=1,
)
-
+
if not conn.entries:
print("⚠️ Нет пользователей")
return
-
+
user = conn.entries[0]
print(f"\nПользователь: {user.entry_dn}\n")
-
+
keywords = ["id", "number", "uid", "employee", "guid"]
-
+
for attr_name in sorted(user.entry_attributes):
attr_lower = attr_name.lower()
if any(kw in attr_lower for kw in keywords):
@@ -254,10 +243,11 @@ def list_all_id_attributes(conn, base_dn):
# GUID или бинарные данные
if len(value) == 16:
import uuid
+
try:
guid_str = str(uuid.UUID(bytes_le=value))
value = f"(GUID) {guid_str}"
- except:
+ except (AttributeError, ValueError):
value = f""
else:
value = f""
@@ -268,50 +258,51 @@ def main():
print("=" * 80)
print("ПРОВЕРКА АТРИБУТОВ employeeID/employeeNumber в LDAP")
print("=" * 80)
-
+
cfg = get_ldap_config()
-
- print(f"\nКонфигурация:")
+
+ print("\nКонфигурация:")
print(f" URI: {cfg['uri']}")
print(f" Bind DN: {cfg['bind_dn']}")
print(f" Base DN: {cfg['base_dn']}")
print(f" Users Base: {cfg['users_base']}")
print(f" CA Cert: {cfg['ca_cert'] or 'не задан'}")
-
+
if not cfg["uri"] or not cfg["bind_dn"]:
print("\n❌ LDAP не настроен! Проверьте переменные окружения.")
return 1
-
+
try:
print("\nПодключение к LDAP...")
conn, server = connect_ldap(cfg)
print(f"✅ Подключено к {cfg['uri']}")
-
+
# 1. Проверка схемы
- schema_attrs = check_schema_attributes(server)
-
+ check_schema_attributes(server)
+
# 2. Проверка на пользователях
search_base = cfg["users_base"] or cfg["base_dn"]
check_user_attributes(conn, search_base)
-
+
# 3. Показать все ID-атрибуты
list_all_id_attributes(conn, search_base)
-
+
# 4. Опционально: тест записи
print("\n" + "=" * 80)
answer = input("\nХотите проверить права записи? (yes/no): ").strip().lower()
if answer == "yes":
check_write_permission(conn, search_base, cfg)
-
+
conn.unbind()
print("\n✅ Проверка завершена")
-
+
except Exception as e:
print(f"\n❌ Ошибка: {type(e).__name__}: {e}")
import traceback
+
traceback.print_exc()
return 1
-
+
return 0
diff --git a/backend/scripts/diagnostic/check_ldap_user.py b/backend/scripts/diagnostic/check_ldap_user.py
index c820bf72..f79354ca 100644
--- a/backend/scripts/diagnostic/check_ldap_user.py
+++ b/backend/scripts/diagnostic/check_ldap_user.py
@@ -2,10 +2,13 @@
"""
Скрипт для проверки записи пользователя в LDAP
"""
-import sys
+
import os
-from ldap3 import Server, Connection, ALL, SUBTREE, Tls
import ssl
+import sys
+from urllib.parse import urlparse
+
+from ldap3 import ALL, SUBTREE, Connection, Server, Tls
# Connection settings are read from environment variables.
LDAP_URI = os.getenv("LDAP_SERVER_URI", "ldaps://ldap.example.local:636")
@@ -18,159 +21,177 @@ LDAP_CA_CERT = os.getenv("LDAP_CA_CERT", "corp-ca.pem")
# Email пользователя для поиска
USER_EMAIL = "nadein-igor-vladimirovich@05-04-1999.ru"
+
def main():
if not LDAP_BIND_PASSWORD:
print("LDAP_BIND_PASSWORD environment variable is required")
sys.exit(1)
- print(f"=" * 80)
+ print("=" * 80)
print(f"Подключение к LDAP: {LDAP_URI}")
print(f"Bind DN: {LDAP_BIND_DN}")
- print(f"=" * 80)
-
+ print("=" * 80)
+
try:
# Настройка TLS
- tls = Tls(
- ca_certs_file=LDAP_CA_CERT,
- validate=ssl.CERT_REQUIRED
- )
-
+ tls = Tls(ca_certs_file=LDAP_CA_CERT, validate=ssl.CERT_REQUIRED)
+
# Подключение к серверу
+ parsed_uri = urlparse(LDAP_URI)
server = Server(
- "DCII.robotail.local",
- port=636,
- use_ssl=True,
+ parsed_uri.hostname or LDAP_URI,
+ port=parsed_uri.port or (636 if parsed_uri.scheme == "ldaps" else 389),
+ use_ssl=parsed_uri.scheme == "ldaps",
tls=tls,
- get_info=ALL
+ get_info=ALL,
)
-
+
conn = Connection(
server,
user=LDAP_BIND_DN,
- password=change-me-redacted-secret
- auto_bind=True
+ password=LDAP_BIND_PASSWORD,
+ auto_bind=True,
)
-
- print(f"✅ Успешное подключение к LDAP")
+
+ print("✅ Успешное подключение к LDAP")
print(f"Server info: {server.info}")
- print(f"\n" + "=" * 80)
+ print("\n" + "=" * 80)
print(f"Поиск пользователя с email: {USER_EMAIL}")
- print(f"=" * 80)
-
+ print("=" * 80)
+
# Поиск пользователя по email
search_filter = f"(&(objectClass=user)(mail={USER_EMAIL}))"
-
+
conn.search(
search_base=LDAP_BASE_DN,
search_filter=search_filter,
search_scope=SUBTREE,
- attributes=['*'] # Получаем все атрибуты
+ attributes=["*"], # Получаем все атрибуты
)
-
+
if not conn.entries:
print(f"❌ Пользователь с email {USER_EMAIL} не найден в LDAP")
-
+
# Попробуем поиск по userPrincipalName
- print(f"\nПоиск по userPrincipalName...")
- upn_filter = f"(&(objectClass=user)(userPrincipalName=*{USER_EMAIL.split('@')[0]}*))"
+ print("\nПоиск по userPrincipalName...")
+ upn_filter = (
+ f"(&(objectClass=user)(userPrincipalName=*{USER_EMAIL.split('@')[0]}*))"
+ )
conn.search(
search_base=LDAP_BASE_DN,
search_filter=upn_filter,
search_scope=SUBTREE,
- attributes=['*']
+ attributes=["*"],
)
-
+
if not conn.entries:
- print(f"❌ Не найдено по userPrincipalName")
-
+ print("❌ Не найдено по userPrincipalName")
+
# Поиск по sAMAccountName
- print(f"\nПоиск по sAMAccountName...")
- sam_filter = f"(&(objectClass=user)(sAMAccountName=*nadein*))"
+ print("\nПоиск по sAMAccountName...")
+ sam_filter = "(&(objectClass=user)(sAMAccountName=*nadein*))"
conn.search(
search_base=LDAP_BASE_DN,
search_filter=sam_filter,
search_scope=SUBTREE,
- attributes=['*']
+ attributes=["*"],
)
-
+
if not conn.entries:
- print(f"❌ Не найдено записей с 'nadein' в sAMAccountName")
+ print("❌ Не найдено записей с 'nadein' в sAMAccountName")
else:
print(f"\n✅ Найдено записей: {len(conn.entries)}")
else:
print(f"\n✅ Найдено записей: {len(conn.entries)}")
else:
print(f"\n✅ Найдено записей: {len(conn.entries)}")
-
+
# Выводим все найденные записи
for idx, entry in enumerate(conn.entries, 1):
print(f"\n{'=' * 80}")
print(f"ЗАПИСЬ #{idx}")
print(f"{'=' * 80}")
print(f"DN: {entry.entry_dn}")
- print(f"\nАтрибуты:")
- print(f"-" * 80)
-
+ print("\nАтрибуты:")
+ print("-" * 80)
+
# Основные атрибуты
important_attrs = [
- 'cn', 'givenName', 'sn', 'displayName', 'mail',
- 'userPrincipalName', 'sAMAccountName', 'objectGUID',
- 'telephoneNumber', 'mobile', 'userAccountControl',
- 'whenCreated', 'whenChanged', 'memberOf', 'thumbnailPhoto'
+ "cn",
+ "givenName",
+ "sn",
+ "displayName",
+ "mail",
+ "userPrincipalName",
+ "sAMAccountName",
+ "objectGUID",
+ "telephoneNumber",
+ "mobile",
+ "userAccountControl",
+ "whenCreated",
+ "whenChanged",
+ "memberOf",
+ "thumbnailPhoto",
]
-
+
for attr in important_attrs:
if hasattr(entry, attr):
value = getattr(entry, attr).value
- if attr == 'thumbnailPhoto' and value:
+ if attr == "thumbnailPhoto" and value:
print(f" {attr:25}: ")
- elif attr == 'memberOf' and value:
+ elif attr == "memberOf" and value:
print(f" {attr:25}:")
if isinstance(value, list):
for group in value:
print(f" - {group}")
else:
print(f" - {value}")
- elif attr == 'objectGUID' and value:
+ elif attr == "objectGUID" and value:
# Преобразуем GUID в читаемый формат
guid_bytes = value
if isinstance(guid_bytes, bytes):
- guid_str = '-'.join([
- guid_bytes[3::-1].hex(),
- guid_bytes[5:3:-1].hex(),
- guid_bytes[7:5:-1].hex(),
- guid_bytes[8:10].hex(),
- guid_bytes[10:].hex()
- ])
+ guid_str = "-".join(
+ [
+ guid_bytes[3::-1].hex(),
+ guid_bytes[5:3:-1].hex(),
+ guid_bytes[7:5:-1].hex(),
+ guid_bytes[8:10].hex(),
+ guid_bytes[10:].hex(),
+ ]
+ )
print(f" {attr:25}: {guid_str}")
else:
print(f" {attr:25}: {value}")
else:
print(f" {attr:25}: {value}")
-
+
# Дополнительные атрибуты
- print(f"\n Все остальные атрибуты:")
+ print("\n Все остальные атрибуты:")
for attr_name in entry.entry_attributes:
if attr_name not in important_attrs:
value = entry[attr_name].value
if isinstance(value, bytes):
- print(f" {attr_name:23}: ")
+ print(
+ f" {attr_name:23}: "
+ )
else:
print(f" {attr_name:23}: {value}")
-
+
conn.unbind()
print(f"\n{'=' * 80}")
- print(f"✅ Проверка завершена")
+ print("✅ Проверка завершена")
print(f"{'=' * 80}")
-
+
except Exception as e:
print(f"\n❌ Ошибка: {type(e).__name__}")
print(f" {str(e)}")
import traceback
+
traceback.print_exc()
return 1
-
+
return 0
+
if __name__ == "__main__":
sys.exit(main())
diff --git a/backend/scripts/diagnostic/check_pagination.py b/backend/scripts/diagnostic/check_pagination.py
index 08f97e1e..02e28337 100644
--- a/backend/scripts/diagnostic/check_pagination.py
+++ b/backend/scripts/diagnostic/check_pagination.py
@@ -1,9 +1,10 @@
import os
import sys
+
import django
sys.path.insert(0, os.path.dirname(__file__))
-os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eusrr_backend.settings')
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "eusrr_backend.settings")
django.setup()
from django.contrib.auth import get_user_model
@@ -15,32 +16,32 @@ admin = User.objects.filter(is_superuser=True).first()
client = APIClient()
client.force_authenticate(user=admin)
-print('=' * 70)
-print('PAGINATION CHECK')
-print('=' * 70)
+print("=" * 70)
+print("PAGINATION CHECK")
+print("=" * 70)
endpoints = [
- ('Departments', '/api/v1/departments/'),
- ('Employees', '/api/v1/employees/'),
- ('Documents', '/api/v1/documents/'),
- ('Requests', '/api/v1/requests/'),
+ ("Departments", "/api/v1/departments/"),
+ ("Employees", "/api/v1/employees/"),
+ ("Documents", "/api/v1/documents/"),
+ ("Requests", "/api/v1/requests/"),
]
for name, url in endpoints:
- print(f'\n{name} ({url})')
- print('-' * 70)
+ print(f"\n{name} ({url})")
+ print("-" * 70)
resp = client.get(url)
if resp.status_code == 200:
data = resp.json()
- if isinstance(data, dict) and 'results' in data:
- print(f'OK Paginated')
- print(f'Total: {data.get("count", "?")}')
- print(f'Page size: {len(data.get("results", []))}')
- print(f'Next: {"Yes" if data.get("next") else "No"}')
+ if isinstance(data, dict) and "results" in data:
+ print("OK Paginated")
+ print(f"Total: {data.get('count', '?')}")
+ print(f"Page size: {len(data.get('results', []))}")
+ print(f"Next: {'Yes' if data.get('next') else 'No'}")
elif isinstance(data, list):
- print(f'ERROR Not paginated (list)')
- print(f'Items: {len(data)}')
+ print("ERROR Not paginated (list)")
+ print(f"Items: {len(data)}")
else:
- print(f'ERROR HTTP {resp.status_code}')
+ print(f"ERROR HTTP {resp.status_code}")
-print('\n' + '=' * 70)
+print("\n" + "=" * 70)
diff --git a/backend/scripts/diagnostic/check_reactions.py b/backend/scripts/diagnostic/check_reactions.py
index 19c3b28f..ce869f65 100644
--- a/backend/scripts/diagnostic/check_reactions.py
+++ b/backend/scripts/diagnostic/check_reactions.py
@@ -3,11 +3,12 @@
import os
import sys
+
import django
# Настройка Django
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
-os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eusrr_backend.settings')
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "eusrr_backend.settings")
django.setup()
from communications.models import AvailableReaction
@@ -16,7 +17,7 @@ print("=" * 60)
print("ДОСТУПНЫЕ РЕАКЦИИ В БД")
print("=" * 60)
-reactions = AvailableReaction.objects.filter(is_active=True).order_by('order')
+reactions = AvailableReaction.objects.filter(is_active=True).order_by("order")
if reactions.exists():
print(f"\nВсего активных реакций: {reactions.count()}\n")
diff --git a/backend/scripts/diagnostic/check_templates.py b/backend/scripts/diagnostic/check_templates.py
index 82791dbc..588acad8 100644
--- a/backend/scripts/diagnostic/check_templates.py
+++ b/backend/scripts/diagnostic/check_templates.py
@@ -3,29 +3,31 @@
Быстрая проверка синтаксиса шаблонов без запуска тестов.
Использование: python check_templates.py
"""
+
import os
import sys
from pathlib import Path
# Настройка Django окружения
-os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eusrr_backend.settings')
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "eusrr_backend.settings")
import django
+
django.setup()
+from django.conf import settings
from django.template import loader
from django.template.exceptions import TemplateSyntaxError
-from django.conf import settings
# Цветной вывод для терминала
class Colors:
- GREEN = '\033[92m'
- RED = '\033[91m'
- YELLOW = '\033[93m'
- BLUE = '\033[94m'
- RESET = '\033[0m'
- BOLD = '\033[1m'
+ GREEN = "\033[92m"
+ RED = "\033[91m"
+ YELLOW = "\033[93m"
+ BLUE = "\033[94m"
+ RESET = "\033[0m"
+ BOLD = "\033[1m"
def check_template_syntax(template_path):
@@ -41,130 +43,133 @@ def check_template_syntax(template_path):
def check_forbidden_tags(template_path):
"""Проверка на недопустимые теги в компонентах"""
- templates_dir = Path(settings.BASE_DIR) / 'templates'
+ templates_dir = Path(settings.BASE_DIR) / "templates"
file_path = templates_dir / template_path
-
+
if not file_path.exists():
return []
-
+
forbidden_tags = [
- '{% extends ',
- '{% block content %}',
- '{% block extra_css %}',
- '{% block extra_js %}',
+ "{% extends ",
+ "{% block content %}",
+ "{% block extra_css %}",
+ "{% block extra_js %}",
]
-
- content = file_path.read_text(encoding='utf-8')
+
+ content = file_path.read_text(encoding="utf-8")
found_tags = []
-
+
for tag in forbidden_tags:
if tag in content:
found_tags.append(tag.strip())
-
+
return found_tags
def check_orphan_endblock(template_path):
"""Проверка на одинокие {% endblock %}"""
- templates_dir = Path(settings.BASE_DIR) / 'templates'
+ templates_dir = Path(settings.BASE_DIR) / "templates"
file_path = templates_dir / template_path
-
+
if not file_path.exists():
return []
-
- content = file_path.read_text(encoding='utf-8')
+
+ content = file_path.read_text(encoding="utf-8")
lines_with_endblock = [
- i + 1 for i, line in enumerate(content.splitlines())
- if '{% endblock' in line
+ i + 1 for i, line in enumerate(content.splitlines()) if "{% endblock" in line
]
-
+
return lines_with_endblock
def main():
"""Основная функция проверки"""
-
+
# Список всех рефакторенных компонентов
components = [
# Department components
- 'employees/components/department_header.html',
- 'employees/components/department_info.html',
- 'employees/components/department_team_circle.html',
- 'employees/components/department_sidebar.html',
- 'employees/components/department_modals.html',
- 'employees/components/department_scripts.html',
- 'employees/components/department_styles.html',
-
+ "employees/components/department_header.html",
+ "employees/components/department_info.html",
+ "employees/components/department_team_circle.html",
+ "employees/components/department_sidebar.html",
+ "employees/components/department_modals.html",
+ "employees/components/department_scripts.html",
+ "employees/components/department_styles.html",
# Employee form components
- 'employees/components/employee_form_personal.html',
- 'employees/components/employee_form_contact.html',
- 'employees/components/employee_form_position.html',
- 'employees/components/employee_form_actions.html',
- 'employees/components/employee_form_scripts.html',
- 'employees/_employee_edit.html',
+ "employees/components/employee_form_personal.html",
+ "employees/components/employee_form_contact.html",
+ "employees/components/employee_form_position.html",
+ "employees/components/employee_form_actions.html",
+ "employees/components/employee_form_scripts.html",
+ "employees/_employee_edit.html",
]
-
+
# Main templates
main_templates = [
- 'employees/department_detail.html',
- 'includes/rightbar_calendar.html',
+ "employees/department_detail.html",
+ "includes/rightbar_calendar.html",
]
-
+
print(f"\n{Colors.BOLD}=== Проверка компонентов шаблонов ==={Colors.RESET}\n")
-
+
total_errors = 0
-
+
# Проверка компонентов
print(f"{Colors.BLUE}Компоненты:{Colors.RESET}")
for template in components:
# Проверка синтаксиса
is_valid, error = check_template_syntax(template)
-
+
if is_valid:
print(f" {Colors.GREEN}✓{Colors.RESET} {template}")
-
+
# Дополнительные проверки для валидных шаблонов
forbidden = check_forbidden_tags(template)
if forbidden:
- print(f" {Colors.YELLOW}⚠ Содержит теги layout: "
- f"{', '.join(forbidden)}{Colors.RESET}")
+ print(
+ f" {Colors.YELLOW}⚠ Содержит теги layout: "
+ f"{', '.join(forbidden)}{Colors.RESET}"
+ )
total_errors += 1
-
+
endblocks = check_orphan_endblock(template)
if endblocks:
- print(f" {Colors.YELLOW}⚠ Содержит {{% endblock %}} "
- f"на строках: {endblocks}{Colors.RESET}")
+ print(
+ f" {Colors.YELLOW}⚠ Содержит {{% endblock %}} "
+ f"на строках: {endblocks}{Colors.RESET}"
+ )
total_errors += 1
else:
print(f" {Colors.RED}✗{Colors.RESET} {template}")
print(f" {Colors.RED}Ошибка: {error}{Colors.RESET}")
total_errors += 1
-
+
# Проверка главных шаблонов
print(f"\n{Colors.BLUE}Главные шаблоны:{Colors.RESET}")
for template in main_templates:
is_valid, error = check_template_syntax(template)
-
+
if is_valid:
print(f" {Colors.GREEN}✓{Colors.RESET} {template}")
else:
print(f" {Colors.RED}✗{Colors.RESET} {template}")
print(f" {Colors.RED}Ошибка: {error}{Colors.RESET}")
total_errors += 1
-
+
# Итоги
print(f"\n{Colors.BOLD}=== Итоги ==={Colors.RESET}")
print(f"Проверено шаблонов: {len(components) + len(main_templates)}")
-
+
if total_errors == 0:
- print(f"{Colors.GREEN}{Colors.BOLD}✓ Все шаблоны корректны!"
- f"{Colors.RESET}")
+ print(f"{Colors.GREEN}{Colors.BOLD}✓ Все шаблоны корректны!{Colors.RESET}")
sys.exit(0)
else:
- print(f"{Colors.RED}{Colors.BOLD}✗ Обнаружено ошибок: "
- f"{total_errors}{Colors.RESET}")
+ print(
+ f"{Colors.RED}{Colors.BOLD}✗ Обнаружено ошибок: "
+ f"{total_errors}{Colors.RESET}"
+ )
sys.exit(1)
-if __name__ == '__main__':
+if __name__ == "__main__":
main()
diff --git a/backend/scripts/diagnostic/diagnose_page.py b/backend/scripts/diagnostic/diagnose_page.py
index 9f6d62ae..c4f08705 100644
--- a/backend/scripts/diagnostic/diagnose_page.py
+++ b/backend/scripts/diagnostic/diagnose_page.py
@@ -3,18 +3,20 @@
Диагностика проблем с рендерингом страниц.
Проверяет, правильно ли подключаются компоненты.
"""
+
import os
-import sys
from pathlib import Path
-os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eusrr_backend.settings')
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "eusrr_backend.settings")
import django
+
django.setup()
-from django.test import RequestFactory
from django.contrib.auth import get_user_model
from django.template import loader
+from django.test import RequestFactory
+
from employees.models import Department
User = get_user_model()
@@ -23,11 +25,11 @@ User = get_user_model()
def diagnose_department_page():
"""Диагностика страницы отдела"""
print("\n=== Диагностика страницы отдела ===\n")
-
+
# Создаём mock request
factory = RequestFactory()
- request = factory.get('/employees/departments/1/')
-
+ factory.get("/employees/departments/1/")
+
# Пробуем получить отдел
try:
dept = Department.objects.first()
@@ -38,26 +40,26 @@ def diagnose_department_page():
except Exception as e:
print(f"❌ Ошибка получения отдела: {e}")
return
-
+
# Пробуем загрузить шаблон
try:
- template = loader.get_template('employees/department_detail.html')
+ loader.get_template("employees/department_detail.html")
print("✓ Шаблон department_detail.html загружен")
except Exception as e:
print(f"❌ Ошибка загрузки шаблона: {e}")
return
-
+
# Проверяем компоненты
components = [
- 'employees/components/department_header.html',
- 'employees/components/department_info.html',
- 'employees/components/department_team_circle.html',
- 'employees/components/department_sidebar.html',
- 'employees/components/department_modals.html',
- 'employees/components/department_styles.html',
- 'employees/components/department_scripts.html',
+ "employees/components/department_header.html",
+ "employees/components/department_info.html",
+ "employees/components/department_team_circle.html",
+ "employees/components/department_sidebar.html",
+ "employees/components/department_modals.html",
+ "employees/components/department_styles.html",
+ "employees/components/department_scripts.html",
]
-
+
print("\nКомпоненты:")
for comp in components:
try:
@@ -65,32 +67,33 @@ def diagnose_department_page():
print(f" ✓ {comp}")
except Exception as e:
print(f" ❌ {comp}: {e}")
-
+
# Проверяем календарь
print("\nКомпоненты календаря:")
calendar_components = [
- 'includes/calendar/calendar_styles.html',
- 'includes/calendar/calendar_widget.html',
- 'includes/calendar/calendar_events_list.html',
- 'includes/calendar/calendar_modals.html',
- 'includes/calendar/calendar_scripts.html',
+ "includes/calendar/calendar_styles.html",
+ "includes/calendar/calendar_widget.html",
+ "includes/calendar/calendar_events_list.html",
+ "includes/calendar/calendar_modals.html",
+ "includes/calendar/calendar_scripts.html",
]
-
+
for comp in calendar_components:
try:
loader.get_template(comp)
print(f" ✓ {comp}")
except Exception as e:
print(f" ❌ {comp}: {e}")
-
+
print("\n=== Проверка размеров компонентов ===\n")
from django.conf import settings
- templates_dir = Path(settings.BASE_DIR) / 'templates'
-
+
+ templates_dir = Path(settings.BASE_DIR) / "templates"
+
for comp in components:
file_path = templates_dir / comp
if file_path.exists():
- lines = len(file_path.read_text(encoding='utf-8').splitlines())
+ lines = len(file_path.read_text(encoding="utf-8").splitlines())
size = file_path.stat().st_size
print(f" {comp}: {lines} строк, {size} байт")
else:
@@ -100,34 +103,35 @@ def diagnose_department_page():
def check_base_template():
"""Проверка base.html"""
print("\n=== Проверка base.html ===\n")
-
+
try:
- template = loader.get_template('base.html')
+ loader.get_template("base.html")
print("✓ base.html загружен")
-
+
# Проверяем, что в нём есть блоки
from django.conf import settings
- base_path = Path(settings.BASE_DIR) / 'templates' / 'base.html'
- content = base_path.read_text(encoding='utf-8')
-
+
+ base_path = Path(settings.BASE_DIR) / "templates" / "base.html"
+ content = base_path.read_text(encoding="utf-8")
+
checks = [
- ('{% block extra_css %}', 'Блок extra_css'),
- ('{% block extra_js %}', 'Блок extra_js'),
- ('{% include "includes/rightbar_calendar.html" %}', 'Include календаря'),
- ('bootstrap.bundle.min.js', 'Bootstrap JS'),
- ('fullcalendar', 'FullCalendar JS'),
+ ("{% block extra_css %}", "Блок extra_css"),
+ ("{% block extra_js %}", "Блок extra_js"),
+ ('{% include "includes/rightbar_calendar.html" %}', "Include календаря"),
+ ("bootstrap.bundle.min.js", "Bootstrap JS"),
+ ("fullcalendar", "FullCalendar JS"),
]
-
+
for pattern, description in checks:
if pattern in content:
print(f" ✓ {description}")
else:
print(f" ❌ {description} НЕ НАЙДЕН!")
-
+
except Exception as e:
print(f"❌ Ошибка: {e}")
-if __name__ == '__main__':
+if __name__ == "__main__":
diagnose_department_page()
check_base_template()
diff --git a/backend/scripts/diagnostic/diagnostic_summary.py b/backend/scripts/diagnostic/diagnostic_summary.py
index 8d29c99b..461b2c5d 100644
--- a/backend/scripts/diagnostic/diagnostic_summary.py
+++ b/backend/scripts/diagnostic/diagnostic_summary.py
@@ -1,6 +1,7 @@
"""
Финальная диагностика - проверка того, что попадает в HTML
"""
+
print("""
=== ИТОГИ ИСПРАВЛЕНИЙ ===
diff --git a/backend/scripts/fix_ldap_tests.py b/backend/scripts/fix_ldap_tests.py
index 2a402d5b..f2193a51 100644
--- a/backend/scripts/fix_ldap_tests.py
+++ b/backend/scripts/fix_ldap_tests.py
@@ -7,56 +7,56 @@
- Создают объекты через Position.objects.create()
- Не имеют уже ensure_ldap_disabled или ensure_ldap_enabled
"""
+
import re
-import sys
from pathlib import Path
def fix_test_file(file_path: Path) -> tuple[bool, int]:
"""
Исправляет один тестовый файл.
-
+
Returns:
(changed, count) - был ли изменен файл и количество исправленных функций
"""
- content = file_path.read_text(encoding='utf-8')
+ content = file_path.read_text(encoding="utf-8")
original_content = content
-
+
# Паттерн для поиска тестовых функций
# Ищем def test_...(...): где нет ensure_ldap в параметрах
- pattern = r'(def test_\w+\([^)]*\)):'
-
+
fixed_count = 0
- lines = content.split('\n')
+ lines = content.split("\n")
new_lines = []
-
+
i = 0
while i < len(lines):
line = lines[i]
-
+
# Проверяем, является ли это определением тестовой функции
- match = re.match(r'^def (test_\w+)\((.*)\):', line)
-
+ match = re.match(r"^def (test_\w+)\((.*)\):", line)
+
if match:
func_name = match.group(1)
params = match.group(2)
-
+
# Проверяем, нет ли уже ensure_ldap в параметрах
- if 'ensure_ldap' not in params:
+ if "ensure_ldap" not in params:
# Проверяем следующие 50 строк на наличие Department.objects.create или Position.objects.create
- check_lines = '\n'.join(lines[i:min(i+50, len(lines))])
-
- if ('Department.objects.create' in check_lines or
- 'Position.objects.create' in check_lines or
- 'DepartmentRole.objects.create' in check_lines or
- 'make_department' in check_lines):
-
+ check_lines = "\n".join(lines[i : min(i + 50, len(lines))])
+
+ if (
+ "Department.objects.create" in check_lines
+ or "Position.objects.create" in check_lines
+ or "DepartmentRole.objects.create" in check_lines
+ or "make_department" in check_lines
+ ):
# Добавляем ensure_ldap_disabled
if params.strip():
new_params = f"{params}, ensure_ldap_disabled"
else:
new_params = "ensure_ldap_disabled"
-
+
new_line = line.replace(f"({params})", f"({new_params})")
new_lines.append(new_line)
fixed_count += 1
@@ -67,60 +67,60 @@ def fix_test_file(file_path: Path) -> tuple[bool, int]:
new_lines.append(line)
else:
new_lines.append(line)
-
+
i += 1
-
- new_content = '\n'.join(new_lines)
-
+
+ new_content = "\n".join(new_lines)
+
if new_content != original_content:
- file_path.write_text(new_content, encoding='utf-8')
+ file_path.write_text(new_content, encoding="utf-8")
return True, fixed_count
-
+
return False, 0
def main():
"""Главная функция."""
- test_dir = Path(__file__).parent.parent / 'tests' / 'api' / 'v1' / 'employees'
-
+ test_dir = Path(__file__).parent.parent / "tests" / "api" / "v1" / "employees"
+
# Список файлов для исправления
files_to_fix = [
- 'test_department_membership_separation.py',
- 'test_department_roles.py',
- 'test_department_roles_extra.py',
- 'test_departments.py',
- 'test_employee_actions.py',
- 'test_employees.py',
- 'test_positions.py',
- 'test_role_assignment.py',
- 'test_skills.py',
+ "test_department_membership_separation.py",
+ "test_department_roles.py",
+ "test_department_roles_extra.py",
+ "test_departments.py",
+ "test_employee_actions.py",
+ "test_employees.py",
+ "test_positions.py",
+ "test_role_assignment.py",
+ "test_skills.py",
]
-
+
total_fixed = 0
total_files = 0
-
+
for filename in files_to_fix:
file_path = test_dir / filename
-
+
if not file_path.exists():
print(f"⚠ Файл не найден: {filename}")
continue
-
+
print(f"\nОбрабатываем {filename}...")
changed, count = fix_test_file(file_path)
-
+
if changed:
print(f" ✅ {filename} исправлен ({count} функций)")
total_fixed += count
total_files += 1
else:
print(f" ⊘ {filename} не требует изменений")
-
- print(f"\n{'='*60}")
+
+ print(f"\n{'=' * 60}")
print(f"✅ Обработано файлов: {total_files}")
print(f"✅ Исправлено функций: {total_fixed}")
- print(f"{'='*60}\n")
+ print(f"{'=' * 60}\n")
-if __name__ == '__main__':
+if __name__ == "__main__":
main()
diff --git a/backend/scripts/test_denormalized.py b/backend/scripts/test_denormalized.py
index 35aa262c..4056787a 100644
--- a/backend/scripts/test_denormalized.py
+++ b/backend/scripts/test_denormalized.py
@@ -2,18 +2,21 @@
"""
Тест денормализации unread_count
"""
+
import os
import sys
-import django
from pathlib import Path
+import django
+
sys.path.insert(0, str(Path(__file__).parent.parent))
-os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eusrr_backend.settings')
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "eusrr_backend.settings")
django.setup()
+from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import connection, reset_queries
-from django.conf import settings
+
from communications.models import Chat, ChatReadState, Message
# Включаем отслеживание SQL
@@ -21,102 +24,112 @@ settings.DEBUG = True
Employee = get_user_model()
+
def test_denormalized_unread():
"""Тестируем оптимизированный подход с денормализацией"""
-
+
user = Employee.objects.first()
if not user:
print("❌ Нет пользователей в базе")
return
-
+
print(f"📊 Тестируем денормализацию для пользователя: {user}")
print("=" * 80)
-
+
# Сбрасываем счетчик запросов
reset_queries()
-
+
# НОВЫЙ ОПТИМИЗИРОВАННЫЙ KOD из ChatViewSet.get_queryset()
- from django.db.models import Q, Prefetch
+ from django.db.models import Prefetch, Q
+
from communications.models import ChatUserSettings
-
- chats = Chat.objects.filter(
- Q(participants=user) |
- Q(department__in=user.departments_links.filter(
- is_active=True
- ).values('department')) |
- Q(include_all_employees=True)
- ).select_related(
- 'department', 'created_by'
- ).prefetch_related(
- 'participants',
- Prefetch(
- 'user_settings',
- queryset=ChatUserSettings.objects.filter(user=user),
- to_attr='my_settings'
- ),
- Prefetch(
- 'read_states',
- queryset=ChatReadState.objects.filter(user=user),
- to_attr='my_read_state'
+
+ chats = (
+ Chat.objects.filter(
+ Q(participants=user)
+ | Q(
+ department__in=user.departments_links.filter(is_active=True).values(
+ "department"
+ )
+ )
+ | Q(include_all_employees=True)
)
- ).distinct()[:10]
-
+ .select_related("department", "created_by")
+ .prefetch_related(
+ "participants",
+ Prefetch(
+ "user_settings",
+ queryset=ChatUserSettings.objects.filter(user=user),
+ to_attr="my_settings",
+ ),
+ Prefetch(
+ "read_states",
+ queryset=ChatReadState.objects.filter(user=user),
+ to_attr="my_read_state",
+ ),
+ )
+ .distinct()[:10]
+ )
+
# Принудительно выполняем запрос
chat_list = list(chats)
-
+
print(f"\n✅ Загружено чатов: {len(chat_list)}")
print(f"📈 Количество SQL запросов: {len(connection.queries)}")
print("\n" + "=" * 80)
-
+
# Показываем unread_count для каждого чата
print("\n📬 Счетчики непрочитанных:")
for chat in chat_list:
unread = 0
- if hasattr(chat, 'my_read_state') and chat.my_read_state:
+ if hasattr(chat, "my_read_state") and chat.my_read_state:
unread = chat.my_read_state[0].unread_count
-
+
print(f" Chat #{chat.id} ({chat.type}): {unread} непрочитанных")
-
+
# Показываем SQL запросы
print("\n" + "=" * 80)
print("\n🔍 SQL Запросы:")
for i, query in enumerate(connection.queries, 1):
print(f"\n[{i}] {query['sql'][:200]}...")
print(f" Время: {query['time']}s")
-
+
print("\n" + "=" * 80)
print("\n✨ РЕЗУЛЬТАТ:")
- print(f" - Один основной JOIN запрос вместо 100+ подзапросов")
- print(f" - unread_count читается из денормализованного поля")
- print(f" - Общее время выполнения: {sum(float(q['time']) for q in connection.queries):.4f}s")
-
+ print(" - Один основной JOIN запрос вместо 100+ подзапросов")
+ print(" - unread_count читается из денормализованного поля")
+ print(
+ f" - Общее время выполнения: {sum(float(q['time']) for q in connection.queries):.4f}s"
+ )
+
# Тест создания нового сообщения
print("\n" + "=" * 80)
print("\n📝 Тест: создание нового сообщения...")
-
+
test_chat = chat_list[0] if chat_list else None
if test_chat:
reset_queries()
-
+
# Создаем сообщение от другого пользователя
other_user = Employee.objects.exclude(id=user.id).first()
if other_user:
msg = Message.objects.create(
chat=test_chat,
author=other_user,
- content="Тестовое сообщение для проверки инкремента"
+ content="Тестовое сообщение для проверки инкремента",
)
-
+
print(f"✅ Сообщение создано: #{msg.id}")
print(f"📊 SQL запросов: {len(connection.queries)}")
-
+
# Проверяем что счетчик увеличился
read_state = ChatReadState.objects.get(chat=test_chat, user=user)
print(f"📈 Новый unread_count: {read_state.unread_count}")
-
+
# Удаляем тестовое сообщение
msg.delete()
print("🗑️ Тестовое сообщение удалено")
-if __name__ == '__main__':
+
+if __name__ == "__main__":
test_denormalized_unread()
diff --git a/backend/scripts/test_ldap_connection.py b/backend/scripts/test_ldap_connection.py
index 0cc77602..53f9eb00 100755
--- a/backend/scripts/test_ldap_connection.py
+++ b/backend/scripts/test_ldap_connection.py
@@ -4,7 +4,7 @@
Использование:
python scripts/test_ldap_connection.py
-
+
Требования:
- Запущенный LDAP сервер через docker-compose.ldap.yml
- Настроенные переменные окружения в .env
@@ -19,13 +19,14 @@ backend_dir = Path(__file__).parent.parent
sys.path.insert(0, str(backend_dir))
# Настроить Django
-os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eusrr_backend.settings')
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "eusrr_backend.settings")
import django
+
django.setup()
from django.conf import settings
-from ldap3 import Server, Connection, ALL, SUBTREE, BASE
+from ldap3 import ALL, BASE, SUBTREE, Connection, Server
from ldap3.core.exceptions import LDAPException
@@ -54,47 +55,47 @@ def print_info(message):
def test_connection_settings():
"""Проверка настроек LDAP из конфигурации Django."""
print_header("1. Проверка настроек LDAP")
-
+
required_settings = [
- 'LDAP_ENABLED',
- 'LDAP_URI',
- 'LDAP_BIND_DN',
- 'LDAP_BIND_PASSWORD',
- 'LDAP_BASE_DN',
+ "LDAP_ENABLED",
+ "LDAP_URI",
+ "LDAP_BIND_DN",
+ "LDAP_BIND_PASSWORD",
+ "LDAP_BASE_DN",
]
-
+
all_ok = True
for setting in required_settings:
value = getattr(settings, setting, None)
if value:
# Скрыть пароль
- display_value = '***' if 'PASSWORD' in setting else value
+ display_value = "***" if "PASSWORD" in setting else value
print_success(f"{setting} = {display_value}")
else:
print_error(f"{setting} не настроен!")
all_ok = False
-
+
return all_ok
def test_basic_connection():
"""Проверка базового подключения к LDAP серверу."""
print_header("2. Проверка базового подключения")
-
+
try:
- ldap_host = getattr(settings, 'LDAP_URI', 'ldap://localhost:389')
+ ldap_host = getattr(settings, "LDAP_URI", "ldap://localhost:389")
print_info(f"Подключение к {ldap_host}...")
-
+
server = Server(ldap_host, get_info=ALL)
conn = Connection(server, auto_bind=True)
-
+
print_success("Подключение к LDAP серверу установлено")
print_info(f"Сервер: {server.info.other.get('vendor_name', ['Unknown'])[0]}")
print_info(f"Версия: {server.info.other.get('vendor_version', ['Unknown'])[0]}")
-
+
conn.unbind()
return True
-
+
except LDAPException as e:
print_error(f"Ошибка подключения: {e}")
return False
@@ -103,27 +104,27 @@ def test_basic_connection():
def test_bind_authentication():
"""Проверка аутентификации с Bind DN."""
print_header("3. Проверка аутентификации (Bind)")
-
+
try:
- ldap_host = getattr(settings, 'LDAP_URI', 'ldap://localhost:389')
- bind_dn = getattr(settings, 'LDAP_BIND_DN', '')
- bind_password = getattr(settings, 'LDAP_BIND_PASSWORD', '')
-
+ ldap_host = getattr(settings, "LDAP_URI", "ldap://localhost:389")
+ bind_dn = getattr(settings, "LDAP_BIND_DN", "")
+ bind_password = getattr(settings, "LDAP_BIND_PASSWORD", "")
+
if not bind_dn or not bind_password:
print_error("LDAP_BIND_DN или LDAP_BIND_PASSWORD не настроены")
return False
-
+
print_info(f"Аутентификация как: {bind_dn}")
-
+
server = Server(ldap_host, get_info=ALL)
conn = Connection(server, user=bind_dn, password=bind_password, auto_bind=True)
-
+
print_success("Аутентификация успешна")
print_info(f"Связанный DN: {conn.extend.standard.who_am_i()}")
-
+
conn.unbind()
return True
-
+
except LDAPException as e:
print_error(f"Ошибка аутентификации: {e}")
return False
@@ -132,25 +133,25 @@ def test_bind_authentication():
def test_search_base_dn():
"""Проверка доступа к базовому DN."""
print_header("4. Проверка доступа к базовому DN")
-
+
try:
- ldap_host = getattr(settings, 'LDAP_URI', 'ldap://localhost:389')
- bind_dn = getattr(settings, 'LDAP_BIND_DN', '')
- bind_password = getattr(settings, 'LDAP_BIND_PASSWORD', '')
- base_dn = getattr(settings, 'LDAP_BASE_DN', '')
-
+ ldap_host = getattr(settings, "LDAP_URI", "ldap://localhost:389")
+ bind_dn = getattr(settings, "LDAP_BIND_DN", "")
+ bind_password = getattr(settings, "LDAP_BIND_PASSWORD", "")
+ base_dn = getattr(settings, "LDAP_BASE_DN", "")
+
server = Server(ldap_host)
conn = Connection(server, user=bind_dn, password=bind_password, auto_bind=True)
-
+
print_info(f"Поиск в базовом DN: {base_dn}")
-
+
success = conn.search(
search_base=base_dn,
- search_filter='(objectClass=*)',
+ search_filter="(objectClass=*)",
search_scope=BASE,
- attributes=['*']
+ attributes=["*"],
)
-
+
if success:
print_success(f"Базовый DN найден: {base_dn}")
if conn.entries:
@@ -159,10 +160,10 @@ def test_search_base_dn():
else:
print_error(f"Базовый DN не найден: {base_dn}")
return False
-
+
conn.unbind()
return True
-
+
except LDAPException as e:
print_error(f"Ошибка поиска: {e}")
return False
@@ -171,40 +172,46 @@ def test_search_base_dn():
def test_search_users():
"""Поиск пользователей в ou=Users."""
print_header("5. Проверка поиска пользователей")
-
+
try:
- ldap_host = getattr(settings, 'LDAP_URI', 'ldap://localhost:389')
- bind_dn = getattr(settings, 'LDAP_BIND_DN', '')
- bind_password = getattr(settings, 'LDAP_BIND_PASSWORD', '')
- users_base = getattr(settings, 'LDAP_USERS_BASE', '')
-
+ ldap_host = getattr(settings, "LDAP_URI", "ldap://localhost:389")
+ bind_dn = getattr(settings, "LDAP_BIND_DN", "")
+ bind_password = getattr(settings, "LDAP_BIND_PASSWORD", "")
+ users_base = getattr(settings, "LDAP_USERS_BASE", "")
+
server = Server(ldap_host)
conn = Connection(server, user=bind_dn, password=bind_password, auto_bind=True)
-
+
print_info(f"Поиск пользователей в: {users_base}")
-
+
success = conn.search(
search_base=users_base,
- search_filter='(&(objectCategory=person)(objectClass=user))',
+ search_filter="(&(objectCategory=person)(objectClass=user))",
search_scope=SUBTREE,
- attributes=['cn', 'mail', 'telephoneNumber', 'employeeNumber', 'sAMAccountName']
+ attributes=[
+ "cn",
+ "mail",
+ "telephoneNumber",
+ "employeeNumber",
+ "sAMAccountName",
+ ],
)
-
+
if success:
print_success(f"Найдено пользователей: {len(conn.entries)}")
for entry in conn.entries[:5]: # Показать первых 5
- cn = entry.cn.value if hasattr(entry, 'cn') else 'N/A'
- mail = entry.mail.value if hasattr(entry, 'mail') else 'N/A'
+ cn = entry.cn.value if hasattr(entry, "cn") else "N/A"
+ mail = entry.mail.value if hasattr(entry, "mail") else "N/A"
print_info(f" - {cn} ({mail})")
if len(conn.entries) > 5:
print_info(f" ... и еще {len(conn.entries) - 5}")
else:
print_error("Пользователи не найдены")
return False
-
+
conn.unbind()
return True
-
+
except LDAPException as e:
print_error(f"Ошибка поиска пользователей: {e}")
return False
@@ -213,39 +220,41 @@ def test_search_users():
def test_search_groups():
"""Поиск групп в ou=Groups."""
print_header("6. Проверка поиска групп")
-
+
try:
- ldap_host = getattr(settings, 'LDAP_URI', 'ldap://localhost:389')
- bind_dn = getattr(settings, 'LDAP_BIND_DN', '')
- bind_password = getattr(settings, 'LDAP_BIND_PASSWORD', '')
- groups_base = getattr(settings, 'LDAP_GROUPS_BASE', '')
-
+ ldap_host = getattr(settings, "LDAP_URI", "ldap://localhost:389")
+ bind_dn = getattr(settings, "LDAP_BIND_DN", "")
+ bind_password = getattr(settings, "LDAP_BIND_PASSWORD", "")
+ groups_base = getattr(settings, "LDAP_GROUPS_BASE", "")
+
server = Server(ldap_host)
conn = Connection(server, user=bind_dn, password=bind_password, auto_bind=True)
-
+
print_info(f"Поиск групп в: {groups_base}")
-
+
success = conn.search(
search_base=groups_base,
- search_filter='(objectClass=groupOfNames)',
+ search_filter="(objectClass=groupOfNames)",
search_scope=SUBTREE,
- attributes=['cn', 'description', 'member']
+ attributes=["cn", "description", "member"],
)
-
+
if success:
print_success(f"Найдено групп: {len(conn.entries)}")
for entry in conn.entries:
- cn = entry.cn.value if hasattr(entry, 'cn') else 'N/A'
- desc = entry.description.value if hasattr(entry, 'description') else 'N/A'
- members_count = len(entry.member) if hasattr(entry, 'member') else 0
+ cn = entry.cn.value if hasattr(entry, "cn") else "N/A"
+ desc = (
+ entry.description.value if hasattr(entry, "description") else "N/A"
+ )
+ members_count = len(entry.member) if hasattr(entry, "member") else 0
print_info(f" - {cn}: {desc} ({members_count} members)")
else:
print_error("Группы не найдены")
return False
-
+
conn.unbind()
return True
-
+
except LDAPException as e:
print_error(f"Ошибка поиска групп: {e}")
return False
@@ -254,28 +263,29 @@ def test_search_groups():
def test_django_ldap_integration():
"""Проверка интеграции Django с LDAP через employees.ldap."""
print_header("7. Проверка интеграции Django LDAP")
-
+
try:
print_info("Импорт employees.ldap модулей...")
from employees.ldap.infrastructure.connections import _ldap
-
+
print_success("Модули импортированы успешно")
-
+
print_info("Тестирование соединения через _ldap()...")
with _ldap() as conn:
- base_dn = getattr(settings, 'LDAP_BASE_DN', '')
- success = conn.search(base_dn, '(objectClass=*)', search_scope=BASE)
-
+ base_dn = getattr(settings, "LDAP_BASE_DN", "")
+ success = conn.search(base_dn, "(objectClass=*)", search_scope=BASE)
+
if success:
print_success("Соединение через Django LDAP wrapper работает")
return True
else:
print_error("Поиск через Django LDAP wrapper не удался")
return False
-
+
except Exception as e:
print_error(f"Ошибка интеграции Django LDAP: {e}")
import traceback
+
traceback.print_exc()
return False
@@ -283,7 +293,7 @@ def test_django_ldap_integration():
def test_create_test_user():
"""Тест создания тестового пользователя через ORM."""
print_header("8. Тест создания пользователя (ОПЦИОНАЛЬНО)")
-
+
print_info("Этот тест создаст тестового пользователя в LDAP")
print_info("Пропускаем автоматическое создание - используйте Django shell")
print_info("Пример:")
@@ -292,7 +302,7 @@ def test_create_test_user():
print_info(" user.cn = 'Test User'")
print_info(" user.sn = 'User'")
print_info(" user.save()")
-
+
return True
@@ -301,7 +311,7 @@ def main():
print("\n" + "🔬" * 35)
print(" ТЕСТИРОВАНИЕ LDAP ПОДКЛЮЧЕНИЯ - EUSRR")
print("🔬" * 35)
-
+
tests = [
("Настройки LDAP", test_connection_settings),
("Базовое подключение", test_basic_connection),
@@ -312,7 +322,7 @@ def main():
("Интеграция Django LDAP", test_django_ldap_integration),
("Создание пользователя", test_create_test_user),
]
-
+
results = []
for name, test_func in tests:
try:
@@ -324,23 +334,24 @@ def main():
except Exception as e:
print_error(f"Неожиданная ошибка в тесте '{name}': {e}")
import traceback
+
traceback.print_exc()
results.append((name, False))
-
+
# Итоговый отчет
print_header("ИТОГОВЫЙ ОТЧЕТ")
-
+
passed = sum(1 for _, result in results if result)
total = len(results)
-
+
for name, result in results:
status = "✅ PASS" if result else "❌ FAIL"
print(f"{status} {name}")
-
+
print("\n" + "-" * 70)
print(f"Результат: {passed}/{total} тестов пройдено")
print("-" * 70)
-
+
if passed == total:
print("\n🎉 ВСЕ ТЕСТЫ ПРОЙДЕНЫ! LDAP интеграция работает корректно.")
return 0
@@ -349,5 +360,5 @@ def main():
return 1
-if __name__ == '__main__':
+if __name__ == "__main__":
sys.exit(main())
diff --git a/backend/scripts/test_n_plus_one.py b/backend/scripts/test_n_plus_one.py
index 78dc45cd..ce9fa021 100644
--- a/backend/scripts/test_n_plus_one.py
+++ b/backend/scripts/test_n_plus_one.py
@@ -2,92 +2,104 @@
"""
Скрипт для демонстрации N+1 проблемы в текущей реализации
"""
+
import os
import sys
-import django
from pathlib import Path
+import django
+
# Setup Django
sys.path.insert(0, str(Path(__file__).parent.parent))
-os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eusrr_backend.settings')
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "eusrr_backend.settings")
django.setup()
+from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import connection, reset_queries
-from django.db.models import Count, OuterRef, Subquery, Q
+from django.db.models import Count, OuterRef, Q, Subquery
from django.db.models.functions import Coalesce
-from communications.models import Chat, ChatReadState, ChatUserSettings, Message
-from django.conf import settings
+
+from communications.models import Chat, ChatReadState, Message
# Включаем отслеживание SQL
settings.DEBUG = True
Employee = get_user_model()
+
def test_current_implementation():
"""Текущая реализация с подзапросами"""
-
+
user = Employee.objects.first()
if not user:
print("Нет пользователей в базе")
return
-
+
print(f"Тестируем для пользователя: {user}")
print("=" * 80)
-
+
# Сбрасываем счетчик запросов
reset_queries()
-
+
# Текущая реализация из ChatViewSet.get_queryset()
read_state_subq = ChatReadState.objects.filter(
- chat=OuterRef('pk'),
- user=user
- ).values('last_read_message_id')[:1]
+ chat=OuterRef("pk"), user=user
+ ).values("last_read_message_id")[:1]
- unread_count_subq = Message.objects.filter(
- chat=OuterRef('pk'),
- id__gt=Coalesce(Subquery(read_state_subq), 0),
- is_deleted=False
- ).exclude(author=user).values('chat').annotate(
- count=Count('id')
- ).values('count')
+ unread_count_subq = (
+ Message.objects.filter(
+ chat=OuterRef("pk"),
+ id__gt=Coalesce(Subquery(read_state_subq), 0),
+ is_deleted=False,
+ )
+ .exclude(author=user)
+ .values("chat")
+ .annotate(count=Count("id"))
+ .values("count")
+ )
+
+ chats = (
+ Chat.objects.filter(
+ Q(participants=user)
+ | Q(
+ department__in=user.departments_links.filter(is_active=True).values(
+ "department"
+ )
+ )
+ | Q(include_all_employees=True)
+ )
+ .annotate(unread_count=unread_count_subq)
+ .distinct()[:10]
+ ) # Берем первые 10 чатов
- chats = Chat.objects.filter(
- Q(participants=user) |
- Q(department__in=user.departments_links.filter(
- is_active=True
- ).values('department')) |
- Q(include_all_employees=True)
- ).annotate(
- unread_count=unread_count_subq
- ).distinct()[:10] # Берем первые 10 чатов
-
# Принудительно выполняем запрос
chat_list = list(chats)
-
+
print(f"\nЗагружено чатов: {len(chat_list)}")
print(f"Количество SQL запросов: {len(connection.queries)}")
print("\n" + "=" * 80)
-
+
# Показываем первые 3 запроса
for i, query in enumerate(connection.queries[:3], 1):
print(f"\nЗапрос #{i}:")
- print(query['sql'][:500])
- if len(query['sql']) > 500:
+ print(query["sql"][:500])
+ if len(query["sql"]) > 500:
print("... (обрезано)")
print(f"Время: {query['time']}s")
-
+
if len(connection.queries) > 3:
print(f"\n... еще {len(connection.queries) - 3} запросов")
-
+
print("\n" + "=" * 80)
print("\nПРОБЛЕМА:")
print("Несмотря на то, что это ОДИН SQL запрос, внутри него есть")
print("ПОДЗАПРОСЫ (subqueries) для каждого чата в SELECT clause.")
print("PostgreSQL выполняет их для КАЖДОЙ строки результата.")
print("\nЭто не классический N+1, но похожая проблема производительности.")
-
+
return chat_list
-if __name__ == '__main__':
+
+if __name__ == "__main__":
test_current_implementation()
diff --git a/backend/scripts/update_test_helpers.py b/backend/scripts/update_test_helpers.py
index 69e210f7..5b333acc 100644
--- a/backend/scripts/update_test_helpers.py
+++ b/backend/scripts/update_test_helpers.py
@@ -2,6 +2,7 @@
"""
Скрипт для обновления тестов - заменяет локальные helper функции на импорты из test_helpers.
"""
+
import re
import sys
from pathlib import Path
@@ -34,8 +35,8 @@ def add_import_if_missing(content: str) -> str:
return content
# Находим последний import
- import_pattern = r'^(import |from )'
- lines = content.split('\n')
+ import_pattern = r"^(import |from )"
+ lines = content.split("\n")
last_import_idx = 0
for i, line in enumerate(lines):
@@ -45,7 +46,7 @@ def add_import_if_missing(content: str) -> str:
# Вставляем после последнего импорта
lines.insert(last_import_idx + 1, import_line)
- return '\n'.join(lines)
+ return "\n".join(lines)
def remove_local_functions(content: str) -> str:
@@ -54,23 +55,23 @@ def remove_local_functions(content: str) -> str:
# Паттерны для удаления
patterns = [
# make_user fixture
- r'@pytest\.fixture\s+def make_user\([^)]*\)[^:]*:.*?(?=\n(?:def |class |@pytest|$))',
+ r"@pytest\.fixture\s+def make_user\([^)]*\)[^:]*:.*?(?=\n(?:def |class |@pytest|$))",
# make_user обычная функция
- r'def make_user\([^)]*\)[^:]*:.*?(?=\n(?:def |class |@pytest|$))',
+ r"def make_user\([^)]*\)[^:]*:.*?(?=\n(?:def |class |@pytest|$))",
# _make_user
- r'def _make_user\([^)]*\)[^:]*:.*?(?=\n(?:def |class |@pytest|$))',
+ r"def _make_user\([^)]*\)[^:]*:.*?(?=\n(?:def |class |@pytest|$))",
# _user
- r'def _user\([^)]*\)[^:]*:.*?(?=\n(?:def |class |@pytest|$))',
+ r"def _user\([^)]*\)[^:]*:.*?(?=\n(?:def |class |@pytest|$))",
# _grant, _grant_perm
- r'def _grant(?:_perm)?\([^)]*\)[^:]*:.*?(?=\n(?:def |class |@pytest|$))',
+ r"def _grant(?:_perm)?\([^)]*\)[^:]*:.*?(?=\n(?:def |class |@pytest|$))",
# _unique_phone, _unique_email
- r'def _unique_(?:phone|email)\([^)]*\)[^:]*:.*?(?=\n(?:def |class |@pytest|$))',
+ r"def _unique_(?:phone|email)\([^)]*\)[^:]*:.*?(?=\n(?:def |class |@pytest|$))",
# extract_results
- r'def extract_results\([^)]*\)[^:]*:.*?(?=\n(?:def |class |@pytest|$))',
+ r"def extract_results\([^)]*\)[^:]*:.*?(?=\n(?:def |class |@pytest|$))",
]
for pattern in patterns:
- content = re.sub(pattern, '', content, flags=re.DOTALL | re.MULTILINE)
+ content = re.sub(pattern, "", content, flags=re.DOTALL | re.MULTILINE)
return content
@@ -78,10 +79,10 @@ def remove_local_functions(content: str) -> str:
def replace_function_calls(content: str) -> str:
"""Заменяет вызовы локальных функций на импортированные."""
replacements = {
- r'\b_make_user\(': 'make_user(',
- r'\b_user\(': 'make_user(',
- r'\b_grant\(': 'grant_permission(',
- r'\b_grant_perm\(': 'grant_permission(',
+ r"\b_make_user\(": "make_user(",
+ r"\b_user\(": "make_user(",
+ r"\b_grant\(": "grant_permission(",
+ r"\b_grant_perm\(": "grant_permission(",
}
for old, new in replacements.items():
@@ -94,7 +95,7 @@ def update_file(filepath: Path):
"""Обновляет один файл."""
print(f"Обновляем {filepath.name}...")
- content = filepath.read_text(encoding='utf-8')
+ content = filepath.read_text(encoding="utf-8")
# Добавляем импорт
content = add_import_if_missing(content)
@@ -106,9 +107,9 @@ def update_file(filepath: Path):
content = replace_function_calls(content)
# Убираем лишние пустые строки
- content = re.sub(r'\n{3,}', '\n\n', content)
+ content = re.sub(r"\n{3,}", "\n\n", content)
- filepath.write_text(content, encoding='utf-8')
+ filepath.write_text(content, encoding="utf-8")
print(f" ✓ {filepath.name} обновлен")
diff --git a/backend/scripts/utils/analyze_templates.py b/backend/scripts/utils/analyze_templates.py
index 51d4d03d..624fe918 100644
--- a/backend/scripts/utils/analyze_templates.py
+++ b/backend/scripts/utils/analyze_templates.py
@@ -3,51 +3,53 @@
Анализ шаблонов: поиск встроенных стилей и скриптов.
Помогает понять масштаб рефакторинга.
"""
-import os
+
import re
-from pathlib import Path
from collections import defaultdict
+from pathlib import Path
# Путь к шаблонам
-TEMPLATES_DIR = Path(__file__).parent / 'templates'
+TEMPLATES_DIR = Path(__file__).parent / "templates"
def analyze_template(filepath):
"""Анализирует один шаблон"""
try:
- content = filepath.read_text(encoding='utf-8')
- except Exception as e:
+ content = filepath.read_text(encoding="utf-8")
+ except Exception:
return None
-
+
lines = len(content.splitlines())
-
+
# Подсчёт тегов
- style_tags = len(re.findall(r'', content, re.DOTALL)
- script_blocks = re.findall(r'', content, re.DOTALL)
-
+ style_blocks = re.findall(r"", content, re.DOTALL)
+ script_blocks = re.findall(r"", content, re.DOTALL)
+
style_lines = sum(len(block.splitlines()) for block in style_blocks)
script_lines = sum(len(block.splitlines()) for block in script_blocks)
-
+
# Поиск CSS классов
- css_classes = set(re.findall(r'\.([a-z][a-z0-9\-_]*)\s*{', content, re.IGNORECASE))
-
+ css_classes = set(re.findall(r"\.([a-z][a-z0-9\-_]*)\s*{", content, re.IGNORECASE))
+
# Поиск функций JavaScript
- js_functions = set(re.findall(r'function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(', content))
-
+ js_functions = set(
+ re.findall(r"function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(", content)
+ )
+
return {
- 'path': str(filepath.relative_to(TEMPLATES_DIR)),
- 'total_lines': lines,
- 'style_tags': style_tags,
- 'script_tags': script_tags,
- 'style_lines': style_lines,
- 'script_lines': script_lines,
- 'css_classes': css_classes,
- 'js_functions': js_functions,
- 'has_blocks': bool(re.search(r'{%\s*block\s+extra_(css|js)\s*%}', content)),
+ "path": str(filepath.relative_to(TEMPLATES_DIR)),
+ "total_lines": lines,
+ "style_tags": style_tags,
+ "script_tags": script_tags,
+ "style_lines": style_lines,
+ "script_lines": script_lines,
+ "css_classes": css_classes,
+ "js_functions": js_functions,
+ "has_blocks": bool(re.search(r"{%\s*block\s+extra_(css|js)\s*%}", content)),
}
@@ -56,96 +58,112 @@ def main():
print("=" * 80)
print("АНАЛИЗ ШАБЛОНОВ: встроенные стили и скрипты")
print("=" * 80)
-
+
# Находим все HTML файлы
- html_files = list(TEMPLATES_DIR.rglob('*.html'))
-
+ html_files = list(TEMPLATES_DIR.rglob("*.html"))
+
results = []
for filepath in sorted(html_files):
# Пропускаем backup файлы
- if '.backup' in str(filepath):
+ if ".backup" in str(filepath):
continue
-
+
result = analyze_template(filepath)
- if result and (result['style_tags'] > 0 or result['script_tags'] > 0):
+ if result and (result["style_tags"] > 0 or result["script_tags"] > 0):
results.append(result)
-
+
# Сортируем по количеству встроенного кода
- results.sort(key=lambda x: x['style_lines'] + x['script_lines'], reverse=True)
-
+ results.sort(key=lambda x: x["style_lines"] + x["script_lines"], reverse=True)
+
print(f"\nНайдено {len(results)} шаблонов со встроенными стилями/скриптами\n")
-
+
# Таблица результатов
- print(f"{'Файл':<60} {'Строк':<8} {'