Compare commits

..

83 Commits

Author SHA1 Message Date
caoqianming c8a6ced7a0 feat: 更新依赖包 2026-01-14 09:06:50 +08:00
caoqianming 42146f4ff7 feat: base cquery支持add_info_for_list 2026-01-14 09:02:01 +08:00
caoqianming 7abebc58d6 fix: base locked_get_or_create优化 2026-01-14 09:01:43 +08:00
caoqianming 78a781290d feat: base 添加locked_get_or_create 2026-01-14 09:01:29 +08:00
caoqianming 216e82dae7 feat: base dept filter支持parent isnull查询 2026-01-14 09:00:57 +08:00
caoqianming 7128252315 feat: base 提交时可变动工单title 2026-01-14 09:00:33 +08:00
caoqianming fb1e4131ca feat: base ticketmixin perform_update bug 2026-01-14 08:59:57 +08:00
caoqianming 92f559cb4f feat: base userfilter获取归属于该部门及以下部门的人2 2026-01-14 08:57:31 +08:00
caoqianming e2ec9625b4 feat: base userfilter获取归属于该部门及以下部门的人 2026-01-14 08:57:21 +08:00
caoqianming 9cb0c5c681 feat: base ticketmixin传入other_data 2026-01-14 08:55:53 +08:00
caoqianming aa15a48cf5 feat: base 模板字段改为textfield 2026-01-14 08:55:41 +08:00
caoqianming a49eaa7c3a feat: base wf增加ticket_count接口添加分类 2026-01-14 08:55:02 +08:00
caoqianming c4d38eb6b3 feat: base wf增加ticket_count接口 2026-01-14 08:54:52 +08:00
caoqianming 3782938615 feat: base wf 支持反向查询获取处理人 2026-01-14 08:54:20 +08:00
caoqianming 87549bde20 feat: base 优化safe_get_or_create2 2026-01-14 08:53:15 +08:00
caoqianming c80c2dc9dc feat: base 增加statedetailserializer可返回节点操作人员 2026-01-14 08:51:59 +08:00
caoqianming 3fab2b9c9d fix: base ticketmixin先创建再handle 2026-01-14 08:51:46 +08:00
caoqianming 432c79dbc2 feat: base ticket create支持transition非必传2 2026-01-14 08:50:02 +08:00
caoqianming 33aa0464a5 feat: base ticket create支持transition非必传 2026-01-14 08:49:48 +08:00
caoqianming 6c3b391b60 feat: base get_object加锁时注意is_deleted过滤采用base_manager 2026-01-14 08:47:13 +08:00
caoqianming a38c3049ea feat: base ticketmixin添加ticket_auto_submit_on_create 2026-01-14 08:46:33 +08:00
caoqianming 3434376716 fix: base wfmixin gen_ticket_data保存t_id转为str 2026-01-14 08:46:18 +08:00
caoqianming 10b4553f52 feat: base wfmixin 修改时校验 2026-01-14 08:45:58 +08:00
caoqianming 7ea24f9c36 feat: base ticketDetail添加create_by_name 2026-01-14 08:45:42 +08:00
caoqianming b6191bec74 fix: base wf工作流分类接口detail错误 2026-01-14 08:45:09 +08:00
caoqianming c2514e51a4 feat: base workflow添加分类字段 2026-01-14 08:44:56 +08:00
caoqianming 1062b25ca4 feat: base 添加ticketmixin可集成到viewset下以支持工作流 2026-01-14 08:44:21 +08:00
caoqianming 2d551dae3c feat: base wfservice创建出工单时处理人为提交人 2026-01-14 08:42:22 +08:00
caoqianming 5ab36065e4 feat: base handle_ticket 完善transition校验 2026-01-14 08:41:57 +08:00
caoqianming ba9f9251d3 feat: base 创建数据时检验不包含id2 2026-01-14 08:41:19 +08:00
caoqianming cb67fc6457 feat: base 创建数据时检验不包含id 2026-01-14 08:41:02 +08:00
caoqianming 4676c0a79f feat: base 开始编写ticketMixin可自动挂载 2026-01-14 08:40:47 +08:00
caoqianming bf1e2a900e feat: base 添加EuModelViewSet 2026-01-14 08:40:34 +08:00
caoqianming 69ed857fff feat: base handle_ticket 默认参数 2026-01-14 08:40:18 +08:00
caoqianming 153919aee9 fix: base handle_ticket处理ticket_title 2026-01-14 08:39:58 +08:00
caoqianming d7f2fb47a9 feat: base 优化wf create 2026-01-14 08:39:44 +08:00
caoqianming 52fc0c0a89 feat: base execute_raw_sql 增加timeout参数可传none不限时 2026-01-14 08:38:56 +08:00
caoqianming fd2a6c9fcf feat: base execute_raw_sql 增加timeout参数 2026-01-14 08:38:38 +08:00
caoqianming 9f239d54f2 feat: base 获取流转时排序按attribute_type倒序 2026-01-14 08:36:18 +08:00
caoqianming 01f1703984 feat: base 获取流转时排序按attribute_type2 2026-01-14 08:35:38 +08:00
caoqianming e6affb1f96 feat: base 获取流转时排序按attribute_type 2026-01-14 08:35:27 +08:00
caoqianming 417c2e4504 feat: base add_info_for_item 可复用list逻辑 2026-01-14 08:33:15 +08:00
caoqianming dffd752568 feat: base cquery支持annotate 2026-01-14 08:32:53 +08:00
caoqianming d0c3dd788d feat: base send_sms auto_log send_mail使用False 2026-01-14 08:32:31 +08:00
caoqianming b9e0a1e891 feat: base 优化wf通知发送 2026-01-14 08:31:52 +08:00
caoqianming 359aefda82 feat: base sql querydict可传入是否格式化时间参数 2026-01-14 08:31:22 +08:00
caoqianming 509d1f4922 feat: base workflow list 返回view_path 2026-01-14 08:31:11 +08:00
caoqianming 004f66dddd feat: base workflow添加view_path 2026-01-14 08:30:43 +08:00
caoqianming ccbd08079c feat: base 优化system事务处理 2026-01-14 08:30:22 +08:00
caoqianming 0225d346b4 feat: base system和wf优化事务处理2 2026-01-14 08:29:44 +08:00
caoqianming 02c9ca2823 feat: base system和wf优化事务处理 2026-01-13 16:55:43 +08:00
caoqianming 36d267b975 feat: base 移除基础model层事务 2026-01-13 16:53:30 +08:00
caoqianming 2eb29e655b feat: base 优化get_object 2026-01-13 16:53:16 +08:00
caoqianming e8c9e96300 feat: base 优化get_object事务 2026-01-13 16:52:42 +08:00
caoqianming d805560894 fix: base 在create update destroy添加自动事务 2026-01-13 16:52:28 +08:00
caoqianming 1459405f26 fix: base 修改_should_use_transaction 2026-01-13 16:52:08 +08:00
caoqianming d29d126643 feat: base CustomGenericViewSet 添加自动事务 2026-01-13 16:51:55 +08:00
caoqianming 1a060730e3 feat: base 日志默认记录耗时大于2s的 2026-01-13 16:50:55 +08:00
caoqianming d6f3db79b1 feat: base 优化safe_get_or_create 2026-01-13 16:50:36 +08:00
caoqianming 8e085eac84 feat: base 添加悲观锁及其装饰器 2026-01-13 16:49:55 +08:00
caoqianming e2ef190094 fix: base ComplexQueryMixin 默认null值排最后 2026-01-13 16:49:28 +08:00
caoqianming 1bbf114f19 feat: base retreat 撤回功能提到wfservice里 2026-01-13 16:49:15 +08:00
caoqianming e18e64003a feat: base query_one_dict优化 2026-01-13 16:48:43 +08:00
caoqianming cf54b67b2d feat: base complexquery value支持多种类型 2026-01-13 16:47:59 +08:00
caoqianming 3c2820f542 fix: base ticket list 添加create_by_name 2026-01-13 16:47:06 +08:00
caoqianming 561d5cd409 feat: base ticket list 添加create_by_name 2026-01-13 16:46:53 +08:00
caoqianming 8320ec5a0b fix: base ordering排序错误 2026-01-13 16:45:41 +08:00
caoqianming def890a873 feat: base ComplexQueryMixin 支持order查询2 2026-01-13 16:45:26 +08:00
caoqianming 03bec2a6ab feat: base ComplexQueryMixin 支持order查询 2026-01-13 16:45:17 +08:00
caoqianming 7deaadd0a2 feat: base 独立出ComplexQueryMixin 2026-01-13 16:44:53 +08:00
caoqianming 5eb491ec1b feat: base with_children查询去除限制 2026-01-13 16:44:39 +08:00
caoqianming 68c53fc0aa feat: base 性能优化调整位置 2026-01-13 16:41:22 +08:00
zty 734a9ed9dd feat: auth1 utils enm 修改阿里云发送短信引入方式 2026-01-13 16:38:03 +08:00
caoqianming ac1ac19a16 feat: base 将配置文件放到单独的config文件夹中防止误操作 2026-01-13 16:24:03 +08:00
caoqianming d21b07c65b feat: base增加PositiveDecimalField 2026-01-13 16:23:06 +08:00
caoqianming acfdf3bcd6 feat: 添加MyJSONEncoder以支持decimal 2026-01-13 16:22:05 +08:00
caoqianming 454737db03 feat: sysbaseview返回系统版本号 2026-01-13 16:21:06 +08:00
caoqianming ef90f95a6b fix: base log delay=True减少冲突 2026-01-13 16:20:45 +08:00
caoqianming 04d7aedd1a change: base 改变log backupcount为30 2026-01-13 16:19:35 +08:00
caoqianming ab2e9de729 perf: base settings里日志记录handler优化 2026-01-13 16:19:20 +08:00
caoqianming 538d6b8a60 feat: base 允许同源嵌入 2026-01-13 16:18:02 +08:00
caoqianming 456bf58514 feat: base route支持iframe 2026-01-13 16:17:00 +08:00
caoqianming 9f622af533 feat: base get_user_route排序增加create_time 2026-01-13 16:16:46 +08:00
27 changed files with 783 additions and 260 deletions

2
.gitignore vendored
View File

@ -20,6 +20,8 @@ apps.zip
server/conf.py server/conf.py
server/conf.ini server/conf.ini
server/conf*.json server/conf*.json
config/conf*.py
config/conf*.json
sh/* sh/*
temp/* temp/*
nohup.out nohup.out

View File

@ -10,7 +10,7 @@ from apps.auth1.errors import USERNAME_OR_PASSWORD_WRONG
from rest_framework_simplejwt.tokens import RefreshToken from rest_framework_simplejwt.tokens import RefreshToken
from django.core.cache import cache from django.core.cache import cache
from apps.auth1.services import check_phone_code from apps.auth1.services import check_phone_code
from apps.utils.sms import send_sms
from apps.utils.tools import rannum from apps.utils.tools import rannum
from apps.utils.wxmp import wxmpClient from apps.utils.wxmp import wxmpClient
from apps.utils.wx import wxClient from apps.utils.wx import wxClient
@ -182,6 +182,7 @@ class SendCode(CreateAPIView):
短信验证码发送 短信验证码发送
""" """
from apps.utils.sms import send_sms
phone = request.data['phone'] phone = request.data['phone']
code = rannum(6) code = rannum(6)
is_ok, _ = send_sms(phone, 505, {'code': code}) is_ok, _ = send_sms(phone, 505, {'code': code})

View File

@ -1,8 +1,12 @@
from django_filters import rest_framework as filters from django_filters import rest_framework as filters
from .models import Dept, User from .models import Dept, User
from apps.utils.queryset import get_child_queryset2
from rest_framework.exceptions import ParseError
class UserFilterSet(filters.FilterSet): class UserFilterSet(filters.FilterSet):
ubelong_dept__name = filters.CharFilter(label='归属于该部门及以下(按名称)', method='filter_ubelong_dept__name')
ubelong_dept = filters.CharFilter(label='归属于该部门及以下', method='filter_ubelong_dept')
class Meta: class Meta:
model = User model = User
@ -20,6 +24,20 @@ class UserFilterSet(filters.FilterSet):
'posts__code': ["exact", "contains"], 'posts__code': ["exact", "contains"],
} }
def filter_ubelong_dept__name(self, queryset, name, value):
try:
depts = get_child_queryset2(Dept.objects.get(name=value))
except Exception as e:
raise ParseError(f"部门名称错误: {value} {str(e)}")
return queryset.filter(belong_dept__in=depts)
def filter_ubelong_dept(self, queryset, name, value):
try:
depts = get_child_queryset2(Dept.objects.get(id=value))
except Exception as e:
raise ParseError(f"部门ID错误: {value} {str(e)}")
return queryset.filter(belong_dept__in=depts)
class DeptFilterSet(filters.FilterSet): class DeptFilterSet(filters.FilterSet):
@ -27,5 +45,6 @@ class DeptFilterSet(filters.FilterSet):
model = Dept model = Dept
fields = { fields = {
'type': ['exact', 'in'], 'type': ['exact', 'in'],
'name': ['exact', 'in', 'contains'] 'name': ['exact', 'in', 'contains'],
"parent": ['exact', 'isnull'],
} }

View File

@ -272,12 +272,10 @@ class DeptCreateUpdateSerializer(CustomModelSerializer):
model = Dept model = Dept
exclude = EXCLUDE_FIELDS + ['third_info'] exclude = EXCLUDE_FIELDS + ['third_info']
@transaction.atomic
def create(self, validated_data): def create(self, validated_data):
ins = super().create(validated_data) ins = super().create(validated_data)
return ins return ins
@transaction.atomic
def update(self, instance, validated_data): def update(self, instance, validated_data):
ins = super().update(instance, validated_data) ins = super().update(instance, validated_data)
return ins return ins

View File

@ -8,8 +8,7 @@ from django_celery_beat.models import (CrontabSchedule, IntervalSchedule,
from django_celery_results.models import TaskResult from django_celery_results.models import TaskResult
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import ParseError, ValidationError, PermissionDenied from rest_framework.exceptions import ParseError, ValidationError, PermissionDenied
from rest_framework.mixins import (CreateModelMixin, DestroyModelMixin, from rest_framework.mixins import RetrieveModelMixin
ListModelMixin, RetrieveModelMixin)
from rest_framework.parsers import (JSONParser, from rest_framework.parsers import (JSONParser,
MultiPartParser) MultiPartParser)
from rest_framework.serializers import Serializer from rest_framework.serializers import Serializer
@ -19,7 +18,7 @@ from rest_framework.views import APIView
from apps.system.errors import OLD_PASSWORD_WRONG, PASSWORD_NOT_SAME, SCHEDULE_WRONG from apps.system.errors import OLD_PASSWORD_WRONG, PASSWORD_NOT_SAME, SCHEDULE_WRONG
from apps.system.filters import DeptFilterSet, UserFilterSet from apps.system.filters import DeptFilterSet, UserFilterSet
# from django_q.models import Task as QTask, Schedule as QSchedule # from django_q.models import Task as QTask, Schedule as QSchedule
from apps.utils.mixins import (CustomCreateModelMixin, MyLoggingMixin) from apps.utils.mixins import (MyLoggingMixin, BulkCreateModelMixin, BulkDestroyModelMixin, CustomListModelMixin)
from django.conf import settings from django.conf import settings
from apps.utils.permission import ALL_PERMS from apps.utils.permission import ALL_PERMS
from apps.utils.viewsets import CustomGenericViewSet, CustomModelViewSet from apps.utils.viewsets import CustomGenericViewSet, CustomModelViewSet
@ -228,7 +227,7 @@ class PTaskViewSet(CustomModelViewSet):
return Response() return Response()
class PTaskResultViewSet(ListModelMixin, RetrieveModelMixin, CustomGenericViewSet): class PTaskResultViewSet(CustomListModelMixin, RetrieveModelMixin, CustomGenericViewSet):
""" """
list:任务执行结果列表 list:任务执行结果列表
@ -372,7 +371,7 @@ class RoleViewSet(CustomModelViewSet):
ordering = ['create_time'] ordering = ['create_time']
class PostRoleViewSet(CreateModelMixin, DestroyModelMixin, ListModelMixin, CustomGenericViewSet): class PostRoleViewSet(BulkCreateModelMixin, BulkDestroyModelMixin, CustomListModelMixin, CustomGenericViewSet):
"""岗位/角色关系 """岗位/角色关系
岗位/角色关系 岗位/角色关系
@ -384,7 +383,7 @@ class PostRoleViewSet(CreateModelMixin, DestroyModelMixin, ListModelMixin, Custo
filterset_fields = ['post', 'role'] filterset_fields = ['post', 'role']
class UserPostViewSet(CreateModelMixin, DestroyModelMixin, ListModelMixin, CustomGenericViewSet): class UserPostViewSet(BulkCreateModelMixin, BulkDestroyModelMixin, CustomListModelMixin, CustomGenericViewSet):
"""用户/岗位关系 """用户/岗位关系
用户/岗位关系 用户/岗位关系
@ -397,7 +396,6 @@ class UserPostViewSet(CreateModelMixin, DestroyModelMixin, ListModelMixin, Custo
ordering = ['sort', 'create_time'] ordering = ['sort', 'create_time']
def perform_create(self, serializer): def perform_create(self, serializer):
with transaction.atomic():
instance = serializer.save() instance = serializer.save()
user = instance.user user = instance.user
up = UserPost.objects.filter(user=user).order_by( up = UserPost.objects.filter(user=user).order_by(
@ -409,7 +407,6 @@ class UserPostViewSet(CreateModelMixin, DestroyModelMixin, ListModelMixin, Custo
user.save() user.save()
def perform_destroy(self, instance): def perform_destroy(self, instance):
with transaction.atomic():
user = instance.user user = instance.user
instance.delete() instance.delete()
up = UserPost.objects.filter(user=user).order_by( up = UserPost.objects.filter(user=user).order_by(
@ -560,7 +557,7 @@ class UserViewSet(CustomModelViewSet):
return Response() return Response()
class FileViewSet(CustomCreateModelMixin, RetrieveModelMixin, ListModelMixin, CustomGenericViewSet): class FileViewSet(BulkCreateModelMixin, RetrieveModelMixin, CustomListModelMixin, CustomGenericViewSet):
"""文件上传 """文件上传
list: list:
@ -601,7 +598,7 @@ class FileViewSet(CustomCreateModelMixin, RetrieveModelMixin, ListModelMixin, Cu
instance.save() instance.save()
class ApkViewSet(MyLoggingMixin, ListModelMixin, CreateModelMixin, GenericViewSet): class ApkViewSet(MyLoggingMixin, CustomListModelMixin, BulkCreateModelMixin, GenericViewSet):
perms_map = {'get': '*', 'post': 'apk.upload'} perms_map = {'get': '*', 'post': 'apk.upload'}
serializer_class = ApkSerializer serializer_class = ApkSerializer
@ -642,7 +639,7 @@ class ApkViewSet(MyLoggingMixin, ListModelMixin, CreateModelMixin, GenericViewSe
return Response() return Response()
class MyScheduleViewSet(ListModelMixin, CreateModelMixin, DestroyModelMixin, CustomGenericViewSet): class MyScheduleViewSet(CustomListModelMixin, BulkCreateModelMixin, BulkDestroyModelMixin, CustomGenericViewSet):
perms_map = {'get': '*', 'post': '*', perms_map = {'get': '*', 'post': '*',
'delete': 'myschedule.delete'} 'delete': 'myschedule.delete'}
serializer_class = MyScheduleSerializer serializer_class = MyScheduleSerializer
@ -668,7 +665,6 @@ class MyScheduleViewSet(ListModelMixin, CreateModelMixin, DestroyModelMixin, Cus
return get_description(f"{data['minute']} {data['hour']} {data['day_of_month']} {data['month_of_year']} {data['day_of_week']}") return get_description(f"{data['minute']} {data['hour']} {data['day_of_month']} {data['month_of_year']} {data['day_of_week']}")
return '' return ''
@transaction.atomic
def perform_create(self, serializer): def perform_create(self, serializer):
vdata = serializer.validated_data vdata = serializer.validated_data
vdata['create_by'] = self.request.user # 不可少 vdata['create_by'] = self.request.user # 不可少
@ -718,6 +714,8 @@ class SysBaseConfigView(APIView):
config = get_sysconfig() config = get_sysconfig()
base_dict = {key: config[key] base_dict = {key: config[key]
for key in self.read_keys if key in config} for key in self.read_keys if key in config}
base_dict.get("base", {})["sys_version"] = settings.SYS_VERSION
base_dict.get("base", {})["sys_name"] = settings.SYS_NAME
return Response(base_dict) return Response(base_dict)

View File

@ -1,5 +1,9 @@
from django.conf import settings from django.conf import settings
from rest_framework import serializers from rest_framework import serializers
from django.db.models import DecimalField
from django.core.validators import MinValueValidator
from django.utils.functional import cached_property
from decimal import Decimal
class MyFilePathField(serializers.CharField): class MyFilePathField(serializers.CharField):
@ -8,3 +12,9 @@ class MyFilePathField(serializers.CharField):
if 'http' in value: if 'http' in value:
return str(value) return str(value)
return settings.BASE_URL + str(value) return settings.BASE_URL + str(value)
class PositiveDecimalField(DecimalField):
@cached_property
def validators(self):
return [MinValueValidator(Decimal('0.0'))] + super().validators

51
apps/utils/lock.py Normal file
View File

@ -0,0 +1,51 @@
from contextlib import contextmanager
from rest_framework.exceptions import ParseError
from functools import wraps
from django.db import transaction
@contextmanager
def lock_model_record(model_class, pk):
"""
Locks a model instance and returns it.
"""
try:
instance = model_class.objects.select_for_update().get(pk=pk)
yield instance
except model_class.DoesNotExist:
raise ParseError("该记录不存在或已被删除")
def lock_model_record_d_func(model_class, pk_attr='id'):
"""
通用模型锁装饰器内置事务用于装饰函数
"""
def decorator(func):
@wraps(func)
@transaction.atomic
def wrapper(old_instance, *args, **kwargs):
try:
# 获取新鲜记录
fresh_record = model_class.objects.select_for_update().get(pk=getattr(old_instance, pk_attr))
# 调用原函数,但传入新鲜记录
return func(fresh_record, *args, **kwargs)
except model_class.DoesNotExist:
raise ParseError('记录不存在或已被删除')
return wrapper
return decorator
def lock_model_record_d_method(model_class, pk_attr='id'):
"""
通用模型锁装饰器内置事务, 用于装饰类方法
"""
def decorator(func):
@wraps(func)
@transaction.atomic
def wrapper(self, old_instance, *args, **kwargs):
try:
# 获取新鲜记录
fresh_record = model_class.objects.select_for_update().get(pk=getattr(old_instance, pk_attr))
# 调用原函数,但传入新鲜记录
return func(self, fresh_record, *args, **kwargs)
except model_class.DoesNotExist:
raise ParseError('记录不存在或已被删除')
return wrapper
return decorator

View File

@ -9,13 +9,16 @@ from django.utils.timezone import now
from user_agents import parse from user_agents import parse
import logging import logging
from rest_framework.response import Response from rest_framework.response import Response
from django.db import transaction
from rest_framework.exceptions import ParseError, ValidationError from rest_framework.exceptions import ParseError, ValidationError
from apps.utils.errors import PKS_ERROR from apps.utils.errors import PKS_ERROR
from rest_framework.generics import get_object_or_404 from rest_framework.generics import get_object_or_404
from drf_yasg.utils import swagger_auto_schema from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi from drf_yasg import openapi
from apps.utils.serializers import PkSerializer from apps.utils.serializers import PkSerializer
from rest_framework.decorators import action
from apps.utils.serializers import ComplexSerializer
from django.db.models import F
from django.db import transaction
# 实例化myLogger # 实例化myLogger
myLogger = logging.getLogger('log') myLogger = logging.getLogger('log')
@ -79,6 +82,7 @@ class BulkCreateModelMixin(CreateModelMixin):
def after_bulk_create(self, objs): def after_bulk_create(self, objs):
pass pass
@transaction.atomic
def create(self, request, *args, **kwargs): def create(self, request, *args, **kwargs):
"""创建(支持批量) """创建(支持批量)
@ -87,8 +91,13 @@ class BulkCreateModelMixin(CreateModelMixin):
rdata = request.data rdata = request.data
many = False many = False
if isinstance(rdata, list): if isinstance(rdata, list):
for item in rdata:
if "id" in item and item["id"]:
raise ParseError('创建数据中不能包含id字段')
many = True many = True
with transaction.atomic(): else:
if "id" in rdata and rdata["id"]:
raise ParseError('创建数据中不能包含id字段')
sr = self.get_serializer(data=rdata, many=many) sr = self.get_serializer(data=rdata, many=many)
sr.is_valid(raise_exception=True) sr.is_valid(raise_exception=True)
self.perform_create(sr) self.perform_create(sr)
@ -102,6 +111,7 @@ class BulkUpdateModelMixin(UpdateModelMixin):
def after_bulk_update(self, objs): def after_bulk_update(self, objs):
pass pass
@transaction.atomic
def partial_update(self, request, *args, **kwargs): def partial_update(self, request, *args, **kwargs):
"""部分更新(支持批量) """部分更新(支持批量)
@ -110,6 +120,7 @@ class BulkUpdateModelMixin(UpdateModelMixin):
kwargs['partial'] = True kwargs['partial'] = True
return self.update(request, *args, **kwargs) return self.update(request, *args, **kwargs)
@transaction.atomic
def update(self, request, *args, **kwargs): def update(self, request, *args, **kwargs):
"""更新(支持批量) """更新(支持批量)
@ -121,7 +132,6 @@ class BulkUpdateModelMixin(UpdateModelMixin):
queryset = self.filter_queryset(self.get_queryset()) queryset = self.filter_queryset(self.get_queryset())
objs = [] objs = []
if isinstance(request.data, list): if isinstance(request.data, list):
with transaction.atomic():
for ind, item in enumerate(request.data): for ind, item in enumerate(request.data):
obj = get_object_or_404(queryset, id=item['id']) obj = get_object_or_404(queryset, id=item['id'])
sr = self.get_serializer(obj, data=item, partial=partial) sr = self.get_serializer(obj, data=item, partial=partial)
@ -145,6 +155,7 @@ class BulkUpdateModelMixin(UpdateModelMixin):
class BulkDestroyModelMixin(DestroyModelMixin): class BulkDestroyModelMixin(DestroyModelMixin):
@swagger_auto_schema(request_body=PkSerializer) @swagger_auto_schema(request_body=PkSerializer)
@transaction.atomic
def destroy(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs):
"""删除(支持批量) """删除(支持批量)
@ -188,6 +199,8 @@ class CustomRetrieveModelMixin(RetrieveModelMixin):
给dict返回数据添加额外信息 给dict返回数据添加额外信息
""" """
if hasattr(self, 'add_info_for_list'):
return self.add_info_for_list([data])[0]
return data return data
class CustomListModelMixin(ListModelMixin): class CustomListModelMixin(ListModelMixin):
@ -219,6 +232,79 @@ class CustomListModelMixin(ListModelMixin):
""" """
return data return data
class ComplexQueryMixin:
"""复杂查询
"""
@swagger_auto_schema(request_body=ComplexSerializer, responses={200: {}})
@action(methods=['post'], detail=False, perms_map={'post': '*'})
def cquery(self, request):
"""复杂查询
复杂查询
"""
sr = ComplexSerializer(data=request.data)
sr.is_valid(raise_exception=True)
vdata = sr.validated_data
queryset = self.get_queryset()
querys = vdata.get('querys', [])
annotate_field_list = vdata.get('annotate_field_list', [])
if not querys:
new_qs = queryset
else:
new_qs = queryset.none()
try:
for m in querys:
one_qs = queryset
for n in m:
st = {}
if n['compare'] == '!': # 如果是排除比较式
st[n['field']] = n['value']
one_qs = one_qs.exclude(**st)
elif n['compare'] == '':
st[n['field']] = n['value']
one_qs = one_qs.filter(**st)
else:
st[n['field'] + '__' + n['compare']] = n['value']
one_qs = one_qs.filter(**st)
new_qs = new_qs | one_qs
except Exception as e:
raise ParseError(str(e))
if annotate_field_list:
annotate_dict = getattr(self, "annotate_dict", {})
if annotate_dict:
filtered_annotate_dict = { key: annotate_dict[key] for key in annotate_field_list if key in annotate_dict}
new_qs = new_qs.annotate(**filtered_annotate_dict)
ordering = vdata.get('ordering', None)
if not ordering:
ordering = getattr(self, 'ordering', None)
if isinstance(ordering, str):
ordering = ordering.replace('\n', '').replace(' ', '')
ordering = ordering.split(',')
order_fields = []
if ordering:
for item in ordering:
if item.startswith('-'):
# JSONField 排序只能用字符串,不要 F
order_fields.append(F(item[1:]).desc(nulls_last=True) if '__' not in item else item)
else:
order_fields.append(F(item).asc(nulls_last=True) if '__' not in item else item)
new_qs = new_qs.order_by(*order_fields)
page = self.paginate_queryset(new_qs)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(new_qs, many=True)
rdata = serializer.data
if hasattr(self, 'add_info_for_list'):
rdata = self.add_info_for_list(rdata)
return Response(rdata)
class MyLoggingMixin(object): class MyLoggingMixin(object):
"""Mixin to log requests""" """Mixin to log requests"""
@ -264,6 +350,7 @@ class MyLoggingMixin(object):
response = super().finalize_response( response = super().finalize_response(
request, response, *args, **kwargs request, response, *args, **kwargs
) )
self.log["response_ms"] = self._get_response_ms()
# Ensure backward compatibility for those using _should_log hook # Ensure backward compatibility for those using _should_log hook
should_log = ( should_log = (
self._should_log if hasattr(self, "_should_log") else self.should_log self._should_log if hasattr(self, "_should_log") else self.should_log
@ -292,7 +379,7 @@ class MyLoggingMixin(object):
"method": request.method, "method": request.method,
"query_params": self._clean_data(request.query_params.dict()), "query_params": self._clean_data(request.query_params.dict()),
"user": self._get_user(request), "user": self._get_user(request),
"response_ms": self._get_response_ms(), # "response_ms": self._get_response_ms(),
"response": self._clean_data(rendered_content), "response": self._clean_data(rendered_content),
"status_code": response.status_code, "status_code": response.status_code,
"agent": self._get_agent(request), "agent": self._get_agent(request),
@ -386,7 +473,8 @@ class MyLoggingMixin(object):
By default, check if the request method is in logging_methods. By default, check if the request method is in logging_methods.
""" """
return self.logging_methods == "__all__" or response.status_code > 404 or response.status_code == 400 \ return self.logging_methods == "__all__" or response.status_code > 404 or response.status_code == 400 \
or (request.method in self.logging_methods and response.status_code not in [401, 403, 404]) or (request.method in self.logging_methods and response.status_code not in [401, 403, 404])\
or (self.log.get("response_ms", 0) > 2000)
def _clean_data(self, data): def _clean_data(self, data):
""" """

View File

@ -8,6 +8,7 @@ from django.db import IntegrityError
from django.db import transaction from django.db import transaction
from rest_framework.exceptions import ParseError from rest_framework.exceptions import ParseError
from django.core.cache import cache from django.core.cache import cache
from django.db import transaction, connection
import hashlib import hashlib
# 自定义软删除查询基类 # 自定义软删除查询基类
@ -115,13 +116,66 @@ class BaseModel(models.Model):
@classmethod @classmethod
def safe_get_or_create(cls, defaults=None, **kwargs): def safe_get_or_create(cls, defaults=None, **kwargs):
"""
多进程/多服务器安全的 get_or_create
- 数据库唯一约束不够时 Redis 锁防止重复创建
- 在事务中使用 select_for_update
"""
defaults = defaults or {} defaults = defaults or {}
lock_data = {**kwargs, **defaults} create_kwargs = {**kwargs, **defaults}
lock_hash = hashlib.md5(str(lock_data).encode()).hexdigest()
for attempt in range(3):
try:
if connection.in_atomic_block:
# 在事务中,先锁定再获取
try:
obj = cls.objects.select_for_update().get(**kwargs)
return obj, False
except cls.DoesNotExist:
obj = cls(**create_kwargs)
obj.save()
return obj, True
else:
# 非事务,使用分布式锁
sorted_kwargs = dict(sorted(create_kwargs.items()))
lock_hash = hashlib.md5(str(sorted_kwargs).encode()).hexdigest()
lock_key = f"safe_get_or_create:{cls.__name__}:{lock_hash}" lock_key = f"safe_get_or_create:{cls.__name__}:{lock_hash}"
with cache.lock(lock_key, timeout=10): with cache.lock(lock_key, timeout=10):
return cls.objects.get_or_create(**kwargs, defaults=defaults) return cls.objects.get_or_create(**kwargs, defaults=defaults)
except IntegrityError:
# 唯一约束冲突,重试
if attempt == 2:
raise
time.sleep(0.1 * (attempt + 1))
@classmethod
def locked_get_or_create(cls, defaults: dict, **kwargs):
"""
仅用于事务内
并发安全的 get_or_create
"""
if not connection.in_atomic_block:
raise RuntimeError("locked_get_or_create 必须在事务中调用")
defaults = defaults or {}
qs = cls.objects.select_for_update().filter(**kwargs)
cnt = qs.count()
if cnt > 1:
raise RuntimeError(
f"{cls.__name__} 数据异常:定位条件 {kwargs} 命中 {cnt}"
)
if cnt == 1:
return qs.get(), False
params = {**kwargs, **defaults}
obj = cls.objects.create(**params)
return obj, True
def handle_parent(self): def handle_parent(self):
pass pass
@ -132,7 +186,7 @@ class BaseModel(models.Model):
if not self.id: if not self.id:
is_create = True is_create = True
self.id = idWorker.get_id() self.id = idWorker.get_id()
with transaction.atomic():
old_parent = None old_parent = None
need_handle_parent = False need_handle_parent = False
if hasattr(self, "parent"): if hasattr(self, "parent"):

View File

@ -36,7 +36,7 @@ def get_user_route(user: User) -> List[str]:
else: else:
user_routes_qs = perm_qs.filter(role_perms__in=PostRole.objects.filter( user_routes_qs = perm_qs.filter(role_perms__in=PostRole.objects.filter(
post__in=UserPost.objects.filter(user=user).values_list("post", flat=True)).values_list("role", flat=True)).distinct() post__in=UserPost.objects.filter(user=user).values_list("post", flat=True)).values_list("role", flat=True)).distinct()
user_routes_qs = user_routes_qs.order_by('sort') user_routes_qs = user_routes_qs.order_by('sort', 'create_time')
user_routes_list = list(user_routes_qs.values("id", "name", "type", "route_name", "icon", "path", "component", "is_hidden", "is_fullpage", "parent")) user_routes_list = list(user_routes_qs.values("id", "name", "type", "route_name", "icon", "path", "component", "is_hidden", "is_fullpage", "parent"))
for item in user_routes_list: for item in user_routes_list:
item["meta"] = {} item["meta"] = {}
@ -54,6 +54,8 @@ def get_user_route(user: User) -> List[str]:
item.pop("is_fullpage") item.pop("is_fullpage")
item["name"] = item["route_name"] item["name"] = item["route_name"]
item.pop("route_name") item.pop("route_name")
if item["path"].startswith("http"):
item["meta"]["type"] = "iframe"
return build_tree_from_list(user_routes_list) return build_tree_from_list(user_routes_list)

View File

@ -75,12 +75,14 @@ class CustomModelSerializer(DynamicFieldsMixin, TreeSerializerMixin, serializers
class QuerySerializer(serializers.Serializer): class QuerySerializer(serializers.Serializer):
field = serializers.CharField(label='字段名') field = serializers.CharField(label='字段名')
compare = serializers.ChoiceField( compare = serializers.ChoiceField(
label='比较式', choices=["", "!", "gte", "gt", "lte", "lt", "in", "contains"]) label='比较式', choices=["", "!", "gte", "gt", "lte", "lt", "in", "contains", "isnull"])
value = serializers.CharField(label='') value = serializers.JSONField(label='', allow_null=True)
class ComplexSerializer(serializers.Serializer): class ComplexSerializer(serializers.Serializer):
page = serializers.IntegerField(min_value=0, required=False) page = serializers.IntegerField(min_value=0, required=False)
page_size = serializers.IntegerField(min_value=1, required=False) page_size = serializers.IntegerField(min_value=1, required=False)
ordering = serializers.CharField(required=False)
querys = serializers.ListField(child=QuerySerializer( querys = serializers.ListField(child=QuerySerializer(
many=True), label="查询列表", required=False) many=True), label="查询列表", required=False)
annotate_field_list = serializers.ListField(child=serializers.CharField(), label="RawSQL字段列表", required=False)

View File

@ -1,5 +1,4 @@
from aliyunsdkcore.client import AcsClient
from aliyunsdkcore.request import CommonRequest
import json import json
import logging import logging
from server.settings import get_sysconfig from server.settings import get_sysconfig
@ -8,8 +7,11 @@ from apps.utils.decorators import auto_log
# 实例化myLogger # 实例化myLogger
myLogger = logging.getLogger('log') myLogger = logging.getLogger('log')
@auto_log(name='阿里云短信', raise_exception=True, send_mail=True)
@auto_log(name='阿里云短信', raise_exception=True, send_mail=False)
def send_sms(phone: str, template_code: int, template_param: dict): def send_sms(phone: str, template_code: int, template_param: dict):
from aliyunsdkcore.client import AcsClient
from aliyunsdkcore.request import CommonRequest
config = get_sysconfig() config = get_sysconfig()
if config.get("sms", {}).get('enabled', True) is False: if config.get("sms", {}).get('enabled', True) is False:
return return

View File

@ -1,6 +1,8 @@
from django.db import connection from django.db import connection
from django.utils import timezone
from datetime import datetime
def execute_raw_sql(sql: str, params=None): def execute_raw_sql(sql: str, params=None, timeout=30):
"""执行原始sql并返回rows, columns数据 """执行原始sql并返回rows, columns数据
Args: Args:
@ -8,7 +10,8 @@ def execute_raw_sql(sql: str, params=None):
params (_type_, optional): 参数列表. Defaults to None. params (_type_, optional): 参数列表. Defaults to None.
""" """
with connection.cursor() as cursor: with connection.cursor() as cursor:
cursor.execute("SET statement_timeout TO %s;", [30000]) if timeout:
cursor.execute(f"SET statement_timeout TO '{int(timeout*1000)}ms';")
if params: if params:
cursor.execute(sql, params=params) cursor.execute(sql, params=params)
else: else:
@ -23,7 +26,7 @@ def format_sqldata(columns, rows):
return [columns] + rows, [dict(zip(columns, row)) for row in rows] return [columns] + rows, [dict(zip(columns, row)) for row in rows]
def query_all_dict(sql, params=None): def query_all_dict(sql, params=None, with_time_format=False):
''' '''
查询所有结果返回字典类型数据 查询所有结果返回字典类型数据
:param sql: :param sql:
@ -36,9 +39,19 @@ def query_all_dict(sql, params=None):
else: else:
cursor.execute(sql) cursor.execute(sql)
columns = [desc[0] for desc in cursor.description] columns = [desc[0] for desc in cursor.description]
if with_time_format:
results = []
for row in cursor.fetchall():
row_dict = {}
for col, val in zip(columns, row):
if isinstance(val, datetime):
val = timezone.make_naive(val).strftime("%Y-%m-%d %H:%M:%S")
row_dict[col] = val
results.append(row_dict)
return results
return [dict(zip(columns, row)) for row in cursor.fetchall()] return [dict(zip(columns, row)) for row in cursor.fetchall()]
def query_one_dict(sql, params=None): def query_one_dict(sql, params=None, with_time_format=False):
""" """
查询一个结果返回字典类型数据 查询一个结果返回字典类型数据
:param sql: :param sql:
@ -46,13 +59,17 @@ def query_one_dict(sql, params=None):
:return: :return:
""" """
with connection.cursor() as cursor: with connection.cursor() as cursor:
if params: cursor.execute(sql, params or ()) # 更简洁的参数处理
cursor.execute(sql, params=params)
else:
cursor.execute(sql)
columns = [desc[0] for desc in cursor.description] columns = [desc[0] for desc in cursor.description]
row = cursor.fetchone() row = cursor.fetchone()
return dict(zip(columns, row)) if with_time_format:
row_dict = {}
for col, val in zip(columns, row):
if isinstance(val, datetime):
val = timezone.make_naive(val).strftime("%Y-%m-%d %H:%M:%S")
row_dict[col] = val
return row_dict
return dict(zip(columns, row)) if row else None # 安全处理None情况
import pymysql import pymysql
import psycopg2 import psycopg2

View File

@ -10,6 +10,14 @@ from io import BytesIO
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
import ast import ast
from typing import Dict from typing import Dict
from django.core.serializers.json import DjangoJSONEncoder
from decimal import Decimal
class MyJSONEncoder(DjangoJSONEncoder):
def default(self, obj):
if isinstance(obj, Decimal):
return float(obj)
return super().default(obj)
class CodeAnalyzer(ast.NodeVisitor): class CodeAnalyzer(ast.NodeVisitor):
def __init__(self): def __init__(self):

View File

@ -1,5 +1,6 @@
from django.core.cache import cache from django.core.cache import cache
from django.http import StreamingHttpResponse, Http404
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import ParseError from rest_framework.exceptions import ParseError
from rest_framework.mixins import RetrieveModelMixin from rest_framework.mixins import RetrieveModelMixin
@ -9,14 +10,17 @@ from rest_framework.viewsets import GenericViewSet
from apps.system.models import DataFilter, Dept from apps.system.models import DataFilter, Dept
from apps.utils.mixins import (MyLoggingMixin, BulkCreateModelMixin, BulkUpdateModelMixin, from apps.utils.mixins import (MyLoggingMixin, BulkCreateModelMixin, BulkUpdateModelMixin,
BulkDestroyModelMixin, CustomListModelMixin, CustomRetrieveModelMixin) BulkDestroyModelMixin, CustomListModelMixin,
CustomRetrieveModelMixin, ComplexQueryMixin)
from apps.utils.permission import ALL_PERMS, RbacPermission, get_user_perms_map from apps.utils.permission import ALL_PERMS, RbacPermission, get_user_perms_map
from apps.utils.queryset import get_child_queryset2, get_child_queryset_u from apps.utils.queryset import get_child_queryset2, get_child_queryset_u
from apps.utils.serializers import ComplexSerializer from apps.utils.serializers import ComplexSerializer
from rest_framework.throttling import UserRateThrottle from rest_framework.throttling import UserRateThrottle
from drf_yasg.utils import swagger_auto_schema from drf_yasg.utils import swagger_auto_schema
import json import json
from django.db import connection
from django.core.exceptions import ObjectDoesNotExist
from django.db.utils import NotSupportedError
class CustomGenericViewSet(MyLoggingMixin, GenericViewSet): class CustomGenericViewSet(MyLoggingMixin, GenericViewSet):
""" """
@ -84,6 +88,36 @@ class CustomGenericViewSet(MyLoggingMixin, GenericViewSet):
elif hash_v_e: elif hash_v_e:
return Response(hash_v_e) return Response(hash_v_e)
def get_object(self, force_lock=False):
"""
智能加锁的get_object
- 只读请求普通查询
- 非只读请求且在事务中加锁查询
- 非只读请求但不在事务中普通查询带警告
"""
# 只读方法列表
read_only_methods = ['GET', 'HEAD', 'OPTIONS']
if self.request.method not in read_only_methods and connection.in_atomic_block:
if force_lock:
raise ParseError("当前操作需要在事务中进行,请使用事务装饰器")
# 非只读请求且在事务中:加锁查询
queryset = self.filter_queryset(self.get_queryset())
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
filter_kwargs = {self.lookup_field: self.kwargs[lookup_url_kwarg]}
try:
obj = queryset.get(**filter_kwargs)
l_obj = queryset.model._base_manager.select_for_update().get(pk=obj.pk)
self.check_object_permissions(self.request, l_obj)
return l_obj
except queryset.model.DoesNotExist:
raise Http404
else:
# 其他情况:普通查询
return super().get_object()
def get_serializer_class(self): def get_serializer_class(self):
action_serializer_name = f"{self.action}_serializer_class" action_serializer_name = f"{self.action}_serializer_class"
action_serializer_class = getattr(self, action_serializer_name, None) action_serializer_class = getattr(self, action_serializer_name, None)
@ -102,16 +136,16 @@ class CustomGenericViewSet(MyLoggingMixin, GenericViewSet):
return queryset return queryset
def filter_queryset(self, queryset): def filter_queryset(self, queryset):
queryset = super().filter_queryset(queryset)
# 如果带有with_children查询, 出于优化需要应自动过滤掉一些内容
if (self.request.query_params.get("with_children", "no") in ["yes", "count"]
and self.request.query_params.get("parent", None) is None):
queryset = queryset.filter(parent=None)
# 用于性能优化 # 用于性能优化
if self.select_related_fields: if self.select_related_fields:
queryset = queryset.select_related(*self.select_related_fields) queryset = queryset.select_related(*self.select_related_fields)
if self.prefetch_related_fields: if self.prefetch_related_fields:
queryset = queryset.prefetch_related(*self.prefetch_related_fields) queryset = queryset.prefetch_related(*self.prefetch_related_fields)
queryset = super().filter_queryset(queryset)
# 如果带有with_children查询, 出于优化需要应自动过滤掉一些内容
# if (self.request.query_params.get("with_children", "no") in ["yes", "count"]
# and self.request.query_params.get("parent", None) is None):
# queryset = queryset.filter(parent=None)
return queryset return queryset
def get_queryset(self): def get_queryset(self):
@ -183,44 +217,16 @@ class CustomGenericViewSet(MyLoggingMixin, GenericViewSet):
return queryset return queryset
return queryset.filter(create_by=self.request.user) return queryset.filter(create_by=self.request.user)
class CustomModelViewSet(BulkCreateModelMixin, BulkUpdateModelMixin, CustomListModelMixin, class CustomModelViewSet(BulkCreateModelMixin, BulkUpdateModelMixin, CustomListModelMixin,
CustomRetrieveModelMixin, BulkDestroyModelMixin, CustomGenericViewSet): CustomRetrieveModelMixin, BulkDestroyModelMixin, ComplexQueryMixin, CustomGenericViewSet):
""" """
增强的ModelViewSet 增强的ModelViewSet
""" """
@swagger_auto_schema(request_body=ComplexSerializer, responses={200: {}})
@action(methods=['post'], detail=False, perms_map={'post': '*'})
def cquery(self, request):
"""复杂查询
复杂查询 class EuModelViewSet(BulkCreateModelMixin, CustomListModelMixin,
CustomRetrieveModelMixin, BulkDestroyModelMixin, ComplexQueryMixin, CustomGenericViewSet):
"""
不支持更新的增强ModelViewSet
""" """
sr = ComplexSerializer(data=request.data)
sr.is_valid(raise_exception=True)
vdata = sr.validated_data
queryset = self.filter_queryset(self.get_queryset())
new_qs = queryset.none()
try:
for m in vdata.get('querys', []):
one_qs = queryset
for n in m:
st = {}
if n['compare'] == '!': # 如果是排除比较式
st[n['field']] = n['value']
one_qs = one_qs.exclude(**st)
elif n['compare'] == '':
st[n['field']] = n['value']
one_qs = one_qs.filter(**st)
else:
st[n['field'] + '__' + n['compare']] = n['value']
one_qs = one_qs.filter(**st)
new_qs = new_qs | one_qs
except Exception as e:
raise ParseError(str(e))
page = self.paginate_queryset(new_qs)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(new_qs, many=True)
return Response(serializer.data)

View File

@ -0,0 +1,18 @@
# Generated by Django 3.2.12 on 2025-09-19 01:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wf', '0002_alter_state_filter_dept'),
]
operations = [
migrations.AddField(
model_name='workflow',
name='view_path',
field=models.TextField(blank=True, null=True, verbose_name='前端自定义页面路径'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.2.12 on 2025-11-18 01:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wf', '0004_workflow_view_path2'),
]
operations = [
migrations.AddField(
model_name='workflow',
name='cate',
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='分类'),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 3.2.12 on 2025-12-15 08:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wf', '0005_workflow_cate'),
]
operations = [
migrations.AlterField(
model_name='workflow',
name='content_template',
field=models.TextField(blank=True, default='标题:{title}, 创建时间:{create_time}', help_text='工单字段的值可以作为参数写到模板中,格式如:标题:{title}, 创建时间:{create_time}', null=True, verbose_name='内容模板'),
),
migrations.AlterField(
model_name='workflow',
name='title_template',
field=models.TextField(blank=True, default='{title}', help_text='工单字段的值可以作为参数写到模板中,格式如:你有一个待办工单:{title}', null=True, verbose_name='标题模板'),
),
]

86
apps/wf/mixins.py Normal file
View File

@ -0,0 +1,86 @@
from apps.wf.models import Workflow, Ticket, State
from rest_framework.exceptions import ParseError
from apps.wf.services import WfService
from apps.system.models import User
class TicketMixin:
"""
可挂载到正常model,使其支持工作流
model添加ticket字段
serializer添加ticket_
该处会修改perform_create和perform_update方法,注意!
"""
workflow_key = None
ticket_auto_submit_on_update = True
ticket_auto_submit_on_create = True
def get_workflow_key(self, instance):
return self.workflow_key
def should_create_ticket(self, instance):
return True
def gen_other_ticket_data(self, instance):
return {}
def gen_ticket_data(self, instance):
ticket_data = {"t_model": instance.__class__.__name__, "t_id": str(instance.id)}
other_data = self.gen_other_ticket_data(instance)
if other_data:
ticket_data.update(other_data)
return ticket_data
def perform_update(self, serializer):
ins = serializer.save()
ruser = self.request.user
if ins.ticket and self.ticket_auto_submit_on_update:
source_state:State = ins.ticket.state
if source_state.type != State.STATE_TYPE_START:
raise ParseError('该工单已开始流转,不可修改')
if ruser != ins.ticket.create_by:
raise ParseError('非工单创建人不可修改')
transitions = WfService.get_state_transitions(source_state)
if transitions.count() == 1:
transition = transitions.first()
ticket_data = self.gen_ticket_data(ins)
WfService.handle_ticket(ticket=ins.ticket, transition=transition, new_ticket_data=ticket_data,
handler=self.request.user, oinfo=self.request.data)
else:
raise ParseError('有多个或无后续状态;不可处理')
def perform_create(self, serializer):
ins = serializer.save()
handler:User = self.request.user
if self.should_create_ticket(ins):
workflow_key = self.get_workflow_key(ins)
if not workflow_key:
raise ParseError('工作流异常:必须赋值workflow_key')
try:
wf = Workflow.objects.get(key=workflow_key)
except Exception as e:
raise ParseError(f'工作流{workflow_key}异常:{e}')
# 开始创建工单
ticket_data = self.gen_ticket_data(ins)
ticket = WfService.handle_ticket(ticket=None, transition=None, workflow=wf, new_ticket_data=ticket_data,
handler=handler, oinfo=self.request.data)
ins.ticket = ticket
ins.save(update_fields=['ticket'])
if self.ticket_auto_submit_on_create:
source_state: State = WfService.get_workflow_start_state(wf)
transitions = WfService.get_state_transitions(source_state)
if transitions.count() == 1:
transition = transitions.first()
WfService.handle_ticket(ticket=ticket, transition=transition, new_ticket_data=ticket_data,
handler=handler, oinfo=self.request.data)
else:
raise ParseError(f'工作流{workflow_key}异常:有多个或无后续状态;不可处理')
def perform_destroy(self, instance):
ticket = instance.ticket
if ticket and ticket.state.type != State.STATE_TYPE_START:
raise ParseError('该工单已开始流转,不可删除')
instance.delete()
ticket.delete()

View File

@ -9,6 +9,7 @@ class Workflow(CommonAModel):
工作流 工作流
""" """
name = models.CharField('名称', max_length=50) name = models.CharField('名称', max_length=50)
cate = models.CharField('分类', max_length=50, null=True, blank=True)
key = models.CharField('工作流标识', unique=True, max_length=20, null=True, blank=True) key = models.CharField('工作流标识', unique=True, max_length=20, null=True, blank=True)
sn_prefix = models.CharField('流水号前缀', max_length=50, default='hb') sn_prefix = models.CharField('流水号前缀', max_length=50, default='hb')
description = models.CharField('描述', max_length=200, null=True, blank=True) description = models.CharField('描述', max_length=200, null=True, blank=True)
@ -17,10 +18,11 @@ class Workflow(CommonAModel):
'限制表达式', default=dict, blank=True, help_text='限制周期({"period":24} 24小时), 限制次数({"count":1}在限制周期内只允许提交1次), 限制级别({"level":1} 针对(1单个用户 2全局)限制周期限制次数,默认特定用户);允许特定人员提交({"allow_persons":"zhangsan,lisi"}只允许张三提交工单,{"allow_depts":"1,2"}只允许部门id为1和2的用户提交工单{"allow_roles":"1,2"}只允许角色id为1和2的用户提交工单)') '限制表达式', default=dict, blank=True, help_text='限制周期({"period":24} 24小时), 限制次数({"count":1}在限制周期内只允许提交1次), 限制级别({"level":1} 针对(1单个用户 2全局)限制周期限制次数,默认特定用户);允许特定人员提交({"allow_persons":"zhangsan,lisi"}只允许张三提交工单,{"allow_depts":"1,2"}只允许部门id为1和2的用户提交工单{"allow_roles":"1,2"}只允许角色id为1和2的用户提交工单)')
display_form_str = models.JSONField('展现表单字段', default=list, blank=True, display_form_str = models.JSONField('展现表单字段', default=list, blank=True,
help_text='默认"[]",用于用户只有对应工单查看权限时显示哪些字段,field_key的list的json,如["days","sn"],内置特殊字段participant_info.participant_name:当前处理人信息(部门名称、角色名称)state.state_name:当前状态的状态名,workflow.workflow_name:工作流名称') help_text='默认"[]",用于用户只有对应工单查看权限时显示哪些字段,field_key的list的json,如["days","sn"],内置特殊字段participant_info.participant_name:当前处理人信息(部门名称、角色名称)state.state_name:当前状态的状态名,workflow.workflow_name:工作流名称')
title_template = models.CharField( title_template = models.TextField(
'标题模板', max_length=50, default='{title}', null=True, blank=True, help_text='工单字段的值可以作为参数写到模板中,格式如:你有一个待办工单:{title}') '标题模板', default='{title}', null=True, blank=True, help_text='工单字段的值可以作为参数写到模板中,格式如:你有一个待办工单:{title}')
content_template = models.CharField( content_template = models.TextField(
'内容模板', max_length=1000, default='标题:{title}, 创建时间:{create_time}', null=True, blank=True, help_text='工单字段的值可以作为参数写到模板中,格式如:标题:{title}, 创建时间:{create_time}') '内容模板', default='标题:{title}, 创建时间:{create_time}', null=True, blank=True, help_text='工单字段的值可以作为参数写到模板中,格式如:标题:{title}, 创建时间:{create_time}')
view_path = models.TextField('前端自定义页面路径', null=True, blank=True)
class Meta: class Meta:
verbose_name = '工作流' verbose_name = '工作流'

View File

@ -1,5 +1,5 @@
from apps.system.models import Dept, User from apps.system.models import Dept, User, Post, Role
from apps.system.serializers import UserSignatureSerializer, UserSimpleSerializer from apps.system.serializers import UserSignatureSerializer, UserSimpleSerializer, DeptSimpleSerializer, PostSimpleSerializer, RoleSimpleSerializer
from rest_framework import serializers from rest_framework import serializers
from apps.utils.serializers import CustomModelSerializer from apps.utils.serializers import CustomModelSerializer
@ -23,11 +23,25 @@ class StateSerializer(CustomModelSerializer):
model = State model = State
fields = '__all__' fields = '__all__'
class StateDetailSerializer(StateSerializer):
participant_ = serializers.SerializerMethodField()
def get_participant_(self, obj:State):
if obj.participant_type == State.PARTICIPANT_TYPE_PERSONAL:
return UserSimpleSerializer(instance=User.objects.get(id=obj.participant)).data
elif obj.participant_type == State.PARTICIPANT_TYPE_MULTI:
return UserSimpleSerializer(instance=User.objects.filter(id__in=obj.participant), many=True).data
elif obj.participant_type == State.PARTICIPANT_TYPE_DEPT:
return DeptSimpleSerializer(instance=Dept.objects.filter(id__in=obj.participant), many=True).data
elif obj.participant_type == State.PARTICIPANT_TYPE_POST:
return PostSimpleSerializer(instance=Post.objects.filter(id__in=obj.participant), many=True).data
elif obj.participant_type == State.PARTICIPANT_TYPE_ROLE:
return RoleSimpleSerializer(instance=Role.objects.filter(id__in=obj.participant), many=True).data
class WorkflowSimpleSerializer(CustomModelSerializer): class WorkflowSimpleSerializer(CustomModelSerializer):
class Meta: class Meta:
model = Workflow model = Workflow
fields = ['id', 'name', 'key'] fields = ['id', 'name', 'key', 'view_path']
class StateSimpleSerializer(CustomModelSerializer): class StateSimpleSerializer(CustomModelSerializer):
@ -94,7 +108,7 @@ class TicketSimpleSerializer(CustomModelSerializer):
class TicketCreateSerializer(CustomModelSerializer): class TicketCreateSerializer(CustomModelSerializer):
transition = serializers.PrimaryKeyRelatedField(queryset=Transition.objects.all(), write_only=True) transition = serializers.PrimaryKeyRelatedField(queryset=Transition.objects.all(), write_only=True, allow_null=True, required=False)
title = serializers.CharField(allow_blank=True, required=False) title = serializers.CharField(allow_blank=True, required=False)
class Meta: class Meta:
@ -123,11 +137,13 @@ class TicketListSerializer(CustomModelSerializer):
workflow_ = WorkflowSimpleSerializer(source='workflow', read_only=True) workflow_ = WorkflowSimpleSerializer(source='workflow', read_only=True)
state_ = StateSimpleSerializer(source='state', read_only=True) state_ = StateSimpleSerializer(source='state', read_only=True)
participant_ = serializers.SerializerMethodField() participant_ = serializers.SerializerMethodField()
create_by_name = serializers.CharField(source='create_by.name', read_only=True)
class Meta: class Meta:
model = Ticket model = Ticket
fields = ['id', 'title', 'sn', 'workflow', 'workflow_', 'state', 'state_', fields = ['id', 'title', 'sn', 'workflow', 'workflow_', 'state', 'state_',
'act_state', 'create_time', 'update_time', 'participant_type', 'create_by', 'ticket_data', 'act_state', 'create_time', 'update_time', 'participant_type',
'create_by', 'create_by_name', 'ticket_data',
'participant_', 'script_run_last_result', 'participant'] 'participant_', 'script_run_last_result', 'participant']
def get_participant_(self, obj): def get_participant_(self, obj):
@ -138,7 +154,7 @@ class TicketListSerializer(CustomModelSerializer):
@staticmethod @staticmethod
def setup_eager_loading(queryset): def setup_eager_loading(queryset):
queryset = queryset.select_related('workflow', 'state') queryset = queryset.select_related('workflow', 'state', 'create_by')
return queryset return queryset
@ -147,6 +163,7 @@ class TicketDetailSerializer(CustomModelSerializer):
state_ = StateSimpleSerializer(source='state', read_only=True) state_ = StateSimpleSerializer(source='state', read_only=True)
ticket_data_ = serializers.SerializerMethodField() ticket_data_ = serializers.SerializerMethodField()
participant_ = serializers.SerializerMethodField() participant_ = serializers.SerializerMethodField()
create_by_name = serializers.CharField(source='create_by.name', read_only=True)
class Meta: class Meta:
model = Ticket model = Ticket

View File

@ -11,7 +11,7 @@ import random
from apps.utils.queryset import get_parent_queryset from apps.utils.queryset import get_parent_queryset
from apps.wf.tasks import run_task from apps.wf.tasks import run_task
from rest_framework.exceptions import ParseError from rest_framework.exceptions import ParseError
import time
class WfService(object): class WfService(object):
@staticmethod @staticmethod
@ -77,7 +77,7 @@ class WfService(object):
""" """
获取状态可执行的操作 获取状态可执行的操作
""" """
return Transition.objects.filter(is_deleted=False, source_state=state).all() return Transition.objects.filter(is_deleted=False, source_state=state).all().order_by("-attribute_type", "-id")
@classmethod @classmethod
def get_ticket_steps(cls, ticket: Ticket): def get_ticket_steps(cls, ticket: Ticket):
@ -184,7 +184,15 @@ class WfService(object):
dpt_attrs = state.filter_dept.split('.') # 通过反向查询得到可能有多层 dpt_attrs = state.filter_dept.split('.') # 通过反向查询得到可能有多层
expr = ticket expr = ticket
for i in dpt_attrs: for i in dpt_attrs:
try:
expr = getattr(expr, i) expr = getattr(expr, i)
except AttributeError as e:
if "'RelatedManager' object has no attribute" in str(e):
expr = getattr(expr.first(), i)
else:
raise
if expr is None:
raise ParseError('未找到对应部门')
dpts = Dept.objects.filter(id=expr.id) dpts = Dept.objects.filter(id=expr.id)
user_queryset = user_queryset.filter(depts__in=dpts) user_queryset = user_queryset.filter(depts__in=dpts)
# if state.filter_policy == 1: # if state.filter_policy == 1:
@ -297,9 +305,56 @@ class WfService(object):
return field_info_dict return field_info_dict
@classmethod @classmethod
def handle_ticket(cls, ticket: Ticket, transition: Transition, new_ticket_data: dict = {}, handler: User = None, def handle_ticket(cls, ticket: Ticket=None, transition: Transition=None, workflow: Workflow=None, new_ticket_data: dict = {}, oinfo: dict = {}, handler: User = None,
suggestion: str = '', created: bool = False, by_timer: bool = False, suggestion: str = '', by_timer: bool = False,
by_task: bool = False, by_hook: bool = False): by_task: bool = False, by_hook: bool = False):
just_created = False
if ticket is None:
# 创建工单逻辑
if transition:
if workflow and transition.workflow.id != workflow.id:
raise ParseError("当前流转不属于该工作流")
workflow = transition.workflow
start_state = WfService.get_workflow_start_state(workflow)
save_ticket_data = {}
if transition and transition.field_require_check:
for key, value in start_state.state_fields.items():
if int(value) == State.STATE_FIELD_REQUIRED:
if key not in new_ticket_data and not new_ticket_data[key]:
raise ParseError('字段{}必填'.format(key))
save_ticket_data[key] = new_ticket_data[key]
elif int(value) == State.STATE_FIELD_OPTIONAL:
save_ticket_data[key] = new_ticket_data[key]
else:
save_ticket_data = new_ticket_data
ticket = Ticket.objects.create(workflow=workflow,
state=start_state,
create_by=handler,
create_time=timezone.now(),
act_state=Ticket.TICKET_ACT_STATE_DRAFT,
belong_dept=handler.belong_dept,
ticket_data=save_ticket_data, participant_type=1, participant=handler.id) # 先创建出来
sn = WfService.get_ticket_sn(ticket.workflow) # 流水号
ticket.sn = sn
ticket.save()
if not transition:
return ticket
just_created = True # 刚创建的工单不需要校验权限
if transition and transition.source_state.type == State.STATE_TYPE_START:
# 更新title和sn
ticket_title = oinfo.get("title", "")
title_template = ticket.workflow.title_template
if title_template:
all_ticket_data = {**oinfo, **new_ticket_data}
try:
ticket_title = title_template.format(**all_ticket_data)
except KeyError as e:
raise ParseError(f"工单标题模板中存在未定义的变量:{e}")
ticket.title = ticket_title
ticket.save(update_fields=["title"])
source_state = ticket.state source_state = ticket.state
source_ticket_data = ticket.ticket_data source_ticket_data = ticket.ticket_data
@ -315,13 +370,13 @@ class WfService(object):
f(ticket=ticket, transition=transition, new_ticket_data=new_ticket_data) f(ticket=ticket, transition=transition, new_ticket_data=new_ticket_data)
# 校验处理权限 # 校验处理权限
if handler is not None and created is False: # 有处理人意味着系统触发校验处理权限 if handler is not None and just_created is False: # 有处理人意味着系统触发校验处理权限
result = WfService.ticket_handle_permission_check(ticket, handler) result = WfService.ticket_handle_permission_check(ticket, handler)
if result.get('permission') is False: if result.get('permission') is False:
raise PermissionDenied(result.get('msg')) raise PermissionDenied(result.get('msg'))
# 校验表单必填项目 # 校验表单必填项目
if transition.field_require_check or not created: if transition.field_require_check or not just_created:
for key, value in ticket.state.state_fields.items(): for key, value in ticket.state.state_fields.items():
if int(value) == State.STATE_FIELD_REQUIRED: if int(value) == State.STATE_FIELD_REQUIRED:
if key not in new_ticket_data or not new_ticket_data[key]: if key not in new_ticket_data or not new_ticket_data[key]:
@ -369,7 +424,7 @@ class WfService(object):
ticket.act_state = Ticket.TICKET_ACT_STATE_BACK ticket.act_state = Ticket.TICKET_ACT_STATE_BACK
# 只更新必填和可选的字段 # 只更新必填和可选的字段
if not created and transition.field_require_check: if not just_created and transition.field_require_check:
for key, value in source_state.state_fields.items(): for key, value in source_state.state_fields.items():
if value in (State.STATE_FIELD_REQUIRED, State.STATE_FIELD_OPTIONAL): if value in (State.STATE_FIELD_REQUIRED, State.STATE_FIELD_OPTIONAL):
if key in new_ticket_data: if key in new_ticket_data:
@ -384,7 +439,7 @@ class WfService(object):
suggestion=suggestion, participant_type=State.PARTICIPANT_TYPE_PERSONAL, suggestion=suggestion, participant_type=State.PARTICIPANT_TYPE_PERSONAL,
participant=handler, transition=transition) participant=handler, transition=transition)
if created: if just_created:
if source_state.participant_cc: if source_state.participant_cc:
TicketFlow.objects.create(ticket=ticket, state=source_state, TicketFlow.objects.create(ticket=ticket, state=source_state,
participant_type=0, intervene_type=Transition.TRANSITION_INTERVENE_TYPE_CC, participant_type=0, intervene_type=Transition.TRANSITION_INTERVENE_TYPE_CC,
@ -442,12 +497,18 @@ class WfService(object):
last_log.intervene_type == Transition.TRANSITION_INTERVENE_TYPE_DELIVER or last_log.intervene_type == Transition.TRANSITION_INTERVENE_TYPE_DELIVER or
ticket.in_add_node): ticket.in_add_node):
# 如果状态变化或是转交加签的情况再发送通知 # 如果状态变化或是转交加签的情况再发送通知
Thread(target=send_ticket_notice_t, args=(ticket,), daemon=True).start() cls.send_ticket_notice(ticketflow=last_log)
# 如果目标状态是脚本则异步执行 # 如果目标状态是脚本则异步执行
if state.participant_type == State.PARTICIPANT_TYPE_ROBOT: if state.participant_type == State.PARTICIPANT_TYPE_ROBOT:
run_task.delay(ticket_id=ticket.id) run_task.delay(ticket_id=ticket.id)
@classmethod
def send_ticket_notice(cls, ticketflow:TicketFlow):
# 根据ticketflow发送通知
Thread(target=send_ticket_notice_t, args=(ticketflow.id,), daemon=True).start()
@classmethod @classmethod
def close_by_task(cls, ticket: Ticket, suggestion: str): def close_by_task(cls, ticket: Ticket, suggestion: str):
# 定时任务触发的工单关闭 # 定时任务触发的工单关闭
@ -462,11 +523,31 @@ class WfService(object):
ticket_data=WfService.get_ticket_all_field_value(ticket), ticket_data=WfService.get_ticket_all_field_value(ticket),
suggestion=suggestion, participant_type=State.PARTICIPANT_TYPE_ROBOT, suggestion=suggestion, participant_type=State.PARTICIPANT_TYPE_ROBOT,
intervene_type=Transition.TRANSITION_INTERVENE_TYPE_CLOSE, transition=None) intervene_type=Transition.TRANSITION_INTERVENE_TYPE_CLOSE, transition=None)
@classmethod
def retreat(cls, ticket: Ticket, suggestion: str, handler: User, next_handler: User):
"""
回退
"""
start_state = WfService.get_workflow_start_state(ticket.workflow)
ticket.state = start_state
ticket.participant_type = State.PARTICIPANT_TYPE_PERSONAL
ticket.participant = next_handler.id
ticket.act_state = Ticket.TICKET_ACT_STATE_RETREAT
ticket.save()
# 更新流转记录
TicketFlow.objects.create(ticket=ticket, state=ticket.state,
ticket_data=WfService.get_ticket_all_field_value(ticket),
suggestion=suggestion, participant_type=State.PARTICIPANT_TYPE_PERSONAL,
intervene_type=Transition.TRANSITION_INTERVENE_TYPE_RETREAT,
participant=handler, transition=None)
cls.task_ticket(ticket=ticket)
def send_ticket_notice_t(ticket: Ticket): def send_ticket_notice_t(ticketflowId: str):
""" """
发送通知 发送通知
""" """
time.sleep(3)
ticket = TicketFlow.objects.get(id=ticketflowId).ticket
params = {'workflow': ticket.workflow.name, 'state': ticket.state.name} params = {'workflow': ticket.workflow.name, 'state': ticket.state.name}
if ticket.participant_type == 1: if ticket.participant_type == 1:
# 发送短信通知 # 发送短信通知

View File

@ -8,7 +8,7 @@ from rest_framework.response import Response
from rest_framework.mixins import CreateModelMixin, DestroyModelMixin, ListModelMixin, \ from rest_framework.mixins import CreateModelMixin, DestroyModelMixin, ListModelMixin, \
RetrieveModelMixin, UpdateModelMixin RetrieveModelMixin, UpdateModelMixin
from apps.wf.serializers import CustomFieldCreateUpdateSerializer, CustomFieldSerializer, StateSerializer, \ from apps.wf.serializers import CustomFieldCreateUpdateSerializer, CustomFieldSerializer, StateSerializer, \
TicketAddNodeEndSerializer, TicketAddNodeSerializer, TicketCloseSerializer, \ StateDetailSerializer, TicketAddNodeEndSerializer, TicketAddNodeSerializer, TicketCloseSerializer, \
TicketCreateSerializer, TicketDeliverSerializer, TicketDestorySerializer, TicketFlowSerializer, \ TicketCreateSerializer, TicketDeliverSerializer, TicketDestorySerializer, TicketFlowSerializer, \
TicketHandleSerializer, TicketRetreatSerializer, \ TicketHandleSerializer, TicketRetreatSerializer, \
TicketSerializer, TransitionSerializer, WorkflowSerializer, \ TicketSerializer, TransitionSerializer, WorkflowSerializer, \
@ -20,7 +20,7 @@ from apps.utils.mixins import CreateUpdateCustomMixin, CreateUpdateModelAMixin
from apps.wf.services import WfService from apps.wf.services import WfService
from rest_framework.exceptions import ParseError, NotFound from rest_framework.exceptions import ParseError, NotFound
from rest_framework import status from rest_framework import status
from django.db.models import Count from django.db.models import Count, Case, When, IntegerField, F
from rest_framework.serializers import Serializer from rest_framework.serializers import Serializer
from apps.utils.snowflake import idWorker from apps.utils.snowflake import idWorker
import importlib import importlib
@ -60,10 +60,16 @@ class WorkflowKeyInitView(APIView):
class WorkflowViewSet(CustomModelViewSet): class WorkflowViewSet(CustomModelViewSet):
queryset = Workflow.objects.all() queryset = Workflow.objects.all()
serializer_class = WorkflowSerializer serializer_class = WorkflowSerializer
search_fields = ['name', 'description'] search_fields = ['name', 'description', 'key']
filterset_fields = [] filterset_fields = ['key', 'cate']
ordering_fields = ['create_time'] ordering_fields = ['create_time', 'key', 'cate']
ordering = ['key', '-create_time']
@action(methods=['get'], detail=False, perms_map={'get': '*'})
def cates(self, request, pk=None):
"""
工作流分类
"""
return Response(Workflow.objects.filter(cate__isnull=False).values_list('cate', flat=True).distinct())
@action(methods=['get'], detail=True, perms_map={'get': 'workflow.update'}, @action(methods=['get'], detail=True, perms_map={'get': 'workflow.update'},
pagination_class=None, serializer_class=StateSerializer) pagination_class=None, serializer_class=StateSerializer)
@ -173,12 +179,28 @@ class WorkflowViewSet(CustomModelViewSet):
tr.save() tr.save()
return Response() return Response()
@action(methods=['get'], detail=False, perms_map={'get': '*'})
def ticket_count(self, request, pk=None):
"""工作流下的工单数量统计
工作流下的工单数量统计
"""
queryset = self.filter_queryset(self.get_queryset())
result = Ticket.objects.filter(workflow__in=queryset).annotate(
workflow_name=F('workflow__name'), workflow_cate=F('workflow__cate')).values(
'workflow', 'workflow_name', 'workflow_cate').annotate(
count_done=Count(Case(When(state__type=2, then=1), output_field=IntegerField())),
count_processing=Count(Case(When(state__type=1, then=1), output_field=IntegerField())),
)
return Response(list(result))
class StateViewSet(CreateModelMixin, UpdateModelMixin, RetrieveModelMixin, DestroyModelMixin, CustomGenericViewSet): class StateViewSet(CreateModelMixin, UpdateModelMixin, RetrieveModelMixin, DestroyModelMixin, CustomGenericViewSet):
perms_map = {'get': '*', 'post': 'workflow.update', perms_map = {'get': '*', 'post': 'workflow.update',
'put': 'workflow.update', 'delete': 'workflow.update'} 'put': 'workflow.update', 'delete': 'workflow.update'}
queryset = State.objects.all() queryset = State.objects.all()
serializer_class = StateSerializer serializer_class = StateSerializer
retrieve_serializer_class = StateDetailSerializer
search_fields = ['name'] search_fields = ['name']
filterset_fields = ['workflow'] filterset_fields = ['workflow']
ordering = ['sort'] ordering = ['sort']
@ -239,6 +261,7 @@ class TicketViewSet(CreateUpdateCustomMixin, CreateModelMixin, ListModelMixin, R
raise ParseError('请指定查询分类') raise ParseError('请指定查询分类')
return super().filter_queryset(queryset) return super().filter_queryset(queryset)
@transaction.atomic
def create(self, request, *args, **kwargs): def create(self, request, *args, **kwargs):
""" """
新建工单 新建工单
@ -247,41 +270,12 @@ class TicketViewSet(CreateUpdateCustomMixin, CreateModelMixin, ListModelMixin, R
serializer = self.get_serializer(data=rdata) serializer = self.get_serializer(data=rdata)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
vdata = serializer.validated_data # 校验之后的数据 vdata = serializer.validated_data # 校验之后的数据
start_state = WfService.get_workflow_start_state(vdata['workflow']) transition = vdata.get("transition", None)
transition = vdata.pop('transition') workflow = vdata['workflow']
ticket_data = vdata['ticket_data'] ticket_data = vdata['ticket_data']
ticket = WfService.handle_ticket(ticket=None, transition=transition,
save_ticket_data = {} workflow=workflow, new_ticket_data=ticket_data,
# 校验必填项 oinfo=rdata, handler=request.user)
if transition.field_require_check:
for key, value in start_state.state_fields.items():
if int(value) == State.STATE_FIELD_REQUIRED:
if key not in ticket_data and not ticket_data[key]:
raise ParseError('字段{}必填'.format(key))
save_ticket_data[key] = ticket_data[key]
elif int(value) == State.STATE_FIELD_OPTIONAL:
save_ticket_data[key] = ticket_data[key]
else:
save_ticket_data = ticket_data
with transaction.atomic():
ticket = serializer.save(state=start_state,
create_by=request.user,
create_time=timezone.now(),
act_state=Ticket.TICKET_ACT_STATE_DRAFT,
belong_dept=request.user.belong_dept,
ticket_data=save_ticket_data) # 先创建出来
# 更新title和sn
title = vdata.get('title', '')
title_template = ticket.workflow.title_template
if title_template:
all_ticket_data = {**rdata, **ticket_data}
title = title_template.format(**all_ticket_data)
sn = WfService.get_ticket_sn(ticket.workflow) # 流水号
ticket.sn = sn
ticket.title = title
ticket.save()
ticket = WfService.handle_ticket(ticket=ticket, transition=transition, new_ticket_data=ticket_data,
handler=request.user, created=True)
return Response(TicketSerializer(instance=ticket).data) return Response(TicketSerializer(instance=ticket).data)
@action(methods=['get'], detail=False, perms_map={'get': '*'}) @action(methods=['get'], detail=False, perms_map={'get': '*'})
@ -297,6 +291,7 @@ class TicketViewSet(CreateUpdateCustomMixin, CreateModelMixin, ListModelMixin, R
return Response(ret) return Response(ret)
@action(methods=['post'], detail=True, perms_map={'post': '*'}) @action(methods=['post'], detail=True, perms_map={'post': '*'})
@transaction.atomic
def handle(self, request, pk=None): def handle(self, request, pk=None):
""" """
处理工单 处理工单
@ -307,13 +302,13 @@ class TicketViewSet(CreateUpdateCustomMixin, CreateModelMixin, ListModelMixin, R
vdata = serializer.validated_data vdata = serializer.validated_data
new_ticket_data = ticket.ticket_data new_ticket_data = ticket.ticket_data
new_ticket_data.update(**vdata['ticket_data']) new_ticket_data.update(**vdata['ticket_data'])
with transaction.atomic():
ticket = WfService.handle_ticket(ticket=ticket, transition=vdata['transition'], ticket = WfService.handle_ticket(ticket=ticket, transition=vdata['transition'],
new_ticket_data=new_ticket_data, handler=request.user, new_ticket_data=new_ticket_data, handler=request.user,
suggestion=vdata.get('suggestion', '')) suggestion=vdata.get('suggestion', ''))
return Response(TicketSerializer(instance=ticket).data) return Response(TicketSerializer(instance=ticket).data)
@action(methods=['post'], detail=True, perms_map={'post': '*'}) @action(methods=['post'], detail=True, perms_map={'post': '*'})
@transaction.atomic
def deliver(self, request, pk=None): def deliver(self, request, pk=None):
""" """
转交工单 转交工单
@ -325,15 +320,15 @@ class TicketViewSet(CreateUpdateCustomMixin, CreateModelMixin, ListModelMixin, R
vdata = serializer.validated_data # 校验之后的数据 vdata = serializer.validated_data # 校验之后的数据
if not ticket.state.enable_deliver: if not ticket.state.enable_deliver:
raise ParseError('不允许转交') raise ParseError('不允许转交')
with transaction.atomic():
ticket.participant_type = State.PARTICIPANT_TYPE_PERSONAL ticket.participant_type = State.PARTICIPANT_TYPE_PERSONAL
ticket.participant = vdata['target_user'] ticket.participant = vdata['target_user']
ticket.save() ticket.save()
TicketFlow.objects.create(ticket=ticket, state=ticket.state, tf = TicketFlow.objects.create(ticket=ticket, state=ticket.state,
ticket_data=WfService.get_ticket_all_field_value(ticket), ticket_data=WfService.get_ticket_all_field_value(ticket),
suggestion=vdata.get('suggestion', ''), participant_type=State.PARTICIPANT_TYPE_PERSONAL, suggestion=vdata.get('suggestion', ''), participant_type=State.PARTICIPANT_TYPE_PERSONAL,
intervene_type=Transition.TRANSITION_INTERVENE_TYPE_DELIVER, intervene_type=Transition.TRANSITION_INTERVENE_TYPE_DELIVER,
participant=request.user, transition=None) participant=request.user, transition=None)
WfService.send_ticket_notice(ticketflow=tf)
return Response() return Response()
@action(methods=['get'], detail=True, perms_map={'get': '*'}) @action(methods=['get'], detail=True, perms_map={'get': '*'})
@ -381,11 +376,12 @@ class TicketViewSet(CreateUpdateCustomMixin, CreateModelMixin, ListModelMixin, R
ticket.save() ticket.save()
# 接单日志 # 接单日志
# 更新工单流转记录 # 更新工单流转记录
TicketFlow.objects.create(ticket=ticket, state=ticket.state, tf = TicketFlow.objects.create(ticket=ticket, state=ticket.state,
ticket_data=WfService.get_ticket_all_field_value(ticket), ticket_data=WfService.get_ticket_all_field_value(ticket),
suggestion='', participant_type=State.PARTICIPANT_TYPE_PERSONAL, suggestion='', participant_type=State.PARTICIPANT_TYPE_PERSONAL,
intervene_type=Transition.TRANSITION_ATTRIBUTE_TYPE_ACCEPT, intervene_type=Transition.TRANSITION_ATTRIBUTE_TYPE_ACCEPT,
participant=request.user, transition=None) participant=request.user, transition=None)
WfService.send_ticket_notice(ticketflow=tf)
return Response() return Response()
else: else:
raise ParseError('无需接单') raise ParseError('无需接单')
@ -400,19 +396,7 @@ class TicketViewSet(CreateUpdateCustomMixin, CreateModelMixin, ListModelMixin, R
raise ParseError('非创建人不可撤回') raise ParseError('非创建人不可撤回')
if not ticket.state.enable_retreat: if not ticket.state.enable_retreat:
raise ParseError('该状态不可撤回') raise ParseError('该状态不可撤回')
start_state = WfService.get_workflow_start_state(ticket.workflow) WfService.retreat(ticket, request.data.get('suggestion', ''), request.user, request.user)
ticket.state = start_state
ticket.participant_type = State.PARTICIPANT_TYPE_PERSONAL
ticket.participant = request.user.id
ticket.act_state = Ticket.TICKET_ACT_STATE_RETREAT
ticket.save()
# 更新流转记录
suggestion = request.data.get('suggestion', '') # 撤回原因
TicketFlow.objects.create(ticket=ticket, state=ticket.state,
ticket_data=WfService.get_ticket_all_field_value(ticket),
suggestion=suggestion, participant_type=State.PARTICIPANT_TYPE_PERSONAL,
intervene_type=Transition.TRANSITION_INTERVENE_TYPE_RETREAT,
participant=request.user, transition=None)
return Response() return Response()
@action(methods=['post'], detail=True, perms_map={'post': '*'}, serializer_class=TicketAddNodeSerializer) @action(methods=['post'], detail=True, perms_map={'post': '*'}, serializer_class=TicketAddNodeSerializer)
@ -432,11 +416,12 @@ class TicketViewSet(CreateUpdateCustomMixin, CreateModelMixin, ListModelMixin, R
ticket.save() ticket.save()
# 更新流转记录 # 更新流转记录
suggestion = request.data.get('suggestion', '') # 加签说明 suggestion = request.data.get('suggestion', '') # 加签说明
TicketFlow.objects.create(ticket=ticket, state=ticket.state, tf = TicketFlow.objects.create(ticket=ticket, state=ticket.state,
ticket_data=WfService.get_ticket_all_field_value(ticket), ticket_data=WfService.get_ticket_all_field_value(ticket),
suggestion=suggestion, participant_type=State.PARTICIPANT_TYPE_PERSONAL, suggestion=suggestion, participant_type=State.PARTICIPANT_TYPE_PERSONAL,
intervene_type=Transition.TRANSITION_INTERVENE_TYPE_ADD_NODE, intervene_type=Transition.TRANSITION_INTERVENE_TYPE_ADD_NODE,
participant=request.user, transition=None) participant=request.user, transition=None)
WfService.send_ticket_notice(ticketflow=tf)
return Response() return Response()
@action(methods=['post'], detail=True, perms_map={'post': '*'}, serializer_class=TicketAddNodeEndSerializer) @action(methods=['post'], detail=True, perms_map={'post': '*'}, serializer_class=TicketAddNodeEndSerializer)
@ -456,11 +441,12 @@ class TicketViewSet(CreateUpdateCustomMixin, CreateModelMixin, ListModelMixin, R
ticket.save() ticket.save()
# 更新流转记录 # 更新流转记录
suggestion = request.data.get('suggestion', '') # 加签意见 suggestion = request.data.get('suggestion', '') # 加签意见
TicketFlow.objects.create(ticket=ticket, state=ticket.state, tf = TicketFlow.objects.create(ticket=ticket, state=ticket.state,
ticket_data=WfService.get_ticket_all_field_value(ticket), ticket_data=WfService.get_ticket_all_field_value(ticket),
suggestion=suggestion, participant_type=State.PARTICIPANT_TYPE_PERSONAL, suggestion=suggestion, participant_type=State.PARTICIPANT_TYPE_PERSONAL,
intervene_type=Transition.TRANSITION_INTERVENE_TYPE_ADD_NODE_END, intervene_type=Transition.TRANSITION_INTERVENE_TYPE_ADD_NODE_END,
participant=request.user, transition=None) participant=request.user, transition=None)
WfService.send_ticket_notice(ticketflow=tf)
return Response() return Response()
@action(methods=['post'], detail=True, perms_map={'post': '*'}, @action(methods=['post'], detail=True, perms_map={'post': '*'},

12
config/e.conf.py Normal file
View File

@ -0,0 +1,12 @@
SECRET_KEY = 'xx'
DEBUG = False
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'xx',
'USER': 'postgres',
'PASSWORD': 'xx',
'HOST': 'xx',
'PORT': '5432',
}
}

View File

@ -1,22 +1,21 @@
celery==5.2.3 celery==5.6.2
Django==3.2.12 Django==4.2.27
django-celery-beat==2.3.0 django-celery-beat==2.8.1
django-celery-results==2.4.0 django-celery-results==2.6.0
django-cors-headers==3.11.0 django-cors-headers==4.9.0
django-filter==21.1 django-filter==23.5
djangorestframework==3.13.1 djangorestframework==3.16.1
djangorestframework-simplejwt==5.1.0 djangorestframework-simplejwt==5.5.1
drf-yasg==1.21.3 drf-yasg==1.21.7
psutil==5.9.0 psutil==5.9.0
redis==4.4.0 redis==7.1.0
django-redis==5.2.0 django-redis==6.0.0
user-agents==2.2.0 user-agents==2.2.0
daphne==4.0.0 daphne==4.0.0
channels-redis==4.0.0 channels-redis==4.3.0
django-restql==0.15.2 django-restql==0.15.2
requests==2.28.1 requests==2.28.1
xlwt==1.3.0 xlwt==1.3.0
openpyxl==3.1.0 openpyxl==3.1.5
cron-descriptor==1.2.35 cron-descriptor==1.2.35
docxtpl==0.16.7 docxtpl==0.16.7
# deepface==0.0.79

View File

@ -1,5 +1,5 @@
import os import os
from . import conf from config import conf
from celery import Celery from celery import Celery
from celery.app.control import Control, Inspect from celery.app.control import Control, Inspect

View File

@ -14,8 +14,10 @@ from datetime import datetime, timedelta
import os import os
import json import json
import sys import sys
from .conf import * from config.conf import *
from django.core.cache import cache from django.core.cache import cache
import logging
# Build paths inside the project like this: os.path.join(BASE_DIR, ...) # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(BASE_DIR, 'apps')) sys.path.insert(0, os.path.join(BASE_DIR, 'apps'))
@ -34,7 +36,7 @@ ALLOWED_HOSTS = ['*']
SYS_NAME = 'XT_ADMIN' SYS_NAME = 'XT_ADMIN'
SYS_VERSION = '2.3.0' SYS_VERSION = '2.3.0'
X_FRAME_OPTIONS = 'SAMEORIGIN'
# Application definition # Application definition
@ -245,6 +247,18 @@ LOG_PATH = os.path.join(BASE_DIR, 'log')
if not os.path.exists(LOG_PATH): if not os.path.exists(LOG_PATH):
os.makedirs(LOG_PATH) os.makedirs(LOG_PATH)
class TimedSizeRotatingHandler(logging.handlers.TimedRotatingFileHandler):
def __init__(self, filename, when='midnight', interval=1, backupCount=0,
maxBytes=0, encoding=None, delay=False, utc=False, atTime=None):
super().__init__(filename, when, interval, backupCount, encoding, delay, utc, atTime)
self.maxBytes = maxBytes
def shouldRollover(self, record):
if self.maxBytes > 0 and os.path.exists(self.baseFilename):
if os.stat(self.baseFilename).st_size >= self.maxBytes:
return True
return super().shouldRollover(record)
LOGGING = { LOGGING = {
'version': 1, 'version': 1,
'disable_existing_loggers': False, 'disable_existing_loggers': False,
@ -268,22 +282,28 @@ LOGGING = {
# 默认记录所有日志 # 默认记录所有日志
'default': { 'default': {
'level': 'INFO', 'level': 'INFO',
'class': 'logging.handlers.RotatingFileHandler', 'class': 'server.settings.TimedSizeRotatingHandler',
'filename': os.path.join(LOG_PATH, 'all-{}.log'.format(datetime.now().strftime('%Y-%m-%d'))), 'filename': os.path.join(LOG_PATH, 'all.log'),
'when': 'midnight', # 每天午夜滚动
'interval': 1,
'maxBytes': 1024 * 1024 * 2, # 文件大小 'maxBytes': 1024 * 1024 * 2, # 文件大小
'backupCount': 10, # 备份数 'backupCount': 30, # 备份数
'formatter': 'standard', # 输出格式 'formatter': 'standard', # 输出格式
'encoding': 'utf-8', # 设置默认编码,否则打印出来汉字乱码 'encoding': 'utf-8', # 设置默认编码,否则打印出来汉字乱码
'delay': True, # 延迟打开文件,减少锁定冲突
}, },
# 输出错误日志 # 输出错误日志
'error': { 'error': {
'level': 'ERROR', 'level': 'ERROR',
'class': 'logging.handlers.RotatingFileHandler', 'class': 'server.settings.TimedSizeRotatingHandler',
'filename': os.path.join(LOG_PATH, 'error-{}.log'.format(datetime.now().strftime('%Y-%m-%d'))), 'filename': os.path.join(LOG_PATH, 'error.log'),
'when': 'midnight',
'interval': 1,
'maxBytes': 1024 * 1024 * 2, # 文件大小 'maxBytes': 1024 * 1024 * 2, # 文件大小
'backupCount': 10, # 备份数 'backupCount': 30, # 备份数
'formatter': 'standard', # 输出格式 'formatter': 'standard', # 输出格式
'encoding': 'utf-8', # 设置默认编码 'encoding': 'utf-8', # 设置默认编码
'delay': True, # 延迟打开文件,减少锁定冲突
}, },
# 控制台输出 # 控制台输出
'console': { 'console': {
@ -295,12 +315,15 @@ LOGGING = {
# 输出info日志 # 输出info日志
'info': { 'info': {
'level': 'INFO', 'level': 'INFO',
'class': 'logging.handlers.RotatingFileHandler', 'class': 'server.settings.TimedSizeRotatingHandler',
'filename': os.path.join(LOG_PATH, 'info-{}.log'.format(datetime.now().strftime('%Y-%m-%d'))), 'filename': os.path.join(LOG_PATH, 'info.log'),
'when': 'midnight',
'interval': 1,
'maxBytes': 1024 * 1024 * 2, 'maxBytes': 1024 * 1024 * 2,
'backupCount': 10, 'backupCount': 30,
'formatter': 'standard', 'formatter': 'standard',
'encoding': 'utf-8', # 设置默认编码 'encoding': 'utf-8', # 设置默认编码
'delay': True, # 延迟打开文件,减少锁定冲突
}, },
}, },
# 配置用哪几种 handlers 来处理日志 # 配置用哪几种 handlers 来处理日志
@ -321,7 +344,7 @@ LOGGING = {
} }
##### 加载客户可自定义配置并提供操作方法 ##### ##### 加载客户可自定义配置并提供操作方法 #####
SYS_JSON_PATH = os.path.join(BASE_DIR, 'server/conf.json') SYS_JSON_PATH = os.path.join(BASE_DIR, 'config/conf.json')
def get_sysconfig(key='', default='raise_error', reload=False): def get_sysconfig(key='', default='raise_error', reload=False):
"""获取系统配置可指定key字符串 """获取系统配置可指定key字符串