Compare commits
83 Commits
3d2c703cac
...
c8a6ced7a0
| Author | SHA1 | Date |
|---|---|---|
|
|
c8a6ced7a0 | |
|
|
42146f4ff7 | |
|
|
7abebc58d6 | |
|
|
78a781290d | |
|
|
216e82dae7 | |
|
|
7128252315 | |
|
|
fb1e4131ca | |
|
|
92f559cb4f | |
|
|
e2ec9625b4 | |
|
|
9cb0c5c681 | |
|
|
aa15a48cf5 | |
|
|
a49eaa7c3a | |
|
|
c4d38eb6b3 | |
|
|
3782938615 | |
|
|
87549bde20 | |
|
|
c80c2dc9dc | |
|
|
3fab2b9c9d | |
|
|
432c79dbc2 | |
|
|
33aa0464a5 | |
|
|
6c3b391b60 | |
|
|
a38c3049ea | |
|
|
3434376716 | |
|
|
10b4553f52 | |
|
|
7ea24f9c36 | |
|
|
b6191bec74 | |
|
|
c2514e51a4 | |
|
|
1062b25ca4 | |
|
|
2d551dae3c | |
|
|
5ab36065e4 | |
|
|
ba9f9251d3 | |
|
|
cb67fc6457 | |
|
|
4676c0a79f | |
|
|
bf1e2a900e | |
|
|
69ed857fff | |
|
|
153919aee9 | |
|
|
d7f2fb47a9 | |
|
|
52fc0c0a89 | |
|
|
fd2a6c9fcf | |
|
|
9f239d54f2 | |
|
|
01f1703984 | |
|
|
e6affb1f96 | |
|
|
417c2e4504 | |
|
|
dffd752568 | |
|
|
d0c3dd788d | |
|
|
b9e0a1e891 | |
|
|
359aefda82 | |
|
|
509d1f4922 | |
|
|
004f66dddd | |
|
|
ccbd08079c | |
|
|
0225d346b4 | |
|
|
02c9ca2823 | |
|
|
36d267b975 | |
|
|
2eb29e655b | |
|
|
e8c9e96300 | |
|
|
d805560894 | |
|
|
1459405f26 | |
|
|
d29d126643 | |
|
|
1a060730e3 | |
|
|
d6f3db79b1 | |
|
|
8e085eac84 | |
|
|
e2ef190094 | |
|
|
1bbf114f19 | |
|
|
e18e64003a | |
|
|
cf54b67b2d | |
|
|
3c2820f542 | |
|
|
561d5cd409 | |
|
|
8320ec5a0b | |
|
|
def890a873 | |
|
|
03bec2a6ab | |
|
|
7deaadd0a2 | |
|
|
5eb491ec1b | |
|
|
68c53fc0aa | |
|
|
734a9ed9dd | |
|
|
ac1ac19a16 | |
|
|
d21b07c65b | |
|
|
acfdf3bcd6 | |
|
|
454737db03 | |
|
|
ef90f95a6b | |
|
|
04d7aedd1a | |
|
|
ab2e9de729 | |
|
|
538d6b8a60 | |
|
|
456bf58514 | |
|
|
9f622af533 |
|
|
@ -20,6 +20,8 @@ apps.zip
|
|||
server/conf.py
|
||||
server/conf.ini
|
||||
server/conf*.json
|
||||
config/conf*.py
|
||||
config/conf*.json
|
||||
sh/*
|
||||
temp/*
|
||||
nohup.out
|
||||
|
|
@ -10,7 +10,7 @@ from apps.auth1.errors import USERNAME_OR_PASSWORD_WRONG
|
|||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
from django.core.cache import cache
|
||||
from apps.auth1.services import check_phone_code
|
||||
from apps.utils.sms import send_sms
|
||||
|
||||
from apps.utils.tools import rannum
|
||||
from apps.utils.wxmp import wxmpClient
|
||||
from apps.utils.wx import wxClient
|
||||
|
|
@ -182,6 +182,7 @@ class SendCode(CreateAPIView):
|
|||
|
||||
短信验证码发送
|
||||
"""
|
||||
from apps.utils.sms import send_sms
|
||||
phone = request.data['phone']
|
||||
code = rannum(6)
|
||||
is_ok, _ = send_sms(phone, 505, {'code': code})
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
from django_filters import rest_framework as filters
|
||||
from .models import Dept, User
|
||||
from apps.utils.queryset import get_child_queryset2
|
||||
from rest_framework.exceptions import ParseError
|
||||
|
||||
|
||||
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:
|
||||
model = User
|
||||
|
|
@ -19,6 +23,20 @@ class UserFilterSet(filters.FilterSet):
|
|||
'posts__name': ["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):
|
||||
|
|
@ -27,5 +45,6 @@ class DeptFilterSet(filters.FilterSet):
|
|||
model = Dept
|
||||
fields = {
|
||||
'type': ['exact', 'in'],
|
||||
'name': ['exact', 'in', 'contains']
|
||||
'name': ['exact', 'in', 'contains'],
|
||||
"parent": ['exact', 'isnull'],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -272,12 +272,10 @@ class DeptCreateUpdateSerializer(CustomModelSerializer):
|
|||
model = Dept
|
||||
exclude = EXCLUDE_FIELDS + ['third_info']
|
||||
|
||||
@transaction.atomic
|
||||
def create(self, validated_data):
|
||||
ins = super().create(validated_data)
|
||||
return ins
|
||||
|
||||
@transaction.atomic
|
||||
def update(self, instance, validated_data):
|
||||
ins = super().update(instance, validated_data)
|
||||
return ins
|
||||
|
|
|
|||
|
|
@ -8,8 +8,7 @@ from django_celery_beat.models import (CrontabSchedule, IntervalSchedule,
|
|||
from django_celery_results.models import TaskResult
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ParseError, ValidationError, PermissionDenied
|
||||
from rest_framework.mixins import (CreateModelMixin, DestroyModelMixin,
|
||||
ListModelMixin, RetrieveModelMixin)
|
||||
from rest_framework.mixins import RetrieveModelMixin
|
||||
from rest_framework.parsers import (JSONParser,
|
||||
MultiPartParser)
|
||||
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.filters import DeptFilterSet, UserFilterSet
|
||||
# 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 apps.utils.permission import ALL_PERMS
|
||||
from apps.utils.viewsets import CustomGenericViewSet, CustomModelViewSet
|
||||
|
|
@ -228,7 +227,7 @@ class PTaskViewSet(CustomModelViewSet):
|
|||
return Response()
|
||||
|
||||
|
||||
class PTaskResultViewSet(ListModelMixin, RetrieveModelMixin, CustomGenericViewSet):
|
||||
class PTaskResultViewSet(CustomListModelMixin, RetrieveModelMixin, CustomGenericViewSet):
|
||||
"""
|
||||
list:任务执行结果列表
|
||||
|
||||
|
|
@ -372,7 +371,7 @@ class RoleViewSet(CustomModelViewSet):
|
|||
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']
|
||||
|
||||
|
||||
class UserPostViewSet(CreateModelMixin, DestroyModelMixin, ListModelMixin, CustomGenericViewSet):
|
||||
class UserPostViewSet(BulkCreateModelMixin, BulkDestroyModelMixin, CustomListModelMixin, CustomGenericViewSet):
|
||||
"""用户/岗位关系
|
||||
|
||||
用户/岗位关系
|
||||
|
|
@ -397,32 +396,30 @@ class UserPostViewSet(CreateModelMixin, DestroyModelMixin, ListModelMixin, Custo
|
|||
ordering = ['sort', 'create_time']
|
||||
|
||||
def perform_create(self, serializer):
|
||||
with transaction.atomic():
|
||||
instance = serializer.save()
|
||||
user = instance.user
|
||||
up = UserPost.objects.filter(user=user).order_by(
|
||||
'sort', 'create_time').first()
|
||||
if up:
|
||||
user.belong_dept = up.dept
|
||||
user.post = up.post
|
||||
user.update_by = self.request.user
|
||||
user.save()
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
with transaction.atomic():
|
||||
user = instance.user
|
||||
instance.delete()
|
||||
up = UserPost.objects.filter(user=user).order_by(
|
||||
'sort', 'create_time').first()
|
||||
if up:
|
||||
user.belong_dept = up.dept
|
||||
user.post = up.post
|
||||
else:
|
||||
user.belong_dept = None
|
||||
user.post = None
|
||||
instance = serializer.save()
|
||||
user = instance.user
|
||||
up = UserPost.objects.filter(user=user).order_by(
|
||||
'sort', 'create_time').first()
|
||||
if up:
|
||||
user.belong_dept = up.dept
|
||||
user.post = up.post
|
||||
user.update_by = self.request.user
|
||||
user.save()
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
user = instance.user
|
||||
instance.delete()
|
||||
up = UserPost.objects.filter(user=user).order_by(
|
||||
'sort', 'create_time').first()
|
||||
if up:
|
||||
user.belong_dept = up.dept
|
||||
user.post = up.post
|
||||
else:
|
||||
user.belong_dept = None
|
||||
user.post = None
|
||||
user.update_by = self.request.user
|
||||
user.save()
|
||||
|
||||
|
||||
class UserViewSet(CustomModelViewSet):
|
||||
queryset = User.objects.get_queryset(all=True)
|
||||
|
|
@ -560,7 +557,7 @@ class UserViewSet(CustomModelViewSet):
|
|||
return Response()
|
||||
|
||||
|
||||
class FileViewSet(CustomCreateModelMixin, RetrieveModelMixin, ListModelMixin, CustomGenericViewSet):
|
||||
class FileViewSet(BulkCreateModelMixin, RetrieveModelMixin, CustomListModelMixin, CustomGenericViewSet):
|
||||
"""文件上传
|
||||
|
||||
list:
|
||||
|
|
@ -601,7 +598,7 @@ class FileViewSet(CustomCreateModelMixin, RetrieveModelMixin, ListModelMixin, Cu
|
|||
instance.save()
|
||||
|
||||
|
||||
class ApkViewSet(MyLoggingMixin, ListModelMixin, CreateModelMixin, GenericViewSet):
|
||||
class ApkViewSet(MyLoggingMixin, CustomListModelMixin, BulkCreateModelMixin, GenericViewSet):
|
||||
perms_map = {'get': '*', 'post': 'apk.upload'}
|
||||
serializer_class = ApkSerializer
|
||||
|
||||
|
|
@ -642,7 +639,7 @@ class ApkViewSet(MyLoggingMixin, ListModelMixin, CreateModelMixin, GenericViewSe
|
|||
return Response()
|
||||
|
||||
|
||||
class MyScheduleViewSet(ListModelMixin, CreateModelMixin, DestroyModelMixin, CustomGenericViewSet):
|
||||
class MyScheduleViewSet(CustomListModelMixin, BulkCreateModelMixin, BulkDestroyModelMixin, CustomGenericViewSet):
|
||||
perms_map = {'get': '*', 'post': '*',
|
||||
'delete': 'myschedule.delete'}
|
||||
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 ''
|
||||
|
||||
@transaction.atomic
|
||||
def perform_create(self, serializer):
|
||||
vdata = serializer.validated_data
|
||||
vdata['create_by'] = self.request.user # 不可少
|
||||
|
|
@ -718,6 +714,8 @@ class SysBaseConfigView(APIView):
|
|||
config = get_sysconfig()
|
||||
base_dict = {key: config[key]
|
||||
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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
from django.conf import settings
|
||||
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):
|
||||
|
|
@ -8,3 +12,9 @@ class MyFilePathField(serializers.CharField):
|
|||
if 'http' in value:
|
||||
return str(value)
|
||||
return settings.BASE_URL + str(value)
|
||||
|
||||
class PositiveDecimalField(DecimalField):
|
||||
|
||||
@cached_property
|
||||
def validators(self):
|
||||
return [MinValueValidator(Decimal('0.0'))] + super().validators
|
||||
|
|
@ -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
|
||||
|
|
@ -9,13 +9,16 @@ from django.utils.timezone import now
|
|||
from user_agents import parse
|
||||
import logging
|
||||
from rest_framework.response import Response
|
||||
from django.db import transaction
|
||||
from rest_framework.exceptions import ParseError, ValidationError
|
||||
from apps.utils.errors import PKS_ERROR
|
||||
from rest_framework.generics import get_object_or_404
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
from drf_yasg import openapi
|
||||
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 = logging.getLogger('log')
|
||||
|
|
@ -78,7 +81,8 @@ class BulkCreateModelMixin(CreateModelMixin):
|
|||
|
||||
def after_bulk_create(self, objs):
|
||||
pass
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""创建(支持批量)
|
||||
|
||||
|
|
@ -87,11 +91,16 @@ class BulkCreateModelMixin(CreateModelMixin):
|
|||
rdata = request.data
|
||||
many = False
|
||||
if isinstance(rdata, list):
|
||||
for item in rdata:
|
||||
if "id" in item and item["id"]:
|
||||
raise ParseError('创建数据中不能包含id字段')
|
||||
many = True
|
||||
with transaction.atomic():
|
||||
sr = self.get_serializer(data=rdata, many=many)
|
||||
sr.is_valid(raise_exception=True)
|
||||
self.perform_create(sr)
|
||||
else:
|
||||
if "id" in rdata and rdata["id"]:
|
||||
raise ParseError('创建数据中不能包含id字段')
|
||||
sr = self.get_serializer(data=rdata, many=many)
|
||||
sr.is_valid(raise_exception=True)
|
||||
self.perform_create(sr)
|
||||
if many:
|
||||
self.after_bulk_create(sr.data)
|
||||
return Response(sr.data, status=201)
|
||||
|
|
@ -102,6 +111,7 @@ class BulkUpdateModelMixin(UpdateModelMixin):
|
|||
def after_bulk_update(self, objs):
|
||||
pass
|
||||
|
||||
@transaction.atomic
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
"""部分更新(支持批量)
|
||||
|
||||
|
|
@ -110,6 +120,7 @@ class BulkUpdateModelMixin(UpdateModelMixin):
|
|||
kwargs['partial'] = True
|
||||
return self.update(request, *args, **kwargs)
|
||||
|
||||
@transaction.atomic
|
||||
def update(self, request, *args, **kwargs):
|
||||
"""更新(支持批量)
|
||||
|
||||
|
|
@ -121,16 +132,15 @@ class BulkUpdateModelMixin(UpdateModelMixin):
|
|||
queryset = self.filter_queryset(self.get_queryset())
|
||||
objs = []
|
||||
if isinstance(request.data, list):
|
||||
with transaction.atomic():
|
||||
for ind, item in enumerate(request.data):
|
||||
obj = get_object_or_404(queryset, id=item['id'])
|
||||
sr = self.get_serializer(obj, data=item, partial=partial)
|
||||
if not sr.is_valid():
|
||||
err_dict = { f'第{ind+1}': sr.errors}
|
||||
raise ValidationError(err_dict)
|
||||
self.perform_update(sr) # 用自带的更新,可能需要做其他操作
|
||||
objs.append(sr.data)
|
||||
self.after_bulk_update(objs)
|
||||
for ind, item in enumerate(request.data):
|
||||
obj = get_object_or_404(queryset, id=item['id'])
|
||||
sr = self.get_serializer(obj, data=item, partial=partial)
|
||||
if not sr.is_valid():
|
||||
err_dict = { f'第{ind+1}': sr.errors}
|
||||
raise ValidationError(err_dict)
|
||||
self.perform_update(sr) # 用自带的更新,可能需要做其他操作
|
||||
objs.append(sr.data)
|
||||
self.after_bulk_update(objs)
|
||||
else:
|
||||
raise ParseError('提交数据非列表')
|
||||
return Response(objs)
|
||||
|
|
@ -145,6 +155,7 @@ class BulkUpdateModelMixin(UpdateModelMixin):
|
|||
class BulkDestroyModelMixin(DestroyModelMixin):
|
||||
|
||||
@swagger_auto_schema(request_body=PkSerializer)
|
||||
@transaction.atomic
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
"""删除(支持批量)
|
||||
|
||||
|
|
@ -188,6 +199,8 @@ class CustomRetrieveModelMixin(RetrieveModelMixin):
|
|||
|
||||
给dict返回数据添加额外信息
|
||||
"""
|
||||
if hasattr(self, 'add_info_for_list'):
|
||||
return self.add_info_for_list([data])[0]
|
||||
return data
|
||||
|
||||
class CustomListModelMixin(ListModelMixin):
|
||||
|
|
@ -219,6 +232,79 @@ class CustomListModelMixin(ListModelMixin):
|
|||
"""
|
||||
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):
|
||||
"""Mixin to log requests"""
|
||||
|
||||
|
|
@ -264,6 +350,7 @@ class MyLoggingMixin(object):
|
|||
response = super().finalize_response(
|
||||
request, response, *args, **kwargs
|
||||
)
|
||||
self.log["response_ms"] = self._get_response_ms()
|
||||
# Ensure backward compatibility for those using _should_log hook
|
||||
should_log = (
|
||||
self._should_log if hasattr(self, "_should_log") else self.should_log
|
||||
|
|
@ -292,7 +379,7 @@ class MyLoggingMixin(object):
|
|||
"method": request.method,
|
||||
"query_params": self._clean_data(request.query_params.dict()),
|
||||
"user": self._get_user(request),
|
||||
"response_ms": self._get_response_ms(),
|
||||
# "response_ms": self._get_response_ms(),
|
||||
"response": self._clean_data(rendered_content),
|
||||
"status_code": response.status_code,
|
||||
"agent": self._get_agent(request),
|
||||
|
|
@ -386,7 +473,8 @@ class MyLoggingMixin(object):
|
|||
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 \
|
||||
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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from django.db import IntegrityError
|
|||
from django.db import transaction
|
||||
from rest_framework.exceptions import ParseError
|
||||
from django.core.cache import cache
|
||||
from django.db import transaction, connection
|
||||
import hashlib
|
||||
|
||||
# 自定义软删除查询基类
|
||||
|
|
@ -115,14 +116,67 @@ class BaseModel(models.Model):
|
|||
|
||||
@classmethod
|
||||
def safe_get_or_create(cls, defaults=None, **kwargs):
|
||||
"""
|
||||
多进程/多服务器安全的 get_or_create
|
||||
- 数据库唯一约束不够时,用 Redis 锁防止重复创建
|
||||
- 在事务中使用 select_for_update
|
||||
"""
|
||||
defaults = defaults or {}
|
||||
lock_data = {**kwargs, **defaults}
|
||||
lock_hash = hashlib.md5(str(lock_data).encode()).hexdigest()
|
||||
lock_key = f"safe_get_or_create:{cls.__name__}:{lock_hash}"
|
||||
with cache.lock(lock_key, timeout=10):
|
||||
return cls.objects.get_or_create(**kwargs, defaults=defaults)
|
||||
|
||||
create_kwargs = {**kwargs, **defaults}
|
||||
|
||||
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}"
|
||||
|
||||
with cache.lock(lock_key, timeout=10):
|
||||
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):
|
||||
pass
|
||||
|
||||
|
|
@ -132,32 +186,32 @@ class BaseModel(models.Model):
|
|||
if not self.id:
|
||||
is_create = True
|
||||
self.id = idWorker.get_id()
|
||||
with transaction.atomic():
|
||||
old_parent = None
|
||||
need_handle_parent = False
|
||||
if hasattr(self, "parent"):
|
||||
if is_create:
|
||||
|
||||
old_parent = None
|
||||
need_handle_parent = False
|
||||
if hasattr(self, "parent"):
|
||||
if is_create:
|
||||
need_handle_parent = True
|
||||
else:
|
||||
try:
|
||||
old_parent = self.__class__.objects.get(id=self.id).parent
|
||||
except Exception:
|
||||
self.parent = None
|
||||
need_handle_parent = True
|
||||
else:
|
||||
try:
|
||||
old_parent = self.__class__.objects.get(id=self.id).parent
|
||||
except Exception:
|
||||
self.parent = None
|
||||
need_handle_parent = True
|
||||
if self.parent != old_parent:
|
||||
need_handle_parent = True
|
||||
try:
|
||||
if self.parent != old_parent:
|
||||
need_handle_parent = True
|
||||
try:
|
||||
ins = super().save(*args, **kwargs)
|
||||
except IntegrityError as e:
|
||||
if is_create:
|
||||
time.sleep(0.01)
|
||||
self.id = idWorker.get_id()
|
||||
ins = super().save(*args, **kwargs)
|
||||
except IntegrityError as e:
|
||||
if is_create:
|
||||
time.sleep(0.01)
|
||||
self.id = idWorker.get_id()
|
||||
ins = super().save(*args, **kwargs)
|
||||
raise e
|
||||
# 处理父级
|
||||
if need_handle_parent:
|
||||
self.handle_parent()
|
||||
return ins
|
||||
raise e
|
||||
# 处理父级
|
||||
if need_handle_parent:
|
||||
self.handle_parent()
|
||||
return ins
|
||||
|
||||
|
||||
class SoftModel(BaseModel):
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ def get_user_route(user: User) -> List[str]:
|
|||
else:
|
||||
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()
|
||||
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"))
|
||||
for item in user_routes_list:
|
||||
item["meta"] = {}
|
||||
|
|
@ -54,6 +54,8 @@ def get_user_route(user: User) -> List[str]:
|
|||
item.pop("is_fullpage")
|
||||
item["name"] = item["route_name"]
|
||||
item.pop("route_name")
|
||||
if item["path"].startswith("http"):
|
||||
item["meta"]["type"] = "iframe"
|
||||
return build_tree_from_list(user_routes_list)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -75,12 +75,14 @@ class CustomModelSerializer(DynamicFieldsMixin, TreeSerializerMixin, serializers
|
|||
class QuerySerializer(serializers.Serializer):
|
||||
field = serializers.CharField(label='字段名')
|
||||
compare = serializers.ChoiceField(
|
||||
label='比较式', choices=["", "!", "gte", "gt", "lte", "lt", "in", "contains"])
|
||||
value = serializers.CharField(label='值')
|
||||
label='比较式', choices=["", "!", "gte", "gt", "lte", "lt", "in", "contains", "isnull"])
|
||||
value = serializers.JSONField(label='值', allow_null=True)
|
||||
|
||||
|
||||
class ComplexSerializer(serializers.Serializer):
|
||||
page = serializers.IntegerField(min_value=0, required=False)
|
||||
page_size = serializers.IntegerField(min_value=1, required=False)
|
||||
ordering = serializers.CharField(required=False)
|
||||
querys = serializers.ListField(child=QuerySerializer(
|
||||
many=True), label="查询列表", required=False)
|
||||
annotate_field_list = serializers.ListField(child=serializers.CharField(), label="RawSQL字段列表", required=False)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
from aliyunsdkcore.client import AcsClient
|
||||
from aliyunsdkcore.request import CommonRequest
|
||||
|
||||
import json
|
||||
import logging
|
||||
from server.settings import get_sysconfig
|
||||
|
|
@ -8,8 +7,11 @@ from apps.utils.decorators import auto_log
|
|||
# 实例化myLogger
|
||||
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):
|
||||
from aliyunsdkcore.client import AcsClient
|
||||
from aliyunsdkcore.request import CommonRequest
|
||||
config = get_sysconfig()
|
||||
if config.get("sms", {}).get('enabled', True) is False:
|
||||
return
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
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数据
|
||||
|
||||
Args:
|
||||
|
|
@ -8,7 +10,8 @@ def execute_raw_sql(sql: str, params=None):
|
|||
params (_type_, optional): 参数列表. Defaults to None.
|
||||
"""
|
||||
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:
|
||||
cursor.execute(sql, params=params)
|
||||
else:
|
||||
|
|
@ -23,7 +26,7 @@ def format_sqldata(columns, 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:
|
||||
|
|
@ -36,9 +39,19 @@ def query_all_dict(sql, params=None):
|
|||
else:
|
||||
cursor.execute(sql)
|
||||
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()]
|
||||
|
||||
def query_one_dict(sql, params=None):
|
||||
def query_one_dict(sql, params=None, with_time_format=False):
|
||||
"""
|
||||
查询一个结果返回字典类型数据
|
||||
:param sql:
|
||||
|
|
@ -46,13 +59,17 @@ def query_one_dict(sql, params=None):
|
|||
:return:
|
||||
"""
|
||||
with connection.cursor() as cursor:
|
||||
if params:
|
||||
cursor.execute(sql, params=params)
|
||||
else:
|
||||
cursor.execute(sql)
|
||||
cursor.execute(sql, params or ()) # 更简洁的参数处理
|
||||
columns = [desc[0] for desc in cursor.description]
|
||||
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 psycopg2
|
||||
|
|
|
|||
|
|
@ -10,6 +10,14 @@ from io import BytesIO
|
|||
from rest_framework.serializers import ValidationError
|
||||
import ast
|
||||
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):
|
||||
def __init__(self):
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
|
||||
from django.core.cache import cache
|
||||
from django.http import StreamingHttpResponse, Http404
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ParseError
|
||||
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.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.queryset import get_child_queryset2, get_child_queryset_u
|
||||
from apps.utils.serializers import ComplexSerializer
|
||||
from rest_framework.throttling import UserRateThrottle
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
import json
|
||||
|
||||
from django.db import connection
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db.utils import NotSupportedError
|
||||
|
||||
class CustomGenericViewSet(MyLoggingMixin, GenericViewSet):
|
||||
"""
|
||||
|
|
@ -84,6 +88,36 @@ class CustomGenericViewSet(MyLoggingMixin, GenericViewSet):
|
|||
elif 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):
|
||||
action_serializer_name = f"{self.action}_serializer_class"
|
||||
action_serializer_class = getattr(self, action_serializer_name, None)
|
||||
|
|
@ -102,16 +136,16 @@ class CustomGenericViewSet(MyLoggingMixin, GenericViewSet):
|
|||
return 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:
|
||||
queryset = queryset.select_related(*self.select_related_fields)
|
||||
if 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
|
||||
|
||||
def get_queryset(self):
|
||||
|
|
@ -183,44 +217,16 @@ class CustomGenericViewSet(MyLoggingMixin, GenericViewSet):
|
|||
return queryset
|
||||
return queryset.filter(create_by=self.request.user)
|
||||
|
||||
|
||||
class CustomModelViewSet(BulkCreateModelMixin, BulkUpdateModelMixin, CustomListModelMixin,
|
||||
CustomRetrieveModelMixin, BulkDestroyModelMixin, CustomGenericViewSet):
|
||||
CustomRetrieveModelMixin, BulkDestroyModelMixin, ComplexQueryMixin, CustomGenericViewSet):
|
||||
"""
|
||||
增强的ModelViewSet
|
||||
"""
|
||||
|
||||
@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.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)
|
||||
|
||||
class EuModelViewSet(BulkCreateModelMixin, CustomListModelMixin,
|
||||
CustomRetrieveModelMixin, BulkDestroyModelMixin, ComplexQueryMixin, CustomGenericViewSet):
|
||||
"""
|
||||
不支持更新的增强ModelViewSet
|
||||
"""
|
||||
|
|
@ -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='前端自定义页面路径'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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='分类'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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='标题模板'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
@ -9,6 +9,7 @@ class Workflow(CommonAModel):
|
|||
工作流
|
||||
"""
|
||||
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)
|
||||
sn_prefix = models.CharField('流水号前缀', max_length=50, default='hb')
|
||||
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的用户提交工单)')
|
||||
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:工作流名称')
|
||||
title_template = models.CharField(
|
||||
'标题模板', max_length=50, default='{title}', null=True, blank=True, help_text='工单字段的值可以作为参数写到模板中,格式如:你有一个待办工单:{title}')
|
||||
content_template = models.CharField(
|
||||
'内容模板', max_length=1000, default='标题:{title}, 创建时间:{create_time}', null=True, blank=True, help_text='工单字段的值可以作为参数写到模板中,格式如:标题:{title}, 创建时间:{create_time}')
|
||||
title_template = models.TextField(
|
||||
'标题模板', default='{title}', null=True, blank=True, help_text='工单字段的值可以作为参数写到模板中,格式如:你有一个待办工单:{title}')
|
||||
content_template = models.TextField(
|
||||
'内容模板', default='标题:{title}, 创建时间:{create_time}', null=True, blank=True, help_text='工单字段的值可以作为参数写到模板中,格式如:标题:{title}, 创建时间:{create_time}')
|
||||
view_path = models.TextField('前端自定义页面路径', null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '工作流'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from apps.system.models import Dept, User
|
||||
from apps.system.serializers import UserSignatureSerializer, UserSimpleSerializer
|
||||
from apps.system.models import Dept, User, Post, Role
|
||||
from apps.system.serializers import UserSignatureSerializer, UserSimpleSerializer, DeptSimpleSerializer, PostSimpleSerializer, RoleSimpleSerializer
|
||||
from rest_framework import serializers
|
||||
from apps.utils.serializers import CustomModelSerializer
|
||||
|
||||
|
|
@ -23,11 +23,25 @@ class StateSerializer(CustomModelSerializer):
|
|||
model = State
|
||||
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 Meta:
|
||||
model = Workflow
|
||||
fields = ['id', 'name', 'key']
|
||||
fields = ['id', 'name', 'key', 'view_path']
|
||||
|
||||
|
||||
class StateSimpleSerializer(CustomModelSerializer):
|
||||
|
|
@ -94,7 +108,7 @@ class TicketSimpleSerializer(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)
|
||||
|
||||
class Meta:
|
||||
|
|
@ -123,11 +137,13 @@ class TicketListSerializer(CustomModelSerializer):
|
|||
workflow_ = WorkflowSimpleSerializer(source='workflow', read_only=True)
|
||||
state_ = StateSimpleSerializer(source='state', read_only=True)
|
||||
participant_ = serializers.SerializerMethodField()
|
||||
create_by_name = serializers.CharField(source='create_by.name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Ticket
|
||||
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']
|
||||
|
||||
def get_participant_(self, obj):
|
||||
|
|
@ -138,7 +154,7 @@ class TicketListSerializer(CustomModelSerializer):
|
|||
|
||||
@staticmethod
|
||||
def setup_eager_loading(queryset):
|
||||
queryset = queryset.select_related('workflow', 'state')
|
||||
queryset = queryset.select_related('workflow', 'state', 'create_by')
|
||||
return queryset
|
||||
|
||||
|
||||
|
|
@ -147,6 +163,7 @@ class TicketDetailSerializer(CustomModelSerializer):
|
|||
state_ = StateSimpleSerializer(source='state', read_only=True)
|
||||
ticket_data_ = serializers.SerializerMethodField()
|
||||
participant_ = serializers.SerializerMethodField()
|
||||
create_by_name = serializers.CharField(source='create_by.name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Ticket
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import random
|
|||
from apps.utils.queryset import get_parent_queryset
|
||||
from apps.wf.tasks import run_task
|
||||
from rest_framework.exceptions import ParseError
|
||||
|
||||
import time
|
||||
|
||||
class WfService(object):
|
||||
@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
|
||||
def get_ticket_steps(cls, ticket: Ticket):
|
||||
|
|
@ -184,7 +184,15 @@ class WfService(object):
|
|||
dpt_attrs = state.filter_dept.split('.') # 通过反向查询得到可能有多层
|
||||
expr = ticket
|
||||
for i in dpt_attrs:
|
||||
expr = getattr(expr, i)
|
||||
try:
|
||||
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)
|
||||
user_queryset = user_queryset.filter(depts__in=dpts)
|
||||
# if state.filter_policy == 1:
|
||||
|
|
@ -297,9 +305,56 @@ class WfService(object):
|
|||
return field_info_dict
|
||||
|
||||
@classmethod
|
||||
def handle_ticket(cls, ticket: Ticket, transition: Transition, new_ticket_data: dict = {}, handler: User = None,
|
||||
suggestion: str = '', created: bool = False, by_timer: bool = False,
|
||||
def handle_ticket(cls, ticket: Ticket=None, transition: Transition=None, workflow: Workflow=None, new_ticket_data: dict = {}, oinfo: dict = {}, handler: User = None,
|
||||
suggestion: str = '', by_timer: 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_ticket_data = ticket.ticket_data
|
||||
|
|
@ -315,13 +370,13 @@ class WfService(object):
|
|||
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)
|
||||
if result.get('permission') is False:
|
||||
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():
|
||||
if int(value) == State.STATE_FIELD_REQUIRED:
|
||||
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
|
||||
|
||||
# 只更新必填和可选的字段
|
||||
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():
|
||||
if value in (State.STATE_FIELD_REQUIRED, State.STATE_FIELD_OPTIONAL):
|
||||
if key in new_ticket_data:
|
||||
|
|
@ -384,7 +439,7 @@ class WfService(object):
|
|||
suggestion=suggestion, participant_type=State.PARTICIPANT_TYPE_PERSONAL,
|
||||
participant=handler, transition=transition)
|
||||
|
||||
if created:
|
||||
if just_created:
|
||||
if source_state.participant_cc:
|
||||
TicketFlow.objects.create(ticket=ticket, state=source_state,
|
||||
participant_type=0, intervene_type=Transition.TRANSITION_INTERVENE_TYPE_CC,
|
||||
|
|
@ -442,11 +497,17 @@ class WfService(object):
|
|||
last_log.intervene_type == Transition.TRANSITION_INTERVENE_TYPE_DELIVER or
|
||||
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:
|
||||
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
|
||||
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),
|
||||
suggestion=suggestion, participant_type=State.PARTICIPANT_TYPE_ROBOT,
|
||||
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}
|
||||
if ticket.participant_type == 1:
|
||||
# 发送短信通知
|
||||
|
|
|
|||
124
apps/wf/views.py
124
apps/wf/views.py
|
|
@ -8,7 +8,7 @@ from rest_framework.response import Response
|
|||
from rest_framework.mixins import CreateModelMixin, DestroyModelMixin, ListModelMixin, \
|
||||
RetrieveModelMixin, UpdateModelMixin
|
||||
from apps.wf.serializers import CustomFieldCreateUpdateSerializer, CustomFieldSerializer, StateSerializer, \
|
||||
TicketAddNodeEndSerializer, TicketAddNodeSerializer, TicketCloseSerializer, \
|
||||
StateDetailSerializer, TicketAddNodeEndSerializer, TicketAddNodeSerializer, TicketCloseSerializer, \
|
||||
TicketCreateSerializer, TicketDeliverSerializer, TicketDestorySerializer, TicketFlowSerializer, \
|
||||
TicketHandleSerializer, TicketRetreatSerializer, \
|
||||
TicketSerializer, TransitionSerializer, WorkflowSerializer, \
|
||||
|
|
@ -20,7 +20,7 @@ from apps.utils.mixins import CreateUpdateCustomMixin, CreateUpdateModelAMixin
|
|||
from apps.wf.services import WfService
|
||||
from rest_framework.exceptions import ParseError, NotFound
|
||||
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 apps.utils.snowflake import idWorker
|
||||
import importlib
|
||||
|
|
@ -60,10 +60,16 @@ class WorkflowKeyInitView(APIView):
|
|||
class WorkflowViewSet(CustomModelViewSet):
|
||||
queryset = Workflow.objects.all()
|
||||
serializer_class = WorkflowSerializer
|
||||
search_fields = ['name', 'description']
|
||||
filterset_fields = []
|
||||
ordering_fields = ['create_time']
|
||||
ordering = ['key', '-create_time']
|
||||
search_fields = ['name', 'description', 'key']
|
||||
filterset_fields = ['key', 'cate']
|
||||
ordering_fields = ['create_time', 'key', 'cate']
|
||||
|
||||
@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'},
|
||||
pagination_class=None, serializer_class=StateSerializer)
|
||||
|
|
@ -172,6 +178,21 @@ class WorkflowViewSet(CustomModelViewSet):
|
|||
tr.condition_expression = ce
|
||||
tr.save()
|
||||
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):
|
||||
|
|
@ -179,6 +200,7 @@ class StateViewSet(CreateModelMixin, UpdateModelMixin, RetrieveModelMixin, Destr
|
|||
'put': 'workflow.update', 'delete': 'workflow.update'}
|
||||
queryset = State.objects.all()
|
||||
serializer_class = StateSerializer
|
||||
retrieve_serializer_class = StateDetailSerializer
|
||||
search_fields = ['name']
|
||||
filterset_fields = ['workflow']
|
||||
ordering = ['sort']
|
||||
|
|
@ -239,6 +261,7 @@ class TicketViewSet(CreateUpdateCustomMixin, CreateModelMixin, ListModelMixin, R
|
|||
raise ParseError('请指定查询分类')
|
||||
return super().filter_queryset(queryset)
|
||||
|
||||
@transaction.atomic
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""
|
||||
新建工单
|
||||
|
|
@ -247,41 +270,12 @@ class TicketViewSet(CreateUpdateCustomMixin, CreateModelMixin, ListModelMixin, R
|
|||
serializer = self.get_serializer(data=rdata)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
vdata = serializer.validated_data # 校验之后的数据
|
||||
start_state = WfService.get_workflow_start_state(vdata['workflow'])
|
||||
transition = vdata.pop('transition')
|
||||
transition = vdata.get("transition", None)
|
||||
workflow = vdata['workflow']
|
||||
ticket_data = vdata['ticket_data']
|
||||
|
||||
save_ticket_data = {}
|
||||
# 校验必填项
|
||||
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)
|
||||
ticket = WfService.handle_ticket(ticket=None, transition=transition,
|
||||
workflow=workflow, new_ticket_data=ticket_data,
|
||||
oinfo=rdata, handler=request.user)
|
||||
return Response(TicketSerializer(instance=ticket).data)
|
||||
|
||||
@action(methods=['get'], detail=False, perms_map={'get': '*'})
|
||||
|
|
@ -297,6 +291,7 @@ class TicketViewSet(CreateUpdateCustomMixin, CreateModelMixin, ListModelMixin, R
|
|||
return Response(ret)
|
||||
|
||||
@action(methods=['post'], detail=True, perms_map={'post': '*'})
|
||||
@transaction.atomic
|
||||
def handle(self, request, pk=None):
|
||||
"""
|
||||
处理工单
|
||||
|
|
@ -307,13 +302,13 @@ class TicketViewSet(CreateUpdateCustomMixin, CreateModelMixin, ListModelMixin, R
|
|||
vdata = serializer.validated_data
|
||||
new_ticket_data = ticket.ticket_data
|
||||
new_ticket_data.update(**vdata['ticket_data'])
|
||||
with transaction.atomic():
|
||||
ticket = WfService.handle_ticket(ticket=ticket, transition=vdata['transition'],
|
||||
new_ticket_data=new_ticket_data, handler=request.user,
|
||||
suggestion=vdata.get('suggestion', ''))
|
||||
ticket = WfService.handle_ticket(ticket=ticket, transition=vdata['transition'],
|
||||
new_ticket_data=new_ticket_data, handler=request.user,
|
||||
suggestion=vdata.get('suggestion', ''))
|
||||
return Response(TicketSerializer(instance=ticket).data)
|
||||
|
||||
@action(methods=['post'], detail=True, perms_map={'post': '*'})
|
||||
@transaction.atomic
|
||||
def deliver(self, request, pk=None):
|
||||
"""
|
||||
转交工单
|
||||
|
|
@ -325,15 +320,15 @@ class TicketViewSet(CreateUpdateCustomMixin, CreateModelMixin, ListModelMixin, R
|
|||
vdata = serializer.validated_data # 校验之后的数据
|
||||
if not ticket.state.enable_deliver:
|
||||
raise ParseError('不允许转交')
|
||||
with transaction.atomic():
|
||||
ticket.participant_type = State.PARTICIPANT_TYPE_PERSONAL
|
||||
ticket.participant = vdata['target_user']
|
||||
ticket.save()
|
||||
TicketFlow.objects.create(ticket=ticket, state=ticket.state,
|
||||
ticket_data=WfService.get_ticket_all_field_value(ticket),
|
||||
suggestion=vdata.get('suggestion', ''), participant_type=State.PARTICIPANT_TYPE_PERSONAL,
|
||||
intervene_type=Transition.TRANSITION_INTERVENE_TYPE_DELIVER,
|
||||
participant=request.user, transition=None)
|
||||
ticket.participant_type = State.PARTICIPANT_TYPE_PERSONAL
|
||||
ticket.participant = vdata['target_user']
|
||||
ticket.save()
|
||||
tf = TicketFlow.objects.create(ticket=ticket, state=ticket.state,
|
||||
ticket_data=WfService.get_ticket_all_field_value(ticket),
|
||||
suggestion=vdata.get('suggestion', ''), participant_type=State.PARTICIPANT_TYPE_PERSONAL,
|
||||
intervene_type=Transition.TRANSITION_INTERVENE_TYPE_DELIVER,
|
||||
participant=request.user, transition=None)
|
||||
WfService.send_ticket_notice(ticketflow=tf)
|
||||
return Response()
|
||||
|
||||
@action(methods=['get'], detail=True, perms_map={'get': '*'})
|
||||
|
|
@ -381,11 +376,12 @@ class TicketViewSet(CreateUpdateCustomMixin, CreateModelMixin, ListModelMixin, R
|
|||
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),
|
||||
suggestion='', participant_type=State.PARTICIPANT_TYPE_PERSONAL,
|
||||
intervene_type=Transition.TRANSITION_ATTRIBUTE_TYPE_ACCEPT,
|
||||
participant=request.user, transition=None)
|
||||
WfService.send_ticket_notice(ticketflow=tf)
|
||||
return Response()
|
||||
else:
|
||||
raise ParseError('无需接单')
|
||||
|
|
@ -400,19 +396,7 @@ class TicketViewSet(CreateUpdateCustomMixin, CreateModelMixin, ListModelMixin, R
|
|||
raise ParseError('非创建人不可撤回')
|
||||
if not ticket.state.enable_retreat:
|
||||
raise ParseError('该状态不可撤回')
|
||||
start_state = WfService.get_workflow_start_state(ticket.workflow)
|
||||
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)
|
||||
WfService.retreat(ticket, request.data.get('suggestion', ''), request.user, request.user)
|
||||
return Response()
|
||||
|
||||
@action(methods=['post'], detail=True, perms_map={'post': '*'}, serializer_class=TicketAddNodeSerializer)
|
||||
|
|
@ -432,11 +416,12 @@ class TicketViewSet(CreateUpdateCustomMixin, CreateModelMixin, ListModelMixin, R
|
|||
ticket.save()
|
||||
# 更新流转记录
|
||||
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),
|
||||
suggestion=suggestion, participant_type=State.PARTICIPANT_TYPE_PERSONAL,
|
||||
intervene_type=Transition.TRANSITION_INTERVENE_TYPE_ADD_NODE,
|
||||
participant=request.user, transition=None)
|
||||
WfService.send_ticket_notice(ticketflow=tf)
|
||||
return Response()
|
||||
|
||||
@action(methods=['post'], detail=True, perms_map={'post': '*'}, serializer_class=TicketAddNodeEndSerializer)
|
||||
|
|
@ -456,11 +441,12 @@ class TicketViewSet(CreateUpdateCustomMixin, CreateModelMixin, ListModelMixin, R
|
|||
ticket.save()
|
||||
# 更新流转记录
|
||||
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),
|
||||
suggestion=suggestion, participant_type=State.PARTICIPANT_TYPE_PERSONAL,
|
||||
intervene_type=Transition.TRANSITION_INTERVENE_TYPE_ADD_NODE_END,
|
||||
participant=request.user, transition=None)
|
||||
WfService.send_ticket_notice(ticketflow=tf)
|
||||
return Response()
|
||||
|
||||
@action(methods=['post'], detail=True, perms_map={'post': '*'},
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +1,21 @@
|
|||
celery==5.2.3
|
||||
Django==3.2.12
|
||||
django-celery-beat==2.3.0
|
||||
django-celery-results==2.4.0
|
||||
django-cors-headers==3.11.0
|
||||
django-filter==21.1
|
||||
djangorestframework==3.13.1
|
||||
djangorestframework-simplejwt==5.1.0
|
||||
drf-yasg==1.21.3
|
||||
celery==5.6.2
|
||||
Django==4.2.27
|
||||
django-celery-beat==2.8.1
|
||||
django-celery-results==2.6.0
|
||||
django-cors-headers==4.9.0
|
||||
django-filter==23.5
|
||||
djangorestframework==3.16.1
|
||||
djangorestframework-simplejwt==5.5.1
|
||||
drf-yasg==1.21.7
|
||||
psutil==5.9.0
|
||||
redis==4.4.0
|
||||
django-redis==5.2.0
|
||||
redis==7.1.0
|
||||
django-redis==6.0.0
|
||||
user-agents==2.2.0
|
||||
daphne==4.0.0
|
||||
channels-redis==4.0.0
|
||||
channels-redis==4.3.0
|
||||
django-restql==0.15.2
|
||||
requests==2.28.1
|
||||
xlwt==1.3.0
|
||||
openpyxl==3.1.0
|
||||
openpyxl==3.1.5
|
||||
cron-descriptor==1.2.35
|
||||
docxtpl==0.16.7
|
||||
# deepface==0.0.79
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import os
|
||||
from . import conf
|
||||
from config import conf
|
||||
from celery import Celery
|
||||
from celery.app.control import Control, Inspect
|
||||
|
||||
|
|
|
|||
|
|
@ -14,8 +14,10 @@ from datetime import datetime, timedelta
|
|||
import os
|
||||
import json
|
||||
import sys
|
||||
from .conf import *
|
||||
from config.conf import *
|
||||
from django.core.cache import cache
|
||||
import logging
|
||||
|
||||
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, os.path.join(BASE_DIR, 'apps'))
|
||||
|
|
@ -34,7 +36,7 @@ ALLOWED_HOSTS = ['*']
|
|||
|
||||
SYS_NAME = 'XT_ADMIN'
|
||||
SYS_VERSION = '2.3.0'
|
||||
|
||||
X_FRAME_OPTIONS = 'SAMEORIGIN'
|
||||
|
||||
# Application definition
|
||||
|
||||
|
|
@ -245,6 +247,18 @@ LOG_PATH = os.path.join(BASE_DIR, 'log')
|
|||
if not os.path.exists(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 = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
|
|
@ -268,22 +282,28 @@ LOGGING = {
|
|||
# 默认记录所有日志
|
||||
'default': {
|
||||
'level': 'INFO',
|
||||
'class': 'logging.handlers.RotatingFileHandler',
|
||||
'filename': os.path.join(LOG_PATH, 'all-{}.log'.format(datetime.now().strftime('%Y-%m-%d'))),
|
||||
'class': 'server.settings.TimedSizeRotatingHandler',
|
||||
'filename': os.path.join(LOG_PATH, 'all.log'),
|
||||
'when': 'midnight', # 每天午夜滚动
|
||||
'interval': 1,
|
||||
'maxBytes': 1024 * 1024 * 2, # 文件大小
|
||||
'backupCount': 10, # 备份数
|
||||
'backupCount': 30, # 备份数
|
||||
'formatter': 'standard', # 输出格式
|
||||
'encoding': 'utf-8', # 设置默认编码,否则打印出来汉字乱码
|
||||
'delay': True, # 延迟打开文件,减少锁定冲突
|
||||
},
|
||||
# 输出错误日志
|
||||
'error': {
|
||||
'level': 'ERROR',
|
||||
'class': 'logging.handlers.RotatingFileHandler',
|
||||
'filename': os.path.join(LOG_PATH, 'error-{}.log'.format(datetime.now().strftime('%Y-%m-%d'))),
|
||||
'class': 'server.settings.TimedSizeRotatingHandler',
|
||||
'filename': os.path.join(LOG_PATH, 'error.log'),
|
||||
'when': 'midnight',
|
||||
'interval': 1,
|
||||
'maxBytes': 1024 * 1024 * 2, # 文件大小
|
||||
'backupCount': 10, # 备份数
|
||||
'backupCount': 30, # 备份数
|
||||
'formatter': 'standard', # 输出格式
|
||||
'encoding': 'utf-8', # 设置默认编码
|
||||
'delay': True, # 延迟打开文件,减少锁定冲突
|
||||
},
|
||||
# 控制台输出
|
||||
'console': {
|
||||
|
|
@ -295,12 +315,15 @@ LOGGING = {
|
|||
# 输出info日志
|
||||
'info': {
|
||||
'level': 'INFO',
|
||||
'class': 'logging.handlers.RotatingFileHandler',
|
||||
'filename': os.path.join(LOG_PATH, 'info-{}.log'.format(datetime.now().strftime('%Y-%m-%d'))),
|
||||
'class': 'server.settings.TimedSizeRotatingHandler',
|
||||
'filename': os.path.join(LOG_PATH, 'info.log'),
|
||||
'when': 'midnight',
|
||||
'interval': 1,
|
||||
'maxBytes': 1024 * 1024 * 2,
|
||||
'backupCount': 10,
|
||||
'backupCount': 30,
|
||||
'formatter': 'standard',
|
||||
'encoding': 'utf-8', # 设置默认编码
|
||||
'delay': True, # 延迟打开文件,减少锁定冲突
|
||||
},
|
||||
},
|
||||
# 配置用哪几种 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):
|
||||
"""获取系统配置可指定key字符串
|
||||
|
|
|
|||
Loading…
Reference in New Issue