开发了多角色登录与鉴权接口:实现了普通用户、企业和管理员的登录分流,并支持Token验证。

开发了权限控制接口:实现了通过数据库分配菜单权限节点,控制接口访问安全。
开发了实名认证中心:实现了个人身份证信息与企业营业执照的提交与审核接口。
开发了任务与协作大厅核心业务:实现了任务的发布、接单、状态流转以及专家邀约接口。
配置了全局环境变量与数据库引擎:集成了 PostgreSQL 数据库、Redis 缓存与 MinIO 对象存储。
This commit is contained in:
2026-04-28 16:32:02 +08:00
commit 23855ef0e4
94 changed files with 4950 additions and 0 deletions

0
tasks/__init__.py Normal file
View File

3
tasks/admin.py Normal file
View File

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

6
tasks/apps.py Normal file
View File

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

View File

View File

View 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.'))

View 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} 个认证专家用户'))

View 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',
},
),
]

View 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')},
),
]

View 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',
},
),
]

View File

@@ -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),
),
]

View 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')},
),
]

View File

@@ -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),
),
]

View File

@@ -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'],
},
),
]

View 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),
),
]

View 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),
),
]

View File

@@ -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'],
},
),
]

View File

129
tasks/models.py Normal file
View 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
View 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
View File

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

12
tasks/urls.py Normal file
View 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
View 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': '该次交付已驳回'})