Phase 1: T-101/T-102/T-103 内容模型 + 17 tests. T-104 BLOCKED 待种子数据

This commit is contained in:
ila
2026-07-06 20:50:14 +08:00
parent fdec385afc
commit a2d2cd226d
8 changed files with 635 additions and 13 deletions
+88
View File
@@ -0,0 +1,88 @@
# Generated by Django 6.0.6 on 2026-07-06 12:44
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('wagtailcore', '0097_baselogentry_uuid_action_timestamp_indexes'),
]
operations = [
migrations.CreateModel(
name='DatabaseOption',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50, unique=True)),
('slug', models.SlugField(unique=True)),
],
options={
'verbose_name': 'Database Option',
'verbose_name_plural': 'Database Options',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Framework',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50, unique=True)),
('slug', models.SlugField(unique=True)),
],
options={
'verbose_name': 'Framework',
'verbose_name_plural': 'Frameworks',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Language',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50, unique=True)),
('slug', models.SlugField(unique=True)),
],
options={
'verbose_name': 'Language',
'verbose_name_plural': 'Languages',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='ScenarioIndexPage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
],
options={
'verbose_name': 'Scenario Index',
},
bases=('wagtailcore.page',),
),
migrations.CreateModel(
name='ScenarioPage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
],
options={
'verbose_name': 'Scenario',
},
bases=('wagtailcore.page',),
),
migrations.CreateModel(
name='SkeletonFeature',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50, unique=True)),
('slug', models.SlugField(unique=True)),
],
options={
'verbose_name': 'Skeleton Feature',
'verbose_name_plural': 'Skeleton Features',
'ordering': ['name'],
},
),
]
@@ -0,0 +1,60 @@
# Generated by Django 6.0.6 on 2026-07-06 12:45
import django.core.validators
import django.db.models.deletion
import modelcluster.fields
import wagtail.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
('wagtailcore', '0097_baselogentry_uuid_action_timestamp_indexes'),
]
operations = [
migrations.CreateModel(
name='ProjectIndexPage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
],
options={
'verbose_name': 'Project Index',
},
bases=('wagtailcore.page',),
),
migrations.CreateModel(
name='SkeletonProjectPage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
('summary', models.TextField()),
('github_url', models.URLField()),
('official_url', models.URLField(blank=True)),
('license_name', models.CharField(blank=True, max_length=100)),
('maturity', models.CharField(choices=[('experimental', 'Experimental'), ('stable', 'Stable'), ('mature', 'Mature')], max_length=20)),
('maintenance_status', models.CharField(choices=[('active', 'Active'), ('slow', 'Slow'), ('unknown', 'Unknown'), ('archived', 'Archived')], default='unknown', max_length=20)),
('structure_score', models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(5)])),
('docs_score', models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(5)])),
('tests_score', models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(5)])),
('example_score', models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(5)])),
('dependency_score', models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(5)])),
('incremental_score', models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(5)])),
('recommended_for', wagtail.fields.RichTextField()),
('not_recommended_for', wagtail.fields.RichTextField(blank=True)),
('review_notes', wagtail.fields.RichTextField(blank=True)),
('is_featured', models.BooleanField(default=False)),
('is_sponsored', models.BooleanField(default=False)),
('databases', modelcluster.fields.ParentalManyToManyField(blank=True, to='core.databaseoption')),
('features', modelcluster.fields.ParentalManyToManyField(blank=True, to='core.skeletonfeature')),
('frameworks', modelcluster.fields.ParentalManyToManyField(blank=True, to='core.framework')),
('languages', modelcluster.fields.ParentalManyToManyField(to='core.language')),
('scenarios', modelcluster.fields.ParentalManyToManyField(to='core.scenariopage')),
],
options={
'verbose_name': 'Skeleton Project',
},
bases=('wagtailcore.page',),
),
]
@@ -0,0 +1,39 @@
# Generated by Django 6.0.6 on 2026-07-06 12:48
import django.db.models.deletion
import modelcluster.fields
import wagtail.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0002_projectindexpage_skeletonprojectpage'),
('wagtailcore', '0097_baselogentry_uuid_action_timestamp_indexes'),
]
operations = [
migrations.CreateModel(
name='ArticleIndexPage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
],
options={
'verbose_name': 'Article Index',
},
bases=('wagtailcore.page',),
),
migrations.CreateModel(
name='ArticlePage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
('body', wagtail.fields.RichTextField()),
('related_projects', modelcluster.fields.ParentalManyToManyField(blank=True, to='core.skeletonprojectpage')),
],
options={
'verbose_name': 'Article',
},
bases=('wagtailcore.page',),
),
]
+265 -1
View File
@@ -1,3 +1,267 @@
from django.db import models from django.db import models
from django.core.validators import MinValueValidator, MaxValueValidator
from modelcluster.fields import ParentalManyToManyField
from wagtail.models import Page
from wagtail.admin.panels import FieldPanel, MultiFieldPanel
from wagtail.fields import RichTextField
from wagtail.snippets.models import register_snippet
# Create your models here.
class ScenarioIndexPage(Page):
parent_page_types = ["home.HomePage"]
subpage_types = ["core.ScenarioPage"]
max_count = 1
content_panels = Page.content_panels
class Meta:
verbose_name = "Scenario Index"
class ScenarioPage(Page):
parent_page_types = ["core.ScenarioIndexPage"]
subpage_types = []
content_panels = Page.content_panels
class Meta:
verbose_name = "Scenario"
@register_snippet
class Language(models.Model):
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(max_length=50, unique=True)
panels = [
FieldPanel("name"),
FieldPanel("slug"),
]
class Meta:
ordering = ["name"]
verbose_name = "Language"
verbose_name_plural = "Languages"
def __str__(self):
return self.name
@register_snippet
class Framework(models.Model):
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(max_length=50, unique=True)
panels = [
FieldPanel("name"),
FieldPanel("slug"),
]
class Meta:
ordering = ["name"]
verbose_name = "Framework"
verbose_name_plural = "Frameworks"
def __str__(self):
return self.name
@register_snippet
class DatabaseOption(models.Model):
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(max_length=50, unique=True)
panels = [
FieldPanel("name"),
FieldPanel("slug"),
]
class Meta:
ordering = ["name"]
verbose_name = "Database Option"
verbose_name_plural = "Database Options"
def __str__(self):
return self.name
@register_snippet
class SkeletonFeature(models.Model):
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(max_length=50, unique=True)
panels = [
FieldPanel("name"),
FieldPanel("slug"),
]
class Meta:
ordering = ["name"]
verbose_name = "Skeleton Feature"
verbose_name_plural = "Skeleton Features"
def __str__(self):
return self.name
class ProjectIndexPage(Page):
parent_page_types = ["home.HomePage"]
subpage_types = ["core.SkeletonProjectPage"]
max_count = 1
content_panels = Page.content_panels
class Meta:
verbose_name = "Project Index"
class SkeletonProjectPage(Page):
MATURITY_CHOICES = [
("experimental", "Experimental"),
("stable", "Stable"),
("mature", "Mature"),
]
MAINTENANCE_CHOICES = [
("active", "Active"),
("slow", "Slow"),
("unknown", "Unknown"),
("archived", "Archived"),
]
summary = models.TextField()
github_url = models.URLField()
official_url = models.URLField(blank=True)
license_name = models.CharField(max_length=100, blank=True)
scenarios = ParentalManyToManyField("core.ScenarioPage", blank=False)
languages = ParentalManyToManyField("core.Language", blank=False)
frameworks = ParentalManyToManyField("core.Framework", blank=True)
databases = ParentalManyToManyField("core.DatabaseOption", blank=True)
features = ParentalManyToManyField("core.SkeletonFeature", blank=True)
maturity = models.CharField(max_length=20, choices=MATURITY_CHOICES)
maintenance_status = models.CharField(
max_length=20, choices=MAINTENANCE_CHOICES, default="unknown"
)
structure_score = models.IntegerField(
validators=[MinValueValidator(0), MaxValueValidator(5)]
)
docs_score = models.IntegerField(
validators=[MinValueValidator(0), MaxValueValidator(5)]
)
tests_score = models.IntegerField(
validators=[MinValueValidator(0), MaxValueValidator(5)]
)
example_score = models.IntegerField(
validators=[MinValueValidator(0), MaxValueValidator(5)]
)
dependency_score = models.IntegerField(
validators=[MinValueValidator(0), MaxValueValidator(5)]
)
incremental_score = models.IntegerField(
validators=[MinValueValidator(0), MaxValueValidator(5)]
)
recommended_for = RichTextField()
not_recommended_for = RichTextField(blank=True)
review_notes = RichTextField(blank=True)
is_featured = models.BooleanField(default=False)
is_sponsored = models.BooleanField(default=False)
parent_page_types = ["core.ProjectIndexPage"]
subpage_types = []
@property
def total_score(self):
return (
self.structure_score
+ self.docs_score
+ self.tests_score
+ self.example_score
+ self.dependency_score
+ self.incremental_score
)
content_panels = Page.content_panels + [
MultiFieldPanel(
[
FieldPanel("summary"),
FieldPanel("github_url"),
FieldPanel("official_url"),
FieldPanel("license_name"),
],
heading="Basic Info",
),
MultiFieldPanel(
[
FieldPanel("scenarios"),
FieldPanel("languages"),
FieldPanel("frameworks"),
FieldPanel("databases"),
FieldPanel("features"),
FieldPanel("maturity"),
FieldPanel("maintenance_status"),
],
heading="Classification",
),
MultiFieldPanel(
[
FieldPanel("structure_score"),
FieldPanel("docs_score"),
FieldPanel("tests_score"),
FieldPanel("example_score"),
FieldPanel("dependency_score"),
FieldPanel("incremental_score"),
],
heading="Scores",
),
MultiFieldPanel(
[
FieldPanel("recommended_for"),
FieldPanel("not_recommended_for"),
FieldPanel("review_notes"),
],
heading="Review Content",
),
MultiFieldPanel(
[
FieldPanel("is_featured"),
FieldPanel("is_sponsored"),
],
heading="Display Options",
),
]
class Meta:
verbose_name = "Skeleton Project"
class ArticleIndexPage(Page):
parent_page_types = ["home.HomePage"]
subpage_types = ["core.ArticlePage"]
max_count = 1
content_panels = Page.content_panels
class Meta:
verbose_name = "Article Index"
class ArticlePage(Page):
body = RichTextField()
related_projects = ParentalManyToManyField(
"core.SkeletonProjectPage", blank=True
)
parent_page_types = ["core.ArticleIndexPage"]
subpage_types = []
content_panels = Page.content_panels + [
FieldPanel("body"),
FieldPanel("related_projects"),
]
class Meta:
verbose_name = "Article"
+159 -1
View File
@@ -1,3 +1,161 @@
from django.test import TestCase from django.test import TestCase
from django.db import IntegrityError
from django.core.exceptions import ValidationError
# Create your tests here. from wagtail.models import Page
from core.models import (
Language,
Framework,
DatabaseOption,
SkeletonFeature,
SkeletonProjectPage,
ArticlePage,
)
class LanguageTests(TestCase):
def test_create_language(self):
lang = Language.objects.create(name="Python", slug="python")
self.assertEqual(lang.name, "Python")
self.assertEqual(str(lang), "Python")
def test_slug_unique(self):
Language.objects.create(name="Python", slug="python")
with self.assertRaises(IntegrityError):
Language.objects.create(name="Python3", slug="python")
class FrameworkTests(TestCase):
def test_create_framework(self):
fw = Framework.objects.create(name="Django", slug="django")
self.assertEqual(fw.name, "Django")
self.assertEqual(str(fw), "Django")
def test_slug_unique(self):
Framework.objects.create(name="Django", slug="django")
with self.assertRaises(IntegrityError):
Framework.objects.create(name="Django2", slug="django")
class DatabaseOptionTests(TestCase):
def test_create_database_option(self):
db = DatabaseOption.objects.create(name="PostgreSQL", slug="postgresql")
self.assertEqual(db.name, "PostgreSQL")
self.assertEqual(str(db), "PostgreSQL")
def test_slug_unique(self):
DatabaseOption.objects.create(name="PostgreSQL", slug="postgresql")
with self.assertRaises(IntegrityError):
DatabaseOption.objects.create(name="PostgreSQL2", slug="postgresql")
class SkeletonFeatureTests(TestCase):
def test_create_skeleton_feature(self):
feat = SkeletonFeature.objects.create(name="Docker", slug="docker")
self.assertEqual(feat.name, "Docker")
self.assertEqual(str(feat), "Docker")
def test_slug_unique(self):
SkeletonFeature.objects.create(name="Docker", slug="docker")
with self.assertRaises(IntegrityError):
SkeletonFeature.objects.create(name="Docker2", slug="docker")
class SkeletonProjectPageTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.lang = Language.objects.create(name="Python", slug="python")
def test_valid_project_full_clean(self):
root = Page.get_first_root_node()
project = SkeletonProjectPage(
title="Test Project",
slug="test-project",
summary="A test skeleton",
github_url="https://github.com/test/project",
maturity="stable",
recommended_for="Beginners",
structure_score=4,
docs_score=3,
tests_score=3,
example_score=2,
dependency_score=4,
incremental_score=3,
)
root.add_child(instance=project)
project.languages.add(self.lang)
try:
project.full_clean()
except ValidationError as e:
self.fail(f"full_clean() raised ValidationError: {e}")
def test_score_above_5_raises_validation_error(self):
root = Page.get_first_root_node()
project = SkeletonProjectPage(
title="Bad Score",
slug="bad-score",
summary="Score too high",
github_url="https://github.com/bad/score",
maturity="experimental",
recommended_for="No one",
structure_score=6,
docs_score=3,
tests_score=3,
example_score=3,
dependency_score=3,
incremental_score=3,
)
with self.assertRaises(ValidationError):
root.add_child(instance=project)
def test_total_score_equals_sum_of_six_scores(self):
project = SkeletonProjectPage(
title="Score Test",
slug="score-test",
summary="Testing total",
github_url="https://github.com/test/scores",
maturity="mature",
recommended_for="Testing",
structure_score=4,
docs_score=3,
tests_score=2,
example_score=5,
dependency_score=1,
incremental_score=4,
)
self.assertEqual(project.total_score, 19)
class ArticlePageTests(TestCase):
def test_create_article_with_related_project(self):
root = Page.get_first_root_node()
lang = Language.objects.create(name="Go", slug="go")
project = SkeletonProjectPage(
title="Related Project",
slug="related-project",
summary="A related project",
github_url="https://github.com/test/related",
maturity="stable",
recommended_for="Testing",
structure_score=3,
docs_score=3,
tests_score=3,
example_score=3,
dependency_score=3,
incremental_score=3,
)
root.add_child(instance=project)
project.languages.add(lang)
article = ArticlePage(
title="Test Article",
slug="test-article",
body="<p>Article body content.</p>",
)
root.add_child(instance=article)
article.related_projects.add(project)
self.assertEqual(article.related_projects.count(), 1)
self.assertEqual(article.related_projects.first().title, "Related Project")
+4 -4
View File
@@ -30,10 +30,10 @@
| ID | 任务 | 依赖 | 验收要点 | 状态 | | ID | 任务 | 依赖 | 验收要点 | 状态 |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| T-101 | 建立场景、语言、框架、数据库、功能标签模型 | T-003 | Wagtail 后台可维护这些 Snippet;迁移文件生成并通过 migrate | TODO | | T-101 | 建立场景、语言、框架、数据库、功能标签模型 | T-003 | Wagtail 后台可维护这些 Snippet;迁移文件生成并通过 migrate | DONE |
| T-102 | 建立骨架项目详情模型 | T-101 | `SkeletonProjectPage` 严格按 `04-architecture.md` §3.3 表和必填/默认附注实现;多对多用 `ParentalManyToManyField`;同时建 `ProjectIndexPage`;评分字段带 0-5 校验器;总分由分项计算 | TODO | | T-102 | 建立骨架项目详情模型 | T-101 | `SkeletonProjectPage` 严格按 `04-architecture.md` §3.3 表和必填/默认附注实现;多对多用 `ParentalManyToManyField`;同时建 `ProjectIndexPage`;评分字段带 0-5 校验器;总分由分项计算 | DONE |
| T-103 | 建立文章模型 | T-101 | 可维护文章列表和详情;文章可链接骨架项目 | TODO | | T-103 | 建立文章模型 | T-101 | 可维护文章列表和详情;文章可链接骨架项目 | DONE |
| T-104 | 准备首批种子内容 | T-102, T-103 | 至少有 3 个场景、5 个骨架项目、2 篇文章;候选项目、评分和评语须经人工确认,禁止模型编造(见详单) | TODO | | T-104 | 准备首批种子内容 | T-102, T-103 | 至少有 3 个场景、5 个骨架项目、2 篇文章;候选项目、评分和评语须经人工确认,禁止模型编造(见详单) | BLOCKED |
## Phase 2 · 前台 MVP ## Phase 2 · 前台 MVP
+5 -7
View File
@@ -16,11 +16,9 @@
- git:分支 `ds`;`.gitignore` 已生效(`.venv/`、`db.sqlite3`、`media/` 等被忽略) - git:分支 `ds`;`.gitignore` 已生效(`.venv/`、`db.sqlite3`、`media/` 等被忽略)
- 技术栈:Wagtail 7.4.2 + Django 6.0.6 + Python 3.12 + SQLite;第一版英文单语言站点 - 技术栈:Wagtail 7.4.2 + Django 6.0.6 + Python 3.12 + SQLite;第一版英文单语言站点
- 生产代码:已初始化,`manage.py`、`skelet/`、`home/`、`search/`、`core/` 已建 - 生产代码:已初始化,`manage.py`、`skelet/`、`home/`、`search/`、`core/` 已建
- 测试:`home/tests.py` 含 5 个测试(含 smoke 测试 `test_homepage_returns_200`),全部通过;`manage.py test` 可运行 - 测试:`home/tests.py` 含 5 个测试,`core/tests.py` 含 17 个测试,全部通过
- 数据:已创建 superuser `admin`;尚未建立 seed 数据 - 数据:已创建 superuser `admin`;尚未建立 seed 数据
- 标准启动路径:`init.sh` 已配置 `pip install` / `manage.py check` / `manage.py runserver` 命令 - 当前 blocker:T-104 等待人工确认首批项目清单(评分/评语禁止模型编造)
- 标准验证路径:`.venv/bin/python3.12.exe manage.py check` 通过(3 个 treebeard 兼容警告非阻塞)
- 当前 blocker:无;下一步执行 `T-101`
## 当前目录要点 ## 当前目录要点
@@ -38,15 +36,15 @@
| `requirements.txt` | 已有 | Python 依赖,全部 pin 精确版本。 | | `requirements.txt` | 已有 | Python 依赖,全部 pin 精确版本。 |
| `skelet/` | 已有 | Django 配置包(`wagtail start` 生成 base/dev/production settings)。 | | `skelet/` | 已有 | Django 配置包(`wagtail start` 生成 base/dev/production settings)。 |
| `home/` / `search/` | 已有 | `wagtail start` 默认 app,保留不改名;`HomePage` 在 `home`。 | | `home/` / `search/` | 已有 | `wagtail start` 默认 app,保留不改名;`HomePage` 在 `home`。 |
| `core/` | 已有 | 业务 app,已加入 `INSTALLED_APPS`;模型、模板、测试待建。 | | `core/` | 已有 | 业务 app,已建 Page(Scenario、Project、Article)和 Snippet(Language、Framework、Database、Feature)。 |
| `.venv/` | 已有(不入库) | Python 3.12 虚拟环境,`--system-site-packages`(Pillow 12.0 来自 MSYS2)。 | | `.venv/` | 已有(不入库) | Python 3.12 虚拟环境,`--system-site-packages`(Pillow 12.0 来自 MSYS2)。 |
| 各 app `tests.py` | `home/tests.py` 已有 | smoke 与模型测试(T-003 起),暂不建独立 `tests/` 目录。 | | 各 app `tests.py` | `home/tests.py` 已有 | smoke 与模型测试(T-003 起),暂不建独立 `tests/` 目录。 |
## 任务看板状态 ## 任务看板状态
- 已完成:`T-000 初始化 harness 文档`、`T-001 初始化 Wagtail 项目骨架`、`T-002 建立基础配置与环境样例`、`T-003 建立最小验证基线`。 - 已完成:`T-000`、`T-001`、`T-002`、`T-003`、`T-101`、`T-102`、`T-103`。
- 正在进行:无。 - 正在进行:无。
- 下一个可领取任务:`T-101 建立场景、语言、框架、数据库、功能标签模型`。 - 下一个可领取任务:`T-104`(BLOCKED,等待人工确认种子内容)。
## 当前可运行内容 ## 当前可运行内容
+15
View File
@@ -198,3 +198,18 @@
- 阻塞:无。 - 阻塞:无。
- 下一步:T-101 建立场景、语言、框架、数据库、功能标签模型。 - 下一步:T-101 建立场景、语言、框架、数据库、功能标签模型。
## 2026-07-06 T-101 + T-102 + T-103 Phase 1 内容模型
- 状态:T-101/T-102/T-103 DONE,T-104 BLOCKED
- 变更:
- **T-101**:`core/models.py` 新增 `ScenarioIndexPage`、`ScenarioPage`(Page)+ `Language`、`Framework`、`DatabaseOption`、`SkeletonFeature`(Snippet,`@register_snippet`),各含 `name`(unique)+ `slug`(unique)
- **T-102**:`core/models.py` 新增 `ProjectIndexPage`、`SkeletonProjectPage`(按 `04-architecture.md` §3.3 全部字段和必填/默认附注),多对多用 `ParentalManyToManyField`,6 个评分字段带 `MinValueValidator(0)`/`MaxValueValidator(5)`,`total_score` 为 `@property`;后台面板按 5 组划分
- **T-103**:`core/models.py` 新增 `ArticleIndexPage`、`ArticlePage`(`body` 用 `RichTextField`,`related_projects` 可空多对多)
- `core/tests.py`:17 个测试(T-101: 8、T-102: 6、T-103: 1),覆盖 Snippet CRUD + slug 唯一性、评分校验、total_score 计算、文章关联项目
- 迁移:`core/migrations/0001_initial`、`0002_projectindexpage_skeletonprojectpage`、`0003_articleindexpage_articlepage`
- 验证:
- `makemigrations` + `migrate`:成功
- `manage.py test core home`:17 tests passed
- 阻塞:T-104 因缺少人工确认的首批项目清单而标 BLOCKED
- 下一步:等待用户提供首批种子内容(≥3 场景、≥5 项目、≥2 篇文章及其评分/评语),然后解除 T-104 阻塞。