初始化happy-drf分支

This commit is contained in:
caoqianming 2023-10-07 14:11:26 +08:00
commit b25dfcac0c
109 changed files with 8083 additions and 0 deletions

22
.gitignore vendored Executable file
View File

@ -0,0 +1,22 @@
.vscode/
.vs/
.VSCodeCounter/
.idea/
venv/
__pycache__/
h5/*
*.pyc
media/*
dist/*
!media/default/
celerybeat.pid
celerybeat-schedule.bak
celerybeat-schedule.dat
celerybeat-schedule.dir
db.sqlite3
server/conf*.py
server/conf.ini
server/conf.json
sh/*
temp/*
nohup.out

0
apps/__init__.py Normal file
View File

0
apps/auth1/__init__.py Executable file
View File

2
apps/auth1/admin.py Executable file
View File

@ -0,0 +1,2 @@
from django.contrib import admin
# Register your models here.

6
apps/auth1/apps.py Executable file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class AuthConfig(AppConfig):
name = 'apps.auth1'
verbose_name = "认证"

23
apps/auth1/authentication.py Executable file
View File

@ -0,0 +1,23 @@
from django.contrib.auth.backends import ModelBackend
from django.db.models import Q
from django.contrib.auth import get_user_model
UserModel = get_user_model()
class CustomBackend(ModelBackend):
def authenticate(self, request, username=None, password=None, **kwargs):
if username is None:
username = kwargs.get(UserModel.USERNAME_FIELD)
if username is None or password is None:
return
try:
user = UserModel._default_manager.get(
Q(username=username) | Q(phone=username) | Q(employee__id_number=username))
except UserModel.DoesNotExist:
# Run the default password hasher once to reduce the timing
# difference between an existing and a nonexistent user (#20760).
UserModel().set_password(password)
else:
if user.check_password(password) and self.user_can_authenticate(user):
return user

2
apps/auth1/errors.py Executable file
View File

@ -0,0 +1,2 @@
USERNAME_OR_PASSWORD_WRONG = {"code": "username_or_password_wrong", "detail": "账户名或密码错误"}

3
apps/auth1/models.py Executable file
View File

@ -0,0 +1,3 @@
# Create your models here.

35
apps/auth1/serializers.py Executable file
View File

@ -0,0 +1,35 @@
from rest_framework import serializers
class LoginSerializer(serializers.Serializer):
username = serializers.CharField(label="用户名")
password = serializers.CharField(label="密码")
password_check = serializers.BooleanField(required=False, default=True)
class SendCodeSerializer(serializers.Serializer):
phone = serializers.CharField(label="手机号")
class CodeLoginSerializer(serializers.Serializer):
phone = serializers.CharField(label="手机号")
code = serializers.CharField(label="验证码")
class WxCodeSerializer(serializers.Serializer):
code = serializers.CharField(label="code")
class PwResetSerializer(serializers.Serializer):
phone = serializers.CharField(label="手机号")
code = serializers.CharField(label="验证码")
password = serializers.CharField(label="新密码")
class SecretLoginSerializer(serializers.Serializer):
username = serializers.CharField(label="用户名")
secret = serializers.CharField(label="密钥")
class FaceLoginSerializer(serializers.Serializer):
base64 = serializers.CharField()

24
apps/auth1/services.py Normal file
View File

@ -0,0 +1,24 @@
from django.core.cache import cache
from rest_framework.exceptions import ParseError
import re
def check_phone_code(phone, code, raise_exception=True):
code_exist = cache.get(phone, None)
if code_exist == code:
return True
if raise_exception:
raise ParseError('验证码错误')
return False
def validate_password(password):
# 正则表达式匹配规则
pattern = r"^(?=.*[a-zA-Z])(?=.*\d)(?=.*[@#$%^&+=!])(?!.*\s).{8,}$"
# 使用正则表达式进行匹配
if re.match(pattern, password):
return True
else:
return False

3
apps/auth1/tests.py Executable file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

22
apps/auth1/urls.py Executable file
View File

@ -0,0 +1,22 @@
from django.urls import path
from rest_framework_simplejwt.views import TokenRefreshView
from apps.auth1.views import (CodeLogin, LoginView, LogoutView, PwResetView,
SecretLogin, SendCode, TokenBlackView, WxLogin, WxmpLogin, TokenLoginView, FaceLoginView)
API_BASE_URL = 'api/auth/'
urlpatterns = [
path(API_BASE_URL + 'token/', TokenLoginView.as_view(), name='token_obtain_pair'),
path(API_BASE_URL + 'token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
path(API_BASE_URL + 'token/black/', TokenBlackView.as_view(), name='token_black'),
path(API_BASE_URL + 'login/', LoginView.as_view(), name='session_login'),
path(API_BASE_URL + 'login_secret/', SecretLogin.as_view(), name='secret_login'),
path(API_BASE_URL + 'login_wxmp/', WxmpLogin.as_view(), name='login_wxmp'),
path(API_BASE_URL + 'login_wx/', WxLogin.as_view(), name='login_wx'),
path(API_BASE_URL + 'login_sms_code/', CodeLogin.as_view(), name='login_sms_code'),
path(API_BASE_URL + 'sms_code/', SendCode.as_view(), name='sms_code_send'),
path(API_BASE_URL + 'logout/', LogoutView.as_view(), name='session_logout'),
path(API_BASE_URL + 'reset_password/', PwResetView.as_view(), name='reset_password'),
path(API_BASE_URL + 'login_face/', FaceLoginView.as_view(), name='face_login')
]

298
apps/auth1/views.py Executable file
View File

@ -0,0 +1,298 @@
from rest_framework.exceptions import ParseError
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from django.contrib.auth import authenticate, login, logout
from rest_framework.generics import CreateAPIView
from rest_framework.permissions import IsAuthenticated
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
from django.contrib.auth.hashers import make_password
from django.db.models import Q
from apps.auth1.services import validate_password
import base64
from apps.utils.tools import tran64
from apps.auth1.serializers import FaceLoginSerializer
from apps.auth1.serializers import (CodeLoginSerializer, LoginSerializer,
PwResetSerializer, SecretLoginSerializer, SendCodeSerializer, WxCodeSerializer)
from apps.system.models import User
from rest_framework_simplejwt.views import TokenObtainPairView
# Create your views here.
def get_tokens_for_user(user: User):
refresh = RefreshToken.for_user(user)
return {
'refresh': str(refresh),
'access': str(refresh.access_token),
}
class TokenLoginView(CreateAPIView):
"""
账户名/密码获取token
账户名/密码获取token
"""
authentication_classes = []
permission_classes = []
serializer_class = LoginSerializer
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
vdata = serializer.validated_data
password_check = vdata.get('password_check', True)
# 校验密码复杂度
is_ok = validate_password(vdata.get('password'))
if is_ok is False and password_check:
raise ParseError('密码校验失败, 请更换登录方式并修改密码')
user = authenticate(username=vdata.get('username'),
password=vdata.get('password'))
if user is not None:
token_dict = get_tokens_for_user(user)
token_dict['password_ok'] = is_ok
return Response(token_dict)
raise ParseError(**USERNAME_OR_PASSWORD_WRONG)
class TokenBlackView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request, *args, **kwargs):
"""
Token拉黑
Token拉黑
"""
return Response(status=status.HTTP_200_OK)
class LoginView(CreateAPIView):
"""
Session登录
账户密码Session登录
"""
authentication_classes = []
permission_classes = []
serializer_class = LoginSerializer
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
vdata = serializer.validated_data
user = authenticate(username=vdata.get('username'),
password=vdata.get('password'))
if user is not None:
login(request, user)
return Response(status=201)
raise ParseError(**USERNAME_OR_PASSWORD_WRONG)
class LogoutView(APIView):
authentication_classes = []
permission_classes = []
def post(self, request, *args, **kwargs):
"""
退出登录
退出登录
"""
logout(request)
return Response()
class WxmpLogin(CreateAPIView):
"""微信小程序自动登录
微信小程序自动登录
"""
authentication_classes = []
permission_classes = []
serializer_class = WxCodeSerializer
def post(self, request):
code = request.data['code']
info = wxmpClient.get_basic_info(code=code)
openid = info['openid']
session_key = info['session_key']
try:
user = User.objects.get(wxmp_openid=openid)
ret = get_tokens_for_user(user)
ret['wxmp_session_key'] = session_key
ret['wxmp_openid'] = openid
cache.set(code, ret, 60*5)
return Response(ret)
except Exception:
return Response({'wxmp_openid': openid, 'wxmp_session_key': session_key}, status=400)
class WxLogin(CreateAPIView):
"""微信公众号授权登录
微信公众号授权登录
"""
authentication_classes = []
permission_classes = []
serializer_class = WxCodeSerializer
def post(self, request):
code = request.data['code']
info = wxClient.get_basic_info(code=code)
openid = info['openid']
access = info['access_token']
try:
user = User.objects.get(wx_openid=openid)
ret = get_tokens_for_user(user)
ret['wx_token'] = access
ret['wx_openid'] = openid
cache.set(code, ret, 60*5)
return Response(ret)
except Exception:
return Response({'wx_openid': openid, 'wx_token': access}, status=400)
class SendCode(CreateAPIView):
authentication_classes = []
permission_classes = []
serializer_class = SendCodeSerializer
def post(self, request):
"""短信验证码发送
短信验证码发送
"""
phone = request.data['phone']
code = rannum(6)
is_ok, _ = send_sms(phone, 505, {'code': code})
cache.set(phone, code, 60*5)
if is_ok:
return Response()
raise ParseError('短信发送失败,请确认手机号')
class CodeLogin(CreateAPIView):
"""手机验证码登录
手机验证码登录
"""
authentication_classes = []
permission_classes = []
serializer_class = CodeLoginSerializer
def post(self, request):
phone = request.data['phone']
code = request.data['code']
check_phone_code(phone, code)
user = User.objects.filter(phone=phone).first()
if user:
ret = get_tokens_for_user(user)
return Response(ret)
raise ParseError('账户不存在或已禁用')
class SecretLogin(CreateAPIView):
"""App端密钥登录
App端密钥登录
"""
authentication_classes = []
permission_classes = []
serializer_class = SecretLoginSerializer
def post(self, request):
sr = SecretLoginSerializer(data=request.data)
sr.is_valid(raise_exception=True)
vdata = sr.validated_data
username = vdata['username']
secret = vdata['secret']
user = User.objects.filter(Q(username=username) | Q(phone=username) | Q(
employee__id_number=username)).filter(secret=secret).first()
if user:
ret = get_tokens_for_user(user)
return Response(ret)
raise ParseError('登录失败')
class PwResetView(CreateAPIView):
"""重置密码
重置密码
"""
authentication_classes = []
permission_classes = []
serializer_class = PwResetSerializer
def post(self, request):
sr = PwResetSerializer(data=request.data)
sr.is_valid(raise_exception=True)
vdata = sr.validated_data
check_phone_code(vdata['phone'], vdata['code'])
user = User.objects.filter(phone=vdata['phone']).first()
if user:
user.password = make_password(vdata['password'])
user.save()
return Response()
raise ParseError('账户不存在或已禁用')
class FaceLoginView(CreateAPIView):
"""人脸识别登录
人脸识别登录
"""
authentication_classes = []
permission_classes = []
serializer_class = FaceLoginSerializer
def create(self, request, *args, **kwargs):
"""
人脸识别登录
"""
from apps.hrm.services import HrmService
base64_data = base64.urlsafe_b64decode(tran64(request.data.get('base64').replace(' ', '+')))
ep, msg = HrmService.face_compare_from_base64(base64_data)
if ep:
if ep.user and ep.user.is_active and ep.user.is_deleted is False:
user = ep.user
refresh = RefreshToken.for_user(ep.user)
# # 可设为在岗
# now = timezone.now()
# now_local = timezone.localtime()
# if 8<=now_local.hour<=17:
# ins, created = ClockRecord.objects.get_or_create(
# create_by = user, create_time__hour__range = [8,18],
# create_time__year=now_local.year, create_time__month=now_local.month,
# create_time__day=now_local.day,
# defaults={
# 'type':ClockRecord.ClOCK_WORK1,
# 'create_by':user,
# 'create_time':now
# })
# # 设为在岗
# if created:
# Employee.objects.filter(user=user).update(is_atwork=True, last_check_time=now)
return Response({
'refresh': str(refresh),
'access': str(refresh.access_token),
'username':user.username,
'name':user.name
})
else:
raise ParseError('账户不存在或不可用')
raise ParseError(msg)

0
apps/ops/__init__.py Normal file
View File

3
apps/ops/admin.py Normal file
View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
apps/ops/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class OpsConfig(AppConfig):
name = 'apps.ops'
verbose_name = '系统运维'

1
apps/ops/errors.py Normal file
View File

@ -0,0 +1 @@
LOG_NOT_FONED = {"code": "log_not_found", "detail": "日志不存在"}

21
apps/ops/filters.py Normal file
View File

@ -0,0 +1,21 @@
from django_filters import rest_framework as filters
from apps.ops.models import DrfRequestLog, Tlog
class DrfLogFilterSet(filters.FilterSet):
start_request = filters.DateTimeFilter(field_name="requested_at", lookup_expr='gte')
end_request = filters.DateTimeFilter(field_name="requested_at", lookup_expr='lte')
class Meta:
model = DrfRequestLog
fields = ['id', 'start_request', 'end_request', 'status_code']
class TlogFilterSet(filters.FilterSet):
start_request = filters.DateTimeFilter(field_name="requested_at", lookup_expr='gte')
end_request = filters.DateTimeFilter(field_name="requested_at", lookup_expr='lte')
class Meta:
model = Tlog
fields = ['id', 'start_request', 'end_request', 'result']

View File

@ -0,0 +1,59 @@
# Generated by Django 3.2.12 on 2022-11-29 08:47
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Tlog',
fields=[
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
('target', models.CharField(max_length=20, verbose_name='请求目标')),
('result', models.CharField(max_length=20, verbose_name='请求结果')),
('path', models.CharField(help_text='请求地址', max_length=400)),
('params', models.JSONField(blank=True, null=True)),
('body', models.JSONField(blank=True, null=True)),
('method', models.CharField(max_length=10)),
('requested_at', models.DateTimeField()),
('response_ms', models.PositiveIntegerField(default=0)),
('headers', models.JSONField(blank=True, null=True)),
('response', models.JSONField(blank=True, null=True)),
('errors', models.TextField(blank=True, null=True)),
],
),
migrations.CreateModel(
name='DrfRequestLog',
fields=[
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
('requested_at', models.DateTimeField(db_index=True)),
('response_ms', models.PositiveIntegerField(default=0)),
('path', models.CharField(db_index=True, help_text='请求地址', max_length=400)),
('view', models.CharField(blank=True, db_index=True, help_text='执行视图', max_length=400, null=True)),
('view_method', models.CharField(blank=True, db_index=True, max_length=20, null=True)),
('remote_addr', models.GenericIPAddressField()),
('host', models.URLField()),
('method', models.CharField(max_length=10)),
('query_params', models.JSONField(blank=True, null=True)),
('data', models.JSONField(blank=True, null=True)),
('response', models.JSONField(blank=True, null=True)),
('errors', models.TextField(blank=True, null=True)),
('agent', models.TextField(blank=True, null=True)),
('status_code', models.PositiveIntegerField(blank=True, db_index=True, null=True)),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'DRF请求日志',
},
),
]

View File

66
apps/ops/models.py Normal file
View File

@ -0,0 +1,66 @@
import uuid
from django.db import models
class DrfRequestLog(models.Model):
"""Logs Django rest framework API requests"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4)
user = models.ForeignKey(
'system.user',
on_delete=models.SET_NULL,
null=True,
blank=True,
)
requested_at = models.DateTimeField(db_index=True)
response_ms = models.PositiveIntegerField(default=0)
path = models.CharField(
max_length=400,
db_index=True,
help_text="请求地址",
)
view = models.CharField(
max_length=400,
null=True,
blank=True,
db_index=True,
help_text="执行视图",
)
view_method = models.CharField(
max_length=20,
null=True,
blank=True,
db_index=True,
)
remote_addr = models.GenericIPAddressField()
host = models.URLField()
method = models.CharField(max_length=10)
query_params = models.JSONField(null=True, blank=True)
data = models.JSONField(null=True, blank=True)
response = models.JSONField(null=True, blank=True)
errors = models.TextField(null=True, blank=True)
agent = models.TextField(null=True, blank=True)
status_code = models.PositiveIntegerField(null=True, blank=True, db_index=True)
class Meta:
verbose_name = "DRF请求日志"
def __str__(self):
return "{} {}".format(self.method, self.path)
class Tlog(models.Model):
"""第三方请求与处理日志
"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4)
target = models.CharField('请求目标', max_length=20)
result = models.CharField('请求结果', max_length=20)
path = models.CharField(max_length=400, help_text="请求地址")
params = models.JSONField(null=True, blank=True)
body = models.JSONField(null=True, blank=True)
method = models.CharField(max_length=10)
requested_at = models.DateTimeField()
response_ms = models.PositiveIntegerField(default=0)
headers = models.JSONField(null=True, blank=True)
response = models.JSONField(null=True, blank=True)
errors = models.TextField(null=True, blank=True)

31
apps/ops/serializers.py Normal file
View File

@ -0,0 +1,31 @@
from rest_framework import serializers
from apps.ops.models import DrfRequestLog, Tlog
class DbbackupDeleteSerializer(serializers.Serializer):
filepaths = serializers.ListField(child=serializers.CharField(), label="文件地址列表")
class MemDiskSerializer(serializers.Serializer):
total = serializers.FloatField(label="总大小(GB)")
used = serializers.FloatField(label="已用(GB)")
percent = serializers.FloatField(label="百分比")
class CpuSerializer(serializers.Serializer):
count = serializers.IntegerField(label='物理核心数')
lcount = serializers.IntegerField(label="逻辑核心数")
percent = serializers.FloatField(label="百分比")
class DrfRequestLogSerializer(serializers.ModelSerializer):
class Meta:
model = DrfRequestLog
fields = '__all__'
class TlogSerializer(serializers.ModelSerializer):
class Meta:
model = Tlog
fields = '__all__'
class TextListSerializer(serializers.Serializer):
name = serializers.CharField()
filepath = serializers.CharField()
size = serializers.CharField(label="MB")

32
apps/ops/service.py Normal file
View File

@ -0,0 +1,32 @@
import psutil
class ServerService:
@classmethod
def get_memory_dict(cls):
ret = {}
memory = psutil.virtual_memory()
ret['total'] = round(memory.total/1024/1024/1024, 2)
ret['used'] = round(memory.used/1024/1024/1024, 2)
ret['percent'] = memory.percent
return ret
@classmethod
def get_cpu_dict(cls):
ret = {}
ret['lcount'] = psutil.cpu_count()
ret['count'] = psutil.cpu_count(logical=False)
ret['percent'] = psutil.cpu_percent(interval=1)
return ret
@classmethod
def get_disk_dict(cls):
ret = {}
disk = psutil.disk_usage('/')
ret['total'] = round(disk.total/1024/1024/1024, 2)
ret['used'] = round(disk.used/1024/1024/1024, 2)
ret['percent'] = disk.percent
return ret
@classmethod
def get_full(cls):
return {'cpu': cls.get_cpu_dict(), 'memory': cls.get_memory_dict(), 'disk': cls.get_disk_dict()}

89
apps/ops/tasks.py Normal file
View File

@ -0,0 +1,89 @@
# Create your tasks here
from __future__ import absolute_import, unicode_literals
from datetime import timedelta
from apps.ops.models import DrfRequestLog
from apps.utils.tasks import CustomTask
from celery import shared_task
from django.utils import timezone
from django.conf import settings
import os
import subprocess
from server.settings import DATABASES, BACKUP_PATH, SH_PATH, SD_PWD
@shared_task
def backup_database():
"""
备份数据库
"""
import datetime
name = datetime.datetime.now().strftime('%Y%m%d%H%M%S')
command = 'echo "{}" | sudo -S pg_dump "user={} password={} dbname={}" > {}/bak_{}.sql'.format(
SD_PWD,
DATABASES['default']['USER'],
DATABASES['default']['PASSWORD'],
DATABASES['default']['NAME'],
BACKUP_PATH + '/database',
name)
completed = subprocess.run(command, shell=True, capture_output=True, text=True)
if completed.returncode != 0:
return completed.stderr
@shared_task
def reload_server_git():
command = 'bash {}/git_server.sh'.format(SH_PATH)
completed = subprocess.run(command, shell=True, capture_output=True, text=True)
if completed.returncode != 0:
return completed.stderr
@shared_task
def reload_web_git():
command = 'bash {}/git_web.sh'.format(SH_PATH)
completed = subprocess.run(command, shell=True, capture_output=True, text=True)
if completed.returncode != 0:
return completed.stderr
@shared_task
def reload_server_only():
command = 'echo "{}" | sudo -S supervisorctl reload'.format(SD_PWD)
completed = subprocess.run(command, shell=True, capture_output=True, text=True)
return completed
@shared_task
def backup_media():
command = 'bash {}/backup_media.sh'.format(SH_PATH)
completed = subprocess.run(command, shell=True, capture_output=True, text=True)
if completed.returncode != 0:
return completed.stderr
@shared_task(base=CustomTask)
def clear_drf_log(days: int = 7):
"""清除N天前的日志记录,默认七天
清除N天前的日志记录
"""
now = timezone.now()
days7_ago = now - timedelta(days=days)
DrfRequestLog.objects.filter(create_time__lte=days7_ago).delete()
@shared_task(base=CustomTask)
def clear_dbbackup(num: int = 7):
"""
清除N条前的数据库备份记录,默认七条
清除N条前的数据库备份记录
"""
from apps.ops.views import get_file_list
backpath = settings.BACKUP_PATH + '/database'
files = get_file_list(backpath)
files_remove_list = files[num:]
for f in files_remove_list:
filepath = os.path.join(backpath, f)
os.remove(filepath)

3
apps/ops/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

24
apps/ops/urls.py Normal file
View File

@ -0,0 +1,24 @@
from django.urls import path
from apps.ops.views import (DrfRequestLogViewSet, CpuView, MemoryView, DiskView, DbBackupDeleteView,
LogView, LogDetailView,
DbBackupView, ReloadClientGit, ReloadServerGit, ReloadServerOnly,
BackupDatabase, BackupMedia, TlogViewSet)
API_BASE_URL = 'api/ops/'
HTML_BASE_URL = 'ops/'
urlpatterns = [
path(API_BASE_URL + 'reload_server_git/', ReloadServerGit.as_view()),
path(API_BASE_URL + 'reload_web_git/', ReloadClientGit.as_view()),
path(API_BASE_URL + 'reload_server_only/', ReloadServerOnly.as_view()),
path(API_BASE_URL + 'backup_database/', BackupDatabase.as_view()),
path(API_BASE_URL + 'backup_media/', BackupMedia.as_view()),
path(API_BASE_URL + 'log/', LogView.as_view()),
path(API_BASE_URL + 'log/<str:name>/', LogDetailView.as_view()),
path(API_BASE_URL + 'dbbackup/', DbBackupView.as_view()),
path(API_BASE_URL + 'dbbackup/<str:filepath>/', DbBackupDeleteView.as_view()),
path(API_BASE_URL + 'server/cpu/', CpuView.as_view()),
path(API_BASE_URL + 'server/memory/', MemoryView.as_view()),
path(API_BASE_URL + 'server/disk/', DiskView.as_view()),
path(API_BASE_URL + 'request_log/', DrfRequestLogViewSet.as_view({'get': 'list'}), name='requestlog_view'),
path(API_BASE_URL + 'tlog/', TlogViewSet.as_view({'get': 'list'}), name='tlog_view'),
]

255
apps/ops/views.py Normal file
View File

@ -0,0 +1,255 @@
from django.shortcuts import render
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from django.conf import settings
import os
from apps.ops.serializers import DbbackupDeleteSerializer, MemDiskSerializer, CpuSerializer, DrfRequestLogSerializer, TlogSerializer, TextListSerializer
from rest_framework.exceptions import NotFound
from rest_framework.mixins import ListModelMixin
from apps.ops.filters import DrfLogFilterSet, TlogFilterSet
from apps.ops.models import DrfRequestLog, Tlog
from apps.ops.errors import LOG_NOT_FONED
from apps.utils.viewsets import CustomGenericViewSet
from rest_framework.exceptions import APIException
from apps.ops.tasks import reload_server_git, reload_server_only, reload_web_git, backup_database, backup_media
from rest_framework.permissions import IsAdminUser
from drf_yasg.utils import swagger_auto_schema
from apps.ops.service import ServerService
from server.settings import BACKUP_PATH
# Create your views here.
def index(request):
return render(request, 'ops/index.html')
def room(request, room_name):
return render(request, 'ops/room.html', {
'room_name': room_name
})
class ReloadServerGit(APIView):
permission_classes = [IsAdminUser]
@swagger_auto_schema(operation_summary="拉取后端代码并重启服务", responses=None, request_body=None)
def post(self, request):
reload_server_git.delay()
return Response()
# if completed.returncode == 0:
# return Response()
# else:
# from server.settings import myLogger
# myLogger.error(completed)
# raise ParseError(completed.stderr)
class ReloadClientGit(APIView):
permission_classes = [IsAdminUser]
@swagger_auto_schema(operation_summary="拉取前端代码并打包", responses=None, request_body=None)
def post(self, request):
reload_web_git.delay()
return Response()
# completed = reload_web_git()
# if completed.returncode == 0:
# return Response()
# else:
# raise APIException(completed.stdout)
class ReloadServerOnly(APIView):
permission_classes = [IsAdminUser]
@swagger_auto_schema(operation_summary="仅重启服务", responses=None, request_body=None)
def post(self, request):
completed = reload_server_only()
if completed.returncode == 0:
return Response()
else:
raise APIException(completed.stdout)
class BackupDatabase(APIView):
permission_classes = [IsAdminUser]
@swagger_auto_schema(operation_summary="备份数据库到指定位置", responses=None, request_body=None)
def post(self, request):
err_str = backup_database()
if err_str:
raise APIException(err_str)
return Response()
class BackupMedia(APIView):
permission_classes = [IsAdminUser]
@swagger_auto_schema(operation_summary="备份资源到指定位置", responses=None, request_body=None)
def post(self, request):
err_str = backup_media()
if err_str:
raise APIException(err_str)
return Response()
class CpuView(APIView):
permission_classes = [IsAuthenticated]
@swagger_auto_schema(operation_summary="获取服务器cpu当前状态", responses=CpuSerializer, request_body=None)
def get(self, request, *args, **kwargs):
return Response(ServerService.get_cpu_dict())
class MemoryView(APIView):
permission_classes = [IsAuthenticated]
@swagger_auto_schema(operation_summary="获取服务器内存当前状态", responses=MemDiskSerializer, request_body=None)
def get(self, request, *args, **kwargs):
return Response(ServerService.get_memory_dict())
class DiskView(APIView):
permission_classes = [IsAuthenticated]
@swagger_auto_schema(operation_summary="获取服务器硬盘当前状态", responses=MemDiskSerializer, request_body=None)
def get(self, request, *args, **kwargs):
return Response(ServerService.get_disk_dict())
def get_file_list(file_path):
dir_list = os.listdir(file_path)
if not dir_list:
return
else:
# 注意这里使用lambda表达式将文件按照最后修改时间顺序升序排列
# os.path.getmtime() 函数是获取文件最后修改时间
# os.path.getctime() 函数是获取文件最后创建时间
dir_list = sorted(dir_list, key=lambda x: os.path.getmtime(
os.path.join(file_path, x)), reverse=True)
# print(dir_list)
return dir_list
class LogView(APIView):
permission_classes = [IsAuthenticated]
@swagger_auto_schema(operation_summary="查看最近的日志列表", responses=TextListSerializer(many=True), request_body=None)
def get(self, request, *args, **kwargs):
logs = []
name = request.GET.get('name', None)
# for root, dirs, files in os.walk(settings.LOG_PATH):
# files.reverse()
for file in get_file_list(settings.LOG_PATH):
if len(logs) > 50:
break
filepath = os.path.join(settings.LOG_PATH, file)
if name:
if name in filepath:
fsize = os.path.getsize(filepath)
if fsize:
logs.append({
"name": file,
"filepath": filepath,
"size": round(fsize/1024, 1)
})
else:
fsize = os.path.getsize(filepath)
if fsize:
logs.append({
"name": file,
"filepath": filepath,
"size": round(fsize/1024, 1)
})
return Response(logs)
class LogDetailView(APIView):
permission_classes = [IsAuthenticated]
@swagger_auto_schema(operation_summary="查看日志详情", responses=None)
def get(self, request, name):
try:
with open(os.path.join(settings.LOG_PATH, name)) as f:
data = f.read()
return Response(data)
except Exception:
raise NotFound(**LOG_NOT_FONED)
class DbBackupDeleteView(APIView):
perms_map = {'delete': 'dbback.delete'}
@swagger_auto_schema(operation_summary="删除备份", responses={204: None})
def delete(self, request, filepath):
if BACKUP_PATH in filepath:
os.remove(filepath)
return Response()
class DbBackupView(APIView):
perms_map = {'get': '*', 'post': 'dbback.delete'}
@swagger_auto_schema(operation_summary="批量删除备份", responses={204: None}, request_body=DbbackupDeleteSerializer)
def post(self, request):
filepaths = request.data.get('filepaths', [])
for i in filepaths:
if BACKUP_PATH in i:
os.remove(i)
return Response()
@swagger_auto_schema(operation_summary="查看最近的备份列表", responses=TextListSerializer(many=True), request_body=None)
def get(self, request, *args, **kwargs):
items = []
name = request.GET.get('name', None)
backpath = settings.BACKUP_PATH + '/database'
for file in get_file_list(backpath):
if len(items) > 50:
break
filepath = os.path.join(backpath, file)
if name:
if name in filepath:
fsize = os.path.getsize(filepath)
if fsize:
items.append({
"name": file,
"filepath": filepath,
"size": round(fsize/1024/1024, 1)
})
else:
fsize = os.path.getsize(filepath)
if fsize:
items.append({
"name": file,
"filepath": filepath,
"size": round(fsize/1024/1024, 1)
})
return Response(items)
class DrfRequestLogViewSet(ListModelMixin, CustomGenericViewSet):
"""list:请求日志
请求日志
"""
perms_map = {'get': '*'}
queryset = DrfRequestLog.objects.all()
list_serializer_class = DrfRequestLogSerializer
ordering = ['-requested_at']
filterset_class = DrfLogFilterSet
search_fields = ['path', 'view']
class TlogViewSet(ListModelMixin, CustomGenericViewSet):
"""list:三方日志查看
三方日志查看
"""
perms_map = {'get': '*'}
queryset = Tlog.objects.all()
list_serializer_class = TlogSerializer
ordering = ['-requested_at']
filterset_class = TlogFilterSet
search_fields = ['path']

1
apps/system/__init__.py Executable file
View File

@ -0,0 +1 @@
default_app_config = 'apps.system.apps.SystemConfig'

10
apps/system/admin.py Executable file
View File

@ -0,0 +1,10 @@
from django.contrib import admin
from .models import User, Dept, Role, Permission, DictType, Dictionary, File
# Register your models here.
admin.site.register(User)
admin.site.register(Dept)
admin.site.register(Role)
admin.site.register(Permission)
admin.site.register(DictType)
admin.site.register(Dictionary)
admin.site.register(File)

15
apps/system/apps.py Executable file
View File

@ -0,0 +1,15 @@
from django.apps import AppConfig
from django.core.cache import cache
class SystemConfig(AppConfig):
name = 'apps.system'
verbose_name = '系统管理'
def ready(self) -> None:
# 启动时重新加载系统配置json
if cache.get('cache_sysconfig_need_task', True):
from server.settings import get_sysconfig
get_sysconfig(reload=True)
cache.set('cache_sysconfig_need_task', False, timeout=30)
return super().ready()

9
apps/system/errors.py Executable file
View File

@ -0,0 +1,9 @@
SCHEDULE_WRONG = {"code": "schedule_wrong", "detail": "时间策略有误"}
PASSWORD_NOT_SAME = {"code": "password_not_same", "detail": "新旧密码不一致"}
OLD_PASSWORD_WRONG = {"code": "old_password_wrong", "detail": "旧密码错误"}
FUNC_ERROR = {"code": "func_error", "detail": "执行方法有误"}
USERNAME_EXIST = {"code": "username_exist", "detail": "账户已存在"}
ROLE_NAME_EXIST = {"code": "role_name_exist", "detail": "角色名已存在"}
ROLE_CODE_EXIST = {"code": "role_code_exist", "detail": "角色标识已存在"}

26
apps/system/filters.py Executable file
View File

@ -0,0 +1,26 @@
from django_filters import rest_framework as filters
from .models import Dept, User
class UserFilterSet(filters.FilterSet):
class Meta:
model = User
fields = {
'name': ['exact', 'contains'],
'is_deleted': ['exact'],
'posts': ['exact'],
'post': ['exact'],
'belong_dept': ['exact'],
'depts': ['exact'],
'type': ['exact', 'in']
}
class DeptFilterSet(filters.FilterSet):
class Meta:
model = Dept
fields = {
'type': ['exact', 'in']
}

View File

@ -0,0 +1,288 @@
# Generated by Django 3.2.12 on 2022-08-15 06:02
import apps.system.models
from django.conf import settings
import django.contrib.auth.validators
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='User',
fields=[
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')),
('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')),
('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')),
('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')),
('type', models.CharField(default='employee', max_length=10, verbose_name='账号类型')),
('name', models.CharField(blank=True, max_length=20, null=True, verbose_name='姓名')),
('phone', models.CharField(blank=True, max_length=11, null=True, verbose_name='手机号')),
('avatar', models.CharField(blank=True, default='/media/default/avatar.png', max_length=100, null=True, verbose_name='头像')),
('secret', models.CharField(blank=True, max_length=100, null=True, verbose_name='密钥')),
('wx_openid', models.CharField(blank=True, max_length=100, null=True, verbose_name='微信公众号OpenId')),
('wx_nickname', models.CharField(blank=True, max_length=100, null=True, verbose_name='微信昵称')),
('wx_headimg', models.CharField(blank=True, max_length=100, null=True, verbose_name='微信头像')),
('wxmp_openid', models.CharField(blank=True, max_length=100, null=True, verbose_name='微信小程序OpenId')),
],
options={
'verbose_name': '用户信息',
'verbose_name_plural': '用户信息',
'ordering': ['create_time'],
},
managers=[
('objects', apps.system.models.SoftDeletableUserManager()),
],
),
migrations.CreateModel(
name='Dept',
fields=[
('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')),
('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')),
('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')),
('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')),
('name', models.CharField(max_length=60, verbose_name='名称')),
('type', models.CharField(default='dept', max_length=20, verbose_name='类型')),
('sort', models.PositiveSmallIntegerField(default=1, verbose_name='排序标记')),
('third_info', models.JSONField(default=dict, verbose_name='三方系统信息')),
('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dept_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='system.dept', verbose_name='')),
('update_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dept_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')),
],
options={
'verbose_name': '部门',
'verbose_name_plural': '部门',
'ordering': ['sort'],
},
),
migrations.CreateModel(
name='Permission',
fields=[
('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')),
('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')),
('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')),
('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')),
('name', models.CharField(max_length=30, verbose_name='名称')),
('type', models.PositiveSmallIntegerField(choices=[(10, '目录'), (20, '菜单'), (30, '按钮')], default=30, verbose_name='类型')),
('sort', models.PositiveSmallIntegerField(default=1, verbose_name='排序标记')),
('codes', models.JSONField(blank=True, default=list, null=True, verbose_name='权限标识')),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='system.permission', verbose_name='')),
],
options={
'verbose_name': '功能权限表',
'verbose_name_plural': '功能权限表',
'ordering': ['sort'],
},
),
migrations.CreateModel(
name='Post',
fields=[
('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')),
('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')),
('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')),
('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')),
('name', models.CharField(max_length=32, verbose_name='名称')),
('code', models.CharField(blank=True, max_length=32, null=True, verbose_name='岗位标识')),
('description', models.CharField(blank=True, max_length=50, null=True, verbose_name='描述')),
('min_hour', models.PositiveSmallIntegerField(default=0, verbose_name='最小在岗时间')),
('max_hour', models.PositiveSmallIntegerField(default=12, verbose_name='最长在岗时间')),
('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='post_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='system.post', verbose_name='')),
('update_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='post_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')),
],
options={
'verbose_name': '职位/岗位',
'verbose_name_plural': '职位/岗位',
'ordering': ['create_time'],
},
),
migrations.CreateModel(
name='UserPost',
fields=[
('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')),
('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')),
('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')),
('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')),
('name', models.CharField(blank=True, max_length=20, null=True, verbose_name='名称')),
('sort', models.PositiveSmallIntegerField(default=1, verbose_name='排序')),
('dept', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='up_dept', to='system.dept')),
('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='up_post', to='system.post')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='up_user', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': '用户岗位关系表',
'verbose_name_plural': '用户岗位关系表',
'ordering': ['sort', 'create_time'],
'unique_together': {('user', 'post', 'dept')},
},
),
migrations.CreateModel(
name='Role',
fields=[
('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')),
('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')),
('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')),
('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')),
('name', models.CharField(max_length=32, verbose_name='名称')),
('code', models.CharField(blank=True, max_length=32, null=True, verbose_name='角色标识')),
('description', models.CharField(blank=True, max_length=50, null=True, verbose_name='描述')),
('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='role_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')),
('perms', models.ManyToManyField(blank=True, related_name='role_perms', to='system.Permission', verbose_name='功能权限')),
('update_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='role_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')),
],
options={
'verbose_name': '角色',
'verbose_name_plural': '角色',
'ordering': ['code'],
},
),
migrations.CreateModel(
name='PostRole',
fields=[
('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')),
('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')),
('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')),
('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')),
('data_range', models.PositiveSmallIntegerField(choices=[(10, '全部'), (30, '同级及以下'), (40, '本级及以下'), (50, '本级'), (60, '仅本人')], default=40, verbose_name='数据权限范围')),
('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='system.post', verbose_name='关联岗位')),
('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='system.role', verbose_name='关联角色')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='File',
fields=[
('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')),
('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')),
('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')),
('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')),
('name', models.CharField(blank=True, max_length=100, null=True, verbose_name='名称')),
('size', models.IntegerField(blank=True, default=1, null=True, verbose_name='文件大小')),
('file', models.FileField(upload_to='%Y/%m/%d/', verbose_name='文件')),
('mime', models.CharField(blank=True, max_length=120, null=True, verbose_name='文件格式')),
('type', models.CharField(choices=[(10, '文档'), (20, '视频'), (30, '音频'), (40, '图片'), (50, '其它')], default='文档', max_length=50, verbose_name='文件类型')),
('path', models.CharField(blank=True, max_length=200, null=True, verbose_name='地址')),
('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='file_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')),
('update_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='file_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')),
],
options={
'verbose_name': '文件库',
'verbose_name_plural': '文件库',
},
),
migrations.CreateModel(
name='DictType',
fields=[
('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')),
('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')),
('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')),
('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')),
('name', models.CharField(max_length=30, verbose_name='名称')),
('code', models.CharField(max_length=30, verbose_name='标识')),
('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dicttype_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='system.dicttype', verbose_name='')),
('update_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dicttype_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')),
],
options={
'verbose_name': '字典类型',
'verbose_name_plural': '字典类型',
'ordering': ['-create_time'],
},
),
migrations.AddField(
model_name='user',
name='belong_dept',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='user_belong_dept', to='system.dept', verbose_name='所属部门'),
),
migrations.AddField(
model_name='user',
name='create_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='user_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人'),
),
migrations.AddField(
model_name='user',
name='depts',
field=models.ManyToManyField(through='system.UserPost', to='system.Dept'),
),
migrations.AddField(
model_name='user',
name='groups',
field=models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups'),
),
migrations.AddField(
model_name='user',
name='post',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='system.post', verbose_name='主要岗位'),
),
migrations.AddField(
model_name='user',
name='posts',
field=models.ManyToManyField(related_name='user_posts', through='system.UserPost', to='system.Post'),
),
migrations.AddField(
model_name='user',
name='roles',
field=models.ManyToManyField(to='system.Role', verbose_name='关联角色'),
),
migrations.AddField(
model_name='user',
name='superior',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='上级主管'),
),
migrations.AddField(
model_name='user',
name='update_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='user_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'),
),
migrations.AddField(
model_name='user',
name='user_permissions',
field=models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions'),
),
migrations.CreateModel(
name='Dictionary',
fields=[
('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')),
('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')),
('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')),
('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')),
('name', models.CharField(max_length=60, verbose_name='名称')),
('value', models.CharField(blank=True, max_length=10, null=True, verbose_name='')),
('code', models.CharField(blank=True, max_length=30, null=True, verbose_name='标识')),
('description', models.TextField(blank=True, null=True, verbose_name='描述')),
('sort', models.PositiveSmallIntegerField(default=1, verbose_name='排序')),
('is_used', models.BooleanField(default=True, verbose_name='是否有效')),
('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dictionary_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='system.dictionary', verbose_name='')),
('type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='system.dicttype', verbose_name='类型')),
('update_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dictionary_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')),
],
options={
'verbose_name': '字典',
'verbose_name_plural': '字典',
'ordering': ['sort'],
'unique_together': {('name', 'is_used', 'type')},
},
),
]

View File

@ -0,0 +1,35 @@
# Generated by Django 3.2.12 on 2023-03-09 05:09
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('django_celery_beat', '0016_alter_crontabschedule_timezone'),
('system', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='MySchedule',
fields=[
('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')),
('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')),
('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')),
('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')),
('name', models.CharField(max_length=200, verbose_name='名称')),
('type', models.PositiveSmallIntegerField(default=10, verbose_name='周期类型')),
('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='myschedule_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')),
('crontab', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='django_celery_beat.crontabschedule')),
('interval', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='django_celery_beat.intervalschedule')),
('update_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='myschedule_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')),
],
options={
'abstract': False,
},
),
]

View File

254
apps/system/models.py Executable file
View File

@ -0,0 +1,254 @@
from django.contrib.auth.models import UserManager
from django.db import models
from django.contrib.auth.models import AbstractUser
from apps.utils.models import CommonADModel, CommonAModel, CommonBModel, BaseModel, SoftDeletableManagerMixin
from django_celery_beat.models import IntervalSchedule, CrontabSchedule
class DataFilter(models.IntegerChoices):
ALL = 10, '全部'
SAMELEVE_AND_BELOW = 30, '同级及以下'
THISLEVEL_AND_BELOW = 40, '本级及以下'
THISLEVEL = 50, '本级'
MYSELF = 60, '仅本人'
class Permission(BaseModel):
"""
功能权限:目录,菜单,按钮
"""
PERM_TYPE_LIST = 10
PERM_TYPE_MENU = 20
PERM_TYPE_BUTTON = 30
menu_type_choices = (
(PERM_TYPE_LIST, '目录'),
(PERM_TYPE_MENU, '菜单'),
(PERM_TYPE_BUTTON, '按钮')
)
name = models.CharField('名称', max_length=30)
type = models.PositiveSmallIntegerField('类型', choices=menu_type_choices, default=30)
sort = models.PositiveSmallIntegerField('排序标记', default=1)
parent = models.ForeignKey('self', null=True, blank=True,
on_delete=models.SET_NULL, verbose_name='')
codes = models.JSONField('权限标识', default=list, null=True, blank=True)
def __str__(self):
return self.name
class Meta:
verbose_name = '功能权限表'
verbose_name_plural = verbose_name
ordering = ['sort']
class Dept(CommonAModel):
"""
部门
"""
name = models.CharField('名称', max_length=60)
type = models.CharField('类型', max_length=20, default='dept')
parent = models.ForeignKey('self', null=True, blank=True,
on_delete=models.SET_NULL, verbose_name='')
sort = models.PositiveSmallIntegerField('排序标记', default=1)
third_info = models.JSONField('三方系统信息', default=dict)
class Meta:
verbose_name = '部门'
verbose_name_plural = verbose_name
ordering = ['sort']
def __str__(self):
return self.name
class Role(CommonADModel):
"""
角色
"""
name = models.CharField('名称', max_length=32)
code = models.CharField('角色标识', max_length=32, null=True, blank=True)
perms = models.ManyToManyField(Permission, blank=True, verbose_name='功能权限', related_name='role_perms')
description = models.CharField('描述', max_length=50, blank=True, null=True)
class Meta:
verbose_name = '角色'
verbose_name_plural = verbose_name
ordering = ['code']
def __str__(self):
return self.name
class Post(CommonADModel):
"""
职位/岗位
"""
name = models.CharField('名称', max_length=32)
code = models.CharField('岗位标识', max_length=32, null=True, blank=True)
description = models.CharField('描述', max_length=50, blank=True, null=True)
parent = models.ForeignKey('self', null=True, blank=True,
on_delete=models.SET_NULL, verbose_name='')
min_hour = models.PositiveSmallIntegerField('最小在岗时间', default=0)
max_hour = models.PositiveSmallIntegerField('最长在岗时间', default=12)
class Meta:
verbose_name = '职位/岗位'
verbose_name_plural = verbose_name
ordering = ['create_time']
def __str__(self):
return self.name
class PostRole(BaseModel):
"""
岗位角色关系
"""
data_range = models.PositiveSmallIntegerField('数据权限范围', choices=DataFilter.choices,
default=DataFilter.THISLEVEL_AND_BELOW)
post = models.ForeignKey(Post, verbose_name='关联岗位', on_delete=models.CASCADE)
role = models.ForeignKey(Role, verbose_name='关联角色', on_delete=models.CASCADE)
class SoftDeletableUserManager(SoftDeletableManagerMixin, UserManager):
pass
class User(AbstractUser, CommonBModel):
"""
用户
"""
type = models.CharField('账号类型', max_length=10, default='employee')
name = models.CharField('姓名', max_length=20, null=True, blank=True)
phone = models.CharField('手机号', max_length=11, null=True, blank=True)
avatar = models.CharField(
'头像', default='/media/default/avatar.png', max_length=100, null=True, blank=True)
superior = models.ForeignKey(
'self', null=True, blank=True, on_delete=models.SET_NULL, verbose_name='上级主管')
post = models.ForeignKey(Post, verbose_name='主要岗位', on_delete=models.SET_NULL,
null=True, blank=True)
posts = models.ManyToManyField(Post, through='system.userpost', related_name='user_posts')
depts = models.ManyToManyField(Dept, through='system.userpost')
roles = models.ManyToManyField(Role, verbose_name='关联角色')
# 关联账号
secret = models.CharField('密钥', max_length=100, null=True, blank=True)
wx_openid = models.CharField('微信公众号OpenId', max_length=100, null=True, blank=True)
wx_nickname = models.CharField('微信昵称', max_length=100, null=True, blank=True)
wx_headimg = models.CharField('微信头像', max_length=100, null=True, blank=True)
wxmp_openid = models.CharField('微信小程序OpenId', max_length=100, null=True, blank=True)
objects = SoftDeletableUserManager()
class Meta:
verbose_name = '用户信息'
verbose_name_plural = verbose_name
ordering = ['create_time']
def __str__(self):
return self.username
class UserPost(BaseModel):
"""
用户岗位关系表
"""
name = models.CharField('名称', max_length=20, null=True, blank=True)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='up_user')
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='up_post')
dept = models.ForeignKey(Dept, on_delete=models.CASCADE, related_name='up_dept')
sort = models.PositiveSmallIntegerField('排序', default=1)
class Meta:
verbose_name = '用户岗位关系表'
verbose_name_plural = verbose_name
ordering = ['sort', 'create_time']
unique_together = ('user', 'post', 'dept')
class DictType(CommonAModel):
"""
数据字典类型
"""
name = models.CharField('名称', max_length=30)
code = models.CharField('标识', max_length=30)
parent = models.ForeignKey('self', null=True, blank=True,
on_delete=models.SET_NULL, verbose_name='')
class Meta:
verbose_name = '字典类型'
verbose_name_plural = verbose_name
ordering = ['-create_time']
def __str__(self):
return self.name
class Dictionary(CommonAModel):
"""
数据字典
"""
name = models.CharField('名称', max_length=60)
value = models.CharField('', max_length=10, null=True, blank=True)
code = models.CharField('标识', max_length=30, null=True, blank=True)
description = models.TextField('描述', blank=True, null=True)
type = models.ForeignKey(
DictType, on_delete=models.CASCADE, verbose_name='类型')
sort = models.PositiveSmallIntegerField('排序', default=1)
parent = models.ForeignKey('self', null=True, blank=True,
on_delete=models.SET_NULL, verbose_name='')
is_used = models.BooleanField('是否有效', default=True)
class Meta:
verbose_name = '字典'
verbose_name_plural = verbose_name
unique_together = ('name', 'is_used', 'type')
ordering = ['sort']
def __str__(self):
return self.name
class File(CommonAModel):
"""
文件存储表,业务表根据具体情况选择是否外键关联
"""
FILE_TYPE_DOC = 10
FILE_TYPE_VIDEO = 20
FILE_TYPE_AUDIO = 30
FILE_TYPE_PIC = 40
FILE_TYPE_OTHER = 50
name = models.CharField('名称', max_length=100, null=True, blank=True)
size = models.IntegerField('文件大小', default=1, null=True, blank=True)
file = models.FileField('文件', upload_to='%Y/%m/%d/')
type_choices = (
(FILE_TYPE_DOC, '文档'),
(FILE_TYPE_VIDEO, '视频'),
(FILE_TYPE_AUDIO, '音频'),
(FILE_TYPE_PIC, '图片'),
(FILE_TYPE_OTHER, '其它')
)
mime = models.CharField('文件格式', max_length=120, null=True, blank=True)
type = models.CharField('文件类型', max_length=50, choices=type_choices, default='文档')
path = models.CharField('地址', max_length=200, null=True, blank=True)
class Meta:
verbose_name = '文件库'
verbose_name_plural = verbose_name
def __str__(self):
return self.name
class MySchedule(CommonAModel):
"""
常用周期
"""
MS_TYPE = (
(10, '间隔'),
(20, '定时')
)
name = models.CharField('名称', max_length=200)
type = models.PositiveSmallIntegerField('周期类型', default=10)
interval = models.ForeignKey(IntervalSchedule, on_delete=models.PROTECT, null=True, blank=True)
crontab = models.ForeignKey(CrontabSchedule, on_delete=models.PROTECT, null=True, blank=True)

441
apps/system/serializers.py Executable file
View File

@ -0,0 +1,441 @@
from django_celery_beat.models import PeriodicTask, CrontabSchedule, IntervalSchedule
from rest_framework import serializers
from django_celery_results.models import TaskResult
from apps.hrm.errors import PHONE_EXIST
from apps.system.errors import USERNAME_EXIST
from apps.system.services import sync_dahua_dept
from apps.utils.fields import MyFilePathField
from apps.utils.serializers import CustomModelSerializer
from apps.utils.constants import EXCLUDE_FIELDS, EXCLUDE_FIELDS_BASE
from apps.utils.tools import check_phone_e
from .models import (Dictionary, DictType, File, Dept, MySchedule, Permission, Post, PostRole,
Role, User, UserPost)
from rest_framework.exceptions import ParseError, ValidationError
from django.db import transaction
from apps.third.tapis import dhapis
from rest_framework.validators import UniqueValidator
from django.conf import settings
from django.db.models import Q
# from django_q.models import Task as QTask, Schedule as QSchedule
# class QScheduleSerializer(CustomModelSerializer):
# success = serializers.SerializerMethodField()
# class Meta:
# model = QSchedule
# fields = '__all__'
# def get_success(self, obj):
# return obj.success()
# class QTaskResultSerializer(CustomModelSerializer):
# args = serializers.SerializerMethodField()
# kwargs = serializers.SerializerMethodField()
# result = serializers.SerializerMethodField()
# class Meta:
# model = QTask
# fields = '__all__'
# def get_args(self, obj):
# return obj.args
# def get_kwargs(self, obj):
# return obj.kwargs
# def get_result(self, obj):
# return obj.result
class TaskRunSerializer(serializers.Serializer):
sync = serializers.BooleanField(default=True)
class IntervalSerializer(CustomModelSerializer):
class Meta:
model = IntervalSchedule
fields = '__all__'
class CrontabSerializer(CustomModelSerializer):
class Meta:
model = CrontabSchedule
exclude = ['timezone']
class PTaskCreateUpdateSerializer(CustomModelSerializer):
class Meta:
model = PeriodicTask
fields = ['name', 'task', 'interval', 'crontab', 'args', 'kwargs']
class PTaskSerializer(CustomModelSerializer):
interval_ = IntervalSerializer(source='interval', read_only=True)
crontab_ = CrontabSerializer(source='crontab', read_only=True)
schedule = serializers.SerializerMethodField()
timetype = serializers.SerializerMethodField()
class Meta:
model = PeriodicTask
fields = '__all__'
def get_schedule(self, obj):
if obj.interval:
return obj.interval.__str__()
elif obj.crontab:
return obj.crontab.__str__()
return ''
def get_timetype(self, obj):
if obj.interval:
return 'interval'
elif obj.crontab:
return 'crontab'
return 'interval'
class PTaskResultSerializer(CustomModelSerializer):
class Meta:
model = TaskResult
fields = '__all__'
class FileSerializer(CustomModelSerializer):
class Meta:
model = File
fields = "__all__"
class DictTypeSerializer(CustomModelSerializer):
"""
数据字典类型序列化
"""
class Meta:
model = DictType
fields = '__all__'
class DictTypeCreateUpdateSerializer(CustomModelSerializer):
class Meta:
model = DictType
fields = ['name', 'code', 'parent']
class DictSerializer(CustomModelSerializer):
"""
数据字典序列化
"""
class Meta:
model = Dictionary
fields = '__all__'
class DictSimpleSerializer(CustomModelSerializer):
class Meta:
model = Dictionary
fields = ['id', 'name', 'code']
class DictCreateUpdateSerializer(CustomModelSerializer):
"""
数据字典序列化
"""
class Meta:
model = Dictionary
exclude = EXCLUDE_FIELDS
class PostSerializer(CustomModelSerializer):
"""
岗位序列化
"""
class Meta:
model = Post
fields = '__all__'
class PostCreateUpdateSerializer(CustomModelSerializer):
"""
岗位序列化
"""
class Meta:
model = Post
exclude = EXCLUDE_FIELDS
def create(self, validated_data):
if Post.objects.filter(name=validated_data['name']).exists():
raise ValidationError('该岗位已存在')
return super().create(validated_data)
def update(self, instance, validated_data):
if Post.objects.filter(name=validated_data['name']).exclude(id=instance.id).exists():
raise ValidationError('该岗位已存在')
return super().update(instance, validated_data)
class PostSimpleSerializer(CustomModelSerializer):
class Meta:
model = Post
fields = ['id', 'name', 'code']
class RoleSerializer(CustomModelSerializer):
"""
角色序列化
"""
class Meta:
model = Role
fields = '__all__'
class RoleSimpleSerializer(CustomModelSerializer):
class Meta:
model = Role
fields = ['id', 'name', 'code']
class RoleCreateUpdateSerializer(CustomModelSerializer):
"""
角色序列化
"""
name = serializers.CharField(label="名称", validators=[
UniqueValidator(queryset=Role.objects.all(), message='已存在相同名称的角色')])
code = serializers.CharField(label="标识", validators=[
UniqueValidator(queryset=Role.objects.all(), message='已存在相同标识的角色')])
class Meta:
model = Role
exclude = EXCLUDE_FIELDS
class PermissionSerializer(serializers.ModelSerializer):
"""
权限序列化
"""
class Meta:
model = Permission
fields = '__all__'
class PermissionCreateUpdateSerializer(serializers.ModelSerializer):
"""
权限序列化
"""
class Meta:
model = Permission
exclude = EXCLUDE_FIELDS_BASE
class DeptSimpleSerializer(CustomModelSerializer):
class Meta:
model = Dept
fields = ['id', 'name', 'type']
class DeptSerializer(CustomModelSerializer):
"""
组织架构序列化
"""
class Meta:
model = Dept
fields = '__all__'
class DeptCreateUpdateSerializer(CustomModelSerializer):
"""
部门序列化
"""
parent = serializers.PrimaryKeyRelatedField(queryset=Dept.objects.all(), required=True)
class Meta:
model = Dept
exclude = EXCLUDE_FIELDS + ['third_info']
@transaction.atomic
def create(self, validated_data):
ins = super().create(validated_data)
sync_dahua_dept(ins)
return ins
@transaction.atomic
def update(self, instance, validated_data):
ins = super().update(instance, validated_data)
sync_dahua_dept(ins)
return ins
class UserSimpleSerializer(CustomModelSerializer):
class Meta:
model = User
fields = ['id', 'username', 'name', 'phone']
class UserSignatureSerializer(CustomModelSerializer):
signature = serializers.CharField(source='employee.signature', read_only=True)
class Meta:
model = User
fields = ['id', 'username', 'name', 'phone', 'signature']
class UserListSerializer(CustomModelSerializer):
"""
用户列表序列化
"""
belong_dept_name = serializers.CharField(source='belong_dept.name', read_only=True)
post_name = serializers.CharField(source='post.name', read_only=True)
# posts_ = PostSimpleSerializer(source='posts', many=True)
avatar_f = MyFilePathField(source='avatar', read_only=True)
class Meta:
model = User
exclude = ['password', 'secret']
def phone_exist(phone):
if User.objects.filter(phone=phone).exists():
raise serializers.ValidationError(**PHONE_EXIST)
def user_exist(username):
if User.objects.filter(username=username).exists():
raise serializers.ValidationError(**USERNAME_EXIST)
return username
class UserUpdateSerializer(CustomModelSerializer):
"""
用户编辑序列化
"""
phone = serializers.CharField(required=False)
class Meta:
model = User
fields = ['username', 'name', 'avatar', 'phone', 'type', 'is_deleted']
def update(self, instance, validated_data):
if User.objects.filter(username=validated_data['username']
).exclude(id=instance.id).exists():
raise ParseError(**USERNAME_EXIST)
return super().update(instance, validated_data)
class UserCreateSerializer(CustomModelSerializer):
"""
创建用户序列化
"""
username = serializers.CharField(required=True, validators=[user_exist])
phone = serializers.CharField(required=False, validators=[phone_exist])
class Meta:
model = User
fields = ['username', 'name', 'avatar', 'phone', 'type']
class PasswordChangeSerializer(serializers.Serializer):
old_password = serializers.CharField(label="原密码")
new_password1 = serializers.CharField(label="新密码1")
new_password2 = serializers.CharField(label="新密码2")
class UserPostSerializer(CustomModelSerializer):
"""
用户-岗位序列化
"""
user_ = UserSimpleSerializer(source='user', read_only=True)
post_ = PostSimpleSerializer(source='post', read_only=True)
dept_ = DeptSimpleSerializer(source='dept', read_only=True)
class Meta:
model = UserPost
fields = '__all__'
class UserPostCreateSerializer(CustomModelSerializer):
class Meta:
model = UserPost
exclude = EXCLUDE_FIELDS_BASE
def create(self, validated_data):
return super().create(validated_data)
class PostRoleSerializer(CustomModelSerializer):
"""
岗位-角色序列化
"""
post_ = PostSimpleSerializer(source='post', read_only=True)
role_ = RoleSimpleSerializer(source='role', read_only=True)
class Meta:
model = PostRole
fields = '__all__'
class PostRoleCreateSerializer(CustomModelSerializer):
"""
岗位-角色创建序列化
"""
class Meta:
model = PostRole
fields = ['post', 'role', 'data_range']
class UserInfoSerializer(CustomModelSerializer):
class Meta:
model = User
fields = ['id', 'username', 'name', 'post', 'avatar', 'belong_dept', 'type']
class ApkSerializer(serializers.Serializer):
version = serializers.CharField(label='版本号')
file = serializers.CharField(label='文件地址')
class IntervalScheduleSerializer(serializers.ModelSerializer):
class Meta:
model = IntervalSchedule
fields = '__all__'
class CrontabScheduleSerializer(serializers.ModelSerializer):
class Meta:
model = CrontabSchedule
exclude = ['timezone']
class MyScheduleCreateSerializer(CustomModelSerializer):
interval_ = IntervalScheduleSerializer(allow_null=True, required=False)
crontab_ = CrontabScheduleSerializer(allow_null=True, required=False)
class Meta:
model = MySchedule
fields = ['type', 'interval_', 'crontab_']
def validate(self, attrs):
if attrs['type'] == 10 and attrs.get('interval_', None):
pass
elif attrs['type'] == 20 and attrs.get('crontab_', None):
pass
else:
raise ValidationError('信息有误')
return super().validate(attrs)
class MyScheduleSerializer(CustomModelSerializer):
interval_ = IntervalScheduleSerializer(source='interval', read_only=True)
crontab = CrontabScheduleSerializer(source='crontab', read_only=True)
class Meta:
model = MySchedule
fields = '__all__'

29
apps/system/services.py Normal file
View File

@ -0,0 +1,29 @@
from apps.system.models import Dept
from django.conf import settings
from apps.third.tapis import dhapis
from apps.third.dahua import dhClient
def sync_dahua_dept(dept: Dept):
# 同步大华部门信息
third_info = dept.third_info
if settings.DAHUA_ENABLED:
if third_info.get('dh_id', False):
data = {
"id": dept.third_info['dh_id'],
"parentId": 1,
"name": dept.name
}
dhClient.request(**dhapis['dept_update'], json=data)
else:
# 如果dh_id 不存在
data = {
"parentId": 1,
"name": dept.name,
"service": "ehs"
}
_, res = dhClient.request(**dhapis['dept_create'], json=data)
third_info['dh_id'] = res['id']
dept.third_info = third_info
dept.save()
dhClient.face_bind()

12
apps/system/signals.py Executable file
View File

@ -0,0 +1,12 @@
from django.db.models.signals import m2m_changed
from .models import Role, Permission, User
from django.dispatch import receiver
from django.core.cache import cache
from apps.utils.permission import get_user_perms_map
# 变更用户角色时动态更新权限或者前端刷新
# @receiver(m2m_changed, sender=User.roles.through)
# def update_perms_cache_user(sender, instance, action, **kwargs):
# if action in ['post_remove', 'post_add']:
# if cache.get('perms_' + instance.id, None):
# get_user_perms_map(instance)

18
apps/system/tasks.py Executable file
View File

@ -0,0 +1,18 @@
# Create your tasks here
from __future__ import absolute_import, unicode_literals
from datetime import timedelta
from apps.utils.tasks import CustomTask
from celery import shared_task
from django_celery_results.models import TaskResult
from django.utils import timezone
@shared_task(base=CustomTask)
def cleanup_dcr():
"""清空三十日前的定时任务执行记录
清空三十日前的定时任务执行记录
"""
now = timezone.now()
days30_ago = now - timedelta(days=30)
TaskResult.objects.filter(periodic_task_name__isnull=False, date_done__lte=days30_ago).delete()

3
apps/system/tests.py Executable file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

37
apps/system/urls.py Executable file
View File

@ -0,0 +1,37 @@
from email.mime import base
from django.urls import path, include
from .views import ApkViewSet, FileViewSet, PTaskViewSet, PTaskResultViewSet, PostRoleViewSet, TaskList, \
UserPostViewSet, UserViewSet, DeptViewSet, \
PermissionViewSet, RoleViewSet, PostViewSet, \
DictTypeViewSet, DictViewSet, SysConfigView, SysBaseConfigView
from rest_framework import routers
API_BASE_URL = 'api/system/'
HTML_BASE_URL = 'system/'
router = routers.DefaultRouter()
router.register('user', UserViewSet, basename="user")
router.register('dept', DeptViewSet, basename="dept")
router.register('permission', PermissionViewSet, basename="permission")
router.register('role', RoleViewSet, basename="role")
router.register('post', PostViewSet, basename="post")
router.register('dicttype', DictTypeViewSet, basename="dicttype")
router.register('dict', DictViewSet, basename="dict")
router.register('ptask', PTaskViewSet, basename="ptask")
router.register('ptask_result', PTaskResultViewSet, basename="ptask_result")
# router.register('qschedule', QScheduleViewSet, basename="qschedule")
# router.register('qtask_result', QTaskResultViewSet, basename="qtask_result")
router.register('user_post', UserPostViewSet, basename='user_post')
router.register('post_role', PostRoleViewSet, basename='post_role')
router.register('apk', ApkViewSet, basename='apk')
router2 = routers.DefaultRouter()
router2.register('file', FileViewSet, basename='file')
urlpatterns = [
path(API_BASE_URL, include(router.urls)),
path(API_BASE_URL + 'task/', TaskList.as_view()),
path(API_BASE_URL + 'base_config/', SysBaseConfigView.as_view()),
path(API_BASE_URL + 'config/', SysConfigView.as_view()),
path('api/', include(router2.urls)),
]

761
apps/system/views.py Executable file
View File

@ -0,0 +1,761 @@
import configparser
import os
import importlib
import json
from django.contrib.auth.hashers import check_password, make_password
from django.db import transaction
from django_celery_beat.models import (CrontabSchedule, IntervalSchedule,
PeriodicTask)
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.parsers import (JSONParser,
MultiPartParser)
from rest_framework.serializers import Serializer
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.hrm.models import Employee
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 django.conf import settings
from apps.utils.permission import ALL_PERMS, get_user_perms_map
from apps.utils.viewsets import CustomGenericViewSet, CustomModelViewSet
from server.celery import app as celery_app
from .models import (Dept, Dictionary, DictType, File, Permission, Post, PostRole, Role, User,
UserPost, MySchedule)
from .serializers import (ApkSerializer, DeptCreateUpdateSerializer, DeptSerializer, DictCreateUpdateSerializer,
DictSerializer, DictTypeCreateUpdateSerializer, DictTypeSerializer,
FileSerializer, PasswordChangeSerializer, PermissionCreateUpdateSerializer,
PermissionSerializer, PostCreateUpdateSerializer, PostRoleCreateSerializer,
PostRoleSerializer, PostSerializer,
PTaskSerializer, PTaskCreateUpdateSerializer, PTaskResultSerializer,
RoleCreateUpdateSerializer, RoleSerializer, TaskRunSerializer,
UserCreateSerializer, UserListSerializer, UserPostCreateSerializer,
UserPostSerializer, UserUpdateSerializer, MyScheduleCreateSerializer, MyScheduleSerializer)
from rest_framework.viewsets import GenericViewSet
from cron_descriptor import get_description
import locale
from drf_yasg.utils import swagger_auto_schema
from server.settings import get_sysconfig, update_sysconfig
# logger.info('请求成功! response_code:{}response_headers:{}
# response_body:{}'.format(response_code, response_headers, response_body[:251]))
# logger.error('请求出错-{}'.format(error))
class TaskList(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
"""获取注册任务列表
获取注册任务列表
"""
tasks = list(
sorted(name for name in celery_app.tasks if not name.startswith('celery.')))
return Response(tasks)
# class QScheduleViewSet(CustomModelViewSet):
# """
# list:定时任务列表
# 定时任务列表
# retrieve:定时任务详情
# 定时任务详情
# """
# queryset = QSchedule.objects.all()
# serializer_class = QScheduleSerializer
# search_fields = ['name', 'func']
# filterset_fields = ['schedule_type']
# ordering = ['-pk']
# @action(methods=['get'], detail=True, perms_map={'post': 'qschedule:run_once'})
# def run_once(self, request, pk=None):
# """同步执行一次
# 同步执行一次
# """
# obj = self.get_object()
# module, func = obj.func.rsplit(".", 1)
# m = importlib.import_module(module)
# f = getattr(m, func)
# f(*obj.args.split(','), **eval(f"dict({obj.kwargs})"))
# return Response()
# class QTaskResultViewSet(ListModelMixin, RetrieveModelMixin, CustomGenericViewSet):
# """
# list:任务执行结果列表
# 任务执行结果列表
# retrieve:任务执行结果详情
# 任务执行结果详情
# """
# perms_map = {'get': '*'}
# filterset_fields = ['func']
# queryset = QTask.objects.all()
# serializer_class = QTaskResultSerializer
# ordering = ['-started']
# lookup_field = 'id'
class PTaskViewSet(CustomModelViewSet):
"""
list:定时任务列表
定时任务列表
retrieve:定时任务详情
定时任务详情
"""
queryset = PeriodicTask.objects.exclude(name__contains='celery.')
serializer_class = PTaskSerializer
create_serializer_class = PTaskCreateUpdateSerializer
update_serializer_class = PTaskCreateUpdateSerializer
partial_update_serializer_class = PTaskCreateUpdateSerializer
search_fields = ['name', 'task']
filterset_fields = ['enabled']
select_related_fields = ['interval', 'crontab']
ordering = ['-id']
@action(methods=['post'], detail=True, perms_map={'get': 'qtask.run_once'},
serializer_class=TaskRunSerializer)
def run_once(self, request, pk=None):
"""执行一次
执行一次
"""
obj = self.get_object()
module, func = obj.task.rsplit(".", 1)
m = importlib.import_module(module)
f = getattr(m, func)
if request.data.get('sync', True):
f(*json.loads(obj.args), **json.loads(obj.kwargs))
return Response()
else:
task_obj = f.delay(*json.loads(obj.args), **json.loads(obj.kwargs))
return Response({'task_id': task_obj.id})
@action(methods=['put'], detail=True, perms_map={'put': 'ptask.update'})
def toggle(self, request, pk=None):
"""修改启用禁用状态
修改启用禁用状态
"""
obj = self.get_object()
obj.enabled = False if obj.enabled else True
obj.save()
return Response()
@transaction.atomic
def create(self, request, *args, **kwargs):
"""创建定时任务
创建定时任务
"""
data = request.data
timetype = data.get('timetype', None)
interval_ = data.get('interval_', None)
crontab_ = data.get('crontab_', None)
if timetype == 'interval' and interval_:
data['crontab'] = None
try:
interval, _ = IntervalSchedule.objects.get_or_create(
**interval_, defaults=interval_)
data['interval'] = interval.id
except Exception:
raise ParseError(**SCHEDULE_WRONG)
if timetype == 'crontab' and crontab_:
data['interval'] = None
try:
crontab_['timezone'] = 'Asia/Shanghai'
crontab, _ = CrontabSchedule.objects.get_or_create(
**crontab_, defaults=crontab_)
data['crontab'] = crontab.id
except Exception:
raise ParseError(**SCHEDULE_WRONG)
serializer = self.get_serializer(data=data)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response()
@transaction.atomic
def update(self, request, *args, **kwargs):
"""更新定时任务
更新定时任务
"""
data = request.data
timetype = data.get('timetype', None)
interval_ = data.get('interval_', None)
crontab_ = data.get('crontab_', None)
if timetype == 'interval' and interval_:
data['crontab'] = None
try:
if 'id' in interval_:
del interval_['id']
interval, _ = IntervalSchedule.objects.get_or_create(
**interval_, defaults=interval_)
data['interval'] = interval.id
except Exception:
raise ParseError(**SCHEDULE_WRONG)
if timetype == 'crontab' and crontab_:
data['interval'] = None
try:
crontab_['timezone'] = 'Asia/Shanghai'
if 'id' in crontab_:
del crontab_['id']
crontab, _ = CrontabSchedule.objects.get_or_create(
**crontab_, defaults=crontab_)
data['crontab'] = crontab.id
except Exception:
raise ParseError(**SCHEDULE_WRONG)
instance = self.get_object()
serializer = self.get_serializer(instance, data=data)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response()
class PTaskResultViewSet(ListModelMixin, RetrieveModelMixin, CustomGenericViewSet):
"""
list:任务执行结果列表
任务执行结果列表
retrieve:任务执行结果详情
任务执行结果详情
"""
perms_map = {'get': '*'}
filterset_fields = ['task_name', 'periodic_task_name', 'status']
queryset = TaskResult.objects.all()
serializer_class = PTaskResultSerializer
ordering = ['-date_created']
lookup_field = 'task_id'
class DictTypeViewSet(CustomModelViewSet):
"""数据字典类型-增删改查
数据字典类型-增删改查
"""
queryset = DictType.objects.all()
serializer_class = DictTypeSerializer
create_serializer_class = DictTypeCreateUpdateSerializer
update_serializer_class = DictTypeCreateUpdateSerializer
partial_update_serializer_class = DictTypeCreateUpdateSerializer
search_fields = ['name']
class DictViewSet(CustomModelViewSet):
"""数据字典-增删改查
数据字典-增删改查
"""
# queryset = Dictionary.objects.get_queryset(all=True) # 获取全部的,包括软删除的
queryset = Dictionary.objects.all()
filterset_fields = ['type', 'is_used', 'type__code']
serializer_class = DictSerializer
create_serializer_class = DictCreateUpdateSerializer
update_serializer_class = DictCreateUpdateSerializer
partial_update_serializer_class = DictCreateUpdateSerializer
search_fields = ['name']
ordering = ['sort', 'create_time']
class PostViewSet(CustomModelViewSet):
"""岗位-增删改查
岗位-增删改查
"""
queryset = Post.objects.all()
serializer_class = PostSerializer
create_serializer_class = PostCreateUpdateSerializer
update_serializer_class = PostCreateUpdateSerializer
partial_update_serializer_class = PostCreateUpdateSerializer
search_fields = ['name', 'code', 'description']
ordering = ['create_time']
class PermissionViewSet(CustomModelViewSet):
"""菜单权限-增删改查
菜单权限-增删改查
"""
queryset = Permission.objects.all()
filterset_fields = ['type']
serializer_class = PermissionSerializer
create_serializer_class = PermissionCreateUpdateSerializer
update_serializer_class = PermissionCreateUpdateSerializer
partial_update_serializer_class = PermissionCreateUpdateSerializer
search_fields = ['name', 'code']
ordering = ['sort', 'create_time']
@action(methods=['get'], detail=False, permission_classes=[IsAuthenticated])
def codes(self, request, pk=None):
"""获取全部权限标识
需要先请求一次swagger
"""
ALL_PERMS.sort()
return Response(ALL_PERMS)
class DeptViewSet(CustomModelViewSet):
"""部门-增删改查
部门-增删改查
"""
queryset = Dept.objects.all()
serializer_class = DeptSerializer
create_serializer_class = DeptCreateUpdateSerializer
update_serializer_class = DeptCreateUpdateSerializer
partial_update_serializer_class = DeptCreateUpdateSerializer
filterset_class = DeptFilterSet
search_fields = ['name']
ordering = ['type', 'sort', 'create_time']
# def filter_queryset(self, queryset):
# if not self.detail:
# self.request.query_params._mutable = True
# self.request.query_params.setdefault('type', 'dept')
# return super().filter_queryset(queryset)
# def get_queryset(self):
# type = self.request.query_params.get('type', None)
# if type:
# queryset = Dept.objects.filter(type='rparty')
# else:
# queryset = Dept.objects.filter(type__in=['dept', 'company'])
# return queryset
class RoleViewSet(CustomModelViewSet):
"""角色-增删改查
角色-增删改查
"""
queryset = Role.objects.all()
serializer_class = RoleSerializer
create_serializer_class = RoleCreateUpdateSerializer
update_serializer_class = RoleCreateUpdateSerializer
partial_update_serializer_class = RoleCreateUpdateSerializer
search_fields = ['name', 'code']
ordering = ['create_time']
class PostRoleViewSet(CreateModelMixin, DestroyModelMixin, ListModelMixin, CustomGenericViewSet):
"""岗位/角色关系
岗位/角色关系
"""
perms_map = {'get': '*', 'post': 'post.update', 'delete': 'post.update'}
queryset = PostRole.objects.select_related('post', 'role').all()
serializer_class = PostRoleSerializer
create_serializer_class = PostRoleCreateSerializer
filterset_fields = ['post', 'role']
class UserPostViewSet(CreateModelMixin, DestroyModelMixin, ListModelMixin, CustomGenericViewSet):
"""用户/岗位关系
用户/岗位关系
"""
perms_map = {'get': '*', 'post': 'user.update', 'delete': 'user.update'}
queryset = UserPost.objects.select_related('user', 'post', 'dept').all()
serializer_class = UserPostSerializer
create_serializer_class = UserPostCreateSerializer
filterset_fields = ['user', 'post', 'dept']
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()
# 更新人员表
ep = Employee.objects.get_queryset(all=True).filter(user=user).first()
if ep:
ep.belong_dept = user.belong_dept
ep.post = user.post
ep.is_deleted = False
ep.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
user.update_by = self.request.user
user.save()
# 更新人员表
ep = Employee.objects.get_queryset(all=True).filter(user=user).first()
if ep:
ep.belong_dept = user.belong_dept
ep.post = user.post
ep.is_deleted = False
ep.save()
class UserViewSet(CustomModelViewSet):
queryset = User.objects.get_queryset(all=True)
serializer_class = UserListSerializer
create_serializer_class = UserCreateSerializer
update_serializer_class = UserUpdateSerializer
filterset_class = UserFilterSet
search_fields = ['username', 'name', 'phone', 'email', 'id']
select_related_fields = ['superior', 'belong_dept', 'post']
prefetch_related_fields = ['posts', 'roles', 'depts']
ordering = ['create_time', 'type']
def get_queryset(self):
if self.request.method == 'GET' and (not self.request.query_params.get('is_deleted', None)):
self.queryset = User.objects.all()
return super().get_queryset()
def perform_update(self, serializer):
instance = serializer.save()
ep = Employee.objects.get_queryset(all=True).filter(user=instance).first()
ep2 = Employee.objects.get_queryset(all=True).filter(phone=instance.phone).first()
if ep:
pass
elif ep2:
ep = ep2
else:
ep = Employee()
ep.user = instance
ep.name = instance.name
ep.phone = instance.phone
ep.type = instance.type
ep.is_deleted = False
ep.save()
def create(self, request, *args, **kwargs):
"""创建用户
创建用户
"""
password = make_password('abc!0000')
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
instance = serializer.save(password=password, belong_dept=None)
ep = Employee.objects.get_queryset(all=True).filter(user=instance).first()
ep2 = Employee.objects.get_queryset(all=True).filter(phone=instance.phone).first()
if ep:
pass
elif ep2:
ep = ep2
else:
ep = Employee()
ep.user = instance
ep.name = instance.name
ep.phone = instance.phone
ep.type = instance.type
ep.is_deleted = False
ep.save()
return Response(data=serializer.data)
@action(methods=['put'], detail=False,
permission_classes=[IsAuthenticated],
serializer_class=PasswordChangeSerializer)
def password(self, request, pk=None):
"""修改密码
修改密码
"""
user = request.user
old_password = request.data['old_password']
if check_password(old_password, user.password):
new_password1 = request.data['new_password1']
new_password2 = request.data['new_password2']
if new_password1 == new_password2:
if new_password1 == old_password:
raise ParseError('新密码不得与旧密码相同')
user.set_password(new_password2)
user.save()
return Response()
else:
raise ParseError(**PASSWORD_NOT_SAME)
else:
raise ValidationError(**OLD_PASSWORD_WRONG)
@action(methods=['post'], detail=True, perms_map={'post': '*'}, serializer_class=Serializer)
def reset_password(self, request, pk=None):
user = self.get_object()
if request.user.is_superuser:
user.set_password('abc!0000')
user.save()
else:
raise PermissionDenied()
return Response()
@action(methods=['get'], detail=False, permission_classes=[IsAuthenticated])
def info(self, request, pk=None):
"""登录用户信息
获取登录用户信息
"""
user = request.user
perms = get_user_perms_map(user)
data = {
'id': user.id,
'username': user.username,
'type': user.type,
'name': user.name,
'roles': user.roles.values_list('name', flat=True),
'avatar': user.avatar,
'perms': perms,
'belong_dept': user.belong_dept.id if user.belong_dept else None,
'post': user.post.id if user.post else None,
'belong_dept_name': user.belong_dept.name if user.belong_dept else '',
'post_name': user.post.name if user.post else '',
'is_superuser': user.is_superuser,
'wxmp_openid': user.wxmp_openid,
'wx_openid': user.wx_openid
}
return Response(data)
@action(methods=['post'], detail=False, permission_classes=[IsAuthenticated])
def bind_wxmp(self, request, pk=None):
"""
绑定微信小程序
绑定微信小程序
"""
openid = request.data['openid']
if openid:
user = request.user
if user.wxmp_openid != openid:
User.objects.filter(wxmp_openid=openid).update(wxmp_openid=None)
user.wxmp_openid = openid
user.save()
return Response({'wxmp_openid': openid})
@action(methods=['post'], detail=False, permission_classes=[IsAuthenticated])
def unbind_wxmp(self, request, pk=None):
"""
解绑微信小程序
解绑微信小程序
"""
user = request.user
user.wxmp_openid = None
user.save()
return Response()
@action(methods=['post'], detail=False, permission_classes=[IsAuthenticated])
def bind_wx(self, request, pk=None):
"""绑定微信公众号
绑定微信公众号, 用于发送通知
"""
openid = request.data['openid']
if openid:
user = request.user
if user.wx_openid != openid:
User.objects.filter(wx_openid=openid).update(wx_openid=None)
user.wx_openid = openid
user.save()
return Response({'wx_openid': openid})
@action(methods=['post'], detail=False, permission_classes=[IsAuthenticated])
def bind_secret(self, request, pk=None):
"""创建密钥
创建密钥
"""
secret = request.data['secret']
if secret:
user = request.user
user.secret = secret
user.save()
return Response()
class FileViewSet(CustomCreateModelMixin, RetrieveModelMixin, ListModelMixin, CustomGenericViewSet):
"""文件上传
list:
文件列表
文件列表
create:
文件上传
文件上传
"""
permission_classes = [IsAuthenticated]
parser_classes = [MultiPartParser, JSONParser]
queryset = File.objects.all()
serializer_class = FileSerializer
filterset_fields = ['type']
search_fields = ['name']
cache_seconds = 0
def perform_create(self, serializer):
file_obj = self.request.data.get('file')
name = file_obj._name
size = file_obj.size
mime = file_obj.content_type
file_type = File.FILE_TYPE_OTHER
if 'image' in mime:
file_type = File.FILE_TYPE_PIC
elif 'video' in mime:
file_type = File.FILE_TYPE_VIDEO
elif 'audio' in mime:
file_type = File.FILE_TYPE_AUDIO
elif 'application' or 'text' in mime:
file_type = File.FILE_TYPE_DOC
instance = serializer.save(
create_by=self.request.user, name=name, size=size, type=file_type, mime=mime)
instance.path = settings.MEDIA_URL + instance.file.name
instance.save()
class ApkViewSet(MyLoggingMixin, ListModelMixin, CreateModelMixin, GenericViewSet):
perms_map = {'get': '*', 'post': 'apk.upload'}
serializer_class = ApkSerializer
def get_authenticators(self):
if self.request.method == 'GET':
return []
return super().get_authenticators()
def get_permissions(self):
if self.request.method == 'GET':
return [AllowAny()]
return super().get_permissions()
def list(self, request, *args, **kwargs):
"""
获取apk信息
获取apk信息
"""
config = get_sysconfig()
return Response({'version': config['apk']['apk_version'], 'file': config['apk']['apk_file']})
def create(self, request, *args, **kwargs):
"""
上传apk
上传apk
"""
sr = ApkSerializer(data=request.data)
sr.is_valid(raise_exception=True)
vdata = sr.validated_data
update_sysconfig({
"apk":{
"apk_version": vdata['version'],
"apk_file": vdata['file']
}
})
return Response()
class MyScheduleViewSet(ListModelMixin, CreateModelMixin, DestroyModelMixin, CustomGenericViewSet):
perms_map = {'get': '*', 'post': 'myschedule.create', 'delete': 'myschedule.delete'}
serializer_class = MyScheduleSerializer
create_serializer_class = MyScheduleCreateSerializer
queryset = MySchedule.objects.all()
select_related_fields = ['interval', 'crontab']
period_dict = {
"days": "",
"hours": "小时",
"minutes": "分钟",
"seconds": "",
"microseconds": "毫秒"
}
def get_chinese_description(self, type:str = 'interval', data: dict = {}):
"""转换为汉语描述
"""
if type == 'interval':
return f"每隔{data['every']}{data['period']}"
elif type == 'crontab':
locale.setlocale(locale.LC_ALL, 'zh_CN.UTF-8')
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 #不可少
interval_data = vdata.pop('interval_', None)
crontab_data = vdata.pop('crontab_', None)
if vdata['type'] == 10:
interval, _ = IntervalSchedule.objects.get_or_create(**interval_data, defaults=interval_data)
obj = MySchedule(**vdata)
obj.name = self.get_chinese_description('interval', vdata)
obj.interval = interval
obj.save()
elif vdata['type'] == 20:
crontab_data['timezone'] = 'Asia/Shanghai'
crontab, _ = CrontabSchedule.objects.get_or_create(**crontab_data, defaults=crontab_data)
obj = MySchedule(**vdata)
obj.name = self.get_chinese_description('crontab', vdata)
obj.crontab = crontab
obj.save()
class SysBaseConfigView(APIView):
authentication_classes = []
permission_classes = []
read_keys = ['base', 'apk']
def get(self, request, format=None):
"""
获取系统基本信息
获取系统基本信息
"""
config = get_sysconfig()
base_dict = {key: config[key] for key in self.read_keys if key in config}
return Response(base_dict)
class SysConfigView(MyLoggingMixin, APIView):
perms_map = {'get': 'sysconfig.view', 'put': 'sysconfig.update'}
def get(self, request, format=None):
"""
获取config json
获取config json
"""
reload = False
if request.query_params.get('reload', None):
reload = True
return Response(get_sysconfig(reload=reload))
@swagger_auto_schema(request_body=Serializer)
def put(self, request, format=None):
"""
修改config json
修改config json
"""
data = request.data
update_sysconfig(data)
return Response()

0
apps/utils/__init__.py Executable file
View File

3
apps/utils/admin.py Executable file
View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

5
apps/utils/apps.py Executable file
View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class UtilsConfig(AppConfig):
name = 'apps.utils'

5
apps/utils/constants.py Executable file
View File

@ -0,0 +1,5 @@
from django.db import models
EXCLUDE_FIELDS_BASE = ['create_time', 'update_time', 'is_deleted']
EXCLUDE_FIELDS = ['create_time', 'update_time', 'is_deleted', 'create_by', 'update_by']
EXCLUDE_FIELDS_DEPT = EXCLUDE_FIELDS + ['belong_dept']

47
apps/utils/decorators.py Normal file
View File

@ -0,0 +1,47 @@
import logging
from functools import wraps
from apps.utils.tasks import send_mail_task
import traceback
import json
from django.core.cache import cache
from rest_framework.exceptions import ParseError
myLogger = logging.getLogger('log')
def auto_log(name='', raise_exception=True, send_mail=False):
def decorate(func):
@wraps(func)
def wrapper(*args, **kwargs):
try:
real_func = func(*args, **kwargs)
return real_func
except Exception:
myLogger.error(name, exc_info=True)
if send_mail:
send_mail_task.delay(message=traceback.format_exc())
if raise_exception:
raise
return wrapper
return decorate
def idempotent(seconds=4):
def decorate(func):
@wraps(func)
def wrapper(*args, **kwargs):
rdata = args[1].data
rdata['request_userid'] = getattr(args[1], 'user').id
rdata['request_path'] = getattr(args[1], 'path')
hash_k = hash(json.dumps(rdata))
hash_v_e = cache.get(hash_k, None)
if hash_v_e is None:
cache.set(hash_k, 'o', seconds)
real_func = func(*args, **kwargs)
# real_func.render()
# cache.set(hash_k, real_func, seconds)
return real_func
elif hash_v_e == 'o': # 说明请求正在处理
raise ParseError(f'请求忽略,请{seconds}秒后重试')
return wrapper
return decorate

4
apps/utils/errors.py Executable file
View File

@ -0,0 +1,4 @@
SIGN_MAKE_FAIL = {"code": "sign_make_fail", "detail": "签名照生成失败,请重新上传"}
PKS_ERROR = {"code": "pks_error", "detail": "未获取到主键列表"}
WX_REQUEST_ERROR = {"code": "wx_request_error", "detail": "微信接口访问异常"}
XX_REQUEST_ERROR = {"code": "xx_request_error", "detail": "寻息接口访问异常"}

57
apps/utils/exceptions.py Executable file
View File

@ -0,0 +1,57 @@
import traceback
from django.core.exceptions import PermissionDenied, ValidationError
from django.http import Http404
import logging
from rest_framework import exceptions
from rest_framework.response import Response
from rest_framework.views import set_rollback
import json
from apps.utils.tasks import send_mail_task
from django.conf import settings
# 实例化myLogger
myLogger = logging.getLogger('log')
def custom_exception_hander(exc, context):
"""
自定义异常处理
"""
if isinstance(exc, Http404):
exc = exceptions.NotFound()
elif isinstance(exc, PermissionDenied):
exc = exceptions.PermissionDenied()
elif isinstance(exc, ValidationError):
exc = exceptions.ValidationError(exc.message)
request_id = getattr(context['request'], 'request_id', None)
if isinstance(exc, exceptions.APIException):
headers = {}
if getattr(exc, 'auth_header', None):
headers['WWW-Authenticate'] = exc.auth_header
if getattr(exc, 'wait', None):
headers['Retry-After'] = '%d' % exc.wait
data = {'err_detail': exc.detail}
if isinstance(exc.detail, dict):
data['err_code'] = exc.default_code
data['err_msg'] = json.dumps(exc.detail, ensure_ascii=False) if 'detail' not in exc.detail else exc.detail['detail'] # 取一部分方便前端alert
elif isinstance(exc.detail, list):
data['err_code'] = exc.default_code
data['err_msg'] = json.dumps(exc.detail, ensure_ascii=False)
else:
data = {'err_msg': exc.detail, 'err_code': exc.get_codes()}
set_rollback()
data['request_id'] = request_id
status = exc.status_code
if status not in [401, 404]:
status = 400
return Response(data, status=status, headers=headers)
args = (request_id, traceback.format_exc())
err_detail = f"{args[0]}-{args[1]}"
myLogger.error(err_detail)
if settings.DEBUG is False:
send_mail_task.delay(message=err_detail) # 500邮件通知到开发人员
return Response(data={'err_code': 'server_error', 'err_detail': err_detail if settings.DEBUG else None, 'err_msg': '服务器错误'}, status=500)

165
apps/utils/export.py Normal file
View File

@ -0,0 +1,165 @@
import xlwt
import time
import os
from django.conf import settings
from datetime import datetime
from openpyxl import Workbook, styles
from openpyxl.drawing.image import Image
from openpyxl.utils import get_column_letter, column_index_from_string
def len_byte(value):
# 获取字符串长度一个中文的长度为2
length = len(value)
utf8_length = len(value.encode('utf-8'))
length = (utf8_length - length) / 2 + length
return int(length)
def export_excel(field_data: list, data: list, FileName: str):
"""
Excel导出
:param data: 数据源
:param field_data: 首行数据源表头
:param file_path: 文件保存路径默认保存在media路径
:param FileName: 文件保存名字
:return:返回文件的下载url完整路径
"""
wbk = xlwt.Workbook(encoding='utf-8')
sheet = wbk.add_sheet('Sheet1', cell_overwrite_ok=True) # 第二参数用于确认同一个cell单元是否可以重设值。
style = xlwt.XFStyle() # 赋值style为XFStyle(),初始化样式
# 设置居中
wbk.set_colour_RGB(0x23, 0, 60, 139)
xlwt.add_palette_colour("custom_colour_35", 0x23)
tab_al = xlwt.Alignment()
tab_al.horz = 0x02 # 设置水平居中
tab_al.vert = 0x01 # 设置垂直居中
# 设置表头单元格背景颜色
tab_pattern = xlwt.Pattern() # 创建一个模式
tab_pattern.pattern = xlwt.Pattern.SOLID_PATTERN # 设置其模式为实型
tab_pattern.pattern_fore_colour = 55
# 设置单元格内字体样式
tab_fnt = xlwt.Font() # 创建一个文本格式,包括字体、字号和颜色样式特性
tab_fnt.height = 200
default_width = 14
tab_fnt.name = u'楷体' # 设置其字体为微软雅黑
tab_fnt.colour_index = 1 # 设置其字体颜色
# 设置单元格下框线样式
tab_borders = xlwt.Borders()
tab_borders.left = xlwt.Borders.THIN
tab_borders.right = xlwt.Borders.THIN
tab_borders.top = xlwt.Borders.THIN
tab_borders.bottom = xlwt.Borders.THIN
tab_borders.left_colour = 23
tab_borders.right_colour = 23
tab_borders.bottom_colour = 23
tab_borders.top_colour = 23
# 把数据写入excel中
# 所有表格单元格样式
# 先生成表头
style.alignment = tab_al # 设置居中
style.pattern = tab_pattern # 设置表头单元格背景颜色
style.font = tab_fnt # 设置单元格内字体样式
style.borders = tab_borders
for index, ele in enumerate(field_data):
sheet.write_merge(0, 0, index, index, ele, style) # (列开始, 列结束, 行开始, 行结束, '数据内容')
# 确定栏位宽度
col_width = []
for index, ele in enumerate(data):
for inx, values in enumerate(ele):
if index == 0:
col_width.append(len_byte(str(values)))
else:
if col_width[inx] < len_byte(str(values)):
col_width[inx] = len_byte(str(values))
# 设置栏位宽度栏位宽度小于10时候采用默认宽度
for i in range(len(col_width)):
if col_width[i] > 10:
width = col_width[i] if col_width[i] < 36 else 36
sheet.col(i).width = 256 * (width + 6)
else:
sheet.col(i).width = 256 * (default_width)
row = 1
# 内容背景颜色
left_pattern = xlwt.Pattern() # 创建一个模式
left_pattern.pattern = xlwt.Pattern.SOLID_PATTERN # 设置其模式为实型
left_pattern.pattern_fore_colour = 1
# 设置单元格内字体样式
left_fnt = xlwt.Font() # 创建一个文本格式,包括字体、字号和颜色样式特性
left_fnt.height = 200
left_fnt.name = u'楷体' # 设置其字体为微软雅黑
left_fnt.colour_index = 0 # 设置其字体颜色
left_style = style
left_style.pattern = left_pattern
left_style.font = left_fnt
for results in data:
for index, values in enumerate(results):
sheet.write(row, index, label=values, style=left_style)
row += 1
FileNameF = FileName + datetime.now().strftime('%Y%m%d%H%M%S') + '.xls'
path = '/media/temp/'
pathRoot = settings.BASE_DIR + path
if not os.path.exists(pathRoot):
os.makedirs(pathRoot)
path_name = os.path.join(pathRoot, FileNameF)
wbk.save(path_name)
return path + FileNameF
def export_excel_img(field_data: list, data: list, FileName: str):
"""
带有image的Excel导出
:param data: 数据源
:param field_data: 首行数据源表头{'name':'', 'type':''}
:param img_field_indexs: 图片字段名index列表
:param file_path: 文件保存路径默认保存在media路径
:param FileName: 文件保存名字
:return:返回文件的下载url完整路径
"""
wb = Workbook()
ws = wb.active
imgs = []
for index, value in enumerate(field_data):
cell = ws.cell(column=index+1, row=1)
cell.value = value['name']
cell.font = styles.Font(bold=True)
letter = get_column_letter(index+1)
value['letter'] = letter
ws.column_dimensions[letter].width = 10 # 修改列宽
if value['type'] == 'img':
ws.column_dimensions[letter].width = 15 # 修改列宽
for i1, v1 in enumerate(data):
for i2, v2 in enumerate(v1):
cell = ws.cell(column=i2+1, row=i1+2)
if v2 and field_data[i2]['type'] == 'img':
ws.row_dimensions[i1+2].height = 70
try:
img = Image(settings.BASE_DIR + v2)
img.width, img.height = (90, 90)
imgs.append((img, field_data[i2]['letter'] + str(i1+2)))
except: # 这里先不做处理
pass
else:
cell.value = v2
for i in imgs:
ws.add_image(i[0], i[1])
FileNameF = FileName + datetime.now().strftime('%Y%m%d%H%M%S') + '.xlsx'
path = '/temp/'
pathRoot = settings.BASE_DIR + path
if not os.path.exists(pathRoot):
os.makedirs(pathRoot)
path_name = os.path.join(pathRoot, FileNameF)
wb.save(path_name)
return path + FileNameF

10
apps/utils/fields.py Normal file
View File

@ -0,0 +1,10 @@
from django.conf import settings
from rest_framework import serializers
class MyFilePathField(serializers.CharField):
def to_representation(self, value):
if 'http' in value:
return str(value)
return settings.BASE_URL + str(value)

0
apps/utils/filters.py Executable file
View File

31
apps/utils/img.py Normal file
View File

@ -0,0 +1,31 @@
import os
from PIL import Image
def compress_image(infile, outfile='', kb=40, quality=80):
"""不改变图片尺寸压缩到指定大小
:param infile: 压缩源文件
:param outfile: 压缩文件保存地址
:param kb: 压缩目标, KB
:param step: 每次调整的压缩比率
:param quality: 初始压缩比率
:return: 压缩文件地址压缩文件大小
"""
o_size = os.path.getsize(infile)/1024
if o_size <= kb:
return infile
if outfile == '':
path, end = infile.split('.')
outfile = path + '_compressed.' + end
im = Image.open(infile)
im.save(outfile, quality=quality)
while os.path.getsize(outfile) / 1024 > kb:
imx = Image.open(outfile)
# Resize the image using the same aspect ratio to reduce the file size
width, height = imx.size
new_width = int(width * 0.9) # You can adjust the scaling factor
new_height = int(height * 0.9)
imx = imx.resize((new_width, new_height), Image.ANTIALIAS)
imx.save(outfile, quality=quality)
# quality -= step
return outfile, os.path.getsize(outfile) / 1024

23
apps/utils/middlewares.py Normal file
View File

@ -0,0 +1,23 @@
from rest_framework_simplejwt.authentication import JWTAuthentication
from asgiref.sync import sync_to_async
@sync_to_async
def _get_user(token: str):
jwt = JWTAuthentication()
return jwt.get_user(jwt.get_validated_token(token))
class TokenAuthMiddleware:
def __init__(self, app) -> None:
self.app = app
async def __call__(self, scope, receive, send):
# Look up user from query string (you should also do things like
# checking if it is a valid user ID, or if scope["user"] is already
# populated).
from urllib.parse import parse_qs
token = parse_qs(str(scope["query_string"], 'UTF-8')).get('token', [None])[0]
if token:
user = await _get_user(token)
if user:
scope['user'] = user
return await self.app(scope, receive, send)

389
apps/utils/mixins.py Executable file
View File

@ -0,0 +1,389 @@
import uuid
from rest_framework.mixins import CreateModelMixin, UpdateModelMixin, DestroyModelMixin
import ast
import ipaddress
import traceback
from apps.ops.models import DrfRequestLog
from django.db import connection
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 apps.utils.serializers import PkSerializer
# 实例化myLogger
myLogger = logging.getLogger('log')
class CreateUpdateModelAMixin:
"""
业务用基本表A用
"""
def perform_create(self, serializer):
serializer.save(create_by=self.request.user)
def perform_update(self, serializer):
serializer.save(update_by=self.request.user)
class CreateUpdateModelBMixin:
"""
业务用基本表B用
"""
def perform_create(self, serializer):
serializer.save(create_by=self.request.user, belong_dept=self.request.user.dept)
def perform_update(self, serializer):
serializer.save(update_by=self.request.user)
class CreateUpdateCustomMixin:
"""
整合
"""
def perform_create(self, serializer):
if hasattr(self.queryset.model, 'belong_dept'):
serializer.save(create_by=self.request.user, belong_dept=self.request.user.dept)
else:
serializer.save(create_by=self.request.user)
def perform_update(self, serializer):
serializer.save(update_by=self.request.user)
class CustomCreateModelMixin(CreateModelMixin):
def perform_create(self, serializer):
if hasattr(self.queryset.model, 'belong_dept'):
serializer.save(create_by=self.request.user, belong_dept=self.request.user.dept)
else:
serializer.save(create_by=self.request.user)
class CustomUpdateModelMixin(UpdateModelMixin):
def perform_update(self, serializer):
serializer.save(update_by=self.request.user)
class BulkCreateModelMixin(CreateModelMixin):
def after_bulk_create(self, objs):
pass
def create(self, request, *args, **kwargs):
"""创建(支持批量)
创建(支持批量)
"""
rdata = request.data
many = False
if isinstance(rdata, list):
many = True
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)
class BulkUpdateModelMixin(UpdateModelMixin):
def after_bulk_update(self, objs):
pass
def partial_update(self, request, *args, **kwargs):
"""部分更新(支持批量)
部分更新(支持批量)
"""
kwargs['partial'] = True
return self.update(request, *args, **kwargs)
def update(self, request, *args, **kwargs):
"""更新(支持批量)
更新(支持批量)
"""
partial = kwargs.pop('partial', False)
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
if kwargs[lookup_url_kwarg] == 'bulk': # 如果是批量操作
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)
else:
raise ParseError('提交数据非列表')
return Response(objs)
else:
instance = self.get_object()
serializer = self.get_serializer(instance, data=request.data, partial=partial)
serializer.is_valid(raise_exception=True)
self.perform_update(serializer)
return Response(serializer.data)
class BulkDestroyModelMixin(DestroyModelMixin):
@swagger_auto_schema(request_body=PkSerializer)
def destroy(self, request, *args, **kwargs):
"""删除(支持批量)
删除(支持批量和硬删除(需管理员权限))
"""
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
if kwargs[lookup_url_kwarg] == 'bulk': # 如果是批量操作
queryset = self.filter_queryset(self.get_queryset())
ids = request.data.get('ids', None)
soft = request.data.get('soft', True)
if not soft and not request.user.is_superuser:
raise ParseError('非管理员不支持物理删除')
if ids:
if soft is True:
queryset.filter(id__in=ids).delete()
elif soft is False:
try:
queryset.model.objects.get_queryset(
all=True).filter(id__in=ids).delete(soft=False)
except Exception:
queryset.filter(id__in=ids).delete()
return Response(status=204)
else:
raise ValidationError(**PKS_ERROR)
else:
instance = self.get_object()
self.perform_destroy(instance)
return Response(status=204)
class MyLoggingMixin(object):
"""Mixin to log requests"""
CLEANED_SUBSTITUTE = "********************"
# logging_methods = "__all__"
logging_methods = '__all__'
sensitive_fields = {}
def __init__(self, *args, **kwargs):
assert isinstance(
self.CLEANED_SUBSTITUTE, str
), "CLEANED_SUBSTITUTE must be a string."
super().__init__(*args, **kwargs)
def initial(self, request, *args, **kwargs):
request_id = uuid.uuid4()
self.log = {"requested_at": now(), "id": request_id}
setattr(request, 'request_id', request_id)
if not getattr(self, "decode_request_body", False):
self.log["data"] = ""
else:
self.log["data"] = self._clean_data(request.body)
super().initial(request, *args, **kwargs)
try:
# Accessing request.data *for the first time* parses the request body, which may raise
# ParseError and UnsupportedMediaType exceptions. It's important not to swallow these,
# as (depending on implementation details) they may only get raised this once, and
# DRF logic needs them to be raised by the view for error handling to work correctly.
data = self.request.data.dict()
except AttributeError:
data = self.request.data
self.log["data"] = self._clean_data(data)
def handle_exception(self, exc):
response = super().handle_exception(exc)
self.log["errors"] = traceback.format_exc()
return response
def finalize_response(self, request, response, *args, **kwargs):
response = super().finalize_response(
request, response, *args, **kwargs
)
# Ensure backward compatibility for those using _should_log hook
should_log = (
self._should_log if hasattr(self, "_should_log") else self.should_log
)
if should_log(request, response):
if (connection.settings_dict.get("ATOMIC_REQUESTS") and
getattr(response, "exception", None) and connection.in_atomic_block):
# response with exception (HTTP status like: 401, 404, etc)
# pointwise disable atomic block for handle log (TransactionManagementError)
connection.set_rollback(True)
connection.set_rollback(False)
if response.streaming:
rendered_content = None
elif hasattr(response, "rendered_content"):
rendered_content = response.rendered_content
else:
rendered_content = response.getvalue()
self.log.update(
{
"remote_addr": self._get_ip_address(request),
"view": self._get_view_name(request),
"view_method": self._get_view_method(request),
"path": self._get_path(request),
"host": request.get_host(),
"method": request.method,
"query_params": self._clean_data(request.query_params.dict()),
"user": self._get_user(request),
"response_ms": self._get_response_ms(),
"response": self._clean_data(rendered_content),
"status_code": response.status_code,
"agent": self._get_agent(request),
}
)
try:
self.handle_log()
except Exception:
# ensure that all exceptions raised by handle_log
# doesn't prevent API call to continue as expected
myLogger.exception("Logging API call raise exception!")
return response
def handle_log(self):
"""
Hook to define what happens with the log.
Defaults on saving the data on the db.
"""
DrfRequestLog(**self.log).save()
def _get_path(self, request):
"""Get the request path and truncate it"""
return request.path
def _get_ip_address(self, request):
"""Get the remote ip address the request was generated from."""
ipaddr = request.META.get("HTTP_X_FORWARDED_FOR", None)
if ipaddr:
ipaddr = ipaddr.split(",")[0]
else:
ipaddr = request.META.get("REMOTE_ADDR", "")
# Account for IPv4 and IPv6 addresses, each possibly with port appended. Possibilities are:
# <ipv4 address>
# <ipv6 address>
# <ipv4 address>:port
# [<ipv6 address>]:port
# Note that ipv6 addresses are colon separated hex numbers
possibles = (ipaddr.lstrip("[").split("]")[0], ipaddr.split(":")[0])
for addr in possibles:
try:
return str(ipaddress.ip_address(addr))
except ValueError:
pass
return ipaddr
def _get_view_name(self, request):
"""Get view name."""
method = request.method.lower()
try:
attributes = getattr(self, method)
return (
type(attributes.__self__).__module__ + "." + type(attributes.__self__).__name__
)
except AttributeError:
return None
def _get_view_method(self, request):
"""Get view method."""
if hasattr(self, "action"):
return self.action or None
return request.method.lower()
def _get_user(self, request):
"""Get user."""
user = request.user
if user.is_anonymous:
return None
return user
def _get_agent(self, request):
"""Get os string"""
return str(parse(request.META['HTTP_USER_AGENT']))
def _get_response_ms(self):
"""
Get the duration of the request response cycle is milliseconds.
In case of negative duration 0 is returned.
"""
response_timedelta = now() - self.log["requested_at"]
response_ms = int(response_timedelta.total_seconds() * 1000)
return max(response_ms, 0)
def should_log(self, request, response):
"""
Method that should return a value that evaluated to True if the request should be logged.
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])
def _clean_data(self, data):
"""
Clean a dictionary of data of potentially sensitive info before
sending to the database.
Function based on the "_clean_credentials" function of django
(https://github.com/django/django/blob/stable/1.11.x/django/contrib/auth/__init__.py#L50)
Fields defined by django are by default cleaned with this function
You can define your own sensitive fields in your view by defining a set
eg: sensitive_fields = {'field1', 'field2'}
"""
if isinstance(data, bytes):
data = data.decode(errors="replace")
if isinstance(data, list):
return [self._clean_data(d) for d in data]
if isinstance(data, dict):
SENSITIVE_FIELDS = {
"api",
"token",
"key",
"secret",
"password",
"signature",
}
data = dict(data)
if self.sensitive_fields:
SENSITIVE_FIELDS = SENSITIVE_FIELDS | {
field.lower() for field in self.sensitive_fields
}
for key, value in data.items():
try:
value = ast.literal_eval(value)
except (ValueError, SyntaxError):
pass
if isinstance(value, (list, dict)):
data[key] = self._clean_data(value)
if key.lower() in SENSITIVE_FIELDS:
data[key] = self.CLEANED_SUBSTITUTE
return data

191
apps/utils/models.py Executable file
View File

@ -0,0 +1,191 @@
import time
import django.utils.timezone as timezone
from django.db import models
from django.db.models.query import QuerySet
from apps.utils.snowflake import idWorker
from django.db import IntegrityError
# 自定义软删除查询基类
class SoftDeletableQuerySetMixin(object):
'''
QuerySet for SoftDeletableModel. Instead of removing instance sets
its ``is_deleted`` field to True.
'''
def delete(self, soft=True):
'''
Soft delete objects from queryset (set their ``is_deleted``
field to True)
'''
if soft:
self.update(is_deleted=True)
else:
return super(SoftDeletableQuerySetMixin, self).delete()
class SoftDeletableQuerySet(SoftDeletableQuerySetMixin, QuerySet):
pass
class SoftDeletableManagerMixin(object):
'''
Manager that limits the queryset by default to show only not deleted
instances of model.
'''
_queryset_class = SoftDeletableQuerySet
def get_queryset(self, all=False):
'''
Return queryset limited to not deleted entries.
'''
kwargs = {'model': self.model, 'using': self._db}
if hasattr(self, '_hints'):
kwargs['hints'] = self._hints
if all:
return self._queryset_class(**kwargs)
return self._queryset_class(**kwargs).filter(is_deleted=False)
class SoftDeletableManager(SoftDeletableManagerMixin, models.Manager):
pass
class BaseModel(models.Model):
"""
基本表
"""
id = models.CharField(max_length=20, primary_key=True,
editable=False, verbose_name='主键ID', help_text='主键ID')
create_time = models.DateTimeField(
default=timezone.now, verbose_name='创建时间', help_text='创建时间')
update_time = models.DateTimeField(
auto_now=True, verbose_name='修改时间', help_text='修改时间')
is_deleted = models.BooleanField(
default=False, verbose_name='删除标记', help_text='删除标记')
class Meta:
abstract = True
def save(self, *args, **kwargs) -> None:
# 出现了雪花ID重复, 先这样异常处理一下;已经修改了snowflake, 以防万一, 这里依然保留
gen_id = False
if not self.id:
gen_id = True
self.id = idWorker.get_id()
try:
return super().save(*args, **kwargs)
except IntegrityError as e:
if gen_id:
time.sleep(0.01)
self.id = idWorker.get_id()
return super().save(*args, **kwargs)
raise e
class SoftModel(BaseModel):
"""
软删除基本表
"""
class Meta:
abstract = True
objects = SoftDeletableManager()
def delete(self, using=None, soft=True, update_by=None, *args, **kwargs):
'''
这里需要真删除的话soft=False即可
'''
if soft:
self.is_deleted = True
self.update_by = update_by
self.save(using=using)
else:
return super(SoftModel, self).delete(using=using, *args, **kwargs)
class CommonAModel(SoftModel):
"""
业务用基本表A,包含create_by, update_by字段
"""
create_by = models.ForeignKey(
'system.user', null=True, blank=True, on_delete=models.SET_NULL,
verbose_name='创建人', related_name='%(class)s_create_by')
update_by = models.ForeignKey(
'system.user', null=True, blank=True, on_delete=models.SET_NULL,
verbose_name='最后编辑人', related_name='%(class)s_update_by')
# delete_by = models.ForeignKey(
# 'system.user', null=True, blank=True, on_delete=models.SET_NULL,
# verbose_name='删除人', related_name='%(class)s_delete_by')
class Meta:
abstract = True
class CommonBModel(SoftModel):
"""
业务用基本表B,包含create_by, update_by, belong_dept字段
"""
create_by = models.ForeignKey(
'system.user', null=True, blank=True, on_delete=models.SET_NULL,
verbose_name='创建人', related_name='%(class)s_create_by')
update_by = models.ForeignKey(
'system.user', null=True, blank=True, on_delete=models.SET_NULL,
verbose_name='最后编辑人', related_name='%(class)s_update_by')
# delete_by = models.ForeignKey(
# 'system.user', null=True, blank=True, on_delete=models.SET_NULL,
# verbose_name='删除人', related_name='%(class)s_delete_by')
belong_dept = models.ForeignKey(
'system.dept', null=True, blank=True, on_delete=models.SET_NULL,
verbose_name='所属部门', related_name='%(class)s_belong_dept')
class Meta:
abstract = True
class CommonADModel(BaseModel):
"""
业务用基本表A, 物理删除, 包含create_by, update_by字段
"""
create_by = models.ForeignKey(
'system.user', null=True, blank=True, on_delete=models.SET_NULL,
verbose_name='创建人', related_name='%(class)s_create_by')
update_by = models.ForeignKey(
'system.user', null=True, blank=True, on_delete=models.SET_NULL,
verbose_name='最后编辑人', related_name='%(class)s_update_by')
# delete_by = models.ForeignKey(
# 'system.user', null=True, blank=True, on_delete=models.SET_NULL,
# verbose_name='删除人', related_name='%(class)s_delete_by')
class Meta:
abstract = True
class CommonBDModel(BaseModel):
"""
业务用基本表B, 物理删除, 包含create_by, update_by, belong_dept字段
"""
create_by = models.ForeignKey(
'system.user', null=True, blank=True, on_delete=models.SET_NULL,
verbose_name='创建人', related_name='%(class)s_create_by')
update_by = models.ForeignKey(
'system.user', null=True, blank=True, on_delete=models.SET_NULL,
verbose_name='最后编辑人', related_name='%(class)s_update_by')
# delete_by = models.ForeignKey(
# 'system.user', null=True, blank=True, on_delete=models.SET_NULL,
# verbose_name='删除人', related_name='%(class)s_delete_by')
belong_dept = models.ForeignKey(
'system.dept', null=True, blank=True, on_delete=models.SET_NULL,
verbose_name='所属部门', related_name='%(class)s_belong_dept')
class Meta:
abstract = True
# class Smslog(BaseModel):
# """
# 短信发送记录表
# """
# phone = models.CharField('号码')

13
apps/utils/my_rsa.py Normal file
View File

@ -0,0 +1,13 @@
import base64
from Crypto.PublicKey import RSA
from Crypto.Hash import SHA
from Crypto.Signature import PKCS1_v1_5 as PKCS1_signature
from Crypto.Cipher import PKCS1_v1_5 as PKCS1_cipher
def encrypt_data(msg, pub_key):
pub_key = '-----BEGIN RSA PUBLIC KEY-----\n'+pub_key+'\n-----END RSA PUBLIC KEY-----'
public_key = RSA.importKey(pub_key)
cipher = PKCS1_cipher.new(public_key)
encrypt_text = base64.b64encode(cipher.encrypt(bytes(msg.encode("utf8"))))
return encrypt_text.decode('utf-8')

100
apps/utils/myconfig.py Normal file
View File

@ -0,0 +1,100 @@
from configparser import ConfigParser
import os
from django.conf import settings
class MyConfig:
def __init__(self, config_file, encode="utf-8"):
if os.path.exists(config_file):
self.__cfg_file = config_file
else:
# 此处做其他异常处理或创建配置文件操作
raise OSError("配置文件不存在!")
self.__config = ConfigParser()
self.__config.read(config_file, encoding=encode)
def get_sections(self):
"""获取配置文件的所有section
"""
return self.__config.sections()
def get_options(self, section_name):
"""获取指定section的所有option
"""
if self.__config.has_section(section_name):
return self.__config.options(section_name)
else:
raise ValueError(section_name)
def get_option_value(self, section_name, option_name):
"""获取指定section下option的value值
"""
if self.__config.has_option(section_name, option_name):
return self.__config.get(section_name, option_name)
def get_all_items(self, section_name, to_dict: bool=True):
"""获取指定section下的option的键值对
"""
if self.__config.has_section(section_name):
if to_dict:
return dict(self.__config.items(section_name))
return self.__config.items(section_name)
def print_all_items(self):
"""打印配置文件所有的值
"""
for section in self.get_sections():
print("[" + section + "]")
for K, V in self.__config.items(section):
print(K + "=" + V)
def add_new_section(self, new_section):
"""增加section
"""
if not self.__config.has_section(new_section):
self.__config.add_section(new_section)
self.__update_cfg_file()
def add_option(self, section_name, option_key, option_value):
"""增加指定section下option
"""
if self.__config.has_section(section_name):
self.__config.set(section_name, option_key, option_value)
self.__update_cfg_file()
def del_section(self, section_name):
"""删除指定section
"""
if self.__config.has_section(section_name):
self.__config.remove_section(section_name)
self.__update_cfg_file()
def del_option(self, section_name, option_name):
"""删除指定section下的option
"""
if self.__config.has_option(section_name, option_name):
self.__config.remove_option(section_name, option_name)
self.__update_cfg_file()
def update_section(self, section_name, option_dict: dict):
"""批量更新指定section下的option的值
"""
if self.__config.has_section(section_name):
for k, v in option_dict:
self.__config.set(section_name, k, v)
self.__update_cfg_file()
def update_option_value(self, section_name, option_key, option_value):
"""更新指定section下的option的值
"""
if self.__config.has_option(section_name, option_key):
self.add_option(section_name, option_key, option_value)
# 私有方法:操作配置文件的增删改时,更新配置文件的数据
def __update_cfg_file(self):
with open(self.__cfg_file, "w") as f:
self.__config.write(f)
myConfig = MyConfig(os.path.join(settings.BASE_DIR, 'server/conf.ini'))

17
apps/utils/pagination.py Executable file
View File

@ -0,0 +1,17 @@
from rest_framework.pagination import PageNumberPagination
from rest_framework.exceptions import ParseError
class MyPagination(PageNumberPagination):
"""
自定义分页/传入page为0则不分页
"""
page_size = 10
page_size_query_param = 'page_size'
def paginate_queryset(self, queryset, request, view=None):
if request.query_params.get('pageoff', None) or request.query_params.get('page', None) == '0':
if queryset.count() < 800:
return None
raise ParseError('单次请求数据量大,请分页获取')
return super().paginate_queryset(queryset, request, view=view)

140
apps/utils/permission.py Executable file
View File

@ -0,0 +1,140 @@
from django.core.cache import cache
from rest_framework.permissions import BasePermission
from apps.utils.queryset import get_child_queryset2
from apps.system.models import DataFilter, Dept, Permission, PostRole, UserPost
from django.db.models.query import QuerySet
ALL_PERMS = [
]
def get_user_perms_map(user):
"""
获取权限字典,可用redis存取(包括功能和数据权限)
"""
user_perms_map = {}
if user.is_superuser:
for perm in Permission.objects.all():
if perm.codes:
for code in perm.codes:
user_perms_map[code] = {}
else:
objs = UserPost.objects.filter(user=user).exclude(post=None)
for i in objs:
dept_id = str(i.dept.id)
for pr in PostRole.objects.filter(post=i.post):
"""
岗位角色
"""
for perm in Permission.objects.filter(role_perms=pr.role):
if perm.codes:
for code in perm.codes:
if code in user_perms_map:
data_range = user_perms_map[code].get(
dept_id, -1)
if pr.data_range < data_range:
user_perms_map[code][dept_id] = pr.data_range
else:
user_perms_map[code] = {dept_id: pr.data_range}
cache.set('perms_' + str(user.id), user_perms_map, timeout=300)
return user_perms_map
class RbacPermission(BasePermission):
"""
基于角色的权限校验类
"""
def has_permission(self, request, view):
"""
权限校验逻辑
:param request:
:param view:
:return:
"""
if not hasattr(view, 'perms_map'):
return True
user_perms_map = cache.get('perms_' + request.user.id, None)
if user_perms_map is None:
user_perms_map = get_user_perms_map(request.user)
if isinstance(user_perms_map, dict):
perms_map = view.perms_map
_method = request._request.method.lower()
if perms_map:
for key in perms_map:
if key == _method or key == '*':
if perms_map[key] in user_perms_map or perms_map[key] == '*':
return True
return False
return False
class RbacDataMixin:
"""
数据权限控权返回的queryset
在必须的View下继承
需要控数据权限的表需有belong_dept, create_by, update_by字段(部门, 创建人, 编辑人)
带性能优化
此处对性能有较大影响,根据业务需求进行修改或取舍
"""
def get_queryset(self):
assert self.queryset is not None, (
"'%s' should either include a `queryset` attribute, "
"or override the `get_queryset()` method."
% self.__class__.__name__
)
queryset = self.queryset
if isinstance(queryset, QuerySet):
# Ensure queryset is re-evaluated on each request.
queryset = queryset.all()
if hasattr(self.get_serializer_class(), 'setup_eager_loading'):
queryset = self.get_serializer_class().setup_eager_loading(queryset) # 性能优化
if self.request.user.is_superuser:
return queryset
if hasattr(queryset.model, 'belong_dept'):
user = self.request.user
user_perms_map = cache.get('perms_' + user.id, None)
if user_perms_map is None:
user_perms_map = get_user_perms_map(self.request.user)
if isinstance(user_perms_map, dict):
if hasattr(self.view, 'perms_map'):
perms_map = self.view.perms_map
action_str = perms_map.get(
self.request._request.method.lower(), None)
if '*' in perms_map:
return queryset
elif action_str == '*':
return queryset
elif action_str in user_perms_map:
new_queryset = queryset.none()
for dept_id, data_range in user_perms_map[action_str].items:
dept = Dept.objects.get(id=dept_id)
if data_range == DataFilter.ALL:
return queryset
elif data_range == DataFilter.SAMELEVE_AND_BELOW:
if dept.parent:
belong_depts = get_child_queryset2(
dept.parent)
else:
belong_depts = get_child_queryset2(dept)
queryset = queryset.filter(
belong_dept__in=belong_depts)
elif data_range == DataFilter.THISLEVEL_AND_BELOW:
belong_depts = get_child_queryset2(dept)
queryset = queryset.filter(
belong_dept__in=belong_depts)
elif data_range == DataFilter.THISLEVEL:
queryset = queryset.filter(belong_dept=dept)
elif data_range == DataFilter.MYSELF:
queryset = queryset.filter(create_by=user)
new_queryset = new_queryset | queryset
return new_queryset
else:
return queryset.none()
return queryset

71
apps/utils/queryset.py Executable file
View File

@ -0,0 +1,71 @@
from django.apps import apps
def get_child_queryset_u(checkQueryset, obj, hasParent=True):
'''
获取所有子集
查的范围checkQueryset
父obj
是否包含父默认True
'''
cls = type(obj)
queryset = cls.objects.none()
fatherQueryset = cls.objects.filter(pk=obj.id)
if hasParent:
queryset = queryset | fatherQueryset
child_queryset = checkQueryset.filter(parent=obj)
while child_queryset:
queryset = queryset | child_queryset
child_queryset = checkQueryset.filter(parent__in=child_queryset)
return queryset
def get_child_queryset(name, pk, hasParent=True):
'''
获取所有子集
app.model名称
Id
是否包含父默认True
'''
app, model = name.split('.')
cls = apps.get_model(app, model)
queryset = cls.objects.none()
fatherQueryset = cls.objects.filter(pk=pk)
if fatherQueryset.exists():
if hasParent:
queryset = queryset | fatherQueryset
child_queryset = cls.objects.filter(parent=fatherQueryset.first())
while child_queryset:
queryset = queryset | child_queryset
child_queryset = cls.objects.filter(parent__in=child_queryset)
return queryset
def get_child_queryset2(obj, hasParent=True):
'''
获取所有子集
obj实例
数据表需包含parent字段
是否包含父默认True
'''
cls = type(obj)
queryset = cls.objects.none()
fatherQueryset = cls.objects.filter(pk=obj.id)
if hasParent:
queryset = queryset | fatherQueryset
child_queryset = cls.objects.filter(parent=obj)
while child_queryset:
queryset = queryset | child_queryset
child_queryset = cls.objects.filter(parent__in=child_queryset)
return queryset
def get_parent_queryset(obj, hasSelf=True):
cls = type(obj)
ids = []
if hasSelf:
ids.append(obj.id)
while obj.parent:
obj = obj.parent
ids.append(obj.id)
return cls.objects.filter(id__in=ids)

108
apps/utils/request.py Executable file
View File

@ -0,0 +1,108 @@
import json
from user_agents import parse
def get_request_ip(request):
"""
获取请求IP
"""
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR', '')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[-1].strip()
return ip
ip = request.META.get('REMOTE_ADDR', '') or getattr(request, 'request_ip', None)
return ip or 'unknown'
def get_request_data(request):
"""
获取请求参数
"""
request_data = getattr(request, 'request_data', None)
if request_data:
return request_data
data: dict = {**request.GET.dict(), **request.POST.dict()}
if not data:
try:
body = request.body
if body:
data = json.loads(body)
except Exception:
pass
if not isinstance(data, dict):
data = {'data': data}
return data
def get_request_path(request, *args, **kwargs):
"""
获取请求路径
"""
request_path = getattr(request, 'request_path', None)
if request_path:
return request_path
values = []
for arg in args:
if len(arg) == 0:
continue
if isinstance(arg, str):
values.append(arg)
elif isinstance(arg, (tuple, set, list)):
values.extend(arg)
elif isinstance(arg, dict):
values.extend(arg.values())
if len(values) == 0:
return request.path
path: str = request.path
for value in values:
path = path.replace('/' + value, '/' + '{id}')
return path
def get_browser(request, ):
"""
获取浏览器名
:param request:
:param args:
:param kwargs:
:return:
"""
ua_string = request.META['HTTP_USER_AGENT']
user_agent = parse(ua_string)
return user_agent.get_browser()
def get_os(request, ):
"""
获取操作系统
:param request:
:param args:
:param kwargs:
:return:
"""
ua_string = request.META['HTTP_USER_AGENT']
user_agent = parse(ua_string)
return user_agent.get_os()
def get_verbose_name(queryset=None, view=None, model=None):
"""
获取 verbose_name
:param request:
:param view:
:return:
"""
try:
if queryset and hasattr(queryset, 'model'):
model = queryset.model
elif view and hasattr(view.get_queryset(), 'model'):
model = view.get_queryset().model
elif view and hasattr(view.get_serializer(), 'Meta') and hasattr(view.get_serializer().Meta, 'model'):
model = view.get_serializer().Meta.model
if model:
return getattr(model, '_meta').verbose_name
else:
model = queryset.model._meta.verbose_name
except Exception:
pass
return model if model else ""

55
apps/utils/serializers.py Executable file
View File

@ -0,0 +1,55 @@
from rest_framework import serializers
from django_restql.mixins import DynamicFieldsMixin
from rest_framework.fields import empty
from rest_framework.request import Request
class PkSerializer(serializers.Serializer):
ids = serializers.ListField(child=serializers.CharField(max_length=20), label="主键ID列表")
soft = serializers.BooleanField(label="是否软删除", default=True, required=False)
class GenSignatureSerializer(serializers.Serializer):
path = serializers.CharField(label="图片地址")
class CustomModelSerializer(DynamicFieldsMixin, serializers.ModelSerializer):
"""
自定义serializer/包含创建和新增字段处理
"""
def __init__(self, instance=None, data=empty, request=None, **kwargs):
super().__init__(instance, data, **kwargs)
self.request: Request = request or self.context.get('request', None)
def create(self, validated_data):
if self.request:
if getattr(self.request, 'user', None):
if getattr(self.Meta.model, 'create_by', None):
validated_data['create_by'] = self.request.user
validated_data['update_by'] = self.request.user
if 'belong_dept' in validated_data:
pass
elif getattr(self.request.user, 'belong_dept', None):
if hasattr(self.Meta.model, 'belong_dept'):
validated_data['belong_dept'] = self.request.user.belong_dept
return super().create(validated_data)
def update(self, instance, validated_data):
if self.request:
if hasattr(instance, 'update_by'):
validated_data['update_by'] = getattr(self.request, 'user', None)
return super().update(instance, validated_data)
class QuerySerializer(serializers.Serializer):
field = serializers.CharField(label='字段名')
compare = serializers.ChoiceField(label='比较式', choices=["", "!", "gte", "gt", "lte", "lt", "in", "contains"])
value = serializers.CharField(label='')
class ComplexSerializer(serializers.Serializer):
# page = serializers.IntegerField(min_value=0)
# page_size = serializers.IntegerField(min_value=1)
# query = serializers.CharField(label='获取字段名')
querys = serializers.ListField(child=QuerySerializer(many=True), label="查询列表", required=False)

56
apps/utils/sms.py Normal file
View File

@ -0,0 +1,56 @@
from aliyunsdkcore.client import AcsClient
from aliyunsdkcore.request import CommonRequest
import json
import logging
from server.settings import get_sysconfig
from apps.utils.decorators import auto_log
# 实例化myLogger
myLogger = logging.getLogger('log')
@auto_log(name='阿里云短信', raise_exception=True, send_mail=True)
def send_sms(phone: str, template_code: int, template_param: dict):
config = get_sysconfig()
if config['sms'].get('enabled', True) is False:
return
client = AcsClient(config['sms']['xn_key'], config['sms']['xn_secret'], 'default')
request = CommonRequest()
# 固定json
request.set_accept_format('json')
# 固定地址
request.set_domain('sms11.hzgxr.com:40081')
# 固定POST
request.set_method('POST')
# 固定HTTP
request.set_protocol_type('http') # https | http
# 固定版本号
request.set_version('2017-05-25')
# 固定操作名
request.set_action_name('SendSms')
# 手机号码
request.add_query_param('PhoneNumbers', phone)
# 签名名称
request.add_query_param('SignName', config['sms']['xn_sign'])
# 模板CODE
request.add_query_param('TemplateCode', template_code)
# 如果有模板参数 填写模板参数 如果无 无须填写
request.add_query_param('TemplateParam', json.dumps(template_param))
res = client.do_action(request)
res_dict = json.loads(str(res, encoding='utf-8'))
# print(phone, template_code, template_param, res_dict)
if res_dict['result'] == 0:
return True, res_dict
else:
myLogger.error("短信发送失败:{}-{}-{}-{}".format(phone, template_code, str(template_param), str(res_dict)))
return False, res_dict
def send_sms_huawei():
"""华为短信发送/备用
"""
def send_sms_tencent():
"""腾讯短信发送/备用
"""

108
apps/utils/snowflake.py Executable file
View File

@ -0,0 +1,108 @@
# Twitter's Snowflake algorithm implementation which is used to generate distributed IDs.
# https://github.com/twitter-archive/snowflake/blob/snowflake-2010/src/main/scala/com/twitter/service/snowflake/IdWorker.scala
from random import randint
import time
from server.settings import SNOW_DATACENTER_ID
class InvalidSystemClock(Exception):
"""
时钟回拨异常
"""
pass
class Constant(object):
# 64位ID的划分
WORKER_ID_BITS = 6
DATACENTER_ID_BITS = 5
SEQUENCE_BITS = 12
# 最大取值计算
MAX_WORKER_ID = -1 ^ (-1 << WORKER_ID_BITS) # 2**5-1 0b11111
MAX_DATACENTER_ID = -1 ^ (-1 << DATACENTER_ID_BITS)
# 移位偏移计算
WOKER_ID_SHIFT = SEQUENCE_BITS
DATACENTER_ID_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS
TIMESTAMP_LEFT_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS + DATACENTER_ID_BITS
# 序号循环掩码
SEQUENCE_MASK = -1 ^ (-1 << SEQUENCE_BITS)
# Twitter元年时间戳
TWEPOCH = 1288834974657
class IdWorker(object):
"""
用于生成IDs
"""
def __init__(self, datacenter_id, worker_id, sequence=0):
"""
初始化
:param datacenter_id: 数据中心机器区域ID
:param worker_id: 机器ID
:param sequence: 起始序号
"""
# sanity check
if worker_id > Constant.MAX_WORKER_ID or worker_id < 0:
raise ValueError('worker_id值越界')
if datacenter_id > Constant.MAX_DATACENTER_ID or datacenter_id < 0:
raise ValueError('datacenter_id值越界')
self.worker_id = worker_id
self.datacenter_id = datacenter_id
self.sequence = sequence
self.last_timestamp = -1 # 上次计算的时间戳
def _gen_timestamp(self):
"""
生成整数时间戳
:return:int timestamp
"""
return int(time.time() * 1000)
def get_id(self):
"""
获取新ID
:return:
"""
timestamp = self._gen_timestamp()
# 时钟回拨
if timestamp < self.last_timestamp:
raise InvalidSystemClock
if timestamp == self.last_timestamp:
self.sequence = (self.sequence + 1) & Constant.SEQUENCE_MASK
if self.sequence == 0:
timestamp = self._til_next_millis(self.last_timestamp)
else:
self.sequence = 0
self.last_timestamp = timestamp
new_id = ((timestamp - Constant.TWEPOCH) << Constant.TIMESTAMP_LEFT_SHIFT
) | (self.datacenter_id << Constant.DATACENTER_ID_SHIFT) | \
(self.worker_id << Constant.WOKER_ID_SHIFT) | self.sequence
return new_id
def _til_next_millis(self, last_timestamp):
"""
等到下一毫秒
"""
timestamp = self._gen_timestamp()
while timestamp <= last_timestamp:
timestamp = self._gen_timestamp()
return timestamp
idWorker = IdWorker(SNOW_DATACENTER_ID, randint(1, 60))
if __name__ == '__main__':
print(idWorker.get_id())

32
apps/utils/speech.py Normal file
View File

@ -0,0 +1,32 @@
from aip import AipSpeech
from django.conf import settings
import uuid
import os
from django.utils import timezone
def generate_voice(msg: str, per: int = 0):
"""文本生成语音
Args:
msg (str): 文本
per (int): /女声
Returns:
bool: 成功
str: 地址
dict: result
"""
client = AipSpeech(settings.BD_SP_ID, settings.BD_SP_KEY, settings.BD_SP_SECRET)
result = client.synthesis(msg, 'zh', 1, {'vol': 5, 'spd': 5, 'per': per})
# 识别正确返回语音二进制 错误则返回dict 参照下面错误码
if not isinstance(result, dict):
file_name = '{}.mp3'.format(uuid.uuid4())
path = '/media/' + timezone.now().strftime('%Y/%m/%d/')
full_path = settings.BASE_DIR + path
if not os.path.exists(full_path):
os.makedirs(full_path)
with open(full_path + file_name, 'wb') as f:
f.write(result)
return True, path + file_name, None
return False, None, result

78
apps/utils/sql.py Normal file
View File

@ -0,0 +1,78 @@
from django.db import connection
def execute_raw_sql(sql: str, params=None):
"""执行原始sql并返回rows, columns数据
Args:
sql (str): 查询语句
params (_type_, optional): 参数列表. Defaults to None.
"""
with connection.cursor() as cursor:
cursor.execute("SET statement_timeout TO %s;", [30000])
if params:
cursor.execute(sql, params=params)
else:
cursor.execute(sql)
columns = [desc[0] for desc in cursor.description]
rows = cursor.fetchall()
return columns, rows
def format_sqldata(columns, rows):
return [columns] + rows, [dict(zip(columns, row)) for row in rows]
def query_all_dict(sql, params=None):
'''
查询所有结果返回字典类型数据
:param sql:
:param params:
:return:
'''
with connection.cursor() as cursor:
if params:
cursor.execute(sql, params=params)
else:
cursor.execute(sql)
columns = [desc[0] for desc in cursor.description]
return [dict(zip(columns, row)) for row in cursor.fetchall()]
def query_one_dict(sql, params=None):
"""
查询一个结果返回字典类型数据
:param sql:
:param params:
:return:
"""
with connection.cursor() as cursor:
if params:
cursor.execute(sql, params=params)
else:
cursor.execute(sql)
columns = [desc[0] for desc in cursor.description]
row = cursor.fetchone()
return dict(zip(columns, row))
import pymysql
class DbConnection:
def __init__(self, host, user, password, database):
self.host = host
self.user = user
self.password = password
self.database = database
self.conn = None
self.cursor = None
def __enter__(self):
self.conn = pymysql.connect(
host=self.host,
user=self.user,
password=self.password,
database=self.database
)
self.cursor = self.conn.cursor()
return self.cursor
def __exit__(self, exc_type, exc_val, exc_tb):
self.cursor.close()
self.conn.close()

33
apps/utils/tasks.py Normal file
View File

@ -0,0 +1,33 @@
# from __future__ import absolute_import, unicode_literals
from celery import Task
from celery import shared_task
import logging
from django.conf import settings
from server.settings import get_sysconfig
# 实例化myLogger
myLogger = logging.getLogger('log')
@shared_task
def send_mail_task(**args):
config = get_sysconfig()
from django.core.mail import send_mail
args['subject'] = '{}:{}_{}_{}'.format(
settings.SYS_NAME, settings.SYS_VERSION, config['base']['base_name_short'], args.get('subject', '500'))
args['from_email'] = args.get('from_email', settings.EMAIL_HOST_USER)
args['recipient_list'] = args.get(
'recipient_list', [settings.EMAIL_HOST_USER])
send_mail(**args)
class CustomTask(Task):
"""
自定义的任务回调
"""
def on_failure(self, exc, task_id, args, kwargs, einfo):
detail = '{0!r} failed: {1!r}'.format(task_id, exc)
myLogger.error(detail)
send_mail_task.delay(subject='task_error', message=detail)
return super().on_failure(exc, task_id, args, kwargs, einfo)

205
apps/utils/tools.py Executable file
View File

@ -0,0 +1,205 @@
import re
import textwrap
import random
import string
from datetime import datetime
from django.conf import settings
import base64
import requests
from io import BytesIO
from rest_framework.serializers import ValidationError
def tran64(s):
missing_padding = len(s) % 4
if missing_padding != 0:
s = s+'='* (4 - missing_padding)
return s
def singleton(cls):
_instance = {}
def inner():
if cls not in _instance:
_instance[cls] = cls()
return _instance[cls]
return inner
def print_roundtrip(response, *args, **kwargs):
def format_headers(d): return '\n'.join(f'{k}: {v}' for k, v in d.items())
print(textwrap.dedent('''
---------------- request ----------------
{req.method} {req.url}
{reqhdrs}
{req.body}
---------------- response ----------------
{res.status_code} {res.reason} {res.url}
{reshdrs}
{res.text}
''').format(
req=response.request,
res=response,
reqhdrs=format_headers(response.request.headers),
reshdrs=format_headers(response.headers),
))
def ranstr(num):
salt = ''.join(random.sample(string.ascii_lowercase + string.digits, num))
return salt
def rannum(num):
salt = ''.join(random.sample(string.digits, num))
return salt
def timestamp_to_time(millis):
"""10位时间戳转换为日期格式字符串"""
return datetime.fromtimestamp(millis)
def convert_to_base64(path: str):
"""给定图片转base64
Args:
path (str): 图片地址
"""
if path.startswith('http'): # 如果是网络图片
return str(base64.b64encode(BytesIO(requests.get(url=path).content).read()), 'utf-8')
else:
with open(settings.BASE_DIR + path, 'rb') as f:
return str(base64.b64encode(f.read()), 'utf-8')
def p_in_poly(p, poly):
px = p['x']
py = p['y']
flag = False
i = 0
l = len(poly)
j = l - 1
# for(i = 0, l = poly.length, j = l - 1; i < l; j = i, i++):
while i < l:
sx = poly[i]['x']
sy = poly[i]['y']
tx = poly[j]['x']
ty = poly[j]['y']
# 点与多边形顶点重合
if (sx == px and sy == py) or (tx == px and ty == py):
return (px, py)
# 判断线段两端点是否在射线两侧
if (sy < py and ty >= py) or (sy >= py and ty < py):
# 线段上与射线 Y 坐标相同的点的 X 坐标
x = sx + (py - sy) * (tx - sx) / (ty - sy)
# 点在多边形的边上
if x == px:
return (px, py)
# 射线穿过多边形的边界
if x > px:
flag = not flag
j = i
i += 1
# 射线穿过多边形边界的次数为奇数时点在多边形内
return (px, py) if flag else 'out'
def check_id_number_e(val):
re_s = r'^[1-9]\d{5}(18|19|20|(3\d))\d{2}((0[1-9])|(1[0-2]))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$'
if not re.match(re_s, val):
raise ValidationError('身份证号校验错误')
return val
def get_info_from_id(val):
birth = val[6:14]
birth_year = birth[0:4]
age = datetime.now().year - int(birth_year)
sex = int(val[-2])
gender = ''
if sex % 2:
gender = ''
return dict(age=age, gender=gender)
def check_id_number(idcard):
"""校验身份证号
Args:
id_number (_type_): 身份证号
"""
Errors = ['身份证号码位数不对!', '身份证号码出生日期超出范围或含有非法字符!', '身份证号码校验错误!', '身份证地区非法!']
area = {"11": "北京", "12": "天津", "13": "河北", "14": "山西", "15": "内蒙古", "21": "辽宁", "22": "吉林", "23": "黑龙江",
"31": "上海", "32": "江苏", "33": "浙江", "34": "安徽", "35": "福建", "36": "江西", "37": "山东", "41": "河南", "42": "湖北",
"43": "湖南", "44": "广东", "45": "广西", "46": "海南", "50": "重庆", "51": "四川", "52": "贵州", "53": "云南", "54": "西藏",
"61": "陕西", "62": "甘肃", "63": "青海", "64": "宁夏", "65": "新疆", "71": "台湾", "81": "香港", "82": "澳门", "91": "国外"}
idcard = str(idcard)
idcard = idcard.strip()
idcard_list = list(idcard)
# 地区校验
if str(idcard[0:2]) not in area:
return False, Errors[3]
# 15位身份号码检测
if len(idcard) == 15:
if ((int(idcard[6:8]) + 1900) % 4 == 0 or (
(int(idcard[6:8]) + 1900) % 100 == 0 and (int(idcard[6:8]) + 1900) % 4 == 0)):
ereg = re.compile(
'[1-9][0-9]{5}[0-9]{2}((01|03|05|07|08|10|12)(0[1-9]|[1-2][0-9]|3[0-1])|(04|06|09|11)(0[1-9]|[1-2][0-9]|30)|02(0[1-9]|[1-2][0-9]))[0-9]{3}$') # //测试出生日期的合法性
else:
ereg = re.compile(
'[1-9][0-9]{5}[0-9]{2}((01|03|05|07|08|10|12)(0[1-9]|[1-2][0-9]|3[0-1])|(04|06|09|11)(0[1-9]|[1-2][0-9]|30)|02(0[1-9]|1[0-9]|2[0-8]))[0-9]{3}$') # //测试出生日期的合法性
if re.match(ereg, idcard):
return True, ''
else:
return False, Errors[1]
# 18位身份号码检测
elif len(idcard) == 18:
# 出生日期的合法性检查
# 闰年月日:((01|03|05|07|08|10|12)(0[1-9]|[1-2][0-9]|3[0-1])|(04|06|09|11)(0[1-9]|[1-2][0-9]|30)|02(0[1-9]|[1-2][0-9]))
# 平年月日:((01|03|05|07|08|10|12)(0[1-9]|[1-2][0-9]|3[0-1])|(04|06|09|11)(0[1-9]|[1-2][0-9]|30)|02(0[1-9]|1[0-9]|2[0-8]))
if (int(idcard[6:10]) % 4 == 0 or (int(idcard[6:10]) % 100 == 0 and int(idcard[6:10]) % 4 == 0)):
# 闰年出生日期的合法性正则表达式
ereg = re.compile(
'[1-9][0-9]{5}19[0-9]{2}((01|03|05|07|08|10|12)(0[1-9]|[1-2][0-9]|3[0-1])|(04|06|09|11)(0[1-9]|[1-2][0-9]|30)|02(0[1-9]|[1-2][0-9]))[0-9]{3}[0-9Xx]$')
else:
# 平年出生日期的合法性正则表达式
ereg = re.compile(
'[1-9][0-9]{5}19[0-9]{2}((01|03|05|07|08|10|12)(0[1-9]|[1-2][0-9]|3[0-1])|(04|06|09|11)(0[1-9]|[1-2][0-9]|30)|02(0[1-9]|1[0-9]|2[0-8]))[0-9]{3}[0-9Xx]$')
# 测试出生日期的合法性
if re.match(ereg, idcard):
# 计算校验位
S = (int(idcard_list[0]) + int(idcard_list[10])) * 7 + (int(idcard_list[1]) + int(idcard_list[11])) * 9 + (
int(idcard_list[2]) + int(idcard_list[12])) * 10 + (
int(idcard_list[3]) + int(idcard_list[13])) * 5 + (
int(idcard_list[4]) + int(idcard_list[14])) * 8 + (
int(idcard_list[5]) + int(idcard_list[15])) * 4 + (
int(idcard_list[6]) + int(idcard_list[16])) * 2 + int(idcard_list[7]) * 1 + int(
idcard_list[8]) * 6 + int(idcard_list[9]) * 3
Y = S % 11
M = "F"
JYM = "10X98765432"
M = JYM[Y] # 判断校验位
if M == idcard_list[17]: # 检测ID的校验位
return True, ''
else:
return False, Errors[2]
else:
return False, Errors[1]
else:
return False, Errors[0]
def check_phone_e(phone):
re_phone = r'^1\d{10}$'
if not re.match(re_phone, phone):
raise ValidationError('手机号格式错误')
return phone

11
apps/utils/urls.py Executable file
View File

@ -0,0 +1,11 @@
from django.urls import path, include
from rest_framework import routers
from apps.utils.views import SignatureViewSet
API_BASE_URL = 'api/utils/'
router = routers.DefaultRouter()
router.register('signature', SignatureViewSet, basename='signature')
urlpatterns = [
path(API_BASE_URL, include(router.urls)),
]

57
apps/utils/views.py Executable file
View File

@ -0,0 +1,57 @@
import os
import cv2
from django.http import HttpResponse
from apps.utils.errors import SIGN_MAKE_FAIL
from server.settings import BASE_DIR
import numpy as np
from rest_framework.response import Response
from rest_framework.exceptions import ParseError
from apps.utils.viewsets import CustomGenericViewSet
from apps.utils.mixins import CustomCreateModelMixin
from apps.utils.serializers import GenSignatureSerializer
from rest_framework.views import APIView
from rest_framework.decorators import action
from rest_framework.serializers import Serializer
from django.core.cache import cache
import json
import requests
class SignatureViewSet(CustomCreateModelMixin, CustomGenericViewSet):
authentication_classes = ()
permission_classes = ()
create_serializer_class = GenSignatureSerializer
def create(self, request, *args, **kwargs):
"""
照片生成透明签名图片
照片生成透明签名图片
"""
path = (BASE_DIR + request.data['path']).replace('\\', '/')
try:
image = cv2.imread(path, cv2.IMREAD_UNCHANGED)
size = image.shape
width = size[0] # 宽度
height = size[1] # 高度
if size[2] != 4: # 判断
background = np.zeros((size[0], size[1], 4))
for yh in range(height):
for xw in range(width):
background[xw, yh, :3] = image[xw, yh]
background[xw, yh, 3] = 255
image = background
size = image.shape
for i in range(size[0]):
for j in range(size[1]):
if image[i][j][0] > 100 and image[i][j][1] > 100 and image[i][j][2] > 100:
image[i][j][3] = 0
else:
image[i][j][0], image[i][j][1], image[i][j][2] = 0, 0, 0
ext = os.path.splitext(path)
new_path = ext[0] + '.png'
cv2.imwrite(new_path, image)
return Response({'path': new_path.replace(BASE_DIR, '')})
except Exception:
raise ParseError(**SIGN_MAKE_FAIL)

215
apps/utils/viewsets.py Executable file
View File

@ -0,0 +1,215 @@
from django.core.cache import cache
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError, ParseError
from rest_framework.mixins import (CreateModelMixin, ListModelMixin,
RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin)
from rest_framework.permissions import IsAuthenticated, IsAdminUser
from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet
from apps.system.models import DataFilter, Dept, User
from apps.utils.errors import PKS_ERROR
from apps.utils.mixins import MyLoggingMixin, BulkCreateModelMixin, BulkUpdateModelMixin, BulkDestroyModelMixin
from apps.utils.permission import ALL_PERMS, RbacPermission, get_user_perms_map
from apps.utils.queryset import get_child_queryset2
from apps.utils.serializers import PkSerializer, ComplexSerializer
from rest_framework.throttling import UserRateThrottle
from drf_yasg.utils import swagger_auto_schema
from apps.utils.decorators import idempotent
from django.db import transaction
import json
from rest_framework.generics import get_object_or_404
class CustomGenericViewSet(MyLoggingMixin, GenericViewSet):
"""
增强的GenericViewSet
"""
perms_map = {} # 权限标识
throttle_classes = [UserRateThrottle]
logging_methods = ['POST', 'PUT', 'PATCH', 'DELETE']
ordering_fields = '__all__'
ordering = '-create_time'
create_serializer_class = None
update_serializer_class = None
partial_update_serializer_class = None
list_serializer_class = None
retrieve_serializer_class = None
select_related_fields = []
prefetch_related_fields = []
permission_classes = [IsAuthenticated & RbacPermission]
data_filter = False # 数据权限过滤是否开启(需要RbacPermission)
data_filter_field = 'belong_dept'
hash_k = None
cache_seconds = 5 # 接口缓存时间默认5秒
filterset_fields = select_related_fields
def finalize_response(self, request, response, *args, **kwargs):
if self.hash_k and self.cache_seconds:
cache.set(self.hash_k, response.data,
timeout=self.cache_seconds) # 将结果存入缓存,设置超时时间
return super().finalize_response(request, response, *args, **kwargs)
def initial(self, request, *args, **kwargs):
super().initial(request, *args, **kwargs)
cache_seconds = getattr(
self, f"{self.action}_cache_seconds", getattr(self, 'cache_seconds', 0))
if cache_seconds:
self.cache_seconds = cache_seconds
rdata = {}
rdata['request_method'] = request.method
rdata['request_path'] = request.path
rdata['request_data'] = request.data
rdata['request_query'] = request.query_params.dict()
rdata['request_userid'] = request.user.id
self.hash_k = hash(json.dumps(rdata))
hash_v_e = cache.get(self.hash_k, None)
if hash_v_e is None:
cache.set(self.hash_k, 'o', self.cache_seconds)
elif hash_v_e == 'o': # 说明请求正在处理
raise ParseError(f'请求忽略,请{self.cache_seconds}秒后重试')
elif hash_v_e:
return Response(hash_v_e)
def get_serializer_class(self):
action_serializer_name = f"{self.action}_serializer_class"
action_serializer_class = getattr(self, action_serializer_name, None)
if action_serializer_class:
return action_serializer_class
return super().get_serializer_class()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.perms_map:
for k, v in self.perms_map.items():
if v not in ALL_PERMS and v != '*':
ALL_PERMS.append(v)
def get_queryset(self):
queryset = super().get_queryset()
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)
if self.data_filter:
user = self.request.user
if user.is_superuser:
return queryset
user_perms_map = cache.get('perms_' + str(user.id), None)
if user_perms_map is None:
user_perms_map = get_user_perms_map(self.request.user)
if isinstance(user_perms_map, dict):
if hasattr(self, 'perms_map'):
perms_map = self.perms_map
action_str = perms_map.get(
self.request._request.method.lower(), None)
if '*' in perms_map:
return queryset
elif action_str == '*':
return queryset
elif action_str in user_perms_map:
new_queryset = queryset.none()
for dept_id, data_range in user_perms_map[action_str].items():
dept = Dept.objects.get(id=dept_id)
if data_range == DataFilter.ALL:
return queryset
elif data_range == DataFilter.SAMELEVE_AND_BELOW:
queryset = self.filter_s_a_b(queryset, dept)
elif data_range == DataFilter.THISLEVEL_AND_BELOW:
queryset = self.filter_t_a_b(queryset, dept)
elif data_range == DataFilter.THISLEVEL:
queryset = self.filter_t(queryset, dept)
elif data_range == DataFilter.MYSELF:
queryset = queryset.filter(create_by=user)
new_queryset = new_queryset | queryset
return new_queryset
else:
return queryset.none()
return queryset
def filter_s_a_b(self, queryset, dept):
"""过滤同级及以下, 可重写
"""
if hasattr(queryset.model, 'belong_dept'):
if dept.parent:
belong_depts = get_child_queryset2(dept.parent)
else:
belong_depts = get_child_queryset2(dept)
whereis = {self.data_filter_field + '__in': belong_depts}
queryset = queryset.filter(**whereis)
return queryset
return queryset.filter(create_by=self.request.user)
def filter_t_a_b(self, queryset, dept):
"""过滤本级及以下, 可重写
"""
if hasattr(queryset.model, 'belong_dept'):
belong_depts = get_child_queryset2(dept)
whereis = {self.data_filter_field + '__in': belong_depts}
queryset = queryset.filter(**whereis)
return queryset
return queryset.filter(create_by=self.request.user)
def filter_t(self, queryset, dept):
"""过滤本级, 可重写
"""
if hasattr(queryset.model, 'belong_dept'):
whereis = {self.data_filter_field: dept}
queryset = queryset.filter(whereis)
return queryset
return queryset.filter(create_by=self.request.user)
class CustomModelViewSet(BulkCreateModelMixin, BulkUpdateModelMixin, ListModelMixin,
RetrieveModelMixin, BulkDestroyModelMixin, CustomGenericViewSet):
"""
增强的ModelViewSet
"""
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
# 增加默认权限标识
if not self.perms_map or self.perms_map == {'get': '*'}:
basename = self.basename
self.perms_map = {'get': '*', 'post': '{}.create'.format(basename), 'put': '{}.update'.format(
basename), 'patch': '{}.update'.format(basename), 'delete': '{}.delete'.format(basename)}
for k, v in self.perms_map.items():
if v not in ALL_PERMS and v != '*':
ALL_PERMS.append(v)
@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)

139
apps/utils/wx.py Normal file
View File

@ -0,0 +1,139 @@
from django.core.cache import cache
import time
from threading import Thread
import uuid
import requests
from django.conf import settings
from rest_framework.exceptions import APIException, ParseError
import logging
from apps.ops.models import Tlog
from apps.utils.errors import WX_REQUEST_ERROR
from apps.utils.tools import print_roundtrip
from django.utils.timezone import now
import traceback
requests.packages.urllib3.disable_warnings()
# 实例化myLogger
myLogger = logging.getLogger('log')
class WxClient:
"""
微信公众号相关
"""
def __init__(self, app_id=settings.WX_APPID,
app_secret=settings.WX_APPSECRET) -> None:
if settings.WX_ENABLED:
self.app_id = app_id
self.app_secret = app_secret
self.isRuning = True
self.token = None # 普通token
self.t = None # 线程
self.log = {}
self.setup()
def _get_token_loop(self):
while self.isRuning:
parmas = {
'grant_type': 'client_credential',
'appid': self.app_id,
'secret': self.app_secret
}
_, ret = self.request(url='/cgi-bin/token', params=parmas, method='get')
self.token = ret['access_token']
cache.set(self.app_id + '_token', self.token, timeout=3600)
time.sleep(3000)
def setup(self):
t = Thread(target=self._get_token_loop, args=(), daemon=True)
t.start()
def __del__(self):
"""
自定义销毁
"""
self.isRuning = False
# self.t.join()
def request(self, url: str, method: str, params=dict(), json=dict(), timeout=10,
file_path_rela=None, raise_exception=True):
if not settings.WX_ENABLED:
raise ParseError('微信公众号未启用')
self.log = {"requested_at": now(), "id": uuid.uuid4(), "path": url, "method": method,
"params": params, "body": json, "target": "wx", "result": 10}
files = None
if file_path_rela: # 相对路径
files = {'file': open(settings.BASE_DIR + file_path_rela, 'rb')}
try:
if params:
url = url.format(**params)
self.log.update({"path": url})
r = getattr(requests, method)('{}{}'.format('https://api.weixin.qq.com', url),
params=params, json=json,
timeout=timeout, files=files, verify=False)
except Exception:
errors = traceback.format_exc()
myLogger.error('微信错误', exc_info=True)
self.handle_log(result='error', errors=errors)
if raise_exception:
raise APIException(**WX_REQUEST_ERROR)
return 'error', WX_REQUEST_ERROR
# if settings.DEBUG:
# print_roundtrip(r)
if r.status_code == 200:
ret = r.json()
if 'errcode' in ret and ret['errcode'] not in [0, '0']:
detail = '微信错误:' + \
'{}|{}'.format(str(ret['errcode']), ret.get('errmsg', ''))
err_detail = dict(detail=detail, code='wx_'+str(ret['errcode']))
self.handle_log(result='fail', response=ret)
if raise_exception:
raise ParseError(**err_detail)
return 'fail', dict(detail=detail, code='wx_'+str(ret['errcode']))
# self.handle_log(result='success', response=ret) # 成功的日志就不记录了
return 'success', ret
self.handle_log(result='error', response=None)
if raise_exception:
raise APIException(**WX_REQUEST_ERROR)
return 'error', WX_REQUEST_ERROR
def _get_response_ms(self):
"""
Get the duration of the request response cycle is milliseconds.
In case of negative duration 0 is returned.
"""
response_timedelta = now() - self.log["requested_at"]
response_ms = int(response_timedelta.total_seconds() * 1000)
return max(response_ms, 0)
def handle_log(self, result, response=None, errors=None):
self.log.update({
"result": result,
"response": response,
"response_ms": self._get_response_ms(),
"errors": errors
})
Tlog(**self.log).save()
def get_basic_info(self, code):
params = {
'appid': self.app_id,
'secret': self.app_secret,
'code': code,
'grant_type': 'authorization_code'
}
_, res = self.request('/sns/oauth2/access_token', params=params, method='get')
return res
def send_tem_msg(self, data: dict):
"""发送模板消息
"""
_, res = self.request('/cgi-bin/message/template/send',
params={'access_token': self.token}, json=data, method='post')
return res
wxClient = WxClient()

103
apps/utils/wxmp.py Normal file
View File

@ -0,0 +1,103 @@
import uuid
import requests
from django.conf import settings
from rest_framework.exceptions import APIException, ParseError
import logging
from apps.ops.models import Tlog
from apps.utils.errors import WX_REQUEST_ERROR
from apps.utils.tools import print_roundtrip
from django.utils.timezone import now
import traceback
requests.packages.urllib3.disable_warnings()
# 实例化myLogger
myLogger = logging.getLogger('log')
class WxmpClient:
"""
微信小程序相关
"""
def __init__(self, app_id=settings.WXMP_APPID,
app_secret=settings.WXMP_APPSECRET) -> None:
self.app_id, self.app_secret = None, None
if settings.WXMP_ENABLED:
self.app_id = app_id
self.app_secret = app_secret
self.log = {}
def request(self, url: str, method: str, params=dict(), json=dict(), timeout=10,
file_path_rela=None, raise_exception=True):
if not settings.WX_ENABLED:
raise ParseError('微信小程序未启用')
self.log = {"requested_at": now(), "id": uuid.uuid4(), "path": url, "method": method,
"params": params, "body": json, "target": "wx", "result": 10}
files = None
if file_path_rela: # 相对路径
files = {'file': open(settings.BASE_DIR + file_path_rela, 'rb')}
try:
if params:
url = url.format(**params)
self.log.update({"path": url})
r = getattr(requests, method)('{}{}'.format('https://api.weixin.qq.com', url),
params=params, json=json,
timeout=timeout, files=files, verify=False)
except Exception:
errors = traceback.format_exc()
myLogger.error('微信小程序错误', exc_info=True)
self.handle_log(result='error', errors=errors)
if raise_exception:
raise APIException(**WX_REQUEST_ERROR)
return 'error', WX_REQUEST_ERROR
# if settings.DEBUG:
# print_roundtrip(r)
if r.status_code == 200:
ret = r.json()
if 'errcode' in ret and ret['errcode'] not in [0, '0']:
detail = '微信错误:' + \
'{}|{}'.format(str(ret['errcode']), ret.get('errmsg', ''))
err_detail = dict(detail=detail, code='wx_'+str(ret['errcode']))
self.handle_log(result='fail', response=ret)
if raise_exception:
raise ParseError(**err_detail)
return 'fail', dict(detail=detail, code='wx_'+str(ret['errcode']))
# self.handle_log(result='success', response=ret) # 成功的日志就不记录了
return 'success', ret
self.handle_log(result='error', response=None)
if raise_exception:
raise APIException(**WX_REQUEST_ERROR)
return 'error', WX_REQUEST_ERROR
def _get_response_ms(self):
"""
Get the duration of the request response cycle is milliseconds.
In case of negative duration 0 is returned.
"""
response_timedelta = now() - self.log["requested_at"]
response_ms = int(response_timedelta.total_seconds() * 1000)
return max(response_ms, 0)
def handle_log(self, result, response=None, errors=None):
self.log.update({
"result": result,
"response": response,
"response_ms": self._get_response_ms(),
"errors": errors
})
Tlog(**self.log).save()
def get_basic_info(self, code):
params = {
'appid': self.app_id,
'secret': self.app_secret,
'js_code': code,
'grant_type': 'authorization_code'
}
_, res = self.request('/sns/jscode2session', params=params, method='get')
return res
wxmpClient = WxmpClient()

0
apps/wf/__init__.py Executable file
View File

18
apps/wf/admin.py Executable file
View File

@ -0,0 +1,18 @@
from django.contrib import admin
from apps.wf.models import State, Transition, Workflow
# Register your models here.
@admin.register(Workflow)
class WorkflowAdmin(admin.ModelAdmin):
date_hierarchy = 'create_time'
@admin.register(State)
class StateAdmin(admin.ModelAdmin):
date_hierarchy = 'create_time'
@admin.register(Transition)
class TransitionAdmin(admin.ModelAdmin):
date_hierarchy = 'create_time'

6
apps/wf/apps.py Executable file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class WfConfig(AppConfig):
name = 'apps.wf'
verbose_name = '工作流管理'

31
apps/wf/filters.py Executable file
View File

@ -0,0 +1,31 @@
from django_filters import rest_framework as filters
from .models import Ticket
class TicketFilterSet(filters.FilterSet):
start_create = filters.DateTimeFilter(field_name="create_time", lookup_expr='gte')
end_create = filters.DateTimeFilter(field_name="create_time", lookup_expr='lte')
category = filters.ChoiceFilter(choices=Ticket.category_choices, method='filter_category')
class Meta:
model = Ticket
fields = ['workflow', 'state', 'act_state', 'start_create', 'end_create', 'category', 'script_run_last_result']
def filter_category(self, queryset, name, value):
user = self.request.user
if value == 'owner': # 我的
queryset = queryset.filter(create_by=user)
elif value == 'duty': # 待办
queryset = queryset.filter(participant__contains=user.id).exclude(
act_state__in=[Ticket.TICKET_ACT_STATE_FINISH, Ticket.TICKET_ACT_STATE_CLOSED])
elif value == 'worked': # 处理过的
queryset = queryset.filter(ticketflow_ticket__participant=user).exclude(
create_by=user).order_by('-update_time').distinct()
elif value == 'cc': # 抄送我的
queryset = queryset.filter(ticketflow_ticket__participant_cc__contains=user.id).exclude(
create_by=user).order_by('-update_time').distinct()
elif value == 'all':
pass
else:
queryset = queryset.none()
return queryset

View File

@ -0,0 +1,185 @@
# Generated by Django 3.2.12 on 2022-08-15 06:02
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
('system', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='State',
fields=[
('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')),
('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')),
('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')),
('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')),
('name', models.CharField(max_length=50, verbose_name='名称')),
('key', models.CharField(blank=True, max_length=20, null=True, verbose_name='状态标识')),
('is_hidden', models.BooleanField(default=False, help_text='设置为True时,获取工单步骤api中不显示此状态(当前处于此状态时除外)', verbose_name='是否隐藏')),
('sort', models.IntegerField(default=0, help_text='用于工单步骤接口时step上状态的顺序(因为存在网状情况,所以需要人为设定顺序),值越小越靠前', verbose_name='状态顺序')),
('type', models.IntegerField(choices=[(0, '普通'), (1, '开始'), (2, '结束')], default=0, help_text='0.普通类型 1.初始状态(用于新建工单时,获取对应的字段必填及transition信息) 2.结束状态(此状态下的工单不得再处理即没有对应的transition)', verbose_name='状态类型')),
('enable_retreat', models.BooleanField(default=False, help_text='开启后允许工单创建人在此状态直接撤回工单到初始状态', verbose_name='允许撤回')),
('enable_deliver', models.BooleanField(default=False, verbose_name='允许转交')),
('participant_type', models.IntegerField(blank=True, choices=[(0, '无处理人'), (1, '个人'), (2, '多人'), (3, '部门'), (4, '角色'), (10, '岗位'), (6, '脚本'), (7, '工单的字段'), (9, '代码获取')], default=1, help_text='0.无处理人,1.个人,2.多人,3.部门,4.角色,5.变量(支持工单创建人,创建人的leader),6.脚本,7.工单的字段内容(如表单中的"测试负责人",需要为用户名或者逗号隔开的多个用户名),8.父工单的字段内容。 初始状态请选择类型5参与人填create_by', verbose_name='参与者类型')),
('participant', models.JSONField(blank=True, default=list, help_text='可以为空(无处理人的情况,如结束状态)、userid、userid列表\\部门id\\角色id\\变量(create_by,create_by_tl)\\脚本记录的id等包含子工作流的需要设置处理人为loonrobot', verbose_name='参与者')),
('state_fields', models.JSONField(blank=True, default=dict, help_text='json格式字典存储,包括读写属性1只读2必填3可选, 4:隐藏 示例:{"create_time":1,"title":2, "sn":1}, 内置特殊字段participant_info.participant_name:当前处理人信息(部门名称、角色名称)state.state_name:当前状态的状态名,workflow.workflow_name:工作流名称', verbose_name='表单字段')),
('distribute_type', models.IntegerField(choices=[(1, '主动接单'), (2, '直接处理'), (3, '随机分配'), (4, '全部处理')], default=1, help_text='1.主动接单(如果当前处理人实际为多人的时候,需要先接单才能处理) 2.直接处理(即使当前处理人实际为多人,也可以直接处理) 3.随机分配(如果实际为多人,则系统会随机分配给其中一个人) 4.全部处理(要求所有参与人都要处理一遍,才能进入下一步)', verbose_name='分配方式')),
('filter_dept', models.CharField(blank=True, max_length=20, null=True, verbose_name='部门字段过滤')),
('participant_cc', models.JSONField(blank=True, default=list, help_text='抄送给(userid列表)', verbose_name='抄送给')),
('on_reach_func', models.CharField(blank=True, max_length=100, null=True, verbose_name='到达时调用方法')),
('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='state_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')),
('update_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='state_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')),
],
options={
'verbose_name': '工作流节点',
'verbose_name_plural': '工作流节点',
},
),
migrations.CreateModel(
name='Ticket',
fields=[
('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')),
('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')),
('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')),
('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')),
('title', models.CharField(blank=True, help_text='工单标题', max_length=500, null=True, verbose_name='标题')),
('sn', models.CharField(help_text='工单的流水号', max_length=25, verbose_name='流水号')),
('ticket_data', models.JSONField(default=dict, help_text='工单自定义字段内容', verbose_name='工单数据')),
('in_add_node', models.BooleanField(default=False, help_text='是否处于加签状态下', verbose_name='加签状态中')),
('script_run_last_result', models.BooleanField(default=True, verbose_name='脚本最后一次执行结果')),
('participant_type', models.IntegerField(choices=[(0, '无处理人'), (1, '个人'), (2, '多人'), (3, '部门'), (4, '角色'), (10, '岗位'), (6, '脚本'), (7, '工单的字段'), (9, '代码获取')], default=0, help_text='0.无处理人,1.个人,2.多人', verbose_name='当前处理人类型')),
('participant', models.JSONField(blank=True, default=list, help_text='可以为空(无处理人的情况,如结束状态)、userid、userid列表', verbose_name='当前处理人')),
('act_state', models.IntegerField(choices=[(0, '草稿中'), (1, '进行中'), (2, '被退回'), (3, '被撤回'), (4, '已完成'), (5, '已关闭')], default=1, help_text='当前工单的进行状态', verbose_name='进行状态')),
('multi_all_person', models.JSONField(blank=True, default=dict, help_text='需要当前状态处理人全部处理时实际的处理结果json格式', verbose_name='全部处理的结果')),
('add_node_man', models.ForeignKey(blank=True, help_text='加签操作的人,工单当前处理人处理完成后会回到该处理人,当处于加签状态下才有效', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='加签人')),
('belong_dept', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ticket_belong_dept', to='system.dept', verbose_name='所属部门')),
('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ticket_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='wf.ticket', verbose_name='父工单')),
('parent_state', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='ticket_parent_state', to='wf.state', verbose_name='父工单状态')),
('state', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_state', to='wf.state', verbose_name='当前状态')),
('update_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ticket_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='Workflow',
fields=[
('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')),
('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')),
('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')),
('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')),
('name', models.CharField(max_length=50, verbose_name='名称')),
('key', models.CharField(blank=True, max_length=20, null=True, unique=True, verbose_name='工作流标识')),
('sn_prefix', models.CharField(default='hb', max_length=50, verbose_name='流水号前缀')),
('description', models.CharField(blank=True, max_length=200, null=True, verbose_name='描述')),
('view_permission_check', models.BooleanField(default=True, help_text='开启后,只允许工单的关联人(创建人、曾经的处理人)有权限查看工单', verbose_name='查看权限校验')),
('limit_expression', models.JSONField(blank=True, default=dict, 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的用户提交工单)', verbose_name='限制表达式')),
('display_form_str', models.JSONField(blank=True, default=list, help_text='默认"[]",用于用户只有对应工单查看权限时显示哪些字段,field_key的list的json,如["days","sn"],内置特殊字段participant_info.participant_name:当前处理人信息(部门名称、角色名称)state.state_name:当前状态的状态名,workflow.workflow_name:工作流名称', verbose_name='展现表单字段')),
('title_template', models.CharField(blank=True, default='{title}', help_text='工单字段的值可以作为参数写到模板中,格式如:你有一个待办工单:{title}', max_length=50, null=True, verbose_name='标题模板')),
('content_template', models.CharField(blank=True, default='标题:{title}, 创建时间:{create_time}', help_text='工单字段的值可以作为参数写到模板中,格式如:标题:{title}, 创建时间:{create_time}', max_length=1000, null=True, verbose_name='内容模板')),
('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workflow_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')),
('update_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workflow_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')),
],
options={
'verbose_name': '工作流',
'verbose_name_plural': '工作流',
},
),
migrations.CreateModel(
name='Transition',
fields=[
('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')),
('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')),
('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')),
('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')),
('name', models.CharField(max_length=50, verbose_name='操作')),
('timer', models.IntegerField(default=0, help_text='单位秒。处于源状态X秒后如果状态都没有过变化则自动流转到目标状态。设置时间有效', verbose_name='定时器(单位秒)')),
('condition_expression', models.JSONField(blank=True, default=list, help_text='流转条件表达式,根据表达式中的条件来确定流转的下个状态,格式为[{"expression":"{days} > 3 and {days}<10", "target_state":11}] 其中{}用于填充工单的字段key,运算时会换算成实际的值当符合条件下个状态将变为target_state_id中的值,表达式只支持简单的运算或datetime/time运算.loonflow会以首次匹配成功的条件为准所以多个条件不要有冲突', verbose_name='条件表达式')),
('attribute_type', models.IntegerField(choices=[(1, '同意'), (2, '拒绝'), (3, '其他')], default=1, help_text='属性类型1.同意2.拒绝3.其他', verbose_name='属性类型')),
('field_require_check', models.BooleanField(default=True, help_text='默认在用户点击操作的时候需要校验工单表单的必填项,如果设置为否则不检查。用于如"退回"属性的操作,不需要填写表单内容', verbose_name='是否校验必填项')),
('on_submit_func', models.CharField(blank=True, max_length=100, null=True, verbose_name='提交操作调用方法')),
('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transition_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')),
('destination_state', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dstate_transition', to='wf.state', verbose_name='目的状态')),
('source_state', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sstate_transition', to='wf.state', verbose_name='源状态')),
('update_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transition_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')),
('workflow', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='wf.workflow', verbose_name='所属工作流')),
],
options={
'verbose_name': '工作流流转',
'verbose_name_plural': '工作流流转',
},
),
migrations.CreateModel(
name='TicketFlow',
fields=[
('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')),
('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')),
('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')),
('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')),
('suggestion', models.CharField(blank=True, default='', max_length=10000, verbose_name='处理意见')),
('participant_type', models.IntegerField(choices=[(0, '无处理人'), (1, '个人'), (2, '多人'), (3, '部门'), (4, '角色'), (10, '岗位'), (6, '脚本'), (7, '工单的字段'), (9, '代码获取')], default=0, help_text='0.无处理人,1.个人,2.多人等', verbose_name='处理人类型')),
('participant_str', models.CharField(blank=True, help_text='非人工处理的处理人相关信息', max_length=200, null=True, verbose_name='处理人')),
('ticket_data', models.JSONField(blank=True, default=dict, help_text='可以用于记录当前表单数据json格式', verbose_name='工单数据')),
('intervene_type', models.IntegerField(choices=[(0, '正常处理'), (1, '转交'), (2, '加签'), (3, '加签处理完成'), (4, '接单'), (5, '评论'), (6, '删除'), (7, '强制关闭'), (8, '强制修改状态'), (9, 'hook操作'), (10, '撤回'), (11, '抄送')], default=0, help_text='流转类型', verbose_name='干预类型')),
('participant_cc', models.JSONField(blank=True, default=list, help_text='抄送给(userid列表)', verbose_name='抄送给')),
('participant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ticketflow_participant', to=settings.AUTH_USER_MODEL, verbose_name='处理人')),
('state', models.ForeignKey(blank=True, default=0, on_delete=django.db.models.deletion.CASCADE, to='wf.state', verbose_name='当前状态')),
('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticketflow_ticket', to='wf.ticket', verbose_name='关联工单')),
('transition', models.ForeignKey(blank=True, help_text='与worklow.Transition关联, 为空时表示认为干预的操作', null=True, on_delete=django.db.models.deletion.SET_NULL, to='wf.transition', verbose_name='流转id')),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='ticket',
name='workflow',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='wf.workflow', verbose_name='关联工作流'),
),
migrations.AddField(
model_name='state',
name='workflow',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='wf.workflow', verbose_name='所属工作流'),
),
migrations.CreateModel(
name='CustomField',
fields=[
('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')),
('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')),
('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')),
('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')),
('field_type', models.CharField(choices=[('string', '字符串'), ('int', '整型'), ('float', '浮点'), ('boolean', '布尔'), ('date', '日期'), ('datetime', '日期时间'), ('radio', '单选'), ('checkbox', '多选'), ('select', '单选下拉'), ('selects', '多选下拉'), ('cascader', '单选级联'), ('cascaders', '多选级联'), ('select_dg', '弹框单选'), ('select_dgs', '弹框多选'), ('textarea', '文本域'), ('file', '附件'), ('table', '表格')], help_text='string, int, float, date, datetime, radio, checkbox, select, selects, cascader, cascaders, select_dg, select_dgs,textarea, file', max_length=50, verbose_name='类型')),
('field_key', models.CharField(help_text='字段类型请尽量特殊,避免与系统中关键字冲突', max_length=50, verbose_name='字段标识')),
('field_name', models.CharField(max_length=50, verbose_name='字段名称')),
('sort', models.IntegerField(default=0, help_text='工单基础字段在表单中排序为:流水号0,标题20,状态id40,状态名41,创建人80,创建时间100,更新时间120.前端展示工单信息的表单可以根据这个id顺序排列', verbose_name='排序')),
('default_value', models.CharField(blank=True, help_text='前端展示时,可以将此内容作为表单中的该字段的默认值', max_length=100, null=True, verbose_name='默认值')),
('description', models.CharField(blank=True, help_text='字段的描述信息,可用于显示在字段的下方对该字段的详细描述', max_length=100, null=True, verbose_name='描述')),
('placeholder', models.CharField(blank=True, help_text='用户工单详情表单中作为字段的占位符显示', max_length=100, null=True, verbose_name='占位符')),
('field_template', models.TextField(blank=True, help_text='文本域类型字段前端显示时可以将此内容作为字段的placeholder', null=True, verbose_name='文本域模板')),
('boolean_field_display', models.JSONField(blank=True, default=dict, help_text='当为布尔类型时候,可以支持自定义显示形式。{"1":"","0":""}或{"1":"需要","0":"不需要"},注意数字也需要引号', verbose_name='布尔类型显示名')),
('field_choice', models.JSONField(blank=True, default=list, help_text='选项值格式为list, 例["id":1, "name":"张三"]', verbose_name='选项值')),
('label', models.CharField(default='', help_text='处理特殊逻辑使用,比如sys_user用于获取用户作为选项', max_length=1000, verbose_name='标签')),
('is_hidden', models.BooleanField(default=False, help_text='可用于携带不需要用户查看的字段信息', verbose_name='是否隐藏')),
('group', models.CharField(blank=True, max_length=100, null=True, verbose_name='字段分组')),
('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='customfield_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='wf.customfield', verbose_name='父字段')),
('update_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='customfield_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')),
('workflow', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='wf.workflow', verbose_name='所属工作流')),
],
options={
'abstract': False,
},
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.2.12 on 2023-03-30 02:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wf', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='state',
name='filter_dept',
field=models.CharField(blank=True, max_length=60, null=True, verbose_name='部门字段过滤'),
),
]

View File

289
apps/wf/models.py Executable file
View File

@ -0,0 +1,289 @@
from django.db import models
from apps.utils.models import CommonAModel, CommonBModel
from apps.system.models import User
from apps.utils.models import BaseModel
class Workflow(CommonAModel):
"""
工作流
"""
name = models.CharField('名称', max_length=50)
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)
view_permission_check = models.BooleanField('查看权限校验', default=True, help_text='开启后,只允许工单的关联人(创建人、曾经的处理人)有权限查看工单')
limit_expression = models.JSONField(
'限制表达式', 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}')
class Meta:
verbose_name = '工作流'
verbose_name_plural = verbose_name
def __str__(self):
return self.name
class State(CommonAModel):
"""
状态记录
"""
STATE_TYPE_START = 1
STATE_TYPE_END = 2
type_choices = (
(0, '普通'),
(STATE_TYPE_START, '开始'),
(STATE_TYPE_END, '结束')
)
PARTICIPANT_TYPE_PERSONAL = 1
PARTICIPANT_TYPE_MULTI = 2
PARTICIPANT_TYPE_DEPT = 3
PARTICIPANT_TYPE_ROLE = 4
PARTICIPANT_TYPE_VARIABLE = 5
PARTICIPANT_TYPE_ROBOT = 6
PARTICIPANT_TYPE_FIELD = 7
PARTICIPANT_TYPE_PARENT_FIELD = 8
PARTICIPANT_TYPE_FORMCODE = 9
PARTICIPANT_TYPE_POST = 10
state_participanttype_choices = (
(0, '无处理人'),
(PARTICIPANT_TYPE_PERSONAL, '个人'),
(PARTICIPANT_TYPE_MULTI, '多人'),
(PARTICIPANT_TYPE_DEPT, '部门'),
(PARTICIPANT_TYPE_ROLE, '角色'),
(PARTICIPANT_TYPE_POST, '岗位'),
# (PARTICIPANT_TYPE_VARIABLE, '变量'),
(PARTICIPANT_TYPE_ROBOT, '脚本'),
(PARTICIPANT_TYPE_FIELD, '工单的字段'),
# (PARTICIPANT_TYPE_PARENT_FIELD, '父工单的字段'),
(PARTICIPANT_TYPE_FORMCODE, '代码获取')
)
STATE_DISTRIBUTE_TYPE_ACTIVE = 1 # 主动接单
STATE_DISTRIBUTE_TYPE_DIRECT = 2 # 直接处理(当前为多人的情况,都可以处理,而不需要先接单)
STATE_DISTRIBUTE_TYPE_RANDOM = 3 # 随机分配
STATE_DISTRIBUTE_TYPE_ALL = 4 # 全部处理
state_distribute_choices = (
(STATE_DISTRIBUTE_TYPE_ACTIVE, '主动接单'),
(STATE_DISTRIBUTE_TYPE_DIRECT, '直接处理'),
(STATE_DISTRIBUTE_TYPE_RANDOM, '随机分配'),
(STATE_DISTRIBUTE_TYPE_ALL, '全部处理'),
)
STATE_FIELD_READONLY = 1 # 字段只读
STATE_FIELD_REQUIRED = 2 # 字段必填
STATE_FIELD_OPTIONAL = 3 # 字段可选
STATE_FIELD_HIDDEN = 4 # 字段隐藏
state_filter_choices = (
(0, ''),
(1, '和工单同属一及上级部门'),
(2, '和创建人同属一及上级部门'),
(3, '和上步处理人同属一及上级部门'),
)
name = models.CharField('名称', max_length=50)
key = models.CharField('状态标识', max_length=20, null=True, blank=True)
workflow = models.ForeignKey(Workflow, on_delete=models.CASCADE, verbose_name='所属工作流')
is_hidden = models.BooleanField('是否隐藏', default=False, help_text='设置为True时,获取工单步骤api中不显示此状态(当前处于此状态时除外)')
sort = models.IntegerField('状态顺序', default=0, help_text='用于工单步骤接口时step上状态的顺序(因为存在网状情况,所以需要人为设定顺序),值越小越靠前')
type = models.IntegerField('状态类型', default=0, choices=type_choices,
help_text='0.普通类型 1.初始状态(用于新建工单时,获取对应的字段必填及transition信息) 2.结束状态(此状态下的工单不得再处理即没有对应的transition)')
enable_retreat = models.BooleanField('允许撤回', default=False, help_text='开启后允许工单创建人在此状态直接撤回工单到初始状态')
enable_deliver = models.BooleanField('允许转交', default=False)
participant_type = models.IntegerField('参与者类型', choices=state_participanttype_choices, default=1, blank=True,
help_text='0.无处理人,1.个人,2.多人,3.部门,4.角色,5.变量(支持工单创建人,创建人的leader),6.脚本,7.工单的字段内容(如表单中的"测试负责人",需要为用户名或者逗号隔开的多个用户名),8.父工单的字段内容。 初始状态请选择类型5参与人填create_by')
participant = models.JSONField('参与者', default=list, blank=True,
help_text='可以为空(无处理人的情况,如结束状态)、userid、userid列表\部门id\角色id\变量(create_by,create_by_tl)\脚本记录的id等包含子工作流的需要设置处理人为loonrobot')
# json格式存储,包括读写属性1只读2必填3可选4不显示, 字典的字典
state_fields = models.JSONField(
'表单字段', blank=True, default=dict, help_text='json格式字典存储,包括读写属性1只读2必填3可选, 4:隐藏 示例:{"create_time":1,"title":2, "sn":1}, 内置特殊字段participant_info.participant_name:当前处理人信息(部门名称、角色名称)state.state_name:当前状态的状态名,workflow.workflow_name:工作流名称')
distribute_type = models.IntegerField('分配方式', default=1, choices=state_distribute_choices,
help_text='1.主动接单(如果当前处理人实际为多人的时候,需要先接单才能处理) 2.直接处理(即使当前处理人实际为多人,也可以直接处理) 3.随机分配(如果实际为多人,则系统会随机分配给其中一个人) 4.全部处理(要求所有参与人都要处理一遍,才能进入下一步)')
# filter_policy = models.IntegerField('参与人过滤策略', default=0, choices=state_filter_choices)
filter_dept = models.CharField('部门字段过滤', max_length=60, null=True, blank=True)
participant_cc = models.JSONField('抄送给', default=list, blank=True, help_text='抄送给(userid列表)')
on_reach_func = models.CharField('到达时调用方法', max_length=100, null=True, blank=True)
class Meta:
verbose_name = '工作流节点'
verbose_name_plural = verbose_name
def __str__(self):
return '{}-{}-{}'.format(self.id, self.workflow.name, self.name)
class Transition(CommonAModel):
"""
工作流流转定时器条件(允许跳过) 条件流转与定时器不可同时存在
"""
TRANSITION_ATTRIBUTE_TYPE_ACCEPT = 1 # 同意
TRANSITION_ATTRIBUTE_TYPE_REFUSE = 2 # 拒绝
TRANSITION_ATTRIBUTE_TYPE_OTHER = 3 # 其他
attribute_type_choices = (
(1, '同意'),
(2, '拒绝'),
(3, '其他')
)
TRANSITION_INTERVENE_TYPE_DELIVER = 1 # 转交操作
TRANSITION_INTERVENE_TYPE_ADD_NODE = 2 # 加签操作
TRANSITION_INTERVENE_TYPE_ADD_NODE_END = 3 # 加签处理完成
TRANSITION_INTERVENE_TYPE_ACCEPT = 4 # 接单操作
TRANSITION_INTERVENE_TYPE_COMMENT = 5 # 评论操作
TRANSITION_INTERVENE_TYPE_DELETE = 6 # 删除操作
TRANSITION_INTERVENE_TYPE_CLOSE = 7 # 强制关闭操作
TRANSITION_INTERVENE_TYPE_ALTER_STATE = 8 # 强制修改状态操作
TRANSITION_INTERVENE_TYPE_HOOK = 9 # hook操作
TRANSITION_INTERVENE_TYPE_RETREAT = 10 # 撤回
TRANSITION_INTERVENE_TYPE_CC = 11 # 抄送
intervene_type_choices = (
(0, '正常处理'),
(TRANSITION_INTERVENE_TYPE_DELIVER, '转交'),
(TRANSITION_INTERVENE_TYPE_ADD_NODE, '加签'),
(TRANSITION_INTERVENE_TYPE_ADD_NODE_END, '加签处理完成'),
(TRANSITION_INTERVENE_TYPE_ACCEPT, '接单'),
(TRANSITION_INTERVENE_TYPE_COMMENT, '评论'),
(TRANSITION_INTERVENE_TYPE_DELETE, '删除'),
(TRANSITION_INTERVENE_TYPE_CLOSE, '强制关闭'),
(TRANSITION_INTERVENE_TYPE_ALTER_STATE, '强制修改状态'),
(TRANSITION_INTERVENE_TYPE_HOOK, 'hook操作'),
(TRANSITION_INTERVENE_TYPE_RETREAT, '撤回'),
(TRANSITION_INTERVENE_TYPE_CC, '抄送')
)
name = models.CharField('操作', max_length=50)
workflow = models.ForeignKey(Workflow, on_delete=models.CASCADE, verbose_name='所属工作流')
timer = models.IntegerField('定时器(单位秒)', default=0, help_text='单位秒。处于源状态X秒后如果状态都没有过变化则自动流转到目标状态。设置时间有效')
source_state = models.ForeignKey(State, on_delete=models.CASCADE,
verbose_name='源状态', related_name='sstate_transition')
destination_state = models.ForeignKey(State, on_delete=models.CASCADE,
verbose_name='目的状态', related_name='dstate_transition')
condition_expression = models.JSONField('条件表达式', default=list, blank=True,
help_text='流转条件表达式,根据表达式中的条件来确定流转的下个状态,格式为[{"expression":"{days} > 3 and {days}<10", "target_state":11}] 其中{}用于填充工单的字段key,运算时会换算成实际的值当符合条件下个状态将变为target_state_id中的值,表达式只支持简单的运算或datetime/time运算.loonflow会以首次匹配成功的条件为准所以多个条件不要有冲突')
attribute_type = models.IntegerField(
'属性类型', default=1, choices=attribute_type_choices, help_text='属性类型1.同意2.拒绝3.其他')
field_require_check = models.BooleanField(
'是否校验必填项', default=True, help_text='默认在用户点击操作的时候需要校验工单表单的必填项,如果设置为否则不检查。用于如"退回"属性的操作,不需要填写表单内容')
on_submit_func = models.CharField('提交操作调用方法', max_length=100, null=True, blank=True)
class Meta:
verbose_name = '工作流流转'
verbose_name_plural = verbose_name
def __str__(self):
return '{}-{}-{}'.format(self.id, self.workflow.name, self.name)
class CustomField(CommonAModel):
"""自定义字段, 设定某个工作流有哪些自定义字段"""
field_type_choices = (
('string', '字符串'),
('int', '整型'),
('float', '浮点'),
('boolean', '布尔'),
('date', '日期'),
('datetime', '日期时间'),
('radio', '单选'),
('checkbox', '多选'),
('select', '单选下拉'),
('selects', '多选下拉'),
('cascader', '单选级联'),
('cascaders', '多选级联'),
('select_dg', '弹框单选'),
('select_dgs', '弹框多选'),
('textarea', '文本域'),
('file', '附件'),
('table', '表格')
)
workflow = models.ForeignKey(Workflow, on_delete=models.CASCADE, verbose_name='所属工作流')
field_type = models.CharField('类型', max_length=50, choices=field_type_choices,
help_text='string, int, float, date, datetime, radio, checkbox, select, selects, cascader, cascaders, select_dg, select_dgs,textarea, file')
field_key = models.CharField('字段标识', max_length=50, help_text='字段类型请尽量特殊,避免与系统中关键字冲突')
field_name = models.CharField('字段名称', max_length=50)
sort = models.IntegerField(
'排序', default=0, help_text='工单基础字段在表单中排序为:流水号0,标题20,状态id40,状态名41,创建人80,创建时间100,更新时间120.前端展示工单信息的表单可以根据这个id顺序排列')
default_value = models.CharField('默认值', null=True, blank=True, max_length=100,
help_text='前端展示时,可以将此内容作为表单中的该字段的默认值')
description = models.CharField('描述', max_length=100, blank=True, null=True,
help_text='字段的描述信息,可用于显示在字段的下方对该字段的详细描述')
placeholder = models.CharField('占位符', max_length=100, blank=True, null=True, help_text='用户工单详情表单中作为字段的占位符显示')
field_template = models.TextField('文本域模板', null=True, blank=True, help_text='文本域类型字段前端显示时可以将此内容作为字段的placeholder')
boolean_field_display = models.JSONField('布尔类型显示名', default=dict, blank=True,
help_text='当为布尔类型时候,可以支持自定义显示形式。{"1":"","0":""}或{"1":"需要","0":"不需要"},注意数字也需要引号')
field_choice = models.JSONField('选项值', default=list, blank=True,
help_text='选项值格式为list, 例["id":1, "name":"张三"]')
label = models.CharField('标签', max_length=1000, default='', help_text='处理特殊逻辑使用,比如sys_user用于获取用户作为选项')
# hook = models.CharField('hook', max_length=1000, default='', help_text='获取下拉选项用于动态选项值')
is_hidden = models.BooleanField('是否隐藏', default=False, help_text='可用于携带不需要用户查看的字段信息')
group = models.CharField('字段分组', max_length=100, null=True, blank=True)
parent = models.ForeignKey('self', verbose_name='父字段', on_delete=models.SET_NULL, null=True, blank=True)
class Ticket(CommonBModel):
"""
工单
"""
TICKET_ACT_STATE_DRAFT = 0 # 草稿中
TICKET_ACT_STATE_ONGOING = 1 # 进行中
TICKET_ACT_STATE_BACK = 2 # 被退回
TICKET_ACT_STATE_RETREAT = 3 # 被撤回
TICKET_ACT_STATE_FINISH = 4 # 已完成
TICKET_ACT_STATE_CLOSED = 5 # 已关闭
act_state_choices = (
(TICKET_ACT_STATE_DRAFT, '草稿中'),
(TICKET_ACT_STATE_ONGOING, '进行中'),
(TICKET_ACT_STATE_BACK, '被退回'),
(TICKET_ACT_STATE_RETREAT, '被撤回'),
(TICKET_ACT_STATE_FINISH, '已完成'),
(TICKET_ACT_STATE_CLOSED, '已关闭')
)
category_choices = (
('all', '全部'),
('owner', '我创建的'),
('duty', '待办'),
('worked', '我处理的'),
('cc', '抄送我的')
)
title = models.CharField('标题', max_length=500, null=True, blank=True, help_text="工单标题")
workflow = models.ForeignKey(Workflow, on_delete=models.CASCADE, verbose_name='关联工作流')
sn = models.CharField('流水号', max_length=25, help_text="工单的流水号")
state = models.ForeignKey(State, on_delete=models.CASCADE, verbose_name='当前状态', related_name='ticket_state')
parent = models.ForeignKey('self', null=True, blank=True, on_delete=models.CASCADE, verbose_name='父工单')
parent_state = models.ForeignKey(State, null=True, blank=True, on_delete=models.CASCADE,
verbose_name='父工单状态', related_name='ticket_parent_state')
ticket_data = models.JSONField('工单数据', default=dict, help_text='工单自定义字段内容')
in_add_node = models.BooleanField('加签状态中', default=False, help_text='是否处于加签状态下')
add_node_man = models.ForeignKey(User, verbose_name='加签人', on_delete=models.SET_NULL,
null=True, blank=True, help_text='加签操作的人,工单当前处理人处理完成后会回到该处理人,当处于加签状态下才有效')
script_run_last_result = models.BooleanField('脚本最后一次执行结果', default=True)
participant_type = models.IntegerField(
'当前处理人类型', default=0, help_text='0.无处理人,1.个人,2.多人', choices=State.state_participanttype_choices)
participant = models.JSONField('当前处理人', default=list, blank=True, help_text='可以为空(无处理人的情况,如结束状态)、userid、userid列表')
act_state = models.IntegerField('进行状态', default=1, help_text='当前工单的进行状态', choices=act_state_choices)
multi_all_person = models.JSONField('全部处理的结果', default=dict, blank=True, help_text='需要当前状态处理人全部处理时实际的处理结果json格式')
class TicketFlow(BaseModel):
"""
工单流转日志
"""
ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, verbose_name='关联工单', related_name='ticketflow_ticket')
transition = models.ForeignKey(Transition, verbose_name='流转id',
help_text='与worklow.Transition关联, 为空时表示认为干预的操作', on_delete=models.SET_NULL, null=True, blank=True)
suggestion = models.CharField('处理意见', max_length=10000, default='', blank=True)
participant_type = models.IntegerField(
'处理人类型', default=0, help_text='0.无处理人,1.个人,2.多人等', choices=State.state_participanttype_choices)
participant = models.ForeignKey(User, verbose_name='处理人', on_delete=models.SET_NULL,
null=True, blank=True, related_name='ticketflow_participant')
participant_str = models.CharField('处理人', max_length=200, null=True, blank=True, help_text='非人工处理的处理人相关信息')
state = models.ForeignKey(State, verbose_name='当前状态', default=0, blank=True, on_delete=models.CASCADE)
ticket_data = models.JSONField('工单数据', default=dict, blank=True, help_text='可以用于记录当前表单数据json格式')
intervene_type = models.IntegerField('干预类型', default=0, help_text='流转类型', choices=Transition.intervene_type_choices)
participant_cc = models.JSONField('抄送给', default=list, blank=True, help_text='抄送给(userid列表)')

270
apps/wf/serializers.py Executable file
View File

@ -0,0 +1,270 @@
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
from .models import State, Ticket, TicketFlow, Workflow, Transition, CustomField
class WorkflowSerializer(CustomModelSerializer):
class Meta:
model = Workflow
fields = '__all__'
class WorkflowCloneSerializer(CustomModelSerializer):
class Meta:
model = Workflow
fields = ['name', 'key']
class StateSerializer(CustomModelSerializer):
class Meta:
model = State
fields = '__all__'
class WorkflowSimpleSerializer(CustomModelSerializer):
class Meta:
model = Workflow
fields = ['id', 'name', 'key']
class StateSimpleSerializer(CustomModelSerializer):
class Meta:
model = State
fields = ['id', 'name', 'type', 'distribute_type', 'enable_retreat', 'enable_deliver', 'key']
class TransitionSerializer(CustomModelSerializer):
source_state_ = StateSimpleSerializer(source='source_state', read_only=True)
destination_state_ = StateSimpleSerializer(source='destination_state', read_only=True)
class Meta:
model = Transition
fields = '__all__'
@staticmethod
def setup_eager_loading(queryset):
""" Perform necessary eager loading of data. """
queryset = queryset.select_related('source_state', 'destination_state')
return queryset
class TransitionSimpleSerializer(CustomModelSerializer):
class Meta:
model = Transition
fields = ['id', 'name', 'attribute_type']
class AllField(serializers.Field):
def to_representation(self, value):
return value
def to_internal_value(self, data):
return data
class FieldChoiceSerializer(serializers.Serializer):
id = AllField(label='ID')
name = serializers.CharField(label='名称')
class CustomFieldSerializer(CustomModelSerializer):
class Meta:
model = CustomField
fields = '__all__'
class CustomFieldCreateUpdateSerializer(CustomModelSerializer):
field_choice = FieldChoiceSerializer(label='选项列表', many=True, required=False)
class Meta:
model = CustomField
fields = ['workflow', 'field_type', 'field_key', 'field_name',
'sort', 'default_value', 'description', 'placeholder', 'field_template',
'boolean_field_display', 'field_choice', 'label', 'is_hidden']
class TicketSimpleSerializer(CustomModelSerializer):
state_ = StateSimpleSerializer(source='state', read_only=True)
class Meta:
model = Ticket
fields = '__all__'
class TicketCreateSerializer(CustomModelSerializer):
transition = serializers.PrimaryKeyRelatedField(queryset=Transition.objects.all(), write_only=True)
title = serializers.CharField(allow_blank=True, required=False)
class Meta:
model = Ticket
fields = ['title', 'workflow', 'ticket_data', 'transition']
def create(self, validated_data):
return super().create(validated_data)
class TicketSerializer(CustomModelSerializer):
workflow_ = WorkflowSimpleSerializer(source='workflow', read_only=True)
state_ = StateSimpleSerializer(source='state', read_only=True)
class Meta:
model = Ticket
fields = '__all__'
@staticmethod
def setup_eager_loading(queryset):
queryset = queryset.select_related('workflow', 'state')
return queryset
class TicketListSerializer(CustomModelSerializer):
workflow_ = WorkflowSimpleSerializer(source='workflow', read_only=True)
state_ = StateSimpleSerializer(source='state', read_only=True)
participant_ = serializers.SerializerMethodField()
class Meta:
model = Ticket
fields = ['id', 'title', 'sn', 'workflow', 'workflow_', 'state', 'state_',
'act_state', 'create_time', 'update_time', 'participant_type', 'create_by', 'ticket_data',
'participant_', 'script_run_last_result', 'participant']
def get_participant_(self, obj):
if obj.participant_type in [1, 2]:
instance = User.objects.filter(id__in=obj.participant) | User.objects.filter(id=obj.participant)
return UserSimpleSerializer(instance=instance, many=True).data
return None
@staticmethod
def setup_eager_loading(queryset):
queryset = queryset.select_related('workflow', 'state')
return queryset
class TicketDetailSerializer(CustomModelSerializer):
workflow_ = WorkflowSimpleSerializer(source='workflow', read_only=True)
state_ = StateSimpleSerializer(source='state', read_only=True)
ticket_data_ = serializers.SerializerMethodField()
participant_ = serializers.SerializerMethodField()
class Meta:
model = Ticket
fields = '__all__'
def get_participant_(self, obj):
if obj.participant_type in [1, 2]:
instance = User.objects.filter(id__in=obj.participant) | User.objects.filter(id=obj.participant)
return UserSimpleSerializer(instance=instance, many=True).data
return None
@staticmethod
def setup_eager_loading(queryset):
queryset = queryset.select_related('workflow', 'state')
return queryset
def get_ticket_data_(self, obj):
ticket_data = obj.ticket_data
state_fields = obj.state.state_fields
all_fields = CustomField.objects.filter(workflow=obj.workflow).order_by('sort')
all_fields_l = CustomFieldSerializer(instance=all_fields, many=True).data
for i in all_fields_l:
key = i['field_key']
i['field_state'] = state_fields.get(key, 1)
i['field_value'] = ticket_data.get(key, None)
i['field_display'] = i['field_value'] # 该字段是用于查看详情直接展示
if i['field_value']:
if 'sys_user' in i['label']:
if isinstance(i['field_value'], list):
i['field_display'] = ','.join(list(User.objects.filter(
id__in=i['field_value']).values_list('name', flat=True)))
else:
f_obj = User.objects.filter(id=i['field_value']).first()
if f_obj:
i['field_display'] = f_obj.name
elif 'deptSelect' in i['label']:
if isinstance(i['field_value'], list):
i['field_display'] = ','.join(list(Dept.objects.filter(
id__in=i['field_value']).values_list('name', flat=True)))
else:
f_obj = Dept.objects.filter(id=i['field_value']).first()
if f_obj:
i['field_display'] = f_obj.name
elif i['field_type'] in ['radio', 'select']:
for m in i['field_choice']:
if m['id'] == i['field_value']:
i['field_display'] = m['name']
elif i['field_type'] in ['checkbox', 'selects']:
d_list = []
for m in i['field_choice']:
if m['id'] in i['field_value']:
d_list.append(m['name'])
i['field_display'] = ','.join(d_list)
return all_fields_l
def filter_display(self, item, field_value):
if item['id'] == field_value:
return
class TicketFlowSerializer(CustomModelSerializer):
participant_ = UserSignatureSerializer(source='participant', read_only=True)
state_ = StateSimpleSerializer(source='state', read_only=True)
transition_ = TransitionSimpleSerializer(source='transition', read_only=True)
transition_attribute = serializers.CharField(source='transition.attribute_type', read_only=True)
class Meta:
model = TicketFlow
fields = '__all__'
class TicketFlowSimpleSerializer(CustomModelSerializer):
participant_ = UserSignatureSerializer(source='participant', read_only=True)
state_ = StateSimpleSerializer(source='state', read_only=True)
transition_ = TransitionSimpleSerializer(source='transition', read_only=True)
transition_attribute = serializers.CharField(source='transition.attribute_type', read_only=True)
class Meta:
model = TicketFlow
exclude = ['ticket_data']
class TicketHandleSerializer(serializers.Serializer):
transition = serializers.PrimaryKeyRelatedField(queryset=Transition.objects.all(), label="流转id")
ticket_data = serializers.JSONField(label="表单数据json")
suggestion = serializers.CharField(label="处理意见", required=False, allow_blank=True)
class TicketDeliverSerializer(serializers.Serializer):
target_user = serializers.PrimaryKeyRelatedField(queryset=User.objects.all(), label='转交人')
suggestion = serializers.CharField(label="转交原因", required=False)
class TicketRetreatSerializer(serializers.Serializer):
suggestion = serializers.CharField(label="撤回原因", required=False)
class TicketCloseSerializer(serializers.Serializer):
suggestion = serializers.CharField(label="关闭原因", required=False)
class TicketAddNodeSerializer(serializers.Serializer):
suggestion = serializers.CharField(label="加签说明", required=False)
toadd_user = serializers.PrimaryKeyRelatedField(queryset=User.objects.all(), label='发送给谁去加签')
class TicketAddNodeEndSerializer(serializers.Serializer):
suggestion = serializers.CharField(label="加签意见", required=False)
class TicketDestorySerializer(serializers.Serializer):
ids = serializers.ListField(child=serializers.PrimaryKeyRelatedField(queryset=Ticket.objects.all()), label='工单ID列表')
class TicketStateUpateSerializer(serializers.ModelSerializer):
suggestion = serializers.CharField(label="变更理由", required=False)
need_log = serializers.BooleanField(label="是否记录日志", default=True)
class Meta:
model = Ticket
fields = ['state', 'suggestion', 'need_log']

474
apps/wf/services.py Executable file
View File

@ -0,0 +1,474 @@
import importlib
from threading import Thread
from apps.utils.sms import send_sms
from apps.wf.serializers import TicketSimpleSerializer
from apps.system.models import Dept, User
from apps.wf.models import CustomField, State, Ticket, TicketFlow, Transition, Workflow
from rest_framework.exceptions import APIException, PermissionDenied, ValidationError
from django.utils import timezone
from datetime import timedelta, datetime
import random
from apps.utils.queryset import get_parent_queryset
from apps.wf.tasks import run_task
from rest_framework.exceptions import ParseError
class WfService(object):
@staticmethod
def get_worlflow_states(workflow: Workflow):
"""
获取工作流状态列表
"""
return State.objects.filter(workflow=workflow, is_deleted=False).order_by('sort')
@staticmethod
def get_workflow_transitions(workflow: Workflow):
"""
获取工作流流转列表
"""
return Transition.objects.filter(workflow=workflow, is_deleted=False)
@staticmethod
def get_workflow_start_state(workflow: Workflow):
"""
获取工作流初始状态
"""
try:
wf_state_obj = State.objects.get(workflow=workflow, type=State.STATE_TYPE_START, is_deleted=False)
return wf_state_obj
except Exception:
raise APIException('工作流状态配置错误')
@staticmethod
def get_workflow_end_state(workflow: Workflow):
"""
获取工作流结束状态
"""
try:
wf_state_obj = State.objects.get(workflow=workflow, type=State.STATE_TYPE_END, is_deleted=False)
return wf_state_obj
except Exception:
raise APIException('工作流状态配置错误')
@staticmethod
def get_workflow_custom_fields(workflow: Workflow):
"""
获取工单字段
"""
return CustomField.objects.filter(is_deleted=False, workflow=workflow).order_by('sort')
@staticmethod
def get_workflow_custom_fields_list(workflow: Workflow):
"""
获取工单字段key List
"""
return list(CustomField.objects.filter(is_deleted=False,
workflow=workflow).order_by('sort').values_list('field_key', flat=True))
@classmethod
def get_ticket_transitions(cls, ticket: Ticket):
"""
获取工单当前状态下可用的流转条件
"""
return cls.get_state_transitions(ticket.state)
@classmethod
def get_state_transitions(cls, state: State):
"""
获取状态可执行的操作
"""
return Transition.objects.filter(is_deleted=False, source_state=state).all()
@classmethod
def get_ticket_steps(cls, ticket: Ticket):
steps = cls.get_worlflow_states(ticket.workflow)
nsteps_list = []
for i in steps:
if ticket.state == i or (not i.is_hidden):
nsteps_list.append(i)
return nsteps_list
@classmethod
def get_transition_by_args(cls, kwargs: dict):
"""
查询并获取流转
"""
kwargs['is_deleted'] = False
return Transition.objects.filter(**kwargs).all()
@classmethod
def get_ticket_sn(cls, workflow: Workflow, now=None):
"""
生成工单流水号
"""
if now is None:
now = datetime.now()
today = str(now)[:10]+' 00:00:00'
ticket_day_count_new = Ticket.objects.get_queryset(all=True).filter(
create_time__gte=today, create_time__lte=now, workflow=workflow).count()
return '%s_%04d%02d%02d%04d' % (workflow.sn_prefix, now.year, now.month, now.day, ticket_day_count_new)
@classmethod
def get_next_state_by_transition_and_ticket_info(cls, ticket: Ticket,
transition: Transition, new_ticket_data: dict = {}) -> object:
"""
获取下个节点状态
"""
destination_state = transition.destination_state
ticket_all_value = cls.get_ticket_all_field_value(ticket)
ticket_all_value.update(**new_ticket_data)
for key, value in ticket_all_value.items():
if isinstance(ticket_all_value[key], str):
ticket_all_value[key] = "'" + ticket_all_value[key] + "'"
if transition.condition_expression:
for i in transition.condition_expression:
expression = i['expression'].format(**ticket_all_value)
import datetime
import time # 用于支持条件表达式中对时间的操作
if eval(expression, {'__builtins__': None}, {'datetime': datetime, 'time': time}):
destination_state = State.objects.get(pk=i['target_state'])
return destination_state
return destination_state
@classmethod
def get_ticket_state_participant_info(cls, state: State,
ticket: Ticket, new_ticket_data: dict = {}, handler: User = None):
"""
获取工单目标状态实际的处理人, 处理人类型
"""
if state.type == State.STATE_TYPE_START:
"""
回到初始状态
"""
return dict(destination_participant_type=State.PARTICIPANT_TYPE_PERSONAL,
destination_participant=ticket.create_by.id,
multi_all_person={})
elif state.type == State.STATE_TYPE_END:
"""
到达结束状态
"""
return dict(destination_participant_type=0,
destination_participant=0,
multi_all_person={})
multi_all_person_dict = {}
destination_participant_type, destination_participant = state.participant_type, state.participant
if destination_participant_type == State.PARTICIPANT_TYPE_FIELD:
destination_participant = new_ticket_data.get(destination_participant, 0) if destination_participant \
in new_ticket_data else Ticket.ticket_data.get(destination_participant, 0)
elif destination_participant_type == State.PARTICIPANT_TYPE_FORMCODE: # 代码获取
module, func = destination_participant.rsplit(".", 1)
m = importlib.import_module(module)
f = getattr(m, func)
destination_participant = f(state=state, ticket=ticket, new_ticket_data=new_ticket_data, handler=handler)
elif destination_participant_type == State.PARTICIPANT_TYPE_DEPT: # 部门
destination_participant = list(User.objects.filter(
depts__in=destination_participant).values_list('id', flat=True))
elif destination_participant_type == State.PARTICIPANT_TYPE_POST: # 岗位
user_queryset = User.objects.filter(posts__in=destination_participant)
# 如果选择了岗位, 可能需要走过滤策略
if state.filter_dept not in [0, '0', None]:
# if not new_ticket_data.get(state.filter_dept, None):
# raise ParseError('部门过滤字段错误')
if '.' not in state.filter_dept:
dpts = Dept.objects.filter(id=new_ticket_data[state.filter_dept])
else:
dpt_attrs = state.filter_dept.split('.') # 通过反向查询得到可能有多层
expr = ticket
for i in dpt_attrs:
expr = getattr(expr, i)
dpts = Dept.objects.filter(id=expr.id)
user_queryset = user_queryset.filter(depts__in=dpts)
# if state.filter_policy == 1:
# depts = get_parent_queryset(ticket.belong_dept)
# user_queryset = user_queryset.filter(depts__in=depts)
# elif state.filter_policy == 2:
# depts = get_parent_queryset(ticket.create_by.belong_dept)
# user_queryset = user_queryset.filter(depts__in=depts)
# elif state.filter_policy == 3:
# depts = get_parent_queryset(handler.belong_dept)
# user_queryset = user_queryset.filter(depts__in=depts)
destination_participant = list(user_queryset.values_list('id', flat=True))
elif destination_participant_type == State.PARTICIPANT_TYPE_ROLE: # 角色
user_queryset = User.objects.filter(roles__in=destination_participant, is_active=True, is_deleted=False)
# 如果选择了角色, 需要走过滤策略
if state.filter_dept not in [0, '0', None]:
# if not new_ticket_data.get(state.filter_dept, None):
# raise ParseError('部门过滤字段错误')
if '.' not in state.filter_dept:
dpts = Dept.objects.filter(id=new_ticket_data[state.filter_dept])
else:
dpt_attrs = state.filter_dept.split('.')
expr = ticket
for i in dpt_attrs:
expr = getattr(expr, i)
dpts = Dept.objects.filter(id=expr.id)
user_queryset = user_queryset.filter(depts__in=dpts)
# if state.filter_policy == 1:
# depts = get_parent_queryset(ticket.belong_dept)
# user_queryset = user_queryset.filter(depts__in=depts)
# elif state.filter_policy == 2:
# depts = get_parent_queryset(ticket.create_by.belong_dept)
# user_queryset = user_queryset.filter(depts__in=depts)
# elif state.filter_policy == 3:
# depts = get_parent_queryset(handler.belong_dept)
# user_queryset = user_queryset.filter(depts__in=depts)
destination_participant = list(user_queryset.values_list('id', flat=True))
if destination_participant_type != 0 and (not destination_participant):
raise ParseError('下一步未找到处理人,工单无法继续')
if type(destination_participant) == list:
destination_participant_type = State.PARTICIPANT_TYPE_MULTI
destination_participant = list(set(destination_participant))
if len(destination_participant) == 1: # 如果只有一个人
destination_participant_type = State.PARTICIPANT_TYPE_PERSONAL
destination_participant = destination_participant[0]
elif destination_participant_type == State.PARTICIPANT_TYPE_ROBOT:
pass
else:
destination_participant_type = State.PARTICIPANT_TYPE_PERSONAL
if destination_participant_type == State.PARTICIPANT_TYPE_MULTI:
if state.distribute_type == State.STATE_DISTRIBUTE_TYPE_RANDOM:
destination_participant = random.choice(destination_participant)
elif state.distribute_type == State.STATE_DISTRIBUTE_TYPE_ALL:
for i in destination_participant:
multi_all_person_dict[i] = {}
return dict(destination_participant_type=destination_participant_type,
destination_participant=destination_participant,
multi_all_person=multi_all_person_dict)
@classmethod
def ticket_handle_permission_check(cls, ticket: Ticket, user: User) -> dict:
if ticket.in_add_node:
return dict(permission=False, msg="工单当前处于加签中,请加签完成后操作", need_accept=False)
transitions = cls.get_state_transitions(ticket.state)
if not transitions:
return dict(permission=True, msg="工单当前状态无需操作")
participant = ticket.participant
state = ticket.state
if type(participant) == list:
if user.id not in participant:
return dict(permission=False, msg="非当前处理人", need_accept=False)
if len(participant) > 1 and state.distribute_type == State.STATE_DISTRIBUTE_TYPE_ACTIVE:
return dict(permission=False, msg="需要先接单再处理", need_accept=True)
else:
if user.id != participant:
return dict(permission=False, msg="非当前处理人", need_accept=False)
return dict(permission=True, msg="", need_accept=False)
@classmethod
def check_dict_has_all_same_value(cls, dict_obj: object) -> tuple:
"""
check whether all key are equal in a dict
:param dict_obj:
:return:
"""
value_list = []
for key, value in dict_obj.items():
value_list.append(value)
value_0 = value_list[0]
for value in value_list:
if value_0 != value:
return False
return True
@classmethod
def get_ticket_all_field_value(cls, ticket: Ticket) -> dict:
"""
工单所有字段的值
get ticket's all field value
:param ticket:
:return:
"""
# 获取工单基础表中的字段中的字段信息
field_info_dict = TicketSimpleSerializer(instance=ticket).data
# 获取自定义字段的值
custom_fields_queryset = cls.get_workflow_custom_fields(ticket.workflow)
for i in custom_fields_queryset:
field_info_dict[i.field_key] = ticket.ticket_data.get(i.field_key, None)
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,
by_task: bool = False, by_hook: bool = False):
source_state = ticket.state
source_ticket_data = ticket.ticket_data
if transition.source_state != source_state:
raise ParseError('非该工单节点状态下的流转')
# 提交时可能进行的操作
if transition.on_submit_func:
module, func = transition.on_submit_func.rsplit(".", 1)
m = importlib.import_module(module)
f = getattr(m, func)
f(ticket=ticket, transition=transition, new_ticket_data=new_ticket_data)
# 校验处理权限
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 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]:
raise ValidationError('字段{}必填'.format(key))
destination_state = cls.get_next_state_by_transition_and_ticket_info(ticket, transition, new_ticket_data)
multi_all_person = ticket.multi_all_person
if multi_all_person:
multi_all_person[handler.id] = dict(transition=transition.id)
# 判断所有人处理结果是否一致
if WfService.check_dict_has_all_same_value(multi_all_person):
participant_info = WfService.get_ticket_state_participant_info(
destination_state, ticket, new_ticket_data)
destination_participant_type = participant_info.get('destination_participant_type', 0)
destination_participant = participant_info.get('destination_participant', 0)
multi_all_person = {}
else:
# 处理人没有没有全部处理完成或者处理动作不一致
destination_participant_type = ticket.participant_type
destination_state = ticket.state # 保持原状态
destination_participant = []
for key, value in multi_all_person.items():
if not value:
destination_participant.append(key)
else:
# 当前处理人类型非全部处理
participant_info = WfService.get_ticket_state_participant_info(destination_state, ticket, new_ticket_data)
destination_participant_type = participant_info.get('destination_participant_type', 0)
destination_participant = participant_info.get('destination_participant', 0)
multi_all_person = participant_info.get('multi_all_person', {})
# 更新工单信息:基础字段及自定义字段, add_relation字段 需要下个处理人是部门、角色等的情况
ticket.state = destination_state
ticket.participant_type = destination_participant_type
ticket.participant = destination_participant
ticket.multi_all_person = multi_all_person
if destination_state.type == State.STATE_TYPE_END:
ticket.act_state = Ticket.TICKET_ACT_STATE_FINISH
elif destination_state.type == State.STATE_TYPE_START:
ticket.act_state = Ticket.TICKET_ACT_STATE_DRAFT
else:
ticket.act_state = Ticket.TICKET_ACT_STATE_ONGOING
if transition.attribute_type == Transition.TRANSITION_ATTRIBUTE_TYPE_REFUSE:
ticket.act_state = Ticket.TICKET_ACT_STATE_BACK
# 只更新必填和可选的字段
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:
source_ticket_data[key] = new_ticket_data[key]
ticket.ticket_data = source_ticket_data
ticket.save()
# 更新工单流转记录
if not by_task:
TicketFlow.objects.create(ticket=ticket, state=source_state,
ticket_data=WfService.get_ticket_all_field_value(ticket),
suggestion=suggestion, participant_type=State.PARTICIPANT_TYPE_PERSONAL,
participant=handler, transition=transition)
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,
participant=None, participant_cc=source_state.participant_cc)
# 目标状态需要抄送
if destination_state.participant_cc:
TicketFlow.objects.create(ticket=ticket, state=destination_state,
participant_type=0, intervene_type=Transition.TRANSITION_INTERVENE_TYPE_CC,
participant=None, participant_cc=destination_state.participant_cc)
if destination_state.type == State.STATE_TYPE_END:
TicketFlow.objects.create(ticket=ticket, state=destination_state,
participant_type=0, intervene_type=0,
participant=None)
cls.task_ticket(ticket=ticket)
return ticket
@classmethod
def update_ticket_state(cls, ticket: Ticket, new_state: State, suggestion: str, handler: User, need_log:bool):
participant_info = cls.get_ticket_state_participant_info(
new_state, ticket, {})
source_state = ticket.state
ticket.state = new_state
ticket.participant_type = participant_info.get('destination_participant_type', 0)
ticket.participant = participant_info.get('destination_participant', 0)
ticket.multi_all_person = {}
ticket.save()
if need_log:
TicketFlow.objects.create(ticket=ticket, state=source_state,
ticket_data=WfService.get_ticket_all_field_value(ticket),
suggestion=suggestion, participant_type=State.PARTICIPANT_TYPE_PERSONAL,
intervene_type=Transition.TRANSITION_INTERVENE_TYPE_ALTER_STATE,
participant=handler)
cls.task_ticket(ticket=ticket)
@classmethod
def task_ticket(cls, ticket: Ticket):
"""
执行任务(自定义任务和通知)
"""
state = ticket.state
# 如果目标状态有func,由func执行额外操作(比如发送通知)
if state.on_reach_func:
module, func = state.on_reach_func.rsplit(".", 1)
m = importlib.import_module(module)
f = getattr(m, func)
f(ticket=ticket) # 同步执行
# wf默认只发送通知
last_log = TicketFlow.objects.filter(ticket=ticket).order_by('-create_time').first()
if (last_log.state != state or
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()
# 如果目标状态是脚本则异步执行
if state.participant_type == State.PARTICIPANT_TYPE_ROBOT:
run_task.delay(ticket_id=ticket.id)
@classmethod
def close_by_task(cls, ticket: Ticket, suggestion: str):
# 定时任务触发的工单关闭
end_state = WfService.get_workflow_end_state(ticket.workflow)
ticket.state = end_state
ticket.participant_type = 0
ticket.participant = 0
ticket.act_state = Ticket.TICKET_ACT_STATE_CLOSED
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_ROBOT,
intervene_type=Transition.TRANSITION_INTERVENE_TYPE_CLOSE, transition=None)
def send_ticket_notice_t(ticket: Ticket):
"""
发送通知
"""
params = {'workflow': ticket.workflow.name, 'state': ticket.state.name}
if ticket.participant_type == 1:
# 发送短信通知
pt = User.objects.filter(id=ticket.participant).first()
if pt and pt.phone:
send_sms(pt.phone, 1002, params)
elif ticket.participant_type == 2:
pts = User.objects.filter(id__in=ticket.participant)
for i in pts:
if i.phone:
send_sms(i.phone, 1002, params)

91
apps/wf/tasks.py Normal file
View File

@ -0,0 +1,91 @@
# Create your tasks here
from __future__ import absolute_import, unicode_literals
import importlib
import logging
import traceback
from apps.system.models import User
from apps.utils.sms import send_sms
from apps.utils.tasks import CustomTask
from celery import shared_task
from apps.wf.models import State, Ticket, TicketFlow, Transition
from apps.wf.serializers import TicketDetailSerializer
import time
from apps.utils.tasks import send_mail_task
from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync
myLogger = logging.getLogger('log')
@shared_task(base=CustomTask)
def ticket_push(ticketId, userId):
ticket = Ticket.objects.get(id=ticketId)
channel_layer = get_channel_layer()
data = {
'type': 'ticket',
'ticket': TicketDetailSerializer(instance=ticket).data,
'msg': ''
}
async_to_sync(channel_layer.group_send)(f"user_{userId}", data)
@shared_task(base=CustomTask)
def send_ticket_notice(ticket_id):
"""
发送通知
"""
ticket = Ticket.objects.filter(id=ticket_id).first()
params = {'workflow': ticket.workflow.name, 'state': ticket.state.name}
if ticket:
if ticket.participant_type == 1:
# ws推送
# 发送短信通知
pt = User.objects.filter(id=ticket.participant).first()
ticket_push.delay(ticket.id, pt.id)
if pt and pt.phone:
send_sms(pt.phone, 1002, params)
elif ticket.participant_type == 2:
pts = User.objects.filter(id__in=ticket.participant)
for i in pts:
ticket_push.delay(ticket.id, i.id)
if i.phone:
send_sms(i.phone, 1002, params)
@shared_task(base=CustomTask)
def run_task(ticket_id: str, retry_num=1):
ticket = Ticket.objects.get(id=ticket_id)
transition_obj = Transition.objects.filter(
source_state=ticket.state, is_deleted=False).first()
script_result = True
script_result_msg = ''
script_str = ticket.participant
try:
module, func = script_str.rsplit(".", 1)
m = importlib.import_module(module)
f = getattr(m, func)
f(ticket)
except Exception:
retry_num_new = retry_num - 1
err_detail = traceback.format_exc()
myLogger.error('工作流脚本执行失败', exc_info=True)
script_result = False
script_result_msg = err_detail
if retry_num_new >= 0:
time.sleep(10)
run_task.delay(ticket_id, retry_num_new)
return
send_mail_task.delay(subject='wf_task_error', message=err_detail) # run_task执行失败发送邮件
ticket = Ticket.objects.filter(id=ticket_id).first()
if not script_result:
ticket.script_run_last_result = False
ticket.save()
# 记录日志
TicketFlow.objects.create(ticket=ticket, state=ticket.state,
participant_type=State.PARTICIPANT_TYPE_ROBOT,
participant_str='func:{}'.format(script_str),
transition=transition_obj,
suggestion=script_result_msg)
# 自动流转
if script_result and transition_obj:
from apps.wf.services import WfService
WfService.handle_ticket(ticket=ticket, transition=transition_obj,
new_ticket_data=ticket.ticket_data, by_task=True)

3
apps/wf/tests.py Executable file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

19
apps/wf/urls.py Executable file
View File

@ -0,0 +1,19 @@
from apps.wf.views import CustomFieldViewSet, StateViewSet, TicketFlowViewSet, \
TicketViewSet, TransitionViewSet, WorkflowKeyInitView, WorkflowViewSet
from django.urls import path, include
from rest_framework.routers import DefaultRouter
API_BASE_URL = 'api/wf/'
HTML_BASE_URL = 'wf/'
router = DefaultRouter()
router.register('workflow', WorkflowViewSet, basename='workflow')
router.register('state', StateViewSet, basename='state')
router.register('transition', TransitionViewSet, basename='transition')
router.register('customfield', CustomFieldViewSet, basename='customfield')
router.register('ticket', TicketViewSet, basename='ticket')
router.register('ticketflow', TicketFlowViewSet, basename='ticketflow')
urlpatterns = [
path(API_BASE_URL, include(router.urls)),
path(API_BASE_URL + 'workflow/<str:key>/init_key/', WorkflowKeyInitView.as_view())
]

546
apps/wf/views.py Executable file
View File

@ -0,0 +1,546 @@
from django.utils import timezone
from django.db import transaction
from rest_framework.views import APIView
from apps.system.models import User
from apps.utils.viewsets import CustomGenericViewSet, CustomModelViewSet
from apps.wf.filters import TicketFilterSet
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, \
TicketCreateSerializer, TicketDeliverSerializer, TicketDestorySerializer, TicketFlowSerializer, \
TicketHandleSerializer, TicketRetreatSerializer, \
TicketSerializer, TransitionSerializer, WorkflowSerializer, \
TicketListSerializer, TicketDetailSerializer, WorkflowCloneSerializer, TicketStateUpateSerializer, TicketFlowSimpleSerializer
from rest_framework.viewsets import GenericViewSet, ModelViewSet
from rest_framework.decorators import action
from apps.wf.models import CustomField, Ticket, Workflow, State, Transition, TicketFlow
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 rest_framework.serializers import Serializer
from apps.utils.snowflake import idWorker
import importlib
from apps.wf.tasks import run_task
# Create your views here.
class WorkflowKeyInitView(APIView):
perms_map = {'get': '*'}
def get(self, request, key=None):
"""
新建工单初始化-通过key
新建工单初始化
"""
ret = {}
try:
wf = Workflow.objects.get(key=key)
except Exception:
raise NotFound('获取工作流失败')
start_state = WfService.get_workflow_start_state(wf)
transitions = WfService.get_state_transitions(start_state)
ret['workflow'] = wf.id
ret['transitions'] = TransitionSerializer(instance=transitions, many=True).data
field_list = CustomFieldSerializer(instance=WfService.get_workflow_custom_fields(wf), many=True).data
for i in field_list:
if i['field_key'] in start_state.state_fields:
i['field_attribute'] = start_state.state_fields[i['field_key']]
else:
i['field_attribute'] = State.STATE_FIELD_READONLY
ret['field_list'] = field_list
return Response(ret)
class WorkflowViewSet(CustomModelViewSet):
queryset = Workflow.objects.all()
serializer_class = WorkflowSerializer
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)
def states(self, request, pk=None):
"""
工作流下的状态节点
"""
wf = self.get_object()
serializer = self.serializer_class(instance=WfService.get_worlflow_states(wf), many=True)
return Response(serializer.data)
@action(methods=['get'], detail=True, perms_map={'get': 'workflow.update'},
pagination_class=None, serializer_class=TransitionSerializer)
def transitions(self, request, pk=None):
"""
工作流下的流转规则
"""
wf = self.get_object()
serializer = self.serializer_class(instance=WfService.get_workflow_transitions(wf), many=True)
return Response(serializer.data)
@action(methods=['get'], detail=True, perms_map={'get': 'workflow.update'},
pagination_class=None, serializer_class=CustomFieldSerializer)
def customfields(self, request, pk=None):
"""
工作流下的自定义字段
"""
wf = self.get_object()
serializer = self.serializer_class(instance=CustomField.objects.filter(
workflow=wf, is_deleted=False).order_by('sort'), many=True)
return Response(serializer.data)
@action(methods=['get'], detail=True, perms_map={'get': '*'})
def init(self, request, pk=None):
"""
新建工单初始化
新建工单初始化
"""
ret = {}
wf = self.get_object()
start_state = WfService.get_workflow_start_state(wf)
transitions = WfService.get_state_transitions(start_state)
ret['workflow'] = wf.id
ret['transitions'] = TransitionSerializer(instance=transitions, many=True).data
field_list = CustomFieldSerializer(instance=WfService.get_workflow_custom_fields(wf), many=True).data
for i in field_list:
if i['field_key'] in start_state.state_fields:
i['field_attribute'] = start_state.state_fields[i['field_key']]
else:
i['field_attribute'] = State.STATE_FIELD_READONLY
ret['field_list'] = field_list
return Response(ret)
@action(methods=['post'], detail=True, perms_map={'post': 'workflow.clone'},
pagination_class=None, serializer_class=WorkflowCloneSerializer)
@transaction.atomic
def clone(self, request, pk=None):
"""工作流复制
工作流复制
"""
wf = self.get_object()
sr = WorkflowCloneSerializer(data=request.data)
sr.is_valid(raise_exception=True)
vdata = sr.validated_data
wf_new = Workflow()
for f in Workflow._meta.fields:
if f.name not in ['id', 'create_by', 'update_by', 'key', 'name', 'create_time', 'update_time']:
setattr(wf_new, f.name, getattr(wf, f.name, None))
wf_new.id = idWorker.get_id()
wf_new.key = vdata['key']
wf_new.name = vdata['name']
wf_new.create_by = request.user
wf_new.save()
stas_dict = {}
for s in State.objects.filter(workflow=wf):
sta = State()
sta.id = idWorker.get_id()
sta.workflow = wf_new
for f in State._meta.fields:
if f.name not in ['workflow', 'create_time', 'update_time', 'id']:
setattr(sta, f.name, getattr(s, f.name))
sta.save()
stas_dict[s.id] = sta # 保存一下, 后续备用
for c in CustomField.objects.filter(workflow=wf):
cf = CustomField()
cf.id = idWorker.get_id()
cf.workflow = wf_new
for f in CustomField._meta.fields:
if f.name not in ['workflow', 'create_time', 'update_time', 'id']:
setattr(sta, f.name, getattr(s, f.name))
cf.save()
for t in Transition.objects.filter(workflow=wf):
tr = Transition()
tr.id = idWorker.get_id()
tr.workflow = wf_new
for f in Transition._meta.fields:
if f.name not in ['workflow', 'create_time', 'update_time', 'id']:
setattr(tr, f.name, getattr(t, f.name))
tr.source_state = stas_dict[t.source_state.id]
tr.destination_state = stas_dict[t.destination_state.id]
ce = tr.condition_expression
for i in ce:
i['target_state'] = stas_dict[i['target_state']].id
tr.condition_expression = ce
tr.save()
return Response()
class StateViewSet(CreateModelMixin, UpdateModelMixin, RetrieveModelMixin, DestroyModelMixin, CustomGenericViewSet):
perms_map = {'get': '*', 'post': 'workflow.update',
'put': 'workflow.update', 'delete': 'workflow.update'}
queryset = State.objects.all()
serializer_class = StateSerializer
search_fields = ['name']
filterset_fields = ['workflow']
ordering = ['sort']
class TransitionViewSet(CreateModelMixin, UpdateModelMixin, RetrieveModelMixin, DestroyModelMixin, CustomGenericViewSet):
perms_map = {'get': '*', 'post': 'workflow.update',
'put': 'workflow.update', 'delete': 'workflow.update'}
queryset = Transition.objects.all()
serializer_class = TransitionSerializer
select_related_fields = ['source_state', 'destination_state']
search_fields = ['name']
filterset_fields = ['workflow']
ordering = ['id']
class CustomFieldViewSet(CreateModelMixin, UpdateModelMixin, RetrieveModelMixin, DestroyModelMixin, CustomGenericViewSet):
perms_map = {'get': '*', 'post': 'workflow.update',
'put': 'workflow.update', 'delete': 'workflow.update'}
queryset = CustomField.objects.all()
serializer_class = CustomFieldSerializer
search_fields = ['field_name']
filterset_fields = ['workflow', 'field_type']
ordering = ['sort']
def get_serializer_class(self):
if self.action in ['create', 'update']:
return CustomFieldCreateUpdateSerializer
return super().get_serializer_class()
class TicketViewSet(CreateUpdateCustomMixin, CreateModelMixin, ListModelMixin, RetrieveModelMixin, CustomGenericViewSet):
perms_map = {'get': '*', 'post': '*'}
queryset = Ticket.objects.all()
serializer_class = TicketSerializer
search_fields = ['title']
select_related_fields = ['workflow', 'state']
filterset_class = TicketFilterSet
ordering = ['-create_time']
def get_serializer_class(self):
if self.action == 'create':
return TicketCreateSerializer
elif self.action == 'handle':
return TicketHandleSerializer
elif self.action == 'retreat':
return TicketRetreatSerializer
elif self.action == 'list':
return TicketListSerializer
elif self.action == 'retrieve':
return TicketDetailSerializer
elif self.action == 'deliver':
return TicketDeliverSerializer
return super().get_serializer_class()
def filter_queryset(self, queryset):
if not self.detail and not self.request.query_params.get('category', None):
raise ParseError('请指定查询分类')
return super().filter_queryset(queryset)
def create(self, request, *args, **kwargs):
"""
新建工单
"""
rdata = request.data
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')
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)
return Response(TicketSerializer(instance=ticket).data)
@action(methods=['get'], detail=False, perms_map={'get': '*'})
def duty_agg(self, request, pk=None):
"""
工单待办聚合
"""
ret = {}
queryset = Ticket.objects.filter(participant__contains=request.user.id, is_deleted=False)\
.exclude(act_state__in=[Ticket.TICKET_ACT_STATE_FINISH, Ticket.TICKET_ACT_STATE_CLOSED])
ret['total_count'] = queryset.count()
ret['details'] = list(queryset.values('workflow', 'workflow__name').annotate(count=Count('workflow')))
return Response(ret)
@action(methods=['post'], detail=True, perms_map={'post': '*'})
def handle(self, request, pk=None):
"""
处理工单
"""
ticket = self.get_object()
serializer = TicketHandleSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
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', ''))
return Response(TicketSerializer(instance=ticket).data)
@action(methods=['post'], detail=True, perms_map={'post': '*'})
def deliver(self, request, pk=None):
"""
转交工单
"""
ticket = self.get_object()
rdata = request.data
serializer = self.get_serializer(data=rdata)
serializer.is_valid(raise_exception=True)
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)
return Response()
@action(methods=['get'], detail=True, perms_map={'get': '*'})
def flowsteps(self, request, pk=None):
"""
工单流转step, 用于显示当前状态的step图(线性结构)
"""
ticket = self.get_object()
steps = WfService.get_ticket_steps(ticket)
data = StateSerializer(instance=steps, many=True).data
for i in data:
if i['id'] == ticket.state.id:
i['checked'] = True
return Response(data)
@action(methods=['get'], detail=True, perms_map={'get': '*'})
def flowlogs(self, request, pk=None):
"""
工单流转记录
"""
ticket = self.get_object()
flowlogs = TicketFlow.objects.filter(ticket=ticket).order_by('-create_time')
serializer = TicketFlowSimpleSerializer(instance=flowlogs.select_related('participant', 'state', 'transition', 'participant__employee'), many=True)
return Response(serializer.data)
@action(methods=['get'], detail=True, perms_map={'get': '*'})
def transitions(self, request, pk=None):
"""
获取工单可执行的操作
"""
ticket = self.get_object()
transitions = WfService.get_ticket_transitions(ticket)
return Response(TransitionSerializer(instance=transitions.select_related('source_state', 'destination_state'), many=True).data)
@action(methods=['post'], detail=True, perms_map={'post': '*'})
def accpet(self, request, pk=None):
"""
接单,当工单当前处理人实际为多个人时(角色部门多人都有可能 注意角色和部门有可能实际只有一人)
"""
ticket = self.get_object()
result = WfService.ticket_handle_permission_check(ticket, request.user)
if result.get('need_accept', False):
ticket.participant_type = State.PARTICIPANT_TYPE_PERSONAL
ticket.participant = request.user.id
ticket.save()
# 接单日志
# 更新工单流转记录
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)
return Response()
else:
raise ParseError('无需接单')
@action(methods=['post'], detail=True, perms_map={'post': '*'})
def retreat(self, request, pk=None):
"""
撤回工单允许创建人在指定状态撤回工单至初始状态状态设置中开启允许撤回
"""
ticket = self.get_object()
if ticket.create_by != request.user:
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)
return Response()
@action(methods=['post'], detail=True, perms_map={'post': '*'}, serializer_class=TicketAddNodeSerializer)
def add_node(self, request, pk=None):
"""
加签
"""
data = request.data
sr = TicketAddNodeSerializer(data=data)
sr.is_valid(raise_exception=True)
ticket = self.get_object()
add_user = User.objects.get(pk=data['toadd_user'])
ticket.participant_type = State.PARTICIPANT_TYPE_PERSONAL
ticket.participant = add_user.id
ticket.in_add_node = True
ticket.add_node_man = request.user
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_ADD_NODE,
participant=request.user, transition=None)
return Response()
@action(methods=['post'], detail=True, perms_map={'post': '*'}, serializer_class=TicketAddNodeEndSerializer)
def add_node_end(self, request, pk=None):
"""
加签完成
"""
ticket = self.get_object()
if ticket.in_add_node is False:
raise ParseError('该工单不在加签状态中')
elif ticket.participant != request.user.id:
raise ParseError('非当前加签人')
ticket.participant_type = State.PARTICIPANT_TYPE_PERSONAL
ticket.in_add_node = False
ticket.participant = ticket.add_node_man.id
ticket.add_node_man = None
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_ADD_NODE_END,
participant=request.user, transition=None)
return Response()
@action(methods=['post'], detail=True, perms_map={'post': '*'},
serializer_class=TicketCloseSerializer)
@transaction.atomic
def close(self, request, pk=None):
"""
关闭工单(创建人在初始状态)
"""
ticket = self.get_object()
if ticket.state.type == State.STATE_TYPE_START and ticket.create_by == request.user:
end_state = WfService.get_workflow_end_state(ticket.workflow)
ticket.state = end_state
ticket.participant_type = 0
ticket.participant = 0
ticket.act_state = Ticket.TICKET_ACT_STATE_CLOSED
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_CLOSE,
participant=request.user, transition=None)
if end_state.on_reach_func: # 如果有到达方法还需要进行处理
module, func = end_state.on_reach_func.rsplit(".", 1)
m = importlib.import_module(module)
f = getattr(m, func)
f(ticket=ticket) # 同步执行
return Response()
else:
return Response('工单不可关闭', status=status.HTTP_400_BAD_REQUEST)
@action(methods=['post'], detail=False, perms_map={'post': 'ticket.destorys'},
serializer_class=TicketDestorySerializer)
def destorys(self, request, pk=None):
"""
批量物理删除
"""
Ticket.objects.filter(id__in=request.data.get('ids', [])).delete(soft=False)
return Response()
@action(methods=['post'], detail=True, perms_map={'post': '*'},
serializer_class=Serializer)
def retry_script(self, request, pk=None):
"""重试脚本
重试脚本
"""
ticket = self.get_object()
if not ticket.script_run_last_result:
ticket.script_run_last_result = True
ticket.save()
run_task.delay(ticket.id)
return Response()
@action(methods=['put'], detail=True, perms_map={'put': 'ticket.state_update'},
serializer_class=TicketStateUpateSerializer)
def state(self, request, pk=None):
"""强制修改工单状态
强制修改工单状态
"""
sr = TicketStateUpateSerializer(data=request.data)
sr.is_valid(raise_exception=True)
vdata = sr.validated_data
ticket = self.get_object()
WfService.update_ticket_state(ticket, vdata['state'], vdata.get('suggestion', ''), request.user, vdata['need_log'])
return Response()
class TicketFlowViewSet(ListModelMixin, RetrieveModelMixin, CustomGenericViewSet):
"""
工单日志
"""
perms_map = {'get': '*'}
queryset = TicketFlow.objects.all()
list_serializer_class = TicketFlowSimpleSerializer
serializer_class = TicketFlowSerializer
search_fields = ['suggestion']
select_related_fields = ['participant', 'state', 'transition']
filterset_fields = ['ticket']
ordering = ['-create_time']

0
apps/ws/__init__.py Normal file
View File

3
apps/ws/admin.py Normal file
View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
apps/ws/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class WsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'ws'

95
apps/ws/consumers.py Normal file
View File

@ -0,0 +1,95 @@
from channels.generic.websocket import AsyncWebsocketConsumer
import json
class RoomConsumer(AsyncWebsocketConsumer):
async def connect(self):
self.room_name = self.scope['url_route']['kwargs']['room_name']
self.room_group_name = 'chat_%s' % self.room_name
# Join room group
await self.channel_layer.group_add(
self.room_group_name,
self.channel_name
)
username = self.scope['user'].username
await self.channel_layer.group_send(
self.room_group_name,
{
'type': 'chat',
'msg': f'你好,{username}, 欢迎进入{self.room_name}房间' ,
'from': '系统',
'to': username
}
)
await self.accept()
async def disconnect(self, close_code):
# Leave room group
await self.channel_layer.group_discard(
self.room_group_name,
self.channel_name
)
async def receive(self, text_data=None, bytes_data=None):
sender_user = self.scope["user"]
if text_data:
content = json.loads(text_data)
if content['type'] == 'chat':
content['from'] = sender_user.username
await self.channel_layer.group_send(
self.room_group_name,
content
)
async def chat(self, content):
await self.send(json.dumps(content, ensure_ascii=False))
class MyConsumer(AsyncWebsocketConsumer):
async def connect(self):
user_id = self.scope['user'].id
self.room_group_name = f'user_{user_id}'
# Join room group
await self.channel_layer.group_add(
self.room_group_name,
self.channel_name
)
await self.channel_layer.group_send(
self.room_group_name,
{
'type': 'remind',
'msg': '你好,' + self.scope['user'].username,
'from': '系统'
}
)
await self.accept()
async def receive(self, text_data=None, bytes_data=None):
if text_data:
content = json.loads(text_data)
if content['type'] == 'event':
await self.channel_layer.group_add(
'event',
self.channel_name
)
async def disconnect(self, close_code):
# Leave room group
await self.channel_layer.group_discard(
self.room_group_name,
self.channel_name
)
await self.channel_layer.group_discard(
'event',
self.channel_name
)
async def event(self, content):
await self.send(json.dumps(content, ensure_ascii=False))
async def ticket(self, content):
await self.send(json.dumps(content, ensure_ascii=False))
async def remind(self, content):
await self.send(json.dumps(content, ensure_ascii=False))

View File

3
apps/ws/models.py Normal file
View File

@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

9
apps/ws/routing.py Normal file
View File

@ -0,0 +1,9 @@
from django.urls import path
from apps.ws.consumers import MyConsumer, RoomConsumer
WS_BASE_URL = 'ws/'
websocket_urlpatterns = [
path(f'{WS_BASE_URL}my/', MyConsumer.as_asgi()),
path(WS_BASE_URL + '<str:room_name>/', RoomConsumer.as_asgi()),
]

3
apps/ws/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

3
apps/ws/views.py Normal file
View File

@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

2
log/.gitignore vendored Executable file
View File

@ -0,0 +1,2 @@
*
!.gitignore

21
manage.py Executable file
View File

@ -0,0 +1,21 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'server.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

Some files were not shown because too many files have changed in this diff Show More