diff --git a/api/urls.py b/api/urls.py index ddcd049..10bd673 100644 --- a/api/urls.py +++ b/api/urls.py @@ -28,6 +28,7 @@ api.add_router("tutorial/", "task.tutorial.router") api.add_router("challenge/", "task.challenge.router") api.add_router("submission/", "submission.api.router") api.add_router("upload/", "utils.upload.router") +api.add_router("assets/", "task.assets.router") api.add_router("prompt/", "prompt.api.router") diff --git a/task/assets.py b/task/assets.py new file mode 100644 index 0000000..373dd18 --- /dev/null +++ b/task/assets.py @@ -0,0 +1,75 @@ +from ninja import Router, File, Form, Schema +from ninja.files import UploadedFile +from django.shortcuts import get_object_or_404 +from django.conf import settings +from account.decorators import admin_required, super_required +from .models import Challenge, Tutorial, TaskAsset + +router = Router() + + +class AssetOut(Schema): + name: str + url: str + + +def _asset_url(asset: TaskAsset) -> str: + return f"{settings.MEDIA_URL}{asset.file.name}" + + +# ── Challenge assets ────────────────────────────────────────────────────────── + +@router.get("/challenge/{display}", response=list[AssetOut]) +def list_challenge_assets(request, display: int): + challenge = get_object_or_404(Challenge, display=display) + return [AssetOut(name=a.name, url=_asset_url(a)) for a in challenge.assets.all()] + + +@router.post("/challenge/{display}", response=AssetOut) +@admin_required +def upload_challenge_asset(request, display: int, name: Form[str], file: File[UploadedFile]): + challenge = get_object_or_404(Challenge, display=display) + asset, _ = TaskAsset.objects.get_or_create(task=challenge.task_ptr, name=name) + if asset.file: + asset.file.delete(save=False) + asset.file.save(name, file, save=True) + return AssetOut(name=asset.name, url=_asset_url(asset)) + + +@router.delete("/challenge/{display}/{name}") +@admin_required +def delete_challenge_asset(request, display: int, name: str): + challenge = get_object_or_404(Challenge, display=display) + asset = get_object_or_404(TaskAsset, task=challenge.task_ptr, name=name) + asset.file.delete(save=False) + asset.delete() + return {"message": "删除成功"} + + +# ── Tutorial assets ─────────────────────────────────────────────────────────── + +@router.get("/tutorial/{display}", response=list[AssetOut]) +def list_tutorial_assets(request, display: int): + tutorial = get_object_or_404(Tutorial, display=display) + return [AssetOut(name=a.name, url=_asset_url(a)) for a in tutorial.assets.all()] + + +@router.post("/tutorial/{display}", response=AssetOut) +@super_required +def upload_tutorial_asset(request, display: int, name: Form[str], file: File[UploadedFile]): + tutorial = get_object_or_404(Tutorial, display=display) + asset, _ = TaskAsset.objects.get_or_create(task=tutorial.task_ptr, name=name) + if asset.file: + asset.file.delete(save=False) + asset.file.save(name, file, save=True) + return AssetOut(name=asset.name, url=_asset_url(asset)) + + +@router.delete("/tutorial/{display}/{name}") +@super_required +def delete_tutorial_asset(request, display: int, name: str): + tutorial = get_object_or_404(Tutorial, display=display) + asset = get_object_or_404(TaskAsset, task=tutorial.task_ptr, name=name) + asset.file.delete(save=False) + asset.delete() + return {"message": "删除成功"} diff --git a/task/migrations/0007_taskasset.py b/task/migrations/0007_taskasset.py new file mode 100644 index 0000000..8206900 --- /dev/null +++ b/task/migrations/0007_taskasset.py @@ -0,0 +1,29 @@ +# Generated by Django 6.0.1 on 2026-04-13 09:33 + +import django.db.models.deletion +import task.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('task', '0006_challenge_pass_score'), + ] + + operations = [ + migrations.CreateModel( + name='TaskAsset', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, verbose_name='文件名')), + ('file', models.FileField(upload_to=task.models.task_asset_upload_to, verbose_name='文件')), + ('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assets', to='task.task')), + ], + options={ + 'verbose_name': '任务素材', + 'verbose_name_plural': '任务素材', + 'unique_together': {('task', 'name')}, + }, + ), + ] diff --git a/task/models.py b/task/models.py index 707d140..649e029 100644 --- a/task/models.py +++ b/task/models.py @@ -49,3 +49,21 @@ class Challenge(Task): ordering = ("display",) verbose_name = "挑战" verbose_name_plural = verbose_name + + +def task_asset_upload_to(instance, filename): + return f"tasks/{instance.task.task_type}/{instance.task.display}/{instance.name}" + + +class TaskAsset(models.Model): + task = models.ForeignKey(Task, on_delete=models.CASCADE, related_name="assets") + name = models.CharField(max_length=100, verbose_name="文件名") + file = models.FileField(upload_to=task_asset_upload_to, verbose_name="文件") + + class Meta: + unique_together = ("task", "name") + verbose_name = "任务素材" + verbose_name_plural = verbose_name + + def __str__(self): + return f"{self.task} / {self.name}"