diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000000000000000000000000000000000000..25f1ac39ea55e2d1c684ba7ec2838859565b2ad8 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "web/src/views/plugins/dvadmin3-celery-web"] + path = web/src/views/plugins/dvadmin3-celery-web + url = https://gitee.com/wskaudh/dvadmin3-celery-web.git +[submodule "backend/plugins/dvadmin3-celery"] + path = backend/plugins/dvadmin3-celery + url = https://gitee.com/lxy0722/dvadmin3-celery.git diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..9bad5790a5799b96f2e164d825c0b1f8ec0c2dfb --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +# coding=utf-8 diff --git a/backend/app/material/__init__.py b/backend/app/material/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/app/material/admin.py b/backend/app/material/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..8c38f3f3dad51e4585f3984282c2a4bec5349c1e --- /dev/null +++ b/backend/app/material/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/backend/app/material/apps.py b/backend/app/material/apps.py new file mode 100644 index 0000000000000000000000000000000000000000..32b36f375056c38f234d0116e658034115effcef --- /dev/null +++ b/backend/app/material/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class MaterialConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'app.material' diff --git a/backend/app/material/migrations/__init__.py b/backend/app/material/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/app/material/models.py b/backend/app/material/models.py new file mode 100644 index 0000000000000000000000000000000000000000..f1abdc27dcaf20659ced391cc370a30a3fed5a66 --- /dev/null +++ b/backend/app/material/models.py @@ -0,0 +1,104 @@ +# coding=utf-8 + + +from django.db import models + +# Create your models here. +from dvadmin.utils.models import CoreModel, SoftDeleteModel, table_prefix +from dvadmin.system.models import Users + + +class MaterialBaseInfo(CoreModel, SoftDeleteModel): + type = models.CharField(max_length=255, verbose_name="物料类型", help_text="物料类型") + name = models.CharField(unique=True, max_length=255, verbose_name="物料名称", help_text="物料名称") + code = models.CharField(unique=True, max_length=255, verbose_name="物料编码", help_text="物料编码") + specification = models.CharField(max_length=255, null=True, blank=True, verbose_name="物料规格", help_text="物料规格") + storage_location = models.CharField(max_length=255, null=True, blank=True, verbose_name="存放位置", help_text="存放位置") + safety_stock = models.IntegerField(default=0, verbose_name="安全库存", help_text="安全库存") + + class Meta: + db_table = table_prefix + "material" + verbose_name = "物料基础信息管理" + verbose_name_plural = verbose_name + ordering = ("-create_datetime", "-update_datetime") + + +class MaterialInventory(CoreModel): + number = models.IntegerField(default=0, verbose_name="剩余库存", help_text="剩余库存") + SAFETY_STOCK_LEVEL = ( + (0, "安全库存"), + (1, "及时补充库存"), + (2, "紧急补充库存"), + (3, "无库存"), + ) + safety_stock_level = models.IntegerField(default=3, choices=SAFETY_STOCK_LEVEL, null=True, blank=True, + verbose_name="剩余安全库存等级", help_text="剩余安全库存等级") + code = models.OneToOneField( + to="MaterialBaseInfo", + on_delete=models.PROTECT, + # to_field="id", + verbose_name="关联物料编码", + help_text="关联物料编码", + default=None, + related_name="inventory", + ) + + class Meta: + db_table = table_prefix + "material_inventory" + verbose_name = "物料库存数量管理" + verbose_name_plural = verbose_name + ordering = ("number", "-update_datetime") + + +class MaterialRecords(CoreModel): + RECORD_TYPE = ( + (0, "入库"), + (1, "出库"), + ) + record_type = models.IntegerField(choices=RECORD_TYPE, null=True, blank=True, verbose_name="出入库类型", help_text="出入库类型") + receive_name = models.CharField(max_length=255, null=True, blank=True, verbose_name="领用人", help_text="领用人") + number = models.IntegerField(null=True, blank=True, verbose_name="数量", help_text="数量") + code = models.ForeignKey( + to="MaterialBaseInfo", + on_delete=models.PROTECT, + # to_field="code", + verbose_name="关联物料编码", + help_text="关联物料编码", + related_name="records", + ) + + class Meta: + db_table = table_prefix + "material_number_records" + verbose_name = "物料出入库记录" + verbose_name_plural = verbose_name + ordering = ("-create_datetime", "-update_datetime") + + +class MaterialApplyFor(CoreModel): + + examine_user = models.CharField(max_length=255, null=True, blank=True, verbose_name="审核人", help_text="审核人") + EXAMINE_TYPE = ( + (0, "审核通过"), + (1, "待审核"), + (2, "审核不通过"), + (3, "已撤回"), + ) + examine_status = models.IntegerField(default=1, choices=EXAMINE_TYPE, verbose_name="审核状态", help_text="审核状态") + examine_opinion = models.CharField(max_length=255, null=True, blank=True, verbose_name="审核意见", help_text="审核意见") + number = models.IntegerField(null=True, verbose_name="申请数量", help_text="申请数量") + reason = models.CharField(max_length=255, null=True, blank=True, verbose_name="申请理由", help_text="申请理由") + url = models.CharField(max_length=255, null=True, blank=True, verbose_name="申请链接", help_text="申请链接") + code = models.ForeignKey( + to="MaterialBaseInfo", + on_delete=models.PROTECT, + # to_field="code", + verbose_name="关联物料编码", + help_text="关联物料编码", + related_name="apply_for", + ) + + class Meta: + db_table = table_prefix + "material_apply_for" + verbose_name = "物料申请" + verbose_name_plural = verbose_name + ordering = ("-create_datetime", "-update_datetime") diff --git a/backend/app/material/serializers.py b/backend/app/material/serializers.py new file mode 100644 index 0000000000000000000000000000000000000000..aac8161fcd553e91db4613ff9ac355469906b98b --- /dev/null +++ b/backend/app/material/serializers.py @@ -0,0 +1,197 @@ +# coding=utf-8 + +from application import dispatch +from rest_framework import serializers +from dvadmin.utils.json_response import DetailResponse, ErrorResponse + + +from app.material.models import MaterialBaseInfo, MaterialInventory, MaterialRecords, MaterialApplyFor +from dvadmin.utils.serializers import CustomModelSerializer +from dvadmin.system.models import Users + +RECORD_TYPE = {} +for i in MaterialRecords.RECORD_TYPE: + RECORD_TYPE[i[0]] = i[1] + + +EXAMINE_TYPE = {} +for i in MaterialApplyFor.EXAMINE_TYPE: + EXAMINE_TYPE[i[0]] = i[1] + + +SAFETY_STOCK_LEVEL = {} +for i in MaterialInventory.SAFETY_STOCK_LEVEL: + SAFETY_STOCK_LEVEL[i[0]] = i[1] + + +class MaterialBaseInfoSerializer(CustomModelSerializer): + + class Meta: + model = MaterialBaseInfo + fields = "__all__" + + +class MaterialBaseInfoUpdateSerializer(CustomModelSerializer): + type = serializers.CharField(read_only=True) + code = serializers.CharField(read_only=True) + + class Meta: + model = MaterialBaseInfo + fields = "__all__" + + +class MaterialBaseInfoExportSerializer(CustomModelSerializer): + type = serializers.CharField(read_only=True) + name = serializers.CharField(read_only=True) + code = serializers.CharField(read_only=True) + user_name = serializers.CharField(source='creator.name', read_only=True) + + class Meta: + model = MaterialBaseInfo + fields = "__all__" + + +class MaterialInventoryCreateSerializer(CustomModelSerializer): + + class Meta: + model = MaterialInventory + fields = "__all__" + + +class MaterialInventoryExportSerializer(CustomModelSerializer): + type = serializers.CharField(source='code.type', read_only=True) + name = serializers.CharField(source='code.name', read_only=True) + code = serializers.CharField(source='code.code', read_only=True) + specification = serializers.CharField(source='code.specification', read_only=True) + safety_stock = serializers.CharField(source='code.safety_stock', read_only=True) + storage_location = serializers.CharField(source='code.storage_location', read_only=True) + user_name = serializers.CharField(source='creator.name', read_only=True) + safety_stock_level = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = MaterialInventory + fields = "__all__" + + def get_safety_stock_level(self, instance): + if not hasattr(instance, "safety_stock_level") or instance.safety_stock_level not in SAFETY_STOCK_LEVEL: + return None + + return SAFETY_STOCK_LEVEL[instance.safety_stock_level] + + +class MaterialInventorySerializer(CustomModelSerializer): + type = serializers.CharField(source='code.type', read_only=True) + name = serializers.CharField(source='code.name', read_only=True) + code = serializers.CharField(source='code.code', read_only=True) + specification = serializers.CharField(source='code.specification', read_only=True) + + class Meta: + model = MaterialInventory + fields = "__all__" + + +class MaterialRecordsUpdateSerializer(CustomModelSerializer): + + class Meta: + model = MaterialRecords + fields = "__all__" + + +class MaterialRecordsExportSerializer(CustomModelSerializer): + type = serializers.CharField(source='code.type', read_only=True) + name = serializers.CharField(source='code.name', read_only=True) + code = serializers.CharField(source='code.code', read_only=True) + specification = serializers.CharField(source='code.specification', read_only=True) + safety_stock = serializers.CharField(source='code.safety_stock', read_only=True) + storage_location = serializers.CharField(source='code.storage_location', read_only=True) + user_name = serializers.CharField(source='creator.name', read_only=True) + record_type = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = MaterialRecords + fields = "__all__" + + def get_record_type(self, instance): + if not hasattr(instance, "record_type") or instance.record_type not in RECORD_TYPE: + return None + + return RECORD_TYPE[instance.record_type] + + +class MaterialRecordsSerializer(CustomModelSerializer): + type = serializers.CharField(source='code.type', read_only=True) + name = serializers.CharField(source='code.name', read_only=True) + code = serializers.CharField(source='code.code', read_only=True) + specification = serializers.CharField(source='code.specification', read_only=True) + + class Meta: + model = MaterialRecords + fields = "__all__" + + +class MaterialApplyForSerializer(CustomModelSerializer): + type = serializers.CharField(source='code.type', read_only=True) + name = serializers.CharField(source='code.name', read_only=True) + code = serializers.CharField(source='code.code', read_only=True) + specification = serializers.CharField(source='code.specification', read_only=True) + # 前端重新申请时展示使用 + material = serializers.CharField(source='code.id', read_only=True) + examine_user_name = serializers.SerializerMethodField(method_name='get_examine_user_name', read_only=True) + + class Meta: + model = MaterialApplyFor + fields = "__all__" + + def get_examine_user_name(self, instance): + if not hasattr(instance, "examine_user"): + return None + queryset = ( + Users.objects.filter(id=instance.examine_user) + .values_list("name", flat=True) + .first() + ) + if queryset: + return queryset + return None + + +class MaterialApplyForExportSerializer(CustomModelSerializer): + type = serializers.CharField(source='code.type', read_only=True) + name = serializers.CharField(source='code.name', read_only=True) + code = serializers.CharField(source='code.code', read_only=True) + specification = serializers.CharField(source='code.specification', read_only=True) + safety_stock = serializers.CharField(source='code.safety_stock', read_only=True) + storage_location = serializers.CharField(source='code.storage_location', read_only=True) + user_name = serializers.CharField(source='creator.name', read_only=True) + examine_user_name = serializers.SerializerMethodField(method_name='get_examine_user_name', read_only=True) + examine_status = serializers.SerializerMethodField(method_name='get_examine_status', read_only=True) + + class Meta: + model = MaterialApplyFor + fields = "__all__" + + def get_examine_user_name(self, instance): + if not hasattr(instance, "examine_user"): + return None + queryset = ( + Users.objects.filter(id=instance.examine_user) + .values_list("name", flat=True) + .first() + ) + if queryset: + return queryset + return None + + def get_examine_status(self, instance): + if not hasattr(instance, "examine_status") or instance.examine_status not in EXAMINE_TYPE: + return None + + return EXAMINE_TYPE[instance.examine_status] + + +class MaterialApplyForCreateSerializer(CustomModelSerializer): + + class Meta: + model = MaterialApplyFor + fields = "__all__" + diff --git a/backend/app/material/tests.py b/backend/app/material/tests.py new file mode 100644 index 0000000000000000000000000000000000000000..7ce503c2dd97ba78597f6ff6e4393132753573f6 --- /dev/null +++ b/backend/app/material/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/app/material/urls.py b/backend/app/material/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..53c4e69dcce74845dbef2390842ffbe9388002bc --- /dev/null +++ b/backend/app/material/urls.py @@ -0,0 +1,21 @@ +# coding=utf-8 + +from rest_framework.routers import SimpleRouter +from django.urls import path +from django.http import HttpResponse, JsonResponse +from dvadmin.system.views.clause import PrivacyView, TermsServiceView + +from .views import MaterialBaseInfoViewSet, MaterialInventoryViewSet, MaterialRecordsViewSet, MaterialApplyForViewSet + +router = SimpleRouter() +# 这里进行注册路径,并把视图关联上,这里的api地址以视图名称为后缀,这样方便记忆api/CrudDemoModelViewSet +router.register("base_info", MaterialBaseInfoViewSet) +router.register("inventory", MaterialInventoryViewSet) +router.register("operation_log", MaterialRecordsViewSet) +router.register("apply_for", MaterialApplyForViewSet) + +urlpatterns = [ + +] + +urlpatterns += router.urls diff --git a/backend/app/material/views.py b/backend/app/material/views.py new file mode 100644 index 0000000000000000000000000000000000000000..d0ed4f5c20b2d72d45b52cdae7987300ca085af3 --- /dev/null +++ b/backend/app/material/views.py @@ -0,0 +1,628 @@ +import os +import datetime + +from django.db import transaction +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.filters import BaseFilterBackend + +from dvadmin.utils.json_response import DetailResponse, SuccessResponse, ErrorResponse +from dvadmin.utils.viewset import CustomModelViewSet +from dvadmin.utils.permission import CustomPermission +from dvadmin.utils.filters import DataLevelPermissionsFilter, CoreModelFilterBankend +from dvadmin.system.models import Users, Role, MenuButton, RoleMenuButtonPermission + +from dvadmin.system.views.message_center import websocket_push as message_push, MessageCenterCreateSerializer + +from app.material.serializers import * + + +def websocket_push_designated_user(user_id, title, message, material_type=None, material_name=None, number=None): + """ + 主动推送消息 + """ + try: + data = MessageCenterCreateSerializer(data={ + 'title': title, + 'target_type': 0, + 'target_user': [user_id], + 'content': f'
{message}
' + f'物料类型:{material_type}
' + f'物料名称:{material_name}
' + f'申请数量:{number}
' + }) + data.is_valid(raise_exception=True) + data.save() + + message_push(user_id, + message={ + "sender": 'system', + "contentType": 'MATERIAL', + "content": message, + "unread": 0} + ) + + except: + pass + + +def websocket_push_designated_role(role, title, message): + """ + 主动推送消息 + """ + try: + data = MessageCenterCreateSerializer(data={ + 'title': title, + 'target_type': 1, + 'target_role': role, + 'content': f'{message}
' + }) + data.is_valid(raise_exception=True) + data.save() + for user in GetPermissionUsers().get_permission_users(role): + message_push(user, + message={ + "sender": 'system', + "contentType": 'MATERIAL', + "title": title, + "content": message, + "unread": 1} + ) + except: + pass + + +class GetPermissionUsers: + + def get_permission_role(self, url, method): + queryset = RoleMenuButtonPermission.objects.filter(menu_button__api=url, menu_button__method=method) + + return queryset.values_list("role_id", flat=True) + + def get_permission_users(self, role_id): + users = Users.objects.filter(role__in=role_id, is_superuser=False, is_active=True) + + return users.values_list("id", flat=True) + + def get_users(self, api_url, method): + roles = self.get_permission_role(api_url, method) + if not roles: + return [] + + return self.get_permission_users(roles) + + +class CustomModelFilterBanked(BaseFilterBackend): + """ + 自定义过滤器 + """ + + def filter_queryset(self, request, queryset, view): + + # 库存模型过滤已经删数据 + if isinstance(view, MaterialInventoryViewSet): + queryset = queryset.exclude(code__is_deleted=True) + + if (query_material_type := request.query_params.get('type', '')) != '': + queryset = queryset.filter(code__type__icontains=query_material_type) + + if (query_material_name := request.query_params.get('name', '')) != '': + queryset = queryset.filter(code__name__icontains=query_material_name) + + if query_creator_name := request.query_params.get('creator_name', '') != '': + queryset = queryset.filter(creator__name=query_creator_name) + + return queryset + + +class MaterialBaseInfoViewSet(CustomModelViewSet): + """ + 物料基本信息接口 + list:查询 + create:新增 + update:修改 + retrieve:单例 + destroy:删除 + get_material_type:获取所有物料类型 + get_material_name:获取所有物料类型名称 + get_id:根据id获取物料信息 + """ + queryset = MaterialBaseInfo.objects.all() + serializer_class = MaterialBaseInfoSerializer + update_serializer_class = MaterialBaseInfoUpdateSerializer + export_field_label = { + 'type': "物料类型", + 'name': "物料名称", + 'code': "物料编码", + 'safety_stock': "安全库存", + 'storage_location': "存放位置", + 'user_name': "创建人", + 'create_datetime': "创建时间", + 'update_datetime': "更新时间", + } + export_serializer_class = MaterialBaseInfoExportSerializer + + @transaction.atomic + def create(self, request, *args, **kwargs): + # 获取物料类型 + material_type = request.data.get('type', None) + + if material_type is None: + return ErrorResponse(msg='缺少type(物料类型)参数') + + try: + if int(request.data.get('safety_stock', 0)) <= 0: + return ErrorResponse(msg='安全库存必须大于0') + + except: + return ErrorResponse(msg='type参数类型错误') + + # 当天 00:00:00:00 - 23:59:59:999999 + today = datetime.date.today() + today = datetime.datetime(today.year, today.month, today.day, 0, 0, 0, 0) + tomorrow = datetime.datetime(today.year, today.month, today.day, 23, 59, 59, 999999) + # tomorrow = today + datetime.timedelta(hours=23, minutes=59, seconds=59) + + # 删选当天创建的数据, 删除的数据不进行过滤 + count = MaterialBaseInfo.objects.filter(create_datetime__range=(today, tomorrow), is_deleted=None) + + # 生成物料编码 + material_code = str(count.count() + 1).zfill(4) + + # 补充物料编码 + request.data['code'] = f"HY-{today.strftime('%Y%m%d')}-{material_code}" + + # 判断是否有删除记录 + queryset = MaterialBaseInfo.objects.filter(name=request.data["name"], is_deleted=True) + if queryset.count() > 0: + + instance = queryset[0] + + """ + 没有设置的字段设置为None + """ + data = {} + for key in ["type", "name", "safety_stock", "storage_location", "safety_stock"]: + data[key] = request.data.get(key, None) + + for key, value in data.items(): + instance.__setattr__(key, value) + + instance.is_deleted = False # 设置未删除 + instance.save() + + return DetailResponse(data=instance.DICT_DATA, msg="新增成功") + + data = super().create(request, *args, **kwargs) + + # 关联属性 + serializer = MaterialInventoryCreateSerializer(request.user, + data={ + "code": data.data['data']['id'], + "number": 0, + "safety_stock_level": 3 + }, + request=request) + + serializer.is_valid(raise_exception=True) + serializer.create(serializer.validated_data) + + return data + + def update(self, request, *args, **kwargs): + """ + 更新时检查是否变更了安全库存数量 + """ + try: + if (safety_stock := int(request.data.get('safety_stock', 0))) <= 0: + return ErrorResponse(msg='安全库存必须大于0') + + except: + return ErrorResponse(msg='type参数类型错误') + + # 先获取原有数据 + instance = self.get_object() + + # 保存变更数据 + data = super().update(request, *args, **kwargs) + + # 变更安全库存数量需要变更安全库存等级 + if safety_stock != instance.safety_stock: + # 获取库存数据对象 + inventory_obj = MaterialInventory.objects.get(code=instance.id) + # 变更等级 + inventory_obj.safety_stock_level = MaterialInventoryViewSet.inventory_level(inventory_obj.number, + safety_stock) + # 保存 + inventory_obj.save() + + if getattr(inventory_obj, '_prefetched_objects_cache', None): + # If 'prefetch_related' has been applied to a queryset, we need to + # forcibly invalidate the prefetch cache on the instance. + inventory_obj._prefetched_objects_cache = {} + + return data + + def list(self, request, *args, **kwargs): + """" + 前端code字段多页面共用,查询时返回关联的id字段 + """ + if code := request.query_params.get("code", ""): + queryset = self.get_queryset().filter(id=code) + + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True, request=request) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(queryset, many=True) + return SuccessResponse(data=serializer.data) + + return super().list(request, *args, **kwargs) + + def destroy(self, request, *args, **kwargs): + """ + 物料删除 + """ + instance = self.get_object() + + if instance.inventory.number > 0: + return ErrorResponse(msg=f'该物料还有库存: {instance.inventory.number},禁止删除!!!') + + instance.delete() + return DetailResponse(data=[], msg="删除成功") + + @action(methods=['delete'], detail=False, permission_classes=[CustomPermission]) + def multiple_delete(self, request, *args, **kwargs): + + request_data = request.data + keys = request_data.get('keys', None) + if not keys: + return ErrorResponse(msg="未获取到keys字段") + + instances = [] + err_msg = [] + for instance in self.get_queryset().filter(id__in=keys): + instances.append(instance) + if instance.inventory.number > 0: + err_msg.append(f'物料编码:{instance.code}还有库存: {instance.inventory.number},禁止删除!!!') + # return ErrorResponse(msg=f'该物料:{instance.name}还有库存: {instance.inventory.number},禁止删除!!!') + if err_msg: + return ErrorResponse(msg=err_msg) + + for instance in instances: + instance.delete() + + return DetailResponse(data=[], msg=f"{len(instances)}条记录已删除") + + @action(methods=['get'], detail=False, permission_classes=[IsAuthenticated]) + def get_material_type(self, request, *args, **kwargs): + data = [] + info = [] + queryset = MaterialBaseInfo.objects.filter(is_deleted=None).order_by('type') + for value in queryset.values('id', 'type'): + if value['type'] not in info: + data.append(value) + info.append(value['type']) + return SuccessResponse(data=data) + + @action(methods=['get'], detail=False, permission_classes=[IsAuthenticated]) + def get_material_type_name(self, request, *args, **kwargs): + queryset = MaterialBaseInfo.objects.filter(is_deleted=None).order_by('name') + if 'type' in request.query_params: + queryset = queryset.filter(type=request.query_params['type']) + + return SuccessResponse(data=queryset.values('id', "type", 'name', 'code')) + + @action(methods=['get'], detail=False, permission_classes=[IsAuthenticated], url_path='byIds') + def get_id(self, request, *args, **kwargs): + queryset = MaterialBaseInfo.objects.filter(is_deleted=None).filter(id__in=list(request.query_params.values())) + serializer = self.get_serializer(queryset, many=True) + return DetailResponse(data=serializer.data) + + +class MaterialInventoryViewSet(CustomModelViewSet): + """ + 物料库存管理接口 + list:查询 + create:新增 + update:修改 + retrieve:单例 + destroy:删除 + get_all_user:获取所有用户 + """ + queryset = MaterialInventory.objects.all() + serializer_class = MaterialInventorySerializer + extra_filter_class = [CoreModelFilterBankend, DataLevelPermissionsFilter, CustomModelFilterBanked] + export_field_label = { + 'type': "物料类型", + 'name': "物料名称", + 'code': "物料编码", + "number": "剩余库存", + 'safety_stock': "安全库存", + "safety_stock_level": "剩余库存等级", + 'storage_location': "存放位置", + 'user_name': "创建人", + 'create_datetime': "创建时间", + 'update_datetime': "更新时间", + } + export_serializer_class = MaterialInventoryExportSerializer + + def create(self, request, *args, **kwargs): + + return ErrorResponse(msg='禁止手动创建库存记录') + + def update(self, request, *args, **kwargs): + if request.data.get('number', None) is None: + return ErrorResponse(msg='缺少number(数量)参数') + + if request.data.get('record_type', None) is None: + return ErrorResponse(msg='缺少record_type(操作类型)参数') + + try: + number = int(request.data['number']) + if request.data['number'] <= 0: + return ErrorResponse(msg='number(数量)参数必须大于0') + except ValueError: + return ErrorResponse(msg='number(数量)参数必须为整数') + + ######################################################################################################### + instance = self.get_object() + + # 判断是入库还是出库 + if request.data['record_type'] == 0: + instance.number += number + else: + + if instance.number - number < 0: + return ErrorResponse(msg=f'库存数量不足, 剩余库存数量:{instance.number}') + if request.data.get('receive_name', None) is None: + return ErrorResponse(msg='缺少receive_name(领用人)参数') + + instance.number -= number + + # 库存等级 + instance.safety_stock_level = self.inventory_level(instance.number, instance.code.safety_stock) + ######################################################################################################### + + # 操作记录 + request.data['code'] = instance.code.id + serializer = MaterialRecordsUpdateSerializer(request.user, data=request.data, request=request) + serializer.is_valid(raise_exception=True) + serializer.create(serializer.validated_data) + ######################################################################################################### + # 保存数据 + instance.save() + ######################################################################################################### + + partial = kwargs.pop('partial', False) + serializer = self.get_serializer(instance, data=request.data, request=request, partial=partial) + + serializer.is_valid(raise_exception=True) + + if getattr(instance, '_prefetched_objects_cache', None): + # If 'prefetch_related' has been applied to a queryset, we need to + # forcibly invalidate the prefetch cache on the instance. + instance._prefetched_objects_cache = {} + return DetailResponse(data=serializer.data, msg="更新成功") + + @staticmethod + def inventory_level(inventory, safety_stock): + level = 3 + if inventory >= safety_stock * 2: + level = 0 + elif safety_stock <= inventory <= safety_stock * 2: + level = 1 + elif 1 <= inventory <= safety_stock: + level = 2 + return level + + # path('inventory/get_all_user/', MaterialInventoryViewSet.as_view({'get': 'get_all_user'})), + + @action(methods=['get'], detail=False, permission_classes=[]) + def get_all_user(self, request, *args, **kwargs): + from dvadmin.system.models import Users + + return SuccessResponse(data=Users.objects.all().exclude(is_superuser=True).values('id', 'name', 'dept__parent')) + + +class MaterialRecordsViewSet(CustomModelViewSet): + """ + 物料出入库记录接口 + list:查询 + create:新增 + update:修改 + retrieve:单例 + destroy:删除 + """ + queryset = MaterialRecords.objects.all() + serializer_class = MaterialRecordsSerializer + extra_filter_class = [CoreModelFilterBankend, DataLevelPermissionsFilter, CustomModelFilterBanked] + export_field_label = { + 'type': "物料类型", + 'name': "物料名称", + 'code': "物料编码", + 'safety_stock': "安全库存", + 'storage_location': "存放位置", + "number": "数量", + "record_type": "操作类型", + "receive_name": "领用人", + 'user_name': "创建人", + 'create_datetime': "创建时间", + 'update_datetime': "更新时间", + } + export_serializer_class = MaterialRecordsExportSerializer + + +class MaterialApplyForViewSet(CustomModelViewSet): + """ + 物料申请接口 + list:查询 + create:新增 + update:修改 + retrieve:单例 + destroy:删除 + revocation:撤销申请 + examine:审核申请 + dismissal:驳回申请 + """ + + queryset = MaterialApplyFor.objects.all() + serializer_class = MaterialApplyForSerializer + create_serializer_class = MaterialApplyForCreateSerializer + extra_filter_class = [CoreModelFilterBankend, DataLevelPermissionsFilter, CustomModelFilterBanked] + export_field_label = { + 'type': "物料类型", + 'name': "物料名称", + 'code': "物料编码", + 'safety_stock': "安全库存", + 'storage_location': "存放位置", + "number": "申请数量", + 'user_name': "申请人", + 'reason': "申请原因", + "examine_status": "审核状态", + "examine_user_name": "审核人", + 'url': "申请链接", + 'create_datetime': "创建时间", + 'update_datetime': "更新时间", + } + export_serializer_class = MaterialApplyForExportSerializer + + def create(self, request, *args, **kwargs): + + material_id = request.data.get('id', -1) + if material_id is None: + return ErrorResponse(msg='申请的物料不存在,请先创建物料, 再提交申请') + if material_id == -1: + return ErrorResponse(msg='缺少id(主键)参数') + + request.data['code'] = material_id + + # 删除该属性,前端用于表单申请字段,后端实际并无该字段 + # del request.data['material'] + + data = super().create(request, *args, **kwargs) + + websocket_push_designated_role( + GetPermissionUsers().get_permission_role('/api/material/apply_for/operation/examine/', 2), + title='物料申请通知', + message=f'{request.user.name}提交了新的物料申请, 请及时查看', + ) + + return DetailResponse(data=data.data, msg="申请成功") + + def update(self, request, *args, **kwargs): + + if request.data['examine_status'] not in [1, 2, 3]: + return ErrorResponse(msg='当前状态不允许重新申请') + + request.data['examine_status'] = 1 # 设置为待审核状态 + request.data['examine_opinion'] = None # 设置审核意见为None + request.data['examine_user'] = None # 设置审核人为None + + partial = kwargs.pop('partial', False) + + instance = self.get_object() + if instance.creator.id != request.user.id: + return ErrorResponse(msg='只能由“申请用户”重新修改提交') + + serializer = self.get_serializer(instance, data=request.data, request=request, partial=partial) + serializer.is_valid(raise_exception=True) + self.perform_update(serializer) + + if getattr(instance, '_prefetched_objects_cache', None): + # If 'prefetch_related' has been applied to a queryset, we need to + # forcibly invalidate the prefetch cache on the instance. + instance._prefetched_objects_cache = {} + + websocket_push_designated_role( + GetPermissionUsers().get_permission_role('/api/material/apply_for/operation/examine/', 2), + title='物料重新提交通知', + message=f'{request.user.name}重新提交了物料申请, 请及时查看', + ) + + return DetailResponse(data=serializer.data, msg="重新提交成功") + + def destroy(self, request, *args, **kwargs): + instance = self.get_object() + + # 超级管理员允许删除 + if instance.examine_status != 1 and request.user.is_superuser is False: + return ErrorResponse(msg='当前状态不允许删除') + + # 判断是否为申请人或者超级管理员 + if instance.creator.id != request.user.id and request.user.is_superuser is False: + return ErrorResponse(msg='申请单必须由申请人删除') + + return super().destroy(request, *args, **kwargs) + + @action(methods=['put'], detail=False, permission_classes=[CustomPermission], url_path='operation/revocation') + def revocation(self, request, *args, **kwargs): + + queryset = self.queryset.get(id=request.data.get('id', None)) + if queryset.examine_status != 1: + return ErrorResponse(msg='该申请单状态不处于“待审核”,无法撤销') + + # 判断是否为申请人或者超级管理员 + if queryset.creator.id != request.user.id and request.user.is_superuser is False: + return ErrorResponse(msg='申请单必须由申请人撤销') + + queryset.examine_status = 3 + queryset.save() + + return DetailResponse(msg='撤销成功') + + @action(methods=['put'], detail=False, permission_classes=[CustomPermission], url_path='operation/examine') + def examine(self, request, *args, **kwargs): + queryset = self.queryset.filter(id__in=list(request.data.get('ids', []))) + if queryset.count() == 0: + return ErrorResponse(msg='审核失败, 没有找到该记录') + + for obj in queryset: + if obj.examine_status != 1: + return ErrorResponse(msg=f'该申请单不处于“待审核”状态,物料名称: {obj.code.name}') + + obj.examine_status = 0 + obj.examine_opinion = "通过" + obj.examine_user = request.user.id + + # 一条数据不满足则不保存 + for obj in queryset: + websocket_push_designated_user(obj.creator.id, + title='物料申请审核通知', + message=f'您提交的物料申请已通过审核!', + material_type=obj.code.type, + material_name=obj.code.name, + number=obj.number, + ) + obj.save() + + return DetailResponse(msg=f'审核成功') + + @action(methods=['put'], detail=False, permission_classes=[CustomPermission], url_path='operation/dismissal') + def dismissal(self, request, *args, **kwargs): + queryset = self.queryset.filter(id__in=list(request.data.get('ids', []))) + if queryset.count() == 0: + return ErrorResponse(msg='审核失败, 没有找到该记录') + + for obj in queryset: + if obj.examine_status != 1: + return ErrorResponse(msg=f'该申请单不处于“待审核”状态,物料名称: {obj.code.name}') + + obj.examine_status = 2 + obj.examine_user = request.user.id + obj.examine_opinion = request.data['examine_opinion'] + + # 一条数据不满足则不保存 + for obj in queryset: + websocket_push_designated_user(obj.creator.id, + title='物料申请审核通知', + message=f'您提交的物料申请已被驳回!驳回原因:{request.data["examine_opinion"]}!', + material_type=obj.code.type, + material_name=obj.code.name, + number=obj.number + ) + + obj.save() + + return DetailResponse(msg='驳回申请成功') diff --git a/backend/app/test_tool/__init__.py b/backend/app/test_tool/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/app/test_tool/admin.py b/backend/app/test_tool/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..8c38f3f3dad51e4585f3984282c2a4bec5349c1e --- /dev/null +++ b/backend/app/test_tool/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/backend/app/test_tool/apps.py b/backend/app/test_tool/apps.py new file mode 100644 index 0000000000000000000000000000000000000000..395cfd214dd680f4d586d448972264d1d109c0c3 --- /dev/null +++ b/backend/app/test_tool/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class TestAppConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'app.test_tool' diff --git a/backend/app/test_tool/migrations/__init__.py b/backend/app/test_tool/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/app/test_tool/models.py b/backend/app/test_tool/models.py new file mode 100644 index 0000000000000000000000000000000000000000..f93c30bfdb76d7236f0776dc71e8059a6b8d9b71 --- /dev/null +++ b/backend/app/test_tool/models.py @@ -0,0 +1,137 @@ +# coding=utf-8 + +import hashlib +import os +import datetime + +from django.db import models + +# Create your models here. +from dvadmin.utils.models import CoreModel, SoftDeleteModel, table_prefix + + +def media_file_name(instance, filename): + h = instance.md5sum + return os.path.join("files", f"{datetime.date.today()}", h[-8:], filename) + + +class TestToolsFileListModel(CoreModel): + name = models.CharField(max_length=200, null=True, blank=True, verbose_name="名称", help_text="名称") + url = models.FileField(upload_to=media_file_name, null=True, blank=True, ) + file_url = models.CharField(max_length=255, blank=True, verbose_name="文件地址", help_text="文件地址") + engine = models.CharField(max_length=100, default='local', blank=True, verbose_name="引擎", help_text="引擎") + mime_type = models.CharField(max_length=100, blank=True, verbose_name="Mime类型", help_text="Mime类型") + size = models.CharField(max_length=36, blank=True, verbose_name="文件大小", help_text="文件大小") + md5sum = models.CharField(max_length=36, blank=True, verbose_name="文件md5", help_text="文件md5") + version = models.CharField(max_length=36, blank=True, verbose_name="工具版本", help_text="工具版本") + update_comment = models.TextField(blank=True, verbose_name="工具更新说明", help_text="工具更新说明") + forced_update = models.BooleanField(default=False, blank=True, verbose_name="强制更新", help_text="强制更新") + platform = models.CharField(max_length=36, blank=True, verbose_name="工具平台", help_text="工具平台") + status = models.BooleanField(default=True, verbose_name="启用状态", help_text="启用状态") + UPLOAD_METHOD_CHOIDES = ( + (0, '默认上传'), + (1, '文件选择器上传'), + ) + upload_method = models.SmallIntegerField(default=0, blank=True, null=True, choices=UPLOAD_METHOD_CHOIDES, + verbose_name='上传方式', help_text='上传方式') + FILE_TYPE_CHOIDES = ( + (0, '图片'), + (1, '视频'), + (2, '音频'), + (3, '其他'), + ) + file_type = models.SmallIntegerField(default=3, choices=FILE_TYPE_CHOIDES, blank=True, null=True, + verbose_name='文件类型', help_text='文件类型') + + def save(self, *args, **kwargs): + if not self.md5sum: # file is new + md5 = hashlib.md5() + for chunk in self.url.chunks(): + md5.update(chunk) + self.md5sum = md5.hexdigest() + if not self.size: + self.size = self.url.size + if not self.file_url: + url = media_file_name(self, self.name) + self.file_url = f'media/{url}' + super(TestToolsFileListModel, self).save(*args, **kwargs) + + class Meta: + db_table = table_prefix + "test_tools_list" + verbose_name = "产测工具管理" + verbose_name_plural = verbose_name + ordering = ("-version",) + # constraints = [ + # models.UniqueConstraint(fields=['version'], name='tool_version_unique_together', + # violation_error_message='当前版本已经存在,请勿重复上传') + # ] + + +class TestToolLicenseModel(SoftDeleteModel, CoreModel): + license = models.CharField(max_length=200, verbose_name="license", help_text="license") + uuid = models.CharField(max_length=200, unique=True, verbose_name="uuid", help_text="uuid") + comment = models.TextField(max_length=999, blank=True, verbose_name="备注", help_text="备注") + status = models.BooleanField(default=True, verbose_name="启用状态", help_text="启用状态") + + class Meta: + db_table = table_prefix + "test_tool_license" + verbose_name = "产测工具授权管理" + verbose_name_plural = verbose_name + ordering = ("-create_datetime",) + constraints = [ + models.UniqueConstraint(fields=['uuid'], name='tool_uuid_unique_together', + violation_error_message='当前uuid已经存在,请勿重复上传') + ] + + +class TestToolLogModel(CoreModel): + name = models.CharField(max_length=200, null=True, blank=True, verbose_name="文件名称", help_text="文件名称") + url = models.FileField(upload_to=media_file_name, null=True, blank=True, ) + file_url = models.CharField(max_length=255, blank=True, verbose_name="文件地址", help_text="文件地址") + engine = models.CharField(max_length=100, default='local', blank=True, verbose_name="引擎", help_text="引擎") + mime_type = models.CharField(max_length=100, blank=True, verbose_name="Mime类型", help_text="Mime类型") + size = models.CharField(max_length=36, blank=True, verbose_name="文件大小", help_text="文件大小") + md5sum = models.CharField(max_length=36, blank=True, verbose_name="文件md5", help_text="文件md5") + problem_description = models.CharField(max_length=255, null=True, blank=True, verbose_name="问题描述", help_text="问题描述") + platform = models.CharField(max_length=36, blank=True, verbose_name="工具平台", help_text="工具平台") + version = models.CharField(max_length=36, blank=True, verbose_name="工具版本", help_text="工具版本") + mes_info = models.CharField(max_length=255, null=True, blank=True, verbose_name="MES信息", help_text="MES信息") + os_info = models.TextField(blank=True, verbose_name="系统信息", help_text="系统信息") + network_info = models.TextField(blank=True, verbose_name="网络信息", help_text="网络信息") + cpu_info = models.TextField(blank=True, verbose_name="CPU信息", help_text="CPU信息") + memory_info = models.TextField(blank=True, verbose_name="内存信息", help_text="内存信息") + disk_info = models.TextField(blank=True, verbose_name="磁盘信息", help_text="磁盘信息") + script = models.TextField(blank=True, verbose_name="使用脚本", help_text="使用脚本") + UPLOAD_METHOD_CHOIDES = ( + (0, '默认上传'), + (1, '文件选择器上传'), + ) + upload_method = models.SmallIntegerField(default=0, blank=True, null=True, choices=UPLOAD_METHOD_CHOIDES, + verbose_name='上传方式', help_text='上传方式') + FILE_TYPE_CHOIDES = ( + (0, '图片'), + (1, '视频'), + (2, '音频'), + (3, '其他'), + ) + file_type = models.SmallIntegerField(default=3, choices=FILE_TYPE_CHOIDES, blank=True, null=True, + verbose_name='文件类型', help_text='文件类型') + + class Meta: + db_table = table_prefix + "test_tool_log" + verbose_name = "产测工具log" + verbose_name_plural = verbose_name + ordering = ("-create_datetime", "-update_datetime") + + def save(self, *args, **kwargs): + if not self.md5sum: # file is new + md5 = hashlib.md5() + for chunk in self.url.chunks(): + md5.update(chunk) + self.md5sum = md5.hexdigest() + if not self.size: + self.size = self.url.size + if not self.file_url: + url = media_file_name(self, self.name) + self.file_url = f'media/{url}' + super(TestToolLogModel, self).save(*args, **kwargs) diff --git a/backend/app/test_tool/serializers.py b/backend/app/test_tool/serializers.py new file mode 100644 index 0000000000000000000000000000000000000000..15613da5d873a474647ee0e12b74774f516dcd67 --- /dev/null +++ b/backend/app/test_tool/serializers.py @@ -0,0 +1,27 @@ +# coding=utf-8 + + +from app.test_tool.models import TestToolsFileListModel, TestToolLicenseModel, TestToolLogModel +from dvadmin.utils.serializers import CustomModelSerializer +from dvadmin.system.views.file_list import FileSerializer + + +class TestToolModelSerializer(FileSerializer): + + class Meta: + model = TestToolsFileListModel + fields = "__all__" + + +class TestToolLicenseModelSerializer(CustomModelSerializer): + + class Meta: + model = TestToolLicenseModel + fields = "__all__" + + +class TestToolLogModelSerializer(FileSerializer): + + class Meta: + model = TestToolLogModel + fields = "__all__" diff --git a/backend/app/test_tool/tests.py b/backend/app/test_tool/tests.py new file mode 100644 index 0000000000000000000000000000000000000000..7ce503c2dd97ba78597f6ff6e4393132753573f6 --- /dev/null +++ b/backend/app/test_tool/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/app/test_tool/urls.py b/backend/app/test_tool/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..a9a02a4718904c70b330563a70f404e2d13e7e3c --- /dev/null +++ b/backend/app/test_tool/urls.py @@ -0,0 +1,24 @@ +# coding=utf-8 + +from rest_framework.routers import SimpleRouter +from django.urls import path +from django.http import HttpResponse, JsonResponse +from dvadmin.system.views.clause import PrivacyView, TermsServiceView + +from .views import ToolFileViewSet, ToolLicenseViewSet, ToolLogViewSet + +router = SimpleRouter() +# 这里进行注册路径,并把视图关联上,这里的api地址以视图名称为后缀,这样方便记忆api/CrudDemoModelViewSet +router.register("testTool", ToolFileViewSet) +router.register("license", ToolLicenseViewSet) +router.register("log", ToolLogViewSet) + +urlpatterns = [ + + path('testTool/latestVersion/', ToolFileViewSet.as_view({'get': 'get_latest_version'})), + path('testTool/forcedUpdateVersion/', ToolFileViewSet.as_view({'get': 'get_forced_update_version'})), + path('testTool/historyUpdateNote/', ToolFileViewSet.as_view({'get': 'get_history_update_note'})), + path('license/get_license/', ToolLicenseViewSet.as_view({'get': 'get_license'})), +] + +urlpatterns += router.urls diff --git a/backend/app/test_tool/views.py b/backend/app/test_tool/views.py new file mode 100644 index 0000000000000000000000000000000000000000..dad3c82c2c6fbe94ac2d22d650ec37f034e3f7a4 --- /dev/null +++ b/backend/app/test_tool/views.py @@ -0,0 +1,297 @@ +import os + + +from cryptography.fernet import Fernet + +from application import dispatch +from app.test_tool.models import TestToolsFileListModel, TestToolLicenseModel, TestToolLogModel +from dvadmin.utils.json_response import DetailResponse, SuccessResponse, ErrorResponse + +from dvadmin.utils.viewset import CustomModelViewSet +from dvadmin.utils.filters import DataLevelPermissionsFilter, CoreModelFilterBankend + +from dvadmin.utils.permission import CustomPermission + +from app.test_tool.serializers import TestToolModelSerializer, TestToolLicenseModelSerializer, TestToolLogModelSerializer + + +class ToolFileViewSet(CustomModelViewSet): + """ + 产测工具管理接口 + list:查询 + create:新增 + update:修改 + retrieve:单例 + destroy:删除 + """ + + queryset = TestToolsFileListModel.objects.all() + serializer_class = TestToolModelSerializer + # filter_fields = ['name', 'version', 'update_comment', 'platform', 'status'] + # filter_fields = '__all__' # 某些字段不允许模塑搜索 + permission_classes = [] + custom_extra_filter_class = [CoreModelFilterBankend] + + def filter_queryset(self, queryset): + for backend in set(set(self.filter_backends) | set(self.custom_extra_filter_class or [])): + + queryset = backend().filter_queryset(self.request, queryset, self) + return queryset + + def get_latest_version(self, request): + """ + 查询最新的工具版本信息 + """ + + if request.query_params.get('platform', None) is None: + return ErrorResponse(data=[{"platform:": "windows | macOSX"}], msg="platform字段为空") + + queryset = self.filter_queryset(self.get_queryset()).exclude(status=False) + + if not queryset: + return DetailResponse(data=[], msg="暂无新版本") + + serializer = self.get_serializer(queryset[0], request=request) + + return DetailResponse(data=serializer.data, msg="获取成功") + + def get_forced_update_version(self, request): + """ + 查询最新的强制更新工具版本信息 + """ + if request.query_params.get('platform', None) is None: + return ErrorResponse(data=[], msg="platform字段为空>>>windows | macOSX") + + queryset = self.filter_queryset(self.get_queryset()).exclude(status=False).exclude(forced_update=False) + # queryset = ToolFileViewSet.queryset.filter(forced_update=True, + # platform=request.query_params.get('platform'), + # status=True) + if not queryset: + return DetailResponse(data=[], msg="暂无强制更新版本") + + serializer = self.get_serializer(queryset[0], request=request) + + return DetailResponse(data=serializer.data, msg="获取成功") + + def get_history_update_note(self, request): + """ + 查询历史更新说明 + """ + + if request.query_params.get('platform', None) is None: + return ErrorResponse(data=[], msg="platform字段为空>>>windows | macOSX") + + queryset = self.filter_queryset(self.get_queryset()).exclude(status=False) + if request.query_params.get('start_version', None) is not None: + queryset = queryset.filter(version__gt=request.query_params.get('start_version')) + + if request.query_params.get('end_version', None) is not None: + queryset = queryset.filter(version__lte=request.query_params.get('end_version')) + + if not queryset: + return DetailResponse(data=[], msg="暂无更新版本说明") + + update_note = "" + for i in queryset: + update_note += f"{i.version}\r\n{i.update_comment}\r\n\r\n\r\n" + + return DetailResponse(data={"update_comment": update_note}, msg="获取成功") + + def destroy(self, request, *args, **kwargs): + # 删除时检查权限 + if CustomPermission().has_permission(request, ToolFileViewSet) is not True: + return ErrorResponse(data=[], msg="您没有该操作的执行权限。") + + instance = self.get_object() + + # 配置是否删除本地文件, 前端配置 + if dispatch.get_system_config_values("tool.remove_is_deleted"): + try: + os.remove(instance.file_url) + except FileNotFoundError: # 删除文件的目的就是为了删除文件,所以找不到文件就认为删除了 + pass + + instance.delete() + return DetailResponse(data=[], msg="删除成功") + + def create(self, request, *args, **kwargs): + + # 上传文件时检查权限 + if CustomPermission().has_permission(request, ToolFileViewSet) is not True: + return ErrorResponse(data=[], msg="您没有该操作的执行权限") + + if 'update_comment' not in request.data or \ + 'version' not in request.data or \ + 'forced_update' not in request.data or \ + 'platform' not in request.data or \ + 'file' not in request.data: + raise KeyError("缺少必要参数") + + version = request.data.get('version', None) + platform = request.data.get('platform', None) + + if self.filter_queryset(self.get_queryset()).filter(version=version, platform=platform): + return ErrorResponse(data=[], msg=f"{platform}系统已经存在版本号:{version}的产测工具.请勿重复上传") + + return super().create(request, *args, **kwargs) + + def update(self, request, *args, **kwargs): + + # 更新时检查权限 + if CustomPermission().has_permission(request, ToolFileViewSet) is not True: + + return ErrorResponse(data=[], msg="您没有该操作的执行权限。") + + return super().update(request, *args, **kwargs) + + +class ToolLicenseViewSet(CustomModelViewSet): + """ + 产测工具lisense管理接口 + list:查询 + create:新增 + update:修改 + retrieve:单例 + destroy:删除 + """ + + queryset = TestToolLicenseModel.objects.all() + serializer_class = TestToolLicenseModelSerializer + filter_fields = "__all__" + permission_classes = [] + custom_extra_filter_class = [CoreModelFilterBankend] + + _fernet = Fernet(b'fLoKjwCwE-NOsb2LGHNMLMa9ZHW000DFXgQrQ6UaruY=') # 产测工具密钥 + + def filter_queryset(self, queryset): + """ + 自定义过滤器去除权限过滤器, 用在非标准接口 + """ + for backend in set(set(self.filter_backends) | set(self.custom_extra_filter_class or [])): + + queryset = backend().filter_queryset(self.request, queryset, self) + return queryset + + def parse_license(self, uuid, uuid_license): + + data = self._fernet.decrypt(uuid_license).decode() + return data == uuid + + def generate_license(self, uuid, uuid_license): + + # 如果license 已经存在且和现有的UUID校验一致通过,直接返回license + if uuid_license and self.parse_license(uuid, uuid_license): + return uuid_license + + data = self._fernet.encrypt(f'{uuid}'.encode()).decode() + return data + + def get_license(self, request): + """ + 获取产测工具license + """ + if request.query_params.get('uuid', None) is None: + return ErrorResponse(data=[], msg="uuid字段为空") + + queryset = self.filter_queryset(self.get_queryset()) # 不进行筛选状态 + if not queryset: + return DetailResponse(data=[], msg="暂无license") + + serializer = self.get_serializer(queryset[0], request=request) + + return DetailResponse(data=serializer.data, msg="获取成功") + + def destroy(self, request, *args, **kwargs): + # 删除时检查权限 + if CustomPermission().has_permission(request, ToolLicenseViewSet) is not True: + return ErrorResponse(data=[], msg="您没有该操作的执行权限。") + + return super().destroy(request, *args, **kwargs) + + def update(self, request, *args, **kwargs): + # 更新时检查权限 + if CustomPermission().has_permission(request, ToolLicenseViewSet) is not True: + return ErrorResponse(data=[], msg="您没有该操作的执行权限。") + + request.data['license'] = self.generate_license(request.data['uuid'], request.data['license']) + + return super().update(request, *args, **kwargs) + + def create(self, request, *args, **kwargs): + + # 创建时检查权限,, 产测工具创建为工具特有 + if (request.data.get('comment', '') != '产测工具创建' + and CustomPermission().has_permission(request, ToolLicenseViewSet) is not True): + return ErrorResponse(data=[], msg="您没有该操作的执行权限。") + + # 获取是否启动自动授权 + request.data['status'] = dispatch.get_system_config_values("tool.license_auto_authorized") + + request.data['license'] = self.generate_license(request.data['uuid'], request.data.get('license', '')) + + queryset = TestToolLicenseModel.objects.filter(is_deleted=None, uuid=request.data['uuid']) + + if queryset: # 软删除恢复数据 + instance = queryset[0] + instance.is_deleted = False + partial = kwargs.pop('partial', False) + serializer = self.get_serializer(instance, data=request.data, request=request, partial=partial) + serializer.is_valid(raise_exception=True) + + self.perform_update(serializer) + + if getattr(instance, '_prefetched_objects_cache', None): + # If 'prefetch_related' has been applied to a queryset, we need to + # forcibly invalidate the prefetch cache on the instance. + instance._prefetched_objects_cache = {} + + return DetailResponse(data=serializer.data, msg="创建成功") + + return super().create(request, *args, **kwargs) + + +class ToolLogViewSet(CustomModelViewSet): + """ + 产测工具log接口 + list:查询 + create:新增 + update:修改 + retrieve:单例 + destroy:删除 + """ + queryset = TestToolLogModel.objects.all() + serializer_class = TestToolLogModelSerializer + + permission_classes = [] + + def list(self, request, *args, **kwargs): + if CustomPermission().has_permission(request, ToolLicenseViewSet) is not True: + return ErrorResponse(data=[], msg="您没有该操作的执行权限。") + + return super().list(request, *args, **kwargs) + + def update(self, request, *args, **kwargs): + if CustomPermission().has_permission(request, ToolLicenseViewSet) is not True: + return ErrorResponse(data=[], msg="您没有该操作的执行权限。") + + return super().update(request, *args, **kwargs) + + def retrieve(self, request, *args, **kwargs): + if CustomPermission().has_permission(request, ToolLicenseViewSet) is not True: + return ErrorResponse(data=[], msg="您没有该操作的执行权限。") + + return super().retrieve(request, *args, **kwargs) + + def create(self, request, *args, **kwargs): + """ + 不做权限校验,数据主要来自产测工具 + """ + return super().create(request, *args, **kwargs) + + def destroy(self, request, *args, **kwargs): + + return ErrorResponse(data=[], msg="该操作被禁止!") + + def multiple_delete(self, request, *args, **kwargs): + + return ErrorResponse(data=[], msg="该操作被禁止!") diff --git a/backend/application/settings.py b/backend/application/settings.py index e6bdec5eaa5fa6bc0778592ace87c8da995262ef..1d27dab102562fefb67547c9703e500f4737d8ca 100644 --- a/backend/application/settings.py +++ b/backend/application/settings.py @@ -60,8 +60,18 @@ INSTALLED_APPS = [ "captcha", "channels", "dvadmin.system", + 'django_celery_beat', + 'django_celery_results', + 'dvadmin3_celery', ] +MY_APPS = [ + 'app.test_tool', + 'app.material' +] + +INSTALLED_APPS += MY_APPS + MIDDLEWARE = [ "dvadmin.utils.middleware.HealthCheckMiddleware", "django.middleware.security.SecurityMiddleware", @@ -155,7 +165,7 @@ STATICFILES_DIRS = [ MEDIA_ROOT = "media" # 项目下的目录 MEDIA_URL = "/media/" # 跟STATIC_URL类似,指定用户可以通过这个url找到文件 -#添加以下代码以后就不用写{% load staticfiles %},可以直接引用 +# 添加以下代码以后就不用写{% load staticfiles %},可以直接引用 STATICFILES_FINDERS = ( "django.contrib.staticfiles.finders.FileSystemFinder", "django.contrib.staticfiles.finders.AppDirectoriesFinder" @@ -369,8 +379,8 @@ CAPTCHA_CHALLENGE_FUNCT = "captcha.helpers.math_challenge" # 加减乘除验证 DEFAULT_AUTO_FIELD = "django.db.models.AutoField" API_LOG_ENABLE = True -# API_LOG_METHODS = 'ALL' # ['POST', 'DELETE'] -API_LOG_METHODS = ["POST", "UPDATE", "DELETE", "PUT"] # ['POST', 'DELETE'] +API_LOG_METHODS = 'ALL' # ['POST', 'DELETE'] +# API_LOG_METHODS = ["POST", "UPDATE", "DELETE", "PUT"] # ['POST', 'DELETE'] API_MODEL_MAP = { "/token/": "登录模块", "/api/login/": "登录模块", @@ -404,11 +414,27 @@ PLUGINS_URL_PATTERNS = [] # ********** 一键导入插件配置开始 ********** # 例如: # from dvadmin_upgrade_center.settings import * # 升级中心 -# from dvadmin3_celery.settings import * # celery 异步任务 +from dvadmin3_celery.settings import * # celery 异步任务 + # from dvadmin_third.settings import * # 第三方用户管理 # from dvadmin_ak_sk.settings import * # 秘钥管理管理 # from dvadmin_tenants.settings import * # 租户管理 -#from dvadmin_social_auth.settings import * -#from dvadmin_uniapp.settings import * +# from dvadmin_social_auth.settings import * +# from dvadmin_uniapp.settings import * # ... # ********** 一键导入插件配置结束 ********** + + +CACHES = { # 配置缓存 + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": f'{REDIS_URL}/1', # 库名可自选1~16 + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + } + }, +} +CELERY_BROKER_URL = f'{REDIS_URL}/2' # 库名可自选1~16 +# BROKER_URL = f'{REDIS_URL}/2' # 库名可自选1~16 +CELERY_RESULT_BACKEND = 'django-db' # celery结果存储到数据库中 +CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers.DatabaseScheduler' # Backend数据库 diff --git a/backend/application/urls.py b/backend/application/urls.py index cb5a89997adb091fb3f8b071a6c7be814583e766..c82ef237b97dcf652267f752208e8d7766da30c8 100644 --- a/backend/application/urls.py +++ b/backend/application/urls.py @@ -115,8 +115,16 @@ urlpatterns = ( # 前端页面映射 path('web/', web_view, name='web_view'), path('web/