feat: add solutions app, fix HomePage subpage_types, add pytest test suite

- apps/solutions: SolutionIndexPage/SolutionPage (mirrors products/cases)
- apps/home: HomePage.subpage_types now includes products/solutions/cases index pages (previously only blog, blocking admin page creation)
- pytest.ini + conftest.py (root_page/home_page fixtures)
- unit tests for core (SEOablePage, FormBlock API repr), products, cases, forms (lead submit API)
This commit is contained in:
2026-08-06 16:23:49 +08:00
parent 12f7caa158
commit 052fff1a40
16 changed files with 443 additions and 30 deletions
+50
View File
@@ -0,0 +1,50 @@
"""apps.cases 单元测试:页面树结构与列表排序。"""
import pytest
from django.utils import timezone
from apps.cases.models import CaseStudyIndexPage, CaseStudyPage
pytestmark = pytest.mark.django_db
@pytest.fixture
def case_index(home_page):
index = CaseStudyIndexPage(title="客户案例", slug="cases", intro="客户的成功案例")
home_page.add_child(instance=index)
return index
def test_case_study_page_can_be_created_under_index(case_index):
case = CaseStudyPage(
title="某集团数字化转型",
slug="some-group",
client_name="某集团",
industry="制造业",
published_at=timezone.now(),
)
case_index.add_child(instance=case)
assert CaseStudyPage.objects.live().descendant_of(case_index).count() == 1
def test_case_index_context_orders_by_published_at_desc(case_index):
older = CaseStudyPage(
title="案例一",
slug="case-1",
client_name="客户一",
published_at=timezone.now() - timezone.timedelta(days=10),
)
case_index.add_child(instance=older)
newer = CaseStudyPage(
title="案例二",
slug="case-2",
client_name="客户二",
published_at=timezone.now(),
)
case_index.add_child(instance=newer)
context = case_index.get_context(request=None)
titles = [c.title for c in context["case_studies"]]
assert titles == ["案例二", "案例一"]
+62
View File
@@ -0,0 +1,62 @@
"""apps.core 基础单元测试:SEOablePage 抽象基类字段与公共 Block。"""
import pytest
from apps.core.blocks import FormBlock
from apps.forms.models import FormDefinition, FormDefinitionField
pytestmark = pytest.mark.django_db
def test_seoable_page_fields_available_on_subclass(home_page):
"""SEOablePage 是抽象基类,其 SEO 字段应出现在具体子类(HomePage)上。"""
home_page.seo_title_override = "自定义标题"
home_page.seo_description_override = "自定义描述"
home_page.canonical_url = "https://example.com/"
home_page.schema_json = {"@type": "Organization"}
home_page.save()
home_page.refresh_from_db()
assert home_page.seo_title_override == "自定义标题"
assert home_page.seo_description_override == "自定义描述"
assert home_page.canonical_url == "https://example.com/"
assert home_page.schema_json == {"@type": "Organization"}
def test_form_block_api_representation_expands_fields():
"""FormBlock.get_api_representation 应展开 FormDefinition 及其子字段,
而不是仅返回 SnippetChooserBlock 默认的主键。"""
form_def = FormDefinition.objects.create(
name="联系我们",
submit_button_text="立即提交",
success_message="已收到您的信息",
)
FormDefinitionField.objects.create(
form=form_def,
label="姓名",
field_key="name",
field_type="text",
required=True,
)
FormDefinitionField.objects.create(
form=form_def,
label="意向",
field_key="interest",
field_type="select",
required=False,
choices="产品咨询, 合作洽谈",
)
block = FormBlock()
result = block.get_api_representation({"form": form_def})
assert result["form"]["id"] == form_def.pk
assert result["form"]["name"] == "联系我们"
assert result["form"]["submit_button_text"] == "立即提交"
assert len(result["form"]["fields"]) == 2
assert result["form"]["fields"][1]["choices"] == ["产品咨询", "合作洽谈"]
def test_form_block_api_representation_handles_empty_form():
block = FormBlock()
result = block.get_api_representation({"form": None})
assert result == {"form": None}
+64
View File
@@ -0,0 +1,64 @@
"""apps.forms 单元测试:线索提交 API(B2B 官网核心转化路径,测试覆盖率要求较高)。"""
import pytest
from rest_framework.test import APIClient
from apps.forms.models import FormDefinition, FormDefinitionField, Lead
pytestmark = pytest.mark.django_db
@pytest.fixture
def contact_form():
form_def = FormDefinition.objects.create(
name="联系我们",
success_message="提交成功,我们会尽快联系您。",
)
FormDefinitionField.objects.create(
form=form_def, label="姓名", field_key="name", field_type="text", required=True
)
return form_def
def test_lead_submit_creates_lead_and_returns_success_message(contact_form):
client = APIClient()
response = client.post(
"/api/v1/custom/leads/",
{
"form_id": contact_form.pk,
"data": {"name": "张三"},
"source_url": "https://example.com/contact",
},
format="json",
)
assert response.status_code == 201
assert response.data["success"] is True
assert response.data["message"] == contact_form.success_message
lead = Lead.objects.get()
assert lead.form_id == contact_form.pk
assert lead.data == {"name": "张三"}
assert lead.source_url == "https://example.com/contact"
def test_lead_submit_returns_404_for_unknown_form():
client = APIClient()
response = client.post(
"/api/v1/custom/leads/",
{"form_id": 9999, "data": {}},
format="json",
)
assert response.status_code == 404
assert response.data["error"]["code"] == "NOT_FOUND"
def test_lead_submit_requires_form_id():
client = APIClient()
response = client.post(
"/api/v1/custom/leads/",
{"data": {"name": "张三"}},
format="json",
)
assert response.status_code == 400
+3
View File
@@ -16,6 +16,9 @@ class HomePage(SEOablePage):
subpage_types = [
"blog.BlogIndexPage",
"products.ProductIndexPage",
"solutions.SolutionIndexPage",
"cases.CaseStudyIndexPage",
]
class Meta:
+34
View File
@@ -0,0 +1,34 @@
"""apps.products 单元测试:页面树结构与列表上下文。"""
import pytest
from apps.products.models import ProductIndexPage, ProductPage
pytestmark = pytest.mark.django_db
@pytest.fixture
def product_index(home_page):
index = ProductIndexPage(title="产品", slug="products", intro="我们的产品")
home_page.add_child(instance=index)
return index
def test_product_page_can_be_created_under_index(product_index):
product = ProductPage(title="产品 A", slug="product-a", summary="简介 A")
product_index.add_child(instance=product)
assert ProductPage.objects.live().descendant_of(product_index).count() == 1
def test_product_index_context_lists_live_products_only(product_index):
live_product = ProductPage(title="产品 A", slug="product-a")
product_index.add_child(instance=live_product)
draft_product = ProductPage(title="产品 B", slug="product-b", live=False)
product_index.add_child(instance=draft_product)
context = product_index.get_context(request=None)
titles = [p.title for p in context["products"]]
assert "产品 A" in titles
assert "产品 B" not in titles
View File
+8
View File
@@ -0,0 +1,8 @@
from django.apps import AppConfig
class SolutionsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.solutions"
label = "solutions"
verbose_name = "解决方案"
+53
View File
@@ -0,0 +1,53 @@
# Generated by Django 6.0.5 on 2026-08-06 08:14
import django.db.models.deletion
import wagtail.fields
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('wagtailcore', '0097_baselogentry_uuid_action_timestamp_indexes'),
('wagtailimages', '0027_image_description'),
]
operations = [
migrations.CreateModel(
name='SolutionIndexPage',
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')),
('seo_title_override', models.CharField(blank=True, help_text='留空则使用页面标题', max_length=70, verbose_name='SEO 标题')),
('seo_description_override', models.CharField(blank=True, max_length=160, verbose_name='SEO 描述')),
('canonical_url', models.URLField(blank=True, verbose_name='Canonical URL')),
('schema_json', models.JSONField(blank=True, default=dict, verbose_name='结构化数据 (JSON-LD)')),
('intro', models.TextField(blank=True, verbose_name='栏目简介')),
('og_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='社交分享图')),
],
options={
'verbose_name': '解决方案栏目页',
},
bases=('wagtailcore.page',),
),
migrations.CreateModel(
name='SolutionPage',
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')),
('seo_title_override', models.CharField(blank=True, help_text='留空则使用页面标题', max_length=70, verbose_name='SEO 标题')),
('seo_description_override', models.CharField(blank=True, max_length=160, verbose_name='SEO 描述')),
('canonical_url', models.URLField(blank=True, verbose_name='Canonical URL')),
('schema_json', models.JSONField(blank=True, default=dict, verbose_name='结构化数据 (JSON-LD)')),
('industry', models.CharField(blank=True, max_length=100, verbose_name='适用行业')),
('summary', models.CharField(blank=True, max_length=250, verbose_name='方案简介')),
('body', wagtail.fields.StreamField([('hero', 7), ('stats', 12), ('feature_grid', 19), ('faq', 24), ('cta', 27), ('logo_cloud', 31), ('case_study', 34), ('form', 36), ('richtext', 37)], blank=True, block_lookup={0: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 120}), 1: ('wagtail.blocks.TextBlock', (), {'label': '副标题', 'required': False}), 2: ('wagtail.images.blocks.ImageChooserBlock', (), {'label': '背景图', 'required': False}), 3: ('wagtail.blocks.CharBlock', (), {'label': '按钮文案', 'max_length': 50}), 4: ('wagtail.blocks.URLBlock', (), {'label': '按钮链接'}), 5: ('wagtail.blocks.StructBlock', [[('text', 3), ('link', 4)]], {}), 6: ('wagtail.blocks.ListBlock', (5,), {'label': '按钮组'}), 7: ('wagtail.blocks.StructBlock', [[('title', 0), ('subtitle', 1), ('background_image', 2), ('buttons', 6)]], {}), 8: ('wagtail.blocks.CharBlock', (), {'label': '数值', 'max_length': 20}), 9: ('wagtail.blocks.CharBlock', (), {'label': '说明文字', 'max_length': 50}), 10: ('wagtail.blocks.StructBlock', [[('value', 8), ('label', 9)]], {}), 11: ('wagtail.blocks.ListBlock', (10,), {'label': '数据项'}), 12: ('wagtail.blocks.StructBlock', [[('items', 11)]], {}), 13: ('wagtail.blocks.CharBlock', (), {'label': '模块标题', 'max_length': 100, 'required': False}), 14: ('wagtail.blocks.CharBlock', (), {'label': '图标', 'max_length': 50, 'required': False}), 15: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 100}), 16: ('wagtail.blocks.TextBlock', (), {'label': '描述'}), 17: ('wagtail.blocks.StructBlock', [[('icon', 14), ('title', 15), ('description', 16)]], {}), 18: ('wagtail.blocks.ListBlock', (17,), {'label': '能力列表'}), 19: ('wagtail.blocks.StructBlock', [[('heading', 13), ('items', 18)]], {}), 20: ('wagtail.blocks.CharBlock', (), {'label': '问题', 'max_length': 200}), 21: ('wagtail.blocks.RichTextBlock', (), {'label': '回答'}), 22: ('wagtail.blocks.StructBlock', [[('question', 20), ('answer', 21)]], {}), 23: ('wagtail.blocks.ListBlock', (22,), {'label': '问答列表'}), 24: ('wagtail.blocks.StructBlock', [[('items', 23)]], {}), 25: ('wagtail.blocks.TextBlock', (), {'label': '描述', 'required': False}), 26: ('wagtail.blocks.StructBlock', [[('text', 3), ('link', 4)]], {'label': '按钮'}), 27: ('wagtail.blocks.StructBlock', [[('heading', 0), ('description', 25), ('button', 26)]], {}), 28: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 100, 'required': False}), 29: ('wagtail.images.blocks.ImageChooserBlock', (), {}), 30: ('wagtail.blocks.ListBlock', (29,), {'label': 'Logo 列表'}), 31: ('wagtail.blocks.StructBlock', [[('heading', 28), ('logos', 30)]], {}), 32: ('wagtail.blocks.PageChooserBlock', (), {'label': '案例页面', 'page_type': ['cases.CaseStudyPage']}), 33: ('wagtail.blocks.CharBlock', (), {'label': '摘要覆盖', 'max_length': 250, 'required': False}), 34: ('wagtail.blocks.StructBlock', [[('case_page', 32), ('summary', 33)]], {}), 35: ('wagtail.snippets.blocks.SnippetChooserBlock', ('forms.FormDefinition',), {'label': '选择表单'}), 36: ('wagtail.blocks.StructBlock', [[('form', 35)]], {}), 37: ('wagtail.blocks.RichTextBlock', (), {'label': '富文本'})})),
('cover_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='方案配图')),
('og_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='社交分享图')),
],
options={
'verbose_name': '解决方案页',
},
bases=('wagtailcore.page',),
),
]
+66
View File
@@ -0,0 +1,66 @@
"""行业解决方案栏目与详情页。详见 documents/设计方案分析与完善版.md §2.5 页面树结构。"""
from django.db import models
from wagtail.admin.panels import FieldPanel
from wagtail.fields import StreamField
from wagtail.search import index
from apps.core.blocks import COMMON_BLOCKS
from apps.core.models import SEOablePage
class SolutionIndexPage(SEOablePage):
"""解决方案栏目列表页,子页面为 SolutionPage。"""
intro = models.TextField(blank=True, verbose_name="栏目简介")
content_panels = SEOablePage.content_panels + [
FieldPanel("intro"),
]
subpage_types = ["solutions.SolutionPage"]
parent_page_types = ["home.HomePage"]
class Meta:
verbose_name = "解决方案栏目页"
def get_context(self, request, *args, **kwargs):
context = super().get_context(request, *args, **kwargs)
context["solutions"] = (
SolutionPage.objects.live().descendant_of(self).order_by("path")
)
return context
class SolutionPage(SEOablePage):
"""单个行业解决方案详情页。"""
industry = models.CharField(max_length=100, blank=True, verbose_name="适用行业")
summary = models.CharField(max_length=250, blank=True, verbose_name="方案简介")
cover_image = models.ForeignKey(
"wagtailimages.Image",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
verbose_name="方案配图",
)
body = StreamField(COMMON_BLOCKS, use_json_field=True, blank=True)
search_fields = SEOablePage.search_fields + [
index.SearchField("industry"),
index.SearchField("summary"),
index.SearchField("body"),
]
content_panels = SEOablePage.content_panels + [
FieldPanel("industry"),
FieldPanel("summary"),
FieldPanel("cover_image"),
FieldPanel("body"),
]
parent_page_types = ["solutions.SolutionIndexPage"]
subpage_types = []
class Meta:
verbose_name = "解决方案页"
@@ -0,0 +1,14 @@
{% load wagtailcore_tags %}
<!doctype html>
<html lang="zh-hans">
<head><meta charset="utf-8"><title>{{ page.title }}</title></head>
<body>
<h1>{{ page.title }}</h1>
<p>{{ page.intro }}</p>
<ul>
{% for solution in solutions %}
<li><a href="{% pageurl solution %}">{{ solution.title }}</a></li>
{% endfor %}
</ul>
</body>
</html>
@@ -0,0 +1,13 @@
{% load wagtailcore_tags %}
<!doctype html>
<html lang="zh-hans">
<head><meta charset="utf-8"><title>{{ page.title }}</title></head>
<body>
<h1>{{ page.title }}</h1>
<p>{{ page.industry }}</p>
<p>{{ page.summary }}</p>
{% for block in page.body %}
{% include_block block %}
{% endfor %}
</body>
</html>