commit 2ca8b8d181cdac43508240dfe09e9b100b288300 Author: xiaji Date: Fri Dec 5 13:45:16 2025 +0800 创建中心任务的管理系统 diff --git a/.trae/documents/任务中心管理系统实现计划.md b/.trae/documents/任务中心管理系统实现计划.md new file mode 100644 index 0000000..6178948 --- /dev/null +++ b/.trae/documents/任务中心管理系统实现计划.md @@ -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 `进行身份验证 +- 使用Django REST Framework的TokenAuthentication +- 未提供有效token的请求将返回401 Unauthorized + +### 任务管理API +- `GET /api/tasks/` - 获取任务列表(需认证) +- `GET /api/tasks//` - 获取任务详情(需认证) +- `POST /api/tasks/` - 创建任务(需认证) +- `PUT /api/tasks//` - 更新任务(需认证) +- `DELETE /api/tasks//` - 删除任务(需认证) + +### 客户端API +- `POST /api/tasks/claim/` - 客户端原子认领任务(需认证) +- `POST /api/tasks//start/` - 客户端开始执行任务(需认证) +- `POST /api/tasks//complete/` - 客户端完成任务(需认证) +- `POST /api/task_results/` - 上传任务结果(需认证,支持文件上传) + +### 客户端管理API +- `GET /api/clients/` - 获取客户端列表(需认证) +- `POST /api/clients/` - 创建客户端(需认证) +- `GET /api/clients//` - 获取客户端详情(需认证) + +### 文件下载API +- `GET /api/task_results//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//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前端界面 +- 全面的测试用例覆盖 \ No newline at end of file diff --git a/db.sqlite3 b/db.sqlite3 new file mode 100644 index 0000000..f184754 Binary files /dev/null and b/db.sqlite3 differ diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..0bcddb2 --- /dev/null +++ b/manage.py @@ -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() diff --git a/task_center/__init__.py b/task_center/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/task_center/__pycache__/__init__.cpython-311.pyc b/task_center/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..a3788e4 Binary files /dev/null and b/task_center/__pycache__/__init__.cpython-311.pyc differ diff --git a/task_center/__pycache__/settings.cpython-311.pyc b/task_center/__pycache__/settings.cpython-311.pyc new file mode 100644 index 0000000..e45fd21 Binary files /dev/null and b/task_center/__pycache__/settings.cpython-311.pyc differ diff --git a/task_center/__pycache__/urls.cpython-311.pyc b/task_center/__pycache__/urls.cpython-311.pyc new file mode 100644 index 0000000..0f1ee79 Binary files /dev/null and b/task_center/__pycache__/urls.cpython-311.pyc differ diff --git a/task_center/__pycache__/wsgi.cpython-311.pyc b/task_center/__pycache__/wsgi.cpython-311.pyc new file mode 100644 index 0000000..65d564b Binary files /dev/null and b/task_center/__pycache__/wsgi.cpython-311.pyc differ diff --git a/task_center/asgi.py b/task_center/asgi.py new file mode 100644 index 0000000..30d87d2 --- /dev/null +++ b/task_center/asgi.py @@ -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() diff --git a/task_center/settings.py b/task_center/settings.py new file mode 100644 index 0000000..903e6bb --- /dev/null +++ b/task_center/settings.py @@ -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 diff --git a/task_center/urls.py b/task_center/urls.py new file mode 100644 index 0000000..99fbe54 --- /dev/null +++ b/task_center/urls.py @@ -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) diff --git a/task_center/wsgi.py b/task_center/wsgi.py new file mode 100644 index 0000000..af6dfcf --- /dev/null +++ b/task_center/wsgi.py @@ -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() diff --git a/tasks/__init__.py b/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tasks/__pycache__/__init__.cpython-311.pyc b/tasks/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..e3bd103 Binary files /dev/null and b/tasks/__pycache__/__init__.cpython-311.pyc differ diff --git a/tasks/__pycache__/admin.cpython-311.pyc b/tasks/__pycache__/admin.cpython-311.pyc new file mode 100644 index 0000000..b9941d0 Binary files /dev/null and b/tasks/__pycache__/admin.cpython-311.pyc differ diff --git a/tasks/__pycache__/apps.cpython-311.pyc b/tasks/__pycache__/apps.cpython-311.pyc new file mode 100644 index 0000000..e19ef92 Binary files /dev/null and b/tasks/__pycache__/apps.cpython-311.pyc differ diff --git a/tasks/__pycache__/models.cpython-311.pyc b/tasks/__pycache__/models.cpython-311.pyc new file mode 100644 index 0000000..c263114 Binary files /dev/null and b/tasks/__pycache__/models.cpython-311.pyc differ diff --git a/tasks/__pycache__/serializers.cpython-311.pyc b/tasks/__pycache__/serializers.cpython-311.pyc new file mode 100644 index 0000000..6cae975 Binary files /dev/null and b/tasks/__pycache__/serializers.cpython-311.pyc differ diff --git a/tasks/__pycache__/urls.cpython-311.pyc b/tasks/__pycache__/urls.cpython-311.pyc new file mode 100644 index 0000000..1150be7 Binary files /dev/null and b/tasks/__pycache__/urls.cpython-311.pyc differ diff --git a/tasks/__pycache__/views.cpython-311.pyc b/tasks/__pycache__/views.cpython-311.pyc new file mode 100644 index 0000000..e1bfa86 Binary files /dev/null and b/tasks/__pycache__/views.cpython-311.pyc differ diff --git a/tasks/__pycache__/views_frontend.cpython-311.pyc b/tasks/__pycache__/views_frontend.cpython-311.pyc new file mode 100644 index 0000000..5c3b924 Binary files /dev/null and b/tasks/__pycache__/views_frontend.cpython-311.pyc differ diff --git a/tasks/admin.py b/tasks/admin.py new file mode 100644 index 0000000..ea5d68b --- /dev/null +++ b/tasks/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/tasks/apps.py b/tasks/apps.py new file mode 100644 index 0000000..ba66733 --- /dev/null +++ b/tasks/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class TasksConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'tasks' diff --git a/tasks/management/__init__.py b/tasks/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tasks/management/__pycache__/__init__.cpython-311.pyc b/tasks/management/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..b01b4bc Binary files /dev/null and b/tasks/management/__pycache__/__init__.cpython-311.pyc differ diff --git a/tasks/management/commands/__init__.py b/tasks/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tasks/management/commands/__pycache__/__init__.cpython-311.pyc b/tasks/management/commands/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..ebdeaf1 Binary files /dev/null and b/tasks/management/commands/__pycache__/__init__.cpython-311.pyc differ diff --git a/tasks/management/commands/check_task_timeouts.py b/tasks/management/commands/check_task_timeouts.py new file mode 100644 index 0000000..733b77b --- /dev/null +++ b/tasks/management/commands/check_task_timeouts.py @@ -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')) diff --git a/tasks/migrations/0001_initial.py b/tasks/migrations/0001_initial.py new file mode 100644 index 0000000..64e5e3c --- /dev/null +++ b/tasks/migrations/0001_initial.py @@ -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': '任务结果', + }, + ), + ] diff --git a/tasks/migrations/__init__.py b/tasks/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tasks/migrations/__pycache__/0001_initial.cpython-311.pyc b/tasks/migrations/__pycache__/0001_initial.cpython-311.pyc new file mode 100644 index 0000000..be66cc7 Binary files /dev/null and b/tasks/migrations/__pycache__/0001_initial.cpython-311.pyc differ diff --git a/tasks/migrations/__pycache__/__init__.cpython-311.pyc b/tasks/migrations/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..7c2dce7 Binary files /dev/null and b/tasks/migrations/__pycache__/__init__.cpython-311.pyc differ diff --git a/tasks/models.py b/tasks/models.py new file mode 100644 index 0000000..71eeb1e --- /dev/null +++ b/tasks/models.py @@ -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 = '任务结果' diff --git a/tasks/serializers.py b/tasks/serializers.py new file mode 100644 index 0000000..fd2b33f --- /dev/null +++ b/tasks/serializers.py @@ -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) diff --git a/tasks/templates/tasks/base.html b/tasks/templates/tasks/base.html new file mode 100644 index 0000000..ae9efcd --- /dev/null +++ b/tasks/templates/tasks/base.html @@ -0,0 +1,39 @@ + + + + + + 任务中心 + + + + + +
+ {% block content %} + {% endblock %} +
+ + + + \ No newline at end of file diff --git a/tasks/templates/tasks/client_create.html b/tasks/templates/tasks/client_create.html new file mode 100644 index 0000000..7637f40 --- /dev/null +++ b/tasks/templates/tasks/client_create.html @@ -0,0 +1,25 @@ +{% extends 'tasks/base.html' %} + +{% block content %} +

创建客户端

+ +
+
+
+ {% csrf_token %} + +
+ + +
+ + + + + 取消 +
+
+
+{% endblock %} \ No newline at end of file diff --git a/tasks/templates/tasks/client_list.html b/tasks/templates/tasks/client_list.html new file mode 100644 index 0000000..57c49f8 --- /dev/null +++ b/tasks/templates/tasks/client_list.html @@ -0,0 +1,37 @@ +{% extends 'tasks/base.html' %} + +{% block content %} +

客户端列表

+ +
+ +
+ + + + + + + + + + + + {% for client in clients %} + + + + + + + + {% endfor %} + +
ID客户端名称API Token最后活跃时间创建时间
{{ client.id }}{{ client.name }} + {{ client.token }} + {{ client.last_seen|date:'Y-m-d H:i:s' }}{{ client.created_at|date:'Y-m-d H:i:s' }}
+
+
+{% endblock %} \ No newline at end of file diff --git a/tasks/templates/tasks/index.html b/tasks/templates/tasks/index.html new file mode 100644 index 0000000..dba7006 --- /dev/null +++ b/tasks/templates/tasks/index.html @@ -0,0 +1,96 @@ + + + + + + 任务中心 - 首页 + + + + + +
+
+

欢迎使用任务中心

+

一个基于Django的任务管理系统,支持客户端任务认领、执行和结果上传。

+
+

系统功能:

+
    +
  • 创建和管理任务
  • +
  • 客户端原子认领任务
  • +
  • 任务执行状态跟踪
  • +
  • 结果文件上传下载
  • +
  • 客户端管理和认证
  • +
+ +
+ +
+
+
+
+
任务管理
+

创建、查看和管理任务,支持设置执行客户端和超时时间。

+ 进入 +
+
+
+
+
+
+
客户端管理
+

管理客户端列表,自动生成API Token,用于客户端认证。

+ 进入 +
+
+
+
+
+
+
后台管理
+

使用Django Admin进行系统管理,包括用户、任务和客户端。

+ 进入 +
+
+
+
+
+ +
+
+

© 2025 任务中心管理系统

+
+
+ + + + \ No newline at end of file diff --git a/tasks/templates/tasks/task_create.html b/tasks/templates/tasks/task_create.html new file mode 100644 index 0000000..b5a41fd --- /dev/null +++ b/tasks/templates/tasks/task_create.html @@ -0,0 +1,37 @@ +{% extends 'tasks/base.html' %} + +{% block content %} +

创建任务

+ +
+
+
+ {% csrf_token %} + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
默认3天(259200秒)
+
+ + + 取消 +
+
+
+{% endblock %} \ No newline at end of file diff --git a/tasks/templates/tasks/task_detail.html b/tasks/templates/tasks/task_detail.html new file mode 100644 index 0000000..3ccd24a --- /dev/null +++ b/tasks/templates/tasks/task_detail.html @@ -0,0 +1,98 @@ +{% extends 'tasks/base.html' %} + +{% block content %} +

任务详情

+ +
+
+
{{ task.name }}
+
+
+
+
+

ID: {{ task.id }}

+

指定客户端: {{ task.client_name|default:'未指定' }}

+

实际执行客户端: {{ task.assigned_to|default:'未分配' }}

+

状态: + + {{ task.get_status_display }} + +

+
+
+

创建时间: {{ task.created_at|date:'Y-m-d H:i:s' }}

+

更新时间: {{ task.updated_at|date:'Y-m-d H:i:s' }}

+

开始执行时间: {{ task.started_at|default:'未开始'|date:'Y-m-d H:i:s' }}

+

完成时间: {{ task.completed_at|default:'未完成'|date:'Y-m-d H:i:s' }}

+

超时时间: {{ task.timeout_seconds }} 秒

+
+
+ + {% if task.script %} +
+
执行脚本:
+
{{ task.script }}
+
+ {% endif %} +
+
+ +

执行结果

+ +
+
+ {% if results %} + + + + + + + + + + + + + {% for result in results %} + + + + + + + + + {% endfor %} + +
ID执行客户端状态执行消息创建时间结果文件
{{ result.id }}{{ result.client.name }} + + {{ result.get_status_display }} + + {{ result.message|default:'无消息' }}{{ result.created_at|date:'Y-m-d H:i:s' }} + {% if result.result_file %} + 下载 + {% else %} + 无文件 + {% endif %} +
+ {% else %} +

暂无执行结果

+ {% endif %} +
+
+ + +{% endblock %} \ No newline at end of file diff --git a/tasks/templates/tasks/task_list.html b/tasks/templates/tasks/task_list.html new file mode 100644 index 0000000..e6043c2 --- /dev/null +++ b/tasks/templates/tasks/task_list.html @@ -0,0 +1,48 @@ +{% extends 'tasks/base.html' %} + +{% block content %} +

任务列表

+ +
+ +
+ + + + + + + + + + + + + {% for task in tasks %} + + + + + + + + + {% endfor %} + +
ID任务名称指定客户端状态创建时间操作
{{ task.id }}{{ task.name }}{{ task.client_name|default:'未指定' }} + + {{ task.get_status_display }} + + {{ task.created_at|date:'Y-m-d H:i' }} + 详情 +
+
+
+{% endblock %} \ No newline at end of file diff --git a/tasks/tests.py b/tasks/tests.py new file mode 100644 index 0000000..de8bdc0 --- /dev/null +++ b/tasks/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/tasks/tests/__init__.py b/tasks/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tasks/tests/__pycache__/__init__.cpython-311.pyc b/tasks/tests/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..9c86400 Binary files /dev/null and b/tasks/tests/__pycache__/__init__.cpython-311.pyc differ diff --git a/tasks/tests/__pycache__/test_api.cpython-311-pytest-8.3.2.pyc b/tasks/tests/__pycache__/test_api.cpython-311-pytest-8.3.2.pyc new file mode 100644 index 0000000..154f80c Binary files /dev/null and b/tasks/tests/__pycache__/test_api.cpython-311-pytest-8.3.2.pyc differ diff --git a/tasks/tests/__pycache__/test_api.cpython-311.pyc b/tasks/tests/__pycache__/test_api.cpython-311.pyc new file mode 100644 index 0000000..d130bc7 Binary files /dev/null and b/tasks/tests/__pycache__/test_api.cpython-311.pyc differ diff --git a/tasks/tests/__pycache__/test_factories.cpython-311-pytest-8.3.2.pyc b/tasks/tests/__pycache__/test_factories.cpython-311-pytest-8.3.2.pyc new file mode 100644 index 0000000..b63e90a Binary files /dev/null and b/tasks/tests/__pycache__/test_factories.cpython-311-pytest-8.3.2.pyc differ diff --git a/tasks/tests/__pycache__/test_factories.cpython-311.pyc b/tasks/tests/__pycache__/test_factories.cpython-311.pyc new file mode 100644 index 0000000..d9491d2 Binary files /dev/null and b/tasks/tests/__pycache__/test_factories.cpython-311.pyc differ diff --git a/tasks/tests/__pycache__/test_integration.cpython-311-pytest-8.3.2.pyc b/tasks/tests/__pycache__/test_integration.cpython-311-pytest-8.3.2.pyc new file mode 100644 index 0000000..0aff01c Binary files /dev/null and b/tasks/tests/__pycache__/test_integration.cpython-311-pytest-8.3.2.pyc differ diff --git a/tasks/tests/__pycache__/test_integration.cpython-311.pyc b/tasks/tests/__pycache__/test_integration.cpython-311.pyc new file mode 100644 index 0000000..c1cb842 Binary files /dev/null and b/tasks/tests/__pycache__/test_integration.cpython-311.pyc differ diff --git a/tasks/tests/__pycache__/test_models.cpython-311-pytest-8.3.2.pyc b/tasks/tests/__pycache__/test_models.cpython-311-pytest-8.3.2.pyc new file mode 100644 index 0000000..b886f13 Binary files /dev/null and b/tasks/tests/__pycache__/test_models.cpython-311-pytest-8.3.2.pyc differ diff --git a/tasks/tests/__pycache__/test_models.cpython-311.pyc b/tasks/tests/__pycache__/test_models.cpython-311.pyc new file mode 100644 index 0000000..395af4b Binary files /dev/null and b/tasks/tests/__pycache__/test_models.cpython-311.pyc differ diff --git a/tasks/tests/__pycache__/test_views.cpython-311-pytest-8.3.2.pyc b/tasks/tests/__pycache__/test_views.cpython-311-pytest-8.3.2.pyc new file mode 100644 index 0000000..77c7b36 Binary files /dev/null and b/tasks/tests/__pycache__/test_views.cpython-311-pytest-8.3.2.pyc differ diff --git a/tasks/tests/__pycache__/test_views.cpython-311.pyc b/tasks/tests/__pycache__/test_views.cpython-311.pyc new file mode 100644 index 0000000..31b2db9 Binary files /dev/null and b/tasks/tests/__pycache__/test_views.cpython-311.pyc differ diff --git a/tasks/tests/test_api.py b/tasks/tests/test_api.py new file mode 100644 index 0000000..ff5f7fe --- /dev/null +++ b/tasks/tests/test_api.py @@ -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') diff --git a/tasks/tests/test_factories.py b/tasks/tests/test_factories.py new file mode 100644 index 0000000..d8d7e06 --- /dev/null +++ b/tasks/tests/test_factories.py @@ -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() diff --git a/tasks/tests/test_integration.py b/tasks/tests/test_integration.py new file mode 100644 index 0000000..1102dc5 --- /dev/null +++ b/tasks/tests/test_integration.py @@ -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') diff --git a/tasks/tests/test_models.py b/tasks/tests/test_models.py new file mode 100644 index 0000000..542a6b0 --- /dev/null +++ b/tasks/tests/test_models.py @@ -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} - 成功") diff --git a/tasks/tests/test_views.py b/tasks/tests/test_views.py new file mode 100644 index 0000000..14396a7 --- /dev/null +++ b/tasks/tests/test_views.py @@ -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, '创建客户端') diff --git a/tasks/urls.py b/tasks/urls.py new file mode 100644 index 0000000..953644d --- /dev/null +++ b/tasks/urls.py @@ -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//', task_detail, name='task_detail'), + path('clients/list/', client_list, name='client_list'), + path('clients/create/', client_create, name='client_create'), +] diff --git a/tasks/views.py b/tasks/views.py new file mode 100644 index 0000000..4ede97b --- /dev/null +++ b/tasks/views.py @@ -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) + diff --git a/tasks/views_frontend.py b/tasks/views_frontend.py new file mode 100644 index 0000000..137e5bc --- /dev/null +++ b/tasks/views_frontend.py @@ -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')