commit e2a20fe67a4c76775b0b99777a1581f304cbac39 Author: caoqianming Date: Sat Apr 2 10:08:26 2022 +0800 初始化master diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..067ee379 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +.vscode/ +.vs/ +.idea/ +venv/ +__pycache__/ +*.pyc +media/* +dist/* +!media/default/ +celerybeat.pid +celerybeat-schedule.bak +celerybeat-schedule.dat +celerybeat-schedule.dir +db.sqlite3 +server/conf.py +sh/* \ No newline at end of file diff --git a/apps/auth1/__init__.py b/apps/auth1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/auth1/admin.py b/apps/auth1/admin.py new file mode 100644 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 100644 index 00000000..74a0184f --- /dev/null +++ b/apps/auth1/apps.py @@ -0,0 +1,7 @@ +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 100644 index 00000000..3c859439 --- /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(email=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/migrations/__init__.py b/apps/auth1/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/auth1/models.py b/apps/auth1/models.py new file mode 100644 index 00000000..71a83623 --- /dev/null +++ b/apps/auth1/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/apps/auth1/serializers.py b/apps/auth1/serializers.py new file mode 100644 index 00000000..f318bee0 --- /dev/null +++ b/apps/auth1/serializers.py @@ -0,0 +1,5 @@ +from rest_framework import serializers + +class LoginSerializer(serializers.Serializer): + username = serializers.CharField(label="用户名") + password = serializers.CharField(label="密码") \ No newline at end of file diff --git a/apps/auth1/tests.py b/apps/auth1/tests.py new file mode 100644 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 100644 index 00000000..7a7cf1cb --- /dev/null +++ b/apps/auth1/urls.py @@ -0,0 +1,14 @@ +from django.urls import path +from rest_framework_simplejwt.views import (TokenObtainPairView, + TokenRefreshView) + +from apps.auth1.views import LoginView, LogoutView, TokenBlackView + +API_BASE_URL = 'api/auth/' +urlpatterns = [ + path(API_BASE_URL + 'token/', TokenObtainPairView.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 + 'logout/', LogoutView.as_view(), name='session_logout') +] \ No newline at end of file diff --git a/apps/auth1/views.py b/apps/auth1/views.py new file mode 100644 index 00000000..2d2a874a --- /dev/null +++ b/apps/auth1/views.py @@ -0,0 +1,60 @@ + +from django.shortcuts import render +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.serializers import LoginSerializer +from apps.utils.response import FailResponse, SuccessResponse +# Create your views here. + +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 SuccessResponse() + return FailResponse(msg='账户或密码错误') + +class LogoutView(APIView): + authentication_classes = [] + permission_classes = [] + + def post(self, request, *args, **kwargs): + """ + 退出登录 + + + 退出登录 + """ + logout(request) + return SuccessResponse() \ No newline at end of file diff --git a/apps/monitor/__init__.py b/apps/monitor/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/monitor/admin.py b/apps/monitor/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/apps/monitor/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/apps/monitor/apps.py b/apps/monitor/apps.py new file mode 100644 index 00000000..e49ebcad --- /dev/null +++ b/apps/monitor/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class MonitorConfig(AppConfig): + name = 'apps.monitor' + verbose_name = '系统监控' diff --git a/apps/monitor/consumers.py b/apps/monitor/consumers.py new file mode 100644 index 00000000..a84df0cc --- /dev/null +++ b/apps/monitor/consumers.py @@ -0,0 +1,46 @@ +import imp +import json +from channels.generic.websocket import AsyncWebsocketConsumer + +class MonitorConsumer(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 + ) + + 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 + ) + + # Receive message from WebSocket + async def receive(self, text_data): + text_data_json = json.loads(text_data) + message = text_data_json['message'] + + # Send message to room group + await self.channel_layer.group_send( + self.room_group_name, + { + 'type': 'chat_message', + 'message': message + } + ) + + # Receive message from room group + async def chat_message(self, event): + message = event['message'] + + # Send message to WebSocket + await self.send(text_data=json.dumps({ + 'message': message + })) \ No newline at end of file diff --git a/apps/monitor/middleware.py b/apps/monitor/middleware.py new file mode 100644 index 00000000..038a432f --- /dev/null +++ b/apps/monitor/middleware.py @@ -0,0 +1 @@ +from django.utils.deprecation import MiddlewareMixin diff --git a/apps/monitor/migrations/__init__.py b/apps/monitor/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/monitor/models.py b/apps/monitor/models.py new file mode 100644 index 00000000..71a83623 --- /dev/null +++ b/apps/monitor/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/apps/monitor/routing.py b/apps/monitor/routing.py new file mode 100644 index 00000000..5a232d7b --- /dev/null +++ b/apps/monitor/routing.py @@ -0,0 +1,8 @@ +from django.urls import path +from apps.monitor import consumers + +WS_BASE_URL = 'ws/monitor/' + +websocket_urlpatterns = [ + path(WS_BASE_URL + '/', consumers.MonitorConsumer.as_asgi()) +] \ No newline at end of file diff --git a/apps/monitor/templates/monitor/index.html b/apps/monitor/templates/monitor/index.html new file mode 100644 index 00000000..f89b2cd0 --- /dev/null +++ b/apps/monitor/templates/monitor/index.html @@ -0,0 +1,27 @@ + + + + + + Chat Rooms + + + What chat room would you like to enter?
+
+ + + + + \ No newline at end of file diff --git a/apps/monitor/templates/monitor/room.html b/apps/monitor/templates/monitor/room.html new file mode 100644 index 00000000..f6224515 --- /dev/null +++ b/apps/monitor/templates/monitor/room.html @@ -0,0 +1,50 @@ + + + + + + Chat Room + + +
+
+ + {{ room_name|json_script:"room-name" }} + + + \ No newline at end of file diff --git a/apps/monitor/templates/monitor/video.html b/apps/monitor/templates/monitor/video.html new file mode 100644 index 00000000..241a12c4 --- /dev/null +++ b/apps/monitor/templates/monitor/video.html @@ -0,0 +1,26 @@ + + + + + + +videojs-contrib-hls embed + + + + + + + +

Video.js Example Embed

+ + + + + + + \ No newline at end of file diff --git a/apps/monitor/tests.py b/apps/monitor/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/apps/monitor/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/monitor/urls.py b/apps/monitor/urls.py new file mode 100644 index 00000000..aa934695 --- /dev/null +++ b/apps/monitor/urls.py @@ -0,0 +1,16 @@ +from django.urls import path, include +from rest_framework import routers +from .views import ServerInfoView, LogView, LogDetailView, index, room, video + +API_BASE_URL = 'api/monitor/' +HTML_BASE_URL = 'monitor/' +urlpatterns = [ + path(HTML_BASE_URL, index), + path(HTML_BASE_URL + 'index/', index), + path(HTML_BASE_URL + 'video/', video), + path(HTML_BASE_URL + '/', room, name='room'), + + path(API_BASE_URL + 'log/', LogView.as_view()), + path(API_BASE_URL + 'log//', LogDetailView.as_view()), + path(API_BASE_URL + 'server/', ServerInfoView.as_view()), +] diff --git a/apps/monitor/views.py b/apps/monitor/views.py new file mode 100644 index 00000000..2926001c --- /dev/null +++ b/apps/monitor/views.py @@ -0,0 +1,120 @@ + +from django.shortcuts import render +import psutil +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework.permissions import IsAuthenticated +from rest_framework.viewsets import ViewSet +from django.conf import settings +import os +from rest_framework import serializers, status +from drf_yasg import openapi +from drf_yasg.utils import swagger_auto_schema +from apps.utils.response import SuccessResponse +# Create your views here. + + +def index(request): + return render(request, 'monitor/index.html') + + +def room(request, room_name): + return render(request, 'monitor/room.html', { + 'room_name': room_name + }) + +def video(request): + return render(request, 'monitor/video.html') + + +class ServerInfoView(APIView): + permission_classes = [IsAuthenticated] + def get(self, request, *args, **kwargs): + """ + 获取服务器当前状态 + + cpu/内存/硬盘 + """ + ret = {'cpu': {}, 'memory': {}, 'disk': {}} + ret['cpu']['count'] = psutil.cpu_count() + ret['cpu']['lcount'] = psutil.cpu_count(logical=False) + ret['cpu']['percent'] = psutil.cpu_percent(interval=1) + memory = psutil.virtual_memory() + ret['memory']['total'] = round(memory.total/1024/1024/1024, 2) + ret['memory']['used'] = round(memory.used/1024/1024/1024, 2) + ret['memory']['percent'] = memory.percent + disk = psutil.disk_usage('/') + ret['disk']['total'] = round(disk.total/1024/1024/1024, 2) + ret['disk']['used'] = round(disk.used/1024/1024/1024, 2) + ret['disk']['percent'] = disk.percent + return SuccessResponse(ret) + + +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): + + @swagger_auto_schema(manual_parameters=[ + openapi.Parameter('name', openapi.IN_QUERY, + description='日志文件名', type=openapi.TYPE_STRING) + ]) + 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/1000, 1) + }) + else: + fsize = os.path.getsize(filepath) + if fsize: + logs.append({ + "name": file, + "filepath": filepath, + "size": round(fsize/1000, 1) + }) + return SuccessResponse(data=logs) + + +class LogDetailView(APIView): + + def get(self, request, name): + """ + 查看日志详情 + + 查看日志详情 + """ + try: + with open(os.path.join(settings.LOG_PATH, name)) as f: + data = f.read() + return Response(data) + except: + return Response('未找到', status=status.HTTP_404_NOT_FOUND) diff --git a/apps/system/__init__.py b/apps/system/__init__.py new file mode 100644 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 100644 index 00000000..7dee9a6b --- /dev/null +++ b/apps/system/admin.py @@ -0,0 +1,11 @@ +from django.contrib import admin +from simple_history.admin import SimpleHistoryAdmin +from .models import User, Dept, Role, Permission, DictType, Dict, 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(Dict, SimpleHistoryAdmin) +admin.site.register(File) \ No newline at end of file diff --git a/apps/system/apps.py b/apps/system/apps.py new file mode 100644 index 00000000..dfd43d16 --- /dev/null +++ b/apps/system/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class SystemConfig(AppConfig): + name = 'apps.system' + verbose_name = '系统管理' + + def ready(self): + import apps.system.signals \ No newline at end of file diff --git a/apps/system/filters.py b/apps/system/filters.py new file mode 100644 index 00000000..41997900 --- /dev/null +++ b/apps/system/filters.py @@ -0,0 +1,11 @@ +from django_filters import rest_framework as filters +from .models import User + + +class UserFilter(filters.FilterSet): + class Meta: + model = User + fields = { + 'name': ['exact', 'contains'], + 'is_active': ['exact'], + } diff --git a/apps/system/migrations/0001_initial.py b/apps/system/migrations/0001_initial.py new file mode 100644 index 00000000..c206688d --- /dev/null +++ b/apps/system/migrations/0001_initial.py @@ -0,0 +1,263 @@ +# Generated by Django 3.2.12 on 2022-04-02 01:21 + +import apps.utils.snowflake +from django.conf import settings +import django.contrib.auth.models +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(default=apps.utils.snowflake.IdWorker.get_id, 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='姓名')), + ('phone', models.CharField(blank=True, max_length=11, null=True, unique=True, verbose_name='手机号')), + ('avatar', models.CharField(blank=True, default='/media/default/avatar.png', max_length=100, null=True, verbose_name='头像')), + ], + options={ + 'verbose_name': '用户信息', + 'verbose_name_plural': '用户信息', + 'ordering': ['create_time'], + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + migrations.CreateModel( + name='Dept', + fields=[ + ('id', models.CharField(default=apps.utils.snowflake.IdWorker.get_id, 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.PositiveSmallIntegerField(choices=[(10, '公司'), (20, '部门')], default=20, verbose_name='类型')), + ('sort', models.PositiveSmallIntegerField(default=1, 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(default=apps.utils.snowflake.IdWorker.get_id, 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(default=apps.utils.snowflake.IdWorker.get_id, 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, unique=True, verbose_name='名称')), + ('code', models.CharField(blank=True, max_length=32, null=True, unique=True, verbose_name='岗位标识')), + ('description', models.CharField(blank=True, max_length=50, null=True, verbose_name='描述')), + ('data_range', models.PositiveSmallIntegerField(choices=[(10, '全部'), (30, '同级及以下'), (40, '本级及以下'), (50, '本级'), (60, '仅本人')], default=40, 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='创建人')), + ], + options={ + 'verbose_name': '职位/岗位', + 'verbose_name_plural': '职位/岗位', + 'ordering': ['create_time'], + }, + ), + migrations.CreateModel( + name='UserPost', + fields=[ + ('id', models.CharField(default=apps.utils.snowflake.IdWorker.get_id, 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, to='system.dept')), + ('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='system.post')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': '用户岗位关系表', + 'verbose_name_plural': '用户岗位关系表', + 'ordering': ['sort', 'create_time'], + }, + ), + migrations.CreateModel( + name='Role', + fields=[ + ('id', models.CharField(default=apps.utils.snowflake.IdWorker.get_id, 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, unique=True, verbose_name='角色')), + ('code', models.CharField(blank=True, max_length=32, null=True, unique=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.AddField( + model_name='post', + name='roles', + field=models.ManyToManyField(related_name='post_roles', to='system.Role', verbose_name='关联角色'), + ), + migrations.AddField( + model_name='post', + name='update_by', + field=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='最后编辑人'), + ), + migrations.CreateModel( + name='File', + fields=[ + ('id', models.CharField(default=apps.utils.snowflake.IdWorker.get_id, 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(default=apps.utils.snowflake.IdWorker.get_id, 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, unique=True, 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='posts', + field=models.ManyToManyField(related_name='user_posts', through='system.UserPost', to='system.Post'), + ), + 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='Dict', + fields=[ + ('id', models.CharField(default=apps.utils.snowflake.IdWorker.get_id, 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(max_length=10, 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='dict_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.dict', 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='dict_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/__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 100644 index 00000000..ed991d71 --- /dev/null +++ b/apps/system/models.py @@ -0,0 +1,220 @@ +from django.db import models +from django.contrib.auth.models import AbstractUser +from apps.utils.models import CommonAModel, CommonBModel, BaseModel + + +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): + """ + 部门 + """ + DEPT_TYPE_COMPANY = 10 + DEPT_TYPE_DEPT = 20 + dept_type_choices = ( + (DEPT_TYPE_COMPANY, '公司'), + (DEPT_TYPE_DEPT, '部门') + ) + name = models.CharField('名称', max_length=60) + type = models.PositiveSmallIntegerField('类型', choices=dept_type_choices, default=20) + parent = models.ForeignKey('self', null=True, blank=True, + on_delete=models.SET_NULL, verbose_name='父') + sort = models.PositiveSmallIntegerField('排序标记', default=1) + + class Meta: + verbose_name = '部门' + verbose_name_plural = verbose_name + ordering = ['sort'] + + def __str__(self): + return self.name + + +class Role(CommonAModel): + """ + 角色 + """ + name = models.CharField('角色', max_length=32, unique=True) + code = models.CharField('角色标识', max_length=32, unique=True, 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(CommonAModel): + """ + 职位/岗位 + """ + name = models.CharField('名称', max_length=32, unique=True) + code = models.CharField('岗位标识', max_length=32, unique=True, null=True, blank=True) + description = models.CharField('描述', max_length=50, blank=True, null=True) + roles = models.ManyToManyField(Role, verbose_name='关联角色', related_name='post_roles') + POST_DATA_ALL = 10 + POST_DATA_CUSTOM = 20 + POST_DATA_SAMELEVE_AND_BELOW = 30 + POST_DATA_THISLEVEL_AND_BELOW = 40 + POST_DATA_THISLEVEL = 50 + POST_DATA_MYSELF = 60 + data_type_choices = ( + (POST_DATA_ALL, '全部'), + (POST_DATA_SAMELEVE_AND_BELOW, '同级及以下'), + (POST_DATA_THISLEVEL_AND_BELOW, '本级及以下'), + (POST_DATA_THISLEVEL, '本级'), + (POST_DATA_MYSELF, '仅本人') + ) + data_range = models.PositiveSmallIntegerField('数据权限范围', choices=data_type_choices, + default=POST_DATA_THISLEVEL_AND_BELOW) + + class Meta: + verbose_name = '职位/岗位' + verbose_name_plural = verbose_name + ordering = ['create_time'] + + def __str__(self): + return self.name + + +class User(AbstractUser, CommonBModel): + """ + 用户 + """ + name = models.CharField('姓名', max_length=20, null=True, blank=True) + phone = models.CharField('手机号', max_length=11, + null=True, blank=True, unique=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='上级主管') + posts = models.ManyToManyField(Post, through='system.userpost', related_name='user_posts') + depts = models.ManyToManyField(Dept, through='system.userpost') + + 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) + post = models.ForeignKey(Post, on_delete=models.CASCADE) + dept = models.ForeignKey(Dept, on_delete=models.CASCADE) + sort = models.PositiveSmallIntegerField('排序', default=1) + + class Meta: + verbose_name = '用户岗位关系表' + verbose_name_plural = verbose_name + ordering = ['sort', 'create_time'] + + +class DictType(CommonAModel): + """ + 数据字典类型 + """ + name = models.CharField('名称', max_length=30) + code = models.CharField('标识', unique=True, 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 Dict(CommonAModel): + """ + 数据字典 + """ + name = models.CharField('名称', max_length=60) + value = models.CharField('值', max_length=10) + 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 diff --git a/apps/system/serializers.py b/apps/system/serializers.py new file mode 100644 index 00000000..09d8748e --- /dev/null +++ b/apps/system/serializers.py @@ -0,0 +1,286 @@ +import re +from django_celery_beat.models import PeriodicTask, CrontabSchedule, IntervalSchedule +from rest_framework import serializers +from django_celery_results.models import TaskResult +from apps.utils.serializers import CustomModelSerializer +from apps.utils.vars import EXCLUDE_FIELDS, EXCLUDE_FIELDS_BASE +from .models import (Dict, DictType, File, Dept, Permission, Post, + Role, User, UserPost) + + +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__' + + @staticmethod + def setup_eager_loading(queryset): + """ Perform necessary eager loading of data. """ + queryset = queryset.select_related('interval', 'crontab') + return queryset + + 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 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 = Dict + fields = '__all__' + +class DictCreateUpdateSerializer(CustomModelSerializer): + """ + 数据字典序列化 + """ + + class Meta: + model = Dict + exclude = EXCLUDE_FIELDS + +class PostSerializer(CustomModelSerializer): + """ + 岗位序列化 + """ + + class Meta: + model = Post + fields = '__all__' + +class PostCreateUpdateSerializer(CustomModelSerializer): + """ + 岗位序列化 + """ + + class Meta: + model = Post + exclude = EXCLUDE_FIELDS + + +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): + """ + 角色序列化 + """ + + class Meta: + model = Role + exclude = EXCLUDE_FIELDS + +class PermissionSerializer(CustomModelSerializer): + """ + 权限序列化 + """ + + class Meta: + model = Permission + fields = '__all__' + +class PermissionCreateUpdateSerializer(CustomModelSerializer): + """ + 权限序列化 + """ + + class Meta: + model = Permission + exclude = EXCLUDE_FIELDS_BASE + +class DeptSimpleSerializer(CustomModelSerializer): + class Meta: + model = Dept + fields = ['id', 'name', 'type'] + + +class DeptSerializer(CustomModelSerializer): + """ + 组织架构序列化 + """ + type = serializers.ChoiceField( + choices=Dept.dept_type_choices, default='部门') + + class Meta: + model = Dept + fields = '__all__' + +class DeptCreateUpdateSerializer(CustomModelSerializer): + """ + 部门序列化 + """ + + class Meta: + model = Dept + exclude = EXCLUDE_FIELDS + +class UserSimpleSerializer(CustomModelSerializer): + class Meta: + model = User + fields = ['id', 'username', 'name'] + + +class UserListSerializer(CustomModelSerializer): + """ + 用户列表序列化 + """ + belong_dept_ = DeptSimpleSerializer(source='belong_dept', read_only=True) + posts_ = PostSimpleSerializer(source='posts', many=True) + + class Meta: + model = User + exclude = ['password'] + + @staticmethod + def setup_eager_loading(queryset): + """ Perform necessary eager loading of data. """ + queryset = queryset.select_related('superior', 'belong_dept') + queryset = queryset.prefetch_related('posts') + return queryset + + +class UserUpdateSerializer(CustomModelSerializer): + """ + 用户编辑序列化 + """ + phone = serializers.CharField(max_length=11, required=False) + + class Meta: + model = User + fields = ['id', 'username', 'name', 'phone', 'email', 'belong_dept', + 'avatar', 'is_active', 'is_superuser'] + + def validate_phone(self, phone): + re_phone = '^1[358]\d{9}$|^147\d{8}$|^176\d{8}$' + if not re.match(re_phone, phone): + raise serializers.ValidationError('手机号码不合法') + return phone + + +class UserCreateSerializer(CustomModelSerializer): + """ + 创建用户序列化 + """ + username = serializers.CharField(required=True) + phone = serializers.CharField(max_length=11, required=False) + + class Meta: + model = User + fields = ['id', 'username', 'name', 'phone', 'email', 'belong_dept', + 'avatar', 'is_active'] + + def validate_username(self, username): + if User.objects.filter(username=username): + raise serializers.ValidationError(username + ' 账号已存在') + return username + + def validate_phone(self, phone): + re_phone = '^1[358]\d{9}$|^147\d{8}$|^176\d{8}$' + if not re.match(re_phone, phone): + raise serializers.ValidationError('手机号码不合法') + if User.objects.filter(phone=phone): + raise serializers.ValidationError('手机号已经被注册') + return phone + + +class PTaskResultSerializer(CustomModelSerializer): + class Meta: + model = TaskResult + fields = '__all__' + + +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 UserInfoSerializer(CustomModelSerializer): + posts_ = UserPostSerializer(source='post', read_only=True) + + class Meta: + model = User + fields = ['id', 'username', 'name', 'posts_', 'avatar'] diff --git a/apps/system/signals.py b/apps/system/signals.py new file mode 100644 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 100644 index 00000000..6d2adaec --- /dev/null +++ b/apps/system/tasks.py @@ -0,0 +1,9 @@ +# Create your tasks here +from __future__ import absolute_import, unicode_literals + +from celery import shared_task + + +@shared_task +def show(): + print('ok') \ No newline at end of file diff --git a/apps/system/tests.py b/apps/system/tests.py new file mode 100644 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 100644 index 00000000..b5fe81a6 --- /dev/null +++ b/apps/system/urls.py @@ -0,0 +1,27 @@ +from django.urls import path, include +from .views import FileViewSet, PTaskResultViewSet, TaskList, UserPostViewSet, UserViewSet, DeptViewSet, PermissionViewSet, RoleViewSet, PostViewSet, DictTypeViewSet, DictViewSet, PTaskViewSet +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('user_post', UserPostViewSet, basename='user_post') + +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/', include(router2.urls)), +] diff --git a/apps/system/views.py b/apps/system/views.py new file mode 100644 index 00000000..005acc5c --- /dev/null +++ b/apps/system/views.py @@ -0,0 +1,390 @@ +import logging + +from django.conf import settings +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 ValidationError +from rest_framework.mixins import (CreateModelMixin, DestroyModelMixin, + ListModelMixin, RetrieveModelMixin) +from rest_framework.parsers import (JSONParser, + MultiPartParser) +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.utils.mixins import (CustomCreateModelMixin) +from apps.utils.permission import get_user_perms_map +from apps.utils.queryset import get_child_queryset2 +from apps.utils.response import FailResponse, SuccessResponse +from apps.utils.viewsets import CustomGenericViewSet, CustomModelViewSet +from server.celery import app as celery_app +from .filters import UserFilter +from .models import (Dept, Dict, DictType, File, Permission, Post, Role, User, + UserPost) +from .serializers import (DeptCreateUpdateSerializer, DeptSerializer, DictCreateUpdateSerializer, DictSerializer, DictTypeCreateUpdateSerializer, DictTypeSerializer, + FileSerializer, PermissionCreateUpdateSerializer, PermissionSerializer, PostCreateUpdateSerializer, PostSerializer, + PTaskCreateUpdateSerializer, PTaskResultSerializer, + PTaskSerializer, RoleCreateUpdateSerializer, RoleSerializer, + UserCreateSerializer, UserListSerializer, + UserPostSerializer, UserUpdateSerializer) + +logger = logging.getLogger('log') + + +# 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, requests): + """获取注册任务列表 + + 获取注册任务列表 + """ + tasks = list( + sorted(name for name in celery_app.tasks if not name.startswith('celery.'))) + return Response(tasks) + + +class PTaskViewSet(CustomModelViewSet): + """ + list:定时任务列表 + + 定时任务列表 + + retrieve:定时任务详情 + + 定时任务详情 + """ + queryset = PeriodicTask.objects.exclude(name__contains='celery.') + serializer_class = PTaskSerializer + create_serializer_class = PTaskCreateUpdateSerializer + update_serializer_class = PTaskCreateUpdateSerializer + search_fields = ['name', 'task'] + filterset_fields = ['enabled'] + ordering = ['-create_time'] + + @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 SuccessResponse() + + @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: + raise ValidationError('时间策略有误') + 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: + raise ValidationError('时间策略有误') + serializer = self.get_serializer(data=data) + serializer.is_valid(raise_exception=True) + serializer.save() + return SuccessResponse() + + @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: + raise ValidationError('时间策略有误') + 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: + raise ValidationError('时间策略有误') + instance = self.get_object() + serializer = self.get_serializer(instance, data=data) + serializer.is_valid(raise_exception=True) + serializer.save() + return SuccessResponse() + + +class PTaskResultViewSet(ListModelMixin, RetrieveModelMixin, CustomGenericViewSet): + """ + list:任务执行结果列表 + + 任务执行结果列表 + + retrieve:任务执行结果详情 + + 任务执行结果详情 + """ + perms_map = {'get': '*'} + filterset_fields = ['task_name'] + queryset = TaskResult.objects.all() + serializer_class = PTaskResultSerializer + ordering = ['-date_created'] + + +class DictTypeViewSet(CustomModelViewSet): + """数据字典类型-增删改查 + + 数据字典类型-增删改查 + """ + queryset = DictType.objects.all() + serializer_class = DictTypeSerializer + create_serializer_class = DictTypeCreateUpdateSerializer + update_serializer_class = DictTypeCreateUpdateSerializer + search_fields = ['name'] + + +class DictViewSet(CustomModelViewSet): + """数据字典-增删改查 + + 数据字典-增删改查 + """ + # queryset = Dict.objects.get_queryset(all=True) # 获取全部的,包括软删除的 + queryset = Dict.objects.all() + filterset_fields = ['type', 'is_used', 'type__code'] + serializer_class = DictSerializer + create_serializer_class = DictCreateUpdateSerializer + update_serializer_class = DictCreateUpdateSerializer + search_fields = ['name'] + + +class PostViewSet(CustomModelViewSet): + """岗位-增删改查 + + 岗位-增删改查 + """ + queryset = Post.objects.all() + serializer_class = PostSerializer + create_serializer_class = PostCreateUpdateSerializer + update_serializer_class = PostCreateUpdateSerializer + search_fields = ['name', 'description'] + + +class PermissionViewSet(CustomModelViewSet): + """菜单权限-增删改查 + + 菜单权限-增删改查 + """ + queryset = Permission.objects.all() + filterset_fields = ['type'] + serializer_class = PermissionSerializer + create_serializer_class = PermissionCreateUpdateSerializer + update_serializer_class = PermissionCreateUpdateSerializer + search_fields = ['name', 'code'] + + +class DeptViewSet(CustomModelViewSet): + """部门-增删改查 + + 部门-增删改查 + """ + queryset = Dept.objects.all() + serializer_class = DeptSerializer + create_serializer_class = DeptCreateUpdateSerializer + update_serializer_class = DeptCreateUpdateSerializer + filterset_fields = ['type'] + search_fields = ['name'] + + +class RoleViewSet(CustomModelViewSet): + """角色-增删改查 + + 角色-增删改查 + """ + queryset = Role.objects.all() + serializer_class = RoleSerializer + create_serializer_class = RoleCreateUpdateSerializer + update_serializer_class = RoleCreateUpdateSerializer + search_fields = ['name', 'code'] + + +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 + filterset_fields = ['user', 'post', 'dept'] + + def perform_create(self, serializer): + instance = serializer.save() + user = instance.user + adept = UserPost.objects.filter(user=user).order_by('sort', 'create_time').first() + if adept: + user.belong_dept = adept + user.update_by = self.request.user + user.save() + + def perform_destroy(self, instance): + user = instance.user + instance.delete() + fdept = UserPost.objects.filter(user=user).order_by('sort', 'create_time').first() + if fdept: + user.belong_dept = fdept + else: + user.belong_dept = None + user.update_by = self.request.user + user.save() + + +class UserViewSet(CustomModelViewSet): + queryset = User.objects.all() + serializer_class = UserListSerializer + create_serializer_class = UserCreateSerializer + update_serializer_class = UserUpdateSerializer + filterset_class = UserFilter + search_fields = ['username', 'name', 'phone', 'email'] + + def get_queryset(self): + queryset = self.queryset + if hasattr(self.get_serializer_class(), 'setup_eager_loading'): + queryset = self.get_serializer_class().setup_eager_loading(queryset) # 性能优化 + dept = self.request.query_params.get( + 'belong_dept', None) # 该部门及其子部门所有员工 + if dept: + dept_queryset = get_child_queryset2( + Dept.objects.get(pk=dept)) + queryset = queryset.filter(dept__in=dept_queryset) + return queryset + + def create(self, request, *args, **kwargs): + """创建用户 + + 创建用户 + """ + password = request.data.get('password', None) + if password: + password = make_password(password) + else: + password = make_password('0000') + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save(password=password) + return SuccessResponse(data=serializer.data) + + @action(methods=['put'], detail=False, permission_classes=[IsAuthenticated]) + 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: + user.set_password(new_password2) + user.save() + return SuccessResponse() + else: + return FailResponse(msg='新密码两次输入不一致!') + else: + return FailResponse(msg='旧密码错误!') + + @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, + 'name': user.name, + 'posts': user.posts.values_list('name', flat=True), + 'avatar': user.avatar, + 'perms': perms, + } + return SuccessResponse(data) + + +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'] + + 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 = '其它' + if 'image' in mime: + file_type = '图片' + elif 'video' in mime: + file_type = '视频' + elif 'audio' in mime: + file_type = '音频' + elif 'application' or 'text' in mime: + file_type = '文档' + 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() diff --git a/apps/third/__init__.py b/apps/third/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/third/admin.py b/apps/third/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/apps/third/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/apps/third/apps.py b/apps/third/apps.py new file mode 100644 index 00000000..de82698c --- /dev/null +++ b/apps/third/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ThirdConfig(AppConfig): + name = 'apps.third' diff --git a/apps/third/migrations/__init__.py b/apps/third/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/third/models.py b/apps/third/models.py new file mode 100644 index 00000000..71a83623 --- /dev/null +++ b/apps/third/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/apps/third/serializers.py b/apps/third/serializers.py new file mode 100644 index 00000000..0879cec3 --- /dev/null +++ b/apps/third/serializers.py @@ -0,0 +1,14 @@ +from rest_framework import serializers + + +class RequestCommonSerializer(serializers.Serializer): + method_choice = ( + ('post','post'), + ('get','get'), + ('put','put'), + ('delete','delete') + ) + url = serializers.CharField(label='请求地址') + method = serializers.ChoiceField(label='请求方法', choices=method_choice) + params = serializers.JSONField(label='请求参数', required=False, allow_null=True) + json = serializers.JSONField(label='请求body', required=False, allow_null=True) \ No newline at end of file diff --git a/apps/third/tests.py b/apps/third/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/apps/third/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/third/urls.py b/apps/third/urls.py new file mode 100644 index 00000000..bc7492ec --- /dev/null +++ b/apps/third/urls.py @@ -0,0 +1,16 @@ +from django.urls import path, include +from rest_framework import routers +from apps.third.views import DahuaTestView, DhCommonViewSet, XxCommonViewSet, XxTestView + +API_BASE_URL = 'api/third/' +HTML_BASE_URL = 'third/' + +router = routers.DefaultRouter() +router.register('xunxi', XxCommonViewSet, basename='api_xunxi') +router.register('dahua', DhCommonViewSet, basename='api_dahua') + +urlpatterns = [ + path(API_BASE_URL, include(router.urls)), + path(API_BASE_URL + 'dahua/test/', DahuaTestView.as_view()), + path(API_BASE_URL + 'xunxi/test/', XxTestView.as_view()), +] \ No newline at end of file diff --git a/apps/third/views.py b/apps/third/views.py new file mode 100644 index 00000000..3dc9754d --- /dev/null +++ b/apps/third/views.py @@ -0,0 +1,95 @@ +from django.shortcuts import render +from apps.utils.dahua import dhClient +from apps.utils.xunxi import xxClient +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework.permissions import IsAuthenticated +from apps.utils.viewsets import CustomGenericViewSet +from rest_framework.mixins import CreateModelMixin + +from apps.third.serializers import RequestCommonSerializer +# Create your views here. + + +class DahuaTestView(APIView): + """ + 大华测试接口 + """ + permission_classes = [IsAuthenticated] + + def get(self, request, *args, **kwargs): + data = { + "data":{ + "channelId": "1001339$1$0$0", + "streamType": "1", + "type": "hls" + } + } + res = dhClient.request( + url='/evo-apigw/admin/API/video/stream/realtime', method='post', json=data) + # data = { + # "pageNum":1, + # "pageSize":100, + # "isOnline":1, + # "showChildNodeData":1, + # "categorys":[8] + + # } + # res = dhClient.request( + # url='/evo-apigw/evo-brm/1.0.0/device/subsystem/page', method='post', json=data) + # data = { + # "channelCodeList": ["1001382$7$0$0"] + # } + # res = dhClient.request( + # url='/evo-apigw/evo-accesscontrol/1.0.0/card/accessControl/channelControl/closeDoor', method='post', json=data) + return Response(res) + + +class XxTestView(APIView): + """ + 寻息测试接口 + """ + permission_classes = [IsAuthenticated] + + def get(self, request, *args, **kwargs): + res = xxClient.request( + url='/api/application/build/buildListV2', json={}) + return Response(res) + + +class XxCommonViewSet(CreateModelMixin, CustomGenericViewSet): + """ + 寻息通用调用接口 + """ + perms_map = {'post': '*'} + serializer_class = RequestCommonSerializer + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + vdata = serializer.validated_data + res = xxClient.request( + url=vdata['url'], + method=vdata.get('method', 'post'), + params=vdata.get('params', {}), + json=vdata.get('data', {})) + return Response(res) + + +class DhCommonViewSet(CreateModelMixin, CustomGenericViewSet): + """ + 大华通用调用接口 + """ + perms_map = {'post': '*'} + serializer_class = RequestCommonSerializer + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + vdata = serializer.validated_data + res = dhClient.request( + url=vdata['url'], + method=vdata.get('method', 'post'), + params=vdata.get('params', {}), + json=vdata.get('data', {})) + return Response(res) diff --git a/apps/utils/__init__.py b/apps/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/utils/admin.py b/apps/utils/admin.py new file mode 100644 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 100644 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/dahua.py b/apps/utils/dahua.py new file mode 100644 index 00000000..fb554342 --- /dev/null +++ b/apps/utils/dahua.py @@ -0,0 +1,99 @@ +from threading import Thread +import traceback +import requests +from apps.utils.tools import print_roundtrip +from server import settings +import json +import time +requests.packages.urllib3.disable_warnings() + +class DhClient: + """ + 大华 + """ + + def __init__(self, client_id= settings.DAHUA_CLIENTID + , client_secret = settings.DAHUA_SECRET) -> None: + self.client_id = client_id + self.client_secret = client_secret + self.headers = {} + self.isGetingToken = False + self.isRuning = True + self.t = None # 线程 + self.setup() + + def _get_token_loop(self): + while self.isRuning: + params = { + 'grant_type': 'client_credentials', + 'client_id': self.client_id, + 'client_secret': self.client_secret + } + r = requests.post(params=params, url=settings.DAHUA_BASE_URL + '/evo-apigw/evo-oauth/oauth/token', verify=False) + ret = r.json() + if ret['success']: + self.headers['Authorization'] = 'bearer ' + ret['data']['access_token'] + self.headers['User-Id'] = '1' + time.sleep(3600) + + def get_token(self): + self.isGetingToken = True + params = { + 'grant_type': 'client_credentials', + 'client_id': self.client_id, + 'client_secret': self.client_secret + } + r = requests.post(params=params, url=settings.DAHUA_BASE_URL + '/evo-apigw/evo-oauth/oauth/token', verify=False) + ret = r.json() + if ret['success']: + self.headers['Authorization'] = 'bearer ' + ret['data']['access_token'] + self.headers['User-Id'] = '1' + self.isGetingToken = False + + 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=20): + if self.isGetingToken: + req_num = 0 + while True: + time.sleep(0.5) + if not self.isGetingToken: + self.request(url, method, params, json, timeout) + req_num = req_num + 1 + if req_num > 4: + break + else: + r = getattr(requests, method)('{}{}'.format(settings.DAHUA_BASE_URL, url) + , headers = self.headers, params=params, json=json, verify=False) + if settings.DEBUG: + print_roundtrip(r) + if r.status_code == 200: + """ + 请求成功 + """ + ret = r.json() + if ret.get('code') == '27001007': + self.get_token() # 重新获取token + self.request(url, method, params, json, timeout) # 重新请求 + else: + + msg = '{}|{}{}'.format(str(ret['code']), ret.get('errMsg',''), ret.get('desc', '')) + + res = dict(success=True, code=200000, msg= msg, data=ret.get('data', None)) + if ret['code'] not in ['0', '100', '00000']: + res['success'] = False + res['code'] = 400000 + return res + return dict(success=False, code=400901, msg='大华接口访问异常', data=None) + +if settings.DAHUA_ENABLED: + dhClient = DhClient() \ No newline at end of file diff --git a/apps/utils/filters.py b/apps/utils/filters.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/utils/mixins.py b/apps/utils/mixins.py new file mode 100644 index 00000000..f17391a8 --- /dev/null +++ b/apps/utils/mixins.py @@ -0,0 +1,65 @@ +from django.db.models.query import QuerySet +from rest_framework.mixins import CreateModelMixin, UpdateModelMixin, ListModelMixin, RetrieveModelMixin, DestroyModelMixin +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 OptimizationMixin: + """ + 性能优化,需要在序列化器里定义setup_eager_loading,可在必要的View下继承 + """ + def get_queryset(self): + 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) # 性能优化 + return queryset + +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 CustomDestoryModelMixin(DestroyModelMixin): + + def perform_destroy(self, instance): + instance.delete(update_by = self.request.user) + diff --git a/apps/utils/models.py b/apps/utils/models.py new file mode 100644 index 00000000..2c8d582c --- /dev/null +++ b/apps/utils/models.py @@ -0,0 +1,162 @@ +from django.db import models +import django.utils.timezone as timezone +from django.db.models.query import QuerySet + +from apps.utils.snowflake import worker + +# 自定义软删除查询基类 + + +class SoftDeletableQuerySetMixin(object): + ''' + QuerySet for SoftDeletableModel. Instead of removing instance sets + its ``is_deleted`` field to True. + ''' + + def delete(self, soft=True, update_by=None): + ''' + Soft delete objects from queryset (set their ``is_deleted`` + field to True) + ''' + if soft: + self.update(is_deleted=True, update_by=update_by) + 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, default=worker.get_id, 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): + if self.pk: + # If self.pk is not None then it's an update. + cls = self.__class__ + old = cls.objects.filter(pk=self.pk).first() + if old: + # This will get the current model state since super().save() isn't called yet. + new = self # This gets the newly instantiated Mode object with the new values. + changed_fields = [] + for field in cls._meta.get_fields(): + field_name = field.name + try: + if getattr(old, field_name) != getattr(new, field_name): + changed_fields.append(field_name) + except Exception as ex: # Catch field does not exist exception + pass + kwargs['update_fields'] = changed_fields + super().save(*args, **kwargs) + +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') + + 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') + 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') + + 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') + belong_dept = models.ForeignKey( + 'system.organzation', null=True, blank=True, on_delete=models.SET_NULL, verbose_name='所属部门', related_name= '%(class)s_belong_dept') + + class Meta: + abstract = True diff --git a/apps/utils/pagination.py b/apps/utils/pagination.py new file mode 100644 index 00000000..3c6d530a --- /dev/null +++ b/apps/utils/pagination.py @@ -0,0 +1,14 @@ +from rest_framework.pagination import PageNumberPagination +from rest_framework.exceptions import ParseError + +class MyPagination(PageNumberPagination): + page_size = 10 + page_size_query_param = 'page_size' + + def paginate_queryset(self, queryset, request, view): + if request.query_params.get('pageoff', None) or request.query_params.get('page', None)=='0': + if queryset.count()<500: + return None + elif queryset.count()>=500: + raise ParseError('单次请求数据量大,请分页获取') + return super().paginate_queryset(queryset, request, view=view) \ No newline at end of file diff --git a/apps/utils/permission.py b/apps/utils/permission.py new file mode 100644 index 00000000..c571a3d3 --- /dev/null +++ b/apps/utils/permission.py @@ -0,0 +1,128 @@ +from django.core.cache import cache +from rest_framework.permissions import BasePermission, DjangoModelPermissions +from apps.utils.queryset import get_child_queryset2 +from apps.system.models import Dept, Permission, Post, Role, UserPost +from django.db.models import Q +from django.db.models.query import QuerySet + + +def get_user_perms_map(user): + """ + 获取权限列表,可用redis存取 + """ + user_perms_map = {} + if user.is_superuser: + user_perms_map = {'superuser':None} + else: + objs = UserPost.objects.filter(user=user) + for i in objs: + dept_id = str(i.dept.id) + if i.post.roles: # 岗位下有角色 + for perm in Permission.objects.filter(role__perms__in=i.post.roles): + 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 i.post.data_range < data_range: + user_perms_map[code][dept_id] = data_range + else: + user_perms_map[code] = {dept_id:i.post.data_range} + cache.set('perms_' + user.id, user_perms_map, 60*60) + 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 False + user_perms_map = cache.get('perms_' + request.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 'superuser' in user_perms_map: + return True + else: + 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 == Post.POST_DATA_ALL: + return queryset + elif data_range == Post.POST_DATA_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 == Post.POST_DATA_THISLEVEL_AND_BELOW: + belong_depts = get_child_queryset2(dept) + queryset = queryset.filter(belong_dept__in = belong_depts) + elif data_range == Post.POST_DATA_THISLEVEL: + queryset = queryset.filter(belong_dept = dept) + elif data_range == Post.POST_DATA_THISLEVEL: + 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 100644 index 00000000..73a6bb12 --- /dev/null +++ b/apps/utils/queryset.py @@ -0,0 +1,70 @@ +from django.db import models +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) \ No newline at end of file diff --git a/apps/utils/request.py b/apps/utils/request.py new file mode 100644 index 00000000..8591cbbe --- /dev/null +++ b/apps/utils/request.py @@ -0,0 +1,104 @@ +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 as e: + 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 as e: + pass + return model if model else "" diff --git a/apps/utils/response.py b/apps/utils/response.py new file mode 100644 index 00000000..812ab6d3 --- /dev/null +++ b/apps/utils/response.py @@ -0,0 +1,83 @@ +import traceback +from rest_framework.renderers import JSONRenderer +from rest_framework.views import exception_handler +from rest_framework.response import Response +from rest_framework import status as drf_status +import logging + +logger = logging.getLogger('log') + +def custome_exception_hander(exc, context): + """ + 自定义异常处理 + """ + res = exception_handler(exc, context) + if res: + return res + else: + """ + 日志记录 + """ + logger.error(traceback.format_exc()) + return None + +class FitJSONRenderer(JSONRenderer): + """ + 自行封装的渲染器 + """ + + def render(self, data, accepted_media_type=None, renderer_context=None): + """ + 如果使用这个render, + 普通的response将会被包装成: + {"code":200000,"data":"X","msg":"X"} + 这样的结果 + 使用方法: + - 全局 + REST_FRAMEWORK = { + 'DEFAULT_RENDERER_CLASSES': ('utils.response.FitJSONRenderer', ), + } + - 局部 + class UserCountView(APIView): + renderer_classes = [FitJSONRenderer] + + :param data: + :param accepted_media_type: + :param renderer_context: + :return: {"code":200,"data":"X","msg":"X"} + """ + response_body = dict(success=True, code=200000, msg='请求成功', data=None) + response = renderer_context.get("response") + status_code = response.status_code + if isinstance(data, dict) and data.keys() == response_body.keys(): + response_body = data + else: + # 处理drf格式的返回数据 + if status_code >= 400: # 如果http响应异常 + response_body = dict(success=False, code=400000, msg='请求失败', data=None) + response_body['data'] = data # data里是详细异常信息 + response_body['code'] = status_code*1000 + prefix = "" + if isinstance(data, dict): + prefix = list(data.keys())[0] + data = data[prefix] + if isinstance(data, list): + data = data[0] + + if prefix != 'detail': + response_body['msg'] = prefix + str(data) # 取一部分放入msg,方便前端alert + else: + response_body['data'] = data + renderer_context.get("response").status_code = 200 # 统一成200响应, 可用body里code区分业务异常 + return super(FitJSONRenderer, self).render(response_body, accepted_media_type, renderer_context) + + +class SuccessResponse(Response): + def __init__(self, data=None, msg='请求成功', code=200000, status=None, template_name=None, headers=None, exception=False, content_type=None): + std_data = dict(success=True, code=code, msg=msg, data=data) + super().__init__(std_data, status, template_name, headers, exception, content_type) + +class FailResponse(Response): + def __init__(self, msg='请求失败', data=None, code=400000, status=400, template_name=None, headers=None, exception=False, content_type=None): + std_data = dict(success=False, code=code, msg=msg, data=data) + super().__init__(std_data, status, template_name, headers, exception, content_type) \ No newline at end of file diff --git a/apps/utils/serializers.py b/apps/utils/serializers.py new file mode 100644 index 00000000..210d7695 --- /dev/null +++ b/apps/utils/serializers.py @@ -0,0 +1,13 @@ + +from rest_framework import serializers +from django_restql.mixins import DynamicFieldsMixin + +class PkSerializer(serializers.Serializer): + pks = serializers.ListField(child=serializers.IntegerField(min_value=1), label="主键ID列表") + +class GenSignatureSerializer(serializers.Serializer): + path = serializers.CharField(label="图片地址") + +class CustomModelSerializer(DynamicFieldsMixin, serializers.ModelSerializer): + pass + diff --git a/apps/utils/snowflake.py b/apps/utils/snowflake.py new file mode 100644 index 00000000..223b53ed --- /dev/null +++ b/apps/utils/snowflake.py @@ -0,0 +1,107 @@ +# 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 + +import time +import logging + +from django.conf import settings +# logger = logging.getLogger('log') + +class InvalidSystemClock(Exception): + """ + 时钟回拨异常 + """ + pass + +class Constant(object): + # 64位ID的划分 + WORKER_ID_BITS = 5 + 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: + # logger.error('clock is moving backwards. Rejecting requests until {}'.format(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 + +worker = IdWorker(settings.SNOW_DATACENTER_ID, settings.SNOW_WORKER_ID) + +if __name__ == '__main__': + print(worker.get_id()) \ No newline at end of file diff --git a/apps/utils/tools.py b/apps/utils/tools.py new file mode 100644 index 00000000..e61f4867 --- /dev/null +++ b/apps/utils/tools.py @@ -0,0 +1,22 @@ +import textwrap +import requests + +def print_roundtrip(response, *args, **kwargs): + format_headers = lambda d: '\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), + )) \ No newline at end of file diff --git a/apps/utils/urls.py b/apps/utils/urls.py new file mode 100644 index 00000000..5a03b06a --- /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)), +] \ No newline at end of file diff --git a/apps/utils/vars.py b/apps/utils/vars.py new file mode 100644 index 00000000..de5fb199 --- /dev/null +++ b/apps/utils/vars.py @@ -0,0 +1,4 @@ + + +EXCLUDE_FIELDS_BASE = ['create_time', 'update_time', 'is_deleted'] +EXCLUDE_FIELDS = ['create_time', 'update_time', 'is_deleted', 'create_by', 'update_by'] \ No newline at end of file diff --git a/apps/utils/views.py b/apps/utils/views.py new file mode 100644 index 00000000..6df54deb --- /dev/null +++ b/apps/utils/views.py @@ -0,0 +1,47 @@ +from rest_framework.views import APIView +import os +import cv2 +from server.settings import BASE_DIR +import numpy as np +from .response import FailResponse, SuccessResponse +from apps.utils.viewsets import CustomGenericViewSet +from apps.utils.mixins import CustomCreateModelMixin +from apps.utils.serializers import GenSignatureSerializer + +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 SuccessResponse({'path': new_path.replace(BASE_DIR, '')}) + except: + return FailResponse(msg='签名照处理失败,请重新上传') diff --git a/apps/utils/viewsets.py b/apps/utils/viewsets.py new file mode 100644 index 00000000..d29fee3b --- /dev/null +++ b/apps/utils/viewsets.py @@ -0,0 +1,71 @@ + +from rest_framework.viewsets import ModelViewSet, GenericViewSet +from rest_framework.decorators import action +from apps.utils.mixins import CustomCreateModelMixin, CustomDestoryModelMixin, CustomUpdateModelMixin, OptimizationMixin +from apps.utils.permission import RbacDataMixin, RbacPermission +from apps.utils.serializers import PkSerializer +from apps.utils.response import FailResponse, SuccessResponse +from rest_framework.mixins import DestroyModelMixin, RetrieveModelMixin, ListModelMixin +from rest_framework.permissions import IsAuthenticated + + +class CustomGenericViewSet(GenericViewSet): + """ + 增强的GenericViewSet + """ + perms_map = {} + ordering_fields = '__all__' + filter_fields = '__all__' + ordering = '-create_time' + filterset_fields = '__all__' + create_serializer_class = None + update_serializer_class = None + list_serializer_class = None + retrieve_serializer_class = None + permission_classes = [IsAuthenticated & RbacPermission] + + 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() + +class CustomDataGenericViewSet(RbacDataMixin, CustomGenericViewSet): + """ + 增强的GenericViewSet, 带数据权限过滤 + """ + +class CustomModelViewSet(OptimizationMixin, CustomCreateModelMixin + , CustomUpdateModelMixin, ListModelMixin, RetrieveModelMixin + , CustomDestoryModelMixin, CustomGenericViewSet): + """ + 增强的ModelViewSet + """ + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + # 增加默认权限标识 + if not self.perms_map: + basename = self.basename + self.perms_map = {'get':'*', 'post':'{}_create'.format(basename) + ,'put':'{}_update'.format(basename) + ,'patch':'{}_update'.format(basename) + ,'delete':'{}_delete'.format(basename) + ,'deletes':'{}_delete'.format(basename)} + + @action(methods=['post'], detail=False, serializer_class=PkSerializer) + def deletes(self,request,*args,**kwargs): + request_data = request.data + pks = request_data.get('pks',None) + if pks: + self.get_queryset().filter(id__in=pks).delete(update_by=request.user) + return SuccessResponse() + else: + return FailResponse(msg="未获取到pks字段") + + + +class CustomDataModelViewSet(RbacDataMixin, CustomModelViewSet): + """ + 增强的ModelViewSet,带数据权限过滤 + """ \ No newline at end of file diff --git a/apps/utils/xunxi.py b/apps/utils/xunxi.py new file mode 100644 index 00000000..57d447e0 --- /dev/null +++ b/apps/utils/xunxi.py @@ -0,0 +1,88 @@ +from threading import Thread +import requests +import json +from apps.utils.tools import print_roundtrip +from server import settings +import time +requests.packages.urllib3.disable_warnings() + + + +class XxClient: + """ + 寻息 + """ + def __init__(self, licence=settings.XX_LICENCE, username=settings.XX_USERNAME) -> None: + self.licence = licence + self.username = username + self.isGetingToken = False + self.isRuning = True + self.token = '' + self.t = None + self.setup() + + def _get_token_loop(self): + while self.isRuning: + json = { + 'licence':self.licence + } + r = requests.post(json=json, url=settings.XX_BASE_URL + '/getAccessTokenV2', verify=False, timeout=20) + ret = r.json() + if ret.get('errorCode', 1) == 0: + self.token = ret['data']['token'] + time.sleep(3600) + + def get_token(self): + self.isGetingToken = True + json = { + 'licence':self.licence + } + r = requests.post(json=json, url=settings.XX_BASE_URL + '/getAccessTokenV2', verify=False, timeout=20) + ret = r.json() + if ret.get('errorCode', 1) == 0: + self.isGetingToken = False + self.token = ret['data']['token'] + + def setup(self): + self.t = Thread(target= self._get_token_loop, args=(), daemon=True) + self.t.start() + + def __del__(self): + """ + 自定义销毁 + """ + self.isRuning = False + self.t.join() + + def request(self, url:str, method:str='post', params=dict(), json=dict(), timeout=20): + params['accessToken'] = self.token + json['username'] = self.username + if self.isGetingToken: + req_num = 0 + while True: + time.sleep(0.5) + if not self.isGetingToken: + self.request(url, method, params, json, timeout) + req_num = req_num + 1 + if req_num > 4: + break + else: + r = getattr(requests, method)('{}{}'.format(settings.XX_BASE_URL, url) + , params=params, json=json, verify=False) + if settings.DEBUG: + print_roundtrip(r) + ret = r.json() + if ret.get('errorCode') == '1060000': + self.get_token() # 重新获取token + self.request(url, method, params, json, timeout) # 重新请求 + else: + msg = '{}|{}'.format(str(ret['errorCode']), '|'.join(ret['errorMsg'])) + res = dict(success=True, code=200000, msg= msg, data=ret['data']) + if ret['errorCode'] != 0: + res['success'] = False + res['code'] = 400000 + return res + return dict(success=False, code=400900, msg='寻息接口访问异常', data=None) + +if settings.XX_ENABLED: + xxClient = XxClient() \ No newline at end of file diff --git a/apps/wf/__init__.py b/apps/wf/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/wf/admin.py b/apps/wf/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/apps/wf/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/apps/wf/apps.py b/apps/wf/apps.py new file mode 100644 index 00000000..f0709ed4 --- /dev/null +++ b/apps/wf/apps.py @@ -0,0 +1,7 @@ +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 100644 index 00000000..02c90661 --- /dev/null +++ b/apps/wf/filters.py @@ -0,0 +1,26 @@ +from django_filters import rest_framework as filters +from .models import Ticket +class TicketFilterSet(filters.FilterSet): + start_create = filters.DateFilter(field_name="create_time", lookup_expr='gte') + end_create = filters.DateFilter(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'] + + 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 \ No newline at end of file diff --git a/apps/wf/models.py b/apps/wf/models.py new file mode 100644 index 00000000..d56f9cf3 --- /dev/null +++ b/apps/wf/models.py @@ -0,0 +1,237 @@ +from random import choice +from django.db import models +from django.db.models.base import Model +import django.utils.timezone as timezone +from django.db.models.query import QuerySet +from apps.utils.models import CommonAModel +from apps.system.models import Dept, User, Dict, File +from apps.utils.models import SoftModel, BaseModel +from simple_history.models import HistoricalRecords + + +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 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 + state_participanttype_choices = ( + (0, '无处理人'), + (PARTICIPANT_TYPE_PERSONAL, '个人'), + (PARTICIPANT_TYPE_MULTI, '多人'), + # (PARTICIPANT_TYPE_DEPT, '部门'), + (PARTICIPANT_TYPE_ROLE, '角色'), + # (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) + 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='开启后允许工单创建人在此状态直接撤回工单到初始状态') + 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') + state_fields = models.JSONField('表单字段', 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:工作流名称') # json格式存储,包括读写属性1:只读,2:必填,3:可选,4:不显示, 字典的字典 + 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) + participant_cc = models.JSONField('抄送给', default=list, blank=True, help_text='抄送给(userid列表)') + +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('条件表达式', max_length=1000, default=list, 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='默认在用户点击操作的时候需要校验工单表单的必填项,如果设置为否则不检查。用于如"退回"属性的操作,不需要填写表单内容') + + +class CustomField(CommonAModel): + """自定义字段, 设定某个工作流有哪些自定义字段""" + field_type_choices = ( + ('string', '字符串'), + ('int', '整型'), + ('float', '浮点'), + ('boolean', '布尔'), + ('date', '日期'), + ('datetime', '日期时间'), + ('radio', '单选'), + ('checkbox', '多选'), + ('select', '单选下拉'), + ('selects', '多选下拉'), + ('cascader', '单选级联'), + ('cascaders', '多选级联'), + ('select_dg', '弹框单选'), + ('select_dgs', '弹框多选'), + ('textarea', '文本域'), + ('file', '附件') + ) + 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='可用于携带不需要用户查看的字段信息') + +class Ticket(CommonAModel): + """ + 工单 + """ + 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.CASCADE, 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/scripts.py b/apps/wf/scripts.py new file mode 100644 index 00000000..eda2694b --- /dev/null +++ b/apps/wf/scripts.py @@ -0,0 +1,56 @@ +from apps.system.models import User +from apps.wf.models import State, Ticket, TicketFlow, Transition + + +class GetParticipants: + """ + 获取处理人脚本 + """ + all_funcs = [ + {'func':'get_create_by', 'name':'获取工单创建人'} + ] + + # def all_funcs(self): + # # return list(filter(lambda x: x.startswith('get_') and callable(getattr(self, x)), dir(self))) + # return [(func, getattr(self, func).__doc__) for func in dir(self) if callable(getattr(self, func)) and func.startswith('get_')] + + @classmethod + def get_create_by(cls, state:dict={}, ticket:dict={}, new_ticket_data:dict={}, handler:User={}): + """工单创建人""" + participant = ticket.create_by.id + return participant + +class HandleScripts: + """ + 任务处理脚本 + """ + all_funcs = [ + {'func': 'handle_something', 'name':'处理一些工作'} + ] + + + @classmethod + def to_next(cls, ticket:Ticket, by_timer:bool=False, by_task:bool=False, by_hook:bool=False, script_str:str=''): + # 获取信息 + transition_obj = Transition.objects.filter(source_state=ticket.state, is_deleted=False).first() + + TicketFlow.objects.create(ticket=ticket, state=ticket.state, + participant_type=State.PARTICIPANT_TYPE_ROBOT, + participant_str='func:{}'.format(script_str), + transition=transition_obj) + from .services import WfService + + # 自动执行流转 + WfService.handle_ticket(ticket=ticket, transition=transition_obj, new_ticket_data=ticket.ticket_data, by_task=True) + + return ticket + + @classmethod + def handle_something(cls, ticket:Ticket): + """处理一些工作""" + # 任务处理代码区 + + + # 调用自动流转 + ticket = cls.to_next(ticket=ticket, by_task=True, script_str= 'handle_something') + diff --git a/apps/wf/serializers.py b/apps/wf/serializers.py new file mode 100644 index 00000000..e425284b --- /dev/null +++ b/apps/wf/serializers.py @@ -0,0 +1,193 @@ +from apps.system.models import Dept, User +from apps.system.serializers import UserSimpleSerializer +import rest_framework +from rest_framework import serializers + +from .models import State, Ticket, TicketFlow, Workflow, Transition, CustomField + + +class WorkflowSerializer(serializers.ModelSerializer): + class Meta: + model = Workflow + fields = '__all__' + +class StateSerializer(serializers.ModelSerializer): + class Meta: + model = State + fields = '__all__' + +class WorkflowSimpleSerializer(serializers.ModelSerializer): + class Meta: + model = Workflow + fields = ['id', 'name'] + +class StateSimpleSerializer(serializers.ModelSerializer): + class Meta: + model = State + fields = ['id', 'name', 'type', 'distribute_type', 'enable_retreat'] + +class TransitionSerializer(serializers.ModelSerializer): + 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 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(serializers.ModelSerializer): + class Meta: + model = CustomField + fields = '__all__' + +class CustomFieldCreateUpdateSerializer(serializers.ModelSerializer): + + 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(serializers.ModelSerializer): + class Meta: + model = Ticket + fields = '__all__' + +class TicketCreateSerializer(serializers.ModelSerializer): + 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(serializers.ModelSerializer): + 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(serializers.ModelSerializer): + workflow_ = WorkflowSimpleSerializer(source='workflow', read_only=True) + state_ = StateSimpleSerializer(source='state', read_only=True) + + class Meta: + model = Ticket + fields = ['id', 'title', 'sn', 'workflow', 'workflow_', 'state', 'state_', 'act_state', 'create_time', 'update_time', 'participant_type', 'create_by'] + + @staticmethod + def setup_eager_loading(queryset): + queryset = queryset.select_related('workflow','state') + return queryset + +class TicketDetailSerializer(serializers.ModelSerializer): + workflow_ = WorkflowSimpleSerializer(source='workflow', read_only=True) + state_ = StateSimpleSerializer(source='state', read_only=True) + ticket_data_ = serializers.SerializerMethodField() + class Meta: + model = Ticket + fields = '__all__' + + @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: + i['field_display'] = User.objects.get(id=i['field_value']).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: + i['field_display'] = Dept.objects.get(id=i['field_value']).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(serializers.ModelSerializer): + participant_ = UserSimpleSerializer(source='participant', read_only=True) + state_ = StateSimpleSerializer(source='state', read_only=True) + class Meta: + model = TicketFlow + fields = '__all__' + +class TicketFlowSimpleSerializer(serializers.ModelSerializer): + participant_ = UserSimpleSerializer(source='participant', read_only=True) + state_ = StateSimpleSerializer(source='state', 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 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列表') \ No newline at end of file diff --git a/apps/wf/services.py b/apps/wf/services.py new file mode 100644 index 00000000..8dad0a6b --- /dev/null +++ b/apps/wf/services.py @@ -0,0 +1,353 @@ +from apps.wf.serializers import CustomFieldSerializer +from apps.wf.serializers import TicketSerializer, TicketSimpleSerializer +from typing import Tuple +from apps.system.models import 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 +import random +from .scripts import GetParticipants, HandleScripts +from apps.utils.queryset import get_parent_queryset + +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: + 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: + 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_ticket_transitions(cls, ticket:Ticket): + """ + 获取工单可执行的操作 + """ + return cls.get_state_transitions(ticket.state) + + @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 = timezone.now() + today = str(now)[:10]+' 00:00:00' + next_day = str(now+timedelta(days=1))[:10]+' 00:00:00' + ticket_day_count_new = Ticket.objects.filter(create_time__gte=today, create_time__lte=next_day, workflow=workflow).count()+1 + 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: + """ + 获取下个节点状态 + """ + source_state = ticket.state + 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, 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:#代码获取 + destination_participant = getattr(GetParticipants, destination_participant)( + state=state, ticket=ticket, new_ticket_data=new_ticket_data, hander=handler) + + elif destination_participant_type == State.PARTICIPANT_TYPE_DEPT:#部门 + destination_participant = list(User.objects.filter(dept__in=destination_participant).values_list('id', flat=True)) + + elif destination_participant_type == State.PARTICIPANT_TYPE_ROLE:#角色 + user_queryset = User.objects.filter(roles__in=destination_participant) + # 如果选择了角色, 需要走过滤策略 + if state.filter_policy == 1: + depts = get_parent_queryset(ticket.belong_dept) + user_queryset = user_queryset.filter(dept__in=depts) + elif state.filter_policy == 2: + depts = get_parent_queryset(ticket.create_by.dept) + user_queryset = user_queryset.filter(dept__in=depts) + elif state.filter_policy == 3: + depts = get_parent_queryset(handler.dept) + user_queryset = user_queryset.filter(dept__in=depts) + destination_participant = list(user_queryset.values_list('id', flat=True)) + 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] + 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: + transitions = cls.get_state_transitions(ticket.state) + if not transitions: + return dict(permission=True, msg="工单当前状态无需操作") + current_participant_count = 0 + participant_type = ticket.participant_type + participant = ticket.participant + state = ticket.stateF + if participant_type == State.PARTICIPANT_TYPE_PERSONAL: + if user.id != participant: + return dict(permission=False, msg="非当前处理人", need_accept=False) + elif participant_type in [State.PARTICIPANT_TYPE_MULTI, State.PARTICIPANT_TYPE_DEPT, State.PARTICIPANT_TYPE_ROLE]: + if user.id not in participant: + return dict(permission=False, msg="非当前处理人", need_accept=False) + current_participant_count = len(participant) + if current_participant_count == 1: + if [user.id] == participant or user.id == participant: + pass + else: + return dict(permission=False, msg="非当前处理人", need_accept=False) + elif current_participant_count >1 and state.distribute_type == State.STATE_DISTRIBUTE_TYPE_ACTIVE: + if user.id not in participant: + return dict(permission=False, msg="非当前处理人", need_accept=False) + return dict(permission=False, msg="需要先接单再处理", need_accept=True) + if ticket.in_add_node: + 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 not handler or not created: # 没有处理人意味着系统触发不校验处理权限 + 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: + 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.participant_type == State.PARTICIPANT_TYPE_ROBOT: + getattr(HandleScripts, destination_state.participant)(ticket) + + return ticket + diff --git a/apps/wf/tests.py b/apps/wf/tests.py new file mode 100644 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 100644 index 00000000..a631443d --- /dev/null +++ b/apps/wf/urls.py @@ -0,0 +1,21 @@ +from django.db.models import base +from rest_framework import urlpatterns +from apps.wf.views import CustomFieldViewSet, FromCodeListView, StateViewSet, TicketFlowViewSet, TicketViewSet, TransitionViewSet, 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='wf') +router.register('state', StateViewSet, basename='wf_state') +router.register('transition', TransitionViewSet, basename='wf_transitions') +router.register('customfield', CustomFieldViewSet, basename='wf_customfield') +router.register('ticket', TicketViewSet, basename='wf_ticket') +router.register('ticketflow', TicketFlowViewSet, basename='wf_ticketflow') +urlpatterns = [ + path(API_BASE_URL + 'participant_from_code', FromCodeListView.as_view()), + path(API_BASE_URL, include(router.urls)), +] + diff --git a/apps/wf/views.py b/apps/wf/views.py new file mode 100644 index 00000000..ca97334e --- /dev/null +++ b/apps/wf/views.py @@ -0,0 +1,373 @@ +from django.utils import timezone +from django.db import transaction +from django.db.models import query +from rest_framework.utils import serializer_helpers +from rest_framework.views import APIView +from apps.system.models import User +from apps.wf.filters import TicketFilterSet +from django.core.exceptions import AppRegistryNotReady +from rest_framework.response import Response +from rest_framework import serializers +from rest_framework.mixins import CreateModelMixin, DestroyModelMixin, ListModelMixin, RetrieveModelMixin, UpdateModelMixin +from apps.wf.serializers import CustomFieldCreateUpdateSerializer, CustomFieldSerializer, StateSerializer, TicketAddNodeEndSerializer, TicketAddNodeSerializer, TicketCloseSerializer, TicketCreateSerializer, TicketDestorySerializer, TicketFlowSerializer, TicketFlowSimpleSerializer, TicketHandleSerializer, TicketRetreatSerializer, TicketSerializer, TransitionSerializer, WorkflowSerializer, TicketListSerializer, TicketDetailSerializer +from django.shortcuts import get_object_or_404, render +from rest_framework.viewsets import GenericViewSet, ModelViewSet +from rest_framework.decorators import action, api_view +from apps.wf.models import CustomField, Ticket, Workflow, State, Transition, TicketFlow +from apps.utils.mixins import CreateUpdateCustomMixin, CreateUpdateModelAMixin, OptimizationMixin +from apps.wf.services import WfService +from rest_framework.exceptions import APIException, PermissionDenied +from rest_framework import status +from django.db.models import Count +from .scripts import GetParticipants, HandleScripts + + +# Create your views here. +class FromCodeListView(APIView): + def get(self, request, format=None): + """ + 获取处理人代码列表 + """ + return Response(GetParticipants.all_funcs) + +class WorkflowViewSet(CreateUpdateModelAMixin, ModelViewSet): + perms_map = {'get': '*', 'post': 'workflow_create', + 'put': 'workflow_update', 'delete': 'workflow_delete'} + queryset = Workflow.objects.all() + serializer_class = WorkflowSerializer + search_fields = ['name', 'description'] + filterset_fields = [] + ordering_fields = ['create_time'] + ordering = ['-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':'workflow_init'}) + 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'] = pk + 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 StateViewSet(CreateModelMixin, UpdateModelMixin, RetrieveModelMixin, DestroyModelMixin, GenericViewSet): + 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, GenericViewSet): + perms_map = {'get':'*', 'post':'workflow_update', + 'put':'workflow_update', 'delete':'workflow_update'} + queryset = Transition.objects.all() + serializer_class = TransitionSerializer + search_fields = ['name'] + filterset_fields = ['workflow'] + ordering = ['id'] + +class CustomFieldViewSet(CreateModelMixin, UpdateModelMixin, RetrieveModelMixin, DestroyModelMixin, GenericViewSet): + 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(OptimizationMixin, CreateUpdateCustomMixin, CreateModelMixin, ListModelMixin, RetrieveModelMixin, GenericViewSet): + perms_map = {'get':'*', 'post':'ticket_create'} + queryset = Ticket.objects.all() + serializer_class = TicketSerializer + search_fields = ['title'] + 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 + return super().get_serializer_class() + + def filter_queryset(self, queryset): + if not self.detail and not self.request.query_params.get('category', None): + raise APIException('请指定查询分类') + return super().filter_queryset(queryset) + + @transaction.atomic + 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 APIException('字段{}必填'.format(key)) + save_ticket_data[key] = ticket_data[key] + elif int(value) == State.STATE_FIELD_OPTIONAL: + save_ticket_data[key] = ticket_data[key] + + 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.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':'*'}) + @transaction.atomic + 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']) + + ticket = WfService.handle_ticket(ticket=ticket, transition=vdata['transition'], + new_ticket_data=new_ticket_data, handler=request.user, suggestion=vdata['suggestion']) + return Response(TicketSerializer(instance=ticket).data) + + + @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) + return Response(StateSerializer(instance=steps, many=True).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 = TicketFlowSerializer(instance=flowlogs, 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, 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 APIException('无需接单') + + @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 APIException('非创建人不可撤回') + if not ticket.state.enable_retreat: + raise APIException('该状态不可撤回') + 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): + """ + 加签 + """ + ticket = self.get_object() + data = request.data + 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() + 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) + 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) + return Response() + else: + return Response('工单不可关闭', status=status.HTTP_400_BAD_REQUEST) + + @action(methods=['post'], detail=False, perms_map={'post':'ticket_deletes'}, serializer_class=TicketDestorySerializer) + def destory(self, request, pk=None): + """ + 批量物理删除 + """ + Ticket.objects.filter(id__in=request.data.get('ids', [])).delete(soft=False) + return Response() + + + +class TicketFlowViewSet(ListModelMixin, RetrieveModelMixin, GenericViewSet): + """ + 工单日志 + """ + perms_map = {'get':'*'} + queryset = TicketFlow.objects.all() + serializer_class = TicketFlowSerializer + search_fields = ['suggestion'] + filterset_fields = ['ticket'] + ordering = ['-create_time'] \ No newline at end of file diff --git a/log/.gitignore b/log/.gitignore new file mode 100644 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 100644 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 100644 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 100644 index 00000000..e5571a6d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,20 @@ +celery==5.2.3 +Django==3.2.12 +django-celery-beat==2.2.1 +django-celery-results==2.3.0 +django-cors-headers==3.11.0 +django-filter==21.1 +django-simple-history==3.0.0 +djangorestframework==3.13.1 +djangorestframework-simplejwt==5.1.0 +drf-yasg==1.20.0 +psutil==5.9.0 +pillow==9.0.1 +opencv-python==4.5.5.62 +gunicorn==20.1.0 +redis==4.1.4 +user-agents==2.2.0 +daphne==3.0.2 +channels==3.0.4 +channels-redis==3.4.0 +django-restql==0.15.2 diff --git a/server/__init__.py b/server/__init__.py new file mode 100644 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 100644 index 00000000..8fd3ba56 --- /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 channels.auth import AuthMiddlewareStack +import apps.monitor.routing + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'server.settings') + +application = ProtocolTypeRouter({ + "http": get_asgi_application(), + "websocket": AuthMiddlewareStack( + URLRouter( + apps.monitor.routing.websocket_urlpatterns + ) + ) +}) \ No newline at end of file diff --git a/server/celery.py b/server/celery.py new file mode 100644 index 00000000..dbe6eb8b --- /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('server') + +# 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}') \ No newline at end of file diff --git a/server/conf.example.py b/server/conf.example.py new file mode 100644 index 00000000..acade302 --- /dev/null +++ b/server/conf.example.py @@ -0,0 +1,12 @@ +SECRET_KEY = '48j$m*^$m0hf0c7&=+!i9&3$8k8^6^a18t9(c*e8^sp(1&3^z0' +DEBUG = False +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': 'ehs', + 'USER': 'postgres', + 'PASSWORD': 'zcDsj2021', + 'HOST': '49.232.14.174', + 'PORT': '5432', + } +} \ No newline at end of file diff --git a/server/settings.py b/server/settings.py new file mode 100644 index 00000000..99ebc83b --- /dev/null +++ b/server/settings.py @@ -0,0 +1,318 @@ +""" +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 +from . import conf +from server.conf import DATABASES, XX_ENABLED, XX_LICENCE +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/ + +# 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 = ['*'] + + +# Application definition + +INSTALLED_APPS = [ + 'channels', + '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", + 'simple_history', + 'apps.utils', + 'apps.third', + 'apps.system', + 'apps.auth1', + 'apps.monitor', + 'apps.wf' +] + +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', + 'simple_history.middleware.HistoryRequestMiddleware', +] + +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)], + }, + }, +} + +# 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_collect') +STATICFILES_DIRS = ( + os.path.join(BASE_DIR, 'dist/static'), +) + +MEDIA_URL = '/media/' +MEDIA_ROOT = os.path.join(BASE_DIR, 'media') + +# 默认主键 +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +# 雪花ID生成配置 +SNOW_DATACENTER_ID = conf.SNOW_DATACENTER_ID +SNOW_WORKER_ID = conf.SNOW_WORKER_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_PAGINATION_CLASS': 'apps.utils.pagination.MyPagination', + 'DATETIME_FORMAT': '%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.response.custome_exception_hander', +} +# simplejwt配置 +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(days=1), +} + +# 跨域配置/可用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://redis:6379/1", +# "OPTIONS": { +# "CLIENT_CLASS": "django_redis.client.DefaultClient", +# "PICKLE_VERSION": -1 +# } +# } +# } + +# celery配置,celery正常运行必须安装redis +CELERY_BROKER_URL = "redis://redis:6379/0" # 任务存储 +CELERYD_MAX_TASKS_PER_CHILD = 100 # 每个worker最多执行100个任务就会被销毁,可防止内存泄露 +CELERY_TIMEZONE = 'Asia/Shanghai' # 设置时区 +CELERY_ENABLE_UTC = True # 启动时区设置 +CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler' + +# swagger配置 +SWAGGER_SETTINGS = { + 'LOGIN_URL':'/django/login/', + 'LOGOUT_URL':'/django/logout/', +} + +# 日志配置 +# 创建日志的路径 +LOG_PATH = os.path.join(BASE_DIR, 'log') +# 如果地址不存在,则自动创建log文件夹 +if not os.path.exists(os.path.join(LOG_PATH)): + os.mkdir(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 * 5, # 文件大小 + 'backupCount': 5, # 备份数 + '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 * 5, # 文件大小 + 'backupCount': 5, # 备份数 + '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 * 5, + 'backupCount': 5, + 'formatter': 'standard', + 'encoding': 'utf-8', # 设置默认编码 + }, + }, + # 配置用哪几种 handlers 来处理日志 + 'loggers': { + # 类型 为 django 处理所有类型的日志, 默认调用 + 'django': { + 'handlers': ['default', 'console', 'error'], + 'level': 'INFO', + 'propagate': False + }, + # log 调用时需要当作参数传入 + 'log': { + 'handlers': ['error', 'info', 'console', 'default'], + 'level': 'INFO', + 'propagate': True + }, + } +} + +# 大华ICC平台 +DAHUA_ENABLED = conf.DAHUA_ENABLED +DAHUA_BASE_URL = conf.DAHUA_BASE_URL +DAHUA_USERNAME = conf.DAHUA_USERNAME +DAHUA_PASSWORD = conf.DAHUA_PASSWORD +DAHUA_CLIENTID = conf.DAHUA_CLIENTID +DAHUA_SECRET = conf.DAHUA_SECRET + +# 寻息定位 +XX_ENABLED = conf.XX_ENABLED +XX_BASE_URL = conf.XX_BASE_URL +XX_LICENCE = conf.XX_LICENCE +XX_USERNAME = conf.XX_USERNAME \ No newline at end of file diff --git a/server/urls.py b/server/urls.py new file mode 100644 index 00000000..616e378f --- /dev/null +++ b/server/urls.py @@ -0,0 +1,60 @@ +"""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="EHS API", + default_version='v1', + contact=openapi.Contact(email="caoqianming@foxmail.com"), + license=openapi.License(name="MIT License"), + ), + public=True, + permission_classes=[], +) + +urlpatterns = [ + # django后台 + path('django/doc/', include('django.contrib.admindocs.urls')), + path('django/', admin.site.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.third.urls')), + path('', include('apps.utils.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 100644 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()