feat: 新增Snippet全局内容模型(TeamMember/Testimonial/Partner/NavigationMenu/SiteSettings)
- 新增 apps.core.TeamMember/Testimonial/Partner:简单 register_snippet,含 order 排序字段
- 新增 apps.core.NavigationMenu+NavigationMenuItem:ClusterableModel+Orderable+InlinePanel,
支持内部页面或外部链接二选一
- 新增 apps.core.SiteSettings:接入 wagtail.contrib.settings + BaseSiteSetting,
存放公司信息/ICP备案号/社交账号等全局配置
- 新增只读 API:/api/v1/custom/core/{team,testimonials,partners,navigation,site-settings}/
- 补充 apps/core/tests.py 单元测试,pytest 用例由 11 个增至 24 个,全部通过
- 更新设计文档 §2.16 Phase 1 进度清单
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
apps.core 全局 Snippet 只读接口。挂载在 /api/v1/custom/core/
|
||||
(详见 apps/core/urls.py 与 documents/设计方案分析与完善版.md §2.7)。
|
||||
公开只读数据,限流走 DRF ScopedRateThrottle 的 "public" scope(100/min)。
|
||||
"""
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from .models import NavigationMenu, Partner, SiteSettings, TeamMember, Testimonial
|
||||
from .serializers import (
|
||||
NavigationMenuSerializer,
|
||||
PartnerSerializer,
|
||||
SiteSettingsSerializer,
|
||||
TeamMemberSerializer,
|
||||
TestimonialSerializer,
|
||||
)
|
||||
|
||||
|
||||
class TeamMemberListView(APIView):
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
throttle_scope = "public"
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
members = TeamMember.objects.all()
|
||||
return Response({"items": TeamMemberSerializer(members, many=True).data})
|
||||
|
||||
|
||||
class TestimonialListView(APIView):
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
throttle_scope = "public"
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
testimonials = Testimonial.objects.all()
|
||||
return Response({"items": TestimonialSerializer(testimonials, many=True).data})
|
||||
|
||||
|
||||
class PartnerListView(APIView):
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
throttle_scope = "public"
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
partners = Partner.objects.all()
|
||||
return Response({"items": PartnerSerializer(partners, many=True).data})
|
||||
|
||||
|
||||
class NavigationMenuView(APIView):
|
||||
"""根据 ?name= 查询指定导航菜单(如 main / footer)。"""
|
||||
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
throttle_scope = "public"
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
name = request.query_params.get("name", "main")
|
||||
menu = NavigationMenu.objects.prefetch_related("items").filter(name=name).first()
|
||||
if menu is None:
|
||||
return Response({"error": {"code": "NOT_FOUND", "message": "导航菜单不存在"}}, status=404)
|
||||
return Response(NavigationMenuSerializer(menu).data)
|
||||
|
||||
|
||||
class SiteSettingsView(APIView):
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
throttle_scope = "public"
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
settings_obj = SiteSettings.for_request(request)
|
||||
return Response(SiteSettingsSerializer(settings_obj).data)
|
||||
@@ -0,0 +1,110 @@
|
||||
# Generated by Django 6.0.5 on 2026-08-07 03:14
|
||||
|
||||
import django.db.models.deletion
|
||||
import modelcluster.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0001_initial'),
|
||||
('wagtailcore', '0097_baselogentry_uuid_action_timestamp_indexes'),
|
||||
('wagtailimages', '0027_image_description'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='NavigationMenu',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(help_text='前端按此标识获取菜单,如 main(主导航)、footer(页脚导航)', max_length=50, unique=True, verbose_name='菜单标识')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '导航菜单',
|
||||
'verbose_name_plural': '导航菜单',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='NavigationMenuItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('sort_order', models.IntegerField(blank=True, editable=False, null=True)),
|
||||
('label', models.CharField(max_length=50, verbose_name='文案')),
|
||||
('link_url', models.CharField(blank=True, help_text='留空则使用内部页面链接', max_length=255, verbose_name='外部链接')),
|
||||
('open_in_new_tab', models.BooleanField(default=False, verbose_name='新标签页打开')),
|
||||
('link_page', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailcore.page', verbose_name='内部页面')),
|
||||
('menu', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='core.navigationmenu')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['sort_order'],
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Partner',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, verbose_name='名称')),
|
||||
('website_url', models.URLField(blank=True, verbose_name='官网链接')),
|
||||
('order', models.IntegerField(default=0, verbose_name='排序')),
|
||||
('logo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='Logo')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '合作伙伴',
|
||||
'verbose_name_plural': '合作伙伴',
|
||||
'ordering': ['order', 'id'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SiteSettings',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('company_name', models.CharField(blank=True, max_length=100, verbose_name='公司全称')),
|
||||
('contact_phone', models.CharField(blank=True, max_length=30, verbose_name='联系电话')),
|
||||
('contact_email', models.EmailField(blank=True, max_length=254, verbose_name='联系邮箱')),
|
||||
('address', models.CharField(blank=True, max_length=255, verbose_name='公司地址')),
|
||||
('icp_number', models.CharField(blank=True, max_length=50, verbose_name='ICP备案号')),
|
||||
('icp_url', models.URLField(default='https://beian.miit.gov.cn/', verbose_name='备案查询链接')),
|
||||
('wechat_account', models.CharField(blank=True, max_length=100, verbose_name='微信公众号名称')),
|
||||
('weibo_url', models.URLField(blank=True, verbose_name='微博主页')),
|
||||
('site', models.OneToOneField(editable=False, on_delete=django.db.models.deletion.CASCADE, to='wagtailcore.site')),
|
||||
('wechat_qrcode', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='微信公众号二维码')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '站点设置',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TeamMember',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=50, verbose_name='姓名')),
|
||||
('role', models.CharField(blank=True, max_length=100, verbose_name='职位')),
|
||||
('bio', models.TextField(blank=True, verbose_name='简介')),
|
||||
('order', models.IntegerField(default=0, verbose_name='排序')),
|
||||
('photo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='照片')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '团队成员',
|
||||
'verbose_name_plural': '团队成员',
|
||||
'ordering': ['order', 'id'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Testimonial',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('quote', models.TextField(verbose_name='推荐语')),
|
||||
('author_name', models.CharField(max_length=50, verbose_name='客户姓名')),
|
||||
('author_title', models.CharField(blank=True, max_length=100, verbose_name='客户职位/公司')),
|
||||
('order', models.IntegerField(default=0, verbose_name='排序')),
|
||||
('author_photo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='客户头像')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '客户证言',
|
||||
'verbose_name_plural': '客户证言',
|
||||
'ordering': ['order', 'id'],
|
||||
},
|
||||
),
|
||||
]
|
||||
+233
-2
@@ -1,11 +1,19 @@
|
||||
"""
|
||||
公共抽象模型。所有业务页面应继承 SEOablePage 而非直接继承 wagtail.models.Page,
|
||||
以统一获得 SEO 字段(详见 documents/设计方案分析与完善版.md §2.5)。
|
||||
|
||||
本文件同时存放跨页面复用的全局 Snippet:TeamMember/Testimonial/Partner/
|
||||
NavigationMenu/SiteSettings(详见 documents/设计方案分析与完善版.md §2.5、§2.16)。
|
||||
"""
|
||||
from django.db import models
|
||||
from wagtail.admin.panels import FieldPanel
|
||||
from modelcluster.fields import ParentalKey
|
||||
from modelcluster.models import ClusterableModel
|
||||
from wagtail.admin.panels import FieldPanel, InlinePanel, MultiFieldPanel
|
||||
from wagtail.contrib.settings.models import BaseSiteSetting
|
||||
from wagtail.contrib.settings.registry import register_setting
|
||||
from wagtail.fields import RichTextField
|
||||
from wagtail.models import Page
|
||||
from wagtail.models import Orderable, Page
|
||||
from wagtail.snippets.models import register_snippet
|
||||
|
||||
|
||||
class SEOablePage(Page):
|
||||
@@ -53,3 +61,226 @@ class SimpleContentPage(SEOablePage):
|
||||
|
||||
class Meta:
|
||||
verbose_name = "通用内容页"
|
||||
|
||||
|
||||
@register_snippet
|
||||
class TeamMember(models.Model):
|
||||
"""团队成员,用于关于我们/团队介绍等场景复用。"""
|
||||
|
||||
name = models.CharField(max_length=50, verbose_name="姓名")
|
||||
role = models.CharField(max_length=100, blank=True, verbose_name="职位")
|
||||
photo = models.ForeignKey(
|
||||
"wagtailimages.Image",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="+",
|
||||
verbose_name="照片",
|
||||
)
|
||||
bio = models.TextField(blank=True, verbose_name="简介")
|
||||
order = models.IntegerField(default=0, verbose_name="排序")
|
||||
|
||||
panels = [
|
||||
FieldPanel("name"),
|
||||
FieldPanel("role"),
|
||||
FieldPanel("photo"),
|
||||
FieldPanel("bio"),
|
||||
FieldPanel("order"),
|
||||
]
|
||||
|
||||
class Meta:
|
||||
ordering = ["order", "id"]
|
||||
verbose_name = "团队成员"
|
||||
verbose_name_plural = "团队成员"
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
@register_snippet
|
||||
class Testimonial(models.Model):
|
||||
"""客户证言/推荐语,用于首页、案例页等场景复用。"""
|
||||
|
||||
quote = models.TextField(verbose_name="推荐语")
|
||||
author_name = models.CharField(max_length=50, verbose_name="客户姓名")
|
||||
author_title = models.CharField(
|
||||
max_length=100, blank=True, verbose_name="客户职位/公司"
|
||||
)
|
||||
author_photo = models.ForeignKey(
|
||||
"wagtailimages.Image",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="+",
|
||||
verbose_name="客户头像",
|
||||
)
|
||||
order = models.IntegerField(default=0, verbose_name="排序")
|
||||
|
||||
panels = [
|
||||
FieldPanel("quote"),
|
||||
FieldPanel("author_name"),
|
||||
FieldPanel("author_title"),
|
||||
FieldPanel("author_photo"),
|
||||
FieldPanel("order"),
|
||||
]
|
||||
|
||||
class Meta:
|
||||
ordering = ["order", "id"]
|
||||
verbose_name = "客户证言"
|
||||
verbose_name_plural = "客户证言"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.author_name} - {self.quote[:20]}"
|
||||
|
||||
|
||||
@register_snippet
|
||||
class Partner(models.Model):
|
||||
"""合作伙伴/客户 Logo,用于 Logo 墙展示。"""
|
||||
|
||||
name = models.CharField(max_length=100, verbose_name="名称")
|
||||
logo = models.ForeignKey(
|
||||
"wagtailimages.Image",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="+",
|
||||
verbose_name="Logo",
|
||||
)
|
||||
website_url = models.URLField(blank=True, verbose_name="官网链接")
|
||||
order = models.IntegerField(default=0, verbose_name="排序")
|
||||
|
||||
panels = [
|
||||
FieldPanel("name"),
|
||||
FieldPanel("logo"),
|
||||
FieldPanel("website_url"),
|
||||
FieldPanel("order"),
|
||||
]
|
||||
|
||||
class Meta:
|
||||
ordering = ["order", "id"]
|
||||
verbose_name = "合作伙伴"
|
||||
verbose_name_plural = "合作伙伴"
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
@register_snippet
|
||||
class NavigationMenu(ClusterableModel):
|
||||
"""导航菜单容器,通过 name 区分主导航/页脚导航等多套菜单。"""
|
||||
|
||||
name = models.CharField(
|
||||
max_length=50,
|
||||
unique=True,
|
||||
verbose_name="菜单标识",
|
||||
help_text="前端按此标识获取菜单,如 main(主导航)、footer(页脚导航)",
|
||||
)
|
||||
|
||||
panels = [
|
||||
FieldPanel("name"),
|
||||
InlinePanel("items", label="菜单项"),
|
||||
]
|
||||
|
||||
class Meta:
|
||||
verbose_name = "导航菜单"
|
||||
verbose_name_plural = "导航菜单"
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class NavigationMenuItem(Orderable):
|
||||
"""导航菜单项:可链接到内部页面,也可填写外部链接。"""
|
||||
|
||||
menu = ParentalKey(
|
||||
NavigationMenu, on_delete=models.CASCADE, related_name="items"
|
||||
)
|
||||
label = models.CharField(max_length=50, verbose_name="文案")
|
||||
link_page = models.ForeignKey(
|
||||
Page,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="+",
|
||||
verbose_name="内部页面",
|
||||
)
|
||||
link_url = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
verbose_name="外部链接",
|
||||
help_text="留空则使用内部页面链接",
|
||||
)
|
||||
open_in_new_tab = models.BooleanField(default=False, verbose_name="新标签页打开")
|
||||
|
||||
panels = [
|
||||
FieldPanel("label"),
|
||||
FieldPanel("link_page"),
|
||||
FieldPanel("link_url"),
|
||||
FieldPanel("open_in_new_tab"),
|
||||
]
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
if self.link_url:
|
||||
return self.link_url
|
||||
if self.link_page_id:
|
||||
return self.link_page.url
|
||||
return "#"
|
||||
|
||||
def __str__(self):
|
||||
return self.label
|
||||
|
||||
|
||||
@register_setting(icon="cogs")
|
||||
class SiteSettings(BaseSiteSetting):
|
||||
"""站点全局配置:公司信息/联系方式/备案号/社交账号,供 Footer 等全局位置消费。"""
|
||||
|
||||
company_name = models.CharField(max_length=100, blank=True, verbose_name="公司全称")
|
||||
contact_phone = models.CharField(max_length=30, blank=True, verbose_name="联系电话")
|
||||
contact_email = models.EmailField(blank=True, verbose_name="联系邮箱")
|
||||
address = models.CharField(max_length=255, blank=True, verbose_name="公司地址")
|
||||
|
||||
icp_number = models.CharField(max_length=50, blank=True, verbose_name="ICP备案号")
|
||||
icp_url = models.URLField(
|
||||
default="https://beian.miit.gov.cn/", verbose_name="备案查询链接"
|
||||
)
|
||||
|
||||
wechat_qrcode = models.ForeignKey(
|
||||
"wagtailimages.Image",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="+",
|
||||
verbose_name="微信公众号二维码",
|
||||
)
|
||||
wechat_account = models.CharField(
|
||||
max_length=100, blank=True, verbose_name="微信公众号名称"
|
||||
)
|
||||
weibo_url = models.URLField(blank=True, verbose_name="微博主页")
|
||||
|
||||
panels = [
|
||||
MultiFieldPanel(
|
||||
[
|
||||
FieldPanel("company_name"),
|
||||
FieldPanel("contact_phone"),
|
||||
FieldPanel("contact_email"),
|
||||
FieldPanel("address"),
|
||||
],
|
||||
heading="联系方式",
|
||||
),
|
||||
MultiFieldPanel(
|
||||
[FieldPanel("icp_number"), FieldPanel("icp_url")],
|
||||
heading="备案信息",
|
||||
),
|
||||
MultiFieldPanel(
|
||||
[
|
||||
FieldPanel("wechat_qrcode"),
|
||||
FieldPanel("wechat_account"),
|
||||
FieldPanel("weibo_url"),
|
||||
],
|
||||
heading="社交账号",
|
||||
),
|
||||
]
|
||||
|
||||
class Meta:
|
||||
verbose_name = "站点设置"
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
apps.core 全局 Snippet 的只读序列化器,供自定义业务接口
|
||||
(/api/v1/custom/core/...)使用。图片字段统一序列化为
|
||||
{"url": ..., "title": ...},与前端 frontend/types/wagtail.ts
|
||||
中既有的 background_image/logos 字段结构保持一致。
|
||||
"""
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
def serialize_image(image):
|
||||
if not image:
|
||||
return None
|
||||
return {"url": image.file.url, "title": image.title}
|
||||
|
||||
|
||||
class TeamMemberSerializer(serializers.Serializer):
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
role = serializers.CharField()
|
||||
bio = serializers.CharField()
|
||||
photo = serializers.SerializerMethodField()
|
||||
|
||||
def get_photo(self, obj):
|
||||
return serialize_image(obj.photo)
|
||||
|
||||
|
||||
class TestimonialSerializer(serializers.Serializer):
|
||||
id = serializers.IntegerField()
|
||||
quote = serializers.CharField()
|
||||
author_name = serializers.CharField()
|
||||
author_title = serializers.CharField()
|
||||
author_photo = serializers.SerializerMethodField()
|
||||
|
||||
def get_author_photo(self, obj):
|
||||
return serialize_image(obj.author_photo)
|
||||
|
||||
|
||||
class PartnerSerializer(serializers.Serializer):
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
website_url = serializers.CharField()
|
||||
logo = serializers.SerializerMethodField()
|
||||
|
||||
def get_logo(self, obj):
|
||||
return serialize_image(obj.logo)
|
||||
|
||||
|
||||
class NavigationMenuItemSerializer(serializers.Serializer):
|
||||
label = serializers.CharField()
|
||||
url = serializers.CharField()
|
||||
open_in_new_tab = serializers.BooleanField()
|
||||
|
||||
|
||||
class NavigationMenuSerializer(serializers.Serializer):
|
||||
name = serializers.CharField()
|
||||
items = serializers.SerializerMethodField()
|
||||
|
||||
def get_items(self, obj):
|
||||
return NavigationMenuItemSerializer(obj.items.all(), many=True).data
|
||||
|
||||
|
||||
class SiteSettingsSerializer(serializers.Serializer):
|
||||
company_name = serializers.CharField()
|
||||
contact_phone = serializers.CharField()
|
||||
contact_email = serializers.CharField()
|
||||
address = serializers.CharField()
|
||||
icp_number = serializers.CharField()
|
||||
icp_url = serializers.CharField()
|
||||
wechat_account = serializers.CharField()
|
||||
weibo_url = serializers.CharField()
|
||||
wechat_qrcode = serializers.SerializerMethodField()
|
||||
|
||||
def get_wechat_qrcode(self, obj):
|
||||
return serialize_image(obj.wechat_qrcode)
|
||||
+121
-1
@@ -1,8 +1,17 @@
|
||||
"""apps.core 基础单元测试:SEOablePage 抽象基类字段与公共 Block。"""
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.core.blocks import FormBlock
|
||||
from apps.core.models import SimpleContentPage
|
||||
from apps.core.models import (
|
||||
NavigationMenu,
|
||||
NavigationMenuItem,
|
||||
Partner,
|
||||
SimpleContentPage,
|
||||
SiteSettings,
|
||||
TeamMember,
|
||||
Testimonial,
|
||||
)
|
||||
from apps.forms.models import FormDefinition, FormDefinitionField
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -75,3 +84,114 @@ def test_simple_content_page_can_be_created_under_home(home_page):
|
||||
page.refresh_from_db()
|
||||
assert SimpleContentPage.objects.live().descendant_of(home_page).count() == 1
|
||||
assert "个人信息保护" in page.body
|
||||
|
||||
|
||||
def test_team_member_default_ordering():
|
||||
"""团队成员应按 order 字段升序排列。"""
|
||||
TeamMember.objects.create(name="李四", order=2)
|
||||
TeamMember.objects.create(name="张三", order=1)
|
||||
names = list(TeamMember.objects.values_list("name", flat=True))
|
||||
assert names == ["张三", "李四"]
|
||||
|
||||
|
||||
def test_testimonial_str_includes_author_and_quote():
|
||||
testimonial = Testimonial.objects.create(
|
||||
quote="非常专业的服务,效果超出预期。", author_name="王经理"
|
||||
)
|
||||
assert "王经理" in str(testimonial)
|
||||
|
||||
|
||||
def test_partner_str_returns_name():
|
||||
partner = Partner.objects.create(name="示例合作伙伴")
|
||||
assert str(partner) == "示例合作伙伴"
|
||||
|
||||
|
||||
def test_navigation_menu_item_url_prefers_external_link_over_page(home_page):
|
||||
menu = NavigationMenu.objects.create(name="main")
|
||||
item = NavigationMenuItem.objects.create(
|
||||
menu=menu, label="首页", link_page=home_page, link_url="https://example.com"
|
||||
)
|
||||
assert item.url == "https://example.com"
|
||||
|
||||
|
||||
def test_navigation_menu_item_url_falls_back_to_page_url(home_page):
|
||||
menu = NavigationMenu.objects.create(name="main")
|
||||
item = NavigationMenuItem.objects.create(menu=menu, label="首页", link_page=home_page)
|
||||
assert item.url == home_page.url
|
||||
|
||||
|
||||
def test_navigation_menu_item_url_defaults_to_hash_when_empty():
|
||||
menu = NavigationMenu.objects.create(name="main")
|
||||
item = NavigationMenuItem.objects.create(menu=menu, label="占位")
|
||||
assert item.url == "#"
|
||||
|
||||
|
||||
def test_site_settings_for_site_creates_singleton_with_defaults():
|
||||
from wagtail.models import Site
|
||||
|
||||
site = Site.objects.get(is_default_site=True)
|
||||
settings_obj = SiteSettings.for_site(site)
|
||||
assert settings_obj.icp_url == "https://beian.miit.gov.cn/"
|
||||
# 再次获取应复用同一条记录而非重复创建
|
||||
assert SiteSettings.for_site(site).pk == settings_obj.pk
|
||||
|
||||
|
||||
def test_team_api_returns_ordered_members():
|
||||
TeamMember.objects.create(name="李四", role="工程师", order=2)
|
||||
TeamMember.objects.create(name="张三", role="总经理", order=1)
|
||||
|
||||
client = APIClient()
|
||||
response = client.get("/api/v1/custom/core/team/")
|
||||
|
||||
assert response.status_code == 200
|
||||
items = response.data["items"]
|
||||
assert [item["name"] for item in items] == ["张三", "李四"]
|
||||
assert items[0]["role"] == "总经理"
|
||||
|
||||
|
||||
def test_testimonial_api_returns_list():
|
||||
Testimonial.objects.create(quote="效果很好", author_name="客户A")
|
||||
|
||||
client = APIClient()
|
||||
response = client.get("/api/v1/custom/core/testimonials/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["items"][0]["author_name"] == "客户A"
|
||||
|
||||
|
||||
def test_partner_api_returns_list():
|
||||
Partner.objects.create(name="伙伴A", website_url="https://partner-a.example.com")
|
||||
|
||||
client = APIClient()
|
||||
response = client.get("/api/v1/custom/core/partners/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["items"][0]["name"] == "伙伴A"
|
||||
|
||||
|
||||
def test_navigation_menu_api_returns_items_by_name(home_page):
|
||||
menu = NavigationMenu.objects.create(name="main")
|
||||
NavigationMenuItem.objects.create(menu=menu, label="首页", link_page=home_page, sort_order=0)
|
||||
NavigationMenuItem.objects.create(menu=menu, label="外部链接", link_url="https://example.com", sort_order=1)
|
||||
|
||||
client = APIClient()
|
||||
response = client.get("/api/v1/custom/core/navigation/?name=main")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["name"] == "main"
|
||||
assert len(response.data["items"]) == 2
|
||||
assert response.data["items"][1]["url"] == "https://example.com"
|
||||
|
||||
|
||||
def test_navigation_menu_api_404_when_missing():
|
||||
client = APIClient()
|
||||
response = client.get("/api/v1/custom/core/navigation/?name=does-not-exist")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_site_settings_api_returns_defaults():
|
||||
client = APIClient()
|
||||
response = client.get("/api/v1/custom/core/site-settings/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["icp_url"] == "https://beian.miit.gov.cn/"
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
from django.urls import path
|
||||
|
||||
from .api import (
|
||||
NavigationMenuView,
|
||||
PartnerListView,
|
||||
SiteSettingsView,
|
||||
TeamMemberListView,
|
||||
TestimonialListView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path("team/", TeamMemberListView.as_view(), name="team-list"),
|
||||
path("testimonials/", TestimonialListView.as_view(), name="testimonial-list"),
|
||||
path("partners/", PartnerListView.as_view(), name="partner-list"),
|
||||
path("navigation/", NavigationMenuView.as_view(), name="navigation-menu"),
|
||||
path("site-settings/", SiteSettingsView.as_view(), name="site-settings"),
|
||||
]
|
||||
@@ -370,11 +370,11 @@ CI 中要求单元测试覆盖率不低于 70%,核心 `apps/forms`(涉及线
|
||||
- [x] `apps/forms`:`FormDefinition`/`FormDefinitionField`/`Lead`(Snippet 方式)+ 提交 API(`/api/v1/custom/leads/`,限流 + 邮件通知)
|
||||
- [x] Wagtail API v2 挂载、分页/过滤/排序(Wagtail 内置)
|
||||
- [x] Next.js 前端脚手架(独立仓库):Header/Footer/layout、首页、博客/产品/案例/解决方案列表与详情页、`BlockRenderer`、ISR + revalidate route,`lint`/`build` 已验证通过
|
||||
- [x] pytest 基础测试框架(pytest-django + factory_boy):`pytest.ini` + `conftest.py`(root_page/home_page fixtures),为 `apps/core`(SEOablePage 字段、FormBlock API 表示、SimpleContentPage 页面树)、`apps/products`、`apps/cases`、`apps/forms`(线索提交 API)编写了基础单元测试,共 11 个用例均通过
|
||||
- [x] pytest 基础测试框架(pytest-django + factory_boy):`pytest.ini` + `conftest.py`(root_page/home_page fixtures),为 `apps/core`(SEOablePage 字段、FormBlock API 表示、SimpleContentPage 页面树、Snippet 模型与只读 API)、`apps/products`、`apps/cases`、`apps/forms`(线索提交 API)编写了基础单元测试,共 24 个用例均通过
|
||||
- [x] `apps/core.SimpleContentPage`:通用富文本法务/说明类页面模型,用于隐私政策等内容,配套 `seed_privacy_policy` management command 用于幂等创建/更新隐私政策页面(需在 HomePage 实例存在后手动运行)
|
||||
- [x] Snippet:`TeamMember`/`Testimonial`/`Partner`(简单 `@register_snippet`,均含 `order` 排序字段)、`NavigationMenu`+`NavigationMenuItem`(`ClusterableModel`+`Orderable`+`InlinePanel`,内部页面/外部链接二选一)、`SiteSettings`(`wagtail.contrib.settings` + `BaseSiteSetting`,公司信息/ICP备案/社交账号全局配置)均已创建;只读 API 挂载于 `/api/v1/custom/core/`(`team/`、`testimonials/`、`partners/`、`navigation/?name=`、`site-settings/`,限流 public 100/min);前端 `Header`/`Footer` 已接入 `NavigationMenu`/`SiteSettings`(接口不可用时回退静态内容),`TeamMember`/`Testimonial`/`Partner` 已提供 service 层,页面级展示留待对应 StreamField Block(见下)落地时接入
|
||||
|
||||
未完成(待规划排期):
|
||||
- [ ] Snippet:`TeamMember`/`Testimonial`/`Partner`/`NavigationMenu`/`SiteSettings`(`BaseSiteSetting`)均未创建
|
||||
- [ ] 预览模式(Wagtail `preview_token` + Next.js Draft Mode)未实现
|
||||
- [ ] 剩余 5 个 StreamField Block(Pricing/Timeline/Team/TechStack/Video/ProductCard)未开发
|
||||
- [ ] RBAC 落地(Wagtail `Group` + `GroupPagePermission` 实际配置)未开始
|
||||
|
||||
@@ -30,6 +30,7 @@ INSTALLED_APPS = [
|
||||
# Wagtail
|
||||
"wagtail.contrib.forms",
|
||||
"wagtail.contrib.redirects",
|
||||
"wagtail.contrib.settings",
|
||||
"wagtail.embeds",
|
||||
"wagtail.sites",
|
||||
"wagtail.users",
|
||||
@@ -78,6 +79,7 @@ TEMPLATES = [
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
"wagtail.contrib.settings.context_processors.settings",
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -31,6 +31,8 @@ urlpatterns = [
|
||||
path('api/v2/', api_router.urls),
|
||||
# 自定义业务接口(非 Wagtail 页面路由),如线索表单提交
|
||||
path('api/v1/custom/', include('apps.forms.urls')),
|
||||
# 全局 Snippet 只读接口:团队/证言/合作伙伴/导航菜单/站点设置
|
||||
path('api/v1/custom/core/', include('apps.core.urls')),
|
||||
path('healthz', healthz, name='healthz'),
|
||||
path('readyz', readyz, name='readyz'),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user