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