commit b25dfcac0c7f473d9512fd37104744d2c3fdb849 Author: caoqianming Date: Sat Oct 7 14:11:26 2023 +0800 初始化happy-drf分支 diff --git a/.gitignore b/.gitignore new file mode 100755 index 00000000..7164076f --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/apps/__init__.py b/apps/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/auth1/__init__.py b/apps/auth1/__init__.py new file mode 100755 index 00000000..e69de29b diff --git a/apps/auth1/admin.py b/apps/auth1/admin.py new file mode 100755 index 00000000..4f57ae9e --- /dev/null +++ b/apps/auth1/admin.py @@ -0,0 +1,2 @@ +from django.contrib import admin +# Register your models here. diff --git a/apps/auth1/apps.py b/apps/auth1/apps.py new file mode 100755 index 00000000..c7782e0a --- /dev/null +++ b/apps/auth1/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AuthConfig(AppConfig): + name = 'apps.auth1' + verbose_name = "认证" diff --git a/apps/auth1/authentication.py b/apps/auth1/authentication.py new file mode 100755 index 00000000..2767ae2c --- /dev/null +++ b/apps/auth1/authentication.py @@ -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 diff --git a/apps/auth1/errors.py b/apps/auth1/errors.py new file mode 100755 index 00000000..58ec2be7 --- /dev/null +++ b/apps/auth1/errors.py @@ -0,0 +1,2 @@ + +USERNAME_OR_PASSWORD_WRONG = {"code": "username_or_password_wrong", "detail": "账户名或密码错误"} diff --git a/apps/auth1/models.py b/apps/auth1/models.py new file mode 100755 index 00000000..b0b75a3b --- /dev/null +++ b/apps/auth1/models.py @@ -0,0 +1,3 @@ + + +# Create your models here. diff --git a/apps/auth1/serializers.py b/apps/auth1/serializers.py new file mode 100755 index 00000000..2732bf2a --- /dev/null +++ b/apps/auth1/serializers.py @@ -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() \ No newline at end of file diff --git a/apps/auth1/services.py b/apps/auth1/services.py new file mode 100644 index 00000000..d7441e31 --- /dev/null +++ b/apps/auth1/services.py @@ -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 diff --git a/apps/auth1/tests.py b/apps/auth1/tests.py new file mode 100755 index 00000000..7ce503c2 --- /dev/null +++ b/apps/auth1/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/auth1/urls.py b/apps/auth1/urls.py new file mode 100755 index 00000000..e2244f90 --- /dev/null +++ b/apps/auth1/urls.py @@ -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') +] diff --git a/apps/auth1/views.py b/apps/auth1/views.py new file mode 100755 index 00000000..a7071778 --- /dev/null +++ b/apps/auth1/views.py @@ -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) diff --git a/apps/ops/__init__.py b/apps/ops/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/ops/admin.py b/apps/ops/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/apps/ops/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/apps/ops/apps.py b/apps/ops/apps.py new file mode 100644 index 00000000..d917cdc0 --- /dev/null +++ b/apps/ops/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class OpsConfig(AppConfig): + name = 'apps.ops' + verbose_name = '系统运维' diff --git a/apps/ops/errors.py b/apps/ops/errors.py new file mode 100644 index 00000000..3bd94a2e --- /dev/null +++ b/apps/ops/errors.py @@ -0,0 +1 @@ +LOG_NOT_FONED = {"code": "log_not_found", "detail": "日志不存在"} diff --git a/apps/ops/filters.py b/apps/ops/filters.py new file mode 100644 index 00000000..1b6895df --- /dev/null +++ b/apps/ops/filters.py @@ -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'] \ No newline at end of file diff --git a/apps/ops/migrations/0001_initial.py b/apps/ops/migrations/0001_initial.py new file mode 100644 index 00000000..8bee4d0f --- /dev/null +++ b/apps/ops/migrations/0001_initial.py @@ -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请求日志', + }, + ), + ] diff --git a/apps/ops/migrations/__init__.py b/apps/ops/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/ops/models.py b/apps/ops/models.py new file mode 100644 index 00000000..466d9aeb --- /dev/null +++ b/apps/ops/models.py @@ -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) \ No newline at end of file diff --git a/apps/ops/serializers.py b/apps/ops/serializers.py new file mode 100644 index 00000000..cc9bf9f8 --- /dev/null +++ b/apps/ops/serializers.py @@ -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") diff --git a/apps/ops/service.py b/apps/ops/service.py new file mode 100644 index 00000000..0af91db2 --- /dev/null +++ b/apps/ops/service.py @@ -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()} diff --git a/apps/ops/tasks.py b/apps/ops/tasks.py new file mode 100644 index 00000000..1a33049a --- /dev/null +++ b/apps/ops/tasks.py @@ -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) diff --git a/apps/ops/tests.py b/apps/ops/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/apps/ops/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/ops/urls.py b/apps/ops/urls.py new file mode 100644 index 00000000..699cb204 --- /dev/null +++ b/apps/ops/urls.py @@ -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//', LogDetailView.as_view()), + path(API_BASE_URL + 'dbbackup/', DbBackupView.as_view()), + path(API_BASE_URL + 'dbbackup//', 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'), +] diff --git a/apps/ops/views.py b/apps/ops/views.py new file mode 100644 index 00000000..7bc189ef --- /dev/null +++ b/apps/ops/views.py @@ -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'] diff --git a/apps/system/__init__.py b/apps/system/__init__.py new file mode 100755 index 00000000..f5317b93 --- /dev/null +++ b/apps/system/__init__.py @@ -0,0 +1 @@ +default_app_config = 'apps.system.apps.SystemConfig' \ No newline at end of file diff --git a/apps/system/admin.py b/apps/system/admin.py new file mode 100755 index 00000000..249639fc --- /dev/null +++ b/apps/system/admin.py @@ -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) diff --git a/apps/system/apps.py b/apps/system/apps.py new file mode 100755 index 00000000..59d27b05 --- /dev/null +++ b/apps/system/apps.py @@ -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() diff --git a/apps/system/errors.py b/apps/system/errors.py new file mode 100755 index 00000000..ef2d1eb2 --- /dev/null +++ b/apps/system/errors.py @@ -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": "角色标识已存在"} diff --git a/apps/system/filters.py b/apps/system/filters.py new file mode 100755 index 00000000..5ab6c501 --- /dev/null +++ b/apps/system/filters.py @@ -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'] + } diff --git a/apps/system/migrations/0001_initial.py b/apps/system/migrations/0001_initial.py new file mode 100644 index 00000000..bc91b12d --- /dev/null +++ b/apps/system/migrations/0001_initial.py @@ -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')}, + }, + ), + ] diff --git a/apps/system/migrations/0002_myschedule.py b/apps/system/migrations/0002_myschedule.py new file mode 100644 index 00000000..ab32b620 --- /dev/null +++ b/apps/system/migrations/0002_myschedule.py @@ -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, + }, + ), + ] diff --git a/apps/system/migrations/__init__.py b/apps/system/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/system/models.py b/apps/system/models.py new file mode 100755 index 00000000..e7e34e6e --- /dev/null +++ b/apps/system/models.py @@ -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) \ No newline at end of file diff --git a/apps/system/serializers.py b/apps/system/serializers.py new file mode 100755 index 00000000..fa248f60 --- /dev/null +++ b/apps/system/serializers.py @@ -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__' diff --git a/apps/system/services.py b/apps/system/services.py new file mode 100644 index 00000000..d013c970 --- /dev/null +++ b/apps/system/services.py @@ -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() diff --git a/apps/system/signals.py b/apps/system/signals.py new file mode 100755 index 00000000..d7b48426 --- /dev/null +++ b/apps/system/signals.py @@ -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) \ No newline at end of file diff --git a/apps/system/tasks.py b/apps/system/tasks.py new file mode 100755 index 00000000..36dd8a02 --- /dev/null +++ b/apps/system/tasks.py @@ -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() diff --git a/apps/system/tests.py b/apps/system/tests.py new file mode 100755 index 00000000..7ce503c2 --- /dev/null +++ b/apps/system/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/system/urls.py b/apps/system/urls.py new file mode 100755 index 00000000..83150314 --- /dev/null +++ b/apps/system/urls.py @@ -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)), +] diff --git a/apps/system/views.py b/apps/system/views.py new file mode 100755 index 00000000..65a7585a --- /dev/null +++ b/apps/system/views.py @@ -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() \ No newline at end of file diff --git a/apps/utils/__init__.py b/apps/utils/__init__.py new file mode 100755 index 00000000..e69de29b diff --git a/apps/utils/admin.py b/apps/utils/admin.py new file mode 100755 index 00000000..8c38f3f3 --- /dev/null +++ b/apps/utils/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/apps/utils/apps.py b/apps/utils/apps.py new file mode 100755 index 00000000..36e8428e --- /dev/null +++ b/apps/utils/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class UtilsConfig(AppConfig): + name = 'apps.utils' diff --git a/apps/utils/constants.py b/apps/utils/constants.py new file mode 100755 index 00000000..7c09d85b --- /dev/null +++ b/apps/utils/constants.py @@ -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'] diff --git a/apps/utils/decorators.py b/apps/utils/decorators.py new file mode 100644 index 00000000..4a01ab13 --- /dev/null +++ b/apps/utils/decorators.py @@ -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 \ No newline at end of file diff --git a/apps/utils/errors.py b/apps/utils/errors.py new file mode 100755 index 00000000..f80a8bad --- /dev/null +++ b/apps/utils/errors.py @@ -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": "寻息接口访问异常"} \ No newline at end of file diff --git a/apps/utils/exceptions.py b/apps/utils/exceptions.py new file mode 100755 index 00000000..b5f9f498 --- /dev/null +++ b/apps/utils/exceptions.py @@ -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) diff --git a/apps/utils/export.py b/apps/utils/export.py new file mode 100644 index 00000000..16f29558 --- /dev/null +++ b/apps/utils/export.py @@ -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 diff --git a/apps/utils/fields.py b/apps/utils/fields.py new file mode 100644 index 00000000..f93a46d5 --- /dev/null +++ b/apps/utils/fields.py @@ -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) diff --git a/apps/utils/filters.py b/apps/utils/filters.py new file mode 100755 index 00000000..e69de29b diff --git a/apps/utils/img.py b/apps/utils/img.py new file mode 100644 index 00000000..46bd4684 --- /dev/null +++ b/apps/utils/img.py @@ -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 \ No newline at end of file diff --git a/apps/utils/middlewares.py b/apps/utils/middlewares.py new file mode 100644 index 00000000..fdda5e31 --- /dev/null +++ b/apps/utils/middlewares.py @@ -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) \ No newline at end of file diff --git a/apps/utils/mixins.py b/apps/utils/mixins.py new file mode 100755 index 00000000..6626a958 --- /dev/null +++ b/apps/utils/mixins.py @@ -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: + # + # + # :port + # []: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 diff --git a/apps/utils/models.py b/apps/utils/models.py new file mode 100755 index 00000000..3d6d0353 --- /dev/null +++ b/apps/utils/models.py @@ -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('号码') diff --git a/apps/utils/my_rsa.py b/apps/utils/my_rsa.py new file mode 100644 index 00000000..0d755a1b --- /dev/null +++ b/apps/utils/my_rsa.py @@ -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') diff --git a/apps/utils/myconfig.py b/apps/utils/myconfig.py new file mode 100644 index 00000000..49e49e0b --- /dev/null +++ b/apps/utils/myconfig.py @@ -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')) diff --git a/apps/utils/pagination.py b/apps/utils/pagination.py new file mode 100755 index 00000000..d437e4b3 --- /dev/null +++ b/apps/utils/pagination.py @@ -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) diff --git a/apps/utils/permission.py b/apps/utils/permission.py new file mode 100755 index 00000000..efe05610 --- /dev/null +++ b/apps/utils/permission.py @@ -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 diff --git a/apps/utils/queryset.py b/apps/utils/queryset.py new file mode 100755 index 00000000..612d486c --- /dev/null +++ b/apps/utils/queryset.py @@ -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) diff --git a/apps/utils/request.py b/apps/utils/request.py new file mode 100755 index 00000000..e9482151 --- /dev/null +++ b/apps/utils/request.py @@ -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 "" diff --git a/apps/utils/serializers.py b/apps/utils/serializers.py new file mode 100755 index 00000000..bf6ed0b9 --- /dev/null +++ b/apps/utils/serializers.py @@ -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) diff --git a/apps/utils/sms.py b/apps/utils/sms.py new file mode 100644 index 00000000..0fdd69ae --- /dev/null +++ b/apps/utils/sms.py @@ -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(): + """腾讯短信发送/备用 + """ \ No newline at end of file diff --git a/apps/utils/snowflake.py b/apps/utils/snowflake.py new file mode 100755 index 00000000..d03390ae --- /dev/null +++ b/apps/utils/snowflake.py @@ -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()) diff --git a/apps/utils/speech.py b/apps/utils/speech.py new file mode 100644 index 00000000..d48b3a3f --- /dev/null +++ b/apps/utils/speech.py @@ -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 diff --git a/apps/utils/sql.py b/apps/utils/sql.py new file mode 100644 index 00000000..c919ab80 --- /dev/null +++ b/apps/utils/sql.py @@ -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() \ No newline at end of file diff --git a/apps/utils/tasks.py b/apps/utils/tasks.py new file mode 100644 index 00000000..0a5d36c9 --- /dev/null +++ b/apps/utils/tasks.py @@ -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) diff --git a/apps/utils/tools.py b/apps/utils/tools.py new file mode 100755 index 00000000..18dc6661 --- /dev/null +++ b/apps/utils/tools.py @@ -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 \ No newline at end of file diff --git a/apps/utils/urls.py b/apps/utils/urls.py new file mode 100755 index 00000000..325c391f --- /dev/null +++ b/apps/utils/urls.py @@ -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)), +] diff --git a/apps/utils/views.py b/apps/utils/views.py new file mode 100755 index 00000000..a8f8194d --- /dev/null +++ b/apps/utils/views.py @@ -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) \ No newline at end of file diff --git a/apps/utils/viewsets.py b/apps/utils/viewsets.py new file mode 100755 index 00000000..66acb496 --- /dev/null +++ b/apps/utils/viewsets.py @@ -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) diff --git a/apps/utils/wx.py b/apps/utils/wx.py new file mode 100644 index 00000000..0cdaceb7 --- /dev/null +++ b/apps/utils/wx.py @@ -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() diff --git a/apps/utils/wxmp.py b/apps/utils/wxmp.py new file mode 100644 index 00000000..62ecfa26 --- /dev/null +++ b/apps/utils/wxmp.py @@ -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() diff --git a/apps/wf/__init__.py b/apps/wf/__init__.py new file mode 100755 index 00000000..e69de29b diff --git a/apps/wf/admin.py b/apps/wf/admin.py new file mode 100755 index 00000000..60ce84e5 --- /dev/null +++ b/apps/wf/admin.py @@ -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' \ No newline at end of file diff --git a/apps/wf/apps.py b/apps/wf/apps.py new file mode 100755 index 00000000..cefdb021 --- /dev/null +++ b/apps/wf/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class WfConfig(AppConfig): + name = 'apps.wf' + verbose_name = '工作流管理' diff --git a/apps/wf/filters.py b/apps/wf/filters.py new file mode 100755 index 00000000..1606b780 --- /dev/null +++ b/apps/wf/filters.py @@ -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 diff --git a/apps/wf/migrations/0001_initial.py b/apps/wf/migrations/0001_initial.py new file mode 100644 index 00000000..8ceec417 --- /dev/null +++ b/apps/wf/migrations/0001_initial.py @@ -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, + }, + ), + ] diff --git a/apps/wf/migrations/0002_alter_state_filter_dept.py b/apps/wf/migrations/0002_alter_state_filter_dept.py new file mode 100644 index 00000000..8fb3f6f4 --- /dev/null +++ b/apps/wf/migrations/0002_alter_state_filter_dept.py @@ -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='部门字段过滤'), + ), + ] diff --git a/apps/wf/migrations/__init__.py b/apps/wf/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/wf/models.py b/apps/wf/models.py new file mode 100755 index 00000000..f11f0ac5 --- /dev/null +++ b/apps/wf/models.py @@ -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列表)') diff --git a/apps/wf/serializers.py b/apps/wf/serializers.py new file mode 100755 index 00000000..eba05476 --- /dev/null +++ b/apps/wf/serializers.py @@ -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'] diff --git a/apps/wf/services.py b/apps/wf/services.py new file mode 100755 index 00000000..4ac0ad69 --- /dev/null +++ b/apps/wf/services.py @@ -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) diff --git a/apps/wf/tasks.py b/apps/wf/tasks.py new file mode 100644 index 00000000..97df25e9 --- /dev/null +++ b/apps/wf/tasks.py @@ -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) diff --git a/apps/wf/tests.py b/apps/wf/tests.py new file mode 100755 index 00000000..7ce503c2 --- /dev/null +++ b/apps/wf/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/wf/urls.py b/apps/wf/urls.py new file mode 100755 index 00000000..fad073ef --- /dev/null +++ b/apps/wf/urls.py @@ -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//init_key/', WorkflowKeyInitView.as_view()) +] diff --git a/apps/wf/views.py b/apps/wf/views.py new file mode 100755 index 00000000..a5e74990 --- /dev/null +++ b/apps/wf/views.py @@ -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'] diff --git a/apps/ws/__init__.py b/apps/ws/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/ws/admin.py b/apps/ws/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/apps/ws/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/apps/ws/apps.py b/apps/ws/apps.py new file mode 100644 index 00000000..2c87a482 --- /dev/null +++ b/apps/ws/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class WsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'ws' diff --git a/apps/ws/consumers.py b/apps/ws/consumers.py new file mode 100644 index 00000000..1684dbe3 --- /dev/null +++ b/apps/ws/consumers.py @@ -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)) diff --git a/apps/ws/migrations/__init__.py b/apps/ws/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/ws/models.py b/apps/ws/models.py new file mode 100644 index 00000000..71a83623 --- /dev/null +++ b/apps/ws/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/apps/ws/routing.py b/apps/ws/routing.py new file mode 100644 index 00000000..589b0679 --- /dev/null +++ b/apps/ws/routing.py @@ -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 + '/', RoomConsumer.as_asgi()), +] \ No newline at end of file diff --git a/apps/ws/tests.py b/apps/ws/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/apps/ws/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/ws/views.py b/apps/ws/views.py new file mode 100644 index 00000000..91ea44a2 --- /dev/null +++ b/apps/ws/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/log/.gitignore b/log/.gitignore new file mode 100755 index 00000000..c96a04f0 --- /dev/null +++ b/log/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/manage.py b/manage.py new file mode 100755 index 00000000..1c818788 --- /dev/null +++ b/manage.py @@ -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() diff --git a/media/default/avatar.png b/media/default/avatar.png new file mode 100755 index 00000000..07e80789 Binary files /dev/null and b/media/default/avatar.png differ diff --git a/requirements.txt b/requirements.txt new file mode 100755 index 00000000..47a0e6f8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,20 @@ +celery==5.2.3 +Django==3.2.12 +django-celery-beat==2.3.0 +django-celery-results==2.4.0 +django-cors-headers==3.11.0 +django-filter==21.1 +djangorestframework==3.13.1 +djangorestframework-simplejwt==5.1.0 +drf-yasg==1.21.3 +psutil==5.9.0 +redis==4.4.0 +django-redis==5.2.0 +user-agents==2.2.0 +daphne==4.0.0 +channels-redis==4.0.0 +django-restql==0.15.2 +requests==2.28.1 +xlwt==1.3.0 +openpyxl==3.1.0 +cron-descriptor==1.2.35 diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 00000000..c514775d --- /dev/null +++ b/ruff.toml @@ -0,0 +1,3 @@ + +line-length = 200 +fix = true \ No newline at end of file diff --git a/server/__init__.py b/server/__init__.py new file mode 100755 index 00000000..1e3599b0 --- /dev/null +++ b/server/__init__.py @@ -0,0 +1,5 @@ +# This will make sure the app is always imported when +# Django starts so that shared_task will use this app. +from .celery import app as celery_app + +__all__ = ('celery_app',) \ No newline at end of file diff --git a/server/asgi.py b/server/asgi.py new file mode 100755 index 00000000..5632900a --- /dev/null +++ b/server/asgi.py @@ -0,0 +1,25 @@ +""" +ASGI config for server project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/ +""" + +import os +from channels.routing import ProtocolTypeRouter, URLRouter +from django.core.asgi import get_asgi_application +from apps.utils.middlewares import TokenAuthMiddleware +import apps.ws.routing + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'server.settings') + +application = ProtocolTypeRouter({ + "http": get_asgi_application(), + "websocket": TokenAuthMiddleware( + URLRouter( + apps.ws.routing.websocket_urlpatterns + ) + ) +}) \ No newline at end of file diff --git a/server/celery.py b/server/celery.py new file mode 100755 index 00000000..517cf970 --- /dev/null +++ b/server/celery.py @@ -0,0 +1,22 @@ +import os + +from celery import Celery + +# set the default Django settings module for the 'celery' program. +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'server.settings') + +app = Celery('happy-drf') + +# Using a string here means the worker doesn't have to serialize +# the configuration object to child processes. +# - namespace='CELERY' means all celery-related configuration keys +# should have a `CELERY_` prefix. +app.config_from_object('django.conf:settings', namespace='CELERY') + +# Load task modules from all registered Django app configs. +app.autodiscover_tasks() + + +@app.task(bind=True) +def debug_task(self): + print(f'Request: {self.request!r}') diff --git a/server/settings.py b/server/settings.py new file mode 100755 index 00000000..a2b6bbd5 --- /dev/null +++ b/server/settings.py @@ -0,0 +1,396 @@ +""" +Django settings for server project. + +Generated by 'django-admin startproject' using Django 3.0.3. + +For more information on this file, see +https://docs.djangoproject.com/en/3.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/3.0/ref/settings/ +""" + +from datetime import datetime, timedelta +import os +import json +import sys +from . import conf +from django.core.cache import cache +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(BASE_DIR, 'apps')) + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/ + +SYS_JSON_PATH = os.path.join(BASE_DIR, 'server/conf.json') + + +def get_sysconfig(reload=False): + config = cache.get('system_config', None) + if config is None or reload: + # 读取配置文件 + if not os.path.exists(SYS_JSON_PATH): + raise SystemError('未找到配置文件') + with open(SYS_JSON_PATH, 'r', encoding='utf-8') as f: + config = json.loads(f.read()) + cache.set('system_config', config) + return config + return config + + +def update_dict(dict1, dict2): + for key, value in dict2.items(): + if key == 'apk_file': # apk_file拷贝到固定位置 + from shutil import copyfile + copyfile(BASE_DIR + value, BASE_DIR + '/media/zc_ehs.apk') + if key in dict1 and isinstance(dict1[key], dict) and isinstance(value, dict): + update_dict(dict1[key], value) + else: + dict1[key] = value + + +def update_sysconfig(new_dict): + config = get_sysconfig() + update_dict(config, new_dict) + with open(SYS_JSON_PATH, 'wb') as f: + f.write(json.dumps(config, indent=4, ensure_ascii=False).encode('utf-8')) + cache.set('system_config', config) + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = conf.SECRET_KEY + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = conf.DEBUG + +ALLOWED_HOSTS = ['*'] + +SYS_NAME = 'HAPPY-DRF' +SYS_VERSION = '2.2.2' + + +# Application definition + +INSTALLED_APPS = [ + 'daphne', + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'corsheaders', + 'django_celery_beat', + 'django_celery_results', + 'drf_yasg', + 'rest_framework', + 'django_filters', + 'apps.utils', + 'apps.system', + 'apps.auth1', + 'apps.wf', + 'apps.ops' +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'corsheaders.middleware.CorsMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'server.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': ['dist'], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] +# WSGI +WSGI_APPLICATION = 'server.wsgi.application' + +# ASGI +ASGI_APPLICATION = 'server.asgi.application' +CHANNEL_LAYERS = { + 'default': { + 'BACKEND': 'channels_redis.core.RedisChannelLayer', + 'CONFIG': { + "hosts": [('127.0.0.1', 6379)], + "capacity": 1500, + "expiry": 10 + }, + }, +} + +# Database +# https://docs.djangoproject.com/en/3.0/ref/settings/#databases +DATABASES = conf.DATABASES + +# Password validation +# https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/3.0/topics/i18n/ + +LANGUAGE_CODE = 'zh-hans' + +TIME_ZONE = 'Asia/Shanghai' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/3.0/howto/static-files/ + +STATIC_URL = '/static/' +STATIC_ROOT = os.path.join(BASE_DIR, 'dist/static') +# STATICFILES_DIRS = ( +# os.path.join(BASE_DIR, 'dist/static'), +# ) + +MEDIA_URL = '/media/' +MEDIA_ROOT = os.path.join(BASE_DIR, 'media') + +# 人脸库配置 +# 如果地址不存在,则自动创建/现在直接存库可不用 +FACE_PATH = os.path.join(BASE_DIR, 'media/face') +if not os.path.exists(FACE_PATH): + os.makedirs(FACE_PATH) + + +# 邮箱配置 +EMAIL_HOST = conf.EMAIL_HOST +EMAIL_PORT = conf.EMAIL_PORT +EMAIL_HOST_USER = conf.EMAIL_HOST_USER +EMAIL_HOST_PASSWORD = conf.EMAIL_HOST_PASSWORD +EMAIL_USE_TLS = conf.EMAIL_USE_TLS + +# 默认主键 +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +# 雪花ID生成配置 +SNOW_DATACENTER_ID = conf.SNOW_DATACENTER_ID + +# restframework配置 +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework_simplejwt.authentication.JWTAuthentication', + 'rest_framework.authentication.BasicAuthentication', + 'rest_framework.authentication.SessionAuthentication', + ], + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated', + 'apps.utils.permission.RbacPermission' + ], + # 'DEFAULT_RENDERER_CLASSES': [ + # 'apps.utils.response.FitJSONRenderer', + # 'rest_framework.renderers.BrowsableAPIRenderer' + # ], + 'DEFAULT_FILTER_BACKENDS': [ + 'django_filters.rest_framework.DjangoFilterBackend', + 'rest_framework.filters.SearchFilter', + 'rest_framework.filters.OrderingFilter' + ], + 'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.NamespaceVersioning', + 'DEFAULT_PAGINATION_CLASS': 'apps.utils.pagination.MyPagination', + 'DATETIME_FORMAT': '%Y-%m-%d %H:%M:%S', + 'DATETIME_INPUT_FORMATS': ['iso-8601', '%Y-%m-%d %H:%M:%S'], + 'DATE_FORMAT': '%Y-%m-%d', + 'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema', + # 'UNAUTHENTICATED_USER': None, + # 'UNAUTHENTICATED_TOKEN': None, + 'EXCEPTION_HANDLER': 'apps.utils.exceptions.custom_exception_hander', + 'DEFAULT_THROTTLE_RATES': { + 'anon': '100/second', + 'user': '200/second' + } +} +# simplejwt配置 +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(days=1), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=30), +} + +# 跨域配置/可用nginx处理,无需引入corsheaders +CORS_ORIGIN_ALLOW_ALL = True +CORS_ALLOW_CREDENTIALS = True + +# Auth配置 +AUTH_USER_MODEL = 'system.User' +AUTHENTICATION_BACKENDS = ( + 'apps.auth1.authentication.CustomBackend', +) + +# 缓存配置,有需要可更改为redis +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": "redis://127.0.0.1:6379/2", + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + } + } +} + +# celery配置,celery正常运行必须安装redis +CELERY_BROKER_URL = "redis://127.0.0.1:6379/3" # 任务存储 +CELERYD_MAX_TASKS_PER_CHILD = 100 # 每个worker最多执行100个任务就会被销毁,可防止内存泄露 +CELERY_TIMEZONE = 'Asia/Shanghai' # 设置时区 +CELERY_ENABLE_UTC = True # 启动时区设置 +CELERY_RESULT_BACKEND = 'django-db' +CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler' +CELERY_ACCEPT_CONTENT = ['application/json'] +CELERY_TASK_SERIALIZER = 'json' +CELERY_RESULT_SERIALIZER = 'json' +CELERY_RESULT_EXTENDED = True +CELERY_TASK_TRACK_STARTED = True +CELERYD_SOFT_TIME_LIMIT = 60*10 + + +# swagger配置 +SWAGGER_SETTINGS = { + 'LOGIN_URL': '/django/admin/login/', + 'LOGOUT_URL': '/django/admin/logout/', +} + +# 日志配置 +# 创建日志的路径 +LOG_PATH = os.path.join(BASE_DIR, 'log') +# 如果地址不存在,则自动创建log文件夹 +if not os.path.exists(LOG_PATH): + os.makedirs(LOG_PATH) + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + # 日志格式 + 'standard': { + 'format': '[%(asctime)s] [%(filename)s:%(lineno)d] [%(module)s:%(funcName)s] ' + '[%(levelname)s]- %(message)s'}, + 'simple': { # 简单格式 + 'format': '%(levelname)s %(message)s' + }, + }, + # 过滤 + 'filters': { + 'require_debug_true': { + '()': 'django.utils.log.RequireDebugTrue', + }, + }, + # 定义具体处理日志的方式 + 'handlers': { + # 默认记录所有日志 + 'default': { + 'level': 'INFO', + 'class': 'logging.handlers.RotatingFileHandler', + 'filename': os.path.join(LOG_PATH, 'all-{}.log'.format(datetime.now().strftime('%Y-%m-%d'))), + 'maxBytes': 1024 * 1024 * 2, # 文件大小 + 'backupCount': 10, # 备份数 + 'formatter': 'standard', # 输出格式 + 'encoding': 'utf-8', # 设置默认编码,否则打印出来汉字乱码 + }, + # 输出错误日志 + 'error': { + 'level': 'ERROR', + 'class': 'logging.handlers.RotatingFileHandler', + 'filename': os.path.join(LOG_PATH, 'error-{}.log'.format(datetime.now().strftime('%Y-%m-%d'))), + 'maxBytes': 1024 * 1024 * 2, # 文件大小 + 'backupCount': 10, # 备份数 + 'formatter': 'standard', # 输出格式 + 'encoding': 'utf-8', # 设置默认编码 + }, + # 控制台输出 + 'console': { + 'level': 'DEBUG', + 'class': 'logging.StreamHandler', + 'filters': ['require_debug_true'], + 'formatter': 'standard' + }, + # 输出info日志 + 'info': { + 'level': 'INFO', + 'class': 'logging.handlers.RotatingFileHandler', + 'filename': os.path.join(LOG_PATH, 'info-{}.log'.format(datetime.now().strftime('%Y-%m-%d'))), + 'maxBytes': 1024 * 1024 * 2, + 'backupCount': 10, + 'formatter': 'standard', + 'encoding': 'utf-8', # 设置默认编码 + }, + }, + # 配置用哪几种 handlers 来处理日志 + 'loggers': { + # 类型 为 django 处理所有类型的日志, 默认调用 + 'django': { + 'handlers': ['default', 'console'], + 'level': 'INFO', + 'propagate': False + }, + # log 调用时需要当作参数传入 + 'log': { + 'handlers': ['error', 'info', 'console', 'default'], + 'level': 'INFO', + 'propagate': True + }, + } +} + +# 项目 +BASE_URL = conf.BASE_URL +BASE_URL_IN = conf.BASE_URL_IN +BASE_URL_OUT = conf.BASE_URL_OUT + + +# 运维相关 +SD_PWD = conf.SD_PWD +BACKUP_PATH = conf.BACKUP_PATH +SH_PATH = conf.SH_PATH + + +# 百度语音 +BD_SP_ID = conf.BD_SP_ID +BD_SP_KEY = conf.BD_SP_KEY +BD_SP_SECRET = conf.BD_SP_SECRET + + +# 微信有关 +WXMP_ENABLED = conf.WXMP_ENABLED +WXMP_APPID = conf.WXMP_APPID +WXMP_APPSECRET = conf.WXMP_APPSECRET + +WX_ENABLED = conf.WX_ENABLED +WX_APPID = conf.WX_APPID +WX_APPSECRET = conf.WX_APPSECRET diff --git a/server/urls.py b/server/urls.py new file mode 100755 index 00000000..23d934a6 --- /dev/null +++ b/server/urls.py @@ -0,0 +1,61 @@ +"""server URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/3.0/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.conf import settings +from django.conf.urls.static import static +from django.contrib import admin +from django.urls import include, path +from drf_yasg import openapi +from drf_yasg.views import get_schema_view +from rest_framework.documentation import include_docs_urls +from django.views.generic import TemplateView + +schema_view = get_schema_view( + openapi.Info( + title=settings.SYS_NAME, + default_version=settings.SYS_VERSION, + contact=openapi.Contact(email="caoqianming@foxmail.com"), + license=openapi.License(name="MIT License"), + ), + public=True, + permission_classes=[], + url=settings.BASE_URL +) + +urlpatterns = [ + # django后台 + path('django/admin/doc/', include('django.contrib.admindocs.urls')), + path('django/admin/', admin.site.urls), + path('django/api-auth/', include('rest_framework.urls')), + + # api + path('', include('apps.auth1.urls')), + path('', include('apps.system.urls')), + path('', include('apps.monitor.urls')), + path('', include('apps.wf.urls')), + path('', include('apps.utils.urls')), + path('', include('apps.ops.urls')), + + + + # api文档 + path('api/docs/', include_docs_urls(title="接口文档", authentication_classes=[], permission_classes=[])), + path('api/swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), + path('api/redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'), + # 前端页面入口 + path('', TemplateView.as_view(template_name="index.html")), +] + \ + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + \ + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/server/wsgi.py b/server/wsgi.py new file mode 100755 index 00000000..c65f7e24 --- /dev/null +++ b/server/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for server project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'server.settings') + +application = get_wsgi_application()