chore: 将后端移动到 api/ 目录,准备合并为单仓库
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e172ffc0b3
commit
0591eda56f
244 files changed
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,6 @@
|
||||
from django.contrib import admin
|
||||
from .models import Tutorial, Challenge, Creation
|
||||
|
||||
admin.site.register(Tutorial)
|
||||
admin.site.register(Challenge)
|
||||
admin.site.register(Creation)
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class TaskConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "task"
|
||||
verbose_name = "任务"
|
||||
@@ -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": "删除成功"}
|
||||
@@ -0,0 +1,97 @@
|
||||
from typing import List
|
||||
from ninja import Router
|
||||
from django.shortcuts import get_object_or_404
|
||||
from account.decorators import admin_required, super_required
|
||||
from submission.models import Submission
|
||||
from .schemas import ChallengeAll, ChallengeDisplay, ChallengeIn, ChallengeSlim
|
||||
from .models import Challenge
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.get("/list", response=List[ChallengeSlim])
|
||||
@admin_required
|
||||
def challenge(request):
|
||||
"""
|
||||
后台显示所有的列表
|
||||
"""
|
||||
return Challenge.objects.select_related("author").all()
|
||||
|
||||
|
||||
@router.get("/display", response=List[ChallengeDisplay])
|
||||
def get_all_public_display(request):
|
||||
"""
|
||||
前台显示所有公开的挑战
|
||||
"""
|
||||
challenges = list(
|
||||
Challenge.objects.select_related("author")
|
||||
.filter(is_public=True)
|
||||
.order_by("-display")
|
||||
)
|
||||
if request.user.is_authenticated:
|
||||
task_ids = [c.task_ptr_id for c in challenges]
|
||||
submitted_ids = set(
|
||||
Submission.objects.filter(
|
||||
user=request.user,
|
||||
task_id__in=task_ids,
|
||||
).values_list("task_id", flat=True)
|
||||
)
|
||||
else:
|
||||
submitted_ids = set()
|
||||
return [
|
||||
ChallengeDisplay(
|
||||
display=c.display,
|
||||
title=c.title,
|
||||
score=c.score,
|
||||
pass_score=c.pass_score,
|
||||
is_public=c.is_public,
|
||||
author_name=c.author_name,
|
||||
submitted=c.task_ptr_id in submitted_ids,
|
||||
)
|
||||
for c in challenges
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{display}", response=ChallengeAll)
|
||||
def get(request, display: int):
|
||||
return get_object_or_404(
|
||||
Challenge.objects.select_related("author"), display=display
|
||||
)
|
||||
|
||||
|
||||
@router.post("/")
|
||||
@admin_required
|
||||
def create_or_update(request, payload: ChallengeIn):
|
||||
try:
|
||||
item = Challenge.objects.get(display=payload.display)
|
||||
item.title = payload.title
|
||||
item.content = payload.content
|
||||
item.score = payload.score
|
||||
item.is_public = payload.is_public
|
||||
item.example_html = payload.example_html
|
||||
item.example_css = payload.example_css
|
||||
item.example_js = payload.example_js
|
||||
if item.author_id is None and request.user.is_authenticated:
|
||||
item.author = request.user
|
||||
item.save()
|
||||
return {"message": "更新成功"}
|
||||
except Challenge.DoesNotExist:
|
||||
Challenge.objects.create(**payload.dict(), author=request.user)
|
||||
return {"message": "创建成功"}
|
||||
|
||||
|
||||
@router.put("/public/{display}")
|
||||
@super_required
|
||||
def toggle_public(request, display: int):
|
||||
item = get_object_or_404(Challenge, display=display)
|
||||
item.is_public = not item.is_public
|
||||
item.save()
|
||||
label = "公开" if item.is_public else "隐藏"
|
||||
return {"message": f"【{item.display}】{item.title} 已{label}"}
|
||||
|
||||
|
||||
@router.delete("/{display}")
|
||||
@admin_required
|
||||
def remove(request, display: int):
|
||||
Challenge.objects.filter(display=display).delete()
|
||||
return {"message": "删除成功"}
|
||||
@@ -0,0 +1,88 @@
|
||||
from typing import List
|
||||
from ninja import Router
|
||||
from ninja.errors import HttpError
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.contrib.auth.decorators import login_required
|
||||
|
||||
from account.models import RoleChoices
|
||||
from .models import Creation
|
||||
from .schemas import CreationCodeIn, CreationIn, CreationOut, CreationSlim
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
def _clean_title(title: str) -> str:
|
||||
title = (title or "").strip()
|
||||
if not title:
|
||||
raise HttpError(400, "主题名称不能为空")
|
||||
return title[:100]
|
||||
|
||||
|
||||
def _get_owned(request, display: int) -> Creation:
|
||||
creation = get_object_or_404(Creation, display=display)
|
||||
if creation.owner_id != request.user.id and request.user.role != RoleChoices.SUPER:
|
||||
raise HttpError(403, "只能操作自己的创作")
|
||||
return creation
|
||||
|
||||
|
||||
@router.get("/mine", response=List[CreationSlim])
|
||||
@login_required
|
||||
def list_mine(request):
|
||||
"""
|
||||
当前用户自己创建的所有主题
|
||||
"""
|
||||
return Creation.objects.filter(owner=request.user)
|
||||
|
||||
|
||||
@router.post("/", response=CreationOut)
|
||||
@login_required
|
||||
def create(request, payload: CreationIn):
|
||||
"""
|
||||
新建一个主题,序号由后端自动分配
|
||||
"""
|
||||
return Creation.objects.create(
|
||||
owner=request.user,
|
||||
title=_clean_title(payload.title),
|
||||
content=payload.content,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{display}", response=CreationOut)
|
||||
@login_required
|
||||
def get(request, display: int):
|
||||
return _get_owned(request, display)
|
||||
|
||||
|
||||
@router.put("/{display}", response=CreationOut)
|
||||
@login_required
|
||||
def update(request, display: int, payload: CreationIn):
|
||||
"""
|
||||
修改主题名称和主题描述
|
||||
"""
|
||||
creation = _get_owned(request, display)
|
||||
creation.title = _clean_title(payload.title)
|
||||
creation.content = payload.content
|
||||
creation.save(update_fields=["title", "content", "modified"])
|
||||
return creation
|
||||
|
||||
|
||||
@router.put("/{display}/code", response=CreationOut)
|
||||
@login_required
|
||||
def update_code(request, display: int, payload: CreationCodeIn):
|
||||
"""
|
||||
保存当前作品代码
|
||||
"""
|
||||
creation = _get_owned(request, display)
|
||||
creation.html = payload.html
|
||||
creation.css = payload.css
|
||||
creation.js = payload.js
|
||||
creation.save(update_fields=["html", "css", "js", "modified"])
|
||||
return creation
|
||||
|
||||
|
||||
@router.delete("/{display}")
|
||||
@login_required
|
||||
def remove(request, display: int):
|
||||
creation = _get_owned(request, display)
|
||||
creation.delete()
|
||||
return {"message": "删除成功"}
|
||||
@@ -0,0 +1,58 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-06 16:03
|
||||
|
||||
import django.db.models.deletion
|
||||
import django_extensions.db.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Task',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')),
|
||||
('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')),
|
||||
('display', models.IntegerField(unique=True)),
|
||||
('title', models.CharField(max_length=100)),
|
||||
('content', models.TextField()),
|
||||
('task_type', models.CharField(choices=[('challenge', 'Challenge'), ('tutorial', 'Tutorial')], editable=False, max_length=20)),
|
||||
('is_public', models.BooleanField(default=False)),
|
||||
],
|
||||
options={
|
||||
'get_latest_by': 'modified',
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Challenge',
|
||||
fields=[
|
||||
('task_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='task.task')),
|
||||
('score', models.IntegerField(default=0)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '挑战',
|
||||
'verbose_name_plural': '挑战',
|
||||
'ordering': ('display',),
|
||||
},
|
||||
bases=('task.task',),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Tutorial',
|
||||
fields=[
|
||||
('task_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='task.task')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '教程',
|
||||
'verbose_name_plural': '教程',
|
||||
'ordering': ('display',),
|
||||
},
|
||||
bases=('task.task',),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-10 10:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('task', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='task',
|
||||
name='display',
|
||||
field=models.IntegerField(db_index=True, unique=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-10 12:23
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('task', '0002_alter_task_display'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='task',
|
||||
name='content',
|
||||
field=models.TextField(verbose_name='内容'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='task',
|
||||
name='display',
|
||||
field=models.IntegerField(db_index=True, unique=True, verbose_name='序号'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='task',
|
||||
name='is_public',
|
||||
field=models.BooleanField(default=False, verbose_name='是否公开'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='task',
|
||||
name='task_type',
|
||||
field=models.CharField(choices=[('challenge', 'Challenge'), ('tutorial', 'Tutorial')], editable=False, max_length=20, verbose_name='类型'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='task',
|
||||
name='title',
|
||||
field=models.CharField(max_length=100, verbose_name='标题'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-18 06:37
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('task', '0003_alter_task_content_alter_task_display_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='task',
|
||||
name='task_type',
|
||||
field=models.CharField(choices=[('challenge', '挑战'), ('tutorial', '教程')], editable=False, max_length=20, verbose_name='类型'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
# Generated by Django 6.0.1 on 2026-03-03 14:59
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('task', '0004_alter_task_task_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='task',
|
||||
options={},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='task',
|
||||
name='display',
|
||||
field=models.IntegerField(db_index=True, verbose_name='序号'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='task',
|
||||
unique_together={('display', 'task_type')},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.1 on 2026-03-09 11:47
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('task', '0005_alter_task_options_alter_task_display_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='challenge',
|
||||
name='pass_score',
|
||||
field=models.FloatField(blank=True, null=True, verbose_name='通过分数线'),
|
||||
),
|
||||
]
|
||||
@@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Codex on 2026-05-06
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("task", "0007_taskasset"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="challenge",
|
||||
name="author",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="authored_challenges",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="出题人",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 6.0.1 on 2026-06-07 11:17
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('task', '0008_challenge_author'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='task',
|
||||
name='example_css',
|
||||
field=models.TextField(blank=True, null=True, verbose_name='示例CSS'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='task',
|
||||
name='example_html',
|
||||
field=models.TextField(blank=True, null=True, verbose_name='示例HTML'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='task',
|
||||
name='example_js',
|
||||
field=models.TextField(blank=True, null=True, verbose_name='示例JS'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,37 @@
|
||||
# Generated by Django 6.0.1 on 2026-08-14 15:13
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('task', '0009_task_example_css_task_example_html_task_example_js'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='task',
|
||||
name='task_type',
|
||||
field=models.CharField(choices=[('challenge', '挑战'), ('tutorial', '教程'), ('creation', '创作')], editable=False, max_length=20, verbose_name='类型'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Creation',
|
||||
fields=[
|
||||
('task_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='task.task')),
|
||||
('html', models.TextField(blank=True, default='', verbose_name='HTML代码')),
|
||||
('css', models.TextField(blank=True, default='', verbose_name='CSS代码')),
|
||||
('js', models.TextField(blank=True, default='', verbose_name='JS代码')),
|
||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='creations', to=settings.AUTH_USER_MODEL, verbose_name='创作者')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '创作',
|
||||
'verbose_name_plural': '创作',
|
||||
'ordering': ('-created',),
|
||||
},
|
||||
bases=('task.task',),
|
||||
),
|
||||
]
|
||||
Whitespace-only changes.
@@ -0,0 +1,124 @@
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
|
||||
|
||||
class TaskTypeChoices(models.TextChoices):
|
||||
CHALLENGE = "challenge", "挑战"
|
||||
TUTORIAL = "tutorial", "教程"
|
||||
CREATION = "creation", "创作"
|
||||
|
||||
|
||||
class Task(TimeStampedModel):
|
||||
display = models.IntegerField(db_index=True, verbose_name="序号")
|
||||
title = models.CharField(max_length=100, verbose_name="标题")
|
||||
content = models.TextField(verbose_name="内容")
|
||||
task_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=TaskTypeChoices.choices,
|
||||
editable=False,
|
||||
verbose_name="类型",
|
||||
)
|
||||
is_public = models.BooleanField(default=False, verbose_name="是否公开")
|
||||
example_html = models.TextField(null=True, blank=True, verbose_name="示例HTML")
|
||||
example_css = models.TextField(null=True, blank=True, verbose_name="示例CSS")
|
||||
example_js = models.TextField(null=True, blank=True, verbose_name="示例JS")
|
||||
|
||||
class Meta:
|
||||
unique_together = ("display", "task_type")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.task_type:
|
||||
self.task_type = self.__class__.__name__.lower()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class Tutorial(Task):
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
class Meta:
|
||||
ordering = ("display",)
|
||||
verbose_name = "教程"
|
||||
verbose_name_plural = verbose_name
|
||||
|
||||
|
||||
class Challenge(Task):
|
||||
score = models.IntegerField(default=0)
|
||||
pass_score = models.FloatField(null=True, blank=True, verbose_name="通过分数线")
|
||||
author = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="authored_challenges",
|
||||
verbose_name="出题人",
|
||||
)
|
||||
|
||||
@property
|
||||
def author_name(self):
|
||||
return self.author.username if self.author_id else ""
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
class Meta:
|
||||
ordering = ("display",)
|
||||
verbose_name = "挑战"
|
||||
verbose_name_plural = verbose_name
|
||||
|
||||
|
||||
class Creation(Task):
|
||||
"""
|
||||
用户自己创建的主题:标题和内容都由用户自己写,只有创作者本人能看到和修改
|
||||
"""
|
||||
|
||||
owner = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="creations",
|
||||
verbose_name="创作者",
|
||||
)
|
||||
html = models.TextField(blank=True, default="", verbose_name="HTML代码")
|
||||
css = models.TextField(blank=True, default="", verbose_name="CSS代码")
|
||||
js = models.TextField(blank=True, default="", verbose_name="JS代码")
|
||||
|
||||
@property
|
||||
def owner_name(self):
|
||||
return self.owner.username if self.owner_id else ""
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.display:
|
||||
last = (
|
||||
Creation.objects.order_by("-display")
|
||||
.values_list("display", flat=True)
|
||||
.first()
|
||||
)
|
||||
self.display = (last or 0) + 1
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
class Meta:
|
||||
ordering = ("-created",)
|
||||
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}"
|
||||
@@ -0,0 +1,99 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from ninja import Schema, ModelSchema
|
||||
from .models import Tutorial, Challenge
|
||||
|
||||
|
||||
class TutorialSlim(Schema):
|
||||
display: int
|
||||
title: str
|
||||
is_public: bool
|
||||
|
||||
|
||||
class TutorialAll(ModelSchema):
|
||||
class Meta:
|
||||
model = Tutorial
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class TutorialIn(Schema):
|
||||
display: int
|
||||
title: str
|
||||
content: str
|
||||
is_public: bool = False
|
||||
example_html: Optional[str] = None
|
||||
example_css: Optional[str] = None
|
||||
example_js: Optional[str] = None
|
||||
|
||||
|
||||
class ChallengeSlim(Schema):
|
||||
display: int
|
||||
title: str
|
||||
score: int
|
||||
pass_score: Optional[float] = None
|
||||
is_public: bool
|
||||
author_name: Optional[str] = None
|
||||
|
||||
|
||||
class ChallengeDisplay(ChallengeSlim):
|
||||
submitted: bool
|
||||
|
||||
|
||||
class ChallengeAll(ModelSchema):
|
||||
author_name: Optional[str] = None
|
||||
|
||||
class Meta:
|
||||
model = Challenge
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class ChallengeIn(Schema):
|
||||
display: int
|
||||
title: str
|
||||
content: str
|
||||
score: int = 0
|
||||
is_public: bool = False
|
||||
example_html: Optional[str] = None
|
||||
example_css: Optional[str] = None
|
||||
example_js: Optional[str] = None
|
||||
|
||||
|
||||
class CreationSlim(Schema):
|
||||
display: int
|
||||
title: str
|
||||
content: str
|
||||
created: datetime
|
||||
modified: datetime
|
||||
has_code: bool
|
||||
|
||||
@staticmethod
|
||||
def resolve_has_code(obj):
|
||||
return bool(obj.html or obj.css or obj.js)
|
||||
|
||||
|
||||
class CreationOut(Schema):
|
||||
task_id: int
|
||||
display: int
|
||||
title: str
|
||||
content: str
|
||||
html: str
|
||||
css: str
|
||||
js: str
|
||||
owner_name: str
|
||||
created: datetime
|
||||
modified: datetime
|
||||
|
||||
@staticmethod
|
||||
def resolve_task_id(obj):
|
||||
return obj.pk
|
||||
|
||||
|
||||
class CreationIn(Schema):
|
||||
title: str
|
||||
content: str = ""
|
||||
|
||||
|
||||
class CreationCodeIn(Schema):
|
||||
html: str = ""
|
||||
css: str = ""
|
||||
js: str = ""
|
||||
@@ -0,0 +1,94 @@
|
||||
from django.test import TestCase
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from prompt.utils import can_access_task
|
||||
from task.models import Creation
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def _make_user(username="student1"):
|
||||
return User.objects.create_user(username=username, password="pw")
|
||||
|
||||
|
||||
class CreationApiTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = _make_user("student1")
|
||||
self.other = _make_user("student2")
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def _create(self, title="我的计时器", content="做一个倒计时"):
|
||||
return self.client.post(
|
||||
"/api/creation/",
|
||||
data={"title": title, "content": content},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
def test_create_assigns_display(self):
|
||||
"""新建主题时后端自动分配递增的序号"""
|
||||
first = self._create().json()
|
||||
second = self._create("第二个主题").json()
|
||||
self.assertEqual(second["display"], first["display"] + 1)
|
||||
self.assertEqual(first["owner_name"], "student1")
|
||||
self.assertEqual(first["task_id"], Creation.objects.get(display=first["display"]).pk)
|
||||
|
||||
def test_create_rejects_empty_title(self):
|
||||
resp = self._create(title=" ")
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
def test_list_mine_only_returns_own(self):
|
||||
self._create()
|
||||
self.client.force_login(self.other)
|
||||
self._create("别人的主题")
|
||||
resp = self.client.get("/api/creation/mine")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
titles = [item["title"] for item in resp.json()]
|
||||
self.assertEqual(titles, ["别人的主题"])
|
||||
|
||||
def test_update_and_save_code(self):
|
||||
display = self._create().json()["display"]
|
||||
resp = self.client.put(
|
||||
f"/api/creation/{display}",
|
||||
data={"title": "新名字", "content": "新的描述"},
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.json()["title"], "新名字")
|
||||
|
||||
resp = self.client.put(
|
||||
f"/api/creation/{display}/code",
|
||||
data={"html": "<p>hi</p>", "css": "p{color:red}", "js": ""},
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
creation = Creation.objects.get(display=display)
|
||||
self.assertEqual(creation.html, "<p>hi</p>")
|
||||
|
||||
resp = self.client.get("/api/creation/mine")
|
||||
self.assertTrue(resp.json()[0]["has_code"])
|
||||
|
||||
def test_other_user_cannot_read_or_change(self):
|
||||
display = self._create().json()["display"]
|
||||
self.client.force_login(self.other)
|
||||
self.assertEqual(self.client.get(f"/api/creation/{display}").status_code, 403)
|
||||
self.assertEqual(self.client.delete(f"/api/creation/{display}").status_code, 403)
|
||||
|
||||
def test_delete(self):
|
||||
display = self._create().json()["display"]
|
||||
self.assertEqual(self.client.delete(f"/api/creation/{display}").status_code, 200)
|
||||
self.assertFalse(Creation.objects.filter(display=display).exists())
|
||||
|
||||
def test_submission_rejected_for_creation(self):
|
||||
"""自由创作不进入提交/评分体系"""
|
||||
task_id = self._create().json()["task_id"]
|
||||
resp = self.client.post(
|
||||
"/api/submission/",
|
||||
data={"task_id": task_id, "html": "<p>hi</p>", "css": "", "js": ""},
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
def test_can_access_task_limits_chat_to_owner(self):
|
||||
creation = Creation.objects.create(owner=self.user, title="t", content="")
|
||||
self.assertTrue(can_access_task(self.user, creation.pk))
|
||||
self.assertFalse(can_access_task(self.other, creation.pk))
|
||||
@@ -0,0 +1,65 @@
|
||||
from typing import List
|
||||
from ninja import Router
|
||||
from django.shortcuts import get_object_or_404
|
||||
from account.decorators import super_required
|
||||
from .schemas import TutorialAll, TutorialIn, TutorialSlim
|
||||
from .models import Tutorial
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.get("/list", response=List[TutorialSlim])
|
||||
@super_required
|
||||
def tutorial(request):
|
||||
"""
|
||||
后台显示所有的列表
|
||||
"""
|
||||
return Tutorial.objects.all()
|
||||
|
||||
|
||||
@router.get("/display", response=List[int])
|
||||
def get_all_public_display(request):
|
||||
"""
|
||||
前台显示所有公开的 display
|
||||
"""
|
||||
return Tutorial.objects.filter(is_public=True).values_list("display", flat=True)
|
||||
|
||||
|
||||
@router.get("/{display}", response=TutorialAll)
|
||||
def get(request, display: int):
|
||||
return get_object_or_404(Tutorial, display=display)
|
||||
|
||||
|
||||
@router.post("/")
|
||||
@super_required
|
||||
def create_or_update(request, payload: TutorialIn):
|
||||
try:
|
||||
item = Tutorial.objects.get(display=payload.display)
|
||||
item.title = payload.title
|
||||
item.content = payload.content
|
||||
item.is_public = payload.is_public
|
||||
item.example_html = payload.example_html
|
||||
item.example_css = payload.example_css
|
||||
item.example_js = payload.example_js
|
||||
item.save()
|
||||
return {"message": "更新成功"}
|
||||
except Tutorial.DoesNotExist:
|
||||
Tutorial.objects.create(**payload.dict())
|
||||
return {"message": "创建成功"}
|
||||
|
||||
|
||||
@router.put("/public/{display}")
|
||||
@super_required
|
||||
def toggle_public(request, display: int):
|
||||
item = get_object_or_404(Tutorial, display=display)
|
||||
item.is_public = not item.is_public
|
||||
item.save()
|
||||
label = "公开" if item.is_public else "隐藏"
|
||||
return {"message": f"【{item.display}】{item.title} 已{label}"}
|
||||
|
||||
|
||||
@router.delete("/{display}")
|
||||
@super_required
|
||||
def remove(request, display: int):
|
||||
Tutorial.objects.filter(display=display).delete()
|
||||
return {"message": "删除成功"}
|
||||
Reference in new issue
Block a user