Enforce repository-wide code quality

This commit is contained in:
lizerk 2026-09-07 19:43:48 +03:00
parent 2a598de8d5
commit dac0234007
1040 changed files with 80198 additions and 44871 deletions

View File

@ -1,2 +1 @@
# Register your models here.

View File

@ -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):

View File

@ -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,

View File

@ -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)

View File

@ -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(),
}
)

View File

@ -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(

View File

@ -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:
# Время истечения токена

View File

@ -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):

View File

@ -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",
),
]

View File

@ -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),
]

View File

@ -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),

View File

@ -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
)

View File

@ -9,6 +9,7 @@ class StandardPagination(PageNumberPagination):
Позволяет клиенту указывать размер страницы через параметр page_size,
с ограничением максимального размера страницы.
"""
page_size = 20
page_size_query_param = "page_size"
max_page_size = 200

View File

@ -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

View File

@ -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 = [

View File

@ -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]

View File

@ -6,8 +6,8 @@ from .viewsets import (
IntegrationSyncRunViewSet,
ProductBindingViewSet,
ProductViewSet,
SaleViewSet,
SaleLineViewSet,
SaleViewSet,
SourceSystemViewSet,
StockSnapshotViewSet,
)

View File

@ -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__)

View File

@ -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()

View File

@ -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

View File

@ -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__)

View File

@ -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()
)

View File

@ -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

View File

@ -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()

View File

@ -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)

View File

@ -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:

View File

@ -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",

View File

@ -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(

View File

@ -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

View File

@ -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

View File

@ -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")

View File

@ -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

View File

@ -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"),
}
)

View File

@ -39,8 +39,8 @@ from .periods import (
PayrollPeriodCarryoverListCreateView,
PayrollPeriodPointBreakdownView,
PayrollPeriodTableView,
PayrollTableReviewMarksView,
PayrollStatementPaymentConfirmationView,
PayrollTableReviewMarksView,
)
from .workflow import (
PayrollPeriodCalculateView,

View File

@ -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,

View File

@ -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,

View File

@ -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"])

View File

@ -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")

View File

@ -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):

View File

@ -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,

View File

@ -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:

View File

@ -25,6 +25,7 @@ from finance.payroll.access import (
has_simple_admin_access,
)
from projects.models import Project, ProjectExternalBinding
from .views import NoStorePayrollResponseMixin

View File

@ -49,8 +49,8 @@ from .presentation import (
PayrollPaymentConfirmationSerializer,
PayrollPeriodDraftApprovalSerializer,
PayrollPeriodTableSerializer,
PayrollTableReviewMarksSerializer,
PayrollRunSerializer,
PayrollTableReviewMarksSerializer,
)
from .work_records import (
PayrollWorkRecordRevisionSerializer,

View File

@ -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()

View File

@ -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)

View File

@ -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"

View File

@ -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(
{

View File

@ -4,7 +4,6 @@ from rest_framework import serializers
from employees.models import Department
from procurement.models import EquipmentCategory
Employee = get_user_model()

View File

@ -3,6 +3,7 @@
from rest_framework import serializers
from procurement.models import ProcurementRequestActivity
from ...employees.serializers import EmployeeBriefSerializer

View File

@ -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()

View File

@ -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": (

View File

@ -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)

View File

@ -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,
)

View File

@ -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,

View File

@ -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

View File

@ -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:

View File

@ -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,

View File

@ -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()

View File

@ -8,6 +8,7 @@ from rest_framework.response import Response
from procurement.models import (
Supplier,
)
from ..permissions import (
CanManageSupplier,
)

View File

@ -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,
)

View File

@ -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")

View File

@ -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):

View File

@ -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",

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -8,8 +8,8 @@ from .viewsets import (
RequestCoreMixin,
RequestNotificationsMixin,
RequestStatisticsMixin,
RequestWorkflowMixin,
RequestVacationMixin,
RequestWorkflowMixin,
)

View File

@ -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",

View File

@ -17,7 +17,6 @@ from ..serializers import (
RequestVacationTermsWriteSerializer,
)
VACATION_STATS_PERIODS = {"half_year", "year", "month", "all", "custom"}

View File

@ -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")

View File

@ -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
)

View File

@ -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):

View File

@ -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"])

View File

@ -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": "У календаря должен остаться хотя бы один владелец."},

View File

@ -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
)

View File

@ -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

View File

@ -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"]
):

View File

@ -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 = [

View File

@ -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()

View File

@ -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,
)

View File

@ -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
},

View File

@ -25,7 +25,6 @@ from tasks.models import (
Task,
TaskAttachment,
TaskBoard,
TaskBoardGroup,
TaskBoardGroupMembership,
TaskBoardPlacement,
TaskChecklistItem,

View File

@ -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

View File

@ -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 (

View File

@ -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()

View File

@ -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()

View File

@ -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,

View File

@ -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}"

View File

@ -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": "Графики работы сотрудников",
},
),
]

View File

@ -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),

View File

@ -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(

View File

@ -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):

View File

@ -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,
)

View File

@ -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"]

View File

@ -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
)

View File

@ -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()

View File

@ -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__)

View File

@ -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:

View File

@ -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

View File

@ -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

Some files were not shown because too many files have changed in this diff Show More