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.ini
server/conf*.json
config/conf*.py
config/conf*.json
sh/*
temp/*
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 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})

View File

@ -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'],
}

View File

@ -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

View File

@ -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)

View File

@ -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

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
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):
"""

View File

@ -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):

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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):

View File

@ -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
"""

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)
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 = '工作流'

View File

@ -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

View File

@ -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:
# 发送短信通知

View File

@ -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': '*'},

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
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

View File

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

View File

@ -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字符串