@@ -0,0 +1,45 @@
|
||||
from django.contrib import admin
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from .models import (
|
||||
Character,
|
||||
MathBTIAssessment,
|
||||
MathBTIResult,
|
||||
MathIdentity,
|
||||
SkillPackage,
|
||||
Story,
|
||||
StoryChoice,
|
||||
StoryRun,
|
||||
StoryVersion,
|
||||
UserRelationship,
|
||||
)
|
||||
from .services import validate_story_content
|
||||
|
||||
|
||||
@admin.register(StoryVersion)
|
||||
class StoryVersionAdmin(admin.ModelAdmin):
|
||||
list_display = ("story", "version", "is_published", "published_at", "created_at")
|
||||
list_filter = ("is_published", "story__kind")
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
errors = validate_story_content(obj.content)
|
||||
if errors:
|
||||
raise ValidationError(errors)
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
|
||||
@admin.register(StoryRun)
|
||||
class StoryRunAdmin(admin.ModelAdmin):
|
||||
list_display = ("id", "user", "story_version", "status", "current_node", "updated_at")
|
||||
list_filter = ("status", "story_version__story")
|
||||
readonly_fields = ("started_at", "updated_at", "completed_at")
|
||||
|
||||
|
||||
admin.site.register(MathIdentity)
|
||||
admin.site.register(MathBTIAssessment)
|
||||
admin.site.register(MathBTIResult)
|
||||
admin.site.register(Character)
|
||||
admin.site.register(Story)
|
||||
admin.site.register(StoryChoice)
|
||||
admin.site.register(UserRelationship)
|
||||
admin.site.register(SkillPackage)
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class MathLifeConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'math_life'
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.utils import timezone
|
||||
|
||||
from accounts.models import InviteCode
|
||||
from content.models import ContentItem
|
||||
from latex_lab.models import LatexCourse, LatexExercise, LatexLesson
|
||||
from math_life.models import (
|
||||
MathBTIAssessment,
|
||||
MathIdentity,
|
||||
SkillPackage,
|
||||
Story,
|
||||
StoryVersion,
|
||||
)
|
||||
from math_life.services import validate_story_content
|
||||
|
||||
|
||||
DISCIPLINE_ICONS = {
|
||||
"人工智能": "🤖",
|
||||
"计算机": "💻",
|
||||
"电子信息": "📡",
|
||||
"微电子": "🔌",
|
||||
"机械工程": "⚙",
|
||||
"自动化": "◉",
|
||||
"能源动力": "🔥",
|
||||
"化工材料": "⚗",
|
||||
"生物医学": "✦",
|
||||
"经济管理": "▥",
|
||||
"环境工程": "♧",
|
||||
"数学建模": "📐",
|
||||
}
|
||||
|
||||
|
||||
def load_json(path):
|
||||
with path.open(encoding="utf-8") as source:
|
||||
return json.load(source)
|
||||
|
||||
|
||||
def video_ability(video):
|
||||
title = video["title"]
|
||||
module = video["module"]
|
||||
discipline = video["discipline"]
|
||||
if module == "数思" or title == "你被平均数骗过吗?":
|
||||
return ContentItem.Ability.VISION
|
||||
if module == "数说" or title == "消费者行为预测的回归分析":
|
||||
return ContentItem.Ability.HUMANITIES
|
||||
if discipline in {"生物医学", "自动化"}:
|
||||
return ContentItem.Ability.DETECTION
|
||||
if discipline in {"机械工程", "化工材料", "环境工程", "能源动力", "数学建模"}:
|
||||
return ContentItem.Ability.MODELING
|
||||
if title in {"金融里的数学:为什么风险可以算出来", "博弈论与市场竞争"}:
|
||||
return ContentItem.Ability.MODELING
|
||||
return ContentItem.Ability.CONNECTION
|
||||
|
||||
|
||||
def skill_content(title, person, dilemma):
|
||||
return {
|
||||
"title": title,
|
||||
"start_node": "opening",
|
||||
"nodes": {
|
||||
"opening": {
|
||||
"character": "旁白",
|
||||
"scene": f"你成为青年时期的{person}。{dilemma}",
|
||||
"choices": [
|
||||
{
|
||||
"text": "接受风险,争取更大的可能",
|
||||
"next": "risk",
|
||||
"effects": {"time": -2, "reputation": 1},
|
||||
},
|
||||
{
|
||||
"text": "先保住当下,再等待机会",
|
||||
"next": "steady",
|
||||
"effects": {"money": 2, "reputation": -1},
|
||||
},
|
||||
],
|
||||
},
|
||||
"risk": {
|
||||
"character": person,
|
||||
"scene": "选择带来了压力,也让你接触到原本看不见的问题。",
|
||||
"choices": [
|
||||
{
|
||||
"text": "把有限时间投入研究",
|
||||
"next": "crossroads",
|
||||
"effects": {"energy": -2, "research": 3},
|
||||
}
|
||||
],
|
||||
},
|
||||
"steady": {
|
||||
"character": person,
|
||||
"scene": "稳定让你积累了资源,但窗口正在逐渐关闭。",
|
||||
"choices": [
|
||||
{
|
||||
"text": "用积累换一次尝试",
|
||||
"next": "crossroads",
|
||||
"effects": {"money": -1, "research": 2},
|
||||
}
|
||||
],
|
||||
},
|
||||
"crossroads": {
|
||||
"character": "同伴",
|
||||
"scene": "同伴提供了不完整的消息。你必须决定是独自推进,还是公开方法。",
|
||||
"choices": [
|
||||
{
|
||||
"text": "先完成证明,再公开",
|
||||
"next": "ending_scholar",
|
||||
"effects": {"research": 2, "cooperation": -1},
|
||||
},
|
||||
{
|
||||
"text": "邀请同伴共同验证",
|
||||
"next": "ending_bridge",
|
||||
"effects": {"cooperation": 3, "reputation": 1},
|
||||
},
|
||||
],
|
||||
},
|
||||
"ending_scholar": {
|
||||
"character": "旁白",
|
||||
"scene": "你守住了方法的完整性,也承担了独行的代价。",
|
||||
"choices": [],
|
||||
},
|
||||
"ending_bridge": {
|
||||
"character": "旁白",
|
||||
"scene": "成果不再只属于一个人,你让更多人能够继续向前。",
|
||||
"choices": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "导入 MathBTI、信仰者主线和两个人物 Skill 样板"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
docs = Path(settings.PROJECT_ROOT) / "docs"
|
||||
mathbti_path = docs / "old_scripts" / "seed_mathbti.json"
|
||||
story_path = docs / "数学少年线_story.json"
|
||||
videos_path = docs / "old_scripts" / "seed_videos.json"
|
||||
if not mathbti_path.exists() or not story_path.exists() or not videos_path.exists():
|
||||
raise CommandError("缺少 docs 中的初始内容文件")
|
||||
|
||||
definition = load_json(mathbti_path)
|
||||
assessment, _ = MathBTIAssessment.objects.update_or_create(
|
||||
version=definition["version"],
|
||||
defaults={
|
||||
"title": definition["title"],
|
||||
"definition": definition,
|
||||
"is_published": True,
|
||||
"published_at": timezone.now(),
|
||||
},
|
||||
)
|
||||
for code, result in definition["results"].items():
|
||||
MathIdentity.objects.update_or_create(
|
||||
code=code,
|
||||
defaults={
|
||||
"name": result["name"],
|
||||
"clan": result["clan_name"],
|
||||
"mathematician": result["mathematician"],
|
||||
"description": result["description"],
|
||||
"initial_abilities": result.get("stats5", {}),
|
||||
"portrait": result.get("portrait", ""),
|
||||
},
|
||||
)
|
||||
|
||||
story_document = load_json(story_path)
|
||||
errors = validate_story_content(story_document)
|
||||
if errors:
|
||||
raise CommandError("; ".join(errors))
|
||||
flagship, _ = Story.objects.update_or_create(
|
||||
slug="believer-math-teen",
|
||||
defaults={
|
||||
"title": story_document["title"],
|
||||
"summary": story_document.get("description", ""),
|
||||
"kind": Story.Kind.FLAGSHIP,
|
||||
"estimated_minutes": 120,
|
||||
"is_visible": True,
|
||||
},
|
||||
)
|
||||
StoryVersion.objects.update_or_create(
|
||||
story=flagship,
|
||||
version=1,
|
||||
defaults={
|
||||
"content": story_document,
|
||||
"is_published": True,
|
||||
"published_at": timezone.now(),
|
||||
},
|
||||
)
|
||||
|
||||
samples = [
|
||||
(
|
||||
"hua-luogeng-skill",
|
||||
"华罗庚:从自学到远行",
|
||||
"华罗庚",
|
||||
"你没有完整的学院路径,却收到一次改变研究方向的机会。",
|
||||
["《华罗庚传》", "中国科学院公开人物资料"],
|
||||
),
|
||||
(
|
||||
"su-buqing-skill",
|
||||
"苏步青:选择回国",
|
||||
"苏步青",
|
||||
"海外研究条件优越,故乡却需要从零建设数学教育。",
|
||||
["复旦大学校史资料", "中国科学院公开人物资料"],
|
||||
),
|
||||
]
|
||||
for slug, title, person, dilemma, sources in samples:
|
||||
story, _ = Story.objects.update_or_create(
|
||||
slug=slug,
|
||||
defaults={
|
||||
"title": title,
|
||||
"summary": dilemma,
|
||||
"kind": Story.Kind.SKILL,
|
||||
"estimated_minutes": 20,
|
||||
"is_visible": True,
|
||||
},
|
||||
)
|
||||
document = skill_content(title, person, dilemma)
|
||||
StoryVersion.objects.update_or_create(
|
||||
story=story,
|
||||
version=1,
|
||||
defaults={
|
||||
"content": document,
|
||||
"is_published": True,
|
||||
"published_at": timezone.now(),
|
||||
},
|
||||
)
|
||||
SkillPackage.objects.update_or_create(
|
||||
story=story,
|
||||
defaults={
|
||||
"real_person": person,
|
||||
"historical_context": dilemma,
|
||||
"fact_sources": sources,
|
||||
"fictional_scope": "人生节点基于公开资料,具体对话与选择为艺术加工。",
|
||||
"creator": "葫芦数学内容组",
|
||||
},
|
||||
)
|
||||
|
||||
InviteCode.objects.get_or_create(
|
||||
code="HULU2026",
|
||||
defaults={"group": "本地首发体验", "max_uses": 100, "is_active": True},
|
||||
)
|
||||
|
||||
course, _ = LatexCourse.objects.update_or_create(
|
||||
slug="latex-from-zero",
|
||||
defaults={
|
||||
"title": "LaTeX 零基础表达",
|
||||
"description": "从上下标到完整数学证明的六步课程。",
|
||||
"order": 1,
|
||||
"is_published": True,
|
||||
},
|
||||
)
|
||||
lesson_specs = [
|
||||
("basics", "上标、下标和基础运算", r"x^2 + y_1", r"x^2+y_1"),
|
||||
("fractions", "分式、根式和括号", r"\frac{a}{b}+\sqrt{x}", r"\frac{a}{b}+\sqrt{x}"),
|
||||
("calculus", "求和、积分和极限", r"\sum_{i=1}^{n}i", r"\sum_{i=1}^{n}i"),
|
||||
("matrix", "矩阵和方程组", r"\begin{matrix}a&b\\c&d\end{matrix}", r"\begin{matrix}a&b\\c&d\end{matrix}"),
|
||||
("alignment", "多行公式与对齐", r"\begin{aligned}a&=b\\&=c\end{aligned}", r"\begin{aligned}a&=b\\&=c\end{aligned}"),
|
||||
("proof", "完整解答与证明排版", r"\because a=b,\ \therefore a+c=b+c", r"\because a=b,\therefore a+c=b+c"),
|
||||
]
|
||||
for order, (slug, title, example, expected) in enumerate(lesson_specs, start=1):
|
||||
lesson, _ = LatexLesson.objects.update_or_create(
|
||||
course=course,
|
||||
slug=slug,
|
||||
defaults={
|
||||
"title": title,
|
||||
"content": f"本节通过可运行示例学习{title}。",
|
||||
"example_source": example,
|
||||
"order": order,
|
||||
},
|
||||
)
|
||||
LatexExercise.objects.update_or_create(
|
||||
lesson=lesson,
|
||||
order=1,
|
||||
defaults={
|
||||
"prompt": f"输入与示例等价的 {title} 公式。",
|
||||
"expected_source": expected,
|
||||
"explanation": "注意命令、花括号和环境闭合。",
|
||||
},
|
||||
)
|
||||
|
||||
content_specs = [
|
||||
(
|
||||
"why-proof-matters",
|
||||
"为什么数学家坚持证明",
|
||||
ContentItem.Kind.KNOWLEDGE,
|
||||
"答案正确并不等于我们知道它为什么正确。",
|
||||
["证明", "数学精神"],
|
||||
),
|
||||
(
|
||||
"gauss-17-gon",
|
||||
"高斯与正十七边形",
|
||||
ContentItem.Kind.PERSON,
|
||||
"一个十九岁少年的发现,如何连接古典几何与代数。",
|
||||
["高斯", "几何"],
|
||||
),
|
||||
(
|
||||
"model-is-not-world",
|
||||
"模型不是现实本身",
|
||||
ContentItem.Kind.KNOWLEDGE,
|
||||
"建模从选择变量开始,也从那一刻开始承担遗漏的代价。",
|
||||
["建模", "应用"],
|
||||
),
|
||||
]
|
||||
for slug, title, kind, summary, topics in content_specs:
|
||||
ContentItem.objects.update_or_create(
|
||||
slug=slug,
|
||||
defaults={
|
||||
"title": title,
|
||||
"kind": kind,
|
||||
"summary": summary,
|
||||
"body": summary,
|
||||
"topics": topics,
|
||||
"source": "葫芦数学首发内容",
|
||||
"is_published": True,
|
||||
"published_at": timezone.now(),
|
||||
},
|
||||
)
|
||||
|
||||
videos = load_json(videos_path)
|
||||
for index, video in enumerate(videos, start=1):
|
||||
ContentItem.objects.update_or_create(
|
||||
slug=f"legacy-video-{index:03d}",
|
||||
defaults={
|
||||
"title": video["title"],
|
||||
"kind": ContentItem.Kind.VIDEO,
|
||||
"summary": video.get("description", ""),
|
||||
"body": video.get("description", ""),
|
||||
"cover_url": video.get("cover_url", ""),
|
||||
"media_url": video.get("video_url", ""),
|
||||
"topics": [
|
||||
video.get("module", ""),
|
||||
video.get("sub_category", ""),
|
||||
video.get("discipline", ""),
|
||||
],
|
||||
"source": "老版志愿者视频流",
|
||||
"ability_dimension": video_ability(video),
|
||||
"module": video.get("module", ""),
|
||||
"sub_category": video.get("sub_category", ""),
|
||||
"discipline": video.get("discipline", ""),
|
||||
"discipline_icon": DISCIPLINE_ICONS.get(
|
||||
video.get("discipline"),
|
||||
video.get("discipline_icon") or "∑",
|
||||
),
|
||||
"author": video.get("author", ""),
|
||||
"duration_seconds": int(video.get("duration_min", 0)) * 60,
|
||||
"view_count": 800 + index * 137,
|
||||
"comment_count": 20 + (index * 29) % 760,
|
||||
"is_published": True,
|
||||
"published_at": timezone.now(),
|
||||
},
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"已导入 MathBTI {assessment.version}、人生内容、LaTeX 课程和 {len(videos)} 条视频"
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,170 @@
|
||||
# Generated by Django 4.2.23 on 2026-08-08 11:15
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Character',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('slug', models.SlugField(unique=True)),
|
||||
('name', models.CharField(max_length=80)),
|
||||
('profile', models.TextField(blank=True)),
|
||||
('metadata', models.JSONField(blank=True, default=dict)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MathBTIAssessment',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('version', models.CharField(max_length=30, unique=True)),
|
||||
('title', models.CharField(max_length=120)),
|
||||
('definition', models.JSONField()),
|
||||
('is_published', models.BooleanField(default=False)),
|
||||
('published_at', models.DateTimeField(blank=True, null=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MathIdentity',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('code', models.CharField(max_length=8, unique=True)),
|
||||
('name', models.CharField(max_length=80)),
|
||||
('clan', models.CharField(max_length=40)),
|
||||
('mathematician', models.CharField(max_length=80)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('initial_abilities', models.JSONField(blank=True, default=dict)),
|
||||
('portrait', models.CharField(blank=True, max_length=200)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Story',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('slug', models.SlugField(unique=True)),
|
||||
('title', models.CharField(max_length=120)),
|
||||
('summary', models.TextField(blank=True)),
|
||||
('kind', models.CharField(choices=[('flagship', '旗舰人生'), ('skill', '人物 Skill'), ('special', '特别篇')], default='flagship', max_length=16)),
|
||||
('estimated_minutes', models.PositiveIntegerField(default=20)),
|
||||
('is_visible', models.BooleanField(default=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='UserRelationship',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('trust', models.SmallIntegerField(default=0)),
|
||||
('rivalry', models.SmallIntegerField(default=0)),
|
||||
('debt', models.SmallIntegerField(default=0)),
|
||||
('cooperation', models.SmallIntegerField(default=0)),
|
||||
('metadata', models.JSONField(blank=True, default=dict)),
|
||||
('character', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='math_life.character')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='StoryVersion',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('version', models.PositiveIntegerField()),
|
||||
('content', models.JSONField()),
|
||||
('is_published', models.BooleanField(default=False)),
|
||||
('published_at', models.DateTimeField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('story', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='math_life.story')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['story', '-version'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='StoryRun',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('current_node', models.CharField(max_length=100)),
|
||||
('state', models.JSONField(default=dict)),
|
||||
('status', models.CharField(choices=[('active', '进行中'), ('completed', '已完成'), ('abandoned', '已放弃')], default='active', max_length=16)),
|
||||
('ending_code', models.CharField(blank=True, max_length=80)),
|
||||
('started_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('completed_at', models.DateTimeField(blank=True, null=True)),
|
||||
('story_version', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='runs', to='math_life.storyversion')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='story_runs', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-updated_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='StoryChoice',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('sequence', models.PositiveIntegerField()),
|
||||
('idempotency_key', models.CharField(blank=True, max_length=80, null=True)),
|
||||
('node_id', models.CharField(max_length=100)),
|
||||
('choice_index', models.PositiveIntegerField()),
|
||||
('choice_text', models.CharField(max_length=300)),
|
||||
('effects', models.JSONField(blank=True, default=dict)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('run', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='choices', to='math_life.storyrun')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['sequence'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SkillPackage',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('real_person', models.CharField(max_length=120)),
|
||||
('historical_context', models.TextField()),
|
||||
('fact_sources', models.JSONField(default=list)),
|
||||
('fictional_scope', models.TextField()),
|
||||
('creator', models.CharField(max_length=120)),
|
||||
('story', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='skill_package', to='math_life.story')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MathBTIResult',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('answers', models.JSONField(default=list)),
|
||||
('axis_scores', models.JSONField(default=dict)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('assessment', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='math_life.mathbtiassessment')),
|
||||
('identity', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='math_life.mathidentity')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mathbti_results', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='userrelationship',
|
||||
constraint=models.UniqueConstraint(fields=('user', 'character'), name='unique_user_character'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='storyversion',
|
||||
constraint=models.UniqueConstraint(fields=('story', 'version'), name='unique_story_version'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='storychoice',
|
||||
constraint=models.UniqueConstraint(fields=('run', 'sequence'), name='unique_run_choice_sequence'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='storychoice',
|
||||
constraint=models.UniqueConstraint(fields=('run', 'idempotency_key'), name='unique_run_choice_idempotency'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,151 @@
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
|
||||
class MathIdentity(models.Model):
|
||||
code = models.CharField(max_length=8, unique=True)
|
||||
name = models.CharField(max_length=80)
|
||||
clan = models.CharField(max_length=40)
|
||||
mathematician = models.CharField(max_length=80)
|
||||
description = models.TextField(blank=True)
|
||||
initial_abilities = models.JSONField(default=dict, blank=True)
|
||||
portrait = models.CharField(max_length=200, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.code} {self.name}"
|
||||
|
||||
|
||||
class MathBTIAssessment(models.Model):
|
||||
version = models.CharField(max_length=30, unique=True)
|
||||
title = models.CharField(max_length=120)
|
||||
definition = models.JSONField()
|
||||
is_published = models.BooleanField(default=False)
|
||||
published_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
|
||||
class MathBTIResult(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="mathbti_results")
|
||||
assessment = models.ForeignKey(MathBTIAssessment, on_delete=models.PROTECT)
|
||||
identity = models.ForeignKey(MathIdentity, on_delete=models.PROTECT)
|
||||
answers = models.JSONField(default=list)
|
||||
axis_scores = models.JSONField(default=dict)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
|
||||
class Character(models.Model):
|
||||
slug = models.SlugField(unique=True)
|
||||
name = models.CharField(max_length=80)
|
||||
profile = models.TextField(blank=True)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Story(models.Model):
|
||||
class Kind(models.TextChoices):
|
||||
FLAGSHIP = "flagship", "旗舰人生"
|
||||
SKILL = "skill", "人物 Skill"
|
||||
SPECIAL = "special", "特别篇"
|
||||
|
||||
slug = models.SlugField(unique=True)
|
||||
title = models.CharField(max_length=120)
|
||||
summary = models.TextField(blank=True)
|
||||
kind = models.CharField(max_length=16, choices=Kind.choices, default=Kind.FLAGSHIP)
|
||||
estimated_minutes = models.PositiveIntegerField(default=20)
|
||||
is_visible = models.BooleanField(default=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
|
||||
class StoryVersion(models.Model):
|
||||
story = models.ForeignKey(Story, on_delete=models.CASCADE, related_name="versions")
|
||||
version = models.PositiveIntegerField()
|
||||
content = models.JSONField()
|
||||
is_published = models.BooleanField(default=False)
|
||||
published_at = models.DateTimeField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=("story", "version"), name="unique_story_version")
|
||||
]
|
||||
ordering = ["story", "-version"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.story} v{self.version}"
|
||||
|
||||
|
||||
class StoryRun(models.Model):
|
||||
class Status(models.TextChoices):
|
||||
ACTIVE = "active", "进行中"
|
||||
COMPLETED = "completed", "已完成"
|
||||
ABANDONED = "abandoned", "已放弃"
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="story_runs")
|
||||
story_version = models.ForeignKey(StoryVersion, on_delete=models.PROTECT, related_name="runs")
|
||||
current_node = models.CharField(max_length=100)
|
||||
state = models.JSONField(default=dict)
|
||||
status = models.CharField(max_length=16, choices=Status.choices, default=Status.ACTIVE)
|
||||
ending_code = models.CharField(max_length=80, blank=True)
|
||||
started_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
completed_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-updated_at"]
|
||||
|
||||
|
||||
class StoryChoice(models.Model):
|
||||
run = models.ForeignKey(StoryRun, on_delete=models.CASCADE, related_name="choices")
|
||||
sequence = models.PositiveIntegerField()
|
||||
idempotency_key = models.CharField(max_length=80, null=True, blank=True)
|
||||
node_id = models.CharField(max_length=100)
|
||||
choice_index = models.PositiveIntegerField()
|
||||
choice_text = models.CharField(max_length=300)
|
||||
effects = models.JSONField(default=dict, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=("run", "sequence"), name="unique_run_choice_sequence"),
|
||||
models.UniqueConstraint(
|
||||
fields=("run", "idempotency_key"), name="unique_run_choice_idempotency"
|
||||
),
|
||||
]
|
||||
ordering = ["sequence"]
|
||||
|
||||
|
||||
class UserRelationship(models.Model):
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
||||
character = models.ForeignKey(Character, on_delete=models.CASCADE)
|
||||
trust = models.SmallIntegerField(default=0)
|
||||
rivalry = models.SmallIntegerField(default=0)
|
||||
debt = models.SmallIntegerField(default=0)
|
||||
cooperation = models.SmallIntegerField(default=0)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=("user", "character"), name="unique_user_character")
|
||||
]
|
||||
|
||||
|
||||
class SkillPackage(models.Model):
|
||||
story = models.OneToOneField(Story, on_delete=models.CASCADE, related_name="skill_package")
|
||||
real_person = models.CharField(max_length=120)
|
||||
historical_context = models.TextField()
|
||||
fact_sources = models.JSONField(default=list)
|
||||
fictional_scope = models.TextField()
|
||||
creator = models.CharField(max_length=120)
|
||||
@@ -0,0 +1,192 @@
|
||||
from copy import deepcopy
|
||||
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from .models import StoryChoice, StoryRun, StoryVersion
|
||||
|
||||
|
||||
def score_mathbti(definition, answer_indexes):
|
||||
questions = definition.get("questions", [])
|
||||
if len(answer_indexes) != len(questions):
|
||||
raise ValidationError({"answers": "答案数量与题目数量不一致"})
|
||||
|
||||
axis_scores = {axis["id"]: 0 for axis in definition.get("axes", [])}
|
||||
for question, raw_index in zip(questions, answer_indexes):
|
||||
try:
|
||||
option_index = int(raw_index)
|
||||
options = question["options"]
|
||||
if option_index < 0 or option_index >= len(options):
|
||||
raise IndexError
|
||||
option = options[option_index]
|
||||
except (IndexError, KeyError, TypeError, ValueError) as exc:
|
||||
raise ValidationError({"answers": f"题目 {question.get('id')} 的答案无效"}) from exc
|
||||
axis_scores[question["axis"]] += int(option["score"])
|
||||
|
||||
cutoff = int(definition.get("scoring", {}).get("cutoff", 4))
|
||||
axis_order = definition.get("scoring", {}).get("axes", list(axis_scores))
|
||||
identity_code = "".join("1" if axis_scores[axis] > cutoff else "0" for axis in axis_order)
|
||||
return identity_code, axis_scores
|
||||
|
||||
|
||||
def validate_story_content(content):
|
||||
nodes = content.get("nodes")
|
||||
start = content.get("start_node")
|
||||
errors = []
|
||||
if not isinstance(nodes, dict) or not nodes:
|
||||
return ["nodes 必须是非空对象"]
|
||||
if start not in nodes:
|
||||
errors.append("start_node 不存在")
|
||||
|
||||
referenced = set()
|
||||
for node_id, node in nodes.items():
|
||||
if not isinstance(node.get("choices", []), list):
|
||||
errors.append(f"{node_id}.choices 必须是数组")
|
||||
continue
|
||||
for choice in node.get("choices", []):
|
||||
target = choice.get("next")
|
||||
if not target:
|
||||
errors.append(f"{node_id} 存在缺少 next 的选项")
|
||||
elif target not in nodes:
|
||||
errors.append(f"{node_id} 引用了不存在的节点 {target}")
|
||||
else:
|
||||
referenced.add(target)
|
||||
|
||||
if start in nodes:
|
||||
reachable = set()
|
||||
pending = [start]
|
||||
while pending:
|
||||
node_id = pending.pop()
|
||||
if node_id in reachable:
|
||||
continue
|
||||
reachable.add(node_id)
|
||||
pending.extend(
|
||||
choice.get("next")
|
||||
for choice in nodes[node_id].get("choices", [])
|
||||
if choice.get("next") in nodes
|
||||
)
|
||||
unreachable = sorted(set(nodes) - reachable)
|
||||
if unreachable:
|
||||
errors.append(f"存在不可达节点: {', '.join(unreachable[:10])}")
|
||||
return errors
|
||||
|
||||
|
||||
def _merge_effects(state, effects):
|
||||
result = deepcopy(state)
|
||||
for key, value in effects.items():
|
||||
if isinstance(value, dict):
|
||||
result[key] = _merge_effects(result.get(key, {}), value)
|
||||
elif isinstance(value, (int, float)):
|
||||
result[key] = result.get(key, 0) + value
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _node_payload(run):
|
||||
node = run.story_version.content["nodes"][run.current_node]
|
||||
choices = [
|
||||
{"index": index, "text": choice.get("text", "")}
|
||||
for index, choice in enumerate(node.get("choices", []))
|
||||
]
|
||||
return {
|
||||
"run_id": run.id,
|
||||
"story": run.story_version.story.title,
|
||||
"version": run.story_version.version,
|
||||
"status": run.status,
|
||||
"current_node": run.current_node,
|
||||
"state": run.state,
|
||||
"node": {
|
||||
"scene": node.get("scene", ""),
|
||||
"character": node.get("character", ""),
|
||||
"sprite": node.get("sprite", ""),
|
||||
"choices": choices,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def start_story(user, story):
|
||||
version = (
|
||||
StoryVersion.objects.select_related("story")
|
||||
.filter(story=story, is_published=True)
|
||||
.order_by("-version")
|
||||
.first()
|
||||
)
|
||||
if version is None:
|
||||
raise ValidationError("该人生尚未发布")
|
||||
active = StoryRun.objects.filter(
|
||||
user=user, story_version=version, status=StoryRun.Status.ACTIVE
|
||||
).first()
|
||||
if active:
|
||||
return _node_payload(active)
|
||||
run = StoryRun.objects.create(
|
||||
user=user,
|
||||
story_version=version,
|
||||
current_node=version.content["start_node"],
|
||||
state={},
|
||||
)
|
||||
return _node_payload(run)
|
||||
|
||||
|
||||
def get_run_payload(run):
|
||||
return _node_payload(run)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def make_choice(*, run_id, user, choice_index, idempotency_key=None):
|
||||
run = (
|
||||
StoryRun.objects.select_for_update()
|
||||
.select_related("story_version__story")
|
||||
.get(id=run_id, user=user)
|
||||
)
|
||||
if idempotency_key:
|
||||
existing = StoryChoice.objects.filter(
|
||||
run=run, idempotency_key=idempotency_key
|
||||
).first()
|
||||
if existing:
|
||||
return _node_payload(run)
|
||||
if run.status != StoryRun.Status.ACTIVE:
|
||||
raise ValidationError("该人生已经结束")
|
||||
|
||||
node_id = run.current_node
|
||||
node = run.story_version.content["nodes"][node_id]
|
||||
choices = node.get("choices", [])
|
||||
try:
|
||||
normalized_index = int(choice_index)
|
||||
if normalized_index < 0 or normalized_index >= len(choices):
|
||||
raise IndexError
|
||||
choice = choices[normalized_index]
|
||||
except (IndexError, TypeError, ValueError) as exc:
|
||||
raise ValidationError({"choice_index": "选项不存在"}) from exc
|
||||
|
||||
sequence = run.choices.count() + 1
|
||||
effects = choice.get("effects", {})
|
||||
StoryChoice.objects.create(
|
||||
run=run,
|
||||
sequence=sequence,
|
||||
idempotency_key=idempotency_key,
|
||||
node_id=node_id,
|
||||
choice_index=normalized_index,
|
||||
choice_text=choice.get("text", ""),
|
||||
effects=effects,
|
||||
)
|
||||
run.state = _merge_effects(run.state, effects)
|
||||
run.current_node = choice["next"]
|
||||
next_node = run.story_version.content["nodes"][run.current_node]
|
||||
if not next_node.get("choices"):
|
||||
run.status = StoryRun.Status.COMPLETED
|
||||
run.ending_code = run.current_node
|
||||
run.completed_at = timezone.now()
|
||||
run.save(
|
||||
update_fields=[
|
||||
"state",
|
||||
"current_node",
|
||||
"status",
|
||||
"ending_code",
|
||||
"completed_at",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
return _node_payload(run)
|
||||
@@ -0,0 +1,144 @@
|
||||
import pytest
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from accounts.models import User
|
||||
from math_life.models import Story, StoryChoice, StoryRun, StoryVersion
|
||||
from math_life.services import (
|
||||
make_choice,
|
||||
score_mathbti,
|
||||
start_story,
|
||||
validate_story_content,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mathbti_definition():
|
||||
return {
|
||||
"axes": [{"id": "style"}, {"id": "purpose"}],
|
||||
"scoring": {"axes": ["style", "purpose"], "cutoff": 1},
|
||||
"questions": [
|
||||
{
|
||||
"id": 1,
|
||||
"axis": "style",
|
||||
"options": [{"score": 0}, {"score": 2}],
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"axis": "purpose",
|
||||
"options": [{"score": 0}, {"score": 2}],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_score_mathbti_按轴汇总并生成身份编码(mathbti_definition):
|
||||
code, scores = score_mathbti(mathbti_definition, [1, 0])
|
||||
|
||||
assert code == "10"
|
||||
assert scores == {"style": 2, "purpose": 0}
|
||||
|
||||
|
||||
def test_score_mathbti_答案数量不一致时拒绝(mathbti_definition):
|
||||
with pytest.raises(ValidationError, match="答案数量"):
|
||||
score_mathbti(mathbti_definition, [1])
|
||||
|
||||
|
||||
def test_score_mathbti_负数选项索引必须拒绝(mathbti_definition):
|
||||
with pytest.raises(ValidationError, match="答案无效"):
|
||||
score_mathbti(mathbti_definition, [-1, 0])
|
||||
|
||||
|
||||
def test_validate_story_content_报告缺失引用与不可达节点():
|
||||
document = {
|
||||
"start_node": "start",
|
||||
"nodes": {
|
||||
"start": {"choices": [{"text": "错误入口", "next": "missing"}]},
|
||||
"orphan": {"choices": []},
|
||||
},
|
||||
}
|
||||
|
||||
errors = validate_story_content(document)
|
||||
|
||||
assert any("不存在的节点 missing" in error for error in errors)
|
||||
assert any("不可达节点: orphan" in error for error in errors)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def story_setup(db):
|
||||
user = User.objects.create_user(
|
||||
username="story_user",
|
||||
password="StrongPass_2026",
|
||||
nickname="剧情用户",
|
||||
)
|
||||
story = Story.objects.create(slug="test-story", title="测试人生")
|
||||
version = StoryVersion.objects.create(
|
||||
story=story,
|
||||
version=1,
|
||||
is_published=True,
|
||||
content={
|
||||
"start_node": "start",
|
||||
"nodes": {
|
||||
"start": {
|
||||
"scene": "起点",
|
||||
"choices": [
|
||||
{
|
||||
"text": "向左",
|
||||
"next": "left_end",
|
||||
"effects": {"energy": -1, "favorability": {"高斯": 2}},
|
||||
},
|
||||
{
|
||||
"text": "向右",
|
||||
"next": "right_end",
|
||||
"effects": {"energy": 2},
|
||||
},
|
||||
],
|
||||
},
|
||||
"left_end": {"scene": "左结局", "choices": []},
|
||||
"right_end": {"scene": "右结局", "choices": []},
|
||||
},
|
||||
},
|
||||
)
|
||||
return user, story, version
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_make_choice_应用嵌套效果完成结局且幂等(story_setup):
|
||||
user, story, _ = story_setup
|
||||
started = start_story(user, story)
|
||||
|
||||
result = make_choice(
|
||||
run_id=started["run_id"],
|
||||
user=user,
|
||||
choice_index=0,
|
||||
idempotency_key="choice-1",
|
||||
)
|
||||
replay = make_choice(
|
||||
run_id=started["run_id"],
|
||||
user=user,
|
||||
choice_index=0,
|
||||
idempotency_key="choice-1",
|
||||
)
|
||||
|
||||
assert result["status"] == StoryRun.Status.COMPLETED
|
||||
assert result["current_node"] == "left_end"
|
||||
assert result["state"] == {"energy": -1, "favorability": {"高斯": 2}}
|
||||
assert replay["current_node"] == "left_end"
|
||||
assert StoryChoice.objects.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_make_choice_负数索引必须拒绝且存档不变(story_setup):
|
||||
user, story, _ = story_setup
|
||||
started = start_story(user, story)
|
||||
|
||||
with pytest.raises(ValidationError, match="选项不存在"):
|
||||
make_choice(
|
||||
run_id=started["run_id"],
|
||||
user=user,
|
||||
choice_index=-1,
|
||||
idempotency_key="invalid-choice",
|
||||
)
|
||||
|
||||
run = StoryRun.objects.get(id=started["run_id"])
|
||||
assert run.current_node == "start"
|
||||
assert StoryChoice.objects.count() == 0
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1,20 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import (
|
||||
MathBTIAssessmentView,
|
||||
MathBTISubmitView,
|
||||
StoryChoiceView,
|
||||
StoryListView,
|
||||
StoryRunView,
|
||||
StoryStartView,
|
||||
)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("mathbti/", MathBTIAssessmentView.as_view(), name="mathbti-assessment"),
|
||||
path("mathbti/submit/", MathBTISubmitView.as_view(), name="mathbti-submit"),
|
||||
path("stories/", StoryListView.as_view(), name="story-list"),
|
||||
path("stories/<slug:slug>/start/", StoryStartView.as_view(), name="story-start"),
|
||||
path("runs/<uuid:run_id>/", StoryRunView.as_view(), name="story-run"),
|
||||
path("runs/<uuid:run_id>/choice/", StoryChoiceView.as_view(), name="story-choice"),
|
||||
]
|
||||
@@ -0,0 +1,129 @@
|
||||
from copy import deepcopy
|
||||
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework import permissions, status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from progression.services import initialize_math_identity
|
||||
|
||||
from .models import (
|
||||
MathBTIAssessment,
|
||||
MathBTIResult,
|
||||
MathIdentity,
|
||||
Story,
|
||||
StoryRun,
|
||||
)
|
||||
from .services import get_run_payload, make_choice, score_mathbti, start_story
|
||||
|
||||
|
||||
class MathBTIAssessmentView(APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def get(self, request):
|
||||
assessment = (
|
||||
MathBTIAssessment.objects.filter(is_published=True).order_by("-published_at").first()
|
||||
)
|
||||
if assessment is None:
|
||||
return Response(
|
||||
{"error": {"code": "not_published", "message": "MathBTI 尚未发布"}},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
definition = deepcopy(assessment.definition)
|
||||
for question in definition.get("questions", []):
|
||||
for option in question.get("options", []):
|
||||
option.pop("score", None)
|
||||
definition.pop("results", None)
|
||||
return Response({"version": assessment.version, "definition": definition})
|
||||
|
||||
|
||||
class MathBTISubmitView(APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def post(self, request):
|
||||
assessment = get_object_or_404(
|
||||
MathBTIAssessment,
|
||||
version=request.data.get("version"),
|
||||
is_published=True,
|
||||
)
|
||||
identity_code, axis_scores = score_mathbti(
|
||||
assessment.definition,
|
||||
request.data.get("answers", []),
|
||||
)
|
||||
identity = get_object_or_404(MathIdentity, code=identity_code)
|
||||
if request.user.is_authenticated:
|
||||
result = MathBTIResult.objects.create(
|
||||
user=request.user,
|
||||
assessment=assessment,
|
||||
identity=identity,
|
||||
answers=request.data.get("answers", []),
|
||||
axis_scores=axis_scores,
|
||||
)
|
||||
initialize_math_identity(request.user, identity)
|
||||
result_id = result.id
|
||||
else:
|
||||
result_id = None
|
||||
return Response(
|
||||
{
|
||||
"id": result_id,
|
||||
"identity": {
|
||||
"code": identity.code,
|
||||
"name": identity.name,
|
||||
"clan": identity.clan,
|
||||
"mathematician": identity.mathematician,
|
||||
"description": identity.description,
|
||||
"portrait": identity.portrait,
|
||||
"initial_abilities": identity.initial_abilities,
|
||||
},
|
||||
"axis_scores": axis_scores,
|
||||
},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
|
||||
class StoryListView(APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def get(self, request):
|
||||
stories = Story.objects.filter(is_visible=True).order_by("kind", "title")
|
||||
return Response(
|
||||
[
|
||||
{
|
||||
"slug": story.slug,
|
||||
"title": story.title,
|
||||
"summary": story.summary,
|
||||
"kind": story.kind,
|
||||
"estimated_minutes": story.estimated_minutes,
|
||||
"available": story.versions.filter(is_published=True).exists(),
|
||||
}
|
||||
for story in stories
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class StoryStartView(APIView):
|
||||
def post(self, request, slug):
|
||||
story = get_object_or_404(Story, slug=slug, is_visible=True)
|
||||
return Response(start_story(request.user, story), status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class StoryRunView(APIView):
|
||||
def get(self, request, run_id):
|
||||
run = get_object_or_404(
|
||||
StoryRun.objects.select_related("story_version__story"),
|
||||
id=run_id,
|
||||
user=request.user,
|
||||
)
|
||||
return Response(get_run_payload(run))
|
||||
|
||||
|
||||
class StoryChoiceView(APIView):
|
||||
def post(self, request, run_id):
|
||||
get_object_or_404(StoryRun, id=run_id, user=request.user)
|
||||
payload = make_choice(
|
||||
run_id=run_id,
|
||||
user=request.user,
|
||||
choice_index=request.data.get("choice_index"),
|
||||
idempotency_key=request.headers.get("Idempotency-Key"),
|
||||
)
|
||||
return Response(payload)
|
||||
Reference in New Issue
Block a user