1
0
forked from erp-dev/erp

feat: prod clean

This commit is contained in:
2026-05-01 22:43:22 +08:00
parent 1afcfde151
commit d7b388e935
19 changed files with 1198 additions and 134 deletions

View File

@@ -1,10 +1,13 @@
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 rest_framework.test import APIClient
from basic_info import models as basic_models
from mission import models as mission_models
from printing import models as printing_models
def grant_permission(user, codename):
@@ -14,6 +17,7 @@ def grant_permission(user, codename):
class MissionV2APITest(TestCase):
def setUp(self):
cache.clear()
self.client = APIClient()
self.merchant = basic_models.Merchant.objects.create(
name="任务商户",
@@ -49,6 +53,31 @@ class MissionV2APITest(TestCase):
merchant=self.other_merchant,
name="其他员工",
)
self.customer = basic_models.Customer.objects.create(
merchant=self.merchant,
name="任务客户",
mobile="13800138000",
)
self.other_customer = basic_models.Customer.objects.create(
merchant=self.other_merchant,
name="其他客户",
mobile="13800138001",
)
self.printing_order = printing_models.PrintingOrder.objects.create(
merchant=self.merchant,
customer=self.customer,
fabric="测试面料",
width="150cm",
created_by=self.user,
)
self.other_printing_order = printing_models.PrintingOrder.objects.create(
merchant=self.other_merchant,
customer=self.other_customer,
fabric="其他面料",
width="160cm",
created_by=self.user,
)
self.printing_order_content_type = ContentType.objects.get_for_model(printing_models.PrintingOrder)
self.client.force_authenticate(user=self.user)
def test_create_mission_uses_default_status_and_current_employee(self):
@@ -56,6 +85,7 @@ class MissionV2APITest(TestCase):
"/api/v2/missions/",
{
"description": "跟进客户问题",
"extra": {"source": "api-test", "priority": 1},
"participant_ids": [self.participant.id],
},
format="json",
@@ -71,8 +101,10 @@ class MissionV2APITest(TestCase):
self.assertFalse(mission.is_cancelled)
self.assertFalse(mission.notify_if_unreplied)
self.assertEqual(mission.unreplied_notify_max_count, 5)
self.assertEqual(mission.extra, {"source": "api-test", "priority": 1})
self.assertEqual(resp.data["category"], self.default_category.id)
self.assertEqual(resp.data["category_name"], "通用")
self.assertEqual(resp.data["extra"], {"source": "api-test", "priority": 1})
self.assertEqual(list(mission.participants.values_list("employee_id", flat=True)), [self.participant.id])
def test_create_mission_supports_unreplied_notification_fields(self):
@@ -165,6 +197,7 @@ class MissionV2APITest(TestCase):
{
"description": "更新后的任务",
"category": self.followup_category.id,
"extra": {"channel": "wechat"},
"participant_ids": [self.participant.id],
},
format="json",
@@ -174,10 +207,26 @@ class MissionV2APITest(TestCase):
mission.refresh_from_db()
self.assertEqual(mission.description, "更新后的任务")
self.assertEqual(mission.category, self.followup_category)
self.assertEqual(mission.extra, {"channel": "wechat"})
self.assertEqual(resp.data["category"], self.followup_category.id)
self.assertEqual(resp.data["category_name"], "跟进")
self.assertEqual(resp.data["extra"], {"channel": "wechat"})
self.assertEqual(list(mission.participants.values_list("employee_id", flat=True)), [self.participant.id])
def test_create_mission_reply_supports_extra(self):
mission = self._create_mission()
resp = self.client.post(
f"/api/v2/missions/{mission.id}/replies/",
{"content": "已记录", "extra": {"attachment_ids": [1, 2]}},
format="json",
)
self.assertEqual(resp.status_code, 201)
reply = mission_models.MissionReply.objects.get(id=resp.data["id"])
self.assertEqual(reply.extra, {"attachment_ids": [1, 2]})
self.assertEqual(resp.data["extra"], {"attachment_ids": [1, 2]})
def test_patch_mission_updates_unreplied_notification_fields(self):
mission = self._create_mission()
@@ -211,6 +260,152 @@ class MissionV2APITest(TestCase):
self.assertEqual(resp.status_code, 200)
self.assertEqual([item["id"] for item in resp.data], [visible.id])
def test_list_missions_by_printing_order_returns_nested_replies_and_extra(self):
mission = mission_models.Mission.objects.create(
merchant=self.merchant,
category=self.default_category,
creator=self.employee,
description="印花订单任务",
content_type=self.printing_order_content_type,
content_id=self.printing_order.id,
extra={"source": "printing-order"},
)
mission_models.MissionReply.objects.create(
merchant=self.merchant,
mission=mission,
responder=self.employee,
content="已跟进",
extra={"attachment_ids": [101]},
)
mission_models.Mission.objects.create(
merchant=self.merchant,
category=self.followup_category,
creator=self.employee,
description="其他订单任务",
content_type=self.printing_order_content_type,
content_id=self.other_printing_order.id,
)
resp = self.client.get(f"/api/v2/missions/by-printing-order/{self.printing_order.id}/")
self.assertEqual(resp.status_code, 200)
self.assertEqual(len(resp.data), 1)
self.assertEqual(resp.data[0]["id"], mission.id)
self.assertEqual(resp.data[0]["extra"], {"source": "printing-order"})
self.assertEqual(len(resp.data[0]["replies"]), 1)
self.assertEqual(resp.data[0]["replies"][0]["content"], "已跟进")
self.assertEqual(resp.data[0]["replies"][0]["extra"], {"attachment_ids": [101]})
def test_list_missions_by_printing_order_supports_category_filter(self):
mission_models.Mission.objects.create(
merchant=self.merchant,
category=self.default_category,
creator=self.employee,
description="通用任务",
content_type=self.printing_order_content_type,
content_id=self.printing_order.id,
)
followup_mission = mission_models.Mission.objects.create(
merchant=self.merchant,
category=self.followup_category,
creator=self.employee,
description="跟进任务",
content_type=self.printing_order_content_type,
content_id=self.printing_order.id,
)
resp = self.client.get(
f"/api/v2/missions/by-printing-order/{self.printing_order.id}/?category_ids={self.followup_category.id}"
)
self.assertEqual(resp.status_code, 200)
self.assertEqual([item["id"] for item in resp.data], [followup_mission.id])
def test_list_missions_by_printing_order_supports_lightweight_mode(self):
mission = mission_models.Mission.objects.create(
merchant=self.merchant,
category=self.default_category,
creator=self.employee,
description="轻量任务",
content_type=self.printing_order_content_type,
content_id=self.printing_order.id,
)
mission_models.MissionParticipant.objects.create(
merchant=self.merchant,
mission=mission,
employee=self.participant,
)
mission_models.MissionReply.objects.create(
merchant=self.merchant,
mission=mission,
responder=self.employee,
content="已处理",
ends_task=True,
)
resp = self.client.get(
f"/api/v2/missions/by-printing-order/{self.printing_order.id}/?include_details=false"
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(len(resp.data), 1)
self.assertEqual(resp.data[0]["id"], mission.id)
self.assertNotIn("participants", resp.data[0])
self.assertNotIn("has_ending_reply", resp.data[0])
self.assertNotIn("can_reply", resp.data[0])
self.assertNotIn("replies", resp.data[0])
def test_list_missions_by_printing_order_rejects_invalid_include_details(self):
resp = self.client.get(
f"/api/v2/missions/by-printing-order/{self.printing_order.id}/?include_details=maybe"
)
self.assertEqual(resp.status_code, 400)
self.assertIn("include_details", resp.data)
def test_list_missions_by_printing_order_uses_separate_cache_by_detail_mode(self):
mission = mission_models.Mission.objects.create(
merchant=self.merchant,
category=self.default_category,
creator=self.employee,
description="缓存任务",
content_type=self.printing_order_content_type,
content_id=self.printing_order.id,
)
mission_models.MissionReply.objects.create(
merchant=self.merchant,
mission=mission,
responder=self.employee,
content="首次回复",
)
detailed_resp = self.client.get(f"/api/v2/missions/by-printing-order/{self.printing_order.id}/")
lightweight_resp = self.client.get(
f"/api/v2/missions/by-printing-order/{self.printing_order.id}/?include_details=false"
)
mission_models.MissionReply.objects.create(
merchant=self.merchant,
mission=mission,
responder=self.employee,
content="后续回复",
)
detailed_cached_resp = self.client.get(f"/api/v2/missions/by-printing-order/{self.printing_order.id}/")
lightweight_cached_resp = self.client.get(
f"/api/v2/missions/by-printing-order/{self.printing_order.id}/?include_details=false"
)
self.assertEqual(len(detailed_resp.data[0]["replies"]), 1)
self.assertEqual(len(detailed_cached_resp.data[0]["replies"]), 1)
self.assertNotIn("replies", lightweight_resp.data[0])
self.assertNotIn("replies", lightweight_cached_resp.data[0])
def test_list_missions_by_printing_order_rejects_cross_merchant_order(self):
resp = self.client.get(f"/api/v2/missions/by-printing-order/{self.other_printing_order.id}/")
self.assertEqual(resp.status_code, 404)
def test_delete_mission_is_not_allowed(self):
mission = self._create_mission()

View File

@@ -0,0 +1,181 @@
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from rest_framework.test import APIClient
from basic_info import models as basic_models
from shipment import models as shipment_models
def tiny_gif(name='photo.gif'):
return SimpleUploadedFile(
name,
(
b'GIF89a\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00\xff\xff\xff!'
b'\xf9\x04\x01\x00\x00\x00\x00,\x00\x00\x00\x00\x01\x00\x01\x00'
b'\x00\x02\x02D\x01\x00;'
),
content_type='image/gif',
)
class ShipmentDeliveryPhotoV2APITest(TestCase):
def setUp(self):
self.client = APIClient()
self.merchant = basic_models.Merchant.objects.create(
name='送货照片商户',
type=basic_models.MerchantTypeEnum.FACTORY,
)
self.other_merchant = basic_models.Merchant.objects.create(
name='其他送货照片商户',
type=basic_models.MerchantTypeEnum.FACTORY,
)
self.user = get_user_model().objects.create_user(username='shipment-photo-user', password='pass12345')
self.employee = basic_models.Employee.objects.create(
merchant=self.merchant,
sys_user=self.user,
name='送货照片员工',
)
self.customer = basic_models.Customer.objects.create(
merchant=self.merchant,
name='照片客户',
mobile='13800138000',
)
self.other_customer = basic_models.Customer.objects.create(
merchant=self.other_merchant,
name='其他照片客户',
mobile='13800138001',
)
self.delivery = shipment_models.ShipmentDelivery.objects.create(
merchant=self.merchant,
driver_name='张司机',
vehicle_trip='TRIP-001',
created_by=self.user,
)
self.other_delivery = shipment_models.ShipmentDelivery.objects.create(
merchant=self.other_merchant,
driver_name='李司机',
vehicle_trip='TRIP-002',
created_by=self.user,
)
self.shipment = shipment_models.Shipment.objects.create(
merchant=self.merchant,
customer=self.customer,
shipment_date='2026-04-28',
created_by=self.user,
delivery=self.delivery,
)
self.shipment2 = shipment_models.Shipment.objects.create(
merchant=self.merchant,
customer=self.customer,
shipment_date='2026-04-29',
created_by=self.user,
delivery=self.delivery,
)
self.other_shipment = shipment_models.Shipment.objects.create(
merchant=self.other_merchant,
customer=self.other_customer,
shipment_date='2026-04-30',
created_by=self.user,
delivery=self.other_delivery,
)
self.client.force_authenticate(user=self.user)
def test_create_shipment_delivery_photo_success(self):
resp = self.client.post(
'/api/v2/shipment-delivery-photos/',
{
'shipment': str(self.shipment.id),
'delivery': str(self.delivery.id),
'remark': '已送达',
'photo': tiny_gif(),
},
format='multipart',
)
self.assertEqual(resp.status_code, 201)
photo = shipment_models.ShipmentDeliveryPhoto.objects.get(id=resp.data['id'])
self.assertEqual(photo.shipment_id, self.shipment.id)
self.assertEqual(photo.delivery_id, self.delivery.id)
self.assertEqual(photo.created_by_id, self.user.id)
self.assertEqual(resp.data['shipment_id'], self.shipment.id)
self.assertEqual(resp.data['delivery_id'], self.delivery.id)
self.assertEqual(resp.data['remark'], '已送达')
self.assertTrue(resp.data['photo'])
def test_create_shipment_delivery_photo_rejects_delivery_mismatch(self):
other_delivery_same_merchant = shipment_models.ShipmentDelivery.objects.create(
merchant=self.merchant,
driver_name='王司机',
vehicle_trip='TRIP-003',
created_by=self.user,
)
resp = self.client.post(
'/api/v2/shipment-delivery-photos/',
{
'shipment': str(self.shipment.id),
'delivery': str(other_delivery_same_merchant.id),
'photo': tiny_gif('mismatch.gif'),
},
format='multipart',
)
self.assertEqual(resp.status_code, 400)
self.assertIn('delivery', resp.data)
def test_list_shipment_delivery_photos_supports_shipment_and_delivery_filters(self):
photo1 = shipment_models.ShipmentDeliveryPhoto.objects.create(
shipment=self.shipment,
delivery=self.delivery,
remark='第一张',
photo=tiny_gif('one.gif'),
created_by=self.user,
)
shipment_models.ShipmentDeliveryPhoto.objects.create(
shipment=self.shipment2,
delivery=self.delivery,
remark='第二张',
photo=tiny_gif('two.gif'),
created_by=self.user,
)
resp = self.client.get(
f'/api/v2/shipment-delivery-photos/?shipment_id={self.shipment.id}&delivery_id={self.delivery.id}'
)
self.assertEqual(resp.status_code, 200)
self.assertEqual([item['id'] for item in resp.data], [photo1.id])
def test_patch_shipment_delivery_photo_updates_remark(self):
photo = shipment_models.ShipmentDeliveryPhoto.objects.create(
shipment=self.shipment,
delivery=self.delivery,
remark='初始备注',
photo=tiny_gif('patch.gif'),
created_by=self.user,
)
resp = self.client.patch(
f'/api/v2/shipment-delivery-photos/{photo.id}/',
{'remark': '更新备注'},
format='multipart',
)
self.assertEqual(resp.status_code, 200)
photo.refresh_from_db()
self.assertEqual(photo.remark, '更新备注')
def test_delete_shipment_delivery_photo_success(self):
photo = shipment_models.ShipmentDeliveryPhoto.objects.create(
shipment=self.shipment,
delivery=self.delivery,
remark='待删除',
photo=tiny_gif('delete.gif'),
created_by=self.user,
)
resp = self.client.delete(f'/api/v2/shipment-delivery-photos/{photo.id}/')
self.assertEqual(resp.status_code, 204)
self.assertFalse(shipment_models.ShipmentDeliveryPhoto.objects.filter(id=photo.id).exists())

View File

@@ -17,6 +17,7 @@ from api_v2.views import (
PrintingOrderBatchAdvanceRecordsView,
BusinessObjectCloneView,
MissionCancelView,
MissionByPrintingOrderView,
MissionCategoryDetailView,
MissionCategoryListCreateView,
MissionDetailView,
@@ -25,6 +26,8 @@ from api_v2.views import (
MissionReplyListCreateView,
MissionReplyRejectView,
MissionSetUrgentView,
ShipmentDeliveryPhotoDetailView,
ShipmentDeliveryPhotoListCreateView,
AgentUnshippedShipmentListView,
AgentTransportVehicleListView,
AgentTransportVehicleDetailView,
@@ -55,10 +58,13 @@ urlpatterns = [
path('mission-categories/', MissionCategoryListCreateView.as_view(), name='api_v2_mission_category_list_create'),
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/<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'),
path('missions/<int:mission_id>/cancel/', MissionCancelView.as_view(), name='api_v2_mission_cancel'),
path('missions/<int:mission_id>/set-urgent/', MissionSetUrgentView.as_view(), name='api_v2_mission_set_urgent'),
path('mission-replies/<int:reply_id>/reject/', MissionReplyRejectView.as_view(), name='api_v2_mission_reply_reject'),
path('shipment-delivery-photos/', ShipmentDeliveryPhotoListCreateView.as_view(), name='api_v2_shipment_delivery_photo_list_create'),
path('shipment-delivery-photos/<int:photo_id>/', ShipmentDeliveryPhotoDetailView.as_view(), name='api_v2_shipment_delivery_photo_detail'),
]

View File

@@ -20,6 +20,7 @@ from .printing import (
from .stateflow import BusinessObjectCloneView
from .mission import (
ContentTypeListView,
MissionByPrintingOrderView,
MissionCancelView,
MissionCategoryDetailView,
MissionCategoryListCreateView,
@@ -31,6 +32,7 @@ from .mission import (
MissionSetUrgentView,
)
from .ai import AgentUnshippedShipmentListView, AgentTransportVehicleListView, AgentTransportVehicleDetailView
from .shipment_delivery_photo import ShipmentDeliveryPhotoDetailView, ShipmentDeliveryPhotoListCreateView
__all__ = [
'HealthCheckView',
@@ -58,7 +60,10 @@ __all__ = [
'MissionReplyRejectView',
'MissionSetUrgentView',
'ContentTypeListView',
'MissionByPrintingOrderView',
'AgentUnshippedShipmentListView',
'AgentTransportVehicleListView',
'AgentTransportVehicleDetailView',
'ShipmentDeliveryPhotoListCreateView',
'ShipmentDeliveryPhotoDetailView',
]

View File

@@ -1,6 +1,7 @@
from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache
from django.db import IntegrityError
from django.db.models import Q
from django.db.models import Exists, OuterRef, Prefetch, Q
from django.db.models import ProtectedError
from django.shortcuts import get_object_or_404
from rest_framework import permissions, serializers, status
@@ -12,6 +13,7 @@ from mission import services as mission_services
STATUS_FIELDS = {"is_urgent", "is_completed", "is_cancelled", "cancelled_by", "cancelled_at", "rejected_by", "rejected_at"}
MISSION_BY_PRINTING_ORDER_CACHE_TIMEOUT = 180
def _get_employee(request):
@@ -36,6 +38,7 @@ class MissionWriteSerializer(serializers.Serializer):
category = serializers.IntegerField(required=False, min_value=1)
content_type = serializers.IntegerField(required=False, allow_null=True, min_value=1)
content_id = serializers.IntegerField(required=False, allow_null=True, min_value=1)
extra = serializers.JSONField(required=False, allow_null=True)
notify_if_unreplied = serializers.BooleanField(required=False)
unreplied_notify_interval_minutes = serializers.IntegerField(required=False, allow_null=True, min_value=1)
unreplied_notify_max_count = serializers.IntegerField(required=False, min_value=1)
@@ -96,6 +99,7 @@ class MissionWriteSerializer(serializers.Serializer):
class MissionReplyCreateSerializer(serializers.Serializer):
content = serializers.CharField(allow_blank=False)
ends_task = serializers.BooleanField(required=False, default=False)
extra = serializers.JSONField(required=False, allow_null=True)
class MissionUrgentSerializer(serializers.Serializer):
@@ -129,8 +133,8 @@ class MissionSerializer(serializers.ModelSerializer):
content_type_label = serializers.SerializerMethodField()
category = serializers.IntegerField(source="category_id", read_only=True)
category_name = serializers.CharField(source="category.name", read_only=True)
has_ending_reply = serializers.BooleanField(read_only=True)
can_reply = serializers.BooleanField(read_only=True)
has_ending_reply = serializers.SerializerMethodField()
can_reply = serializers.SerializerMethodField()
class Meta:
model = mission_models.Mission
@@ -155,6 +159,7 @@ class MissionSerializer(serializers.ModelSerializer):
"content_type",
"content_type_label",
"content_id",
"extra",
"has_ending_reply",
"can_reply",
"created_at",
@@ -169,6 +174,9 @@ class MissionSerializer(serializers.ModelSerializer):
return _employee_payload(obj.cancelled_by)
def get_participants(self, obj):
prefetched_participants = getattr(obj, "_prefetched_objects_cache", {}).get("participants")
if prefetched_participants is not None:
return [_employee_payload(participant.employee) for participant in prefetched_participants]
return [
_employee_payload(participant.employee)
for participant in obj.participants.select_related("employee", "employee__merchant")
@@ -179,6 +187,62 @@ class MissionSerializer(serializers.ModelSerializer):
return None
return f"{obj.content_type.app_label}.{obj.content_type.model}"
def get_has_ending_reply(self, obj):
annotated_value = getattr(obj, "has_ending_reply_value", None)
if annotated_value is not None:
return annotated_value
return obj.has_ending_reply
def get_can_reply(self, obj):
return not obj.is_cancelled and not self.get_has_ending_reply(obj)
class MissionLiteSerializer(serializers.ModelSerializer):
creator = serializers.SerializerMethodField()
cancelled_by = serializers.SerializerMethodField()
content_type_label = serializers.SerializerMethodField()
category = serializers.IntegerField(source="category_id", read_only=True)
category_name = serializers.CharField(source="category.name", read_only=True)
class Meta:
model = mission_models.Mission
fields = [
"id",
"merchant",
"description",
"category",
"category_name",
"is_urgent",
"is_completed",
"is_cancelled",
"notify_if_unreplied",
"unreplied_notify_interval_minutes",
"unreplied_notify_max_count",
"unreplied_notify_sent_count",
"unreplied_last_notified_at",
"cancelled_at",
"creator",
"cancelled_by",
"content_type",
"content_type_label",
"content_id",
"extra",
"created_at",
"updated_at",
]
read_only_fields = fields
def get_creator(self, obj):
return _employee_payload(obj.creator)
def get_cancelled_by(self, obj):
return _employee_payload(obj.cancelled_by)
def get_content_type_label(self, obj):
if obj.content_type_id is None:
return None
return f"{obj.content_type.app_label}.{obj.content_type.model}"
class MissionCategorySerializer(serializers.ModelSerializer):
class Meta:
@@ -205,6 +269,7 @@ class MissionReplySerializer(serializers.ModelSerializer):
"merchant",
"responder",
"content",
"extra",
"replied_at",
"ends_task",
"is_rejected",
@@ -222,9 +287,78 @@ class MissionReplySerializer(serializers.ModelSerializer):
return _employee_payload(obj.rejected_by)
def _mission_queryset_for_employee(employee):
return (
class MissionWithRepliesSerializer(MissionSerializer):
replies = serializers.SerializerMethodField()
class Meta(MissionSerializer.Meta):
fields = [*MissionSerializer.Meta.fields, "replies"]
read_only_fields = fields
def get_replies(self, obj):
replies = getattr(obj, "prefetched_replies", None)
if replies is None:
replies = (
obj.replies.select_related(
"responder",
"responder__merchant",
"rejected_by",
"rejected_by__merchant",
)
.order_by("replied_at", "id")
)
return MissionReplySerializer(replies, many=True).data
def _parse_int_list_query_param(request, param_name: str) -> list[int] | None:
raw_values = request.query_params.getlist(param_name)
if not raw_values:
single_value = request.query_params.get(param_name)
if single_value is None:
return None
raw_values = [single_value]
tokens = []
for raw_value in raw_values:
if raw_value is None:
continue
parts = [part.strip() for part in str(raw_value).split(",")]
tokens.extend(part for part in parts if part)
if not tokens:
return None
try:
values = [int(token) for token in tokens]
except (TypeError, ValueError) as exc:
raise serializers.ValidationError({param_name: "必须是整数 ID 列表"}) from exc
if any(value <= 0 for value in values):
raise serializers.ValidationError({param_name: "必须是正整数 ID 列表"})
return list(dict.fromkeys(values))
def _parse_bool_query_param(request, param_name: str, default: bool) -> bool:
raw_value = request.query_params.get(param_name)
if raw_value is None:
return default
value = str(raw_value).strip().lower()
if value in {"1", "true", "yes", "y", "on"}:
return True
if value in {"0", "false", "no", "n", "off"}:
return False
raise serializers.ValidationError({param_name: "必须是布尔值"})
def _mission_queryset_for_employee(employee, *, include_details: bool = True):
has_ending_reply_subquery = mission_models.MissionReply.objects.filter(
mission_id=OuterRef("pk"),
ends_task=True,
is_rejected=False,
)
queryset = (
mission_models.Mission.objects.filter(merchant=employee.merchant)
.annotate(has_ending_reply_value=Exists(has_ending_reply_subquery))
.select_related(
"merchant",
"category",
@@ -234,9 +368,18 @@ def _mission_queryset_for_employee(employee):
"cancelled_by__merchant",
"content_type",
)
.prefetch_related("participants__employee", "participants__employee__merchant")
.order_by("-created_at", "-id")
)
if include_details:
queryset = queryset.prefetch_related("participants__employee", "participants__employee__merchant")
return queryset
def _mission_by_printing_order_cache_key(*, merchant_id: int, printing_order_id: int, category_ids: list[int] | None,
include_details: bool) -> str:
category_key = "all" if category_ids is None else ",".join(str(category_id) for category_id in category_ids)
detail_key = "detail" if include_details else "lite"
return f"mission:by-printing-order:{merchant_id}:{printing_order_id}:{category_key}:{detail_key}"
def _mission_category_queryset_for_employee(employee):
@@ -334,6 +477,7 @@ class MissionListCreateView(APIView):
category=data.get("category"),
content_type=data.get("content_type"),
content_id=data.get("content_id"),
extra=data.get("extra"),
participant_ids=data.get("participant_ids"),
notify_if_unreplied=data.get("notify_if_unreplied", False),
unreplied_notify_interval_minutes=data.get("unreplied_notify_interval_minutes"),
@@ -344,6 +488,56 @@ class MissionListCreateView(APIView):
return Response(MissionSerializer(mission).data, status=status.HTTP_201_CREATED)
class MissionByPrintingOrderView(APIView):
permission_classes = [permissions.IsAuthenticated]
def get(self, request, printing_order_id):
employee = _get_employee(request)
from printing.models import PrintingOrder
printing_order = get_object_or_404(
PrintingOrder.objects.filter(merchant=employee.merchant),
id=printing_order_id,
)
category_ids = _parse_int_list_query_param(request, "category_ids")
include_details = _parse_bool_query_param(request, "include_details", default=True)
cache_key = _mission_by_printing_order_cache_key(
merchant_id=employee.merchant_id,
printing_order_id=printing_order.id,
category_ids=category_ids,
include_details=include_details,
)
cached_data = cache.get(cache_key)
if cached_data is not None:
return Response(cached_data)
printing_order_content_type = ContentType.objects.get_for_model(PrintingOrder)
queryset = _mission_queryset_for_employee(employee, include_details=include_details).filter(
content_type=printing_order_content_type,
content_id=printing_order.id,
)
if category_ids is not None:
queryset = queryset.filter(category_id__in=category_ids)
if include_details:
replies_queryset = mission_models.MissionReply.objects.select_related(
"responder",
"responder__merchant",
"rejected_by",
"rejected_by__merchant",
).order_by("replied_at", "id")
queryset = queryset.prefetch_related(
Prefetch("replies", queryset=replies_queryset, to_attr="prefetched_replies")
)
serializer_class = MissionWithRepliesSerializer if include_details else MissionLiteSerializer
data = serializer_class(queryset, many=True).data
cache.set(cache_key, data, timeout=MISSION_BY_PRINTING_ORDER_CACHE_TIMEOUT)
return Response(data)
class MissionDetailView(APIView):
permission_classes = [permissions.IsAuthenticated]
@@ -373,6 +567,7 @@ class MissionDetailView(APIView):
category=data.get("category"),
content_type=data.get("content_type"),
content_id=data.get("content_id"),
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"),
notify_if_unreplied=(
@@ -427,6 +622,7 @@ class MissionReplyListCreateView(APIView):
responder=employee,
content=serializer.validated_data["content"],
ends_task=serializer.validated_data.get("ends_task", False),
extra=serializer.validated_data.get("extra"),
)
except ValueError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)

View File

@@ -0,0 +1,144 @@
from rest_framework import permissions, serializers, status
from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.response import Response
from rest_framework.views import APIView
from shipment import models as shipment_models
def _get_employee(request):
employee = getattr(request.user, 'employee', None)
if employee is None:
raise serializers.ValidationError('当前用户未关联员工')
return employee
class ShipmentDeliveryPhotoWriteSerializer(serializers.ModelSerializer):
shipment = serializers.PrimaryKeyRelatedField(queryset=shipment_models.Shipment.objects.all())
delivery = serializers.PrimaryKeyRelatedField(queryset=shipment_models.ShipmentDelivery.objects.all())
class Meta:
model = shipment_models.ShipmentDeliveryPhoto
fields = ['shipment', 'delivery', 'photo', 'remark']
def validate(self, attrs):
employee = self.context['employee']
instance = self.instance
shipment = attrs.get('shipment', getattr(instance, 'shipment', None))
delivery = attrs.get('delivery', getattr(instance, 'delivery', None))
if shipment is None:
raise serializers.ValidationError({'shipment': '出货单不能为空'})
if delivery is None:
raise serializers.ValidationError({'delivery': '送货单不能为空'})
if shipment.merchant_id != employee.merchant_id:
raise serializers.ValidationError({'shipment': '出货单不属于当前商户'})
if delivery.merchant_id != employee.merchant_id:
raise serializers.ValidationError({'delivery': '送货单不属于当前商户'})
if shipment.delivery_id != delivery.id:
raise serializers.ValidationError({'delivery': '送货单与出货单当前绑定关系不一致'})
return attrs
class ShipmentDeliveryPhotoReadSerializer(serializers.ModelSerializer):
created_by = serializers.IntegerField(source='created_by_id', read_only=True, allow_null=True)
shipment_id = serializers.IntegerField(read_only=True)
delivery_id = serializers.IntegerField(read_only=True)
class Meta:
model = shipment_models.ShipmentDeliveryPhoto
fields = [
'id',
'shipment_id',
'delivery_id',
'photo',
'remark',
'created_by',
'created_at',
'updated_at',
]
read_only_fields = fields
class ShipmentDeliveryPhotoListCreateView(APIView):
permission_classes = [permissions.IsAuthenticated]
parser_classes = [MultiPartParser, FormParser]
def _queryset(self, employee):
return shipment_models.ShipmentDeliveryPhoto.objects.select_related(
'shipment', 'delivery', 'created_by'
).filter(
shipment__merchant=employee.merchant,
delivery__merchant=employee.merchant,
)
def get(self, request):
employee = _get_employee(request)
queryset = self._queryset(employee)
shipment_id = request.query_params.get('shipment_id')
delivery_id = request.query_params.get('delivery_id')
if shipment_id:
queryset = queryset.filter(shipment_id=shipment_id)
if delivery_id:
queryset = queryset.filter(delivery_id=delivery_id)
return Response(ShipmentDeliveryPhotoReadSerializer(queryset, many=True, context={'request': request}).data)
def post(self, request):
employee = _get_employee(request)
serializer = ShipmentDeliveryPhotoWriteSerializer(data=request.data, context={'employee': employee})
serializer.is_valid(raise_exception=True)
photo = serializer.save(created_by=request.user)
return Response(
ShipmentDeliveryPhotoReadSerializer(photo, context={'request': request}).data,
status=status.HTTP_201_CREATED,
)
class ShipmentDeliveryPhotoDetailView(APIView):
permission_classes = [permissions.IsAuthenticated]
parser_classes = [MultiPartParser, FormParser]
def get_object(self, request, photo_id):
employee = _get_employee(request)
return shipment_models.ShipmentDeliveryPhoto.objects.select_related(
'shipment', 'delivery', 'created_by'
).filter(
shipment__merchant=employee.merchant,
delivery__merchant=employee.merchant,
id=photo_id,
).first()
def get(self, request, photo_id):
photo = self.get_object(request, photo_id)
if photo is None:
return Response({'detail': '未找到送达照片'}, status=status.HTTP_404_NOT_FOUND)
return Response(ShipmentDeliveryPhotoReadSerializer(photo, context={'request': request}).data)
def patch(self, request, photo_id):
photo = self.get_object(request, photo_id)
if photo is None:
return Response({'detail': '未找到送达照片'}, status=status.HTTP_404_NOT_FOUND)
employee = _get_employee(request)
serializer = ShipmentDeliveryPhotoWriteSerializer(photo, data=request.data, partial=True, context={'employee': employee})
serializer.is_valid(raise_exception=True)
photo = serializer.save()
return Response(ShipmentDeliveryPhotoReadSerializer(photo, context={'request': request}).data)
def put(self, request, photo_id):
photo = self.get_object(request, photo_id)
if photo is None:
return Response({'detail': '未找到送达照片'}, status=status.HTTP_404_NOT_FOUND)
employee = _get_employee(request)
serializer = ShipmentDeliveryPhotoWriteSerializer(photo, data=request.data, context={'employee': employee})
serializer.is_valid(raise_exception=True)
photo = serializer.save()
return Response(ShipmentDeliveryPhotoReadSerializer(photo, context={'request': request}).data)
def delete(self, request, photo_id):
photo = self.get_object(request, photo_id)
if photo is None:
return Response({'detail': '未找到送达照片'}, status=status.HTTP_404_NOT_FOUND)
photo.delete()
return Response(status=status.HTTP_204_NO_CONTENT)