Phase 2: T-201 至 T-206 前台 MVP 全部实现(11 templates + CSS + 31 tests)

This commit is contained in:
ila
2026-07-06 22:57:08 +08:00
parent 5604e2cde5
commit cff3c64c19
20 changed files with 996 additions and 98 deletions
+70
View File
@@ -1,4 +1,6 @@
from django.core.paginator import Paginator, EmptyPage
from django.db import models
from django.db.models import Q
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator, MaxValueValidator
from modelcluster.fields import ParentalManyToManyField
@@ -25,6 +27,18 @@ class ScenarioPage(Page):
content_panels = Page.content_panels
template = "core/scenario_page.html"
def get_context(self, request, *args, **kwargs):
context = super().get_context(request, *args, **kwargs)
context["projects"] = (
SkeletonProjectPage.objects.live()
.filter(scenarios=self)
.prefetch_related("languages", "scenarios")
.order_by("-first_published_at")
)
return context
class Meta:
verbose_name = "Scenario"
@@ -112,11 +126,65 @@ class ProjectIndexPage(Page):
content_panels = Page.content_panels
template = "core/project_index_page.html"
def get_context(self, request, *args, **kwargs):
context = super().get_context(request, *args, **kwargs)
projects = (
SkeletonProjectPage.objects.live()
.prefetch_related("languages", "scenarios")
.order_by("-first_published_at")
)
language = request.GET.get("language", "")
framework = request.GET.get("framework", "")
database = request.GET.get("database", "")
min_score = request.GET.get("min_score", "")
q = request.GET.get("q", "")
if language:
projects = projects.filter(languages__slug=language)
if framework:
projects = projects.filter(frameworks__slug=framework)
if database:
projects = projects.filter(databases__slug=database)
try:
score_val = int(min_score)
if 0 <= score_val <= 30:
projects = [
p for p in projects if p.total_score >= score_val
]
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")
try:
context["projects"] = paginator.page(int(page))
except (ValueError, EmptyPage):
context["projects"] = paginator.page(1)
context["current_language"] = language
context["current_framework"] = framework
context["current_database"] = database
context["current_min_score"] = min_score
context["current_q"] = q
context["languages"] = Language.objects.all()
context["frameworks"] = Framework.objects.all()
context["databases"] = DatabaseOption.objects.all()
return context
class Meta:
verbose_name = "Project Index"
class SkeletonProjectPage(Page):
template = "core/skeleton_project_page.html"
MATURITY_CHOICES = [
("experimental", "Experimental"),
("stable", "Stable"),
@@ -255,6 +323,7 @@ class SkeletonProjectPage(Page):
class ArticleIndexPage(Page):
template = "core/article_index_page.html"
parent_page_types = ["home.HomePage"]
subpage_types = ["core.ArticlePage"]
max_count = 1
@@ -266,6 +335,7 @@ class ArticleIndexPage(Page):
class ArticlePage(Page):
template = "core/article_page.html"
body = RichTextField()
related_projects = ParentalManyToManyField(
"core.SkeletonProjectPage", blank=True
@@ -0,0 +1,19 @@
{% extends "base.html" %}
{% block content %}
<h1>{{ page.title }}</h1>
{% if page.get_children.live %}
<div class="card-grid">
{% for article in page.get_children.live %}
<a class="card" href="{% url 'wagtail_serve' '' %}articles/{{ article.slug }}/">
<h3>{{ article.title }}</h3>
</a>
{% endfor %}
</div>
{% else %}
{% include "includes/empty_state.html" with message="No articles yet." %}
{% endif %}
{% endblock %}
+21
View File
@@ -0,0 +1,21 @@
{% extends "base.html" %}
{% block content %}
<h1>{{ page.title }}</h1>
<div class="rich-text">{{ page.body|safe }}</div>
{% if page.related_projects.exists %}
<h2>Related Projects</h2>
<div class="card-grid">
{% for project in page.related_projects.all %}
<a class="card" href="{% url 'wagtail_serve' '' %}projects/{{ project.slug }}/">
<h3>{{ project.title }}</h3>
<p>{{ project.summary|truncatewords:20 }}</p>
</a>
{% endfor %}
</div>
{% endif %}
{% endblock %}
@@ -0,0 +1,85 @@
{% extends "base.html" %}
{% block content %}
<h1>{{ page.title }}</h1>
<form class="filters" method="get">
<label>
Language:
<select name="language">
<option value="">All</option>
{% for lang in languages %}
<option value="{{ lang.slug }}" {% if current_language == lang.slug %}selected{% endif %}>{{ lang.name }}</option>
{% endfor %}
</select>
</label>
<label>
Framework:
<select name="framework">
<option value="">All</option>
{% for fw in frameworks %}
<option value="{{ fw.slug }}" {% if current_framework == fw.slug %}selected{% endif %}>{{ fw.name }}</option>
{% endfor %}
</select>
</label>
<label>
Database:
<select name="database">
<option value="">All</option>
{% for db in databases %}
<option value="{{ db.slug }}" {% if current_database == db.slug %}selected{% endif %}>{{ db.name }}</option>
{% endfor %}
</select>
</label>
<label>
Min Score:
<input type="number" name="min_score" min="0" max="30" value="{{ current_min_score }}" style="width:5ch;">
</label>
<label>
Search:
<input type="text" name="q" value="{{ current_q }}" placeholder="Keyword...">
</label>
<button type="submit">Filter</button>
</form>
{% if projects %}
<div class="card-grid">
{% for project in projects %}
<a class="card" href="{% url 'wagtail_serve' '' %}projects/{{ project.slug }}/">
<h3>{{ project.title }}</h3>
<p>{{ project.summary|truncatewords:20 }}</p>
<div class="score-bar">
<span class="score-total">{{ project.total_score }}/30</span>
<span class="score-detail">AI-Friendly Score</span>
</div>
<div class="tag-list">
{% for lang in project.languages.all|slice:":3" %}
<span class="tag">{{ lang.name }}</span>
{% endfor %}
</div>
</a>
{% endfor %}
</div>
{% if projects.paginator.num_pages > 1 %}
<nav class="pagination">
{% if projects.has_previous %}
<a href="?{% for k,v in request.GET.items %}{% if k != 'page' %}{{ k }}={{ v }}&{% endif %}{% endfor %}page={{ projects.previous_page_number }}">&laquo; Prev</a>
{% endif %}
<span class="current">Page {{ projects.number }} of {{ projects.paginator.num_pages }}</span>
{% if projects.has_next %}
<a href="?{% for k,v in request.GET.items %}{% if k != 'page' %}{{ k }}={{ v }}&{% endif %}{% endfor %}page={{ projects.next_page_number }}">Next &raquo;</a>
{% endif %}
</nav>
{% endif %}
{% else %}
{% include "includes/empty_state.html" with message="No projects match your filters." %}
{% endif %}
{% endblock %}
+29
View File
@@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block content %}
<h1>{{ page.title }}</h1>
{% if projects %}
<div class="card-grid">
{% for project in projects %}
<a class="card" href="{% url 'wagtail_serve' '' %}projects/{{ project.slug }}/">
<h3>{{ project.title }}</h3>
<p>{{ project.summary|truncatewords:20 }}</p>
<div class="score-bar">
<span class="score-total">{{ project.total_score }}/30</span>
<span class="score-detail">AI-Friendly Score</span>
</div>
<div class="tag-list">
{% for lang in project.languages.all|slice:":3" %}
<span class="tag">{{ lang.name }}</span>
{% endfor %}
</div>
</a>
{% endfor %}
</div>
{% else %}
{% include "includes/empty_state.html" with message="No projects found in this scenario." %}
{% endif %}
{% endblock %}
@@ -0,0 +1,76 @@
{% extends "base.html" %}
{% block content %}
<article>
<h1>
{{ page.title }}
{% if page.is_featured %}<span class="badge badge-featured">Featured</span>{% endif %}
{% if page.is_sponsored %}{% include "includes/sponsored_badge.html" %}{% endif %}
</h1>
<p class="rich-text">{{ page.summary }}</p>
<table class="data-table">
<tr><th>GitHub</th><td><a href="{{ page.github_url }}" target="_blank" rel="noopener">{{ page.github_url }}</a></td></tr>
{% if page.official_url %}
<tr><th>Official Site</th><td><a href="{{ page.official_url }}" target="_blank" rel="noopener">{{ page.official_url }}</a></td></tr>
{% endif %}
{% if page.license_name %}
<tr><th>License</th><td>{{ page.license_name }}</td></tr>
{% endif %}
<tr><th>Maturity</th><td>{{ page.get_maturity_display }}</td></tr>
<tr><th>Maintenance</th><td>{{ page.get_maintenance_status_display }}</td></tr>
{% if page.scenarios.exists %}
<tr><th>Scenarios</th><td>
{% for s in page.scenarios.all %}<span class="tag">{{ s.title }}</span>{% endfor %}
</td></tr>
{% endif %}
{% if page.languages.exists %}
<tr><th>Languages</th><td>
{% for l in page.languages.all %}<span class="tag">{{ l.name }}</span>{% endfor %}
</td></tr>
{% endif %}
{% if page.frameworks.exists %}
<tr><th>Frameworks</th><td>
{% for f in page.frameworks.all %}<span class="tag">{{ f.name }}</span>{% endfor %}
</td></tr>
{% endif %}
{% if page.databases.exists %}
<tr><th>Databases</th><td>
{% for d in page.databases.all %}<span class="tag">{{ d.name }}</span>{% endfor %}
</td></tr>
{% endif %}
{% if page.features.exists %}
<tr><th>Features</th><td>
{% for feat in page.features.all %}<span class="tag">{{ feat.name }}</span>{% endfor %}
</td></tr>
{% endif %}
</table>
<h2>Scores</h2>
<table class="data-table">
<tr><th>Structure</th><td>{{ page.structure_score }}/5</td></tr>
<tr><th>Documentation</th><td>{{ page.docs_score }}/5</td></tr>
<tr><th>Testing</th><td>{{ page.tests_score }}/5</td></tr>
<tr><th>Examples</th><td>{{ page.example_score }}/5</td></tr>
<tr><th>Dependency Control</th><td>{{ page.dependency_score }}/5</td></tr>
<tr><th>Incremental Development</th><td>{{ page.incremental_score }}/5</td></tr>
<tr><th>Total</th><td><strong>{{ page.total_score }}/30</strong></td></tr>
</table>
<h2>Recommended For</h2>
<div class="rich-text">{{ page.recommended_for|safe }}</div>
{% if page.not_recommended_for %}
<h2>Not Recommended For</h2>
<div class="rich-text">{{ page.not_recommended_for|safe }}</div>
{% endif %}
{% if page.review_notes %}
<h2>Review Notes</h2>
<div class="rich-text">{{ page.review_notes|safe }}</div>
{% endif %}
</article>
{% endblock %}
+200 -17
View File
@@ -20,6 +20,206 @@ from core.models import (
)
class PageTreeMixin:
@classmethod
def setUpPageTree(cls):
cls.home = HomePage.objects.get(slug="home")
cls.scenario_index = ScenarioIndexPage(title="Scenarios", slug="scenarios")
cls.home.add_child(instance=cls.scenario_index)
cls.scenario = ScenarioPage(title="SaaS", slug="saas")
cls.scenario_index.add_child(instance=cls.scenario)
cls.project_index = ProjectIndexPage(title="Projects", slug="projects")
cls.home.add_child(instance=cls.project_index)
cls.article_index = ArticleIndexPage(title="Articles", slug="articles")
cls.home.add_child(instance=cls.article_index)
class ScenarioPageTests(PageTreeMixin, TestCase):
@classmethod
def setUpTestData(cls):
cls.setUpPageTree()
cls.lang = Language.objects.create(name="Python", slug="python")
def test_scenario_page_with_projects(self):
project = SkeletonProjectPage(
title="Scenario Project",
slug="scenario-project",
summary="A project in this scenario",
github_url="https://github.com/test/sproject",
maturity="stable",
recommended_for="Testing",
structure_score=3, docs_score=3, tests_score=3,
example_score=3, dependency_score=3, incremental_score=3,
)
self.project_index.add_child(instance=project)
project.languages.add(self.lang)
project.scenarios.add(self.scenario)
project.save()
response = self.client.get(self.scenario.url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Scenario Project")
def test_empty_scenario_page(self):
ScenarioPage.objects.create(
title="Empty Scenario",
slug="empty-scenario",
path=self.scenario_index.path + "9999",
depth=self.scenario_index.depth + 1,
)
response = self.client.get("/scenarios/empty-scenario/")
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Nothing here yet")
class ProjectFilterTests(PageTreeMixin, TestCase):
@classmethod
def setUpTestData(cls):
cls.setUpPageTree()
cls.lang_py = Language.objects.create(name="Python", slug="python")
cls.lang_go = Language.objects.create(name="Go", slug="go")
cls.fw_dj = Framework.objects.create(name="Django", slug="django")
cls.db_pg = DatabaseOption.objects.create(name="PostgreSQL", slug="postgresql")
cls.proj_a = SkeletonProjectPage(
title="Django Project",
slug="django-project",
summary="A Django project for testing",
github_url="https://github.com/test/django",
maturity="stable",
recommended_for="Testing",
structure_score=5, docs_score=5, tests_score=5,
example_score=5, dependency_score=5, incremental_score=5,
)
cls.project_index.add_child(instance=cls.proj_a)
cls.proj_a.languages.add(cls.lang_py)
cls.proj_a.frameworks.add(cls.fw_dj)
cls.proj_a.databases.add(cls.db_pg)
cls.proj_a.scenarios.add(cls.scenario)
cls.proj_a.save()
cls.proj_b = SkeletonProjectPage(
title="Go Project",
slug="go-project",
summary="A Go project for testing",
github_url="https://github.com/test/go",
maturity="experimental",
recommended_for="Testing",
structure_score=1, docs_score=1, tests_score=1,
example_score=1, dependency_score=1, incremental_score=1,
)
cls.project_index.add_child(instance=cls.proj_b)
cls.proj_b.languages.add(cls.lang_go)
cls.proj_b.scenarios.add(cls.scenario)
cls.proj_b.save()
def test_language_filter(self):
resp = self.client.get("/projects/?language=python")
self.assertContains(resp, "Django Project")
self.assertNotContains(resp, "Go Project")
def test_min_score_filter(self):
resp = self.client.get("/projects/?min_score=18")
self.assertContains(resp, "Django Project")
self.assertNotContains(resp, "Go Project")
def test_keyword_search(self):
resp = self.client.get("/projects/?q=Django")
self.assertContains(resp, "Django Project")
self.assertNotContains(resp, "Go Project")
def test_invalid_params_ignored(self):
resp = self.client.get("/projects/?min_score=abc&page=xyz")
self.assertEqual(resp.status_code, 200)
class SkeletonProjectDetailTests(PageTreeMixin, TestCase):
@classmethod
def setUpTestData(cls):
cls.setUpPageTree()
cls.lang = Language.objects.create(name="Ruby", slug="ruby")
def test_detail_shows_scores_and_recommendation(self):
project = SkeletonProjectPage(
title="Detail Test",
slug="detail-test",
summary="Testing detail page",
github_url="https://github.com/test/detail",
maturity="stable",
recommended_for="Developers who need structure",
structure_score=4, docs_score=3, tests_score=3,
example_score=4, dependency_score=3, incremental_score=4,
)
self.project_index.add_child(instance=project)
project.languages.add(self.lang)
project.scenarios.add(self.scenario)
project.save()
resp = self.client.get(project.url)
self.assertContains(resp, "Detail Test")
self.assertContains(resp, "21/30")
self.assertContains(resp, "Developers who need structure")
def test_sponsored_badge(self):
project = SkeletonProjectPage(
title="Sponsored Project",
slug="sponsored-project",
summary="A sponsored test",
github_url="https://github.com/test/sponsored",
maturity="experimental",
recommended_for="Sponsored users",
structure_score=2, docs_score=2, tests_score=2,
example_score=2, dependency_score=2, incremental_score=2,
is_sponsored=True,
)
self.project_index.add_child(instance=project)
project.languages.add(self.lang)
project.scenarios.add(self.scenario)
project.save()
resp = self.client.get(project.url)
self.assertContains(resp, "Sponsored")
class ArticleDetailTests(PageTreeMixin, TestCase):
@classmethod
def setUpTestData(cls):
cls.setUpPageTree()
cls.lang = Language.objects.create(name="Rust", slug="rust")
def test_article_links_to_project(self):
project = SkeletonProjectPage(
title="Linked Project",
slug="linked-project",
summary="Project linked from article",
github_url="https://github.com/test/linked",
maturity="stable",
recommended_for="Testing",
structure_score=3, docs_score=3, tests_score=3,
example_score=3, dependency_score=3, incremental_score=3,
)
self.project_index.add_child(instance=project)
project.languages.add(self.lang)
project.scenarios.add(self.scenario)
project.save()
article = ArticlePage(
title="Review Article",
slug="review-article",
body="<p>About the linked project.</p>",
)
self.article_index.add_child(instance=article)
article.related_projects.add(project)
article.save()
resp = self.client.get(article.url)
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, "/projects/linked-project/")
class LanguageTests(TestCase):
def test_create_language(self):
lang = Language.objects.create(name="Python", slug="python")
@@ -64,23 +264,6 @@ class SkeletonFeatureTests(TestCase):
SkeletonFeature.objects.create(name="Docker2", slug="docker")
class PageTreeMixin:
@classmethod
def setUpPageTree(cls):
cls.home = HomePage.objects.get(slug="home")
cls.scenario_index = ScenarioIndexPage(title="Scenarios", slug="scenarios")
cls.home.add_child(instance=cls.scenario_index)
cls.scenario = ScenarioPage(title="SaaS", slug="saas")
cls.scenario_index.add_child(instance=cls.scenario)
cls.project_index = ProjectIndexPage(title="Projects", slug="projects")
cls.home.add_child(instance=cls.project_index)
cls.article_index = ArticleIndexPage(title="Articles", slug="articles")
cls.home.add_child(instance=cls.article_index)
class SkeletonProjectPageTests(PageTreeMixin, TestCase):
@classmethod
def setUpTestData(cls):