1
0
forked from erp-dev/erp
This commit is contained in:
2026-07-05 13:53:17 +08:00
parent 4538e51ad5
commit e1cc6df122
24 changed files with 3030 additions and 495 deletions

View File

@@ -1,8 +1,11 @@
from datetime import datetime
from django.core.cache import cache
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from django.utils import timezone
from rest_framework.test import APIClient
from basic_info import models as basic_models
@@ -258,7 +261,146 @@ class MissionV2APITest(TestCase):
resp = self.client.get("/api/v2/missions/")
self.assertEqual(resp.status_code, 200)
self.assertEqual([item["id"] for item in resp.data], [visible.id])
self.assertEqual([item["id"] for item in resp.data["results"]], [visible.id])
self.assertEqual(resp.data["count"], 1)
def test_list_missions_supports_limit_offset_pagination(self):
_first = self._create_mission(description="第一条任务")
second = self._create_mission(description="第二条任务")
resp = self.client.get("/api/v2/missions/?limit=1&offset=0")
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.data["count"], 2)
self.assertEqual(len(resp.data["results"]), 1)
self.assertEqual(resp.data["results"][0]["id"], second.id)
self.assertIsNotNone(resp.data["next"])
def test_my_only_returns_related_missions_by_default(self):
created_by_me = self._create_mission(description="我创建的未完结任务")
participant_mission = mission_models.Mission.objects.create(
merchant=self.merchant,
category=self.default_category,
creator=self.participant,
description="我参与的未完结任务",
)
mission_models.MissionParticipant.objects.create(
merchant=self.merchant,
mission=participant_mission,
employee=self.employee,
)
unrelated = mission_models.Mission.objects.create(
merchant=self.merchant,
category=self.default_category,
creator=self.participant,
description="同商户但与我无关",
)
completed = self._create_mission(description="已完成任务")
completed.is_completed = True
completed.save(update_fields=["is_completed", "updated_at"])
cancelled = self._create_mission(description="已取消任务")
cancelled.is_cancelled = True
cancelled.save(update_fields=["is_cancelled", "updated_at"])
cross_merchant = mission_models.Mission.objects.create(
merchant=self.other_merchant,
category=self.other_category,
creator=self.other_employee,
description="跨商户任务",
)
resp = self.client.get("/api/v2/missions/my/")
self.assertEqual(resp.status_code, 200)
ids = {item["id"] for item in resp.data["results"]}
self.assertEqual(ids, {created_by_me.id, participant_mission.id, completed.id, cancelled.id})
self.assertNotIn(unrelated.id, ids)
self.assertNotIn(cross_merchant.id, ids)
self.assertEqual(resp.data["count"], 4)
def test_my_supports_status_urgent_created_by_me_created_at_range_and_pagination(self):
tz = timezone.get_current_timezone()
old_time = timezone.make_aware(datetime(2026, 7, 2, 10, 0, 0), tz)
day_time = timezone.make_aware(datetime(2026, 7, 3, 23, 30, 0), tz)
next_day_time = timezone.make_aware(datetime(2026, 7, 4, 0, 0, 0), tz)
old_urgent = self._create_mission(description="更早的紧急任务")
old_urgent.is_urgent = True
old_urgent.save(update_fields=["is_urgent", "updated_at"])
in_range_urgent = self._create_mission(description="当天紧急任务")
in_range_urgent.is_urgent = True
in_range_urgent.save(update_fields=["is_urgent", "updated_at"])
in_range_normal = self._create_mission(description="当天普通任务")
next_day_urgent = self._create_mission(description="次日紧急任务")
next_day_urgent.is_urgent = True
next_day_urgent.save(update_fields=["is_urgent", "updated_at"])
completed_urgent = self._create_mission(description="当天已完成紧急任务")
completed_urgent.is_urgent = True
completed_urgent.is_completed = True
completed_urgent.save(update_fields=["is_urgent", "is_completed", "updated_at"])
mission_models.Mission.objects.filter(id=old_urgent.id).update(created_at=old_time)
mission_models.Mission.objects.filter(id=in_range_urgent.id).update(created_at=day_time)
mission_models.Mission.objects.filter(id=in_range_normal.id).update(created_at=day_time)
mission_models.Mission.objects.filter(id=next_day_urgent.id).update(created_at=next_day_time)
mission_models.Mission.objects.filter(id=completed_urgent.id).update(created_at=day_time)
resp = self.client.get(
"/api/v2/missions/my/?created_by_me=true&is_completed=false&is_cancelled=false&is_urgent=true"
"&created_at_from=2026-07-03&created_at_to=2026-07-03&limit=1&offset=0"
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.data["count"], 1)
self.assertEqual([item["id"] for item in resp.data["results"]], [in_range_urgent.id])
def test_my_status_counts_groups_related_missions_without_duplicates(self):
open_created = self._create_mission(description="我创建的未完成任务")
mission_models.MissionParticipant.objects.create(
merchant=self.merchant,
mission=open_created,
employee=self.employee,
)
mission_models.MissionParticipant.objects.create(
merchant=self.merchant,
mission=open_created,
employee=self.participant,
)
completed_created = self._create_mission(description="我创建的已完成任务")
completed_created.is_completed = True
completed_created.save(update_fields=["is_completed", "updated_at"])
cancelled_participant = mission_models.Mission.objects.create(
merchant=self.merchant,
category=self.default_category,
creator=self.participant,
description="我参与的已取消任务",
is_cancelled=True,
)
mission_models.MissionParticipant.objects.create(
merchant=self.merchant,
mission=cancelled_participant,
employee=self.employee,
)
mission_models.Mission.objects.create(
merchant=self.merchant,
category=self.default_category,
creator=self.participant,
description="同商户但与我无关",
)
mission_models.Mission.objects.create(
merchant=self.other_merchant,
category=self.other_category,
creator=self.other_employee,
description="跨商户任务",
is_completed=True,
)
resp = self.client.get("/api/v2/missions/my/status-counts/")
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.data, {"total": 3, "open": 1, "completed": 1, "cancelled": 1})
def test_list_missions_by_printing_order_returns_nested_replies_and_extra(self):
mission = mission_models.Mission.objects.create(

View File

@@ -0,0 +1,57 @@
from django.contrib.auth import get_user_model
from django.test import TestCase
from rest_framework.test import APIClient
from rest_framework_simplejwt.tokens import RefreshToken
from basic_info.models import Employee, Merchant, MerchantTypeEnum
class APIDocsSchemaAccessTest(TestCase):
def setUp(self):
self.merchant = Merchant.objects.create(name='文档审查商户', type=MerchantTypeEnum.STORE)
self.user = get_user_model().objects.create_user(username='schema-docs-user', password='pass12345')
self.employee = Employee.objects.create(
merchant=self.merchant,
sys_user=self.user,
name='文档审查员工',
)
def _auth_client(self):
token = str(RefreshToken.for_user(self.user).access_token)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
return client
def test_schema_and_docs_require_jwt(self):
client = APIClient()
schema_response = client.get('/api/schema/', HTTP_ACCEPT='application/json')
docs_response = client.get('/api/docs/')
self.assertEqual(schema_response.status_code, 401)
self.assertEqual(docs_response.status_code, 401)
def test_authenticated_user_can_access_schema_and_docs(self):
client = self._auth_client()
schema_response = client.get('/api/schema/', HTTP_ACCEPT='application/json')
docs_response = client.get('/api/docs/')
self.assertEqual(schema_response.status_code, 200)
self.assertEqual(docs_response.status_code, 200)
def test_schema_contains_latest_mission_api(self):
client = self._auth_client()
response = client.get('/api/schema/', HTTP_ACCEPT='application/json')
self.assertEqual(response.status_code, 200)
schema_text = response.content.decode('utf-8')
expected_fragments = [
'employee_type_ids',
'/api/v2/missions/',
'/api/v2/missions/by-printing-order/{printing_order_id}/',
'/api/v2/mission-replies/{reply_id}/reject/',
]
missing = [fragment for fragment in expected_fragments if fragment not in schema_text]
self.assertEqual(missing, [])

View File

@@ -23,6 +23,8 @@ from api_v2.views import (
MissionCategoryListCreateView,
MissionDetailView,
MissionListCreateView,
MissionMyStatusCountsView,
MissionMyView,
MissionReopenView,
MissionReplyListCreateView,
MissionReplyRejectView,
@@ -79,6 +81,8 @@ urlpatterns = [
path('mission-categories/<int:category_id>/', MissionCategoryDetailView.as_view(), name='api_v2_mission_category_detail'),
path('missions/', MissionListCreateView.as_view(), name='api_v2_mission_list_create'),
path('missions/by-printing-order/<int:printing_order_id>/', MissionByPrintingOrderView.as_view(), name='api_v2_mission_by_printing_order'),
path('missions/my/', MissionMyView.as_view(), name='api_v2_mission_my'),
path('missions/my/status-counts/', MissionMyStatusCountsView.as_view(), name='api_v2_mission_my_status_counts'),
path('missions/<int:mission_id>/', MissionDetailView.as_view(), name='api_v2_mission_detail'),
path('missions/<int:mission_id>/replies/', MissionReplyListCreateView.as_view(), name='api_v2_mission_reply_list_create'),
path('missions/<int:mission_id>/reopen/', MissionReopenView.as_view(), name='api_v2_mission_reopen'),

View File

@@ -27,6 +27,8 @@ from .mission import (
MissionCategoryListCreateView,
MissionDetailView,
MissionListCreateView,
MissionMyStatusCountsView,
MissionMyView,
MissionReopenView,
MissionReplyListCreateView,
MissionReplyRejectView,
@@ -79,6 +81,8 @@ __all__ = [
'MissionCategoryListCreateView',
'MissionDetailView',
'MissionListCreateView',
'MissionMyStatusCountsView',
'MissionMyView',
'MissionReopenView',
'MissionReplyListCreateView',
'MissionReplyRejectView',

View File

@@ -1,13 +1,19 @@
from datetime import datetime, time, timedelta
from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache
from django.db import IntegrityError
from django.db.models import Exists, OuterRef, Prefetch, Q
from django.db.models import Count, Exists, OuterRef, Prefetch, Q
from django.db.models import ProtectedError
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.utils.dateparse import parse_date, parse_datetime
from drf_spectacular.utils import extend_schema
from rest_framework import permissions, serializers, status
from rest_framework.response import Response
from rest_framework.views import APIView
from flower.viewsets import LimitedLimitOffsetPagination
from mission import models as mission_models
from mission import services as mission_services
@@ -47,6 +53,11 @@ class MissionWriteSerializer(serializers.Serializer):
required=False,
allow_empty=True,
)
employee_type_ids = serializers.ListField(
child=serializers.IntegerField(min_value=1),
required=False,
allow_empty=True,
)
def __init__(self, *args, **kwargs):
self.is_create = kwargs.pop("is_create", False)
@@ -358,6 +369,27 @@ def _parse_bool_query_param(request, param_name: str, default: bool) -> bool:
raise serializers.ValidationError({param_name: "必须是布尔值"})
def _parse_date_or_datetime_query_param(value: str | None) -> tuple[datetime | None, bool]:
raw_value = (value or "").strip()
if not raw_value:
return None, False
tz = timezone.get_current_timezone()
date_value = parse_date(raw_value)
if date_value is not None:
return timezone.make_aware(datetime.combine(date_value, time.min), tz), False
datetime_value = parse_datetime(raw_value)
if datetime_value is not None:
if timezone.is_naive(datetime_value):
datetime_value = timezone.make_aware(datetime_value, tz)
else:
datetime_value = timezone.localtime(datetime_value, tz)
return datetime_value, True
return None, False
def _mission_queryset_for_employee(employee, *, include_details: bool = True):
has_ending_reply_subquery = mission_models.MissionReply.objects.filter(
mission_id=OuterRef("pk"),
@@ -402,6 +434,7 @@ class MissionCategoryListCreateView(APIView):
queryset = _mission_category_queryset_for_employee(employee)
return Response(MissionCategorySerializer(queryset, many=True).data)
@extend_schema(request=MissionWriteSerializer, responses={201: MissionSerializer})
def post(self, request):
employee = _get_employee(request)
serializer = MissionCategoryWriteSerializer(data=request.data, context={"employee": employee})
@@ -466,6 +499,7 @@ class MissionCategoryDetailView(APIView):
class MissionListCreateView(APIView):
permission_classes = [permissions.IsAuthenticated]
pagination_class = LimitedLimitOffsetPagination
def get(self, request):
employee = _get_employee(request)
@@ -482,7 +516,10 @@ class MissionListCreateView(APIView):
if request.query_params.get("content_id"):
queryset = queryset.filter(content_id=request.query_params["content_id"])
return Response(MissionSerializer(queryset, many=True).data)
paginator = self.pagination_class()
page = paginator.paginate_queryset(queryset, request, view=self)
serializer = MissionSerializer(page, many=True)
return paginator.get_paginated_response(serializer.data)
def post(self, request):
employee = _get_employee(request)
@@ -498,6 +535,7 @@ class MissionListCreateView(APIView):
content_id=data.get("content_id"),
extra=data.get("extra"),
participant_ids=data.get("participant_ids"),
employee_type_ids=data.get("employee_type_ids"),
notify_if_unreplied=data.get("notify_if_unreplied", False),
unreplied_notify_interval_minutes=data.get("unreplied_notify_interval_minutes"),
unreplied_notify_max_count=data.get("unreplied_notify_max_count", 5),
@@ -507,6 +545,65 @@ class MissionListCreateView(APIView):
return Response(MissionSerializer(mission).data, status=status.HTTP_201_CREATED)
class MissionMyView(APIView):
permission_classes = [permissions.IsAuthenticated]
pagination_class = LimitedLimitOffsetPagination
def get(self, request):
employee = _get_employee(request)
queryset = (
_mission_queryset_for_employee(employee)
.filter(Q(creator=employee) | Q(participants__employee=employee))
.distinct()
)
if _parse_bool_query_param(request, "created_by_me", default=False):
queryset = queryset.filter(creator=employee)
for field in ["is_completed", "is_cancelled", "is_urgent"]:
if request.query_params.get(field) is not None:
queryset = queryset.filter(**{field: _parse_bool_query_param(request, field, default=False)})
created_at_from = request.query_params.get("created_at_from")
if created_at_from is not None:
dt, _has_time = _parse_date_or_datetime_query_param(created_at_from)
if dt is None:
raise serializers.ValidationError({"created_at_from": "必须是日期或日期时间"})
queryset = queryset.filter(created_at__gte=dt)
created_at_to = request.query_params.get("created_at_to")
if created_at_to is not None:
dt, has_time = _parse_date_or_datetime_query_param(created_at_to)
if dt is None:
raise serializers.ValidationError({"created_at_to": "必须是日期或日期时间"})
if has_time:
queryset = queryset.filter(created_at__lte=dt)
else:
queryset = queryset.filter(created_at__lt=dt + timedelta(days=1))
paginator = self.pagination_class()
page = paginator.paginate_queryset(queryset, request, view=self)
serializer = MissionSerializer(page, many=True)
return paginator.get_paginated_response(serializer.data)
class MissionMyStatusCountsView(APIView):
permission_classes = [permissions.IsAuthenticated]
def get(self, request):
employee = _get_employee(request)
queryset = mission_models.Mission.objects.filter(merchant=employee.merchant).filter(
Q(creator=employee) | Q(participants__employee=employee)
)
counts = queryset.aggregate(
total=Count("id", distinct=True),
open=Count("id", filter=Q(is_completed=False, is_cancelled=False), distinct=True),
completed=Count("id", filter=Q(is_completed=True, is_cancelled=False), distinct=True),
cancelled=Count("id", filter=Q(is_cancelled=True), distinct=True),
)
return Response(counts)
class MissionByPrintingOrderView(APIView):
permission_classes = [permissions.IsAuthenticated]
@@ -568,6 +665,7 @@ class MissionDetailView(APIView):
mission = self.get_object(request, mission_id)
return Response(MissionSerializer(mission).data)
@extend_schema(request=MissionWriteSerializer, responses=MissionSerializer)
def patch(self, request, mission_id):
employee = _get_employee(request)
mission = self.get_object(request, mission_id)
@@ -589,6 +687,7 @@ class MissionDetailView(APIView):
extra=(data["extra"] if "extra" in data else mission_services.UNSET),
update_content_object=("content_type" in request.data or "content_id" in request.data),
participant_ids=data.get("participant_ids"),
employee_type_ids=data.get("employee_type_ids"),
notify_if_unreplied=(
data["notify_if_unreplied"]
if "notify_if_unreplied" in data