Enforce repository-wide code quality
This commit is contained in:
parent
2a598de8d5
commit
dac0234007
@ -1,2 +1 @@
|
||||
|
||||
# Register your models here.
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(),
|
||||
}
|
||||
)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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:
|
||||
# Время истечения токена
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
@ -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),
|
||||
]
|
||||
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -9,6 +9,7 @@ class StandardPagination(PageNumberPagination):
|
||||
Позволяет клиенту указывать размер страницы через параметр page_size,
|
||||
с ограничением максимального размера страницы.
|
||||
"""
|
||||
|
||||
page_size = 20
|
||||
page_size_query_param = "page_size"
|
||||
max_page_size = 200
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -6,8 +6,8 @@ from .viewsets import (
|
||||
IntegrationSyncRunViewSet,
|
||||
ProductBindingViewSet,
|
||||
ProductViewSet,
|
||||
SaleViewSet,
|
||||
SaleLineViewSet,
|
||||
SaleViewSet,
|
||||
SourceSystemViewSet,
|
||||
StockSnapshotViewSet,
|
||||
)
|
||||
|
||||
@ -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__)
|
||||
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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__)
|
||||
|
||||
|
||||
@ -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()
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@ -39,8 +39,8 @@ from .periods import (
|
||||
PayrollPeriodCarryoverListCreateView,
|
||||
PayrollPeriodPointBreakdownView,
|
||||
PayrollPeriodTableView,
|
||||
PayrollTableReviewMarksView,
|
||||
PayrollStatementPaymentConfirmationView,
|
||||
PayrollTableReviewMarksView,
|
||||
)
|
||||
from .workflow import (
|
||||
PayrollPeriodCalculateView,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"])
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -25,6 +25,7 @@ from finance.payroll.access import (
|
||||
has_simple_admin_access,
|
||||
)
|
||||
from projects.models import Project, ProjectExternalBinding
|
||||
|
||||
from .views import NoStorePayrollResponseMixin
|
||||
|
||||
|
||||
|
||||
@ -49,8 +49,8 @@ from .presentation import (
|
||||
PayrollPaymentConfirmationSerializer,
|
||||
PayrollPeriodDraftApprovalSerializer,
|
||||
PayrollPeriodTableSerializer,
|
||||
PayrollTableReviewMarksSerializer,
|
||||
PayrollRunSerializer,
|
||||
PayrollTableReviewMarksSerializer,
|
||||
)
|
||||
from .work_records import (
|
||||
PayrollWorkRecordRevisionSerializer,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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(
|
||||
{
|
||||
|
||||
@ -4,7 +4,6 @@ from rest_framework import serializers
|
||||
from employees.models import Department
|
||||
from procurement.models import EquipmentCategory
|
||||
|
||||
|
||||
Employee = get_user_model()
|
||||
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from procurement.models import ProcurementRequestActivity
|
||||
|
||||
from ...employees.serializers import EmployeeBriefSerializer
|
||||
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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": (
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -8,6 +8,7 @@ from rest_framework.response import Response
|
||||
from procurement.models import (
|
||||
Supplier,
|
||||
)
|
||||
|
||||
from ..permissions import (
|
||||
CanManageSupplier,
|
||||
)
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -8,8 +8,8 @@ from .viewsets import (
|
||||
RequestCoreMixin,
|
||||
RequestNotificationsMixin,
|
||||
RequestStatisticsMixin,
|
||||
RequestWorkflowMixin,
|
||||
RequestVacationMixin,
|
||||
RequestWorkflowMixin,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -17,7 +17,6 @@ from ..serializers import (
|
||||
RequestVacationTermsWriteSerializer,
|
||||
)
|
||||
|
||||
|
||||
VACATION_STATS_PERIODS = {"half_year", "year", "month", "all", "custom"}
|
||||
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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"])
|
||||
|
||||
@ -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": "У календаря должен остаться хотя бы один владелец."},
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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"]
|
||||
):
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
@ -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
|
||||
},
|
||||
|
||||
@ -25,7 +25,6 @@ from tasks.models import (
|
||||
Task,
|
||||
TaskAttachment,
|
||||
TaskBoard,
|
||||
TaskBoardGroup,
|
||||
TaskBoardGroupMembership,
|
||||
TaskBoardPlacement,
|
||||
TaskChecklistItem,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}"
|
||||
|
||||
@ -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": "Графики работы сотрудников",
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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"]
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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__)
|
||||
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user