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 ProtectedError from django.shortcuts import get_object_or_404 from rest_framework import permissions, serializers, status from rest_framework.response import Response from rest_framework.views import APIView from mission import models as mission_models 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): employee = getattr(request.user, "employee", None) if employee is None: raise serializers.ValidationError("当前用户未关联员工") return employee def _employee_payload(employee): if employee is None: return None return { "id": employee.id, "name": employee.name, "merchant_id": employee.merchant_id, } class MissionWriteSerializer(serializers.Serializer): description = serializers.CharField(required=False, allow_blank=False) 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) participant_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) super().__init__(*args, **kwargs) if self.is_create: self.fields["description"].required = True def validate_category(self, value): employee = self.context.get("employee") if employee is None: return value try: return mission_models.MissionCategory.objects.get(id=value, merchant=employee.merchant) except mission_models.MissionCategory.DoesNotExist as exc: raise serializers.ValidationError("任务分类不存在") from exc def validate(self, attrs): forbidden_fields = STATUS_FIELDS.intersection(self.initial_data.keys()) if forbidden_fields: raise serializers.ValidationError({field: "状态字段不能通过普通 CRUD 接口修改" for field in sorted(forbidden_fields)}) content_type_provided = "content_type" in self.initial_data content_id_provided = "content_id" in self.initial_data if content_type_provided != content_id_provided: raise serializers.ValidationError("content_type 与 content_id 必须同时提供或同时省略") content_type_id = attrs.get("content_type") if content_type_id is not None: try: attrs["content_type"] = ContentType.objects.get(id=content_type_id) except ContentType.DoesNotExist as exc: raise serializers.ValidationError({"content_type": "ContentType 不存在"}) from exc instance = self.context.get("instance") final_notify_if_unreplied = attrs.get( "notify_if_unreplied", getattr(instance, "notify_if_unreplied", False), ) final_interval = attrs.get( "unreplied_notify_interval_minutes", getattr(instance, "unreplied_notify_interval_minutes", None), ) if final_notify_if_unreplied and final_interval is None: raise serializers.ValidationError( {"unreplied_notify_interval_minutes": "开启未回复提醒时必须设置提醒间隔"} ) return attrs 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): is_urgent = serializers.BooleanField() class MissionCategoryWriteSerializer(serializers.Serializer): name = serializers.CharField(allow_blank=False, max_length=50) speech_enabled = serializers.BooleanField(required=False) payload_processor = serializers.ChoiceField( choices=mission_models.MissionPayloadProcessorEnum.choices, required=False, allow_blank=True, ) def validate_name(self, value): value = value.strip() if not value: raise serializers.ValidationError("分类名称不能为空") employee = self.context["employee"] instance = self.context.get("instance") queryset = mission_models.MissionCategory.objects.filter( merchant=employee.merchant, name=value, ) if instance is not None: queryset = queryset.exclude(id=instance.id) if queryset.exists(): raise serializers.ValidationError("分类名称已存在") return value class MissionSerializer(serializers.ModelSerializer): creator = serializers.SerializerMethodField() cancelled_by = serializers.SerializerMethodField() participants = 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) has_ending_reply = serializers.SerializerMethodField() can_reply = serializers.SerializerMethodField() 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", "participants", "content_type", "content_type_label", "content_id", "extra", "has_ending_reply", "can_reply", "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_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") ] 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}" 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: model = mission_models.MissionCategory fields = [ "id", "merchant", "name", "payload_processor", "speech_enabled", "created_at", "updated_at", ] read_only_fields = fields class MissionReplySerializer(serializers.ModelSerializer): responder = serializers.SerializerMethodField() rejected_by = serializers.SerializerMethodField() class Meta: model = mission_models.MissionReply fields = [ "id", "mission", "merchant", "responder", "content", "extra", "replied_at", "ends_task", "is_rejected", "rejected_by", "rejected_at", "created_at", "updated_at", ] read_only_fields = fields def get_responder(self, obj): return _employee_payload(obj.responder) def get_rejected_by(self, obj): return _employee_payload(obj.rejected_by) 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", "creator", "creator__merchant", "cancelled_by", "cancelled_by__merchant", "content_type", ) .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): return mission_models.MissionCategory.objects.filter(merchant=employee.merchant).order_by("id") class MissionCategoryListCreateView(APIView): permission_classes = [permissions.IsAuthenticated] def get(self, request): employee = _get_employee(request) queryset = _mission_category_queryset_for_employee(employee) return Response(MissionCategorySerializer(queryset, many=True).data) def post(self, request): employee = _get_employee(request) serializer = MissionCategoryWriteSerializer(data=request.data, context={"employee": employee}) serializer.is_valid(raise_exception=True) try: category = mission_models.MissionCategory.objects.create( merchant=employee.merchant, name=serializer.validated_data["name"], payload_processor=serializer.validated_data.get("payload_processor", ""), speech_enabled=serializer.validated_data.get("speech_enabled", False), ) except IntegrityError: return Response({"name": ["分类名称已存在"]}, status=status.HTTP_400_BAD_REQUEST) return Response(MissionCategorySerializer(category).data, status=status.HTTP_201_CREATED) class MissionCategoryDetailView(APIView): permission_classes = [permissions.IsAuthenticated] def get_object(self, request, category_id): employee = _get_employee(request) return get_object_or_404(_mission_category_queryset_for_employee(employee), id=category_id) def get(self, request, category_id): category = self.get_object(request, category_id) return Response(MissionCategorySerializer(category).data) def patch(self, request, category_id): employee = _get_employee(request) category = self.get_object(request, category_id) serializer = MissionCategoryWriteSerializer( data=request.data, partial=True, context={"employee": employee, "instance": category}, ) serializer.is_valid(raise_exception=True) update_fields = [] if "name" in serializer.validated_data: category.name = serializer.validated_data["name"] update_fields.append("name") if "payload_processor" in serializer.validated_data: category.payload_processor = serializer.validated_data["payload_processor"] update_fields.append("payload_processor") if "speech_enabled" in serializer.validated_data: category.speech_enabled = serializer.validated_data["speech_enabled"] update_fields.append("speech_enabled") if update_fields: try: category.save(update_fields=[*update_fields, "updated_at"]) except IntegrityError: return Response({"name": ["分类名称已存在"]}, status=status.HTTP_400_BAD_REQUEST) return Response(MissionCategorySerializer(category).data) def delete(self, request, category_id): category = self.get_object(request, category_id) try: category.delete() except ProtectedError: return Response({"detail": "任务分类已被使用,不能删除"}, status=status.HTTP_400_BAD_REQUEST) return Response(status=status.HTTP_204_NO_CONTENT) class MissionListCreateView(APIView): permission_classes = [permissions.IsAuthenticated] def get(self, request): employee = _get_employee(request) queryset = _mission_queryset_for_employee(employee) if request.query_params.get("category"): queryset = queryset.filter(category_id=request.query_params["category"]) for field in ["is_urgent", "is_completed", "is_cancelled"]: value = request.query_params.get(field) if value is not None: queryset = queryset.filter(**{field: value.lower() in ["1", "true", "yes"]}) if request.query_params.get("content_type"): queryset = queryset.filter(content_type_id=request.query_params["content_type"]) if request.query_params.get("content_id"): queryset = queryset.filter(content_id=request.query_params["content_id"]) return Response(MissionSerializer(queryset, many=True).data) def post(self, request): employee = _get_employee(request) serializer = MissionWriteSerializer(data=request.data, is_create=True, context={"employee": employee}) serializer.is_valid(raise_exception=True) data = serializer.validated_data try: mission = mission_services.create_mission( creator=employee, description=data["description"], 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"), unreplied_notify_max_count=data.get("unreplied_notify_max_count", 5), ) except ValueError as exc: return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) 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] def get_object(self, request, mission_id): employee = _get_employee(request) return get_object_or_404(_mission_queryset_for_employee(employee), id=mission_id) def get(self, request, mission_id): mission = self.get_object(request, mission_id) return Response(MissionSerializer(mission).data) def patch(self, request, mission_id): employee = _get_employee(request) mission = self.get_object(request, mission_id) serializer = MissionWriteSerializer( data=request.data, partial=True, context={"employee": employee, "instance": mission}, ) serializer.is_valid(raise_exception=True) data = serializer.validated_data try: mission = mission_services.update_mission( mission=mission, updated_by=employee, description=data.get("description"), 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=( data["notify_if_unreplied"] if "notify_if_unreplied" in data else mission_services.UNSET ), unreplied_notify_interval_minutes=( data["unreplied_notify_interval_minutes"] if "unreplied_notify_interval_minutes" in data else mission_services.UNSET ), unreplied_notify_max_count=( data["unreplied_notify_max_count"] if "unreplied_notify_max_count" in data else mission_services.UNSET ), ) except ValueError as exc: return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) return Response(MissionSerializer(mission).data) def delete(self, request, mission_id): return Response({"detail": "Mission 删除接口未提供,请使用 cancel 接口取消任务"}, status=status.HTTP_405_METHOD_NOT_ALLOWED) class MissionReplyListCreateView(APIView): permission_classes = [permissions.IsAuthenticated] def get(self, request, mission_id): employee = _get_employee(request) mission = get_object_or_404(_mission_queryset_for_employee(employee), id=mission_id) queryset = ( mission_models.MissionReply.objects.filter(mission=mission) .select_related("responder", "responder__merchant", "rejected_by", "rejected_by__merchant") .order_by("replied_at", "id") ) for field in ["ends_task", "is_rejected"]: value = request.query_params.get(field) if value is not None: queryset = queryset.filter(**{field: value.lower() in ["1", "true", "yes"]}) return Response(MissionReplySerializer(queryset, many=True).data) def post(self, request, mission_id): employee = _get_employee(request) mission = get_object_or_404(_mission_queryset_for_employee(employee), id=mission_id) serializer = MissionReplyCreateSerializer(data=request.data) serializer.is_valid(raise_exception=True) try: reply = mission_services.create_mission_reply( mission=mission, 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) return Response(MissionReplySerializer(reply).data, status=status.HTTP_201_CREATED) class MissionReopenView(APIView): permission_classes = [permissions.IsAuthenticated] def post(self, request, mission_id): if not request.user.has_perm("mission.reopen_mission"): return Response({"detail": "缺少重新打开任务权限"}, status=status.HTTP_403_FORBIDDEN) employee = _get_employee(request) mission = get_object_or_404(_mission_queryset_for_employee(employee), id=mission_id) try: mission = mission_services.reopen_mission(mission=mission, reopened_by=employee) except ValueError as exc: return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) return Response(MissionSerializer(mission).data) class MissionCancelView(APIView): permission_classes = [permissions.IsAuthenticated] def post(self, request, mission_id): employee = _get_employee(request) mission = get_object_or_404(_mission_queryset_for_employee(employee), id=mission_id) try: mission = mission_services.cancel_mission(mission=mission, cancelled_by=employee) except ValueError as exc: return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) return Response(MissionSerializer(mission).data) class MissionReplyRejectView(APIView): permission_classes = [permissions.IsAuthenticated] def post(self, request, reply_id): if not request.user.has_perm("mission.reject_mission_reply"): return Response({"detail": "缺少撤销任务回应权限"}, status=status.HTTP_403_FORBIDDEN) employee = _get_employee(request) reply = get_object_or_404( mission_models.MissionReply.objects.select_related("mission", "responder", "rejected_by").filter( merchant=employee.merchant, ), id=reply_id, ) try: reply = mission_services.reject_reply(reply=reply, rejected_by=employee) except ValueError as exc: return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) return Response(MissionReplySerializer(reply).data) class MissionSetUrgentView(APIView): permission_classes = [permissions.IsAuthenticated] def post(self, request, mission_id): employee = _get_employee(request) mission = get_object_or_404(_mission_queryset_for_employee(employee), id=mission_id) serializer = MissionUrgentSerializer(data=request.data) serializer.is_valid(raise_exception=True) try: mission = mission_services.set_mission_urgent( mission=mission, updated_by=employee, is_urgent=serializer.validated_data["is_urgent"], ) except ValueError as exc: return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) return Response(MissionSerializer(mission).data) class ContentTypeListView(APIView): permission_classes = [permissions.IsAuthenticated] # Models the frontend is allowed to use as mission content_type targets. _ALLOWED_CONTENT_TYPES = [ ("printing", "plateorder"), ("printing", "printingorder"), ("printing", "printingjob"), ("business", "purchaseorder"), ("business", "presalesorder"), ("business", "salesorder"), ("business", "prepurchaseorder"), ("business", "purchasereturnorder"), ("business", "salesreturnorder"), ("business", "paymentorder"), ("business", "receiptorder"), ("stock", "transferorder"), ("shipment", "shipment"), ("stateflow", "process"), ] # Human-readable labels keyed by (app_label, model). _LABELS = { ("printing", "plateorder"): "开版单", ("printing", "printingorder"): "印刷单", ("printing", "printingjob"): "印刷任务", ("business", "purchaseorder"): "采购单", ("business", "presalesorder"): "预销售单", ("business", "salesorder"): "销售单", ("business", "prepurchaseorder"): "预采购单", ("business", "purchasereturnorder"): "采购退货单", ("business", "salesreturnorder"): "销售退货单", ("business", "paymentorder"): "付款单", ("business", "receiptorder"): "收款单", ("stock", "transferorder"): "调拨单", ("shipment", "shipment"): "发货单", ("stateflow", "process"): "工艺流程实例", } def get(self, request): filters = Q() for app_label, model in self._ALLOWED_CONTENT_TYPES: filters |= Q(app_label=app_label, model=model) cts = ContentType.objects.filter(filters).order_by("app_label", "model") data = [ { "id": ct.id, "app_label": ct.app_label, "model": ct.model, "label": f"{ct.app_label}.{ct.model}", "name": self._LABELS.get((ct.app_label, ct.model), ct.model), } for ct in cts ] return Response(data)