T-104 M2M 持久化修复:seed_data.py save() + 持久化测试(21 tests)
This commit is contained in:
@@ -19,6 +19,19 @@ from core.models import (
|
|||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = "Seed database with user-confirmed initial content"
|
help = "Seed database with user-confirmed initial content"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"--clear",
|
||||||
|
action="store_true",
|
||||||
|
help="Delete existing content pages before seeding",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _clear_existing(self):
|
||||||
|
for model in [ArticlePage, SkeletonProjectPage, ProjectIndexPage,
|
||||||
|
ScenarioPage, ScenarioIndexPage, ArticleIndexPage]:
|
||||||
|
model.objects.all().delete()
|
||||||
|
self.stdout.write(" Cleared existing content pages")
|
||||||
|
|
||||||
def _create_snippets(self):
|
def _create_snippets(self):
|
||||||
languages = [
|
languages = [
|
||||||
("Python", "python"),
|
("Python", "python"),
|
||||||
@@ -50,6 +63,13 @@ class Command(BaseCommand):
|
|||||||
def _create_pages(self):
|
def _create_pages(self):
|
||||||
home = HomePage.objects.get(slug="home")
|
home = HomePage.objects.get(slug="home")
|
||||||
|
|
||||||
|
if ScenarioIndexPage.objects.filter(slug="scenarios").exists():
|
||||||
|
scenario_index = ScenarioIndexPage.objects.get(slug="scenarios")
|
||||||
|
project_index = ProjectIndexPage.objects.get(slug="projects")
|
||||||
|
article_index = ArticleIndexPage.objects.get(slug="articles")
|
||||||
|
self.stdout.write(" Page tree already exists, skipping")
|
||||||
|
return scenario_index, project_index, article_index
|
||||||
|
|
||||||
scenario_index = ScenarioIndexPage(title="Scenarios", slug="scenarios")
|
scenario_index = ScenarioIndexPage(title="Scenarios", slug="scenarios")
|
||||||
home.add_child(instance=scenario_index)
|
home.add_child(instance=scenario_index)
|
||||||
|
|
||||||
@@ -68,7 +88,7 @@ class Command(BaseCommand):
|
|||||||
article_index = ArticleIndexPage(title="Articles", slug="articles")
|
article_index = ArticleIndexPage(title="Articles", slug="articles")
|
||||||
home.add_child(instance=article_index)
|
home.add_child(instance=article_index)
|
||||||
|
|
||||||
self.stdout.write(" Page tree created")
|
self.stdout.write(" Page tree ready")
|
||||||
return scenario_index, project_index, article_index
|
return scenario_index, project_index, article_index
|
||||||
|
|
||||||
def _get_snippets(self):
|
def _get_snippets(self):
|
||||||
@@ -226,6 +246,10 @@ class Command(BaseCommand):
|
|||||||
]
|
]
|
||||||
|
|
||||||
for proj in projects:
|
for proj in projects:
|
||||||
|
if SkeletonProjectPage.objects.filter(slug=proj["slug"]).exists():
|
||||||
|
self.stdout.write(f" Skipping existing project: {proj['title']}")
|
||||||
|
continue
|
||||||
|
|
||||||
scenarios = proj.pop("scenarios")
|
scenarios = proj.pop("scenarios")
|
||||||
languages = proj.pop("languages")
|
languages = proj.pop("languages")
|
||||||
frameworks = proj.pop("frameworks")
|
frameworks = proj.pop("frameworks")
|
||||||
@@ -239,6 +263,7 @@ class Command(BaseCommand):
|
|||||||
project.frameworks.add(*frameworks)
|
project.frameworks.add(*frameworks)
|
||||||
if databases:
|
if databases:
|
||||||
project.databases.add(*databases)
|
project.databases.add(*databases)
|
||||||
|
project.save()
|
||||||
self.stdout.write(f" Created project: {proj['title']}")
|
self.stdout.write(f" Created project: {proj['title']}")
|
||||||
|
|
||||||
def _create_articles(self, article_index):
|
def _create_articles(self, article_index):
|
||||||
@@ -297,13 +322,25 @@ class Command(BaseCommand):
|
|||||||
]
|
]
|
||||||
|
|
||||||
for art in articles:
|
for art in articles:
|
||||||
|
if ArticlePage.objects.filter(slug=art["slug"]).exists():
|
||||||
|
self.stdout.write(f" Skipping existing article: {art['title']}")
|
||||||
|
continue
|
||||||
|
|
||||||
related = art.pop("related_projects")
|
related = art.pop("related_projects")
|
||||||
article = ArticlePage(**art)
|
article = ArticlePage(**art)
|
||||||
article_index.add_child(instance=article)
|
article_index.add_child(instance=article)
|
||||||
article.related_projects.add(*related)
|
article.related_projects.add(*related)
|
||||||
|
article.save()
|
||||||
self.stdout.write(f" Created article: {art['title']}")
|
self.stdout.write(f" Created article: {art['title']}")
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
|
if options["clear"]:
|
||||||
|
self._clear_existing()
|
||||||
|
|
||||||
|
if not options["clear"] and ScenarioIndexPage.objects.filter(slug="scenarios").exists():
|
||||||
|
self.stdout.write("Seed data already exists. Use --clear to reset.")
|
||||||
|
return
|
||||||
|
|
||||||
self.stdout.write("Creating snippets...")
|
self.stdout.write("Creating snippets...")
|
||||||
self._create_snippets()
|
self._create_snippets()
|
||||||
|
|
||||||
@@ -320,6 +357,19 @@ class Command(BaseCommand):
|
|||||||
# Verify
|
# Verify
|
||||||
self.stdout.write("")
|
self.stdout.write("")
|
||||||
self.stdout.write("=== Seed verification ===")
|
self.stdout.write("=== Seed verification ===")
|
||||||
|
for proj in SkeletonProjectPage.objects.all():
|
||||||
|
sc = proj.scenarios.count()
|
||||||
|
la = proj.languages.count()
|
||||||
|
status = "OK" if sc >= 1 and la >= 1 else "MISSING_M2M"
|
||||||
|
self.stdout.write(
|
||||||
|
f" {proj.title}: scenarios={sc}, languages={la} [{status}]"
|
||||||
|
)
|
||||||
|
for art in ArticlePage.objects.all():
|
||||||
|
rp = art.related_projects.count()
|
||||||
|
status = "OK" if rp >= 1 else "MISSING_M2M"
|
||||||
|
self.stdout.write(
|
||||||
|
f" {art.title}: related_projects={rp} [{status}]"
|
||||||
|
)
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
f"Scenarios: {ScenarioPage.objects.count()} "
|
f"Scenarios: {ScenarioPage.objects.count()} "
|
||||||
f"(≥3: {ScenarioPage.objects.count() >= 3})"
|
f"(≥3: {ScenarioPage.objects.count() >= 3})"
|
||||||
|
|||||||
@@ -161,6 +161,16 @@ class SkeletonProjectPageTests(PageTreeMixin, TestCase):
|
|||||||
except ValidationError:
|
except ValidationError:
|
||||||
self.fail("full_clean() should pass with all M2M set")
|
self.fail("full_clean() should pass with all M2M set")
|
||||||
|
|
||||||
|
def test_m2m_persists_after_save_and_reload(self):
|
||||||
|
project = self._make_project()
|
||||||
|
project.languages.add(self.lang)
|
||||||
|
project.scenarios.add(self.scenario)
|
||||||
|
project.save()
|
||||||
|
|
||||||
|
reloaded = SkeletonProjectPage.objects.get(pk=project.pk)
|
||||||
|
self.assertGreaterEqual(reloaded.scenarios.count(), 1)
|
||||||
|
self.assertGreaterEqual(reloaded.languages.count(), 1)
|
||||||
|
|
||||||
|
|
||||||
class ArticlePageTests(PageTreeMixin, TestCase):
|
class ArticlePageTests(PageTreeMixin, TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
+13
@@ -246,3 +246,16 @@
|
|||||||
- 阻塞:无。
|
- 阻塞:无。
|
||||||
- 下一步:T-201 实现基础页面框架(Phase 2 前台 MVP)。
|
- 下一步:T-201 实现基础页面框架(Phase 2 前台 MVP)。
|
||||||
|
|
||||||
|
## 2026-07-06 T-104 seed_data M2M 持久化修复
|
||||||
|
|
||||||
|
- 状态:DONE
|
||||||
|
- 变更:
|
||||||
|
- `core/management/commands/seed_data.py`:`add_child()` 后 M2M `.add()` 仅在内存操作 cluster,不持久化到 DB;新增 `project.save()` / `article.save()` 确保关联写入
|
||||||
|
- 添加 `--clear` 参数和重复运行保护
|
||||||
|
- `core/tests.py`:新增 `test_m2m_persists_after_save_and_reload` 测试——save() 后从 DB 重新加载,断言 `scenarios.count() >= 1` 和 `languages.count() >= 1`
|
||||||
|
- 验证:
|
||||||
|
- `manage.py seed_data --clear`:5 个项目全部 `scenarios≥1, languages≥1 [OK]`,2 篇文章全部 `related_projects=1 [OK]`
|
||||||
|
- `manage.py test core home`:21 tests passed(新增 1 个持久化测试)
|
||||||
|
- 阻塞:无。
|
||||||
|
- 下一步:T-201 实现基础页面框架(Phase 2 前台 MVP)。
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user