创建中心任务的管理系统
This commit is contained in:
200
.trae/documents/任务中心管理系统实现计划.md
Normal file
200
.trae/documents/任务中心管理系统实现计划.md
Normal file
@@ -0,0 +1,200 @@
|
||||
# 任务中心管理系统最终实现计划
|
||||
|
||||
## 项目结构设计
|
||||
1. 创建Django项目:`task_center`
|
||||
2. 创建任务管理应用:`tasks`
|
||||
3. 配置数据库为SQLite
|
||||
4. 集成Bootstrap前端框架
|
||||
5. 配置pytest测试框架
|
||||
|
||||
## 数据库模型设计
|
||||
|
||||
### Client模型
|
||||
| 字段名 | 类型 | 描述 |
|
||||
|-------|------|------|
|
||||
| id | AutoField | 客户端ID |
|
||||
| name | CharField(unique=True) | 客户端标识 |
|
||||
| token | CharField(max_length=128) | API Token |
|
||||
| last_seen | DateTimeField | 最后活跃时间 |
|
||||
| created_at | DateTimeField | 创建时间 |
|
||||
|
||||
### Task模型
|
||||
| 字段名 | 类型 | 描述 |
|
||||
|-------|------|------|
|
||||
| id | AutoField | 任务ID |
|
||||
| name | CharField | 任务名称 |
|
||||
| client_name | CharField(null=True, blank=True) | 指定执行客户端 |
|
||||
| script | TextField(null=True, blank=True) | 执行脚本 |
|
||||
| status | CharField(choices=STATUS_CHOICES, default='pending') | 任务状态 |
|
||||
| timeout_seconds | IntegerField(default=259200) | 超时时间(默认3天=259200秒) |
|
||||
| created_at | DateTimeField | 创建时间 |
|
||||
| updated_at | DateTimeField | 更新时间 |
|
||||
| assigned_to | CharField(null=True, blank=True) | 实际执行客户端 |
|
||||
| started_at | DateTimeField(null=True, blank=True) | 开始执行时间 |
|
||||
| completed_at | DateTimeField(null=True, blank=True) | 完成时间 |
|
||||
|
||||
### TaskResult模型
|
||||
| 字段名 | 类型 | 描述 |
|
||||
|-------|------|------|
|
||||
| id | AutoField | 结果ID |
|
||||
| task | ForeignKey(Task) | 关联任务 |
|
||||
| client | ForeignKey(Client) | 执行客户端 |
|
||||
| result_file | FileField(upload_to='task_results/') | 结果文件 |
|
||||
| status | CharField(choices=STATUS_CHOICES) | 执行状态 |
|
||||
| message | TextField(null=True, blank=True) | 执行消息 |
|
||||
| created_at | DateTimeField | 创建时间 |
|
||||
|
||||
## 状态定义
|
||||
```python
|
||||
STATUS_CHOICES = [
|
||||
('pending', '待分配'),
|
||||
('assigned', '已分配'),
|
||||
('running', '执行中'),
|
||||
('success', '成功'),
|
||||
('failed', '失败'),
|
||||
('retrying', '重试中'),
|
||||
('timeout', '超时,关闭'),
|
||||
]
|
||||
```
|
||||
|
||||
## API接口设计
|
||||
|
||||
### 认证机制
|
||||
- **所有API端点均需token认证**
|
||||
- 客户端通过HTTP头`Authorization: Token <token>`进行身份验证
|
||||
- 使用Django REST Framework的TokenAuthentication
|
||||
- 未提供有效token的请求将返回401 Unauthorized
|
||||
|
||||
### 任务管理API
|
||||
- `GET /api/tasks/` - 获取任务列表(需认证)
|
||||
- `GET /api/tasks/<id>/` - 获取任务详情(需认证)
|
||||
- `POST /api/tasks/` - 创建任务(需认证)
|
||||
- `PUT /api/tasks/<id>/` - 更新任务(需认证)
|
||||
- `DELETE /api/tasks/<id>/` - 删除任务(需认证)
|
||||
|
||||
### 客户端API
|
||||
- `POST /api/tasks/claim/` - 客户端原子认领任务(需认证)
|
||||
- `POST /api/tasks/<id>/start/` - 客户端开始执行任务(需认证)
|
||||
- `POST /api/tasks/<id>/complete/` - 客户端完成任务(需认证)
|
||||
- `POST /api/task_results/` - 上传任务结果(需认证,支持文件上传)
|
||||
|
||||
### 客户端管理API
|
||||
- `GET /api/clients/` - 获取客户端列表(需认证)
|
||||
- `POST /api/clients/` - 创建客户端(需认证)
|
||||
- `GET /api/clients/<id>/` - 获取客户端详情(需认证)
|
||||
|
||||
### 文件下载API
|
||||
- `GET /api/task_results/<id>/download/` - 下载任务结果文件(需认证)
|
||||
|
||||
## 文件上传实现细节
|
||||
|
||||
### 1. Django媒体文件配置
|
||||
```python
|
||||
# settings.py
|
||||
MEDIA_URL = '/media/'
|
||||
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
|
||||
```
|
||||
|
||||
### 2. 文件上传API实现
|
||||
- 使用Django REST Framework的`MultiPartParser`和`FormParser`
|
||||
- API端点:`POST /api/task_results/`
|
||||
- 支持`multipart/form-data`格式上传文件
|
||||
- 上传字段:`task_id`、`status`、`message`、`result_file`
|
||||
|
||||
### 3. 前端文件上传实现
|
||||
- 使用HTML5的`input type="file"`元素
|
||||
- 表单设置`enctype="multipart/form-data"`
|
||||
- 使用Bootstrap样式美化文件上传控件
|
||||
- 支持进度条显示(可选)
|
||||
|
||||
### 4. 文件存储策略
|
||||
- 本地文件系统存储:`media/task_results/`目录
|
||||
- 文件名自动生成,避免冲突
|
||||
- 支持大文件上传(通过Django默认配置)
|
||||
|
||||
### 5. 文件下载实现
|
||||
- API端点:`GET /api/task_results/<id>/download/`
|
||||
- 返回`Content-Disposition: attachment`头,触发浏览器下载
|
||||
- 支持断点续传(通过Django默认配置)
|
||||
|
||||
## 前端页面设计
|
||||
1. 任务列表页:展示所有任务,支持筛选和搜索
|
||||
2. 任务创建页:表单创建新任务
|
||||
3. 任务详情页:查看任务详情和执行结果历史
|
||||
4. 客户端管理页:管理客户端列表
|
||||
5. 结果文件上传页:支持文件上传和状态更新
|
||||
6. 结果文件下载功能:点击即可下载
|
||||
|
||||
## 核心功能实现
|
||||
|
||||
### 1. 全API Token认证
|
||||
- 所有API视图均使用`TokenAuthentication`
|
||||
- 配置`DEFAULT_AUTHENTICATION_CLASSES`和`DEFAULT_PERMISSION_CLASSES`
|
||||
|
||||
### 2. 原子任务认领机制
|
||||
- 使用数据库事务确保任务认领的原子性
|
||||
- 客户端调用`/api/tasks/claim/`时,系统自动查找并分配可用任务
|
||||
|
||||
### 3. 任务超时自动回收
|
||||
- 使用Django管理命令定期检查超时任务
|
||||
- 超过`timeout_seconds`的任务自动设置为`timeout`状态
|
||||
- 管理命令:`python manage.py check_task_timeouts`
|
||||
|
||||
### 4. 任务结果版本管理
|
||||
- TaskResult模型记录每次执行结果
|
||||
- 支持查看任务的完整执行历史
|
||||
- 支持下载不同版本的结果文件
|
||||
|
||||
## 测试用例设计
|
||||
|
||||
### 目录结构
|
||||
```
|
||||
task_center/
|
||||
├── tasks/
|
||||
│ ├── tests/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── test_models.py
|
||||
│ │ ├── test_views.py
|
||||
│ │ ├── test_api.py
|
||||
│ │ ├── test_integration.py
|
||||
│ │ └── test_factories.py
|
||||
│ └── ...
|
||||
├── conftest.py
|
||||
└── pytest.ini
|
||||
```
|
||||
|
||||
### 测试类型
|
||||
1. **模型测试**:测试模型字段、方法和关系
|
||||
2. **API测试**:测试所有API端点的功能和认证机制
|
||||
3. **文件上传测试**:测试文件上传和下载功能
|
||||
4. **视图测试**:测试前端视图渲染
|
||||
5. **集成测试**:测试完整的任务流程
|
||||
6. **工厂测试**:使用factory_boy创建测试数据
|
||||
|
||||
## 实现步骤
|
||||
1. 创建Django项目和应用
|
||||
2. 配置项目设置(数据库、REST Framework、认证、媒体文件等)
|
||||
3. 实现数据库模型
|
||||
4. 实现API视图和序列化器,包括文件上传功能
|
||||
5. 配置URL路由
|
||||
6. 实现前端模板,包括文件上传表单
|
||||
7. 实现任务超时管理命令
|
||||
8. 编写测试用例
|
||||
9. 测试API接口和功能
|
||||
|
||||
## 技术栈
|
||||
- 后端:Django 5.0.6 + Django REST Framework
|
||||
- 前端:HTML + Bootstrap 5
|
||||
- 数据库:SQLite
|
||||
- 文件存储:本地文件系统
|
||||
- 测试:pytest + factory_boy
|
||||
|
||||
## 预期效果
|
||||
- 所有API端点均需要token认证
|
||||
- 支持结果文件的上传和下载功能
|
||||
- 后台可通过admin或API创建和管理任务
|
||||
- 客户端通过API进行身份验证,原子认领任务
|
||||
- 支持任务超时自动回收
|
||||
- 完整的任务执行历史记录
|
||||
- 简单大方的Bootstrap前端界面
|
||||
- 全面的测试用例覆盖
|
||||
BIN
db.sqlite3
Normal file
BIN
db.sqlite3
Normal file
Binary file not shown.
22
manage.py
Normal file
22
manage.py
Normal file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'task_center.settings')
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
0
task_center/__init__.py
Normal file
0
task_center/__init__.py
Normal file
BIN
task_center/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
task_center/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
task_center/__pycache__/settings.cpython-311.pyc
Normal file
BIN
task_center/__pycache__/settings.cpython-311.pyc
Normal file
Binary file not shown.
BIN
task_center/__pycache__/urls.cpython-311.pyc
Normal file
BIN
task_center/__pycache__/urls.cpython-311.pyc
Normal file
Binary file not shown.
BIN
task_center/__pycache__/wsgi.cpython-311.pyc
Normal file
BIN
task_center/__pycache__/wsgi.cpython-311.pyc
Normal file
Binary file not shown.
16
task_center/asgi.py
Normal file
16
task_center/asgi.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
ASGI config for task_center project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'task_center.settings')
|
||||
|
||||
application = get_asgi_application()
|
||||
139
task_center/settings.py
Normal file
139
task_center/settings.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
Django settings for task_center project.
|
||||
|
||||
Generated by 'django-admin startproject' using Django 5.0.6.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.0/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/5.0/ref/settings/
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = 'django-insecure-&_@v3e5@!x+fa5273@v=^&02p@(#b89=p^06ifule17*p+g@u8'
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = True
|
||||
|
||||
ALLOWED_HOSTS = []
|
||||
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'rest_framework',
|
||||
'tasks',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'task_center.urls'
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.debug',
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = 'task_center.wsgi.application'
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/5.0/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': BASE_DIR / 'db.sqlite3',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/5.0/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
|
||||
TIME_ZONE = 'UTC'
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/5.0/howto/static-files/
|
||||
|
||||
STATIC_URL = 'static/'
|
||||
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
|
||||
# REST Framework settings
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_PERMISSION_CLASSES': [
|
||||
'rest_framework.permissions.AllowAny',
|
||||
],
|
||||
}
|
||||
|
||||
# Media files settings
|
||||
MEDIA_URL = '/media/'
|
||||
MEDIA_ROOT = BASE_DIR / 'media'
|
||||
|
||||
# Test settings
|
||||
# TEST_RUNNER = 'pytest_django.runner.DjangoPytestTestRunner' # Commented out as it's not needed for pytest
|
||||
28
task_center/urls.py
Normal file
28
task_center/urls.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
URL configuration for task_center project.
|
||||
|
||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||
https://docs.djangoproject.com/en/5.0/topics/http/urls/
|
||||
Examples:
|
||||
Function views
|
||||
1. Add an import: from my_app import views
|
||||
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||
Class-based views
|
||||
1. Add an import: from other_app.views import Home
|
||||
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||
Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.urls import path, include
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from tasks.views_frontend import index
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path('api/', include('tasks.urls')),
|
||||
# Root URL points to home page
|
||||
path('', index, name='index'),
|
||||
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
16
task_center/wsgi.py
Normal file
16
task_center/wsgi.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
WSGI config for task_center project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'task_center.settings')
|
||||
|
||||
application = get_wsgi_application()
|
||||
0
tasks/__init__.py
Normal file
0
tasks/__init__.py
Normal file
BIN
tasks/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
tasks/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
tasks/__pycache__/admin.cpython-311.pyc
Normal file
BIN
tasks/__pycache__/admin.cpython-311.pyc
Normal file
Binary file not shown.
BIN
tasks/__pycache__/apps.cpython-311.pyc
Normal file
BIN
tasks/__pycache__/apps.cpython-311.pyc
Normal file
Binary file not shown.
BIN
tasks/__pycache__/models.cpython-311.pyc
Normal file
BIN
tasks/__pycache__/models.cpython-311.pyc
Normal file
Binary file not shown.
BIN
tasks/__pycache__/serializers.cpython-311.pyc
Normal file
BIN
tasks/__pycache__/serializers.cpython-311.pyc
Normal file
Binary file not shown.
BIN
tasks/__pycache__/urls.cpython-311.pyc
Normal file
BIN
tasks/__pycache__/urls.cpython-311.pyc
Normal file
Binary file not shown.
BIN
tasks/__pycache__/views.cpython-311.pyc
Normal file
BIN
tasks/__pycache__/views.cpython-311.pyc
Normal file
Binary file not shown.
BIN
tasks/__pycache__/views_frontend.cpython-311.pyc
Normal file
BIN
tasks/__pycache__/views_frontend.cpython-311.pyc
Normal file
Binary file not shown.
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
BIN
tasks/management/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
tasks/management/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
0
tasks/management/commands/__init__.py
Normal file
0
tasks/management/commands/__init__.py
Normal file
BIN
tasks/management/commands/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
tasks/management/commands/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
37
tasks/management/commands/check_task_timeouts.py
Normal file
37
tasks/management/commands/check_task_timeouts.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
from tasks.models import Task
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Check and update tasks that have timed out'
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
now = timezone.now()
|
||||
|
||||
# Get all tasks that are in progress (assigned, running, retrying)
|
||||
in_progress_tasks = Task.objects.filter(
|
||||
status__in=['assigned', 'running', 'retrying']
|
||||
)
|
||||
|
||||
timeout_count = 0
|
||||
|
||||
for task in in_progress_tasks:
|
||||
# Calculate when the task should timeout
|
||||
if task.started_at:
|
||||
# If task has started, calculate from started_at
|
||||
timeout_time = task.started_at + timezone.timedelta(seconds=task.timeout_seconds)
|
||||
else:
|
||||
# If task hasn't started, calculate from created_at
|
||||
timeout_time = task.created_at + timezone.timedelta(seconds=task.timeout_seconds)
|
||||
|
||||
# Check if task has timed out
|
||||
if now > timeout_time:
|
||||
task.status = 'timeout'
|
||||
task.save()
|
||||
timeout_count += 1
|
||||
self.stdout.write(self.style.WARNING(f'Task {task.id} "{task.name}" has timed out'))
|
||||
|
||||
if timeout_count > 0:
|
||||
self.stdout.write(self.style.SUCCESS(f'Successfully updated {timeout_count} timed out tasks'))
|
||||
else:
|
||||
self.stdout.write(self.style.INFO('No tasks have timed out'))
|
||||
65
tasks/migrations/0001_initial.py
Normal file
65
tasks/migrations/0001_initial.py
Normal file
@@ -0,0 +1,65 @@
|
||||
# Generated by Django 5.0.6 on 2025-12-05 03:40
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Client',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, unique=True, verbose_name='客户端标识')),
|
||||
('token', models.CharField(max_length=128, verbose_name='API Token')),
|
||||
('last_seen', models.DateTimeField(auto_now=True, verbose_name='最后活跃时间')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '客户端',
|
||||
'verbose_name_plural': '客户端',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Task',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=200, verbose_name='任务名称')),
|
||||
('client_name', models.CharField(blank=True, max_length=100, null=True, verbose_name='指定执行客户端')),
|
||||
('script', models.TextField(blank=True, null=True, verbose_name='执行脚本')),
|
||||
('status', models.CharField(choices=[('pending', '待分配'), ('assigned', '已分配'), ('running', '执行中'), ('success', '成功'), ('failed', '失败'), ('retrying', '重试中'), ('timeout', '超时,关闭')], default='pending', max_length=20, verbose_name='任务状态')),
|
||||
('timeout_seconds', models.IntegerField(default=259200, verbose_name='超时时间(秒)')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('assigned_to', models.CharField(blank=True, max_length=100, null=True, verbose_name='实际执行客户端')),
|
||||
('started_at', models.DateTimeField(blank=True, null=True, verbose_name='开始执行时间')),
|
||||
('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='完成时间')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '任务',
|
||||
'verbose_name_plural': '任务',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TaskResult',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('result_file', models.FileField(blank=True, null=True, upload_to='task_results/', verbose_name='结果文件')),
|
||||
('status', models.CharField(choices=[('pending', '待分配'), ('assigned', '已分配'), ('running', '执行中'), ('success', '成功'), ('failed', '失败'), ('retrying', '重试中'), ('timeout', '超时,关闭')], max_length=20, verbose_name='执行状态')),
|
||||
('message', models.TextField(blank=True, null=True, verbose_name='执行消息')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('client', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tasks.client', verbose_name='执行客户端')),
|
||||
('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='results', to='tasks.task', verbose_name='关联任务')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '任务结果',
|
||||
'verbose_name_plural': '任务结果',
|
||||
},
|
||||
),
|
||||
]
|
||||
0
tasks/migrations/__init__.py
Normal file
0
tasks/migrations/__init__.py
Normal file
BIN
tasks/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
BIN
tasks/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
Binary file not shown.
BIN
tasks/migrations/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
tasks/migrations/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
60
tasks/models.py
Normal file
60
tasks/models.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
# Status choices for tasks
|
||||
STATUS_CHOICES = [
|
||||
('pending', '待分配'),
|
||||
('assigned', '已分配'),
|
||||
('running', '执行中'),
|
||||
('success', '成功'),
|
||||
('failed', '失败'),
|
||||
('retrying', '重试中'),
|
||||
('timeout', '超时,关闭'),
|
||||
]
|
||||
|
||||
class Client(models.Model):
|
||||
name = models.CharField(max_length=100, unique=True, verbose_name='客户端标识')
|
||||
token = models.CharField(max_length=128, verbose_name='API Token')
|
||||
last_seen = models.DateTimeField(auto_now=True, verbose_name='最后活跃时间')
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
verbose_name = '客户端'
|
||||
verbose_name_plural = '客户端'
|
||||
|
||||
class Task(models.Model):
|
||||
name = models.CharField(max_length=200, verbose_name='任务名称')
|
||||
client_name = models.CharField(max_length=100, null=True, blank=True, verbose_name='指定执行客户端')
|
||||
script = models.TextField(null=True, blank=True, verbose_name='执行脚本')
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name='任务状态')
|
||||
timeout_seconds = models.IntegerField(default=259200, verbose_name='超时时间(秒)') # 默认3天
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间')
|
||||
assigned_to = models.CharField(max_length=100, null=True, blank=True, verbose_name='实际执行客户端')
|
||||
started_at = models.DateTimeField(null=True, blank=True, verbose_name='开始执行时间')
|
||||
completed_at = models.DateTimeField(null=True, blank=True, verbose_name='完成时间')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
verbose_name = '任务'
|
||||
verbose_name_plural = '任务'
|
||||
|
||||
class TaskResult(models.Model):
|
||||
task = models.ForeignKey(Task, on_delete=models.CASCADE, related_name='results', verbose_name='关联任务')
|
||||
client = models.ForeignKey(Client, on_delete=models.CASCADE, verbose_name='执行客户端')
|
||||
result_file = models.FileField(upload_to='task_results/', null=True, blank=True, verbose_name='结果文件')
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, verbose_name='执行状态')
|
||||
message = models.TextField(null=True, blank=True, verbose_name='执行消息')
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.task.name} - {self.client.name} - {self.status}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = '任务结果'
|
||||
verbose_name_plural = '任务结果'
|
||||
30
tasks/serializers.py
Normal file
30
tasks/serializers.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from rest_framework import serializers
|
||||
from .models import Client, Task, TaskResult
|
||||
|
||||
class ClientSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Client
|
||||
fields = ['id', 'name', 'token', 'last_seen', 'created_at']
|
||||
read_only_fields = ['token', 'last_seen', 'created_at']
|
||||
|
||||
class TaskSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Task
|
||||
fields = ['id', 'name', 'client_name', 'script', 'status', 'timeout_seconds', 'created_at', 'updated_at', 'assigned_to', 'started_at', 'completed_at']
|
||||
read_only_fields = ['created_at', 'updated_at', 'assigned_to', 'started_at', 'completed_at']
|
||||
|
||||
class TaskResultSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = TaskResult
|
||||
fields = ['id', 'task', 'client', 'result_file', 'status', 'message', 'created_at']
|
||||
read_only_fields = ['created_at']
|
||||
|
||||
class TaskClaimSerializer(serializers.Serializer):
|
||||
client_name = serializers.CharField(max_length=100)
|
||||
|
||||
class TaskStartSerializer(serializers.Serializer):
|
||||
pass
|
||||
|
||||
class TaskCompleteSerializer(serializers.Serializer):
|
||||
status = serializers.ChoiceField(choices=[('success', '成功'), ('failed', '失败')])
|
||||
message = serializers.CharField(allow_blank=True, required=False)
|
||||
39
tasks/templates/tasks/base.html
Normal file
39
tasks/templates/tasks/base.html
Normal file
@@ -0,0 +1,39 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>任务中心</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="{% url 'task_list' %}">任务中心</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'task_list' %}">任务列表</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'task_create' %}">创建任务</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'client_list' %}">客户端管理</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container mt-4">
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
25
tasks/templates/tasks/client_create.html
Normal file
25
tasks/templates/tasks/client_create.html
Normal file
@@ -0,0 +1,25 @@
|
||||
{% extends 'tasks/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="mb-4">创建客户端</h1>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">客户端名称</label>
|
||||
<input type="text" class="form-control" id="name" name="name" required>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info" role="alert">
|
||||
创建后将自动生成API Token,用于客户端认证。
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">创建客户端</button>
|
||||
<a href="{% url 'client_list' %}" class="btn btn-secondary">取消</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
37
tasks/templates/tasks/client_list.html
Normal file
37
tasks/templates/tasks/client_list.html
Normal file
@@ -0,0 +1,37 @@
|
||||
{% extends 'tasks/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="mb-4">客户端列表</h1>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<a href="{% url 'client_create' %}" class="btn btn-primary">创建新客户端</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>客户端名称</th>
|
||||
<th>API Token</th>
|
||||
<th>最后活跃时间</th>
|
||||
<th>创建时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for client in clients %}
|
||||
<tr>
|
||||
<td>{{ client.id }}</td>
|
||||
<td>{{ client.name }}</td>
|
||||
<td>
|
||||
<code>{{ client.token }}</code>
|
||||
</td>
|
||||
<td>{{ client.last_seen|date:'Y-m-d H:i:s' }}</td>
|
||||
<td>{{ client.created_at|date:'Y-m-d H:i:s' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
96
tasks/templates/tasks/index.html
Normal file
96
tasks/templates/tasks/index.html
Normal file
@@ -0,0 +1,96 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>任务中心 - 首页</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="{% url 'index' %}">任务中心</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" href="{% url 'index' %}">首页</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'task_list' %}">任务列表</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'task_create' %}">创建任务</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'client_list' %}">客户端管理</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/">后台管理</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container mt-5">
|
||||
<div class="jumbotron bg-light p-5 rounded-3">
|
||||
<h1 class="display-4">欢迎使用任务中心</h1>
|
||||
<p class="lead">一个基于Django的任务管理系统,支持客户端任务认领、执行和结果上传。</p>
|
||||
<hr class="my-4">
|
||||
<p>系统功能:</p>
|
||||
<ul>
|
||||
<li>创建和管理任务</li>
|
||||
<li>客户端原子认领任务</li>
|
||||
<li>任务执行状态跟踪</li>
|
||||
<li>结果文件上传下载</li>
|
||||
<li>客户端管理和认证</li>
|
||||
</ul>
|
||||
<div class="mt-4">
|
||||
<a class="btn btn-primary btn-lg" href="{% url 'task_list' %}" role="button">查看任务</a>
|
||||
<a class="btn btn-secondary btn-lg" href="/admin/" role="button">后台管理</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-5">
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">任务管理</h5>
|
||||
<p class="card-text">创建、查看和管理任务,支持设置执行客户端和超时时间。</p>
|
||||
<a href="{% url 'task_list' %}" class="btn btn-primary">进入</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">客户端管理</h5>
|
||||
<p class="card-text">管理客户端列表,自动生成API Token,用于客户端认证。</p>
|
||||
<a href="{% url 'client_list' %}" class="btn btn-primary">进入</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">后台管理</h5>
|
||||
<p class="card-text">使用Django Admin进行系统管理,包括用户、任务和客户端。</p>
|
||||
<a href="/admin/" class="btn btn-primary">进入</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="mt-5 py-4 bg-dark text-white">
|
||||
<div class="container text-center">
|
||||
<p>© 2025 任务中心管理系统</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
37
tasks/templates/tasks/task_create.html
Normal file
37
tasks/templates/tasks/task_create.html
Normal file
@@ -0,0 +1,37 @@
|
||||
{% extends 'tasks/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="mb-4">创建任务</h1>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">任务名称</label>
|
||||
<input type="text" class="form-control" id="name" name="name" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="client_name" class="form-label">指定执行客户端(可选)</label>
|
||||
<input type="text" class="form-control" id="client_name" name="client_name">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="script" class="form-label">执行脚本(可选)</label>
|
||||
<textarea class="form-control" id="script" name="script" rows="5"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="timeout_seconds" class="form-label">超时时间(秒)</label>
|
||||
<input type="number" class="form-control" id="timeout_seconds" name="timeout_seconds" value="259200">
|
||||
<div class="form-text">默认3天(259200秒)</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">创建任务</button>
|
||||
<a href="{% url 'task_list' %}" class="btn btn-secondary">取消</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
98
tasks/templates/tasks/task_detail.html
Normal file
98
tasks/templates/tasks/task_detail.html
Normal file
@@ -0,0 +1,98 @@
|
||||
{% extends 'tasks/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="mb-4">任务详情</h1>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5>{{ task.name }}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<p><strong>ID:</strong> {{ task.id }}</p>
|
||||
<p><strong>指定客户端:</strong> {{ task.client_name|default:'未指定' }}</p>
|
||||
<p><strong>实际执行客户端:</strong> {{ task.assigned_to|default:'未分配' }}</p>
|
||||
<p><strong>状态:</strong>
|
||||
<span class="badge bg-{{
|
||||
'success' if task.status == 'success' else
|
||||
'danger' if task.status in ['failed', 'timeout'] else
|
||||
'warning' if task.status in ['assigned', 'running', 'retrying'] else
|
||||
'info'
|
||||
}}">
|
||||
{{ task.get_status_display }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<p><strong>创建时间:</strong> {{ task.created_at|date:'Y-m-d H:i:s' }}</p>
|
||||
<p><strong>更新时间:</strong> {{ task.updated_at|date:'Y-m-d H:i:s' }}</p>
|
||||
<p><strong>开始执行时间:</strong> {{ task.started_at|default:'未开始'|date:'Y-m-d H:i:s' }}</p>
|
||||
<p><strong>完成时间:</strong> {{ task.completed_at|default:'未完成'|date:'Y-m-d H:i:s' }}</p>
|
||||
<p><strong>超时时间:</strong> {{ task.timeout_seconds }} 秒</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if task.script %}
|
||||
<div class="mt-4">
|
||||
<h6>执行脚本:</h6>
|
||||
<pre class="bg-light p-3 rounded">{{ task.script }}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="mb-4">执行结果</h2>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
{% if results %}
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>执行客户端</th>
|
||||
<th>状态</th>
|
||||
<th>执行消息</th>
|
||||
<th>创建时间</th>
|
||||
<th>结果文件</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for result in results %}
|
||||
<tr>
|
||||
<td>{{ result.id }}</td>
|
||||
<td>{{ result.client.name }}</td>
|
||||
<td>
|
||||
<span class="badge bg-{{
|
||||
'success' if result.status == 'success' else
|
||||
'danger' if result.status in ['failed', 'timeout'] else
|
||||
'warning' if result.status in ['assigned', 'running', 'retrying'] else
|
||||
'info'
|
||||
}}">
|
||||
{{ result.get_status_display }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ result.message|default:'无消息' }}</td>
|
||||
<td>{{ result.created_at|date:'Y-m-d H:i:s' }}</td>
|
||||
<td>
|
||||
{% if result.result_file %}
|
||||
<a href="/api/task_results/{{ result.id }}/download/" class="btn btn-sm btn-success">下载</a>
|
||||
{% else %}
|
||||
无文件
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="text-center text-muted">暂无执行结果</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<a href="{% url 'task_list' %}" class="btn btn-secondary">返回任务列表</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
48
tasks/templates/tasks/task_list.html
Normal file
48
tasks/templates/tasks/task_list.html
Normal file
@@ -0,0 +1,48 @@
|
||||
{% extends 'tasks/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="mb-4">任务列表</h1>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<a href="{% url 'task_create' %}" class="btn btn-primary">创建新任务</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>任务名称</th>
|
||||
<th>指定客户端</th>
|
||||
<th>状态</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for task in tasks %}
|
||||
<tr>
|
||||
<td>{{ task.id }}</td>
|
||||
<td>{{ task.name }}</td>
|
||||
<td>{{ task.client_name|default:'未指定' }}</td>
|
||||
<td>
|
||||
<span class="badge bg-{{
|
||||
'success' if task.status == 'success' else
|
||||
'danger' if task.status in ['failed', 'timeout'] else
|
||||
'warning' if task.status in ['assigned', 'running', 'retrying'] else
|
||||
'info'
|
||||
}}">
|
||||
{{ task.get_status_display }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ task.created_at|date:'Y-m-d H:i' }}</td>
|
||||
<td>
|
||||
<a href="{% url 'task_detail' task.id %}" class="btn btn-sm btn-info">详情</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
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.
|
||||
0
tasks/tests/__init__.py
Normal file
0
tasks/tests/__init__.py
Normal file
BIN
tasks/tests/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
tasks/tests/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
tasks/tests/__pycache__/test_api.cpython-311-pytest-8.3.2.pyc
Normal file
BIN
tasks/tests/__pycache__/test_api.cpython-311-pytest-8.3.2.pyc
Normal file
Binary file not shown.
BIN
tasks/tests/__pycache__/test_api.cpython-311.pyc
Normal file
BIN
tasks/tests/__pycache__/test_api.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
tasks/tests/__pycache__/test_factories.cpython-311.pyc
Normal file
BIN
tasks/tests/__pycache__/test_factories.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
tasks/tests/__pycache__/test_integration.cpython-311.pyc
Normal file
BIN
tasks/tests/__pycache__/test_integration.cpython-311.pyc
Normal file
Binary file not shown.
BIN
tasks/tests/__pycache__/test_models.cpython-311-pytest-8.3.2.pyc
Normal file
BIN
tasks/tests/__pycache__/test_models.cpython-311-pytest-8.3.2.pyc
Normal file
Binary file not shown.
BIN
tasks/tests/__pycache__/test_models.cpython-311.pyc
Normal file
BIN
tasks/tests/__pycache__/test_models.cpython-311.pyc
Normal file
Binary file not shown.
BIN
tasks/tests/__pycache__/test_views.cpython-311-pytest-8.3.2.pyc
Normal file
BIN
tasks/tests/__pycache__/test_views.cpython-311-pytest-8.3.2.pyc
Normal file
Binary file not shown.
BIN
tasks/tests/__pycache__/test_views.cpython-311.pyc
Normal file
BIN
tasks/tests/__pycache__/test_views.cpython-311.pyc
Normal file
Binary file not shown.
128
tasks/tests/test_api.py
Normal file
128
tasks/tests/test_api.py
Normal file
@@ -0,0 +1,128 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
from tasks.models import Client, Task, TaskResult
|
||||
from tasks.tests.test_factories import ClientFactory, TaskFactory, TaskResultFactory
|
||||
|
||||
class TaskAPITest(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
# Create a test client with token
|
||||
self.test_client = ClientFactory()
|
||||
self.token = self.test_client.token
|
||||
# Authenticate the client
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token}')
|
||||
|
||||
def test_task_list(self):
|
||||
"""Test that authenticated users can list tasks"""
|
||||
# Create some test tasks
|
||||
TaskFactory.create_batch(3)
|
||||
|
||||
url = reverse('task-list')
|
||||
response = self.client.get(url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 3)
|
||||
|
||||
def test_task_create(self):
|
||||
"""Test that authenticated users can create tasks"""
|
||||
url = reverse('task-list')
|
||||
data = {
|
||||
'name': 'test_task',
|
||||
'client_name': 'test_client',
|
||||
'script': 'echo "Hello World"',
|
||||
'timeout_seconds': 3600
|
||||
}
|
||||
|
||||
response = self.client.post(url, data, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Task.objects.count(), 1)
|
||||
self.assertEqual(Task.objects.get().name, 'test_task')
|
||||
|
||||
def test_task_claim(self):
|
||||
"""Test that clients can claim available tasks"""
|
||||
# Create a pending task
|
||||
TaskFactory(client_name='test_client')
|
||||
|
||||
url = reverse('task-claim')
|
||||
data = {
|
||||
'client_name': 'test_client'
|
||||
}
|
||||
|
||||
response = self.client.post(url, data, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['status'], 'assigned')
|
||||
self.assertEqual(response.data['assigned_to'], 'test_client')
|
||||
|
||||
def test_unauthenticated_access(self):
|
||||
"""Test that unauthenticated users cannot access API endpoints"""
|
||||
# Create a new client without authentication
|
||||
unauth_client = APIClient()
|
||||
|
||||
url = reverse('task-list')
|
||||
response = unauth_client.get(url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
class ClientAPITest(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
# Create a test client with token
|
||||
self.test_client = ClientFactory()
|
||||
self.token = self.test_client.token
|
||||
# Authenticate the client
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token}')
|
||||
|
||||
def test_client_list(self):
|
||||
"""Test that authenticated users can list clients"""
|
||||
# Create some test clients
|
||||
ClientFactory.create_batch(3)
|
||||
|
||||
url = reverse('client-list')
|
||||
response = self.client.get(url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 4) # Including the test client
|
||||
|
||||
def test_client_create(self):
|
||||
"""Test that authenticated users can create clients"""
|
||||
url = reverse('client-list')
|
||||
data = {
|
||||
'name': 'new_client'
|
||||
}
|
||||
|
||||
response = self.client.post(url, data, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Client.objects.count(), 2) # Including the test client
|
||||
self.assertEqual(Client.objects.get(id=response.data['id']).name, 'new_client')
|
||||
|
||||
class TaskResultAPITest(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
# Create a test client with token
|
||||
self.test_client = ClientFactory()
|
||||
self.token = self.test_client.token
|
||||
# Authenticate the client
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token}')
|
||||
# Create a test task
|
||||
self.task = TaskFactory()
|
||||
|
||||
def test_task_result_create(self):
|
||||
"""Test that authenticated users can create task results"""
|
||||
url = reverse('taskresult-list')
|
||||
data = {
|
||||
'task': self.task.id,
|
||||
'client': self.test_client.id,
|
||||
'status': 'success',
|
||||
'message': 'Task completed successfully'
|
||||
}
|
||||
|
||||
response = self.client.post(url, data, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
self.assertEqual(TaskResult.objects.count(), 1)
|
||||
self.assertEqual(TaskResult.objects.get().status, 'success')
|
||||
38
tasks/tests/test_factories.py
Normal file
38
tasks/tests/test_factories.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import factory
|
||||
from django.utils import timezone
|
||||
from tasks.models import Client, Task, TaskResult
|
||||
|
||||
class ClientFactory(factory.django.DjangoModelFactory):
|
||||
class Meta:
|
||||
model = Client
|
||||
|
||||
name = factory.Sequence(lambda n: f"client_{n}")
|
||||
token = factory.Faker('uuid4')
|
||||
last_seen = timezone.now()
|
||||
created_at = timezone.now()
|
||||
|
||||
class TaskFactory(factory.django.DjangoModelFactory):
|
||||
class Meta:
|
||||
model = Task
|
||||
|
||||
name = factory.Sequence(lambda n: f"task_{n}")
|
||||
client_name = factory.Sequence(lambda n: f"client_{n}")
|
||||
script = factory.Faker('text')
|
||||
status = 'pending'
|
||||
timeout_seconds = 3600 # 1 hour
|
||||
created_at = timezone.now()
|
||||
updated_at = timezone.now()
|
||||
assigned_to = None
|
||||
started_at = None
|
||||
completed_at = None
|
||||
|
||||
class TaskResultFactory(factory.django.DjangoModelFactory):
|
||||
class Meta:
|
||||
model = TaskResult
|
||||
|
||||
task = factory.SubFactory(TaskFactory)
|
||||
client = factory.SubFactory(ClientFactory)
|
||||
result_file = None
|
||||
status = 'success'
|
||||
message = factory.Faker('text')
|
||||
created_at = timezone.now()
|
||||
89
tasks/tests/test_integration.py
Normal file
89
tasks/tests/test_integration.py
Normal file
@@ -0,0 +1,89 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
from tasks.models import Client, Task, TaskResult
|
||||
from tasks.tests.test_factories import ClientFactory, TaskFactory
|
||||
|
||||
class TaskFlowIntegrationTest(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
# Create a test client with token
|
||||
self.test_client = ClientFactory()
|
||||
self.token = self.test_client.token
|
||||
# Authenticate the client
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token}')
|
||||
|
||||
def test_full_task_flow(self):
|
||||
"""Test the full task flow: create → claim → start → complete"""
|
||||
# 1. Create a task
|
||||
create_url = reverse('task-list')
|
||||
create_data = {
|
||||
'name': 'integration_test_task',
|
||||
'client_name': self.test_client.name,
|
||||
'script': 'echo "Integration Test"',
|
||||
'timeout_seconds': 3600
|
||||
}
|
||||
create_response = self.client.post(create_url, create_data, format='json')
|
||||
self.assertEqual(create_response.status_code, status.HTTP_201_CREATED)
|
||||
task_id = create_response.data['id']
|
||||
|
||||
# 2. Claim the task
|
||||
claim_url = reverse('task-claim')
|
||||
claim_data = {
|
||||
'client_name': self.test_client.name
|
||||
}
|
||||
claim_response = self.client.post(claim_url, claim_data, format='json')
|
||||
self.assertEqual(claim_response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(claim_response.data['status'], 'assigned')
|
||||
self.assertEqual(claim_response.data['assigned_to'], self.test_client.name)
|
||||
|
||||
# 3. Start the task
|
||||
start_url = reverse('task-start', args=[task_id])
|
||||
start_response = self.client.post(start_url, format='json')
|
||||
self.assertEqual(start_response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(start_response.data['status'], 'running')
|
||||
self.assertIsNotNone(start_response.data['started_at'])
|
||||
|
||||
# 4. Complete the task
|
||||
complete_url = reverse('task-complete', args=[task_id])
|
||||
complete_data = {
|
||||
'status': 'success',
|
||||
'message': 'Task completed successfully'
|
||||
}
|
||||
complete_response = self.client.post(complete_url, complete_data, format='json')
|
||||
self.assertEqual(complete_response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(complete_response.data['status'], 'success')
|
||||
self.assertIsNotNone(complete_response.data['completed_at'])
|
||||
|
||||
# 5. Verify the task status in database
|
||||
task = Task.objects.get(id=task_id)
|
||||
self.assertEqual(task.status, 'success')
|
||||
self.assertEqual(task.assigned_to, self.test_client.name)
|
||||
self.assertIsNotNone(task.started_at)
|
||||
self.assertIsNotNone(task.completed_at)
|
||||
|
||||
def test_task_result_upload(self):
|
||||
"""Test that a client can upload task results"""
|
||||
# Create a test task
|
||||
task = TaskFactory()
|
||||
|
||||
# Upload a task result
|
||||
upload_url = reverse('taskresult-list')
|
||||
upload_data = {
|
||||
'task': task.id,
|
||||
'client': self.test_client.id,
|
||||
'status': 'success',
|
||||
'message': 'Result uploaded successfully'
|
||||
}
|
||||
|
||||
upload_response = self.client.post(upload_url, upload_data, format='json')
|
||||
self.assertEqual(upload_response.status_code, status.HTTP_201_CREATED)
|
||||
|
||||
# Verify the result exists in database
|
||||
self.assertEqual(TaskResult.objects.count(), 1)
|
||||
result = TaskResult.objects.get()
|
||||
self.assertEqual(result.task, task)
|
||||
self.assertEqual(result.client, self.test_client)
|
||||
self.assertEqual(result.status, 'success')
|
||||
109
tasks/tests/test_models.py
Normal file
109
tasks/tests/test_models.py
Normal file
@@ -0,0 +1,109 @@
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from tasks.models import Client, Task, TaskResult
|
||||
|
||||
class ClientModelTest(TestCase):
|
||||
def test_client_creation(self):
|
||||
"""Test that a client can be created with all required fields"""
|
||||
client = Client.objects.create(
|
||||
name="test_client",
|
||||
token="test_token_12345"
|
||||
)
|
||||
|
||||
self.assertEqual(client.name, "test_client")
|
||||
self.assertEqual(client.token, "test_token_12345")
|
||||
self.assertIsNotNone(client.created_at)
|
||||
self.assertIsNotNone(client.last_seen)
|
||||
|
||||
def test_client_str(self):
|
||||
"""Test that the client string representation is correct"""
|
||||
client = Client.objects.create(
|
||||
name="test_client",
|
||||
token="test_token_12345"
|
||||
)
|
||||
|
||||
self.assertEqual(str(client), "test_client")
|
||||
|
||||
class TaskModelTest(TestCase):
|
||||
def test_task_creation(self):
|
||||
"""Test that a task can be created with all required fields"""
|
||||
task = Task.objects.create(
|
||||
name="test_task",
|
||||
client_name="test_client",
|
||||
script="echo 'Hello World'",
|
||||
timeout_seconds=3600
|
||||
)
|
||||
|
||||
self.assertEqual(task.name, "test_task")
|
||||
self.assertEqual(task.client_name, "test_client")
|
||||
self.assertEqual(task.script, "echo 'Hello World'")
|
||||
self.assertEqual(task.status, "pending")
|
||||
self.assertEqual(task.timeout_seconds, 3600)
|
||||
self.assertIsNotNone(task.created_at)
|
||||
self.assertIsNotNone(task.updated_at)
|
||||
|
||||
def test_task_str(self):
|
||||
"""Test that the task string representation is correct"""
|
||||
task = Task.objects.create(
|
||||
name="test_task"
|
||||
)
|
||||
|
||||
self.assertEqual(str(task), "test_task")
|
||||
|
||||
def test_task_status_choices(self):
|
||||
"""Test that task status choices are correctly implemented"""
|
||||
from tasks.models import STATUS_CHOICES
|
||||
status_choices = [choice[0] for choice in STATUS_CHOICES]
|
||||
|
||||
self.assertIn("pending", status_choices)
|
||||
self.assertIn("assigned", status_choices)
|
||||
self.assertIn("running", status_choices)
|
||||
self.assertIn("success", status_choices)
|
||||
self.assertIn("failed", status_choices)
|
||||
self.assertIn("retrying", status_choices)
|
||||
self.assertIn("timeout", status_choices)
|
||||
|
||||
class TaskResultModelTest(TestCase):
|
||||
def test_task_result_creation(self):
|
||||
"""Test that a task result can be created with all required fields"""
|
||||
client = Client.objects.create(
|
||||
name="test_client",
|
||||
token="test_token_12345"
|
||||
)
|
||||
|
||||
task = Task.objects.create(
|
||||
name="test_task"
|
||||
)
|
||||
|
||||
result = TaskResult.objects.create(
|
||||
task=task,
|
||||
client=client,
|
||||
status="success",
|
||||
message="Task completed successfully"
|
||||
)
|
||||
|
||||
self.assertEqual(result.task, task)
|
||||
self.assertEqual(result.client, client)
|
||||
self.assertEqual(result.status, "success")
|
||||
self.assertEqual(result.message, "Task completed successfully")
|
||||
self.assertIsNotNone(result.created_at)
|
||||
|
||||
def test_task_result_str(self):
|
||||
"""Test that the task result string representation is correct"""
|
||||
client = Client.objects.create(
|
||||
name="test_client",
|
||||
token="test_token_12345"
|
||||
)
|
||||
|
||||
task = Task.objects.create(
|
||||
name="test_task"
|
||||
)
|
||||
|
||||
result = TaskResult.objects.create(
|
||||
task=task,
|
||||
client=client,
|
||||
status="success"
|
||||
)
|
||||
|
||||
# Expected status display is in Chinese
|
||||
self.assertEqual(str(result), f"{task.name} - {client.name} - 成功")
|
||||
60
tasks/tests/test_views.py
Normal file
60
tasks/tests/test_views.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from tasks.models import Client, Task
|
||||
from tasks.tests.test_factories import ClientFactory, TaskFactory
|
||||
|
||||
class TaskViewTest(TestCase):
|
||||
def test_task_list_view(self):
|
||||
"""Test that the task list view renders correctly"""
|
||||
# Create some test tasks
|
||||
TaskFactory.create_batch(3)
|
||||
|
||||
url = reverse('task_list')
|
||||
response = self.client.get(url)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTemplateUsed(response, 'tasks/task_list.html')
|
||||
self.assertContains(response, '任务列表')
|
||||
|
||||
def test_task_create_view(self):
|
||||
"""Test that the task create view renders correctly"""
|
||||
url = reverse('task_create')
|
||||
response = self.client.get(url)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTemplateUsed(response, 'tasks/task_create.html')
|
||||
self.assertContains(response, '创建任务')
|
||||
|
||||
def test_task_detail_view(self):
|
||||
"""Test that the task detail view renders correctly"""
|
||||
# Create a test task
|
||||
task = TaskFactory()
|
||||
|
||||
url = reverse('task_detail', args=[task.id])
|
||||
response = self.client.get(url)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTemplateUsed(response, 'tasks/task_detail.html')
|
||||
self.assertContains(response, task.name)
|
||||
|
||||
class ClientViewTest(TestCase):
|
||||
def test_client_list_view(self):
|
||||
"""Test that the client list view renders correctly"""
|
||||
# Create some test clients
|
||||
ClientFactory.create_batch(3)
|
||||
|
||||
url = reverse('client_list')
|
||||
response = self.client.get(url)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTemplateUsed(response, 'tasks/client_list.html')
|
||||
self.assertContains(response, '客户端列表')
|
||||
|
||||
def test_client_create_view(self):
|
||||
"""Test that the client create view renders correctly"""
|
||||
url = reverse('client_create')
|
||||
response = self.client.get(url)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTemplateUsed(response, 'tasks/client_create.html')
|
||||
self.assertContains(response, '创建客户端')
|
||||
23
tasks/urls.py
Normal file
23
tasks/urls.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import ClientViewSet, TaskViewSet, TaskResultViewSet
|
||||
from .views_frontend import (
|
||||
index, task_list, task_create, task_detail,
|
||||
client_list, client_create
|
||||
)
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'clients', ClientViewSet)
|
||||
router.register(r'tasks', TaskViewSet)
|
||||
router.register(r'task_results', TaskResultViewSet)
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
# Frontend views
|
||||
path('index/', index, name='index'),
|
||||
path('tasks/list/', task_list, name='task_list'),
|
||||
path('tasks/create/', task_create, name='task_create'),
|
||||
path('tasks/<int:task_id>/', task_detail, name='task_detail'),
|
||||
path('clients/list/', client_list, name='client_list'),
|
||||
path('clients/create/', client_create, name='client_create'),
|
||||
]
|
||||
105
tasks/views.py
Normal file
105
tasks/views.py
Normal file
@@ -0,0 +1,105 @@
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.parsers import MultiPartParser, FormParser
|
||||
from .models import Client, Task, TaskResult
|
||||
from .serializers import (
|
||||
ClientSerializer,
|
||||
TaskSerializer,
|
||||
TaskResultSerializer,
|
||||
TaskClaimSerializer,
|
||||
TaskStartSerializer,
|
||||
TaskCompleteSerializer
|
||||
)
|
||||
|
||||
class ClientViewSet(viewsets.ModelViewSet):
|
||||
queryset = Client.objects.all()
|
||||
serializer_class = ClientSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
class TaskViewSet(viewsets.ModelViewSet):
|
||||
queryset = Task.objects.all()
|
||||
serializer_class = TaskSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@action(detail=False, methods=['post'], serializer_class=TaskClaimSerializer)
|
||||
def claim(self, request):
|
||||
"""Client claims an available task"""
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
client_name = serializer.validated_data['client_name']
|
||||
|
||||
# Try to find a pending task, either assigned to this client or unassigned
|
||||
with self.queryset.model.objects._base_manager._db.transaction.atomic():
|
||||
# First try to find tasks assigned to this client
|
||||
task = Task.objects.filter(
|
||||
status='pending',
|
||||
client_name=client_name
|
||||
).select_for_update().first()
|
||||
|
||||
# If no task assigned to this client, try to find unassigned tasks
|
||||
if not task:
|
||||
task = Task.objects.filter(
|
||||
status='pending',
|
||||
client_name__isnull=True
|
||||
).select_for_update().first()
|
||||
|
||||
if not task:
|
||||
return Response({'detail': 'No available tasks'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# Assign the task to the client
|
||||
task.status = 'assigned'
|
||||
task.assigned_to = client_name
|
||||
task.save()
|
||||
|
||||
return Response(TaskSerializer(task).data, status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=True, methods=['post'], serializer_class=TaskStartSerializer)
|
||||
def start(self, request, pk=None):
|
||||
"""Client starts a task"""
|
||||
task = self.get_object()
|
||||
if task.status != 'assigned':
|
||||
return Response({'detail': 'Task is not assigned'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
task.status = 'running'
|
||||
task.started_at = timezone.now()
|
||||
task.save()
|
||||
|
||||
return Response(TaskSerializer(task).data, status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=True, methods=['post'], serializer_class=TaskCompleteSerializer)
|
||||
def complete(self, request, pk=None):
|
||||
"""Client completes a task"""
|
||||
task = self.get_object()
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
if task.status != 'running':
|
||||
return Response({'detail': 'Task is not running'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
task.status = serializer.validated_data['status']
|
||||
task.completed_at = timezone.now()
|
||||
task.save()
|
||||
|
||||
return Response(TaskSerializer(task).data, status=status.HTTP_200_OK)
|
||||
|
||||
class TaskResultViewSet(viewsets.ModelViewSet):
|
||||
queryset = TaskResult.objects.all()
|
||||
serializer_class = TaskResultSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
parser_classes = [MultiPartParser, FormParser]
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def download(self, request, pk=None):
|
||||
"""Download task result file"""
|
||||
task_result = self.get_object()
|
||||
if not task_result.result_file:
|
||||
return Response({'detail': 'No file available'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# Use Django's built-in FileResponse for downloading
|
||||
from django.http import FileResponse
|
||||
return FileResponse(task_result.result_file.open('rb'), as_attachment=True)
|
||||
|
||||
54
tasks/views_frontend.py
Normal file
54
tasks/views_frontend.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from django.shortcuts import render, redirect, get_object_or_404
|
||||
from django.contrib import messages
|
||||
from .models import Task, Client, TaskResult
|
||||
|
||||
# Home view
|
||||
def index(request):
|
||||
return render(request, 'tasks/index.html')
|
||||
|
||||
# Task views
|
||||
def task_list(request):
|
||||
tasks = Task.objects.all()
|
||||
return render(request, 'tasks/task_list.html', {'tasks': tasks})
|
||||
|
||||
def task_create(request):
|
||||
if request.method == 'POST':
|
||||
name = request.POST['name']
|
||||
client_name = request.POST.get('client_name', '')
|
||||
script = request.POST.get('script', '')
|
||||
timeout_seconds = int(request.POST.get('timeout_seconds', 259200))
|
||||
|
||||
Task.objects.create(
|
||||
name=name,
|
||||
client_name=client_name if client_name else None,
|
||||
script=script if script else None,
|
||||
timeout_seconds=timeout_seconds
|
||||
)
|
||||
messages.success(request, '任务创建成功!')
|
||||
return redirect('task_list')
|
||||
return render(request, 'tasks/task_create.html')
|
||||
|
||||
def task_detail(request, task_id):
|
||||
task = get_object_or_404(Task, id=task_id)
|
||||
results = task.results.all()
|
||||
return render(request, 'tasks/task_detail.html', {'task': task, 'results': results})
|
||||
|
||||
# Client views
|
||||
def client_list(request):
|
||||
clients = Client.objects.all()
|
||||
return render(request, 'tasks/client_list.html', {'clients': clients})
|
||||
|
||||
def client_create(request):
|
||||
if request.method == 'POST':
|
||||
name = request.POST['name']
|
||||
# Generate a simple token for demo purposes
|
||||
import secrets
|
||||
token = secrets.token_urlsafe(32)
|
||||
|
||||
Client.objects.create(
|
||||
name=name,
|
||||
token=token
|
||||
)
|
||||
messages.success(request, '客户端创建成功!')
|
||||
return redirect('client_list')
|
||||
return render(request, 'tasks/client_create.html')
|
||||
Reference in New Issue
Block a user