diff --git a/core/models.py b/core/models.py index d590360..a81b211 100644 --- a/core/models.py +++ b/core/models.py @@ -31,12 +31,18 @@ class ScenarioPage(Page): def get_context(self, request, *args, **kwargs): context = super().get_context(request, *args, **kwargs) - context["projects"] = ( + projects = ( SkeletonProjectPage.objects.live() .filter(scenarios=self) .prefetch_related("languages", "scenarios") .order_by("-first_published_at") ) + paginator = Paginator(projects, 20) + page = request.GET.get("page", "1") + try: + context["projects"] = paginator.page(int(page)) + except (ValueError, EmptyPage): + context["projects"] = paginator.page(1) return context class Meta: @@ -148,6 +154,10 @@ class ProjectIndexPage(Page): projects = projects.filter(frameworks__slug=framework) if database: projects = projects.filter(databases__slug=database) + if q: + projects = projects.filter( + Q(title__icontains=q) | Q(summary__icontains=q) + ) try: score_val = int(min_score) if 0 <= score_val <= 30: @@ -156,10 +166,6 @@ class ProjectIndexPage(Page): ] except (ValueError, TypeError): pass - if q: - projects = projects.filter( - Q(title__icontains=q) | Q(summary__icontains=q) - ) paginator = Paginator(projects, 20) page = request.GET.get("page", "1") diff --git a/core/templates/core/scenario_page.html b/core/templates/core/scenario_page.html index 1e0500d..be37e70 100644 --- a/core/templates/core/scenario_page.html +++ b/core/templates/core/scenario_page.html @@ -22,6 +22,18 @@ {% endfor %} + + {% if projects.paginator.num_pages > 1 %} + + {% endif %} {% else %} {% include "includes/empty_state.html" with message="No projects found in this scenario." %} {% endif %} diff --git a/core/tests.py b/core/tests.py index 062a192..69b703a 100644 --- a/core/tests.py +++ b/core/tests.py @@ -75,6 +75,46 @@ class ScenarioPageTests(PageTreeMixin, TestCase): self.assertContains(response, "Nothing here yet") +class ScenarioPaginationTests(PageTreeMixin, TestCase): + @classmethod + def setUpTestData(cls): + cls.setUpPageTree() + cls.lang = Language.objects.create(name="Python", slug="python") + for i in range(21): + project = SkeletonProjectPage( + title=f"Pagination Project {i+1}", + slug=f"pagination-project-{i+1}", + summary=f"Project {i+1} for pagination test", + github_url="https://github.com/test/pagination", + maturity="stable", + recommended_for="Testing", + structure_score=3, docs_score=3, tests_score=3, + example_score=3, dependency_score=3, incremental_score=3, + ) + cls.project_index.add_child(instance=project) + project.languages.add(cls.lang) + project.scenarios.add(cls.scenario) + project.save() + + def test_first_page_has_20_items(self): + resp = self.client.get(self.scenario.url) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, "Pagination Project 1") + self.assertContains(resp, "Pagination Project 20") + self.assertNotContains(resp, "Pagination Project 21") + + def test_second_page_has_1_item(self): + resp = self.client.get(f"{self.scenario.url}?page=2") + self.assertEqual(resp.status_code, 200) + self.assertNotContains(resp, "Pagination Project 1") + self.assertContains(resp, "Pagination Project 21") + + def test_invalid_page_falls_back_to_first(self): + resp = self.client.get(f"{self.scenario.url}?page=abc") + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, "Pagination Project 1") + + class ProjectFilterTests(PageTreeMixin, TestCase): @classmethod def setUpTestData(cls): @@ -135,6 +175,21 @@ class ProjectFilterTests(PageTreeMixin, TestCase): resp = self.client.get("/projects/?min_score=abc&page=xyz") self.assertEqual(resp.status_code, 200) + def test_combined_filters_return_200(self): + resp = self.client.get( + "/projects/?language=python&framework=django&min_score=18&q=Django" + ) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, "Django Project") + self.assertNotContains(resp, "Go Project") + + def test_combined_filters_no_results_returns_empty_state(self): + resp = self.client.get( + "/projects/?language=go&framework=django&min_score=18" + ) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, "No projects match") + class SkeletonProjectDetailTests(PageTreeMixin, TestCase): @classmethod diff --git a/docs/current-state.md b/docs/current-state.md index 4e2c278..12abd63 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -16,7 +16,7 @@ - git:分支 `ds`;`.gitignore` 已生效(`.venv/`、`db.sqlite3`、`media/` 等被忽略) - 技术栈:Wagtail 7.4.2 + Django 6.0.6 + Python 3.12 + SQLite;第一版英文单语言站点 - 生产代码:已初始化,`manage.py`、`skelet/`、`home/`、`search/`、`core/` 已建 -- 测试:`home/tests.py` 含 5 个测试,`core/tests.py` 含 17 个测试,全部通过 +- 测试:31 个测试(`home/tests.py` + `core/tests.py`),全部通过;`manage.py test` 可运行 - 数据:3 个场景、5 个骨架项目、2 篇文章已录入(`core/management/commands/seed_data.py`) - 当前 blocker:无;下一步执行 `T-301`(Phase 3 SEO 与上线准备) diff --git a/docs/review/phase2-review.md b/docs/review/phase2-review.md new file mode 100644 index 0000000..12a8a40 --- /dev/null +++ b/docs/review/phase2-review.md @@ -0,0 +1,122 @@ +# Phase 2 前台 MVP 评审 + +评审日期:2026-07-06 +评审角色:全栈开发工程师 +评审对象:DeepSeek 完成的 Phase 2(T-201 至 T-206) + +## 结论 + +Phase 2 **暂不达标**。 + +基础页面、首页、项目详情、文章详情等主要页面已经可访问,自动化测试也通过;但筛选搜索的组合参数存在 500 错误,且场景页没有实现任务详单要求的分页。因此不能把 Phase 2 视为完整验收通过。 + +## 阻断问题 + +### P0:项目列表 `min_score + q` 组合筛选会 500 + +- 影响任务:T-204 +- 位置:`core/models.py:151` 至 `core/models.py:162` +- 现象:`min_score` 合法时,代码把 `projects` 从 QuerySet 转成 list;后续如果同时存在 `q`,继续调用 `projects.filter(...)`,触发 `AttributeError: 'list' object has no attribute 'filter'`。 +- 复现: + +```bash +.venv/bin/python3.12.exe manage.py shell -c "from django.test import Client; c=Client(raise_request_exception=False); print(c.get('/projects/?min_score=18&q=Django').status_code)" +``` + +实际结果:`500` + +任务契约要求 `language` / `framework` / `database` / `min_score` / `q` / `page` 多参数同时出现时按 AND 组合,见 `docs/04-architecture.md:157` 至 `docs/04-architecture.md:161`。当前实现只覆盖了单项筛选测试,没有覆盖组合筛选,见 `core/tests.py:119` 至 `core/tests.py:136`。 + +建议修复方向: + +- 保持筛选链路的数据类型一致。 +- 可以先用 ORM 对 `q`、language、framework、database 过滤,再对 `min_score` 做 Python 层过滤;也可以用 annotation 计算总分后在 ORM 层过滤。 +- 增加组合筛选测试,例如 `/projects/?language=python&framework=django&min_score=18&q=Django`。 + +### P1:场景页未实现分页 + +- 影响任务:T-203 +- 位置:`core/models.py:32` 至 `core/models.py:40`,`core/templates/core/scenario_page.html:7` 至 `core/templates/core/scenario_page.html:27` +- 现象:场景页直接把全部匹配项目放进 `context["projects"]`,模板也没有分页控件。 +- 任务详单要求:场景页只列 live 项目,分页每页 20,空场景渲染空状态。 + +当前只满足 live 项目和空状态;分页缺失。已有测试只验证有项目和空状态,未验证超过 20 条时分页行为,见 `core/tests.py:40` 至 `core/tests.py:75`。 + +建议修复方向: + +- 在 `ScenarioPage.get_context()` 中使用 `Paginator(projects, 20)`。 +- 支持非法 `page` 回退到第一页,行为和项目列表页一致。 +- 在 `scenario_page.html` 复用项目列表页的分页 UI。 +- 增加 21 个项目的场景页分页测试。 + +## 非阻断问题 + +### P2:`docs/current-state.md` 测试数量已过期 + +- 位置:`docs/current-state.md:19` +- 现象:文档写的是 `home/tests.py` 5 个、`core/tests.py` 17 个;实际 `manage.py test` 输出为 31 个测试。 +- 影响:不影响运行,但会误导后续 agent 判断当前状态。 + +建议修复:更新当前快照里的测试数量。 + +### P2:文章列表实现偏薄,但可接受 + +- 影响任务:T-206 +- 位置:`core/templates/core/article_index_page.html:7` 至 `core/templates/core/article_index_page.html:13` +- 现状:文章列表通过 `page.get_children.live` 直接渲染标题链接,没有自定义 `get_context()`,也没有分页。 +- 评估:T-206 只要求文章可访问、文章详情可链接项目详情;当前能满足最低验收。后续内容量增长时建议补排序、摘要和分页。 + +## 逐任务验收 + +| 任务 | 结论 | 说明 | +| --- | --- | --- | +| T-201 基础页面框架 | 达标 | `base.html` 有 viewport meta,导航、页脚、基础 CSS 已实现,容器使用 `max-width` 和响应式宽度。 | +| T-202 首页 | 达标 | 首页展示场景入口、推荐骨架、最新文章,内容来自数据库上下文。 | +| T-203 场景页和项目列表页 | 未达标 | 项目列表页有分页和空状态;场景页有项目和空状态,但缺少每页 20 的分页。 | +| T-204 筛选与基础搜索 | 未达标 | 单项筛选可用,但 `min_score` 与 `q` 组合请求返回 500,不符合 AND 组合契约。 | +| T-205 骨架详情页 | 达标 | 展示详情字段、评分、推荐理由,`is_sponsored=True` 时渲染 Sponsored 标识。 | +| T-206 文章列表和详情 | 达标 | `/articles/` 和文章详情可访问,文章详情可链接关联项目;实现较薄但满足最低验收。 | + +## 验证记录 + +已执行: + +```bash +.venv/bin/python3.12.exe manage.py check +.venv/bin/python3.12.exe manage.py makemigrations --check --dry-run +.venv/bin/python3.12.exe manage.py test +.venv/bin/python3.12.exe manage.py shell -c "from core.models import ScenarioPage,SkeletonProjectPage,ArticlePage; print(ScenarioPage.objects.live().count(), SkeletonProjectPage.objects.live().count(), ArticlePage.objects.live().count())" +.venv/bin/python3.12.exe manage.py shell -c "from django.test import Client; c=Client(raise_request_exception=False); paths=['/','/projects/','/articles/','/projects/?language=python','/projects/?min_score=18','/projects/?q=Django','/projects/?min_score=18&q=Django']; [print(p, c.get(p).status_code) for p in paths]" +``` + +结果摘要: + +- `manage.py check`:0 error,9 个 treebeard/Wagtail 兼容 warning。 +- `makemigrations --check --dry-run`:No changes detected。 +- `manage.py test`:31 tests passed。 +- seed 数据:3 个 live 场景、5 个 live 项目、2 篇 live 文章。 +- URL 补测: + - `/`:200 + - `/projects/`:200 + - `/articles/`:200 + - `/projects/?language=python`:200 + - `/projects/?min_score=18`:200 + - `/projects/?q=Django`:200 + - `/projects/?min_score=18&q=Django`:500 + +## 修复验收建议 + +修复后至少补充并通过以下测试: + +1. 项目列表组合筛选:`language + framework + database + min_score + q` 同时存在时返回 200,并只展示满足全部条件的项目。 +2. 项目列表组合筛选无结果时返回 200,并展示空状态。 +3. 场景页 21 个 live 项目时第一页展示 20 个,第二页展示剩余项目。 +4. 场景页非法 `page` 参数返回第一页,不报错。 + +修复完成后重新运行: + +```bash +.venv/bin/python3.12.exe manage.py check +.venv/bin/python3.12.exe manage.py makemigrations --check --dry-run +.venv/bin/python3.12.exe manage.py test +``` diff --git a/progress.md b/progress.md index d2e2d99..0b5e2c8 100644 --- a/progress.md +++ b/progress.md @@ -267,3 +267,20 @@ - 阻塞:无。 - 下一步:T-301 补 SEO 基础(Phase 3)。 +## 2026-07-06 Phase 2 评审修复(P0 min_score+500 + P1 场景分页 + P2 文档同步) + +- 状态:DONE +- 变更: + - **P0**:`core/models.py` ProjectIndexPage.get_context() 调换 `q` 与 `min_score` 的执行顺序——ORM 筛选(`q`/language/framework/database)先执行,最后做 Python 层 `min_score` 列表过滤,避免 QuerySet→list 后 `.filter()` 崩溃 + - **P1**:`core/models.py` ScenarioPage.get_context() 新增 `Paginator(projects, 20)`;`scenario_page.html` 复用分页 UI;新增 3 个分页测试 + - **P2**:`docs/current-state.md` 测试数量同步为 31 + - 新增测试:`test_combined_filters_return_200`、`test_combined_filters_no_results_returns_empty_state`、`test_first_page_has_20_items`、`test_second_page_has_1_item`、`test_invalid_page_falls_back_to_first` +- 验证: + - `manage.py test core home`:36 tests passed + - `min_score=18&q=Django` 返回 200 ✅ + - `min_score=18&page=abc` 返回 200 ✅ + - `manage.py check`:0 errors + - `makemigrations --check --dry-run`:No changes +- 阻塞:无。 +- 下一步:T-301 补 SEO 基础(Phase 3)。 +