You've already forked opc-backend
开发了多角色登录与鉴权接口:实现了普通用户、企业和管理员的登录分流,并支持Token验证。
开发了权限控制接口:实现了通过数据库分配菜单权限节点,控制接口访问安全。 开发了实名认证中心:实现了个人身份证信息与企业营业执照的提交与审核接口。 开发了任务与协作大厅核心业务:实现了任务的发布、接单、状态流转以及专家邀约接口。 配置了全局环境变量与数据库引擎:集成了 PostgreSQL 数据库、Redis 缓存与 MinIO 对象存储。
This commit is contained in:
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': '该次交付已驳回'})
|
||||
Reference in New Issue
Block a user