You've already forked opc-backend
开发了多角色登录与鉴权接口:实现了普通用户、企业和管理员的登录分流,并支持Token验证。
开发了权限控制接口:实现了通过数据库分配菜单权限节点,控制接口访问安全。 开发了实名认证中心:实现了个人身份证信息与企业营业执照的提交与审核接口。 开发了任务与协作大厅核心业务:实现了任务的发布、接单、状态流转以及专家邀约接口。 配置了全局环境变量与数据库引擎:集成了 PostgreSQL 数据库、Redis 缓存与 MinIO 对象存储。
This commit is contained in:
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.env
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
db.sqlite3
|
||||
media/
|
||||
61
admin_init.py
Normal file
61
admin_init.py
Normal file
@@ -0,0 +1,61 @@
|
||||
import os
|
||||
import django
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||
django.setup()
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from users.models import Role, UserRole
|
||||
from opc_cert.models import OpcCertification, CertStatus
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
def init_admin():
|
||||
print("--- 正在初始化管理员账号 ---")
|
||||
admin_user = User.objects.filter(username='admin').first()
|
||||
if not admin_user:
|
||||
admin_user = User.objects.create_superuser(
|
||||
username='admin',
|
||||
phone='18888888888',
|
||||
email='admin@opc.local',
|
||||
password='admin123',
|
||||
nickname='系统管理员'
|
||||
)
|
||||
print("管理员账号创建成功 (admin/admin123)")
|
||||
else:
|
||||
admin_user.set_password('admin123')
|
||||
admin_user.is_staff = True
|
||||
admin_user.is_superuser = True
|
||||
admin_user.save()
|
||||
print("管理员账号已存在,密码重置为 admin123")
|
||||
|
||||
admin_role, _ = Role.objects.get_or_create(code='ADMIN', defaults={'name': '管理员', 'is_system': True})
|
||||
UserRole.objects.get_or_create(user=admin_user, role=admin_role)
|
||||
print("管理员角色已分配")
|
||||
|
||||
def clean_opc_roles():
|
||||
print("--- 正在清洗错误的 OPC_USER 角色 ---")
|
||||
opc_role = Role.objects.filter(code='OPC_USER').first()
|
||||
if not opc_role:
|
||||
print("未找到 OPC_USER 角色")
|
||||
return
|
||||
|
||||
user_roles = UserRole.objects.filter(role=opc_role)
|
||||
count = 0
|
||||
for ur in user_roles:
|
||||
user = ur.user
|
||||
# 检查该用户是否有 APPROVED 的认证记录
|
||||
has_approved_cert = OpcCertification.objects.filter(user=user, status=CertStatus.APPROVED).exists()
|
||||
if not has_approved_cert:
|
||||
ur.delete()
|
||||
# 给用户分配普通 USER 角色
|
||||
user_role, _ = Role.objects.get_or_create(code='USER', defaults={'name': '普通用户'})
|
||||
UserRole.objects.get_or_create(user=user, role=user_role)
|
||||
print(f"移除了用户 {user.username} 的 OPC_USER 角色,并分配了 USER 角色")
|
||||
count += 1
|
||||
|
||||
print(f"清洗完成,共处理了 {count} 个异常用户的角色。")
|
||||
|
||||
if __name__ == '__main__':
|
||||
init_admin()
|
||||
clean_opc_roles()
|
||||
5
commit_message.txt
Normal file
5
commit_message.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
开发了多角色登录与鉴权接口:实现了普通用户、企业和管理员的登录分流,并支持Token验证。
|
||||
开发了权限控制接口:实现了通过数据库分配菜单权限节点,控制接口访问安全。
|
||||
开发了实名认证中心:实现了个人身份证信息与企业营业执照的提交与审核接口。
|
||||
开发了任务与协作大厅核心业务:实现了任务的发布、接单、状态流转以及专家邀约接口。
|
||||
配置了全局环境变量与数据库引擎:集成了 PostgreSQL 数据库、Redis 缓存与 MinIO 对象存储。
|
||||
0
core/__init__.py
Normal file
0
core/__init__.py
Normal file
16
core/asgi.py
Normal file
16
core/asgi.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
ASGI config for core 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/4.2/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||
|
||||
application = get_asgi_application()
|
||||
108
core/settings.py
Normal file
108
core/settings.py
Normal file
@@ -0,0 +1,108 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
# Load .env file
|
||||
env_file = BASE_DIR / '.env'
|
||||
if env_file.exists():
|
||||
with open(env_file, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#'):
|
||||
key, val = line.split('=', 1)
|
||||
os.environ[key.strip()] = val.strip().strip("'").strip('"')
|
||||
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY', 'django-insecure-opc-community-platform')
|
||||
DEBUG = os.environ.get('DEBUG', 'True') == 'True'
|
||||
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '*').split(',')
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'rest_framework',
|
||||
'corsheaders',
|
||||
'users',
|
||||
'tasks',
|
||||
'reservations',
|
||||
'opc_cert',
|
||||
'system',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'core.urls'
|
||||
CORS_ALLOW_ALL_ORIGINS = os.environ.get('CORS_ALLOW_ALL_ORIGINS', 'True') == 'True'
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [],
|
||||
'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_APPLICATION = 'core.wsgi.application'
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': os.environ.get('DB_ENGINE', 'django.db.backends.postgresql'),
|
||||
'NAME': os.environ.get('DB_NAME', 'opc_db'),
|
||||
'USER': os.environ.get('DB_USER', 'opc_user'),
|
||||
'PASSWORD': os.environ.get('DB_PASSWORD', 'opc_password'),
|
||||
'HOST': os.environ.get('DB_HOST', 'localhost'),
|
||||
'PORT': os.environ.get('DB_PORT', '5432'),
|
||||
}
|
||||
}
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = []
|
||||
|
||||
LANGUAGE_CODE = 'zh-hans'
|
||||
TIME_ZONE = 'Asia/Shanghai'
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
STATIC_URL = 'static/'
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
|
||||
AUTH_USER_MODEL = 'users.User'
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||
),
|
||||
'DEFAULT_PERMISSION_CLASSES': (
|
||||
'rest_framework.permissions.IsAuthenticated',
|
||||
),
|
||||
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
|
||||
'PAGE_SIZE': 20,
|
||||
}
|
||||
|
||||
# MinIO Configuration
|
||||
MINIO_ENDPOINT = os.environ.get('MINIO_ENDPOINT', 'localhost:9000')
|
||||
MINIO_ACCESS_KEY = os.environ.get('MINIO_ACCESS_KEY', 'minioadmin')
|
||||
MINIO_SECRET_KEY = os.environ.get('MINIO_SECRET_KEY', 'minioadmin')
|
||||
MINIO_BUCKET_NAME = os.environ.get('MINIO_BUCKET_NAME', 'opc-assets')
|
||||
MINIO_SECURE = os.environ.get('MINIO_SECURE', 'False') == 'True'
|
||||
MINIO_PUBLIC_URL = os.environ.get('MINIO_PUBLIC_URL', 'http://127.0.0.1:9000')
|
||||
18
core/urls.py
Normal file
18
core/urls.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from django.contrib import admin
|
||||
from django.urls import path, include
|
||||
from django.http import JsonResponse
|
||||
|
||||
def health_check(request):
|
||||
return JsonResponse({"status": "ok", "message": "OPC Community Platform API is running"})
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path('api/health/', health_check),
|
||||
|
||||
# API v1 routes
|
||||
path('api/v1/', include('users.urls')),
|
||||
path('api/v1/', include('tasks.urls')),
|
||||
path('api/v1/', include('reservations.urls')),
|
||||
path('api/v1/', include('opc_cert.urls')),
|
||||
path('api/v1/', include('system.urls')),
|
||||
]
|
||||
16
core/wsgi.py
Normal file
16
core/wsgi.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
WSGI config for core 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/4.2/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||
|
||||
application = get_wsgi_application()
|
||||
22
manage.py
Executable file
22
manage.py
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.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()
|
||||
0
opc_cert/__init__.py
Normal file
0
opc_cert/__init__.py
Normal file
3
opc_cert/admin.py
Normal file
3
opc_cert/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
6
opc_cert/apps.py
Normal file
6
opc_cert/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class OpcCertConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'opc_cert'
|
||||
40
opc_cert/migrations/0001_initial.py
Normal file
40
opc_cert/migrations/0001_initial.py
Normal file
@@ -0,0 +1,40 @@
|
||||
# Generated by Django 4.2.30 on 2026-04-25 09:46
|
||||
|
||||
from django.db import migrations, models
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='OpcCertification',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('real_name', models.CharField(max_length=64)),
|
||||
('id_card', models.TextField(blank=True, null=True)),
|
||||
('skills', models.JSONField(default=list)),
|
||||
('experience', models.TextField(blank=True, null=True)),
|
||||
('resume_url', models.CharField(blank=True, max_length=512, null=True)),
|
||||
('attachments', models.JSONField(default=list)),
|
||||
('status', models.CharField(choices=[('PENDING', '待审核'), ('APPROVED', '已通过'), ('REJECTED', '已驳回')], default='PENDING', max_length=16)),
|
||||
('reject_reason', models.TextField(blank=True, null=True)),
|
||||
('reviewed_at', models.DateTimeField(blank=True, null=True)),
|
||||
('rating', models.SmallIntegerField(blank=True, null=True)),
|
||||
('rating_tags', models.JSONField(default=list)),
|
||||
('rating_note', models.TextField(blank=True, null=True)),
|
||||
('rated_at', models.DateTimeField(blank=True, null=True)),
|
||||
('version', models.SmallIntegerField(default=1)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'opc_certifications',
|
||||
},
|
||||
),
|
||||
]
|
||||
33
opc_cert/migrations/0002_initial.py
Normal file
33
opc_cert/migrations/0002_initial.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 4.2.30 on 2026-04-25 09:46
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('opc_cert', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='opccertification',
|
||||
name='rated_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='rated_certifications', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='opccertification',
|
||||
name='reviewer',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_certifications', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='opccertification',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='opc_certifications', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
0
opc_cert/migrations/__init__.py
Normal file
0
opc_cert/migrations/__init__.py
Normal file
32
opc_cert/models.py
Normal file
32
opc_cert/models.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import uuid
|
||||
from django.db import models
|
||||
|
||||
class CertStatus(models.TextChoices):
|
||||
PENDING = 'PENDING', '待审核'
|
||||
APPROVED = 'APPROVED', '已通过'
|
||||
REJECTED = 'REJECTED', '已驳回'
|
||||
|
||||
class OpcCertification(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.ForeignKey('users.User', on_delete=models.CASCADE, related_name='opc_certifications')
|
||||
real_name = models.CharField(max_length=64)
|
||||
id_card = models.TextField(null=True, blank=True)
|
||||
skills = models.JSONField(default=list)
|
||||
experience = models.TextField(null=True, blank=True)
|
||||
resume_url = models.CharField(max_length=512, null=True, blank=True)
|
||||
attachments = models.JSONField(default=list)
|
||||
status = models.CharField(max_length=16, choices=CertStatus.choices, default=CertStatus.PENDING)
|
||||
reject_reason = models.TextField(null=True, blank=True)
|
||||
reviewer = models.ForeignKey('users.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='reviewed_certifications')
|
||||
reviewed_at = models.DateTimeField(null=True, blank=True)
|
||||
rating = models.SmallIntegerField(null=True, blank=True)
|
||||
rating_tags = models.JSONField(default=list)
|
||||
rating_note = models.TextField(null=True, blank=True)
|
||||
rated_by = models.ForeignKey('users.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='rated_certifications')
|
||||
rated_at = models.DateTimeField(null=True, blank=True)
|
||||
version = models.SmallIntegerField(default=1)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'opc_certifications'
|
||||
21
opc_cert/serializers.py
Normal file
21
opc_cert/serializers.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from rest_framework import serializers
|
||||
from .models import OpcCertification
|
||||
|
||||
class OpcCertificationSerializer(serializers.ModelSerializer):
|
||||
user_detail = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = OpcCertification
|
||||
fields = '__all__'
|
||||
read_only_fields = ['id', 'user', 'status', 'reviewer', 'reviewed_at', 'rating', 'rating_tags', 'rating_note', 'rated_by', 'rated_at', 'created_at', 'updated_at']
|
||||
|
||||
def get_user_detail(self, obj):
|
||||
user = obj.user
|
||||
return {
|
||||
'id': user.id,
|
||||
'username': user.username,
|
||||
'nickname': user.nickname,
|
||||
'avatar_url': user.avatar_url,
|
||||
'email': user.email,
|
||||
'phone': user.phone
|
||||
}
|
||||
3
opc_cert/tests.py
Normal file
3
opc_cert/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
10
opc_cert/urls.py
Normal file
10
opc_cert/urls.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import OpcCertificationViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register('certifications', OpcCertificationViewSet)
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
91
opc_cert/views.py
Normal file
91
opc_cert/views.py
Normal file
@@ -0,0 +1,91 @@
|
||||
from rest_framework import viewsets, permissions, status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.decorators import action
|
||||
from .models import OpcCertification, CertStatus
|
||||
from .serializers import OpcCertificationSerializer
|
||||
from django.utils import timezone
|
||||
|
||||
class OpcCertificationViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
@author: xujl
|
||||
Api说明: OPC(One Person Company)专家认证申请接口视图。提供普通用户提交资质申请、管理员审核(approve)、驳回(reject)等核心流程。认证通过后自动授予OPC_USER角色。
|
||||
"""
|
||||
queryset = OpcCertification.objects.all()
|
||||
serializer_class = OpcCertificationSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
required_permissions = {
|
||||
'GET': 'api:certs:read',
|
||||
'POST': 'api:certs:write',
|
||||
'PUT': 'api:certs:write',
|
||||
'PATCH': 'api:certs:write',
|
||||
'DELETE': 'api:certs:delete'
|
||||
}
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action in ['create', 'destroy']:
|
||||
return [permissions.IsAuthenticated()]
|
||||
if self.action in ['list', 'retrieve']:
|
||||
# Either it's their own, or they have admin permission
|
||||
return [permissions.IsAuthenticated()]
|
||||
if self.action in ['approve', 'reject']:
|
||||
from users.permissions import HasAPIPermission
|
||||
return [HasAPIPermission()]
|
||||
return super().get_permissions()
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
from users.permissions import HasAPIPermission
|
||||
|
||||
# If user has the certs read permission or is superuser, show all
|
||||
has_admin_perm = False
|
||||
if user.is_superuser:
|
||||
has_admin_perm = True
|
||||
else:
|
||||
from users.models import RolePermission
|
||||
perms = set(RolePermission.objects.filter(role__userrole__user=user).values_list('permission__code', flat=True))
|
||||
if 'api:certs:read' in perms or '*' in perms:
|
||||
has_admin_perm = True
|
||||
|
||||
if has_admin_perm:
|
||||
return OpcCertification.objects.all().order_by('-created_at')
|
||||
return OpcCertification.objects.filter(user=user).order_by('-created_at')
|
||||
|
||||
def perform_create(self, serializer):
|
||||
# 提交新申请前,只删除该用户的 PENDING 或 REJECTED 记录,保留已有的 APPROVED 记录
|
||||
OpcCertification.objects.filter(user=self.request.user).exclude(status=CertStatus.APPROVED).delete()
|
||||
serializer.save(user=self.request.user, status=CertStatus.PENDING)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def approve(self, request, pk=None):
|
||||
cert = self.get_object()
|
||||
if cert.status != CertStatus.PENDING:
|
||||
return Response({'detail': '状态不允许该操作'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
cert.status = CertStatus.APPROVED
|
||||
cert.reviewer = request.user
|
||||
cert.reviewed_at = timezone.now()
|
||||
cert.save()
|
||||
|
||||
# 管理员通过后,删除该用户所有的旧认证记录(包括旧的 APPROVED 记录),确保数据库只有最新的一条
|
||||
OpcCertification.objects.filter(user=cert.user).exclude(id=cert.id).delete()
|
||||
|
||||
# 自动追加 OPC_USER 角色
|
||||
from users.models import Role, UserRole
|
||||
role_opc, _ = Role.objects.get_or_create(code='OPC_USER', defaults={'name': '认证专家', 'is_system': True})
|
||||
UserRole.objects.get_or_create(user=cert.user, role=role_opc, defaults={'granted_by': request.user})
|
||||
|
||||
return Response({'status': '认证已通过,用户角色已更新'})
|
||||
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def reject(self, request, pk=None):
|
||||
cert = self.get_object()
|
||||
reason = request.data.get('reject_reason', '不符合要求')
|
||||
|
||||
cert.status = CertStatus.REJECTED
|
||||
cert.reject_reason = reason
|
||||
cert.reviewer = request.user
|
||||
cert.reviewed_at = timezone.now()
|
||||
cert.save()
|
||||
return Response({'status': '认证已驳回'})
|
||||
0
reservations/__init__.py
Normal file
0
reservations/__init__.py
Normal file
3
reservations/admin.py
Normal file
3
reservations/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
6
reservations/apps.py
Normal file
6
reservations/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ReservationsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'reservations'
|
||||
66
reservations/migrations/0001_initial.py
Normal file
66
reservations/migrations/0001_initial.py
Normal file
@@ -0,0 +1,66 @@
|
||||
# Generated by Django 4.2.30 on 2026-04-25 09:46
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ReservationResource',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=128)),
|
||||
('type', models.CharField(choices=[('ACCESS_CONTROL', '门禁通行'), ('MEETING_ROOM', '会议室')], max_length=32)),
|
||||
('description', models.TextField(blank=True, null=True)),
|
||||
('capacity', models.IntegerField(blank=True, null=True)),
|
||||
('location', models.CharField(blank=True, max_length=256, null=True)),
|
||||
('cover_url', models.CharField(blank=True, max_length=512, null=True)),
|
||||
('price_per_unit', models.DecimalField(decimal_places=2, default=0.0, max_digits=8)),
|
||||
('price_unit', models.CharField(default='次', max_length=32)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'reservation_resources',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ReservationOrder',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('order_no', models.CharField(max_length=64, unique=True)),
|
||||
('start_time', models.DateTimeField()),
|
||||
('end_time', models.DateTimeField()),
|
||||
('quantity', models.IntegerField(default=1)),
|
||||
('unit_price', models.DecimalField(decimal_places=2, max_digits=8)),
|
||||
('total_amount', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||
('status', models.CharField(choices=[('PENDING_PAY', '待支付'), ('PAID', '已支付'), ('USED', '已使用'), ('REFUNDED', '已退款'), ('CANCELLED', '已取消')], default='PENDING_PAY', max_length=32)),
|
||||
('wx_prepay_id', models.CharField(blank=True, max_length=128, null=True)),
|
||||
('wx_transaction_id', models.CharField(blank=True, max_length=128, null=True)),
|
||||
('paid_at', models.DateTimeField(blank=True, null=True)),
|
||||
('refund_reason', models.TextField(blank=True, null=True)),
|
||||
('refund_amount', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
|
||||
('wx_refund_id', models.CharField(blank=True, max_length=128, null=True)),
|
||||
('refunded_at', models.DateTimeField(blank=True, null=True)),
|
||||
('qr_code_url', models.CharField(blank=True, max_length=512, null=True)),
|
||||
('verified_at', models.DateTimeField(blank=True, null=True)),
|
||||
('remark', models.TextField(blank=True, null=True)),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('resource', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='orders', to='reservations.reservationresource')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'reservation_orders',
|
||||
},
|
||||
),
|
||||
]
|
||||
28
reservations/migrations/0002_initial.py
Normal file
28
reservations/migrations/0002_initial.py
Normal file
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 4.2.30 on 2026-04-25 09:46
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('reservations', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='reservationorder',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reservation_orders', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='reservationorder',
|
||||
name='verified_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='verified_orders', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
0
reservations/migrations/__init__.py
Normal file
0
reservations/migrations/__init__.py
Normal file
59
reservations/models.py
Normal file
59
reservations/models.py
Normal file
@@ -0,0 +1,59 @@
|
||||
import uuid
|
||||
from django.db import models
|
||||
|
||||
class ResourceType(models.TextChoices):
|
||||
ACCESS_CONTROL = 'ACCESS_CONTROL', '门禁通行'
|
||||
MEETING_ROOM = 'MEETING_ROOM', '会议室'
|
||||
|
||||
class OrderStatus(models.TextChoices):
|
||||
PENDING_PAY = 'PENDING_PAY', '待支付'
|
||||
PAID = 'PAID', '已支付'
|
||||
USED = 'USED', '已使用'
|
||||
REFUNDED = 'REFUNDED', '已退款'
|
||||
CANCELLED = 'CANCELLED', '已取消'
|
||||
|
||||
class ReservationResource(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
name = models.CharField(max_length=128)
|
||||
type = models.CharField(max_length=32, choices=ResourceType.choices)
|
||||
description = models.TextField(null=True, blank=True)
|
||||
capacity = models.IntegerField(null=True, blank=True)
|
||||
location = models.CharField(max_length=256, null=True, blank=True)
|
||||
cover_url = models.CharField(max_length=512, null=True, blank=True)
|
||||
price_per_unit = models.DecimalField(max_digits=8, decimal_places=2, default=0.00)
|
||||
price_unit = models.CharField(max_length=32, default='次')
|
||||
is_active = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'reservation_resources'
|
||||
|
||||
class ReservationOrder(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
order_no = models.CharField(max_length=64, unique=True)
|
||||
user = models.ForeignKey('users.User', on_delete=models.CASCADE, related_name='reservation_orders')
|
||||
resource = models.ForeignKey(ReservationResource, on_delete=models.CASCADE, related_name='orders')
|
||||
start_time = models.DateTimeField()
|
||||
end_time = models.DateTimeField()
|
||||
quantity = models.IntegerField(default=1)
|
||||
unit_price = models.DecimalField(max_digits=8, decimal_places=2)
|
||||
total_amount = models.DecimalField(max_digits=10, decimal_places=2)
|
||||
status = models.CharField(max_length=32, choices=OrderStatus.choices, default=OrderStatus.PENDING_PAY)
|
||||
wx_prepay_id = models.CharField(max_length=128, null=True, blank=True)
|
||||
wx_transaction_id = models.CharField(max_length=128, null=True, blank=True)
|
||||
paid_at = models.DateTimeField(null=True, blank=True)
|
||||
refund_reason = models.TextField(null=True, blank=True)
|
||||
refund_amount = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
|
||||
wx_refund_id = models.CharField(max_length=128, null=True, blank=True)
|
||||
refunded_at = models.DateTimeField(null=True, blank=True)
|
||||
qr_code_url = models.CharField(max_length=512, null=True, blank=True)
|
||||
verified_by = models.ForeignKey('users.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='verified_orders')
|
||||
verified_at = models.DateTimeField(null=True, blank=True)
|
||||
remark = models.TextField(null=True, blank=True)
|
||||
is_deleted = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'reservation_orders'
|
||||
14
reservations/serializers.py
Normal file
14
reservations/serializers.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from rest_framework import serializers
|
||||
from .models import ReservationResource, ReservationOrder
|
||||
|
||||
class ReservationResourceSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ReservationResource
|
||||
fields = '__all__'
|
||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||
|
||||
class ReservationOrderSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ReservationOrder
|
||||
fields = '__all__'
|
||||
read_only_fields = ['id', 'user', 'order_no', 'status', 'paid_at', 'refund_amount', 'wx_refund_id', 'refunded_at', 'qr_code_url', 'verified_by', 'verified_at', 'created_at', 'updated_at']
|
||||
3
reservations/tests.py
Normal file
3
reservations/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
11
reservations/urls.py
Normal file
11
reservations/urls.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import ReservationResourceViewSet, ReservationOrderViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register('resources', ReservationResourceViewSet)
|
||||
router.register('orders', ReservationOrderViewSet)
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
61
reservations/views.py
Normal file
61
reservations/views.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from rest_framework import viewsets, permissions, status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.decorators import action
|
||||
from .models import ReservationResource, ReservationOrder, OrderStatus
|
||||
from .serializers import ReservationResourceSerializer, ReservationOrderSerializer
|
||||
import uuid
|
||||
import datetime
|
||||
|
||||
class ReservationResourceViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
@author: xujl
|
||||
Api说明: 预约资源(工位、会议室等物理资源)接口视图。用于资源空间的增删改查及上下架管理,控制开放时段。
|
||||
"""
|
||||
queryset = ReservationResource.objects.filter(is_active=True)
|
||||
serializer_class = ReservationResourceSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action in ['create', 'update', 'partial_update', 'destroy']:
|
||||
return [permissions.IsAdminUser()]
|
||||
return [permissions.AllowAny()]
|
||||
|
||||
class ReservationOrderViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
@author: xujl
|
||||
Api说明: 预约订单接口视图。用于用户发起资源预约、取消预约,以及后台管理员对订单进行状态流转和支付确认管理。
|
||||
"""
|
||||
queryset = ReservationOrder.objects.filter(is_deleted=False)
|
||||
serializer_class = ReservationOrderSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
return ReservationOrder.objects.filter(user=self.request.user, is_deleted=False)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
resource = serializer.validated_data['resource']
|
||||
quantity = serializer.validated_data.get('quantity', 1)
|
||||
total_amount = resource.price_per_unit * quantity
|
||||
|
||||
# 简单生成订单号
|
||||
order_no = f"RES{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}{str(uuid.uuid4())[:4].upper()}"
|
||||
|
||||
serializer.save(
|
||||
user=self.request.user,
|
||||
order_no=order_no,
|
||||
unit_price=resource.price_per_unit,
|
||||
total_amount=total_amount,
|
||||
status=OrderStatus.PENDING_PAY
|
||||
)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def pay(self, request, pk=None):
|
||||
order = self.get_object()
|
||||
if order.status != OrderStatus.PENDING_PAY:
|
||||
return Response({'detail': '订单状态不允许支付'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
order.status = OrderStatus.PAID
|
||||
order.paid_at = datetime.datetime.now()
|
||||
order.qr_code_url = f"https://api.opc-platform.com/qr/{order.order_no}"
|
||||
order.save()
|
||||
|
||||
return Response({'status': '支付成功', 'qr_code_url': order.qr_code_url})
|
||||
59
seed_permissions.py
Normal file
59
seed_permissions.py
Normal file
@@ -0,0 +1,59 @@
|
||||
import os
|
||||
import django
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||
django.setup()
|
||||
|
||||
from users.models import Permission, Role, RolePermission
|
||||
|
||||
def seed():
|
||||
print("--- 正在初始化权限节点数据 ---")
|
||||
|
||||
# 账号权限组 (Account)
|
||||
account_group, _ = Permission.objects.get_or_create(
|
||||
code="menu:account", defaults={"name": "账号与用户权限组", "type": "MENU"}
|
||||
)
|
||||
|
||||
# 子节点
|
||||
users_menu, _ = Permission.objects.get_or_create(
|
||||
code="menu:account:users", defaults={"name": "用户管理菜单", "type": "MENU", "parent": account_group, "path": "/admin/users"}
|
||||
)
|
||||
roles_menu, _ = Permission.objects.get_or_create(
|
||||
code="menu:account:roles", defaults={"name": "角色管理菜单", "type": "MENU", "parent": account_group, "path": "/admin/roles"}
|
||||
)
|
||||
perms_menu, _ = Permission.objects.get_or_create(
|
||||
code="menu:account:permissions", defaults={"name": "权限树管理菜单", "type": "MENU", "parent": account_group, "path": "/admin/permissions"}
|
||||
)
|
||||
|
||||
# API 权限
|
||||
Permission.objects.get_or_create(code="api:users:read", defaults={"name": "查询用户列表", "type": "API", "parent": users_menu, "method": "GET", "path": "/api/v1/users/"})
|
||||
Permission.objects.get_or_create(code="api:users:write", defaults={"name": "新增/编辑用户", "type": "API", "parent": users_menu, "method": "POST", "path": "/api/v1/users/"})
|
||||
Permission.objects.get_or_create(code="api:users:delete", defaults={"name": "删除用户", "type": "API", "parent": users_menu, "method": "DELETE", "path": "/api/v1/users/{id}/"})
|
||||
|
||||
# 业务权限组 (Business)
|
||||
biz_group, _ = Permission.objects.get_or_create(
|
||||
code="menu:business", defaults={"name": "业务管理权限组", "type": "MENU"}
|
||||
)
|
||||
tasks_menu, _ = Permission.objects.get_or_create(
|
||||
code="menu:business:tasks", defaults={"name": "全站任务管理", "type": "MENU", "parent": biz_group, "path": "/admin/tasks"}
|
||||
)
|
||||
cert_menu, _ = Permission.objects.get_or_create(
|
||||
code="menu:business:certs", defaults={"name": "资质审核管理", "type": "MENU", "parent": biz_group, "path": "/admin/certifications"}
|
||||
)
|
||||
ent_menu, _ = Permission.objects.get_or_create(
|
||||
code="menu:business:enterprises", defaults={"name": "入驻企业管理", "type": "MENU", "parent": biz_group, "path": "/admin/enterprises"}
|
||||
)
|
||||
|
||||
print("权限节点初始化完成!")
|
||||
|
||||
# 为 ADMIN 角色自动分配所有权限
|
||||
admin_role = Role.objects.filter(code='ADMIN').first()
|
||||
if admin_role:
|
||||
print("正在为 ADMIN 角色自动挂载所有权限...")
|
||||
all_perms = Permission.objects.all()
|
||||
for p in all_perms:
|
||||
RolePermission.objects.get_or_create(role=admin_role, permission=p)
|
||||
print("ADMIN 角色权限挂载完毕!")
|
||||
|
||||
if __name__ == '__main__':
|
||||
seed()
|
||||
86
seed_phase5.py
Normal file
86
seed_phase5.py
Normal file
@@ -0,0 +1,86 @@
|
||||
import os
|
||||
import django
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||
django.setup()
|
||||
|
||||
from system.models import Announcement, AiModel
|
||||
from reservations.models import ReservationResource, ResourceType
|
||||
from users.models import User
|
||||
|
||||
def seed_data():
|
||||
# Delete old
|
||||
Announcement.objects.all().delete()
|
||||
AiModel.objects.all().delete()
|
||||
ReservationResource.objects.all().delete()
|
||||
|
||||
admin_user = User.objects.filter(is_superuser=True).first()
|
||||
|
||||
if not admin_user:
|
||||
print("No admin user found. Cannot seed announcements.")
|
||||
return
|
||||
|
||||
# Seed Announcements
|
||||
Announcement.objects.create(
|
||||
title='CorpScale 2.0 品牌升级:助力一人公司跨越式增长',
|
||||
content='全新的 OPC 平台正式上线!为自由职业者提供更强大的背书与任务撮合能力。',
|
||||
publisher=admin_user,
|
||||
is_published=True
|
||||
)
|
||||
Announcement.objects.create(
|
||||
title='关于新增 4D 点云标注、多模态意图识别等高端任务类型的通知',
|
||||
content='针对自动驾驶和前沿 AI 研发,平台已引入高价值数据标注任务,欢迎拥有相关资质的专家承接。',
|
||||
publisher=admin_user,
|
||||
is_published=True
|
||||
)
|
||||
Announcement.objects.create(
|
||||
title='开发者生态支持计划:首批 OPC 认证专家将获得算力补贴',
|
||||
content='首批入驻的专家除了获得免排队特权外,还将每月获得 500,000 Tokens 的模型调用配额。',
|
||||
publisher=admin_user,
|
||||
is_published=True
|
||||
)
|
||||
|
||||
# Seed AI Models
|
||||
AiModel.objects.create(
|
||||
name='CS-Llama-Instruct-7B',
|
||||
provider='CorpScale',
|
||||
description='基于Llama架构深度优化的行业指令微调模型,适用于客服机器人与知识库问答系统,支持超长上下文。',
|
||||
price_per_token=0.0001
|
||||
)
|
||||
AiModel.objects.create(
|
||||
name='VisionX-Segmentation-V3',
|
||||
provider='CorpScale',
|
||||
description='高精度图像语义分割模型,支持256个语义类别,专为自动驾驶避障和医疗辅助诊断分析设计。',
|
||||
price_per_token=0.0005
|
||||
)
|
||||
AiModel.objects.create(
|
||||
name='AutoTrans-Speech-Turbo',
|
||||
provider='CorpScale',
|
||||
description='低延迟实时语音转文字模型,支持极速多国语言翻译及50多种中国方言精准识别。',
|
||||
price_per_token=0.0002
|
||||
)
|
||||
|
||||
# Seed Reservation Resources
|
||||
ReservationResource.objects.create(
|
||||
name='A栋101 大型全景会议室',
|
||||
type=ResourceType.MEETING_ROOM,
|
||||
description='支持50人规模的顶级会议,配备8K投影与哈曼卡顿音响系统。',
|
||||
capacity=50,
|
||||
location='创新园区 A栋 1层',
|
||||
price_per_unit=150.00,
|
||||
price_unit='小时'
|
||||
)
|
||||
ReservationResource.objects.create(
|
||||
name='园区通用智能门禁白名单',
|
||||
type=ResourceType.ACCESS_CONTROL,
|
||||
description='开通后刷脸即可进入园区各公共办公区及休息区。',
|
||||
capacity=1,
|
||||
location='全园区通用',
|
||||
price_per_unit=20.00,
|
||||
price_unit='次'
|
||||
)
|
||||
|
||||
print("Phase 5 Seed data generated successfully!")
|
||||
|
||||
if __name__ == '__main__':
|
||||
seed_data()
|
||||
103
seed_roles_pages.py
Normal file
103
seed_roles_pages.py
Normal file
@@ -0,0 +1,103 @@
|
||||
import os
|
||||
import django
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||
django.setup()
|
||||
|
||||
from users.models import Permission, Role, RolePermission
|
||||
|
||||
def seed():
|
||||
print("--- 正在初始化预设角色与页面权限数据 ---")
|
||||
|
||||
# 1. 预设角色
|
||||
roles_data = [
|
||||
{"code": "ADMIN", "name": "管理员", "description": "系统超级管理员", "is_system": True},
|
||||
{"code": "USER", "name": "普通用户", "description": "平台普通注册用户", "is_system": True},
|
||||
{"code": "ENTERPRISE", "name": "企业用户", "description": "已入驻企业账号及团队成员", "is_system": True},
|
||||
{"code": "OPC_USER", "name": "OPC 专家", "description": "已通过 OPC 认证的专家", "is_system": True},
|
||||
]
|
||||
|
||||
for rd in roles_data:
|
||||
Role.objects.update_or_create(code=rd["code"], defaults=rd)
|
||||
|
||||
# 2. 预设页面菜单节点
|
||||
admin_menus = [
|
||||
"menu:business:tasks",
|
||||
"menu:business:tasks:publish",
|
||||
"menu:business:enterprises",
|
||||
"menu:account:users",
|
||||
"menu:business:certs",
|
||||
"menu:business:enterprise_certs",
|
||||
"menu:account:user_manage",
|
||||
"menu:account:roles",
|
||||
"menu:settings:announcements",
|
||||
"menu:settings:skills",
|
||||
"menu:business:models",
|
||||
"menu:business:screen",
|
||||
]
|
||||
|
||||
ent_menus = [
|
||||
"menu:ent:dashboard",
|
||||
"menu:ent:tasks",
|
||||
"menu:ent:tasks:create",
|
||||
"menu:ent:team",
|
||||
"menu:ent:opc_users",
|
||||
"menu:ent:invitations",
|
||||
"menu:ent:verification",
|
||||
"menu:ent:profile",
|
||||
"menu:ent:settings",
|
||||
]
|
||||
|
||||
user_menus = [
|
||||
"menu:user:dashboard",
|
||||
"menu:user:tasks",
|
||||
"menu:user:my_tasks",
|
||||
"menu:user:invitations",
|
||||
"menu:user:announcements",
|
||||
"menu:user:certification",
|
||||
"menu:user:profile",
|
||||
"menu:user:settings",
|
||||
]
|
||||
|
||||
all_menus = admin_menus + ent_menus + user_menus
|
||||
|
||||
# Create root menus
|
||||
for menu_code in all_menus:
|
||||
parts = menu_code.split(":")
|
||||
for i in range(1, len(parts) + 1):
|
||||
parent_code = ":".join(parts[:i])
|
||||
Permission.objects.get_or_create(
|
||||
code=parent_code,
|
||||
defaults={"name": parent_code, "type": "MENU"}
|
||||
)
|
||||
|
||||
# 3. 分配权限
|
||||
admin_role = Role.objects.get(code="ADMIN")
|
||||
ent_role = Role.objects.get(code="ENTERPRISE")
|
||||
user_role = Role.objects.get(code="USER")
|
||||
opc_role = Role.objects.get(code="OPC_USER")
|
||||
|
||||
# Clear old menu permissions to reset cleanly
|
||||
RolePermission.objects.filter(permission__type='MENU').delete()
|
||||
|
||||
def assign_perms(role, menu_list):
|
||||
perms_to_add = []
|
||||
for menu_code in menu_list:
|
||||
parts = menu_code.split(":")
|
||||
for i in range(1, len(parts) + 1):
|
||||
parent_code = ":".join(parts[:i])
|
||||
perms_to_add.append(parent_code)
|
||||
|
||||
perms = Permission.objects.filter(code__in=perms_to_add)
|
||||
for p in perms:
|
||||
RolePermission.objects.get_or_create(role=role, permission=p)
|
||||
|
||||
assign_perms(admin_role, admin_menus)
|
||||
assign_perms(ent_role, ent_menus)
|
||||
assign_perms(user_role, user_menus)
|
||||
assign_perms(opc_role, user_menus)
|
||||
|
||||
print("角色与页面权限预设完成!")
|
||||
|
||||
if __name__ == '__main__':
|
||||
seed()
|
||||
55
seed_task_admin.py
Normal file
55
seed_task_admin.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import os
|
||||
import django
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||
django.setup()
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from users.models import Role, Permission, RolePermission, UserRole
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
def seed():
|
||||
print("--- 开始配置测试角色与账号 ---")
|
||||
|
||||
# 1. 创建任务管理员角色
|
||||
task_role, _ = Role.objects.get_or_create(
|
||||
code="TASK_ADMIN",
|
||||
defaults={
|
||||
"name": "任务管理员",
|
||||
"description": "仅能管理和查看全站任务的受限管理角色"
|
||||
}
|
||||
)
|
||||
print("角色 TASK_ADMIN 已准备")
|
||||
|
||||
# 2. 为该角色分配 '全站任务管理' 权限
|
||||
task_perm = Permission.objects.filter(code="menu:business:tasks").first()
|
||||
if task_perm:
|
||||
RolePermission.objects.get_or_create(role=task_role, permission=task_perm)
|
||||
print("已分配 menu:business:tasks 权限给该角色")
|
||||
else:
|
||||
print("错误:未找到 menu:business:tasks 权限")
|
||||
|
||||
# 3. 创建测试账号
|
||||
test_user = User.objects.filter(username="task_admin").first()
|
||||
if not test_user:
|
||||
test_user = User.objects.create_user(
|
||||
username="task_admin",
|
||||
password="123456",
|
||||
nickname="任务审核专员",
|
||||
is_staff=True # 必须为 is_staff 才能访问管理端 /admin/login
|
||||
)
|
||||
print("测试账号 task_admin/123456 创建成功")
|
||||
else:
|
||||
test_user.set_password("123456")
|
||||
test_user.is_staff = True
|
||||
test_user.save()
|
||||
print("测试账号已存在,密码已重置为 123456")
|
||||
|
||||
# 4. 绑定角色
|
||||
UserRole.objects.get_or_create(user=test_user, role=task_role)
|
||||
print("已将 TASK_ADMIN 角色绑定给测试账号 task_admin")
|
||||
print("--- 配置完成 ---")
|
||||
|
||||
if __name__ == '__main__':
|
||||
seed()
|
||||
0
system/__init__.py
Normal file
0
system/__init__.py
Normal file
3
system/admin.py
Normal file
3
system/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
6
system/apps.py
Normal file
6
system/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class SystemConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'system'
|
||||
108
system/migrations/0001_initial.py
Normal file
108
system/migrations/0001_initial.py
Normal file
@@ -0,0 +1,108 @@
|
||||
# Generated by Django 4.2.30 on 2026-04-25 09:46
|
||||
|
||||
from django.db import migrations, models
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Announcement',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('title', models.CharField(max_length=256)),
|
||||
('content', models.TextField()),
|
||||
('cover_url', models.CharField(blank=True, max_length=512, null=True)),
|
||||
('is_published', models.BooleanField(default=False)),
|
||||
('published_at', models.DateTimeField(blank=True, null=True)),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'announcements',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='AuditLog',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('action', models.CharField(max_length=128)),
|
||||
('resource', models.CharField(blank=True, max_length=128, null=True)),
|
||||
('resource_id', models.CharField(blank=True, max_length=128, null=True)),
|
||||
('detail', models.JSONField(default=dict)),
|
||||
('ip', models.GenericIPAddressField(blank=True, null=True)),
|
||||
('user_agent', models.TextField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'audit_logs',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ModelToken',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('model_id', models.CharField(max_length=128)),
|
||||
('model_name', models.CharField(blank=True, max_length=256, null=True)),
|
||||
('token_value', models.TextField()),
|
||||
('expires_at', models.DateTimeField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'model_tokens',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Notification',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('type', models.CharField(choices=[('SYSTEM', '系统通知'), ('CERTIFICATION', '认证通知'), ('TASK', '任务通知'), ('RESERVATION', '预约通知')], max_length=32)),
|
||||
('title', models.CharField(max_length=256)),
|
||||
('content', models.TextField()),
|
||||
('related_id', models.CharField(blank=True, max_length=128, null=True)),
|
||||
('is_read', models.BooleanField(default=False)),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'notifications',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SmsVerification',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('phone', models.CharField(max_length=20)),
|
||||
('scene', models.CharField(choices=[('REGISTER', '注册'), ('LOGIN', '登录'), ('RESET_PASSWORD', '重置密码')], max_length=32)),
|
||||
('code', models.CharField(max_length=8)),
|
||||
('is_used', models.BooleanField(default=False)),
|
||||
('expired_at', models.DateTimeField()),
|
||||
('used_at', models.DateTimeField(blank=True, null=True)),
|
||||
('ip', models.GenericIPAddressField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'sms_verifications',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SystemConfig',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('config_key', models.CharField(max_length=128, unique=True)),
|
||||
('config_value', models.JSONField()),
|
||||
('description', models.TextField(blank=True, null=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'system_configs',
|
||||
},
|
||||
),
|
||||
]
|
||||
43
system/migrations/0002_initial.py
Normal file
43
system/migrations/0002_initial.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# Generated by Django 4.2.30 on 2026-04-25 09:46
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('system', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='systemconfig',
|
||||
name='updated_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_configs', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='notification',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='modeltoken',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='model_tokens', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='auditlog',
|
||||
name='user',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='audit_logs', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='announcement',
|
||||
name='publisher',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='published_announcements', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
30
system/migrations/0003_aimodel.py
Normal file
30
system/migrations/0003_aimodel.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 4.2.30 on 2026-04-25 13:10
|
||||
|
||||
from django.db import migrations, models
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('system', '0002_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AiModel',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=128)),
|
||||
('provider', models.CharField(max_length=128)),
|
||||
('description', models.TextField(blank=True, null=True)),
|
||||
('icon', models.CharField(blank=True, max_length=64, null=True)),
|
||||
('price_per_token', models.DecimalField(decimal_places=6, default=0, max_digits=10)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'ai_models',
|
||||
},
|
||||
),
|
||||
]
|
||||
30
system/migrations/0004_skill.py
Normal file
30
system/migrations/0004_skill.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 4.2.30 on 2026-04-27 16:36
|
||||
|
||||
from django.db import migrations, models
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('system', '0003_aimodel'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Skill',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=64, unique=True)),
|
||||
('category', models.CharField(blank=True, help_text='分类: 技术/设计/内容/数据 等', max_length=64, null=True)),
|
||||
('sort_order', models.IntegerField(default=0, help_text='排序权重, 越小越靠前')),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'skills',
|
||||
'ordering': ['sort_order', 'name'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 4.2.30 on 2026-04-27 17:30
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('system', '0004_skill'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='announcement',
|
||||
options={'ordering': ['-is_recommended', '-recommend_priority', '-created_at']},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='announcement',
|
||||
name='is_recommended',
|
||||
field=models.BooleanField(default=False, help_text='推荐/置顶'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='announcement',
|
||||
name='recommend_priority',
|
||||
field=models.IntegerField(default=0, help_text='推荐优先级, 越大越靠前'),
|
||||
),
|
||||
]
|
||||
18
system/migrations/0006_announcement_target_audience.py
Normal file
18
system/migrations/0006_announcement_target_audience.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.30 on 2026-04-27 17:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('system', '0005_alter_announcement_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='announcement',
|
||||
name='target_audience',
|
||||
field=models.CharField(choices=[('ALL', '全平台'), ('USER', '用户端'), ('ENTERPRISE', '企业端')], default='ALL', help_text='公告受众', max_length=16),
|
||||
),
|
||||
]
|
||||
0
system/migrations/__init__.py
Normal file
0
system/migrations/__init__.py
Normal file
78
system/minio_utils.py
Normal file
78
system/minio_utils.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from minio import Minio
|
||||
from django.conf import settings
|
||||
import uuid
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class MinioClient:
|
||||
def __init__(self):
|
||||
self._client = None
|
||||
self.bucket_name = getattr(settings, 'MINIO_BUCKET_NAME', 'opc-assets')
|
||||
self._initialized = False
|
||||
|
||||
@property
|
||||
def client(self):
|
||||
if self._client is None:
|
||||
self._client = Minio(
|
||||
endpoint=getattr(settings, 'MINIO_ENDPOINT', 'localhost:9000'),
|
||||
access_key=getattr(settings, 'MINIO_ACCESS_KEY', 'minioadmin'),
|
||||
secret_key=getattr(settings, 'MINIO_SECRET_KEY', 'minioadmin'),
|
||||
secure=getattr(settings, 'MINIO_SECURE', False)
|
||||
)
|
||||
if not self._initialized:
|
||||
try:
|
||||
self._ensure_bucket()
|
||||
self._initialized = True
|
||||
except Exception as e:
|
||||
logger.warning(f'MinIO bucket init failed (will retry on next call): {e}')
|
||||
return self._client
|
||||
|
||||
def _ensure_bucket(self):
|
||||
if not self._client.bucket_exists(self.bucket_name):
|
||||
self._client.make_bucket(self.bucket_name)
|
||||
|
||||
# Always update policy to ensure public access
|
||||
policy = {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"AWS": ["*"]},
|
||||
"Action": ["s3:GetBucketLocation", "s3:ListBucket"],
|
||||
"Resource": [f"arn:aws:s3:::{self.bucket_name}"],
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"AWS": ["*"]},
|
||||
"Action": ["s3:GetObject"],
|
||||
"Resource": [f"arn:aws:s3:::{self.bucket_name}/*"],
|
||||
},
|
||||
],
|
||||
}
|
||||
self._client.set_bucket_policy(self.bucket_name, json.dumps(policy))
|
||||
|
||||
def upload_file(self, file_obj, folder='general'):
|
||||
# Generate a unique name
|
||||
ext = os.path.splitext(file_obj.name)[1]
|
||||
file_name = f"{folder}/{uuid.uuid4()}{ext}"
|
||||
|
||||
# Upload (this will trigger lazy init of the client)
|
||||
self.client.put_object(
|
||||
self.bucket_name,
|
||||
file_name,
|
||||
file_obj,
|
||||
length=file_obj.size,
|
||||
content_type=file_obj.content_type
|
||||
)
|
||||
|
||||
# Return the public URL
|
||||
base_url = getattr(settings, 'MINIO_PUBLIC_URL', 'http://127.0.0.1:9000')
|
||||
return f"{base_url}/{self.bucket_name}/{file_name}"
|
||||
|
||||
|
||||
# Lazy singleton — does NOT connect at import time
|
||||
minio_client = MinioClient()
|
||||
|
||||
132
system/models.py
Normal file
132
system/models.py
Normal file
@@ -0,0 +1,132 @@
|
||||
import uuid
|
||||
from django.db import models
|
||||
|
||||
class NotificationType(models.TextChoices):
|
||||
SYSTEM = 'SYSTEM', '系统通知'
|
||||
CERTIFICATION = 'CERTIFICATION', '认证通知'
|
||||
TASK = 'TASK', '任务通知'
|
||||
RESERVATION = 'RESERVATION', '预约通知'
|
||||
|
||||
class SmsScene(models.TextChoices):
|
||||
REGISTER = 'REGISTER', '注册'
|
||||
LOGIN = 'LOGIN', '登录'
|
||||
RESET_PASSWORD = 'RESET_PASSWORD', '重置密码'
|
||||
|
||||
class AudienceChoices(models.TextChoices):
|
||||
ALL = 'ALL', '全平台'
|
||||
USER = 'USER', '用户端'
|
||||
ENTERPRISE = 'ENTERPRISE', '企业端'
|
||||
|
||||
class Announcement(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
title = models.CharField(max_length=256)
|
||||
content = models.TextField()
|
||||
cover_url = models.CharField(max_length=512, null=True, blank=True)
|
||||
publisher = models.ForeignKey('users.User', on_delete=models.CASCADE, related_name='published_announcements')
|
||||
target_audience = models.CharField(max_length=16, choices=AudienceChoices.choices, default=AudienceChoices.ALL, help_text='公告受众')
|
||||
is_published = models.BooleanField(default=False)
|
||||
published_at = models.DateTimeField(null=True, blank=True)
|
||||
is_deleted = models.BooleanField(default=False)
|
||||
is_recommended = models.BooleanField(default=False, help_text='推荐/置顶')
|
||||
recommend_priority = models.IntegerField(default=0, help_text='推荐优先级, 越大越靠前')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'announcements'
|
||||
ordering = ['-is_recommended', '-recommend_priority', '-created_at']
|
||||
|
||||
class ModelToken(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.ForeignKey('users.User', on_delete=models.CASCADE, related_name='model_tokens')
|
||||
model_id = models.CharField(max_length=128)
|
||||
model_name = models.CharField(max_length=256, null=True, blank=True)
|
||||
token_value = models.TextField()
|
||||
expires_at = models.DateTimeField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'model_tokens'
|
||||
|
||||
class SmsVerification(models.Model):
|
||||
phone = models.CharField(max_length=20)
|
||||
scene = models.CharField(max_length=32, choices=SmsScene.choices)
|
||||
code = models.CharField(max_length=8)
|
||||
is_used = models.BooleanField(default=False)
|
||||
expired_at = models.DateTimeField()
|
||||
used_at = models.DateTimeField(null=True, blank=True)
|
||||
ip = models.GenericIPAddressField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'sms_verifications'
|
||||
|
||||
class AuditLog(models.Model):
|
||||
user = models.ForeignKey('users.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='audit_logs')
|
||||
action = models.CharField(max_length=128)
|
||||
resource = models.CharField(max_length=128, null=True, blank=True)
|
||||
resource_id = models.CharField(max_length=128, null=True, blank=True)
|
||||
detail = models.JSONField(default=dict)
|
||||
ip = models.GenericIPAddressField(null=True, blank=True)
|
||||
user_agent = models.TextField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'audit_logs'
|
||||
|
||||
class Notification(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.ForeignKey('users.User', on_delete=models.CASCADE, related_name='notifications')
|
||||
type = models.CharField(max_length=32, choices=NotificationType.choices)
|
||||
title = models.CharField(max_length=256)
|
||||
content = models.TextField()
|
||||
related_id = models.CharField(max_length=128, null=True, blank=True)
|
||||
is_read = models.BooleanField(default=False)
|
||||
is_deleted = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'notifications'
|
||||
|
||||
class SystemConfig(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
config_key = models.CharField(max_length=128, unique=True)
|
||||
config_value = models.JSONField()
|
||||
description = models.TextField(null=True, blank=True)
|
||||
updated_by = models.ForeignKey('users.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='updated_configs')
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'system_configs'
|
||||
|
||||
class AiModel(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
name = models.CharField(max_length=128)
|
||||
provider = models.CharField(max_length=128)
|
||||
description = models.TextField(null=True, blank=True)
|
||||
icon = models.CharField(max_length=64, null=True, blank=True)
|
||||
price_per_token = models.DecimalField(max_digits=10, decimal_places=6, default=0)
|
||||
is_active = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'ai_models'
|
||||
|
||||
|
||||
class Skill(models.Model):
|
||||
"""Admin-managed skill/expertise domain tags used across the platform."""
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
name = models.CharField(max_length=64, unique=True)
|
||||
category = models.CharField(max_length=64, null=True, blank=True, help_text='分类: 技术/设计/内容/数据 等')
|
||||
sort_order = models.IntegerField(default=0, help_text='排序权重, 越小越靠前')
|
||||
is_active = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'skills'
|
||||
ordering = ['sort_order', 'name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
36
system/serializers.py
Normal file
36
system/serializers.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from rest_framework import serializers
|
||||
from .models import Announcement, Notification, SystemConfig, AiModel, ModelToken, Skill
|
||||
|
||||
class AnnouncementSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Announcement
|
||||
fields = '__all__'
|
||||
|
||||
class NotificationSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Notification
|
||||
fields = '__all__'
|
||||
read_only_fields = ['id', 'user', 'created_at']
|
||||
|
||||
class SystemConfigSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = SystemConfig
|
||||
fields = '__all__'
|
||||
|
||||
class AiModelSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = AiModel
|
||||
fields = '__all__'
|
||||
|
||||
class ModelTokenSerializer(serializers.ModelSerializer):
|
||||
model_name = serializers.ReadOnlyField(source='model.name')
|
||||
class Meta:
|
||||
model = ModelToken
|
||||
fields = '__all__'
|
||||
read_only_fields = ['id', 'user', 'token_value', 'created_at']
|
||||
|
||||
class SkillSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Skill
|
||||
fields = '__all__'
|
||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||
3
system/tests.py
Normal file
3
system/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
17
system/urls.py
Normal file
17
system/urls.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import AnnouncementViewSet, NotificationViewSet, SystemConfigViewSet, AiModelViewSet, ModelTokenViewSet, FileUploadView, SkillViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register('announcements', AnnouncementViewSet, basename='announcement')
|
||||
router.register('notifications', NotificationViewSet, basename='notification')
|
||||
router.register('configs', SystemConfigViewSet, basename='config')
|
||||
router.register('models', AiModelViewSet, basename='model')
|
||||
router.register('tokens', ModelTokenViewSet, basename='model-token')
|
||||
router.register('skills', SkillViewSet, basename='skill')
|
||||
|
||||
urlpatterns = [
|
||||
path('upload/', FileUploadView.as_view(), name='file_upload'),
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
|
||||
152
system/views.py
Normal file
152
system/views.py
Normal file
@@ -0,0 +1,152 @@
|
||||
from rest_framework import viewsets, generics, permissions, status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.decorators import action
|
||||
from .models import Announcement, Notification, SystemConfig, AiModel, ModelToken, Skill
|
||||
from .serializers import AnnouncementSerializer, NotificationSerializer, SystemConfigSerializer, AiModelSerializer, ModelTokenSerializer, SkillSerializer
|
||||
|
||||
class AnnouncementViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
@author: xujl
|
||||
Api说明: 系统公告接口视图。管理员发布全局或定向通知公告,前端获取并展示给对应受众。
|
||||
"""
|
||||
queryset = Announcement.objects.filter(is_deleted=False)
|
||||
serializer_class = AnnouncementSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
qs = Announcement.objects.filter(is_deleted=False).order_by('-is_recommended', '-recommend_priority', '-created_at')
|
||||
audience = self.request.query_params.get('audience')
|
||||
if audience:
|
||||
qs = qs.filter(target_audience__in=[audience, 'ALL'])
|
||||
if self.action == 'list' and not self.request.user.is_staff:
|
||||
qs = qs.filter(is_published=True)
|
||||
return qs
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action in ['create', 'update', 'partial_update', 'destroy', 'toggle_recommend']:
|
||||
return [permissions.IsAdminUser()]
|
||||
return [permissions.AllowAny()]
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def toggle_recommend(self, request, pk=None):
|
||||
ann = self.get_object()
|
||||
ann.is_recommended = request.data.get('is_recommended', not ann.is_recommended)
|
||||
ann.recommend_priority = request.data.get('recommend_priority', ann.recommend_priority)
|
||||
ann.save(update_fields=['is_recommended', 'recommend_priority'])
|
||||
return Response({'is_recommended': ann.is_recommended, 'recommend_priority': ann.recommend_priority})
|
||||
|
||||
class NotificationViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
@author: xujl
|
||||
Api说明: 站内消息通知接口视图。用于系统内部产生的各类站内信、状态变更通知的拉取和未读/已读状态更新。
|
||||
"""
|
||||
serializer_class = NotificationSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
return Notification.objects.filter(user=self.request.user, is_deleted=False)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def read(self, request, pk=None):
|
||||
notification = self.get_object()
|
||||
notification.is_read = True
|
||||
notification.save()
|
||||
return Response({'status': '已标记为已读'})
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def read_all(self, request):
|
||||
self.get_queryset().update(is_read=True)
|
||||
return Response({'status': '全部标记为已读'})
|
||||
|
||||
class SystemConfigViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
@author: xujl
|
||||
Api说明: 系统全局动态配置接口视图。用于保存字典数据、开关配置等,避免硬编码参数。
|
||||
"""
|
||||
queryset = SystemConfig.objects.all()
|
||||
serializer_class = SystemConfigSerializer
|
||||
lookup_field = 'config_key'
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action in ['create', 'update', 'partial_update', 'destroy']:
|
||||
return [permissions.IsAdminUser()]
|
||||
return [permissions.AllowAny()]
|
||||
|
||||
class AiModelViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
@author: xujl
|
||||
Api说明: AI模型配置接口视图。统一管理平台对接的各大AI平台(如OpenAI、文心一言等)的模型参数及端点。
|
||||
"""
|
||||
queryset = AiModel.objects.all()
|
||||
serializer_class = AiModelSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action in ['create', 'update', 'partial_update', 'destroy']:
|
||||
return [permissions.IsAdminUser()]
|
||||
return [permissions.IsAuthenticatedOrReadOnly()]
|
||||
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset()
|
||||
if self.action == 'list' and not self.request.user.is_staff:
|
||||
return qs.filter(is_active=True)
|
||||
return qs
|
||||
|
||||
class ModelTokenViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
@author: xujl
|
||||
Api说明: 大模型Token/密钥接口视图。安全管理模型请求时使用的Token或API Key认证信息。
|
||||
"""
|
||||
serializer_class = ModelTokenSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
return ModelToken.objects.filter(user=self.request.user)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
# In a real app, this would call MoRouter or another provider
|
||||
# For now, we generate a mock token
|
||||
import uuid
|
||||
mock_token = f"sk-opc-{uuid.uuid4().hex}"
|
||||
serializer.save(user=self.request.user, token_value=mock_token)
|
||||
|
||||
|
||||
class SkillViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
@author: xujl
|
||||
Api说明: 技能字典/用户标签接口视图。用于系统内的技能标签池管理,以供任务画像和用户画像进行匹配。
|
||||
"""
|
||||
"""Admin-managed skill/expertise tags. Read-only for non-admins."""
|
||||
queryset = Skill.objects.all()
|
||||
serializer_class = SkillSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action in ['create', 'update', 'partial_update', 'destroy']:
|
||||
return [permissions.IsAdminUser()]
|
||||
return [permissions.AllowAny()]
|
||||
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset()
|
||||
if self.action == 'list' and not (self.request.user.is_authenticated and self.request.user.is_staff):
|
||||
return qs.filter(is_active=True)
|
||||
return qs
|
||||
|
||||
from .minio_utils import minio_client
|
||||
|
||||
class FileUploadView(generics.GenericAPIView):
|
||||
"""
|
||||
@author: xujl
|
||||
Api说明: 系统通用文件上传接口。对接MinIO或S3等对象存储,处理前端传入的文件并返回URL外链。
|
||||
"""
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def post(self, request):
|
||||
|
||||
file_obj = request.FILES.get('file')
|
||||
if not file_obj:
|
||||
return Response({'detail': '没有上传文件'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
folder = request.data.get('folder', 'general')
|
||||
try:
|
||||
file_url = minio_client.upload_file(file_obj, folder=folder)
|
||||
return Response({'url': file_url}, status=status.HTTP_201_CREATED)
|
||||
except Exception as e:
|
||||
return Response({'detail': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
0
tasks/__init__.py
Normal file
0
tasks/__init__.py
Normal file
3
tasks/admin.py
Normal file
3
tasks/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
6
tasks/apps.py
Normal file
6
tasks/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class TasksConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'tasks'
|
||||
0
tasks/management/__init__.py
Normal file
0
tasks/management/__init__.py
Normal file
0
tasks/management/commands/__init__.py
Normal file
0
tasks/management/commands/__init__.py
Normal file
141
tasks/management/commands/seed_tasks.py
Normal file
141
tasks/management/commands/seed_tasks.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""Seed 10 realistic tasks for the OPC platform."""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from tasks.models import Task, TaskStatus
|
||||
from users.models import User, Enterprise
|
||||
import random
|
||||
|
||||
|
||||
TASKS_DATA = [
|
||||
{
|
||||
"title": "企业官网全栈开发(Vue3 + Django)",
|
||||
"description": "为某科技公司打造全新的企业官方网站。前端使用 Vue 3 + Element Plus 框架,后端使用 Django REST Framework,需支持响应式布局、多语言切换以及 CMS 内容管理模块。要求有 SEO 优化方案和完善的部署文档。",
|
||||
"skill_tags": ["Vue.js", "Django", "前端开发", "全栈"],
|
||||
"budget_min": 25000, "budget_max": 45000,
|
||||
"task_type": "全栈开发",
|
||||
"deadline_days": 45,
|
||||
},
|
||||
{
|
||||
"title": "移动端 App UI/UX 设计(电商类)",
|
||||
"description": "为跨境电商平台设计一套完整的移动端 App UI/UX 方案,包括首页、商品详情、购物车、个人中心等核心页面。需要提供高保真 Figma 原型设计稿和设计规范文档,要求符合 iOS/Android 双端设计规范。",
|
||||
"skill_tags": ["UI设计", "UX设计", "Figma", "电商"],
|
||||
"budget_min": 15000, "budget_max": 30000,
|
||||
"task_type": "设计",
|
||||
"deadline_days": 30,
|
||||
},
|
||||
{
|
||||
"title": "数据分析看板开发(ECharts + Python)",
|
||||
"description": "基于已有的销售数据,开发一套实时数据分析看板,使用 ECharts 进行数据可视化呈现,后端使用 Python/Pandas 进行数据处理与聚合。需支持时间范围筛选、多维度交叉分析和 PDF 报表导出。",
|
||||
"skill_tags": ["数据分析", "Python", "ECharts", "可视化"],
|
||||
"budget_min": 18000, "budget_max": 35000,
|
||||
"task_type": "数据分析",
|
||||
"deadline_days": 25,
|
||||
},
|
||||
{
|
||||
"title": "微信小程序开发(社区团购类)",
|
||||
"description": "开发一款社区团购微信小程序,核心功能包括商品展示、拼团下单、分销推荐、订单管理和微信支付对接。后端使用云开发或已有 API 接口,需提供完整的源代码和部署文档。",
|
||||
"skill_tags": ["微信小程序", "JavaScript", "云开发"],
|
||||
"budget_min": 20000, "budget_max": 40000,
|
||||
"task_type": "移动开发",
|
||||
"deadline_days": 40,
|
||||
},
|
||||
{
|
||||
"title": "AI 智能客服系统集成",
|
||||
"description": "将大语言模型(如 DeepSeek / ChatGLM)集成到企业已有的客服系统中,实现智能工单分类、自动回复建议和知识库检索。需对接企业内部 API 和 CRM 系统,并提供模型微调方案。",
|
||||
"skill_tags": ["AI", "NLP", "Python", "API集成"],
|
||||
"budget_min": 35000, "budget_max": 60000,
|
||||
"task_type": "AI开发",
|
||||
"deadline_days": 50,
|
||||
},
|
||||
{
|
||||
"title": "品牌视觉设计(完整VI体系)",
|
||||
"description": "为创业公司提供完整的品牌视觉识别系统设计,包括 Logo、标准色、品牌字体、名片、信纸信封、品牌手册等全套 VI 设计。需提供 AI/PSD 源文件和 PDF 品牌手册。",
|
||||
"skill_tags": ["品牌设计", "VI设计", "Logo设计", "平面设计"],
|
||||
"budget_min": 12000, "budget_max": 25000,
|
||||
"task_type": "设计",
|
||||
"deadline_days": 20,
|
||||
},
|
||||
{
|
||||
"title": "自动化测试框架搭建(Web + API)",
|
||||
"description": "为已有的 SaaS 系统搭建完整的自动化测试框架,包括 Web 端 UI 自动化(Playwright/Selenium)和 API 自动化(Pytest + Requests),集成 CI/CD 流水线,输出测试报告和覆盖率统计。",
|
||||
"skill_tags": ["自动化测试", "Python", "Playwright", "CI/CD"],
|
||||
"budget_min": 16000, "budget_max": 28000,
|
||||
"task_type": "测试",
|
||||
"deadline_days": 30,
|
||||
},
|
||||
{
|
||||
"title": "短视频剪辑与运营(抖音/快手)",
|
||||
"description": "为餐饮品牌制作 20 条抖音/快手短视频内容,包括脚本撰写、实拍素材剪辑、字幕特效和 BGM 搭配。需提供周度内容日历和数据复盘报告,每条视频时长 15-60 秒。",
|
||||
"skill_tags": ["视频剪辑", "短视频运营", "内容创作"],
|
||||
"budget_min": 8000, "budget_max": 15000,
|
||||
"task_type": "内容运营",
|
||||
"deadline_days": 30,
|
||||
},
|
||||
{
|
||||
"title": "企业内部管理系统二次开发",
|
||||
"description": "基于现有的 Django Admin 管理后台进行二次开发,增加审批流引擎、消息通知模块、权限动态配置和数据导出功能。需配合前端 Vue 改造部分页面,提高运营效率。",
|
||||
"skill_tags": ["Django", "Vue.js", "后端开发", "系统集成"],
|
||||
"budget_min": 22000, "budget_max": 38000,
|
||||
"task_type": "后端开发",
|
||||
"deadline_days": 35,
|
||||
},
|
||||
{
|
||||
"title": "跨境电商数据爬取与分析",
|
||||
"description": "对 Amazon、eBay 等跨境电商平台的商品数据进行采集(Scrapy/Selenium),建立竞品价格监控和市场趋势分析模型。需要提供每日自动采集脚本和可视化分析 Dashboard。",
|
||||
"skill_tags": ["爬虫", "数据分析", "Python", "Scrapy"],
|
||||
"budget_min": 10000, "budget_max": 20000,
|
||||
"task_type": "数据采集",
|
||||
"deadline_days": 20,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Seed 10 realistic tasks for the OPC platform'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Find a publisher: prefer admin/staff user
|
||||
publisher = User.objects.filter(is_staff=True, is_deleted=False).first()
|
||||
if not publisher:
|
||||
publisher = User.objects.filter(is_deleted=False).first()
|
||||
if not publisher:
|
||||
self.stderr.write('No users found. Please create at least one user first.')
|
||||
return
|
||||
|
||||
# Find an enterprise if available
|
||||
enterprise = Enterprise.objects.filter(is_deleted=False, status='VERIFIED').first()
|
||||
|
||||
created = 0
|
||||
for data in TASKS_DATA:
|
||||
deadline = timezone.now().date() + timedelta(days=data.pop('deadline_days'))
|
||||
task, was_created = Task.objects.get_or_create(
|
||||
title=data['title'],
|
||||
defaults={
|
||||
'publisher': publisher,
|
||||
'enterprise': enterprise,
|
||||
'description': data['description'],
|
||||
'skill_tags': data['skill_tags'],
|
||||
'budget_min': data['budget_min'],
|
||||
'budget_max': data['budget_max'],
|
||||
'task_type': data['task_type'],
|
||||
'deadline': deadline,
|
||||
'status': TaskStatus.OPEN,
|
||||
'contact_name': publisher.nickname or publisher.username,
|
||||
'contact_email': publisher.email,
|
||||
}
|
||||
)
|
||||
if was_created:
|
||||
created += 1
|
||||
self.stdout.write(f' ✓ Created: {task.title}')
|
||||
else:
|
||||
self.stdout.write(f' - Exists: {task.title}')
|
||||
|
||||
# Mark 3 random tasks as recommended
|
||||
recommended = Task.objects.filter(status=TaskStatus.OPEN, is_recommended=False).order_by('?')[:3]
|
||||
for i, t in enumerate(recommended):
|
||||
t.is_recommended = True
|
||||
t.recommend_priority = 10 - i
|
||||
t.save(update_fields=['is_recommended', 'recommend_priority'])
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f'\n✅ Seeded {created} new tasks, marked {len(recommended)} as recommended.'))
|
||||
120
tasks/management/commands/seed_users.py
Normal file
120
tasks/management/commands/seed_users.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""Seed 10 certified OPC users with online avatar photos."""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
from users.models import User, Role, UserRole
|
||||
from opc_cert.models import OpcCertification
|
||||
|
||||
USERS_DATA = [
|
||||
{
|
||||
"username": "zhanglei", "nickname": "张磊", "email": "zhanglei@opc.cn",
|
||||
"phone": "13800000001", "bio": "10年全栈开发经验,精通 Vue/React/Django,曾主导多个百万级用户平台的架构设计与交付。",
|
||||
"location": "北京", "avatar_url": "https://api.dicebear.com/7.x/avataaars/svg?seed=zhanglei",
|
||||
"skills": ["Vue.js", "React", "Django", "全栈开发"], "experience": "前阿里巴巴高级工程师,负责过多个核心业务系统开发。",
|
||||
},
|
||||
{
|
||||
"username": "liuyanmei", "nickname": "刘艳梅", "email": "liuyanmei@opc.cn",
|
||||
"phone": "13800000002", "bio": "UI/UX 设计师,擅长移动端与 Web 端产品设计,拥有丰富的 B 端/C 端设计经验。",
|
||||
"location": "上海", "avatar_url": "https://api.dicebear.com/7.x/avataaars/svg?seed=liuyanmei",
|
||||
"skills": ["UI设计", "UX设计", "Figma", "Sketch"], "experience": "前腾讯设计中心资深设计师,主导过微信小程序生态设计规范。",
|
||||
},
|
||||
{
|
||||
"username": "wangqiang", "nickname": "王强", "email": "wangqiang@opc.cn",
|
||||
"phone": "13800000003", "bio": "数据科学家,擅长机器学习模型训练和数据可视化,熟悉 TensorFlow 与 PyTorch 框架。",
|
||||
"location": "深圳", "avatar_url": "https://api.dicebear.com/7.x/avataaars/svg?seed=wangqiang",
|
||||
"skills": ["数据分析", "Python", "机器学习", "TensorFlow"], "experience": "前华为云 AI 团队算法工程师。",
|
||||
},
|
||||
{
|
||||
"username": "chenxiaoling", "nickname": "陈小玲", "email": "chenxiaoling@opc.cn",
|
||||
"phone": "13800000004", "bio": "微信小程序和 App 开发专家,3年独立开发经验,已上线 20+ 款小程序。",
|
||||
"location": "杭州", "avatar_url": "https://api.dicebear.com/7.x/avataaars/svg?seed=chenxiaoling",
|
||||
"skills": ["微信小程序", "React Native", "Flutter"], "experience": "独立开发者,专注移动端开发与创新应用。",
|
||||
},
|
||||
{
|
||||
"username": "zhaowei", "nickname": "赵伟", "email": "zhaowei@opc.cn",
|
||||
"phone": "13800000005", "bio": "DevOps 工程师,精通 Kubernetes、Docker 和 CI/CD 流水线搭建。",
|
||||
"location": "成都", "avatar_url": "https://api.dicebear.com/7.x/avataaars/svg?seed=zhaowei",
|
||||
"skills": ["Docker", "Kubernetes", "CI/CD", "Linux"], "experience": "前字节跳动基础架构部高级 SRE 工程师。",
|
||||
},
|
||||
{
|
||||
"username": "sunmengmeng", "nickname": "孙萌萌", "email": "sunmengmeng@opc.cn",
|
||||
"phone": "13800000006", "bio": "品牌设计师与视觉创意总监,擅长 VI 体系设计、品牌策略和视觉营销。",
|
||||
"location": "广州", "avatar_url": "https://api.dicebear.com/7.x/avataaars/svg?seed=sunmengmeng",
|
||||
"skills": ["品牌设计", "VI设计", "平面设计", "Logo设计"], "experience": "服务过 50+ 品牌客户,包含多家上市企业。",
|
||||
},
|
||||
{
|
||||
"username": "huangdawei", "nickname": "黄大伟", "email": "huangdawei@opc.cn",
|
||||
"phone": "13800000007", "bio": "资深爬虫与数据采集工程师,精通反爬策略和大规模数据治理方案。",
|
||||
"location": "南京", "avatar_url": "https://api.dicebear.com/7.x/avataaars/svg?seed=huangdawei",
|
||||
"skills": ["爬虫", "Scrapy", "数据采集", "Python"], "experience": "曾为多家电商和金融企业搭建数据采集平台。",
|
||||
},
|
||||
{
|
||||
"username": "lixuemei", "nickname": "李雪梅", "email": "lixuemei@opc.cn",
|
||||
"phone": "13800000008", "bio": "内容运营与短视频创作者,抖音/快手/小红书多平台达人孵化经验。",
|
||||
"location": "武汉", "avatar_url": "https://api.dicebear.com/7.x/avataaars/svg?seed=lixuemei",
|
||||
"skills": ["视频剪辑", "短视频运营", "内容创作", "新媒体"], "experience": "MCN 机构签约创作者,累计粉丝 500 万+。",
|
||||
},
|
||||
{
|
||||
"username": "zhoujianhua", "nickname": "周建华", "email": "zhoujianhua@opc.cn",
|
||||
"phone": "13800000009", "bio": "自动化测试与质量保障专家,精通 Selenium、Playwright 和接口测试方案。",
|
||||
"location": "西安", "avatar_url": "https://api.dicebear.com/7.x/avataaars/svg?seed=zhoujianhua",
|
||||
"skills": ["自动化测试", "Playwright", "Selenium", "性能测试"], "experience": "前网易质量保障团队负责人,推动过全链路测试体系建设。",
|
||||
},
|
||||
{
|
||||
"username": "yangtianyu", "nickname": "杨天宇", "email": "yangtianyu@opc.cn",
|
||||
"phone": "13800000010", "bio": "AI 应用开发工程师,专注 LLM 集成、RAG 架构和智能对话系统。",
|
||||
"location": "重庆", "avatar_url": "https://api.dicebear.com/7.x/avataaars/svg?seed=yangtianyu",
|
||||
"skills": ["AI", "NLP", "LLM", "Python"], "experience": "前百度智能对话团队核心开发者。",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '创建10个已认证的OPC专家用户'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Get or create the OPC_USER role
|
||||
opc_role, _ = Role.objects.get_or_create(code='OPC_USER', defaults={'name': 'OPC认证专家', 'is_system': True})
|
||||
|
||||
created = 0
|
||||
for data in USERS_DATA:
|
||||
user, was_created = User.objects.get_or_create(
|
||||
username=data['username'],
|
||||
defaults={
|
||||
'nickname': data['nickname'],
|
||||
'email': data['email'],
|
||||
'phone': data['phone'],
|
||||
'bio': data['bio'],
|
||||
'location': data['location'],
|
||||
'avatar_url': data['avatar_url'],
|
||||
'rating': 4.80,
|
||||
'completed_tasks': 0,
|
||||
'is_recommended': True if created < 3 else False,
|
||||
'recommend_priority': 10 - created if created < 3 else 0,
|
||||
}
|
||||
)
|
||||
if was_created:
|
||||
user.set_password('opc123456')
|
||||
user.save()
|
||||
|
||||
# Assign OPC_USER role
|
||||
UserRole.objects.get_or_create(user=user, role=opc_role)
|
||||
|
||||
# Create approved certification
|
||||
OpcCertification.objects.get_or_create(
|
||||
user=user,
|
||||
defaults={
|
||||
'real_name': data['nickname'],
|
||||
'skills': data['skills'],
|
||||
'experience': data['experience'],
|
||||
'status': 'APPROVED',
|
||||
'reviewed_at': timezone.now(),
|
||||
}
|
||||
)
|
||||
|
||||
if was_created:
|
||||
created += 1
|
||||
self.stdout.write(f' ✓ 创建用户: {data["nickname"]} ({data["username"]})')
|
||||
else:
|
||||
self.stdout.write(f' - 已存在: {data["nickname"]} ({data["username"]})')
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f'\n✅ 已创建 {created} 个认证专家用户'))
|
||||
61
tasks/migrations/0001_initial.py
Normal file
61
tasks/migrations/0001_initial.py
Normal file
@@ -0,0 +1,61 @@
|
||||
# Generated by Django 4.2.30 on 2026-04-25 09:46
|
||||
|
||||
from django.db import migrations, models
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Task',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('title', models.CharField(max_length=256)),
|
||||
('description', models.TextField()),
|
||||
('skill_tags', models.JSONField(default=list)),
|
||||
('budget_min', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
|
||||
('budget_max', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
|
||||
('currency', models.CharField(default='CNY', max_length=8)),
|
||||
('deadline', models.DateField(blank=True, null=True)),
|
||||
('task_type', models.CharField(blank=True, max_length=64, null=True)),
|
||||
('attachments', models.JSONField(default=list)),
|
||||
('status', models.CharField(choices=[('DRAFT', '草稿'), ('OPEN', '已发布'), ('IN_PROGRESS', '进行中'), ('COMPLETED', '已完成'), ('CANCELLED', '已取消')], default='DRAFT', max_length=16)),
|
||||
('assigned_at', models.DateTimeField(blank=True, null=True)),
|
||||
('completed_at', models.DateTimeField(blank=True, null=True)),
|
||||
('completion_note', models.TextField(blank=True, null=True)),
|
||||
('deliverables', models.JSONField(default=list)),
|
||||
('cancelled_at', models.DateTimeField(blank=True, null=True)),
|
||||
('cancel_reason', models.TextField(blank=True, null=True)),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'tasks',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TaskApplication',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('cover_letter', models.TextField(blank=True, null=True)),
|
||||
('expected_days', models.IntegerField(blank=True, null=True)),
|
||||
('expected_price', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
|
||||
('attachments', models.JSONField(default=list)),
|
||||
('status', models.CharField(choices=[('PENDING', '待审核'), ('APPROVED', '已通过'), ('REJECTED', '已拒绝'), ('WITHDRAWN', '已撤回')], default='PENDING', max_length=16)),
|
||||
('reject_reason', models.TextField(blank=True, null=True)),
|
||||
('reviewed_at', models.DateTimeField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'task_applications',
|
||||
},
|
||||
),
|
||||
]
|
||||
53
tasks/migrations/0002_initial.py
Normal file
53
tasks/migrations/0002_initial.py
Normal file
@@ -0,0 +1,53 @@
|
||||
# Generated by Django 4.2.30 on 2026-04-25 09:46
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('users', '0001_initial'),
|
||||
('tasks', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='taskapplication',
|
||||
name='applicant',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='task_applications', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='taskapplication',
|
||||
name='reviewed_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_applications', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='taskapplication',
|
||||
name='task',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='applications', to='tasks.task'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='task',
|
||||
name='assignee',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_tasks', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='task',
|
||||
name='enterprise',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tasks', to='users.enterprise'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='task',
|
||||
name='publisher',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='published_tasks', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='taskapplication',
|
||||
unique_together={('task', 'applicant')},
|
||||
),
|
||||
]
|
||||
32
tasks/migrations/0003_taskinvitation.py
Normal file
32
tasks/migrations/0003_taskinvitation.py
Normal file
@@ -0,0 +1,32 @@
|
||||
# Generated by Django 4.2.30 on 2026-04-25 13:07
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('tasks', '0002_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='TaskInvitation',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('message', models.TextField(blank=True, null=True)),
|
||||
('status', models.CharField(choices=[('PENDING', '待处理'), ('ACCEPTED', '已接受'), ('REJECTED', '已拒绝'), ('DISCUSSING', '洽谈中')], default='PENDING', max_length=16)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('expert', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='task_invitations', to=settings.AUTH_USER_MODEL)),
|
||||
('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to='tasks.task')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'task_invitations',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,41 @@
|
||||
# Generated by Django 4.2.30 on 2026-04-26 14:25
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('tasks', '0003_taskinvitation'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='task',
|
||||
name='contact_email',
|
||||
field=models.EmailField(blank=True, max_length=254, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='task',
|
||||
name='contact_name',
|
||||
field=models.CharField(blank=True, max_length=64, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='task',
|
||||
name='contact_phone',
|
||||
field=models.CharField(blank=True, max_length=32, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='task',
|
||||
name='contact_user',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='task',
|
||||
name='contact_wechat',
|
||||
field=models.CharField(blank=True, max_length=64, null=True),
|
||||
),
|
||||
]
|
||||
24
tasks/migrations/0005_alter_task_status_and_more.py
Normal file
24
tasks/migrations/0005_alter_task_status_and_more.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 4.2.30 on 2026-04-26 14:48
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('tasks', '0004_task_contact_email_task_contact_name_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='task',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('DRAFT', '草稿'), ('OPEN', '已发布'), ('IN_PROGRESS', '进行中'), ('IN_REVIEW', '待验收'), ('COMPLETED', '已完成'), ('CANCELLED', '已取消')], default='DRAFT', max_length=16),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='taskinvitation',
|
||||
unique_together={('task', 'expert')},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 4.2.30 on 2026-04-26 15:07
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tasks', '0005_alter_task_status_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='taskapplication',
|
||||
name='completion_note',
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='taskapplication',
|
||||
name='deliverables',
|
||||
field=models.JSONField(default=list),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='taskapplication',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('PENDING', '待审核'), ('APPROVED', '已录用'), ('REJECTED', '已拒绝'), ('WITHDRAWN', '已撤回'), ('DELIVERED', '交付待验收'), ('COMPLETED', '已完成')], default='PENDING', max_length=16),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
# Generated by Django 4.2.30 on 2026-04-26 15:34
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tasks', '0006_taskapplication_completion_note_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='task',
|
||||
options={'ordering': ['-created_at']},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='taskapplication',
|
||||
options={'ordering': ['-created_at']},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='taskinvitation',
|
||||
options={'ordering': ['-created_at']},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DeliveryRecord',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('note', models.TextField()),
|
||||
('files', models.JSONField(default=list)),
|
||||
('status', models.CharField(choices=[('PENDING', '待验收'), ('APPROVED', '已验收'), ('REJECTED', '已驳回')], default='PENDING', max_length=16)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='delivery_records', to='tasks.taskapplication')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'delivery_records',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
18
tasks/migrations/0008_taskinvitation_messages.py
Normal file
18
tasks/migrations/0008_taskinvitation_messages.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.30 on 2026-04-26 15:46
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tasks', '0007_alter_task_options_alter_taskapplication_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='taskinvitation',
|
||||
name='messages',
|
||||
field=models.JSONField(default=list),
|
||||
),
|
||||
]
|
||||
18
tasks/migrations/0009_taskapplication_negotiation_history.py
Normal file
18
tasks/migrations/0009_taskapplication_negotiation_history.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.30 on 2026-04-26 15:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tasks', '0008_taskinvitation_messages'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='taskapplication',
|
||||
name='negotiation_history',
|
||||
field=models.JSONField(default=list),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,47 @@
|
||||
# Generated by Django 4.2.30 on 2026-04-27 16:36
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('tasks', '0009_taskapplication_negotiation_history'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='task',
|
||||
options={'ordering': ['-is_recommended', '-recommend_priority', '-created_at']},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='task',
|
||||
name='is_recommended',
|
||||
field=models.BooleanField(default=False, help_text='管理员推荐'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='task',
|
||||
name='recommend_priority',
|
||||
field=models.IntegerField(default=0, help_text='推荐优先级, 越大越靠前'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TaskReview',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('score', models.IntegerField(default=5)),
|
||||
('comment', models.TextField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('reviewee', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received_reviews', to=settings.AUTH_USER_MODEL)),
|
||||
('reviewer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='given_reviews', to=settings.AUTH_USER_MODEL)),
|
||||
('task', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='review', to='tasks.task')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'task_reviews',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
tasks/migrations/__init__.py
Normal file
0
tasks/migrations/__init__.py
Normal file
129
tasks/models.py
Normal file
129
tasks/models.py
Normal file
@@ -0,0 +1,129 @@
|
||||
import uuid
|
||||
from django.db import models
|
||||
|
||||
class TaskStatus(models.TextChoices):
|
||||
DRAFT = 'DRAFT', '草稿'
|
||||
OPEN = 'OPEN', '已发布'
|
||||
IN_PROGRESS = 'IN_PROGRESS', '进行中'
|
||||
IN_REVIEW = 'IN_REVIEW', '待验收'
|
||||
COMPLETED = 'COMPLETED', '已完成'
|
||||
CANCELLED = 'CANCELLED', '已取消'
|
||||
|
||||
class ApplyStatus(models.TextChoices):
|
||||
PENDING = 'PENDING', '待审核'
|
||||
APPROVED = 'APPROVED', '已录用'
|
||||
REJECTED = 'REJECTED', '已拒绝'
|
||||
WITHDRAWN = 'WITHDRAWN', '已撤回'
|
||||
DELIVERED = 'DELIVERED', '交付待验收'
|
||||
COMPLETED = 'COMPLETED', '已完成'
|
||||
|
||||
class Task(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
publisher = models.ForeignKey('users.User', on_delete=models.CASCADE, related_name='published_tasks')
|
||||
enterprise = models.ForeignKey('users.Enterprise', on_delete=models.SET_NULL, null=True, blank=True, related_name='tasks')
|
||||
title = models.CharField(max_length=256)
|
||||
description = models.TextField()
|
||||
skill_tags = models.JSONField(default=list)
|
||||
budget_min = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
|
||||
budget_max = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
|
||||
currency = models.CharField(max_length=8, default='CNY')
|
||||
deadline = models.DateField(null=True, blank=True)
|
||||
task_type = models.CharField(max_length=64, null=True, blank=True)
|
||||
attachments = models.JSONField(default=list)
|
||||
contact_user = models.ForeignKey('users.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='+')
|
||||
contact_name = models.CharField(max_length=64, null=True, blank=True)
|
||||
contact_phone = models.CharField(max_length=32, null=True, blank=True)
|
||||
contact_email = models.EmailField(null=True, blank=True)
|
||||
contact_wechat = models.CharField(max_length=64, null=True, blank=True)
|
||||
status = models.CharField(max_length=16, choices=TaskStatus.choices, default=TaskStatus.DRAFT)
|
||||
assignee = models.ForeignKey('users.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='assigned_tasks')
|
||||
assigned_at = models.DateTimeField(null=True, blank=True)
|
||||
completed_at = models.DateTimeField(null=True, blank=True)
|
||||
completion_note = models.TextField(null=True, blank=True)
|
||||
deliverables = models.JSONField(default=list)
|
||||
cancelled_at = models.DateTimeField(null=True, blank=True)
|
||||
cancel_reason = models.TextField(null=True, blank=True)
|
||||
is_deleted = models.BooleanField(default=False)
|
||||
is_recommended = models.BooleanField(default=False, help_text='管理员推荐')
|
||||
recommend_priority = models.IntegerField(default=0, help_text='推荐优先级, 越大越靠前')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'tasks'
|
||||
ordering = ['-is_recommended', '-recommend_priority', '-created_at']
|
||||
|
||||
class TaskApplication(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
task = models.ForeignKey(Task, on_delete=models.CASCADE, related_name='applications')
|
||||
applicant = models.ForeignKey('users.User', on_delete=models.CASCADE, related_name='task_applications')
|
||||
cover_letter = models.TextField(null=True, blank=True)
|
||||
expected_days = models.IntegerField(null=True, blank=True)
|
||||
expected_price = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
|
||||
attachments = models.JSONField(default=list)
|
||||
negotiation_history = models.JSONField(default=list)
|
||||
status = models.CharField(max_length=16, choices=ApplyStatus.choices, default=ApplyStatus.PENDING)
|
||||
reject_reason = models.TextField(null=True, blank=True)
|
||||
reviewed_by = models.ForeignKey('users.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='reviewed_applications')
|
||||
reviewed_at = models.DateTimeField(null=True, blank=True)
|
||||
deliverables = models.JSONField(default=list)
|
||||
completion_note = models.TextField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'task_applications'
|
||||
unique_together = ('task', 'applicant')
|
||||
ordering = ['-created_at']
|
||||
|
||||
class InvitationStatus(models.TextChoices):
|
||||
PENDING = 'PENDING', '待处理'
|
||||
ACCEPTED = 'ACCEPTED', '已接受'
|
||||
REJECTED = 'REJECTED', '已拒绝'
|
||||
DISCUSSING = 'DISCUSSING', '洽谈中'
|
||||
|
||||
class TaskInvitation(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
task = models.ForeignKey(Task, on_delete=models.CASCADE, related_name='invitations')
|
||||
expert = models.ForeignKey('users.User', on_delete=models.CASCADE, related_name='task_invitations')
|
||||
message = models.TextField(null=True, blank=True)
|
||||
messages = models.JSONField(default=list)
|
||||
status = models.CharField(max_length=16, choices=InvitationStatus.choices, default=InvitationStatus.PENDING)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'task_invitations'
|
||||
unique_together = ('task', 'expert')
|
||||
ordering = ['-created_at']
|
||||
|
||||
class DeliveryStatus(models.TextChoices):
|
||||
PENDING = 'PENDING', '待验收'
|
||||
APPROVED = 'APPROVED', '已验收'
|
||||
REJECTED = 'REJECTED', '已驳回'
|
||||
|
||||
class DeliveryRecord(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
application = models.ForeignKey(TaskApplication, on_delete=models.CASCADE, related_name='delivery_records')
|
||||
note = models.TextField()
|
||||
files = models.JSONField(default=list)
|
||||
status = models.CharField(max_length=16, choices=DeliveryStatus.choices, default=DeliveryStatus.PENDING)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'delivery_records'
|
||||
ordering = ['-created_at']
|
||||
|
||||
class TaskReview(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
task = models.OneToOneField(Task, on_delete=models.CASCADE, related_name='review')
|
||||
reviewer = models.ForeignKey('users.User', on_delete=models.CASCADE, related_name='given_reviews')
|
||||
reviewee = models.ForeignKey('users.User', on_delete=models.CASCADE, related_name='received_reviews')
|
||||
score = models.IntegerField(default=5) # 1 to 5
|
||||
comment = models.TextField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'task_reviews'
|
||||
ordering = ['-created_at']
|
||||
69
tasks/serializers.py
Normal file
69
tasks/serializers.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from rest_framework import serializers
|
||||
from .models import Task, TaskApplication, TaskInvitation, DeliveryRecord
|
||||
|
||||
class DeliveryRecordSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = DeliveryRecord
|
||||
fields = '__all__'
|
||||
|
||||
class TaskApplicationSerializer(serializers.ModelSerializer):
|
||||
applicant_name = serializers.ReadOnlyField(source='applicant.nickname')
|
||||
applicant_username = serializers.ReadOnlyField(source='applicant.username')
|
||||
applicant_avatar = serializers.ReadOnlyField(source='applicant.avatar_url')
|
||||
applicant_rating = serializers.ReadOnlyField(source='applicant.rating')
|
||||
applicant_completed_tasks = serializers.ReadOnlyField(source='applicant.completed_tasks')
|
||||
task_detail = serializers.SerializerMethodField()
|
||||
is_invited = serializers.SerializerMethodField()
|
||||
delivery_records = DeliveryRecordSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = TaskApplication
|
||||
fields = '__all__'
|
||||
read_only_fields = ['id', 'applicant', 'status', 'reviewed_by', 'reviewed_at', 'created_at', 'updated_at']
|
||||
|
||||
def get_task_detail(self, obj):
|
||||
return {
|
||||
'id': obj.task.id,
|
||||
'title': obj.task.title,
|
||||
'budget_min': obj.task.budget_min,
|
||||
'budget_max': obj.task.budget_max,
|
||||
'status': obj.task.status,
|
||||
}
|
||||
|
||||
def get_is_invited(self, obj):
|
||||
from .models import TaskInvitation
|
||||
return TaskInvitation.objects.filter(task=obj.task, expert=obj.applicant).exists()
|
||||
|
||||
|
||||
class TaskInvitationSerializer(serializers.ModelSerializer):
|
||||
enterprise_name = serializers.ReadOnlyField(source='task.enterprise.company_name')
|
||||
enterprise_logo = serializers.ReadOnlyField(source='task.enterprise.logo_url')
|
||||
enterprise_avatar = serializers.ReadOnlyField(source='task.publisher.avatar_url')
|
||||
inviter_name = serializers.ReadOnlyField(source='task.publisher.nickname')
|
||||
enterprise_phone = serializers.ReadOnlyField(source='task.publisher.phone')
|
||||
task_title = serializers.ReadOnlyField(source='task.title')
|
||||
expert_name = serializers.ReadOnlyField(source='expert.nickname')
|
||||
expert_avatar = serializers.ReadOnlyField(source='expert.avatar_url')
|
||||
expert_phone = serializers.ReadOnlyField(source='expert.phone')
|
||||
|
||||
|
||||
class Meta:
|
||||
model = TaskInvitation
|
||||
fields = '__all__'
|
||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||
|
||||
|
||||
class TaskSerializer(serializers.ModelSerializer):
|
||||
applications = TaskApplicationSerializer(many=True, read_only=True)
|
||||
invitations = TaskInvitationSerializer(many=True, read_only=True)
|
||||
publisher_name = serializers.ReadOnlyField(source='publisher.nickname')
|
||||
publisher_avatar = serializers.ReadOnlyField(source='publisher.avatar_url')
|
||||
publisher_phone = serializers.ReadOnlyField(source='publisher.phone')
|
||||
enterprise_name = serializers.ReadOnlyField(source='enterprise.company_name')
|
||||
enterprise_logo = serializers.ReadOnlyField(source='enterprise.logo_url')
|
||||
|
||||
class Meta:
|
||||
model = Task
|
||||
fields = '__all__'
|
||||
read_only_fields = ['id', 'publisher', 'enterprise', 'created_at', 'updated_at', 'assigned_at', 'completed_at', 'cancelled_at']
|
||||
|
||||
3
tasks/tests.py
Normal file
3
tasks/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
12
tasks/urls.py
Normal file
12
tasks/urls.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import TaskViewSet, TaskApplicationViewSet, TaskInvitationViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register('tasks', TaskViewSet, basename='task')
|
||||
router.register('applications', TaskApplicationViewSet, basename='task-application')
|
||||
router.register('invitations', TaskInvitationViewSet, basename='task-invitation')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
487
tasks/views.py
Normal file
487
tasks/views.py
Normal file
@@ -0,0 +1,487 @@
|
||||
from django.db import models
|
||||
from rest_framework import viewsets, permissions, status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.decorators import action
|
||||
from .models import Task, TaskApplication, TaskInvitation, TaskStatus, ApplyStatus, InvitationStatus
|
||||
from .serializers import TaskSerializer, TaskApplicationSerializer, TaskInvitationSerializer
|
||||
|
||||
class TaskInvitationViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
@author: xujl
|
||||
Api说明: 任务主动邀请接口视图。企业用户可主动向OPC专家发送定向任务合作邀请,专家可接受或拒绝该邀请。
|
||||
"""
|
||||
serializer_class = TaskInvitationSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
enterprise, _ = _get_user_enterprise(user)
|
||||
if enterprise:
|
||||
# Experts see invitations sent to them
|
||||
# Enterprises see invitations they sent for their tasks
|
||||
return TaskInvitation.objects.filter(
|
||||
models.Q(expert=user) | models.Q(task__enterprise=enterprise)
|
||||
).distinct().order_by('-created_at')
|
||||
else:
|
||||
return TaskInvitation.objects.filter(
|
||||
models.Q(expert=user) | models.Q(task__publisher=user)
|
||||
).distinct().order_by('-created_at')
|
||||
|
||||
def perform_create(self, serializer):
|
||||
task = serializer.validated_data['task']
|
||||
expert = serializer.validated_data['expert']
|
||||
if TaskInvitation.objects.filter(task=task, expert=expert).exists():
|
||||
from rest_framework.exceptions import ValidationError
|
||||
raise ValidationError('已经向该专家发送过邀约')
|
||||
serializer.save()
|
||||
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def accept(self, request, pk=None):
|
||||
invitation = self.get_object()
|
||||
if invitation.status == InvitationStatus.ACCEPTED:
|
||||
return Response({'detail': '您已接受过此邀约'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
task = invitation.task
|
||||
invitation.status = InvitationStatus.ACCEPTED
|
||||
invitation.save()
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
history = []
|
||||
if invitation.message:
|
||||
history.append({
|
||||
'sender_id': str(task.publisher.id),
|
||||
'sender_name': task.publisher.nickname or task.publisher.username,
|
||||
'sender_avatar': task.publisher.avatar_url,
|
||||
'sender_role': 'ENTERPRISE',
|
||||
'content': invitation.message,
|
||||
'created_at': invitation.created_at.isoformat()
|
||||
})
|
||||
|
||||
if isinstance(invitation.messages, list):
|
||||
history.extend(invitation.messages)
|
||||
history.append({
|
||||
'sender_id': str(request.user.id),
|
||||
'sender_name': 'System',
|
||||
'sender_avatar': '',
|
||||
'sender_role': 'SYSTEM',
|
||||
'content': f'【系统记录】专家 {request.user.nickname or request.user.username} 已同意承接并加入了项目',
|
||||
'created_at': timezone.now().isoformat()
|
||||
})
|
||||
|
||||
# Create an application record so the enterprise can track this accepted expert
|
||||
application, created = TaskApplication.objects.get_or_create(
|
||||
task=task,
|
||||
applicant=request.user,
|
||||
defaults={
|
||||
'status': ApplyStatus.APPROVED,
|
||||
'cover_letter': '接受了企业发出的定向邀约',
|
||||
'expected_price': task.budget_max or 0,
|
||||
'expected_days': 0,
|
||||
'negotiation_history': history
|
||||
}
|
||||
)
|
||||
if not created:
|
||||
if application.status != ApplyStatus.APPROVED:
|
||||
application.status = ApplyStatus.APPROVED
|
||||
# If the application existed but was PENDING or something, we should still update its negotiation history
|
||||
application.negotiation_history = history
|
||||
application.save()
|
||||
|
||||
if task.status in [TaskStatus.OPEN, TaskStatus.DRAFT]:
|
||||
task.status = TaskStatus.IN_PROGRESS
|
||||
task.save()
|
||||
|
||||
return Response({'status': '已接受邀约,项目进入进行中状态'})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def reject(self, request, pk=None):
|
||||
invitation = self.get_object()
|
||||
invitation.status = InvitationStatus.REJECTED
|
||||
invitation.save()
|
||||
return Response({'status': '已拒绝邀约'})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def discuss(self, request, pk=None):
|
||||
invitation = self.get_object()
|
||||
user = request.user
|
||||
|
||||
# Verify user is either the expert or enterprise admin/publisher
|
||||
enterprise, role = _get_user_enterprise(user)
|
||||
is_enterprise = False
|
||||
if user == invitation.expert:
|
||||
sender_role = 'EXPERT'
|
||||
elif user == invitation.task.publisher or (enterprise and invitation.task.enterprise == enterprise):
|
||||
sender_role = 'ENTERPRISE'
|
||||
is_enterprise = True
|
||||
else:
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
raise PermissionDenied('无权参与此洽谈')
|
||||
|
||||
content = request.data.get('message', '').strip()
|
||||
if not content:
|
||||
from rest_framework.exceptions import ValidationError
|
||||
raise ValidationError('回复内容不能为空')
|
||||
|
||||
from django.utils import timezone
|
||||
message_obj = {
|
||||
'sender_id': str(user.id),
|
||||
'sender_name': user.nickname or user.username,
|
||||
'sender_avatar': user.avatar_url,
|
||||
'sender_role': sender_role,
|
||||
'content': content,
|
||||
'created_at': timezone.now().isoformat()
|
||||
}
|
||||
|
||||
# Initialize messages if None somehow
|
||||
if not isinstance(invitation.messages, list):
|
||||
invitation.messages = []
|
||||
|
||||
invitation.messages.append(message_obj)
|
||||
|
||||
# Ensure status transitions to DISCUSSING unless already ACCEPTED or REJECTED
|
||||
if invitation.status == InvitationStatus.PENDING:
|
||||
invitation.status = InvitationStatus.DISCUSSING
|
||||
|
||||
invitation.save()
|
||||
return Response({'status': '回复已发送', 'message': message_obj})
|
||||
|
||||
|
||||
def _get_user_enterprise(user):
|
||||
from users.models import EnterpriseMember
|
||||
if hasattr(user, 'enterprise') and user.enterprise:
|
||||
return user.enterprise, 'ADMIN' # Owner is effectively ADMIN
|
||||
membership = EnterpriseMember.objects.filter(user=user).first()
|
||||
if membership:
|
||||
return membership.enterprise, membership.role
|
||||
return None, None
|
||||
|
||||
class TaskViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
@author: xujl
|
||||
Api说明: 主线任务广场接口视图。提供企业发布任务、编辑任务信息,以及用户在大厅分页筛选、搜索开放任务的功能。
|
||||
"""
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
queryset = Task.objects.filter(is_deleted=False).order_by('-is_recommended', '-recommend_priority', '-created_at')
|
||||
|
||||
status_param = self.request.query_params.get('status')
|
||||
if status_param:
|
||||
queryset = queryset.filter(status=status_param)
|
||||
|
||||
enterprise_param = self.request.query_params.get('enterprise')
|
||||
if enterprise_param:
|
||||
queryset = queryset.filter(enterprise_id=enterprise_param)
|
||||
|
||||
# If global admin, show all tasks
|
||||
if user.is_staff or user.is_superuser or 'ADMIN' in getattr(user, 'roles', []):
|
||||
return queryset
|
||||
|
||||
# If enterprise user, show their enterprise's tasks
|
||||
enterprise, role = _get_user_enterprise(user)
|
||||
if enterprise and not enterprise_param:
|
||||
return queryset.filter(enterprise=enterprise)
|
||||
|
||||
# Regular users (OPC experts, etc.) can see all OPEN tasks + tasks they're involved in
|
||||
return queryset
|
||||
|
||||
|
||||
serializer_class = TaskSerializer
|
||||
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
||||
|
||||
def perform_create(self, serializer):
|
||||
user = self.request.user
|
||||
# Admins can publish tasks directly (platform-level tasks)
|
||||
if user.is_staff or user.is_superuser:
|
||||
enterprise, _ = _get_user_enterprise(user)
|
||||
serializer.save(publisher=user, enterprise=enterprise)
|
||||
return
|
||||
enterprise, _ = _get_user_enterprise(user)
|
||||
if not enterprise:
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
raise PermissionDenied('必须关联企业后才能发布任务')
|
||||
if enterprise.status != 'VERIFIED':
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
raise PermissionDenied('企业尚未通过认证,暂无权发布任务')
|
||||
serializer.save(publisher=user, enterprise=enterprise)
|
||||
|
||||
def _check_admin_or_publisher(self, task, user):
|
||||
if user.is_staff or user.is_superuser:
|
||||
return True
|
||||
if 'ADMIN' in getattr(user, 'roles', []):
|
||||
return True
|
||||
if task.publisher == user:
|
||||
return True
|
||||
enterprise, role = _get_user_enterprise(user)
|
||||
if enterprise and task.enterprise == enterprise and role == 'ADMIN':
|
||||
return True
|
||||
return False
|
||||
|
||||
def perform_update(self, serializer):
|
||||
task = self.get_object()
|
||||
if not self._check_admin_or_publisher(task, self.request.user):
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
raise PermissionDenied('无权修改此任务')
|
||||
serializer.save()
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
if not self._check_admin_or_publisher(instance, self.request.user):
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
raise PermissionDenied('无权删除此任务')
|
||||
instance.is_deleted = True
|
||||
instance.save()
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
|
||||
def toggle_recommend(self, request, pk=None):
|
||||
task = self.get_object()
|
||||
task.is_recommended = request.data.get('is_recommended', not task.is_recommended)
|
||||
task.recommend_priority = request.data.get('recommend_priority', task.recommend_priority)
|
||||
task.save(update_fields=['is_recommended', 'recommend_priority'])
|
||||
return Response({'is_recommended': task.is_recommended, 'recommend_priority': task.recommend_priority})
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
|
||||
def publish(self, request, pk=None):
|
||||
task = self.get_object()
|
||||
if not self._check_admin_or_publisher(task, request.user):
|
||||
return Response({'detail': '无权操作'}, status=status.HTTP_403_FORBIDDEN)
|
||||
task.status = TaskStatus.OPEN
|
||||
task.save()
|
||||
return Response({'status': '任务已发布'})
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
|
||||
def cancel_task(self, request, pk=None):
|
||||
task = self.get_object()
|
||||
if not self._check_admin_or_publisher(task, request.user):
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
raise PermissionDenied('无权取消此任务')
|
||||
|
||||
batch_reject = request.data.get('batch_reject', False)
|
||||
# Any application that is PENDING, APPROVED, or DELIVERED should be cancelled.
|
||||
active_apps = task.applications.exclude(status__in=[ApplyStatus.REJECTED, ApplyStatus.COMPLETED, ApplyStatus.WITHDRAWN])
|
||||
|
||||
if active_apps.exists():
|
||||
if not batch_reject:
|
||||
return Response({
|
||||
'detail': '该任务有正在进行中的专家或待审核的申请,禁止直接取消。请选择强制批量中止。',
|
||||
'requires_batch_reject': True
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
else:
|
||||
# Batch reject
|
||||
active_apps.update(status=ApplyStatus.REJECTED, reject_reason='任务已被系统或发布方强制中止/取消')
|
||||
|
||||
task.status = TaskStatus.CANCELLED
|
||||
from django.utils import timezone
|
||||
task.cancelled_at = timezone.now()
|
||||
task.cancel_reason = request.data.get('cancel_reason', '管理员操作下架')
|
||||
task.save()
|
||||
return Response({'status': '任务已下架'})
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
|
||||
def complete_task(self, request, pk=None):
|
||||
task = self.get_object()
|
||||
if not self._check_admin_or_publisher(task, request.user):
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
raise PermissionDenied('无权操作')
|
||||
|
||||
from .models import ApplyStatus, DeliveryStatus
|
||||
|
||||
# Check for experts who haven't delivered
|
||||
working_apps = task.applications.filter(status=ApplyStatus.APPROVED)
|
||||
if working_apps.exists():
|
||||
return Response({'detail': '尚有专家未提交交付物,无法结项。如需结项,请先取消未完成的录用。'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Check for experts who delivered but haven't been approved
|
||||
delivered_apps = task.applications.filter(status=ApplyStatus.DELIVERED)
|
||||
for app in delivered_apps:
|
||||
latest_record = app.delivery_records.order_by('-created_at').first()
|
||||
if not latest_record or latest_record.status != DeliveryStatus.APPROVED:
|
||||
return Response({'detail': '尚有专家的交付成果未验收,无法结项。'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
task.status = TaskStatus.COMPLETED
|
||||
from django.utils import timezone
|
||||
task.completed_at = timezone.now()
|
||||
task.save()
|
||||
|
||||
for app in delivered_apps:
|
||||
app.status = ApplyStatus.COMPLETED
|
||||
app.reviewed_at = timezone.now()
|
||||
app.save()
|
||||
expert = app.applicant
|
||||
if expert:
|
||||
expert.completed_tasks = (expert.completed_tasks or 0) + 1
|
||||
expert.save()
|
||||
|
||||
return Response({'status': '任务已成功结项'})
|
||||
|
||||
class TaskApplicationViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
@author: xujl
|
||||
Api说明: 任务申请投递接口视图。普通用户/专家对感兴趣的任务进行接单投递(包含附件、报价),企业方审批(approve/reject)后即缔结合作。
|
||||
"""
|
||||
queryset = TaskApplication.objects.all()
|
||||
serializer_class = TaskApplicationSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
|
||||
# Admins can see all applications
|
||||
if user.is_staff or user.is_superuser or 'ADMIN' in getattr(user, 'roles', []):
|
||||
qs = TaskApplication.objects.all().order_by('-created_at')
|
||||
else:
|
||||
enterprise, _ = _get_user_enterprise(user)
|
||||
|
||||
if enterprise:
|
||||
# Publishers/Admins see applications for their enterprise's tasks
|
||||
qs = TaskApplication.objects.filter(
|
||||
models.Q(applicant=user) | models.Q(task__enterprise=enterprise)
|
||||
).distinct().order_by('-created_at')
|
||||
else:
|
||||
qs = TaskApplication.objects.filter(
|
||||
models.Q(applicant=user) | models.Q(task__publisher=user)
|
||||
).distinct().order_by('-created_at')
|
||||
|
||||
task_id = self.request.query_params.get('task')
|
||||
if task_id:
|
||||
qs = qs.filter(task_id=task_id)
|
||||
|
||||
return qs
|
||||
|
||||
|
||||
def perform_create(self, serializer):
|
||||
user = self.request.user
|
||||
|
||||
if 'OPC_USER' not in getattr(user, 'roles', []):
|
||||
# Check db if property is missing
|
||||
from users.models import UserRole
|
||||
if not UserRole.objects.filter(user=user, role__code='OPC_USER').exists():
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
raise PermissionDenied('必须是已认证的 OPC 专家才能承接任务')
|
||||
|
||||
from opc_cert.models import OpcCertification
|
||||
cert = OpcCertification.objects.filter(user=user, status='APPROVED').exists()
|
||||
if not cert:
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
raise PermissionDenied('您的 OPC 专家认证未通过审核,无法承接任务')
|
||||
|
||||
serializer.save(applicant=user)
|
||||
|
||||
def _check_admin_or_publisher(self, task, user):
|
||||
if user.is_staff or user.is_superuser:
|
||||
return True
|
||||
if 'ADMIN' in getattr(user, 'roles', []):
|
||||
return True
|
||||
if task.publisher == user:
|
||||
return True
|
||||
enterprise, role = _get_user_enterprise(user)
|
||||
if enterprise and task.enterprise == enterprise:
|
||||
return True
|
||||
return False
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
|
||||
def approve(self, request, pk=None):
|
||||
application = self.get_object()
|
||||
if not self._check_admin_or_publisher(application.task, request.user):
|
||||
return Response({'detail': '无权操作'}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
application.status = ApplyStatus.APPROVED
|
||||
application.reviewed_by = request.user
|
||||
application.save()
|
||||
|
||||
task = application.task
|
||||
if task.status in [TaskStatus.OPEN, TaskStatus.DRAFT]:
|
||||
task.status = TaskStatus.IN_PROGRESS
|
||||
task.save()
|
||||
|
||||
return Response({'status': '申请已通过,专家可开始执行'})
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
|
||||
def reject(self, request, pk=None):
|
||||
application = self.get_object()
|
||||
if not self._check_admin_or_publisher(application.task, request.user):
|
||||
return Response({'detail': '无权操作'}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
application.status = ApplyStatus.REJECTED
|
||||
application.reject_reason = request.data.get('reason', '')
|
||||
application.reviewed_by = request.user
|
||||
from django.utils import timezone
|
||||
application.reviewed_at = timezone.now()
|
||||
application.save()
|
||||
|
||||
return Response({'status': '申请已驳回'})
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
|
||||
def submit_deliverables(self, request, pk=None):
|
||||
application = self.get_object()
|
||||
if application.applicant != request.user:
|
||||
return Response({'detail': '只有承接人自己才能提交成果'}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
deliverables = request.data.get('deliverables', [])
|
||||
completion_note = request.data.get('completion_note', '')
|
||||
|
||||
from .models import DeliveryRecord, DeliveryStatus
|
||||
DeliveryRecord.objects.create(
|
||||
application=application,
|
||||
note=completion_note,
|
||||
files=deliverables,
|
||||
status=DeliveryStatus.PENDING
|
||||
)
|
||||
|
||||
application.status = ApplyStatus.DELIVERED
|
||||
application.save()
|
||||
return Response({'status': '成果已提交,等待企业验收'})
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
|
||||
def approve_delivery(self, request, pk=None):
|
||||
application = self.get_object()
|
||||
if not self._check_admin_or_publisher(application.task, request.user):
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
raise PermissionDenied('无权验收此成果')
|
||||
|
||||
record_id = request.data.get('record_id')
|
||||
from .models import DeliveryRecord, DeliveryStatus
|
||||
|
||||
if record_id:
|
||||
record = application.delivery_records.filter(id=record_id).first()
|
||||
else:
|
||||
record = application.delivery_records.filter(status=DeliveryStatus.PENDING).first()
|
||||
|
||||
if not record:
|
||||
return Response({'detail': '找不到待验收的交付记录'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
record.status = DeliveryStatus.APPROVED
|
||||
record.save()
|
||||
|
||||
# Note: We do NOT change application.status to COMPLETED here.
|
||||
# It remains DELIVERED until the enterprise explicitly closes the task via complete_task.
|
||||
# This ensures the expert's side doesn't show as fully completed before the project is officially wrapped up.
|
||||
|
||||
return Response({'status': '该专家的成果已验收通过'})
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
|
||||
def reject_delivery(self, request, pk=None):
|
||||
application = self.get_object()
|
||||
if not self._check_admin_or_publisher(application.task, request.user):
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
raise PermissionDenied('无权操作')
|
||||
|
||||
record_id = request.data.get('record_id')
|
||||
from .models import DeliveryRecord, DeliveryStatus
|
||||
if record_id:
|
||||
record = application.delivery_records.filter(id=record_id).first()
|
||||
else:
|
||||
record = application.delivery_records.filter(status=DeliveryStatus.PENDING).first()
|
||||
|
||||
if not record:
|
||||
return Response({'detail': '找不到待驳回的交付记录'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
record.status = DeliveryStatus.REJECTED
|
||||
record.save()
|
||||
|
||||
# If all records are rejected, application goes back to APPROVED (IN_PROGRESS)
|
||||
if not application.delivery_records.filter(status=DeliveryStatus.PENDING).exists():
|
||||
application.status = ApplyStatus.APPROVED
|
||||
application.save()
|
||||
|
||||
return Response({'status': '该次交付已驳回'})
|
||||
0
users/__init__.py
Normal file
0
users/__init__.py
Normal file
3
users/admin.py
Normal file
3
users/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
6
users/apps.py
Normal file
6
users/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class UsersConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'users'
|
||||
126
users/migrations/0001_initial.py
Normal file
126
users/migrations/0001_initial.py
Normal file
@@ -0,0 +1,126 @@
|
||||
# Generated by Django 4.2.30 on 2026-04-25 09:46
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('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')),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('username', models.CharField(max_length=64, unique=True)),
|
||||
('phone', models.CharField(blank=True, max_length=20, null=True, unique=True)),
|
||||
('email', models.EmailField(blank=True, max_length=128, null=True, unique=True)),
|
||||
('nickname', models.CharField(blank=True, max_length=64, null=True)),
|
||||
('avatar_url', models.CharField(blank=True, max_length=512, null=True)),
|
||||
('face_url', models.CharField(blank=True, max_length=512, null=True)),
|
||||
('wx_openid', models.CharField(blank=True, max_length=128, null=True, unique=True)),
|
||||
('wx_unionid', models.CharField(blank=True, max_length=128, null=True, unique=True)),
|
||||
('status', models.CharField(choices=[('ACTIVE', '正常'), ('INACTIVE', '未激活'), ('BANNED', '封禁')], default='ACTIVE', max_length=16)),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('is_staff', models.BooleanField(default=False)),
|
||||
('groups', 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')),
|
||||
('user_permissions', 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')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'users',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Permission',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('code', models.CharField(max_length=128, unique=True)),
|
||||
('name', models.CharField(max_length=128)),
|
||||
('type', models.CharField(choices=[('MENU', '菜单'), ('BUTTON', '按钮'), ('API', '接口')], max_length=16)),
|
||||
('path', models.CharField(blank=True, max_length=256, null=True)),
|
||||
('method', models.CharField(blank=True, max_length=16, null=True)),
|
||||
('sort_order', models.IntegerField(default=0)),
|
||||
('icon', models.CharField(blank=True, max_length=64, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='users.permission')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'permissions',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Role',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('code', models.CharField(max_length=64, unique=True)),
|
||||
('name', models.CharField(max_length=64)),
|
||||
('description', models.TextField(blank=True, null=True)),
|
||||
('is_system', models.BooleanField(default=False)),
|
||||
('sort_order', models.IntegerField(default=0)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'roles',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Enterprise',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('company_name', models.CharField(max_length=256)),
|
||||
('business_license', models.CharField(blank=True, max_length=512, null=True)),
|
||||
('contact_name', models.CharField(blank=True, max_length=64, null=True)),
|
||||
('contact_phone', models.CharField(blank=True, max_length=20, null=True)),
|
||||
('contact_email', models.CharField(blank=True, max_length=128, null=True)),
|
||||
('address', models.TextField(blank=True, null=True)),
|
||||
('description', models.TextField(blank=True, null=True)),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'enterprises',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='UserRole',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('granted_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='granted_roles', to=settings.AUTH_USER_MODEL)),
|
||||
('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='users.role')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_roles', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'user_roles',
|
||||
'unique_together': {('user', 'role')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RolePermission',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('permission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='users.permission')),
|
||||
('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='role_permissions', to='users.role')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'role_permissions',
|
||||
'unique_together': {('role', 'permission')},
|
||||
},
|
||||
),
|
||||
]
|
||||
18
users/migrations/0002_enterprise_credit_code.py
Normal file
18
users/migrations/0002_enterprise_credit_code.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.30 on 2026-04-25 10:30
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='enterprise',
|
||||
name='credit_code',
|
||||
field=models.CharField(blank=True, max_length=64, null=True),
|
||||
),
|
||||
]
|
||||
23
users/migrations/0003_user_bio_user_location.py
Normal file
23
users/migrations/0003_user_bio_user_location.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.2.30 on 2026-04-25 13:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0002_enterprise_credit_code'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='bio',
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='location',
|
||||
field=models.CharField(blank=True, max_length=128, null=True),
|
||||
),
|
||||
]
|
||||
18
users/migrations/0004_alter_enterprise_credit_code.py
Normal file
18
users/migrations/0004_alter_enterprise_credit_code.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.30 on 2026-04-25 13:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0003_user_bio_user_location'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='enterprise',
|
||||
name='credit_code',
|
||||
field=models.CharField(blank=True, max_length=64, null=True, unique=True),
|
||||
),
|
||||
]
|
||||
30
users/migrations/0005_enterprisemember.py
Normal file
30
users/migrations/0005_enterprisemember.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 4.2.30 on 2026-04-25 14:48
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0004_alter_enterprise_credit_code'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='EnterpriseMember',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('role', models.CharField(choices=[('ADMIN', '管理员'), ('MEMBER', '普通成员'), ('GUEST', '外部观察员')], default='MEMBER', max_length=16)),
|
||||
('joined_at', models.DateTimeField(auto_now_add=True)),
|
||||
('enterprise', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='members', to='users.enterprise')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='enterprise_memberships', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'enterprise_members',
|
||||
'unique_together': {('enterprise', 'user')},
|
||||
},
|
||||
),
|
||||
]
|
||||
23
users/migrations/0006_user_completed_tasks_user_rating.py
Normal file
23
users/migrations/0006_user_completed_tasks_user_rating.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.2.30 on 2026-04-25 14:51
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0005_enterprisemember'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='completed_tasks',
|
||||
field=models.IntegerField(default=0),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='rating',
|
||||
field=models.DecimalField(decimal_places=2, default=5.0, max_digits=3),
|
||||
),
|
||||
]
|
||||
18
users/migrations/0007_enterprise_status.py
Normal file
18
users/migrations/0007_enterprise_status.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.30 on 2026-04-25 14:53
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0006_user_completed_tasks_user_rating'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='enterprise',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('PENDING', '待审核'), ('VERIFIED', '已认证'), ('REJECTED', '已拒绝')], default='PENDING', max_length=16),
|
||||
),
|
||||
]
|
||||
18
users/migrations/0008_enterprise_logo_url.py
Normal file
18
users/migrations/0008_enterprise_logo_url.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.30 on 2026-04-26 13:02
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0007_enterprise_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='enterprise',
|
||||
name='logo_url',
|
||||
field=models.CharField(blank=True, max_length=512, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.2.30 on 2026-04-27 17:30
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0008_enterprise_logo_url'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='is_recommended',
|
||||
field=models.BooleanField(default=False, help_text='管理员推荐'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='recommend_priority',
|
||||
field=models.IntegerField(default=0, help_text='推荐优先级, 越大越靠前'),
|
||||
),
|
||||
]
|
||||
18
users/migrations/0010_enterprise_landline.py
Normal file
18
users/migrations/0010_enterprise_landline.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.30 on 2026-04-27 18:06
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0009_user_is_recommended_user_recommend_priority'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='enterprise',
|
||||
name='landline',
|
||||
field=models.CharField(blank=True, help_text='座机号码', max_length=32, null=True),
|
||||
),
|
||||
]
|
||||
0
users/migrations/__init__.py
Normal file
0
users/migrations/__init__.py
Normal file
160
users/models.py
Normal file
160
users/models.py
Normal file
@@ -0,0 +1,160 @@
|
||||
import uuid
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin
|
||||
|
||||
class UserStatus(models.TextChoices):
|
||||
ACTIVE = 'ACTIVE', '正常'
|
||||
INACTIVE = 'INACTIVE', '未激活'
|
||||
BANNED = 'BANNED', '封禁'
|
||||
|
||||
class CustomUserManager(BaseUserManager):
|
||||
def create_user(self, username, phone=None, password=None, **extra_fields):
|
||||
if not username:
|
||||
raise ValueError('The Username field must be set')
|
||||
user = self.model(username=username, phone=phone, **extra_fields)
|
||||
if password:
|
||||
user.set_password(password)
|
||||
user.save(using=self._db)
|
||||
return user
|
||||
|
||||
def create_superuser(self, username, phone=None, password=None, **extra_fields):
|
||||
extra_fields.setdefault('is_staff', True)
|
||||
extra_fields.setdefault('is_superuser', True)
|
||||
return self.create_user(username, phone, password, **extra_fields)
|
||||
|
||||
class User(AbstractBaseUser, PermissionsMixin):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
username = models.CharField(max_length=64, unique=True)
|
||||
phone = models.CharField(max_length=20, unique=True, null=True, blank=True)
|
||||
email = models.EmailField(max_length=128, unique=True, null=True, blank=True)
|
||||
nickname = models.CharField(max_length=64, null=True, blank=True)
|
||||
avatar_url = models.CharField(max_length=512, null=True, blank=True)
|
||||
face_url = models.CharField(max_length=512, null=True, blank=True)
|
||||
wx_openid = models.CharField(max_length=128, unique=True, null=True, blank=True)
|
||||
wx_unionid = models.CharField(max_length=128, unique=True, null=True, blank=True)
|
||||
bio = models.TextField(null=True, blank=True)
|
||||
location = models.CharField(max_length=128, null=True, blank=True)
|
||||
rating = models.DecimalField(max_digits=3, decimal_places=2, default=5.00)
|
||||
completed_tasks = models.IntegerField(default=0)
|
||||
|
||||
status = models.CharField(max_length=16, choices=UserStatus.choices, default=UserStatus.ACTIVE)
|
||||
|
||||
is_deleted = models.BooleanField(default=False)
|
||||
is_recommended = models.BooleanField(default=False, help_text='管理员推荐')
|
||||
recommend_priority = models.IntegerField(default=0, help_text='推荐优先级, 越大越靠前')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
is_active = models.BooleanField(default=True)
|
||||
is_staff = models.BooleanField(default=False)
|
||||
|
||||
objects = CustomUserManager()
|
||||
|
||||
@property
|
||||
def roles(self):
|
||||
return list(self.user_roles.values_list('role__code', flat=True))
|
||||
|
||||
USERNAME_FIELD = 'username'
|
||||
|
||||
REQUIRED_FIELDS = ['phone']
|
||||
|
||||
class Meta:
|
||||
db_table = 'users'
|
||||
|
||||
class Role(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
code = models.CharField(max_length=64, unique=True)
|
||||
name = models.CharField(max_length=64)
|
||||
description = models.TextField(null=True, blank=True)
|
||||
is_system = models.BooleanField(default=False)
|
||||
sort_order = models.IntegerField(default=0)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'roles'
|
||||
|
||||
class PermissionType(models.TextChoices):
|
||||
MENU = 'MENU', '菜单'
|
||||
BUTTON = 'BUTTON', '按钮'
|
||||
API = 'API', '接口'
|
||||
|
||||
class Permission(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
code = models.CharField(max_length=128, unique=True)
|
||||
name = models.CharField(max_length=128)
|
||||
type = models.CharField(max_length=16, choices=PermissionType.choices)
|
||||
path = models.CharField(max_length=256, null=True, blank=True)
|
||||
method = models.CharField(max_length=16, null=True, blank=True)
|
||||
parent = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True)
|
||||
sort_order = models.IntegerField(default=0)
|
||||
icon = models.CharField(max_length=64, null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'permissions'
|
||||
|
||||
class UserRole(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='user_roles')
|
||||
role = models.ForeignKey(Role, on_delete=models.CASCADE)
|
||||
granted_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='granted_roles')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'user_roles'
|
||||
unique_together = ('user', 'role')
|
||||
|
||||
class RolePermission(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
role = models.ForeignKey(Role, on_delete=models.CASCADE, related_name='role_permissions')
|
||||
permission = models.ForeignKey(Permission, on_delete=models.CASCADE)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'role_permissions'
|
||||
unique_together = ('role', 'permission')
|
||||
|
||||
class Enterprise(models.Model):
|
||||
class EnterpriseStatus(models.TextChoices):
|
||||
PENDING = 'PENDING', '待审核'
|
||||
VERIFIED = 'VERIFIED', '已认证'
|
||||
REJECTED = 'REJECTED', '已拒绝'
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE)
|
||||
company_name = models.CharField(max_length=256)
|
||||
credit_code = models.CharField(max_length=64, unique=True, null=True, blank=True)
|
||||
status = models.CharField(max_length=16, choices=EnterpriseStatus.choices, default=EnterpriseStatus.PENDING)
|
||||
|
||||
logo_url = models.CharField(max_length=512, null=True, blank=True)
|
||||
|
||||
business_license = models.CharField(max_length=512, null=True, blank=True)
|
||||
contact_name = models.CharField(max_length=64, null=True, blank=True)
|
||||
contact_phone = models.CharField(max_length=20, null=True, blank=True)
|
||||
landline = models.CharField(max_length=32, null=True, blank=True, help_text='座机号码')
|
||||
contact_email = models.CharField(max_length=128, null=True, blank=True)
|
||||
address = models.TextField(null=True, blank=True)
|
||||
description = models.TextField(null=True, blank=True)
|
||||
is_deleted = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'enterprises'
|
||||
|
||||
class EnterpriseMember(models.Model):
|
||||
class MemberRole(models.TextChoices):
|
||||
ADMIN = 'ADMIN', '管理员'
|
||||
MEMBER = 'MEMBER', '普通成员'
|
||||
GUEST = 'GUEST', '外部观察员'
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
enterprise = models.ForeignKey(Enterprise, on_delete=models.CASCADE, related_name='members')
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='enterprise_memberships')
|
||||
role = models.CharField(max_length=16, choices=MemberRole.choices, default=MemberRole.MEMBER)
|
||||
joined_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'enterprise_members'
|
||||
unique_together = ('enterprise', 'user')
|
||||
|
||||
31
users/permissions.py
Normal file
31
users/permissions.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from rest_framework import permissions
|
||||
from .models import RolePermission
|
||||
|
||||
class HasAPIPermission(permissions.BasePermission):
|
||||
"""
|
||||
Checks if the user has the specific API permission required by the view.
|
||||
The view must define `required_permission = 'api:something'` or a dictionary mapping methods to permissions:
|
||||
`required_permissions = {'GET': 'api:read', 'POST': 'api:write'}`
|
||||
"""
|
||||
def has_permission(self, request, view):
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
if getattr(request.user, 'is_superuser', False):
|
||||
return True
|
||||
|
||||
method = request.method
|
||||
required_perm = None
|
||||
|
||||
if hasattr(view, 'required_permissions') and isinstance(view.required_permissions, dict):
|
||||
required_perm = view.required_permissions.get(method)
|
||||
elif hasattr(view, 'required_permission'):
|
||||
required_perm = view.required_permission
|
||||
|
||||
if not required_perm:
|
||||
# If no permission is required, default to IsAdminUser logic for safety,
|
||||
# or True if you want it open. Let's require staff status by default for admin views.
|
||||
return request.user.is_staff
|
||||
|
||||
user_perms = RolePermission.objects.filter(role__userrole__user=request.user).values_list('permission__code', flat=True)
|
||||
return required_perm in user_perms
|
||||
438
users/serializers.py
Normal file
438
users/serializers.py
Normal file
@@ -0,0 +1,438 @@
|
||||
from rest_framework import serializers
|
||||
from .models import User, Enterprise, Role, EnterpriseMember, Permission
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
roles = serializers.ReadOnlyField()
|
||||
permissions = serializers.SerializerMethodField()
|
||||
opc_certification = serializers.SerializerMethodField()
|
||||
enterprise_info = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['id', 'username', 'phone', 'email', 'nickname', 'avatar_url', 'face_url', 'bio', 'location', 'status', 'is_active', 'created_at', 'roles', 'permissions', 'is_staff', 'is_superuser', 'rating', 'completed_tasks', 'opc_certification', 'enterprise_info', 'is_recommended', 'recommend_priority']
|
||||
read_only_fields = ['id', 'status', 'is_active', 'created_at', 'roles', 'permissions', 'is_staff', 'is_superuser']
|
||||
|
||||
def get_enterprise_info(self, obj):
|
||||
if 'ENTERPRISE' in obj.roles:
|
||||
from .models import Enterprise, EnterpriseMember
|
||||
ent = Enterprise.objects.filter(user=obj).first()
|
||||
if ent:
|
||||
return {'company_name': ent.company_name, 'role': 'OWNER'}
|
||||
member = EnterpriseMember.objects.filter(user=obj).first()
|
||||
if member:
|
||||
return {'company_name': member.enterprise.company_name, 'role': member.role}
|
||||
return None
|
||||
|
||||
def get_opc_certification(self, obj):
|
||||
if 'OPC_USER' in obj.roles:
|
||||
from opc_cert.models import OpcCertification
|
||||
from opc_cert.serializers import OpcCertificationSerializer
|
||||
cert = OpcCertification.objects.filter(user=obj, status='APPROVED').first()
|
||||
if cert:
|
||||
return OpcCertificationSerializer(cert).data
|
||||
return None
|
||||
|
||||
def get_permissions(self, obj):
|
||||
if obj.is_superuser:
|
||||
return ['*']
|
||||
from .models import RolePermission
|
||||
perms = RolePermission.objects.filter(role__userrole__user=obj).values_list('permission__code', flat=True).distinct()
|
||||
return list(perms)
|
||||
|
||||
def validate(self, attrs):
|
||||
# Normalize empty strings to None to avoid unique constraint issues
|
||||
for field in ['phone', 'email']:
|
||||
if field in attrs and attrs[field] == '':
|
||||
attrs[field] = None
|
||||
return attrs
|
||||
|
||||
class RoleSerializer(serializers.ModelSerializer):
|
||||
permission_ids = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Role
|
||||
fields = '__all__'
|
||||
|
||||
def get_permission_ids(self, obj):
|
||||
return list(obj.role_permissions.values_list('permission_id', flat=True).distinct().values_list('permission_id', flat=True))
|
||||
|
||||
def to_representation(self, instance):
|
||||
data = super().to_representation(instance)
|
||||
# Ensure permission_ids are strings for frontend comparison
|
||||
data['permission_ids'] = [str(pid) for pid in data.get('permission_ids', [])]
|
||||
return data
|
||||
|
||||
class PermissionSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Permission
|
||||
fields = '__all__'
|
||||
|
||||
class EnterpriseMemberSerializer(serializers.ModelSerializer):
|
||||
user_details = UserSerializer(source='user', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = EnterpriseMember
|
||||
fields = ['id', 'user', 'user_details', 'role', 'joined_at']
|
||||
read_only_fields = ['id', 'joined_at']
|
||||
|
||||
|
||||
|
||||
import re
|
||||
from django.core.validators import EmailValidator
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
class RegisterSerializer(serializers.ModelSerializer):
|
||||
password = serializers.CharField(write_only=True, min_length=6)
|
||||
|
||||
username = serializers.CharField(
|
||||
error_messages={
|
||||
'unique': '该用户名已被占用,请尝试其他名称'
|
||||
}
|
||||
)
|
||||
phone = serializers.CharField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
error_messages={
|
||||
'unique': '该手机号已注册,请直接登录'
|
||||
}
|
||||
)
|
||||
email = serializers.EmailField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
error_messages={
|
||||
'unique': '该邮箱已注册,请尝试其他邮箱'
|
||||
}
|
||||
)
|
||||
avatar_url = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
class Meta:
|
||||
|
||||
|
||||
model = User
|
||||
fields = ['username', 'phone', 'email', 'password', 'nickname', 'avatar_url']
|
||||
|
||||
def validate_phone(self, value):
|
||||
if value:
|
||||
if not re.match(r'^1[3-9]\d{9}$', value):
|
||||
raise ValidationError('请输入有效的11位手机号')
|
||||
if User.objects.filter(phone=value, is_deleted=False).exists():
|
||||
raise ValidationError('该手机号已注册,请直接登录')
|
||||
return value
|
||||
|
||||
def validate_email(self, value):
|
||||
if value:
|
||||
if User.objects.filter(email=value, is_deleted=False).exists():
|
||||
raise ValidationError('该邮箱已注册,请尝试其他邮箱')
|
||||
return value
|
||||
|
||||
def validate_username(self, value):
|
||||
if User.objects.filter(username=value, is_deleted=False).exists():
|
||||
raise ValidationError('该用户名已被占用,请尝试其他名称')
|
||||
return value
|
||||
|
||||
def create(self, validated_data):
|
||||
phone = validated_data.get('phone')
|
||||
if phone == '':
|
||||
phone = None
|
||||
email = validated_data.get('email')
|
||||
if email == '':
|
||||
email = None
|
||||
|
||||
user = User.objects.create_user(
|
||||
username=validated_data['username'],
|
||||
phone=phone,
|
||||
email=email,
|
||||
password=validated_data['password'],
|
||||
nickname=validated_data.get('nickname'),
|
||||
avatar_url=validated_data.get('avatar_url', '')
|
||||
)
|
||||
# Auto-assign USER role
|
||||
from .models import Role, UserRole
|
||||
user_role, _ = Role.objects.get_or_create(code='USER', defaults={'name': '普通用户'})
|
||||
UserRole.objects.get_or_create(user=user, role=user_role)
|
||||
return user
|
||||
|
||||
class AdminUserCreateSerializer(serializers.ModelSerializer):
|
||||
password = serializers.CharField(write_only=True, min_length=6)
|
||||
email = serializers.EmailField(required=False, allow_blank=True)
|
||||
username = serializers.CharField(
|
||||
error_messages={'unique': '该用户名已被占用'}
|
||||
)
|
||||
phone = serializers.CharField(
|
||||
required=False, allow_blank=True,
|
||||
error_messages={'unique': '该手机号已注册'}
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['username', 'phone', 'email', 'password', 'nickname']
|
||||
|
||||
def validate_phone(self, value):
|
||||
if value:
|
||||
if not re.match(r'^1[3-9]\d{9}$', value):
|
||||
raise ValidationError('请输入有效的11位手机号')
|
||||
if User.objects.filter(phone=value).exists():
|
||||
raise ValidationError('该手机号已注册')
|
||||
return value
|
||||
|
||||
def validate_email(self, value):
|
||||
if value:
|
||||
if User.objects.filter(email=value).exists():
|
||||
raise ValidationError('该邮箱已注册')
|
||||
return value
|
||||
|
||||
def create(self, validated_data):
|
||||
phone = validated_data.get('phone')
|
||||
if phone == '':
|
||||
phone = None
|
||||
|
||||
user = User.objects.create_user(
|
||||
username=validated_data['username'],
|
||||
phone=phone,
|
||||
email=validated_data.get('email'),
|
||||
password=validated_data['password'],
|
||||
nickname=validated_data.get('nickname')
|
||||
)
|
||||
# Auto-assign USER role
|
||||
from .models import Role, UserRole
|
||||
user_role, _ = Role.objects.get_or_create(code='USER', defaults={'name': '普通用户'})
|
||||
UserRole.objects.get_or_create(user=user, role=user_role)
|
||||
return user
|
||||
|
||||
|
||||
|
||||
class EnterpriseSerializer(serializers.ModelSerializer):
|
||||
user_details = UserSerializer(source='user', read_only=True)
|
||||
credit_code = serializers.CharField(
|
||||
error_messages={
|
||||
'unique': '该统一社会信用代码已注册,请核对信息'
|
||||
}
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
model = Enterprise
|
||||
fields = ['id', 'user', 'user_details', 'company_name', 'credit_code', 'business_license', 'contact_name', 'contact_phone', 'landline', 'contact_email', 'address', 'description', 'status', 'logo_url', 'created_at', 'updated_at']
|
||||
read_only_fields = ['id', 'user', 'user_details', 'status', 'created_at', 'updated_at']
|
||||
|
||||
|
||||
def validate_credit_code(self, value):
|
||||
if value:
|
||||
if not re.match(r'^[A-Z0-9]{18}$', value):
|
||||
raise ValidationError('请输入有效的18位统一社会信用代码')
|
||||
return value
|
||||
|
||||
|
||||
from django.db import transaction
|
||||
|
||||
class EnterpriseRegisterSerializer(serializers.Serializer):
|
||||
email = serializers.EmailField()
|
||||
password = serializers.CharField(write_only=True, min_length=6)
|
||||
company_name = serializers.CharField(max_length=255)
|
||||
credit_code = serializers.CharField(max_length=18)
|
||||
business_license = serializers.CharField(required=False, allow_blank=True)
|
||||
logo_url = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
def validate_credit_code(self, value):
|
||||
if not re.match(r'^[A-Z0-9]{18}$', value):
|
||||
raise ValidationError('请输入有效的18位统一社会信用代码')
|
||||
if Enterprise.objects.filter(credit_code=value).exists():
|
||||
raise ValidationError('该社会信用代码已注册')
|
||||
return value
|
||||
|
||||
def validate_email(self, value):
|
||||
if User.objects.filter(email=value).exists():
|
||||
raise ValidationError('该邮箱已注册')
|
||||
return value
|
||||
|
||||
def create(self, validated_data):
|
||||
with transaction.atomic():
|
||||
# 1. Create User
|
||||
user = User.objects.create_user(
|
||||
username=validated_data['email'],
|
||||
email=validated_data['email'],
|
||||
password=validated_data['password'],
|
||||
nickname=validated_data['company_name'],
|
||||
avatar_url=validated_data.get('logo_url')
|
||||
)
|
||||
|
||||
# 2. Create Enterprise
|
||||
enterprise = Enterprise.objects.create(
|
||||
user=user,
|
||||
company_name=validated_data['company_name'],
|
||||
credit_code=validated_data['credit_code'],
|
||||
business_license=validated_data.get('business_license', ''),
|
||||
logo_url=validated_data.get('logo_url')
|
||||
)
|
||||
|
||||
# 3. Assign Role
|
||||
from .models import Role, UserRole
|
||||
role, created = Role.objects.get_or_create(code='ENTERPRISE', defaults={'name': '企业用户'})
|
||||
UserRole.objects.get_or_create(user=user, role=role)
|
||||
|
||||
return enterprise
|
||||
|
||||
class AdminEnterpriseCreateSerializer(serializers.ModelSerializer):
|
||||
email = serializers.EmailField(write_only=True)
|
||||
password = serializers.CharField(write_only=True, min_length=6)
|
||||
|
||||
class Meta:
|
||||
model = Enterprise
|
||||
fields = ['email', 'password', 'company_name', 'credit_code', 'business_license', 'contact_name', 'contact_phone', 'contact_email', 'address', 'description', 'logo_url']
|
||||
|
||||
def validate_credit_code(self, value):
|
||||
if value:
|
||||
if not re.match(r'^[A-Z0-9]{18}$', value):
|
||||
raise ValidationError('请输入有效的18位统一社会信用代码')
|
||||
if Enterprise.objects.filter(credit_code=value).exists():
|
||||
raise ValidationError('该社会信用代码已注册')
|
||||
return value
|
||||
|
||||
def validate_email(self, value):
|
||||
if User.objects.filter(email=value).exists():
|
||||
raise ValidationError('该邮箱已注册,无法创建企业账号')
|
||||
return value
|
||||
|
||||
def create(self, validated_data):
|
||||
email = validated_data.pop('email')
|
||||
password = validated_data.pop('password')
|
||||
|
||||
with transaction.atomic():
|
||||
user = User.objects.create_user(
|
||||
username=email,
|
||||
email=email,
|
||||
password=password,
|
||||
nickname=validated_data['company_name'],
|
||||
avatar_url=validated_data.get('logo_url')
|
||||
)
|
||||
|
||||
enterprise = Enterprise.objects.create(
|
||||
user=user,
|
||||
status='VERIFIED', # Admins create pre-verified enterprises
|
||||
**validated_data
|
||||
)
|
||||
|
||||
from .models import Role, UserRole
|
||||
role, _ = Role.objects.get_or_create(code='ENTERPRISE', defaults={'name': '企业用户'})
|
||||
UserRole.objects.get_or_create(user=user, role=role)
|
||||
|
||||
return enterprise
|
||||
|
||||
from django.contrib.auth import authenticate
|
||||
|
||||
class EnterpriseLoginSerializer(serializers.Serializer):
|
||||
login_type = serializers.ChoiceField(choices=['ADMIN', 'EMPLOYEE'], default='ADMIN')
|
||||
enterprise_email = serializers.EmailField()
|
||||
username = serializers.CharField(required=False, allow_blank=True)
|
||||
password = serializers.CharField(write_only=True)
|
||||
|
||||
def validate(self, data):
|
||||
login_type = data.get('login_type', 'ADMIN')
|
||||
enterprise_email = data.get('enterprise_email')
|
||||
username = data.get('username')
|
||||
password = data.get('password')
|
||||
|
||||
# 1. Find enterprise
|
||||
try:
|
||||
enterprise_user = User.objects.get(email=enterprise_email)
|
||||
enterprise = Enterprise.objects.get(user=enterprise_user)
|
||||
except (User.DoesNotExist, Enterprise.DoesNotExist):
|
||||
raise ValidationError({'detail': '企业邮箱未注册'})
|
||||
|
||||
if login_type == 'ADMIN':
|
||||
# 2. Find and authenticate the admin
|
||||
if not enterprise_user.is_active or enterprise_user.is_deleted:
|
||||
raise ValidationError({'detail': '您的账号已被禁用,可能因为违规操作或安全风险。如有疑问请联系平台管理员。'})
|
||||
if not enterprise_user.check_password(password):
|
||||
raise ValidationError({'detail': '管理员邮箱或密码错误'})
|
||||
|
||||
# Check if user is the enterprise owner
|
||||
user = enterprise_user
|
||||
|
||||
else:
|
||||
# EMPLOYEE login
|
||||
if not username:
|
||||
raise ValidationError({'detail': '员工账号不能为空'})
|
||||
|
||||
namespaced_username = f"{enterprise_email}_{username}"
|
||||
user = User.objects.filter(username=namespaced_username).first()
|
||||
|
||||
if not user:
|
||||
raise ValidationError({'detail': '员工账号未注册'})
|
||||
if not user.is_active or user.is_deleted:
|
||||
raise ValidationError({'detail': '您的账号已被禁用,可能因为违规操作或安全风险。如有疑问请联系平台管理员。'})
|
||||
if not user.check_password(password):
|
||||
raise ValidationError({'detail': '员工账号或密码错误'})
|
||||
|
||||
# Check if user is a member of the enterprise
|
||||
if not EnterpriseMember.objects.filter(enterprise=enterprise, user=user).exists():
|
||||
raise ValidationError({'detail': '该员工不属于此企业团队'})
|
||||
|
||||
data['user'] = user
|
||||
return data
|
||||
|
||||
class EnterpriseMemberAddSerializer(serializers.Serializer):
|
||||
username = serializers.CharField()
|
||||
password = serializers.CharField(write_only=True, min_length=6)
|
||||
nickname = serializers.CharField(required=False, allow_blank=True)
|
||||
role = serializers.ChoiceField(choices=EnterpriseMember.MemberRole.choices, default=EnterpriseMember.MemberRole.MEMBER)
|
||||
enterprise_id = serializers.UUIDField(required=False)
|
||||
|
||||
def _get_enterprise(self):
|
||||
request_user = self.context['request'].user
|
||||
enterprise_id = self.initial_data.get('enterprise_id')
|
||||
|
||||
if enterprise_id and (request_user.is_staff or request_user.is_superuser):
|
||||
try:
|
||||
return Enterprise.objects.get(id=enterprise_id)
|
||||
except Enterprise.DoesNotExist:
|
||||
raise ValidationError('指定的企业不存在')
|
||||
|
||||
try:
|
||||
return Enterprise.objects.get(user=request_user)
|
||||
except Enterprise.DoesNotExist:
|
||||
raise ValidationError('当前用户没有关联的企业主体')
|
||||
|
||||
def validate_username(self, value):
|
||||
enterprise = self._get_enterprise()
|
||||
|
||||
namespaced_username = f"{enterprise.user.email}_{value}"
|
||||
if User.objects.filter(username=namespaced_username).exists():
|
||||
raise ValidationError('该用户名已被占用')
|
||||
return value
|
||||
|
||||
def create(self, validated_data):
|
||||
enterprise = self._get_enterprise()
|
||||
|
||||
with transaction.atomic():
|
||||
namespaced_username = f"{enterprise.user.email}_{validated_data['username']}"
|
||||
user = User.objects.create_user(
|
||||
username=namespaced_username,
|
||||
password=validated_data['password'],
|
||||
nickname=validated_data.get('nickname', validated_data['username'])
|
||||
)
|
||||
|
||||
# Assign ENTERPRISE role so they can access enterprise portal
|
||||
from .models import Role, UserRole
|
||||
role, _ = Role.objects.get_or_create(code='ENTERPRISE', defaults={'name': '企业用户'})
|
||||
UserRole.objects.get_or_create(user=user, role=role)
|
||||
|
||||
member = EnterpriseMember.objects.create(
|
||||
enterprise=enterprise,
|
||||
user=user,
|
||||
role=validated_data.get('role', EnterpriseMember.MemberRole.MEMBER)
|
||||
)
|
||||
|
||||
return member
|
||||
|
||||
class ChangePasswordSerializer(serializers.Serializer):
|
||||
old_password = serializers.CharField(required=True)
|
||||
new_password = serializers.CharField(required=True, min_length=6)
|
||||
|
||||
class PasswordResetRequestSerializer(serializers.Serializer):
|
||||
email = serializers.EmailField(required=True)
|
||||
|
||||
class PasswordResetConfirmSerializer(serializers.Serializer):
|
||||
email = serializers.EmailField(required=True)
|
||||
verification_code = serializers.CharField(required=True, min_length=6, max_length=6)
|
||||
new_password = serializers.CharField(required=True, min_length=6)
|
||||
3
users/tests.py
Normal file
3
users/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
23
users/urls.py
Normal file
23
users/urls.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
||||
from .views import UserViewSet, RegisterView, EnterpriseViewSet, MyTokenObtainPairView, EnterpriseRegisterView, EnterpriseMemberViewSet, RoleViewSet, PermissionViewSet, EnterpriseLoginView
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register('users', UserViewSet, basename='user')
|
||||
router.register('enterprises', EnterpriseViewSet)
|
||||
router.register('enterprise-members', EnterpriseMemberViewSet, basename='enterprise-member')
|
||||
router.register('roles', RoleViewSet, basename='role')
|
||||
router.register('permissions', PermissionViewSet, basename='permission')
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path('auth/login/', MyTokenObtainPairView.as_view(), name='token_obtain_pair'),
|
||||
path('auth/enterprise/login/', EnterpriseLoginView.as_view(), name='auth_enterprise_login'),
|
||||
path('auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
|
||||
path('auth/register/', RegisterView.as_view(), name='auth_register'),
|
||||
path('auth/enterprise/register/', EnterpriseRegisterView.as_view(), name='auth_enterprise_register'),
|
||||
path('auth/me/', UserViewSet.as_view({'get': 'me'}), name='auth_me'),
|
||||
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
900
users/views.py
Normal file
900
users/views.py
Normal file
@@ -0,0 +1,900 @@
|
||||
from rest_framework import viewsets, generics, permissions, status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework_simplejwt.views import TokenObtainPairView
|
||||
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.cache import cache
|
||||
import random
|
||||
from .serializers import (
|
||||
UserSerializer, RegisterSerializer, EnterpriseSerializer, EnterpriseRegisterSerializer,
|
||||
EnterpriseMemberSerializer, RoleSerializer, PermissionSerializer, EnterpriseLoginSerializer,
|
||||
EnterpriseMemberAddSerializer, AdminUserCreateSerializer, ChangePasswordSerializer,
|
||||
PasswordResetRequestSerializer, PasswordResetConfirmSerializer
|
||||
)
|
||||
from .models import Enterprise, EnterpriseMember, Role, Permission
|
||||
from .permissions import HasAPIPermission
|
||||
from rest_framework import permissions as drf_permissions
|
||||
|
||||
class EnterpriseLoginView(APIView):
|
||||
"""
|
||||
@author: xujl
|
||||
Api说明: 企业管理员专属登录通道接口。校验企业身份并返回携带对应权限的JWT Token。
|
||||
"""
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
serializer = EnterpriseLoginSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
user = serializer.validated_data['user']
|
||||
refresh = RefreshToken.for_user(user)
|
||||
return Response({
|
||||
'refresh': str(refresh),
|
||||
'access': str(refresh.access_token),
|
||||
'user': UserSerializer(user).data
|
||||
})
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
class MyTokenObtainPairSerializer(TokenObtainPairSerializer):
|
||||
def validate(self, attrs):
|
||||
username = attrs.get(self.username_field)
|
||||
password = attrs.get('password')
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db.models import Q
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
from django.contrib.auth.models import update_last_login
|
||||
|
||||
User = get_user_model()
|
||||
user = User.objects.filter(Q(username=username) | Q(phone=username) | Q(email=username)).first()
|
||||
|
||||
if not user:
|
||||
raise AuthenticationFailed('账号未注册', code='not_registered')
|
||||
|
||||
if not user.is_active or user.is_deleted:
|
||||
raise AuthenticationFailed('您的账号已被禁用,可能因为违规操作或安全风险。如有疑问请联系平台管理员。', code='account_disabled')
|
||||
|
||||
if not user.check_password(password):
|
||||
raise AuthenticationFailed('密码错误,请检查后再试', code='wrong_password')
|
||||
|
||||
refresh = RefreshToken.for_user(user)
|
||||
update_last_login(None, user)
|
||||
|
||||
return {
|
||||
'refresh': str(refresh),
|
||||
'access': str(refresh.access_token),
|
||||
'user': UserSerializer(user).data
|
||||
}
|
||||
|
||||
class MyTokenObtainPairView(TokenObtainPairView):
|
||||
"""
|
||||
@author: xujl
|
||||
Api说明: 标准用户认证授权(JWT)接口视图。验证普通用户账号密码并下发访问凭证。
|
||||
"""
|
||||
serializer_class = MyTokenObtainPairSerializer
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
class UserViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
@author: xujl
|
||||
Api说明: 平台用户中心接口视图。用于用户的增删改查、信息完善、封禁及状态管理,以及管理员的全局管控。
|
||||
"""
|
||||
serializer_class = UserSerializer
|
||||
permission_classes = [drf_permissions.IsAuthenticated]
|
||||
|
||||
# Custom attributes for HasAPIPermission check
|
||||
required_permissions = {
|
||||
'GET': 'api:users:read',
|
||||
'POST': 'api:users:write',
|
||||
'PUT': 'api:users:write',
|
||||
'PATCH': 'api:users:write',
|
||||
'DELETE': 'api:users:delete'
|
||||
}
|
||||
|
||||
def get_queryset(self):
|
||||
from django.db.models import Q
|
||||
from tasks.models import Task, TaskApplication, TaskStatus, ApplyStatus
|
||||
queryset = User.objects.filter(is_deleted=False)
|
||||
|
||||
# Non-admins can only see OPC users
|
||||
if not (self.request.user.is_staff or self.request.user.is_superuser):
|
||||
queryset = queryset.filter(user_roles__role__code='OPC_USER')
|
||||
|
||||
role = self.request.query_params.get('role')
|
||||
username = self.request.query_params.get('username')
|
||||
nickname = self.request.query_params.get('nickname')
|
||||
real_name = self.request.query_params.get('real_name')
|
||||
phone = self.request.query_params.get('phone')
|
||||
email = self.request.query_params.get('email')
|
||||
status_filter = self.request.query_params.get('status')
|
||||
idle = self.request.query_params.get('idle')
|
||||
|
||||
if role:
|
||||
queryset = queryset.filter(user_roles__role__code=role)
|
||||
if username:
|
||||
queryset = queryset.filter(username__icontains=username)
|
||||
if nickname:
|
||||
queryset = queryset.filter(nickname__icontains=nickname)
|
||||
if real_name:
|
||||
queryset = queryset.filter(opc_certifications__real_name__icontains=real_name)
|
||||
if phone:
|
||||
queryset = queryset.filter(phone__icontains=phone)
|
||||
if email:
|
||||
queryset = queryset.filter(email__icontains=email)
|
||||
|
||||
if status_filter == 'active':
|
||||
queryset = queryset.filter(is_active=True)
|
||||
elif status_filter == 'disabled':
|
||||
queryset = queryset.filter(is_active=False)
|
||||
|
||||
if idle == 'true':
|
||||
active_app_users = TaskApplication.objects.filter(
|
||||
status__in=[ApplyStatus.PENDING, ApplyStatus.APPROVED, ApplyStatus.DELIVERED]
|
||||
).values_list('applicant_id', flat=True)
|
||||
|
||||
active_task_publishers = Task.objects.filter(
|
||||
status__in=[TaskStatus.OPEN, TaskStatus.IN_PROGRESS]
|
||||
).values_list('publisher_id', flat=True)
|
||||
|
||||
queryset = queryset.exclude(id__in=active_app_users).exclude(id__in=active_task_publishers)
|
||||
|
||||
return queryset.order_by('-is_recommended', '-recommend_priority', '-created_at').distinct()
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action in ['me', 'update_profile', 'avatar', 'change_password', 'list', 'retrieve']:
|
||||
return [drf_permissions.IsAuthenticated()]
|
||||
if self.action in ['admin_create', 'toggle_status', 'reset_password', 'create', 'update', 'partial_update', 'destroy', 'update_opc_stats']:
|
||||
return [HasAPIPermission()]
|
||||
if self.action in ['request_password_reset', 'confirm_password_reset']:
|
||||
return [drf_permissions.AllowAny()]
|
||||
return super().get_permissions()
|
||||
|
||||
@action(detail=False, methods=['post'], permission_classes=[permissions.IsAdminUser])
|
||||
def admin_create(self, request):
|
||||
serializer = AdminUserCreateSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
user = serializer.save()
|
||||
user.set_password(serializer.validated_data['password'])
|
||||
user.save()
|
||||
return Response(UserSerializer(user).data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
partial = kwargs.pop('partial', False)
|
||||
instance = self.get_object()
|
||||
serializer = self.get_serializer(instance, data=request.data, partial=partial)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
self.perform_update(serializer)
|
||||
|
||||
# Handle explicitly read-only fields for Admin
|
||||
if request.user.is_staff or request.user.is_superuser:
|
||||
needs_save = False
|
||||
for field in ['is_active', 'status', 'avatar_url', 'bio', 'location', 'face_url']:
|
||||
if field in request.data:
|
||||
setattr(instance, field, request.data[field])
|
||||
needs_save = True
|
||||
if needs_save:
|
||||
instance.save()
|
||||
|
||||
# Handle Role Assignments inside the big form
|
||||
if 'roles' in request.data:
|
||||
role_codes = request.data['roles']
|
||||
from .models import Role, UserRole
|
||||
UserRole.objects.filter(user=instance).delete()
|
||||
for code in role_codes:
|
||||
role = Role.objects.filter(code=code).first()
|
||||
if role:
|
||||
UserRole.objects.create(user=instance, role=role, granted_by=request.user)
|
||||
|
||||
# Handle enterprise_info updates
|
||||
enterprise_info = request.data.get('enterprise_info')
|
||||
if enterprise_info and 'ENTERPRISE' in instance.roles:
|
||||
from .models import Enterprise
|
||||
ent, created = Enterprise.objects.get_or_create(user=instance, defaults={'status': 'VERIFIED'})
|
||||
ent.company_name = enterprise_info.get('company_name', ent.company_name)
|
||||
ent.credit_code = enterprise_info.get('credit_code', ent.credit_code)
|
||||
ent.business_license = enterprise_info.get('business_license', ent.business_license)
|
||||
ent.logo_url = enterprise_info.get('logo_url', ent.logo_url)
|
||||
ent.contact_name = enterprise_info.get('contact_name', ent.contact_name)
|
||||
ent.contact_phone = enterprise_info.get('contact_phone', ent.contact_phone)
|
||||
ent.contact_email = enterprise_info.get('contact_email', ent.contact_email)
|
||||
ent.address = enterprise_info.get('address', ent.address)
|
||||
ent.description = enterprise_info.get('description', ent.description)
|
||||
ent.save()
|
||||
|
||||
# Handle opc_certification updates
|
||||
opc_certification = request.data.get('opc_certification')
|
||||
if opc_certification and 'OPC_USER' in instance.roles:
|
||||
from opc_cert.models import OpcCertification
|
||||
cert, created = OpcCertification.objects.get_or_create(user=instance, defaults={'status': 'APPROVED'})
|
||||
cert.real_name = opc_certification.get('real_name', cert.real_name)
|
||||
cert.id_card = opc_certification.get('id_card', cert.id_card)
|
||||
cert.experience = opc_certification.get('experience', cert.experience)
|
||||
cert.resume_url = opc_certification.get('resume_url', cert.resume_url)
|
||||
cert.attachments = opc_certification.get('attachments', cert.attachments)
|
||||
cert.skills = opc_certification.get('skills', cert.skills)
|
||||
cert.status = opc_certification.get('status', cert.status)
|
||||
if 'rating' in opc_certification:
|
||||
cert.rating = opc_certification.get('rating')
|
||||
cert.save()
|
||||
|
||||
if 'rating' in opc_certification:
|
||||
instance.rating = opc_certification.get('rating')
|
||||
instance.save()
|
||||
|
||||
if getattr(instance, '_prefetched_objects_cache', None):
|
||||
instance._prefetched_objects_cache = {}
|
||||
|
||||
return Response(self.get_serializer(instance).data)
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def change_password(self, request):
|
||||
serializer = ChangePasswordSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
user = request.user
|
||||
if not user.check_password(serializer.validated_data['old_password']):
|
||||
return Response({'old_password': ['原密码错误']}, status=status.HTTP_400_BAD_REQUEST)
|
||||
user.set_password(serializer.validated_data['new_password'])
|
||||
user.save()
|
||||
return Response({'status': '密码修改成功'})
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@action(detail=False, methods=['post'], permission_classes=[drf_permissions.AllowAny])
|
||||
def request_password_reset(self, request):
|
||||
serializer = PasswordResetRequestSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
email = serializer.validated_data['email']
|
||||
user = User.objects.filter(email=email).first()
|
||||
if user:
|
||||
# Mock sending code: Generate 6-digit code and store in cache
|
||||
code = ''.join(random.choices('0123456789', k=6))
|
||||
cache.set(f'pwd_reset_{email}', code, timeout=300) # 5 minutes
|
||||
# In production, send via Email/SMS. Here we return it or print it.
|
||||
print(f"--- MOCK EMAIL --- Password reset code for {email}: {code}")
|
||||
return Response({'status': '验证码已发送 (开发环境请查看控制台输出)'})
|
||||
else:
|
||||
# To prevent email enumeration, still return success
|
||||
return Response({'status': '验证码已发送 (开发环境请查看控制台输出)'})
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@action(detail=False, methods=['post'], permission_classes=[drf_permissions.AllowAny])
|
||||
def confirm_password_reset(self, request):
|
||||
serializer = PasswordResetConfirmSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
email = serializer.validated_data['email']
|
||||
code = serializer.validated_data['verification_code']
|
||||
cached_code = cache.get(f'pwd_reset_{email}')
|
||||
|
||||
if not cached_code or cached_code != code:
|
||||
return Response({'verification_code': ['验证码无效或已过期']}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
user = User.objects.filter(email=email).first()
|
||||
if user:
|
||||
user.set_password(serializer.validated_data['new_password'])
|
||||
user.save()
|
||||
cache.delete(f'pwd_reset_{email}')
|
||||
return Response({'status': '密码重置成功'})
|
||||
return Response({'detail': '用户不存在'}, status=status.HTTP_404_NOT_FOUND)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def reset_password(self, request, pk=None):
|
||||
user = self.get_object()
|
||||
new_password = request.data.get('password', '123456')
|
||||
user.set_password(new_password)
|
||||
user.save()
|
||||
return Response({'status': '密码已重置'})
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def me(self, request):
|
||||
serializer = self.get_serializer(request.user)
|
||||
return Response(serializer.data)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
from tasks.models import Task, TaskApplication, TaskStatus, ApplyStatus
|
||||
from django.utils import timezone
|
||||
|
||||
force = self.request.query_params.get('force', '').lower() == 'true'
|
||||
|
||||
# Check active expert applications
|
||||
active_apps = TaskApplication.objects.filter(
|
||||
applicant=instance,
|
||||
status__in=[ApplyStatus.PENDING, ApplyStatus.APPROVED, ApplyStatus.DELIVERED]
|
||||
)
|
||||
|
||||
# Check active enterprise tasks
|
||||
active_tasks = Task.objects.filter(
|
||||
publisher=instance,
|
||||
status__in=[TaskStatus.OPEN, TaskStatus.IN_PROGRESS]
|
||||
)
|
||||
|
||||
has_blockers = active_apps.exists() or active_tasks.exists()
|
||||
|
||||
if has_blockers and not force:
|
||||
from rest_framework.exceptions import ValidationError
|
||||
blocker_details = []
|
||||
if active_apps.exists():
|
||||
blocker_details.append(f'专家接单 {active_apps.count()} 项')
|
||||
if active_tasks.exists():
|
||||
blocker_details.append(f'发布任务 {active_tasks.count()} 项')
|
||||
raise ValidationError(
|
||||
f'用户 {instance.username} 有正在进行中的任务({"、".join(blocker_details)}),无法直接删除。请先取消相关任务或强制删除。'
|
||||
)
|
||||
|
||||
if has_blockers and force:
|
||||
# Lock tasks instead of cancelling — mark as anomalous
|
||||
# For tasks where this user is the ONLY active participant, set CANCELLED
|
||||
# Otherwise keep IN_PROGRESS but lock the user's application
|
||||
for app in active_apps:
|
||||
app.status = ApplyStatus.REJECTED
|
||||
app.reject_reason = f'用户 {instance.nickname or instance.username} 账户已被管理员删除/锁定'
|
||||
app.save()
|
||||
|
||||
# Check if task still has other active participants
|
||||
other_apps = app.task.applications.filter(
|
||||
status__in=[ApplyStatus.APPROVED, ApplyStatus.DELIVERED]
|
||||
).exclude(applicant=instance)
|
||||
if not other_apps.exists() and app.task.status == TaskStatus.IN_PROGRESS:
|
||||
app.task.status = TaskStatus.CANCELLED
|
||||
app.task.cancel_reason = f'唯一执行专家 {instance.nickname or instance.username} 已被系统锁定,任务异常中止'
|
||||
app.task.cancelled_at = timezone.now()
|
||||
app.task.save()
|
||||
|
||||
active_tasks.update(
|
||||
status=TaskStatus.CANCELLED,
|
||||
cancelled_at=timezone.now(),
|
||||
cancel_reason=f'管理员强制删除用户 {instance.username},系统自动取消关联任务'
|
||||
)
|
||||
|
||||
# Check and cascade close enterprise
|
||||
enterprise = Enterprise.objects.filter(user=instance, is_deleted=False).first()
|
||||
if enterprise:
|
||||
# Close enterprise: soft delete + remove all members
|
||||
enterprise.is_deleted = True
|
||||
enterprise.status = 'REJECTED'
|
||||
enterprise.save()
|
||||
EnterpriseMember.objects.filter(enterprise=enterprise).delete()
|
||||
# Cancel enterprise's published tasks
|
||||
Task.objects.filter(
|
||||
enterprise=enterprise,
|
||||
status__in=[TaskStatus.OPEN, TaskStatus.IN_PROGRESS]
|
||||
).update(
|
||||
status=TaskStatus.CANCELLED,
|
||||
cancelled_at=timezone.now(),
|
||||
cancel_reason=f'企业 {enterprise.company_name} 已被清退,关联任务自动取消'
|
||||
)
|
||||
|
||||
instance.is_deleted = True
|
||||
instance.is_active = False
|
||||
# Free up unique fields for re-registration while preserving for audit
|
||||
deleted_suffix = f'__deleted_{instance.id}'
|
||||
if instance.phone:
|
||||
instance.phone = instance.phone + deleted_suffix
|
||||
if instance.email:
|
||||
instance.email = instance.email + deleted_suffix
|
||||
instance.username = instance.username + deleted_suffix
|
||||
instance.save()
|
||||
|
||||
@action(detail=False, methods=['post'], permission_classes=[permissions.IsAdminUser])
|
||||
def batch_delete(self, request):
|
||||
user_ids = request.data.get('user_ids', [])
|
||||
force = request.data.get('force', False)
|
||||
if not user_ids:
|
||||
return Response({'detail': '缺少 user_ids'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
users = User.objects.filter(id__in=user_ids)
|
||||
from tasks.models import Task, TaskApplication, TaskStatus, ApplyStatus
|
||||
from django.utils import timezone
|
||||
|
||||
blocked_users = []
|
||||
for user in users:
|
||||
active_apps = TaskApplication.objects.filter(
|
||||
applicant=user,
|
||||
status__in=[ApplyStatus.PENDING, ApplyStatus.APPROVED, ApplyStatus.DELIVERED]
|
||||
)
|
||||
active_tasks = Task.objects.filter(
|
||||
publisher=user,
|
||||
status__in=[TaskStatus.OPEN, TaskStatus.IN_PROGRESS]
|
||||
)
|
||||
if active_apps.exists() or active_tasks.exists():
|
||||
blocked_users.append({
|
||||
'id': str(user.id),
|
||||
'username': user.username,
|
||||
'nickname': user.nickname or user.username,
|
||||
'app_count': active_apps.count(),
|
||||
'task_count': active_tasks.count(),
|
||||
})
|
||||
|
||||
if blocked_users and not force:
|
||||
return Response({
|
||||
'detail': f'共 {len(blocked_users)} 个用户有正在进行中的任务,无法直接批量删除。',
|
||||
'blocked_users': blocked_users,
|
||||
'has_blockers': True,
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if blocked_users and force:
|
||||
for user in users:
|
||||
TaskApplication.objects.filter(
|
||||
applicant=user,
|
||||
status__in=[ApplyStatus.PENDING, ApplyStatus.APPROVED, ApplyStatus.DELIVERED]
|
||||
).update(status=ApplyStatus.WITHDRAWN)
|
||||
Task.objects.filter(
|
||||
publisher=user,
|
||||
status__in=[TaskStatus.OPEN, TaskStatus.IN_PROGRESS]
|
||||
).update(
|
||||
status=TaskStatus.CANCELLED,
|
||||
cancelled_at=timezone.now(),
|
||||
cancel_reason=f'管理员批量强制删除,系统自动取消关联任务'
|
||||
)
|
||||
|
||||
for user in users:
|
||||
user.is_deleted = True
|
||||
user.is_active = False
|
||||
deleted_suffix = f'__deleted_{user.id}'
|
||||
if user.phone:
|
||||
user.phone = user.phone + deleted_suffix
|
||||
if user.email:
|
||||
user.email = user.email + deleted_suffix
|
||||
user.username = user.username + deleted_suffix
|
||||
user.save()
|
||||
return Response({'status': '批量删除成功'})
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAdminUser])
|
||||
def assign_roles(self, request, pk=None):
|
||||
user = self.get_object()
|
||||
role_codes = request.data.get('roles', [])
|
||||
from .models import Role, UserRole
|
||||
# 清除旧角色
|
||||
UserRole.objects.filter(user=user).delete()
|
||||
# 重新分配
|
||||
for code in role_codes:
|
||||
role = Role.objects.filter(code=code).first()
|
||||
if role:
|
||||
UserRole.objects.create(user=user, role=role, granted_by=request.user)
|
||||
return Response({'status': '角色分配成功'})
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAdminUser])
|
||||
def update_rating(self, request, pk=None):
|
||||
user = self.get_object()
|
||||
rating = request.data.get('rating')
|
||||
if rating is not None:
|
||||
user.rating = rating
|
||||
user.save()
|
||||
return Response({'status': '评分已更新'})
|
||||
return Response({'detail': '缺少评分参数'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@action(detail=True, methods=['get'], permission_classes=[permissions.IsAdminUser])
|
||||
def active_tasks(self, request, pk=None):
|
||||
user = self.get_object()
|
||||
from tasks.models import Task, TaskApplication, TaskStatus, ApplyStatus
|
||||
|
||||
results = []
|
||||
|
||||
# Expert tasks
|
||||
expert_apps = TaskApplication.objects.filter(
|
||||
applicant=user,
|
||||
status__in=[ApplyStatus.PENDING, ApplyStatus.APPROVED, ApplyStatus.DELIVERED]
|
||||
)
|
||||
for app in expert_apps:
|
||||
results.append({
|
||||
'id': app.task.id,
|
||||
'title': app.task.title,
|
||||
'role': '专家接单',
|
||||
'status': app.status,
|
||||
'task_status': app.task.status,
|
||||
'type': 'application'
|
||||
})
|
||||
|
||||
# Publisher tasks
|
||||
pub_tasks = Task.objects.filter(
|
||||
publisher=user,
|
||||
status__in=[TaskStatus.OPEN, TaskStatus.IN_PROGRESS]
|
||||
)
|
||||
for t in pub_tasks:
|
||||
results.append({
|
||||
'id': t.id,
|
||||
'title': t.title,
|
||||
'role': '任务发布方',
|
||||
'status': t.status,
|
||||
'task_status': t.status,
|
||||
'type': 'task'
|
||||
})
|
||||
|
||||
return Response(results)
|
||||
|
||||
@action(detail=False, methods=['put'])
|
||||
def update_profile(self, request):
|
||||
user = request.user
|
||||
serializer = UserSerializer(user, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAdminUser])
|
||||
def toggle_status(self, request, pk=None):
|
||||
user = self.get_object()
|
||||
user.is_active = not user.is_active
|
||||
user.save()
|
||||
return Response({'status': '状态已更新', 'is_active': user.is_active})
|
||||
|
||||
@action(detail=True, methods=['put'], permission_classes=[permissions.IsAdminUser])
|
||||
def update_opc_stats(self, request, pk=None):
|
||||
user = self.get_object()
|
||||
|
||||
rating = request.data.get('rating')
|
||||
completed_tasks = request.data.get('completed_tasks')
|
||||
|
||||
if rating is not None:
|
||||
user.rating = rating
|
||||
if completed_tasks is not None:
|
||||
user.completed_tasks = completed_tasks
|
||||
|
||||
user.save()
|
||||
return Response(UserSerializer(user).data)
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAdminUser])
|
||||
def toggle_recommend(self, request, pk=None):
|
||||
user = self.get_object()
|
||||
user.is_recommended = request.data.get('is_recommended', not user.is_recommended)
|
||||
user.recommend_priority = request.data.get('recommend_priority', user.recommend_priority)
|
||||
user.save(update_fields=['is_recommended', 'recommend_priority'])
|
||||
return Response({'is_recommended': user.is_recommended, 'recommend_priority': user.recommend_priority})
|
||||
|
||||
@action(detail=False, methods=['get'], permission_classes=[permissions.IsAdminUser])
|
||||
def deleted_users(self, request):
|
||||
"""List soft-deleted users for admin review/recovery."""
|
||||
deleted = User.objects.filter(is_deleted=True).order_by('-updated_at')
|
||||
page = self.paginate_queryset(deleted)
|
||||
if page is not None:
|
||||
serializer = UserSerializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
serializer = UserSerializer(deleted, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAdminUser])
|
||||
def restore(self, request, pk=None):
|
||||
"""Restore a soft-deleted user by clearing the deletion suffix."""
|
||||
try:
|
||||
user = User.objects.get(id=pk, is_deleted=True)
|
||||
except User.DoesNotExist:
|
||||
return Response({'detail': '未找到已删除的用户'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
deleted_suffix = f'__deleted_{user.id}'
|
||||
|
||||
# Restore unique fields
|
||||
if user.username.endswith(deleted_suffix):
|
||||
original_username = user.username[:-len(deleted_suffix)]
|
||||
if User.objects.filter(username=original_username, is_deleted=False).exists():
|
||||
return Response({'detail': f'用户名 {original_username} 已被新用户占用,无法恢复'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
user.username = original_username
|
||||
|
||||
if user.phone and user.phone.endswith(deleted_suffix):
|
||||
original_phone = user.phone[:-len(deleted_suffix)]
|
||||
if User.objects.filter(phone=original_phone, is_deleted=False).exists():
|
||||
return Response({'detail': f'手机号 {original_phone} 已被新用户占用,无法恢复'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
user.phone = original_phone
|
||||
|
||||
if user.email and user.email.endswith(deleted_suffix):
|
||||
original_email = user.email[:-len(deleted_suffix)]
|
||||
if User.objects.filter(email=original_email, is_deleted=False).exists():
|
||||
return Response({'detail': f'邮箱 {original_email} 已被新用户占用,无法恢复'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
user.email = original_email
|
||||
|
||||
user.is_deleted = False
|
||||
user.is_active = True
|
||||
user.save()
|
||||
return Response({'status': f'用户 {user.username} 已成功恢复'})
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def avatar(self, request):
|
||||
# In a real app, handle file upload to MinIO
|
||||
# For now, we expect a URL or just mock it
|
||||
user = request.user
|
||||
avatar_url = request.data.get('avatar_url')
|
||||
if avatar_url:
|
||||
user.avatar_url = avatar_url
|
||||
user.save()
|
||||
return Response({'status': '头像已更新', 'avatar_url': avatar_url})
|
||||
return Response({'detail': '缺少 avatar_url'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class RegisterView(generics.CreateAPIView):
|
||||
"""
|
||||
@author: xujl
|
||||
Api说明: 平台普通用户注册接口视图。提供基础的账号密码/手机/邮箱注册流程。
|
||||
"""
|
||||
queryset = User.objects.all()
|
||||
permission_classes = [permissions.AllowAny]
|
||||
serializer_class = RegisterSerializer
|
||||
|
||||
class EnterpriseViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
@author: xujl
|
||||
Api说明: 企业资质及账号管理接口视图。企业用户提交工商资料认证、基本信息更新,管理员对企业入驻进行审核。
|
||||
"""
|
||||
queryset = Enterprise.objects.filter(is_deleted=False).order_by('-created_at')
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == 'create' and (self.request.user.is_staff or self.request.user.is_superuser):
|
||||
from .serializers import AdminEnterpriseCreateSerializer
|
||||
return AdminEnterpriseCreateSerializer
|
||||
from .serializers import EnterpriseSerializer
|
||||
return EnterpriseSerializer
|
||||
|
||||
required_permissions = {
|
||||
'GET': 'api:enterprises:read',
|
||||
'POST': 'api:enterprises:write',
|
||||
'PUT': 'api:enterprises:write',
|
||||
'PATCH': 'api:enterprises:write',
|
||||
'DELETE': 'api:enterprises:delete'
|
||||
}
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action in ['me', 'create', 'update', 'partial_update']:
|
||||
return [drf_permissions.IsAuthenticated()]
|
||||
if self.action in ['list', 'retrieve', 'verify_status']:
|
||||
return [HasAPIPermission()]
|
||||
return super().get_permissions()
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def me(self, request):
|
||||
try:
|
||||
# First check if user is the enterprise owner
|
||||
enterprise = Enterprise.objects.filter(user=request.user).first()
|
||||
role = 'ADMIN'
|
||||
|
||||
if not enterprise:
|
||||
# If not owner, check if user is a member
|
||||
member = EnterpriseMember.objects.filter(user=request.user).first()
|
||||
if member:
|
||||
enterprise = member.enterprise
|
||||
role = member.role
|
||||
else:
|
||||
return Response({'detail': '未找到企业信息'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
serializer = self.get_serializer(enterprise)
|
||||
data = serializer.data
|
||||
data['my_role'] = role
|
||||
return Response(data)
|
||||
except Exception as e:
|
||||
return Response({'detail': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
if self.request.user.is_staff or self.request.user.is_superuser:
|
||||
serializer.save()
|
||||
else:
|
||||
enterprise = serializer.save(user=self.request.user)
|
||||
# Auto assign ENTERPRISE role
|
||||
from .models import Role, UserRole
|
||||
role, created = Role.objects.get_or_create(code='ENTERPRISE', defaults={'name': '企业用户'})
|
||||
UserRole.objects.get_or_create(user=self.request.user, role=role)
|
||||
|
||||
def _check_admin_permission(self, request, enterprise):
|
||||
if request.user.is_superuser or request.user.is_staff:
|
||||
return True
|
||||
if request.user == enterprise.user:
|
||||
return True
|
||||
member = EnterpriseMember.objects.filter(enterprise=enterprise, user=request.user).first()
|
||||
return member and member.role == 'ADMIN'
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
enterprise = self.get_object()
|
||||
if not self._check_admin_permission(request, enterprise):
|
||||
return Response({'detail': '只有企业管理员可以编辑企业数据'}, status=status.HTTP_403_FORBIDDEN)
|
||||
return super().update(request, *args, **kwargs)
|
||||
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
enterprise = self.get_object()
|
||||
if not self._check_admin_permission(request, enterprise):
|
||||
return Response({'detail': '只有企业管理员可以编辑企业数据'}, status=status.HTTP_403_FORBIDDEN)
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""Cascade enterprise deletion: soft-delete, remove members, cancel active tasks, free email."""
|
||||
from tasks.models import Task, TaskStatus
|
||||
from django.utils import timezone
|
||||
|
||||
# Cancel all active tasks for this enterprise
|
||||
Task.objects.filter(
|
||||
enterprise=instance,
|
||||
status__in=[TaskStatus.OPEN, TaskStatus.IN_PROGRESS]
|
||||
).update(
|
||||
status=TaskStatus.CANCELLED,
|
||||
cancelled_at=timezone.now(),
|
||||
cancel_reason=f'企业 {instance.company_name} 已被清退,项目中止'
|
||||
)
|
||||
|
||||
# Remove all team members
|
||||
EnterpriseMember.objects.filter(enterprise=instance).delete()
|
||||
|
||||
# Free up the enterprise user's email for re-use (suffix pattern)
|
||||
owner = instance.user
|
||||
suffix = f'__deleted_{str(instance.id)[:8]}'
|
||||
if owner.email and '__deleted_' not in owner.email:
|
||||
owner.email = owner.email + suffix
|
||||
owner.save(update_fields=['email'])
|
||||
|
||||
# Free up credit_code
|
||||
if instance.credit_code and '__deleted_' not in instance.credit_code:
|
||||
instance.credit_code = instance.credit_code + suffix
|
||||
|
||||
# Soft delete enterprise
|
||||
instance.is_deleted = True
|
||||
instance.status = 'REJECTED'
|
||||
instance.save()
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def deleted_enterprises(self, request):
|
||||
"""List soft-deleted enterprises for the recycle bin."""
|
||||
deleted = Enterprise.objects.filter(is_deleted=True).order_by('-updated_at')
|
||||
serializer = self.get_serializer(deleted, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def restore(self, request, pk=None):
|
||||
"""Restore a soft-deleted enterprise."""
|
||||
enterprise = Enterprise.objects.filter(pk=pk, is_deleted=True).first()
|
||||
if not enterprise:
|
||||
return Response({'detail': '未找到已删除的企业'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# Restore credit_code
|
||||
if enterprise.credit_code and '__deleted_' in enterprise.credit_code:
|
||||
original_code = enterprise.credit_code.split('__deleted_')[0]
|
||||
if Enterprise.objects.filter(credit_code=original_code, is_deleted=False).exists():
|
||||
return Response({'detail': f'统一社会信用代码 {original_code} 已被其他企业占用'}, status=status.HTTP_409_CONFLICT)
|
||||
enterprise.credit_code = original_code
|
||||
|
||||
# Restore owner email
|
||||
owner = enterprise.user
|
||||
if owner.email and '__deleted_' in owner.email:
|
||||
original_email = owner.email.split('__deleted_')[0]
|
||||
if User.objects.filter(email=original_email, is_deleted=False).exclude(pk=owner.pk).exists():
|
||||
return Response({'detail': f'邮箱 {original_email} 已被其他用户占用,无法恢复'}, status=status.HTTP_409_CONFLICT)
|
||||
owner.email = original_email
|
||||
owner.save(update_fields=['email'])
|
||||
|
||||
enterprise.is_deleted = False
|
||||
enterprise.status = 'VERIFIED'
|
||||
enterprise.save()
|
||||
return Response({'detail': '企业已恢复'})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def verify_status(self, request, pk=None):
|
||||
enterprise = self.get_object()
|
||||
status_val = request.data.get('status')
|
||||
if status_val not in [choice[0] for choice in Enterprise.EnterpriseStatus.choices]:
|
||||
return Response({'detail': '无效的状态'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
enterprise.status = status_val
|
||||
enterprise.save()
|
||||
return Response({'status': '企业审核状态已更新', 'enterprise_status': enterprise.status})
|
||||
|
||||
class EnterpriseRegisterView(generics.CreateAPIView):
|
||||
"""
|
||||
@author: xujl
|
||||
Api说明: 企业级账号入驻/注册接口视图。提交企业信用代码和法人代表等认证资料。
|
||||
"""
|
||||
serializer_class = EnterpriseRegisterSerializer
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response({'status': '企业注册成功'}, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
class EnterpriseMemberViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
@author: xujl
|
||||
Api说明: 企业内部成员管理接口视图。用于给企业内部关联多个员工账号并分配角色(管理员、普通成员)。
|
||||
"""
|
||||
serializer_class = EnterpriseMemberSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
if self.request.user.is_staff or self.request.user.is_superuser:
|
||||
enterprise_id = self.request.query_params.get('enterprise')
|
||||
if enterprise_id:
|
||||
return EnterpriseMember.objects.filter(enterprise_id=enterprise_id).order_by('-joined_at')
|
||||
return EnterpriseMember.objects.all().order_by('-joined_at')
|
||||
|
||||
try:
|
||||
enterprise = Enterprise.objects.get(user=self.request.user)
|
||||
return EnterpriseMember.objects.filter(enterprise=enterprise).order_by('-joined_at')
|
||||
except Enterprise.DoesNotExist:
|
||||
return EnterpriseMember.objects.none()
|
||||
|
||||
def perform_create(self, serializer):
|
||||
enterprise = Enterprise.objects.get(user=self.request.user)
|
||||
serializer.save(enterprise=enterprise)
|
||||
|
||||
@action(detail=False, methods=['post'], permission_classes=[permissions.IsAuthenticated])
|
||||
def add_member(self, request):
|
||||
serializer = EnterpriseMemberAddSerializer(data=request.data, context={'request': request})
|
||||
if serializer.is_valid():
|
||||
member = serializer.save()
|
||||
return Response(EnterpriseMemberSerializer(member).data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@action(detail=False, methods=['post'], permission_classes=[permissions.IsAdminUser])
|
||||
def batch_delete(self, request):
|
||||
member_ids = request.data.get('member_ids', [])
|
||||
if not member_ids:
|
||||
return Response({'detail': '缺少 member_ids'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
EnterpriseMember.objects.filter(id__in=member_ids).delete()
|
||||
return Response({'status': '批量删除成功'})
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def invite(self, request):
|
||||
email = request.data.get('email')
|
||||
role = request.data.get('role', 'MEMBER')
|
||||
|
||||
try:
|
||||
enterprise = Enterprise.objects.get(user=request.user)
|
||||
user = User.objects.get(email=email)
|
||||
|
||||
if EnterpriseMember.objects.filter(enterprise=enterprise, user=user).exists():
|
||||
return Response({'detail': '该用户已在团队中'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
member = EnterpriseMember.objects.create(
|
||||
enterprise=enterprise,
|
||||
user=user,
|
||||
role=role
|
||||
)
|
||||
return Response(EnterpriseMemberSerializer(member).data, status=status.HTTP_201_CREATED)
|
||||
except Enterprise.DoesNotExist:
|
||||
return Response({'detail': '未找到企业信息'}, status=status.HTTP_404_NOT_FOUND)
|
||||
except User.DoesNotExist:
|
||||
return Response({'detail': '该用户尚未注册平台,请先引导其注册'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
class RoleViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
@author: xujl
|
||||
Api说明: RBAC 角色管理接口视图。用于定义平台角色类别,以及为用户进行角色的授予和回收。
|
||||
"""
|
||||
queryset = Role.objects.all().order_by('sort_order', 'created_at')
|
||||
serializer_class = RoleSerializer
|
||||
pagination_class = None # Return all roles without pagination
|
||||
permission_classes = [HasAPIPermission]
|
||||
required_permissions = {
|
||||
'GET': 'menu:account:roles',
|
||||
'POST': 'menu:account:roles',
|
||||
'PUT': 'menu:account:roles',
|
||||
'DELETE': 'menu:account:roles'
|
||||
}
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def assign_permissions(self, request, pk=None):
|
||||
role = self.get_object()
|
||||
permission_ids = request.data.get('permissions', [])
|
||||
from .models import Permission, RolePermission
|
||||
RolePermission.objects.filter(role=role).delete()
|
||||
for pid in permission_ids:
|
||||
perm = Permission.objects.filter(id=pid).first()
|
||||
if perm:
|
||||
RolePermission.objects.create(role=role, permission=perm)
|
||||
return Response({'status': '权限分配成功'})
|
||||
|
||||
class PermissionViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
@author: xujl
|
||||
Api说明: RBAC 权限节点管理接口视图。用于查询系统的路由、菜单及按钮级别的权限树结构。
|
||||
"""
|
||||
queryset = Permission.objects.all().order_by('sort_order', 'created_at')
|
||||
serializer_class = PermissionSerializer
|
||||
pagination_class = None # Return all permissions without pagination
|
||||
permission_classes = [HasAPIPermission]
|
||||
required_permissions = {
|
||||
'GET': 'menu:account:permissions',
|
||||
'POST': 'menu:account:permissions',
|
||||
'PUT': 'menu:account:permissions',
|
||||
'DELETE': 'menu:account:permissions'
|
||||
}
|
||||
Reference in New Issue
Block a user