Compare commits

..

No commits in common. "c8a6ced7a09d1d1394099fea844515c22e032705" and "3d2c703cac3c839ea10ed43e4fa023e2abebc27a" have entirely different histories.

27 changed files with 260 additions and 783 deletions

2
.gitignore vendored
View File

@ -20,8 +20,6 @@ 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,7 +182,6 @@ 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,12 +1,8 @@
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
@ -23,20 +19,6 @@ 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):
@ -45,6 +27,5 @@ class DeptFilterSet(filters.FilterSet):
model = Dept
fields = {
'type': ['exact', 'in'],
'name': ['exact', 'in', 'contains'],
"parent": ['exact', 'isnull'],
'name': ['exact', 'in', 'contains']
}

View File

@ -272,10 +272,12 @@ 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,7 +8,8 @@ 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 RetrieveModelMixin
from rest_framework.mixins import (CreateModelMixin, DestroyModelMixin,
ListModelMixin, RetrieveModelMixin)
from rest_framework.parsers import (JSONParser,
MultiPartParser)
from rest_framework.serializers import Serializer
@ -18,7 +19,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 (MyLoggingMixin, BulkCreateModelMixin, BulkDestroyModelMixin, CustomListModelMixin)
from apps.utils.mixins import (CustomCreateModelMixin, MyLoggingMixin)
from django.conf import settings
from apps.utils.permission import ALL_PERMS
from apps.utils.viewsets import CustomGenericViewSet, CustomModelViewSet
@ -227,7 +228,7 @@ class PTaskViewSet(CustomModelViewSet):
return Response()
class PTaskResultViewSet(CustomListModelMixin, RetrieveModelMixin, CustomGenericViewSet):
class PTaskResultViewSet(ListModelMixin, RetrieveModelMixin, CustomGenericViewSet):
"""
list:任务执行结果列表
@ -371,7 +372,7 @@ class RoleViewSet(CustomModelViewSet):
ordering = ['create_time']
class PostRoleViewSet(BulkCreateModelMixin, BulkDestroyModelMixin, CustomListModelMixin, CustomGenericViewSet):
class PostRoleViewSet(CreateModelMixin, DestroyModelMixin, ListModelMixin, CustomGenericViewSet):
"""岗位/角色关系
岗位/角色关系
@ -383,7 +384,7 @@ class PostRoleViewSet(BulkCreateModelMixin, BulkDestroyModelMixin, CustomListMod
filterset_fields = ['post', 'role']
class UserPostViewSet(BulkCreateModelMixin, BulkDestroyModelMixin, CustomListModelMixin, CustomGenericViewSet):
class UserPostViewSet(CreateModelMixin, DestroyModelMixin, ListModelMixin, CustomGenericViewSet):
"""用户/岗位关系
用户/岗位关系
@ -396,29 +397,31 @@ class UserPostViewSet(BulkCreateModelMixin, BulkDestroyModelMixin, CustomListMod
ordering = ['sort', 'create_time']
def perform_create(self, serializer):
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()
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):
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()
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
user.update_by = self.request.user
user.save()
class UserViewSet(CustomModelViewSet):
@ -557,7 +560,7 @@ class UserViewSet(CustomModelViewSet):
return Response()
class FileViewSet(BulkCreateModelMixin, RetrieveModelMixin, CustomListModelMixin, CustomGenericViewSet):
class FileViewSet(CustomCreateModelMixin, RetrieveModelMixin, ListModelMixin, CustomGenericViewSet):
"""文件上传
list:
@ -598,7 +601,7 @@ class FileViewSet(BulkCreateModelMixin, RetrieveModelMixin, CustomListModelMixin
instance.save()
class ApkViewSet(MyLoggingMixin, CustomListModelMixin, BulkCreateModelMixin, GenericViewSet):
class ApkViewSet(MyLoggingMixin, ListModelMixin, CreateModelMixin, GenericViewSet):
perms_map = {'get': '*', 'post': 'apk.upload'}
serializer_class = ApkSerializer
@ -639,7 +642,7 @@ class ApkViewSet(MyLoggingMixin, CustomListModelMixin, BulkCreateModelMixin, Gen
return Response()
class MyScheduleViewSet(CustomListModelMixin, BulkCreateModelMixin, BulkDestroyModelMixin, CustomGenericViewSet):
class MyScheduleViewSet(ListModelMixin, CreateModelMixin, DestroyModelMixin, CustomGenericViewSet):
perms_map = {'get': '*', 'post': '*',
'delete': 'myschedule.delete'}
serializer_class = MyScheduleSerializer
@ -665,6 +668,7 @@ class MyScheduleViewSet(CustomListModelMixin, BulkCreateModelMixin, BulkDestroyM
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 # 不可少
@ -714,8 +718,6 @@ 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,9 +1,5 @@
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):
@ -12,9 +8,3 @@ 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

View File

@ -1,51 +0,0 @@
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,16 +9,13 @@ 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')
@ -81,8 +78,7 @@ class BulkCreateModelMixin(CreateModelMixin):
def after_bulk_create(self, objs):
pass
@transaction.atomic
def create(self, request, *args, **kwargs):
"""创建(支持批量)
@ -91,16 +87,11 @@ 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
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)
with transaction.atomic():
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)
@ -111,7 +102,6 @@ class BulkUpdateModelMixin(UpdateModelMixin):
def after_bulk_update(self, objs):
pass
@transaction.atomic
def partial_update(self, request, *args, **kwargs):
"""部分更新(支持批量)
@ -120,7 +110,6 @@ class BulkUpdateModelMixin(UpdateModelMixin):
kwargs['partial'] = True
return self.update(request, *args, **kwargs)
@transaction.atomic
def update(self, request, *args, **kwargs):
"""更新(支持批量)
@ -132,15 +121,16 @@ class BulkUpdateModelMixin(UpdateModelMixin):
queryset = self.filter_queryset(self.get_queryset())
objs = []
if isinstance(request.data, list):
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)
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)
else:
raise ParseError('提交数据非列表')
return Response(objs)
@ -155,7 +145,6 @@ class BulkUpdateModelMixin(UpdateModelMixin):
class BulkDestroyModelMixin(DestroyModelMixin):
@swagger_auto_schema(request_body=PkSerializer)
@transaction.atomic
def destroy(self, request, *args, **kwargs):
"""删除(支持批量)
@ -199,8 +188,6 @@ class CustomRetrieveModelMixin(RetrieveModelMixin):
给dict返回数据添加额外信息
"""
if hasattr(self, 'add_info_for_list'):
return self.add_info_for_list([data])[0]
return data
class CustomListModelMixin(ListModelMixin):
@ -232,79 +219,6 @@ 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"""
@ -350,7 +264,6 @@ 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
@ -379,7 +292,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),
@ -473,8 +386,7 @@ 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 (self.log.get("response_ms", 0) > 2000)
or (request.method in self.logging_methods and response.status_code not in [401, 403, 404])
def _clean_data(self, data):
"""

View File

@ -8,7 +8,6 @@ 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
# 自定义软删除查询基类
@ -116,67 +115,14 @@ class BaseModel(models.Model):
@classmethod
def safe_get_or_create(cls, defaults=None, **kwargs):
"""
多进程/多服务器安全的 get_or_create
- 数据库唯一约束不够时 Redis 锁防止重复创建
- 在事务中使用 select_for_update
"""
defaults = defaults or {}
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))
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)
@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
@ -186,32 +132,32 @@ class BaseModel(models.Model):
if not self.id:
is_create = True
self.id = idWorker.get_id()
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
with transaction.atomic():
old_parent = None
need_handle_parent = False
if hasattr(self, "parent"):
if is_create:
need_handle_parent = True
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()
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:
ins = super().save(*args, **kwargs)
raise e
# 处理父级
if need_handle_parent:
self.handle_parent()
return ins
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
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', 'create_time')
user_routes_qs = user_routes_qs.order_by('sort')
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,8 +54,6 @@ 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,14 +75,12 @@ class CustomModelSerializer(DynamicFieldsMixin, TreeSerializerMixin, serializers
class QuerySerializer(serializers.Serializer):
field = serializers.CharField(label='字段名')
compare = serializers.ChoiceField(
label='比较式', choices=["", "!", "gte", "gt", "lte", "lt", "in", "contains", "isnull"])
value = serializers.JSONField(label='', allow_null=True)
label='比较式', choices=["", "!", "gte", "gt", "lte", "lt", "in", "contains"])
value = serializers.CharField(label='')
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,4 +1,5 @@
from aliyunsdkcore.client import AcsClient
from aliyunsdkcore.request import CommonRequest
import json
import logging
from server.settings import get_sysconfig
@ -7,11 +8,8 @@ from apps.utils.decorators import auto_log
# 实例化myLogger
myLogger = logging.getLogger('log')
@auto_log(name='阿里云短信', raise_exception=True, send_mail=False)
@auto_log(name='阿里云短信', raise_exception=True, send_mail=True)
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,8 +1,6 @@
from django.db import connection
from django.utils import timezone
from datetime import datetime
def execute_raw_sql(sql: str, params=None, timeout=30):
def execute_raw_sql(sql: str, params=None):
"""执行原始sql并返回rows, columns数据
Args:
@ -10,8 +8,7 @@ def execute_raw_sql(sql: str, params=None, timeout=30):
params (_type_, optional): 参数列表. Defaults to None.
"""
with connection.cursor() as cursor:
if timeout:
cursor.execute(f"SET statement_timeout TO '{int(timeout*1000)}ms';")
cursor.execute("SET statement_timeout TO %s;", [30000])
if params:
cursor.execute(sql, params=params)
else:
@ -26,7 +23,7 @@ def format_sqldata(columns, rows):
return [columns] + rows, [dict(zip(columns, row)) for row in rows]
def query_all_dict(sql, params=None, with_time_format=False):
def query_all_dict(sql, params=None):
'''
查询所有结果返回字典类型数据
:param sql:
@ -39,19 +36,9 @@ def query_all_dict(sql, params=None, with_time_format=False):
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, with_time_format=False):
def query_one_dict(sql, params=None):
"""
查询一个结果返回字典类型数据
:param sql:
@ -59,17 +46,13 @@ def query_one_dict(sql, params=None, with_time_format=False):
:return:
"""
with connection.cursor() as cursor:
cursor.execute(sql, params or ()) # 更简洁的参数处理
if params:
cursor.execute(sql, params=params)
else:
cursor.execute(sql)
columns = [desc[0] for desc in cursor.description]
row = cursor.fetchone()
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情况
return dict(zip(columns, row))
import pymysql
import psycopg2

View File

@ -10,14 +10,6 @@ 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,6 +1,5 @@
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
@ -10,17 +9,14 @@ from rest_framework.viewsets import GenericViewSet
from apps.system.models import DataFilter, Dept
from apps.utils.mixins import (MyLoggingMixin, BulkCreateModelMixin, BulkUpdateModelMixin,
BulkDestroyModelMixin, CustomListModelMixin,
CustomRetrieveModelMixin, ComplexQueryMixin)
BulkDestroyModelMixin, CustomListModelMixin, CustomRetrieveModelMixin)
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):
"""
@ -88,36 +84,6 @@ 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)
@ -136,16 +102,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):
@ -217,16 +183,44 @@ class CustomGenericViewSet(MyLoggingMixin, GenericViewSet):
return queryset
return queryset.filter(create_by=self.request.user)
class CustomModelViewSet(BulkCreateModelMixin, BulkUpdateModelMixin, CustomListModelMixin,
CustomRetrieveModelMixin, BulkDestroyModelMixin, ComplexQueryMixin, CustomGenericViewSet):
CustomRetrieveModelMixin, BulkDestroyModelMixin, CustomGenericViewSet):
"""
增强的ModelViewSet
"""
@swagger_auto_schema(request_body=ComplexSerializer, responses={200: {}})
@action(methods=['post'], detail=False, perms_map={'post': '*'})
def cquery(self, request):
"""复杂查询
class EuModelViewSet(BulkCreateModelMixin, CustomListModelMixin,
CustomRetrieveModelMixin, BulkDestroyModelMixin, ComplexQueryMixin, CustomGenericViewSet):
"""
不支持更新的增强ModelViewSet
"""
复杂查询
"""
sr = ComplexSerializer(data=request.data)
sr.is_valid(raise_exception=True)
vdata = sr.validated_data
queryset = self.filter_queryset(self.get_queryset())
new_qs = queryset.none()
try:
for m in vdata.get('querys', []):
one_qs = queryset
for n in m:
st = {}
if n['compare'] == '!': # 如果是排除比较式
st[n['field']] = n['value']
one_qs = one_qs.exclude(**st)
elif n['compare'] == '':
st[n['field']] = n['value']
one_qs = one_qs.filter(**st)
else:
st[n['field'] + '__' + n['compare']] = n['value']
one_qs = one_qs.filter(**st)
new_qs = new_qs | one_qs
except Exception as e:
raise ParseError(str(e))
page = self.paginate_queryset(new_qs)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(new_qs, many=True)
return Response(serializer.data)

View File

@ -1,18 +0,0 @@
# 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

@ -1,18 +0,0 @@
# 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

@ -1,23 +0,0 @@
# 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='标题模板'),
),
]

View File

@ -1,86 +0,0 @@
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,7 +9,6 @@ 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)
@ -18,11 +17,10 @@ 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.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)
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}')
class Meta:
verbose_name = '工作流'

View File

@ -1,5 +1,5 @@
from apps.system.models import Dept, User, Post, Role
from apps.system.serializers import UserSignatureSerializer, UserSimpleSerializer, DeptSimpleSerializer, PostSimpleSerializer, RoleSimpleSerializer
from apps.system.models import Dept, User
from apps.system.serializers import UserSignatureSerializer, UserSimpleSerializer
from rest_framework import serializers
from apps.utils.serializers import CustomModelSerializer
@ -23,25 +23,11 @@ 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', 'view_path']
fields = ['id', 'name', 'key']
class StateSimpleSerializer(CustomModelSerializer):
@ -108,7 +94,7 @@ class TicketSimpleSerializer(CustomModelSerializer):
class TicketCreateSerializer(CustomModelSerializer):
transition = serializers.PrimaryKeyRelatedField(queryset=Transition.objects.all(), write_only=True, allow_null=True, required=False)
transition = serializers.PrimaryKeyRelatedField(queryset=Transition.objects.all(), write_only=True)
title = serializers.CharField(allow_blank=True, required=False)
class Meta:
@ -137,13 +123,11 @@ 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', 'create_by_name', 'ticket_data',
'act_state', 'create_time', 'update_time', 'participant_type', 'create_by', 'ticket_data',
'participant_', 'script_run_last_result', 'participant']
def get_participant_(self, obj):
@ -154,7 +138,7 @@ class TicketListSerializer(CustomModelSerializer):
@staticmethod
def setup_eager_loading(queryset):
queryset = queryset.select_related('workflow', 'state', 'create_by')
queryset = queryset.select_related('workflow', 'state')
return queryset
@ -163,7 +147,6 @@ 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().order_by("-attribute_type", "-id")
return Transition.objects.filter(is_deleted=False, source_state=state).all()
@classmethod
def get_ticket_steps(cls, ticket: Ticket):
@ -184,15 +184,7 @@ class WfService(object):
dpt_attrs = state.filter_dept.split('.') # 通过反向查询得到可能有多层
expr = ticket
for i in dpt_attrs:
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('未找到对应部门')
expr = getattr(expr, i)
dpts = Dept.objects.filter(id=expr.id)
user_queryset = user_queryset.filter(depts__in=dpts)
# if state.filter_policy == 1:
@ -305,56 +297,9 @@ class WfService(object):
return field_info_dict
@classmethod
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,
def handle_ticket(cls, ticket: Ticket, transition: Transition, new_ticket_data: dict = {}, handler: User = None,
suggestion: str = '', created: bool = False, 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
@ -370,13 +315,13 @@ class WfService(object):
f(ticket=ticket, transition=transition, new_ticket_data=new_ticket_data)
# 校验处理权限
if handler is not None and just_created is False: # 有处理人意味着系统触发校验处理权限
if handler is not None and 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 just_created:
if transition.field_require_check or not 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]:
@ -424,7 +369,7 @@ class WfService(object):
ticket.act_state = Ticket.TICKET_ACT_STATE_BACK
# 只更新必填和可选的字段
if not just_created and transition.field_require_check:
if not 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:
@ -439,7 +384,7 @@ class WfService(object):
suggestion=suggestion, participant_type=State.PARTICIPANT_TYPE_PERSONAL,
participant=handler, transition=transition)
if just_created:
if created:
if source_state.participant_cc:
TicketFlow.objects.create(ticket=ticket, state=source_state,
participant_type=0, intervene_type=Transition.TRANSITION_INTERVENE_TYPE_CC,
@ -497,17 +442,11 @@ class WfService(object):
last_log.intervene_type == Transition.TRANSITION_INTERVENE_TYPE_DELIVER or
ticket.in_add_node):
# 如果状态变化或是转交加签的情况再发送通知
cls.send_ticket_notice(ticketflow=last_log)
Thread(target=send_ticket_notice_t, args=(ticket,), daemon=True).start()
# 如果目标状态是脚本则异步执行
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):
@ -523,31 +462,11 @@ 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(ticketflowId: str):
def send_ticket_notice_t(ticket: Ticket):
"""
发送通知
"""
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, \
StateDetailSerializer, TicketAddNodeEndSerializer, TicketAddNodeSerializer, TicketCloseSerializer, \
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, Case, When, IntegerField, F
from django.db.models import Count
from rest_framework.serializers import Serializer
from apps.utils.snowflake import idWorker
import importlib
@ -60,16 +60,10 @@ class WorkflowKeyInitView(APIView):
class WorkflowViewSet(CustomModelViewSet):
queryset = Workflow.objects.all()
serializer_class = WorkflowSerializer
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())
search_fields = ['name', 'description']
filterset_fields = []
ordering_fields = ['create_time']
ordering = ['key', '-create_time']
@action(methods=['get'], detail=True, perms_map={'get': 'workflow.update'},
pagination_class=None, serializer_class=StateSerializer)
@ -178,21 +172,6 @@ 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):
@ -200,7 +179,6 @@ 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']
@ -261,7 +239,6 @@ class TicketViewSet(CreateUpdateCustomMixin, CreateModelMixin, ListModelMixin, R
raise ParseError('请指定查询分类')
return super().filter_queryset(queryset)
@transaction.atomic
def create(self, request, *args, **kwargs):
"""
新建工单
@ -270,12 +247,41 @@ class TicketViewSet(CreateUpdateCustomMixin, CreateModelMixin, ListModelMixin, R
serializer = self.get_serializer(data=rdata)
serializer.is_valid(raise_exception=True)
vdata = serializer.validated_data # 校验之后的数据
transition = vdata.get("transition", None)
workflow = vdata['workflow']
start_state = WfService.get_workflow_start_state(vdata['workflow'])
transition = vdata.pop('transition')
ticket_data = vdata['ticket_data']
ticket = WfService.handle_ticket(ticket=None, transition=transition,
workflow=workflow, new_ticket_data=ticket_data,
oinfo=rdata, handler=request.user)
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)
return Response(TicketSerializer(instance=ticket).data)
@action(methods=['get'], detail=False, perms_map={'get': '*'})
@ -291,7 +297,6 @@ 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):
"""
处理工单
@ -302,13 +307,13 @@ class TicketViewSet(CreateUpdateCustomMixin, CreateModelMixin, ListModelMixin, R
vdata = serializer.validated_data
new_ticket_data = ticket.ticket_data
new_ticket_data.update(**vdata['ticket_data'])
ticket = WfService.handle_ticket(ticket=ticket, transition=vdata['transition'],
new_ticket_data=new_ticket_data, handler=request.user,
suggestion=vdata.get('suggestion', ''))
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', ''))
return Response(TicketSerializer(instance=ticket).data)
@action(methods=['post'], detail=True, perms_map={'post': '*'})
@transaction.atomic
def deliver(self, request, pk=None):
"""
转交工单
@ -320,15 +325,15 @@ class TicketViewSet(CreateUpdateCustomMixin, CreateModelMixin, ListModelMixin, R
vdata = serializer.validated_data # 校验之后的数据
if not ticket.state.enable_deliver:
raise ParseError('不允许转交')
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)
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)
return Response()
@action(methods=['get'], detail=True, perms_map={'get': '*'})
@ -376,12 +381,11 @@ class TicketViewSet(CreateUpdateCustomMixin, CreateModelMixin, ListModelMixin, R
ticket.save()
# 接单日志
# 更新工单流转记录
tf = TicketFlow.objects.create(ticket=ticket, state=ticket.state,
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('无需接单')
@ -396,7 +400,19 @@ class TicketViewSet(CreateUpdateCustomMixin, CreateModelMixin, ListModelMixin, R
raise ParseError('非创建人不可撤回')
if not ticket.state.enable_retreat:
raise ParseError('该状态不可撤回')
WfService.retreat(ticket, request.data.get('suggestion', ''), request.user, request.user)
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)
return Response()
@action(methods=['post'], detail=True, perms_map={'post': '*'}, serializer_class=TicketAddNodeSerializer)
@ -416,12 +432,11 @@ class TicketViewSet(CreateUpdateCustomMixin, CreateModelMixin, ListModelMixin, R
ticket.save()
# 更新流转记录
suggestion = request.data.get('suggestion', '') # 加签说明
tf = TicketFlow.objects.create(ticket=ticket, state=ticket.state,
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)
@ -441,12 +456,11 @@ class TicketViewSet(CreateUpdateCustomMixin, CreateModelMixin, ListModelMixin, R
ticket.save()
# 更新流转记录
suggestion = request.data.get('suggestion', '') # 加签意见
tf = TicketFlow.objects.create(ticket=ticket, state=ticket.state,
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': '*'},

View File

@ -1,12 +0,0 @@
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,21 +1,22 @@
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
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
psutil==5.9.0
redis==7.1.0
django-redis==6.0.0
redis==4.4.0
django-redis==5.2.0
user-agents==2.2.0
daphne==4.0.0
channels-redis==4.3.0
channels-redis==4.0.0
django-restql==0.15.2
requests==2.28.1
xlwt==1.3.0
openpyxl==3.1.5
openpyxl==3.1.0
cron-descriptor==1.2.35
docxtpl==0.16.7
# deepface==0.0.79

View File

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

View File

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