ordloom-cms 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.4
2
+ Name: ordloom-cms
3
+ Version: 0.1.0
4
+ Summary: Ordloom 内容管理子应用
5
+ Project-URL: Homepage, https://neekin.github.io/ordloom-docs/
6
+ Project-URL: Documentation, https://neekin.github.io/ordloom-docs/guides/cms-and-members.html
7
+ Project-URL: Repository, https://github.com/neekin/simpleui-antd
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: ordloom<2,>=1.1.0
11
+ Requires-Dist: Markdown<4,>=3.7
12
+ Requires-Dist: bleach<7,>=6.2
13
+
14
+ # ordloom-cms
15
+
16
+ Ordloom 子应用 SDK 的内容管理 MVP:站点、栏目、标签、Markdown 文章、封面、送审/发布/下线、不可变发布快照、公开 HTML/JSON、搜索/分页、Sitemap,以及可选会员内容。
17
+
18
+ 版本 0.1.0,依赖 Ordloom 1.1.0+ 的 SDK v1。独立安装:
19
+
20
+ ```bash
21
+ python -m pip install ordloom-cms==0.1.0
22
+ ```
23
+
24
+ 注册 `ordloom_cms.apps.ExtensionConfig`,挂载 `include("ordloom_cms.urls")`,执行 `migrate` 与 `collectstatic`。站点默认不公开。管理沿用 Django 模型权限,并限制负责人/编辑成员的站点范围;公开发布另需 `publish_article` / `publish_site`。不安装会员模块时,会员内容保持关闭。
25
+
26
+ 完整接入与边界见 [CMS 与会员指南](https://neekin.github.io/ordloom-docs/guides/cms-and-members.html)。
@@ -0,0 +1,13 @@
1
+ # ordloom-cms
2
+
3
+ Ordloom 子应用 SDK 的内容管理 MVP:站点、栏目、标签、Markdown 文章、封面、送审/发布/下线、不可变发布快照、公开 HTML/JSON、搜索/分页、Sitemap,以及可选会员内容。
4
+
5
+ 版本 0.1.0,依赖 Ordloom 1.1.0+ 的 SDK v1。独立安装:
6
+
7
+ ```bash
8
+ python -m pip install ordloom-cms==0.1.0
9
+ ```
10
+
11
+ 注册 `ordloom_cms.apps.ExtensionConfig`,挂载 `include("ordloom_cms.urls")`,执行 `migrate` 与 `collectstatic`。站点默认不公开。管理沿用 Django 模型权限,并限制负责人/编辑成员的站点范围;公开发布另需 `publish_article` / `publish_site`。不安装会员模块时,会员内容保持关闭。
12
+
13
+ 完整接入与边界见 [CMS 与会员指南](https://neekin.github.io/ordloom-docs/guides/cms-and-members.html)。
File without changes
@@ -0,0 +1,91 @@
1
+ from django.contrib import admin,messages
2
+ from django.core.exceptions import PermissionDenied,ValidationError
3
+ from .models import Site,Section,Tag,Article
4
+ from .services import site_scope,article_scope,transition
5
+
6
+
7
+ class ScopedAdmin(admin.ModelAdmin):
8
+ def has_module_permission(self,request):
9
+ from .views import enabled
10
+ return enabled() and super().has_module_permission(request)
11
+ def has_add_permission(self,request):return self.has_module_permission(request) and super().has_add_permission(request)
12
+ def get_queryset(self,request):
13
+ rows=super().get_queryset(request)
14
+ if not self.has_module_permission(request):return rows.none()
15
+ return rows.filter(pk__in=site_scope(request)) if self.model is Site else rows.filter(site__in=site_scope(request))
16
+ def has_view_permission(self,request,obj=None):
17
+ return self.has_module_permission(request) and super().has_view_permission(request,obj) and (obj is None or self.get_queryset(request).filter(pk=obj.pk).exists())
18
+ def has_change_permission(self,request,obj=None):
19
+ return self.has_module_permission(request) and super().has_change_permission(request,obj) and (obj is None or self.get_queryset(request).filter(pk=obj.pk).exists())
20
+ def has_delete_permission(self,request,obj=None):
21
+ return self.has_module_permission(request) and super().has_delete_permission(request,obj) and (obj is None or self.get_queryset(request).filter(pk=obj.pk).exists())
22
+ def formfield_for_foreignkey(self,db_field,request,**kwargs):
23
+ if db_field.name=='site':kwargs['queryset']=site_scope(request)
24
+ if db_field.name=='section':kwargs['queryset']=Section.objects.filter(site__in=site_scope(request))
25
+ return super().formfield_for_foreignkey(db_field,request,**kwargs)
26
+ def formfield_for_manytomany(self,db_field,request,**kwargs):
27
+ if db_field.name=='tags':kwargs['queryset']=Tag.objects.filter(site__in=site_scope(request))
28
+ return super().formfield_for_manytomany(db_field,request,**kwargs)
29
+
30
+
31
+ @admin.register(Site)
32
+ class SiteAdmin(ScopedAdmin):
33
+ list_display=('name','slug','public_enabled','domain')
34
+ search_fields=('name','slug')
35
+ fields=('name','slug','domain','public_enabled','owner','editors')
36
+ def get_readonly_fields(self,request,obj=None):
37
+ return (() if request.user.is_superuser else ('owner',))+(() if request.user.has_perm('ordloom_cms.publish_site') else ('public_enabled',))
38
+ def save_model(self,request,obj,form,change):
39
+ if not change and not request.user.is_superuser:obj.owner=request.user
40
+ super().save_model(request,obj,form,change)
41
+ def formfield_for_foreignkey(self,db_field,request,**kwargs):
42
+ if db_field.name=='owner':
43
+ from django.contrib.auth import get_user_model
44
+ kwargs['queryset']=get_user_model()._default_manager.filter(is_staff=True,is_active=True)
45
+ return super().formfield_for_foreignkey(db_field,request,**kwargs)
46
+ def formfield_for_manytomany(self,db_field,request,**kwargs):
47
+ if db_field.name=='editors':
48
+ from django.contrib.auth import get_user_model
49
+ kwargs['queryset']=get_user_model()._default_manager.filter(is_staff=True,is_active=True)
50
+ return super().formfield_for_manytomany(db_field,request,**kwargs)
51
+
52
+
53
+ @admin.register(Section)
54
+ class SectionAdmin(ScopedAdmin):
55
+ list_display=('name','slug','site');search_fields=('name','slug')
56
+
57
+
58
+ @admin.register(Tag)
59
+ class TagAdmin(ScopedAdmin):
60
+ list_display=('name','slug','site');search_fields=('name','slug')
61
+
62
+
63
+ @admin.register(Article)
64
+ class ArticleAdmin(ScopedAdmin):
65
+ list_display=('title','site','state','publication','access_level','updated_at')
66
+ list_filter=('state','access_level','site')
67
+ search_fields=('title','excerpt')
68
+ fields=('site','section','title','slug','excerpt','body','cover','tags','access_level')
69
+ actions=('submit_articles','publish_articles','unpublish_articles')
70
+ @admin.display(description='公开版本')
71
+ def publication(self,obj):return '公开 v'+str(obj.published_version.number) if obj.published_version_id else '未公开'
72
+ def get_readonly_fields(self,request,obj=None):
73
+ return (('site','slug') if obj and obj.versions.exists() else ())+(() if request.user.has_perm('ordloom_cms.publish_article') else ('access_level',))
74
+ def has_publish_permission(self,request,obj=None):
75
+ return request.user.has_perm('ordloom_cms.publish_article') and self.has_change_permission(request,obj)
76
+ def save_model(self,request,obj,form,change):
77
+ if not change:obj.created_by=request.user
78
+ super().save_model(request,obj,form,change)
79
+ def save_related(self,request,form,formsets,change):
80
+ super().save_related(request,form,formsets,change)
81
+ if form.instance.tags.exclude(site=form.instance.site).exists():raise ValidationError('标签须属于当前站点')
82
+ def _transition(self,request,queryset,action):
83
+ # The core action endpoint already keeps this in its transaction.
84
+ for article in queryset:transition(request,article.pk,action)
85
+ self.message_user(request,'内容操作已完成',level=messages.SUCCESS)
86
+ @admin.action(description='送审当前草稿',permissions=['change'])
87
+ def submit_articles(self,request,queryset):self._transition(request,queryset,'submit')
88
+ @admin.action(description='审核并发布',permissions=['publish'])
89
+ def publish_articles(self,request,queryset):self._transition(request,queryset,'publish')
90
+ @admin.action(description='下线公开版本',permissions=['publish'])
91
+ def unpublish_articles(self,request,queryset):self._transition(request,queryset,'unpublish')
@@ -0,0 +1,11 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class ExtensionConfig(AppConfig):
5
+ name = 'ordloom_cms'
6
+ verbose_name = '内容管理'
7
+ default_auto_field = 'django.db.models.BigAutoField'
8
+ ordloom_application = 'ordloom_cms.extension.application'
9
+
10
+ def ready(self):
11
+ from . import signals # noqa: F401
@@ -0,0 +1,29 @@
1
+ from ordloom.sdk import Application,AppPage,AppTask,CanvasWidget,visible_objects
2
+
3
+
4
+ def overview(request):
5
+ rows=visible_objects(request,'ordloom_cms.article')
6
+ from django.contrib import admin
7
+ from django.contrib.admin.utils import flatten_fieldsets
8
+ from django.urls import reverse,NoReverseMatch
9
+ from .models import Article
10
+ manager=admin.site._registry[Article];recent=[]
11
+ for row in sorted(rows,key=lambda r:r.pk,reverse=True)[:20]:
12
+ fields=set(flatten_fieldsets(manager.get_fieldsets(request,row)))
13
+ try:url=reverse('ordloom_cms:article',kwargs={'site_slug':row.site.slug,'slug':row.slug}) if row.published_version_id else None
14
+ except NoReverseMatch:url=None
15
+ recent.append({'id':str(row.pk),'title':row.title if 'title' in fields else '文章 '+str(row.pk),
16
+ 'state':row.get_state_display(),'published_version':row.published_version.number if row.published_version_id else None,
17
+ 'url':url,'history':[{'number':v.number,'title':v.title if 'title' in fields else '内容版本','created_at':v.created_at.isoformat()} for v in row.versions.all()[:10]]})
18
+ return {'articles':recent,'title':'内容概览','drafts':sum(r.state=='draft' for r in rows),'review':sum(r.state=='review' for r in rows),
19
+ 'published':sum(r.published_version_id is not None for r in rows),'description':'草稿送审后由具有发布权限的人员审核发布;公开内容读取不可变快照。'}
20
+
21
+
22
+ def audit(context):return {'processed':len(visible_objects(context.request,'ordloom_cms.article'))}
23
+
24
+ application=Application(id='cms',label='内容管理',version='0.1.0',asset='ordloom_cms/extension.js', description='站点、文章审核和不可变发布内容。', category='content', distribution='ordloom-cms', documentation_url='https://neekin.github.io/ordloom-docs/guides/cms-and-members.html',
25
+ pages=(AppPage('overview','内容概览',overview,('ordloom_cms.view_article',),model='ordloom_cms.article'),),
26
+ widgets=(CanvasWidget('contentlink','内容入口',('ordloom_cms.view_article',),model='ordloom_cms.article',public=True,
27
+ fields={'title':{'type':'text','label':'入口标题','required':True,'max_length':100},'description':{'type':'textarea','label':'入口说明','max_length':500},'path':{'type':'text','label':'内容路径','required':True,'max_length':1000}},
28
+ defaults={'title':'阅读我们的最新内容','description':'浏览文章、资讯与会员专享内容。','path':'/cms/main/'}),),
29
+ tasks=(AppTask('audit','内容范围检查',audit,('ordloom_cms.view_article',),model='ordloom_cms.article'),))
@@ -0,0 +1,144 @@
1
+ # Generated by Django 5.2.19 on 2026-11-11 14:10
2
+
3
+ import django.db.models.deletion
4
+ import ordloom.editor.fields
5
+ from django.conf import settings
6
+ from django.db import migrations, models
7
+
8
+
9
+ class Migration(migrations.Migration):
10
+
11
+ initial = True
12
+
13
+ dependencies = [
14
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
15
+ ]
16
+
17
+ operations = [
18
+ migrations.CreateModel(
19
+ name='Section',
20
+ fields=[
21
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
22
+ ('name', models.CharField(max_length=100, verbose_name='栏目名称')),
23
+ ('slug', models.SlugField(verbose_name='栏目标识')),
24
+ ],
25
+ options={
26
+ 'verbose_name': '栏目',
27
+ 'verbose_name_plural': '栏目',
28
+ },
29
+ ),
30
+ migrations.CreateModel(
31
+ name='Article',
32
+ fields=[
33
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
34
+ ('title', models.CharField(max_length=200, verbose_name='标题')),
35
+ ('slug', models.SlugField(max_length=120, verbose_name='文章标识')),
36
+ ('excerpt', models.CharField(blank=True, max_length=500, verbose_name='摘要')),
37
+ ('body', ordloom.editor.fields.MDTextField(blank=True, max_length=100000, verbose_name='正文')),
38
+ ('cover', models.ImageField(blank=True, upload_to='ordloom-cms/covers/', verbose_name='封面图片')),
39
+ ('access_level', models.CharField(choices=[('public', '公开'), ('members', '登录会员')], default='public', max_length=12, verbose_name='访问范围')),
40
+ ('state', models.CharField(choices=[('draft', '草稿'), ('review', '待审核'), ('published', '已发布')], default='draft', editable=False, max_length=12, verbose_name='草稿状态')),
41
+ ('draft_revision', models.PositiveIntegerField(default=1, editable=False)),
42
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
43
+ ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ordloom_cms_articles', to=settings.AUTH_USER_MODEL, verbose_name='创建人')),
44
+ ],
45
+ options={
46
+ 'verbose_name': '文章',
47
+ 'verbose_name_plural': '文章',
48
+ 'ordering': ['-updated_at', '-pk'],
49
+ 'permissions': [('publish_article', '可审核发布及下线文章')],
50
+ },
51
+ ),
52
+ migrations.CreateModel(
53
+ name='ArticleVersion',
54
+ fields=[
55
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
56
+ ('number', models.PositiveIntegerField()),
57
+ ('title', models.CharField(max_length=200)),
58
+ ('excerpt', models.CharField(blank=True, max_length=500)),
59
+ ('body', models.TextField(blank=True)),
60
+ ('cover_url', models.CharField(blank=True, max_length=2000)),
61
+ ('section_slug', models.SlugField(blank=True)),
62
+ ('section_name', models.CharField(blank=True, max_length=100)),
63
+ ('tags', models.JSONField(default=list)),
64
+ ('fingerprint', models.CharField(max_length=64)),
65
+ ('created_at', models.DateTimeField(auto_now_add=True)),
66
+ ('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='ordloom_cms.article')),
67
+ ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ordloom_cms_versions', to=settings.AUTH_USER_MODEL)),
68
+ ],
69
+ options={
70
+ 'ordering': ['-number'],
71
+ },
72
+ ),
73
+ migrations.AddField(
74
+ model_name='article',
75
+ name='published_version',
76
+ field=models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='ordloom_cms.articleversion'),
77
+ ),
78
+ migrations.AddField(
79
+ model_name='article',
80
+ name='section',
81
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='ordloom_cms.section', verbose_name='栏目'),
82
+ ),
83
+ migrations.CreateModel(
84
+ name='Site',
85
+ fields=[
86
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
87
+ ('name', models.CharField(max_length=100, verbose_name='站点名称')),
88
+ ('slug', models.SlugField(unique=True, verbose_name='站点标识')),
89
+ ('domain', models.CharField(blank=True, max_length=253, verbose_name='公开域名(可留空)')),
90
+ ('public_enabled', models.BooleanField(default=False, verbose_name='公开访问')),
91
+ ('editors', models.ManyToManyField(blank=True, related_name='ordloom_cms_editing_sites', to=settings.AUTH_USER_MODEL, verbose_name='编辑成员')),
92
+ ('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='ordloom_cms_sites', to=settings.AUTH_USER_MODEL, verbose_name='负责人')),
93
+ ],
94
+ options={
95
+ 'verbose_name': '内容站点',
96
+ 'verbose_name_plural': '内容站点',
97
+ 'permissions': [('publish_site', '可开启内容站点公开访问')],
98
+ },
99
+ ),
100
+ migrations.AddField(
101
+ model_name='section',
102
+ name='site',
103
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sections', to='ordloom_cms.site', verbose_name='站点'),
104
+ ),
105
+ migrations.AddField(
106
+ model_name='article',
107
+ name='site',
108
+ field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='articles', to='ordloom_cms.site', verbose_name='站点'),
109
+ ),
110
+ migrations.CreateModel(
111
+ name='Tag',
112
+ fields=[
113
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
114
+ ('name', models.CharField(max_length=80, verbose_name='标签名称')),
115
+ ('slug', models.SlugField(verbose_name='标签标识')),
116
+ ('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tags', to='ordloom_cms.site', verbose_name='站点')),
117
+ ],
118
+ options={
119
+ 'verbose_name': '内容标签',
120
+ 'verbose_name_plural': '内容标签',
121
+ },
122
+ ),
123
+ migrations.AddField(
124
+ model_name='article',
125
+ name='tags',
126
+ field=models.ManyToManyField(blank=True, to='ordloom_cms.tag', verbose_name='标签'),
127
+ ),
128
+ migrations.AddConstraint(
129
+ model_name='articleversion',
130
+ constraint=models.UniqueConstraint(fields=('article', 'number'), name='ordloom_cms_version_number'),
131
+ ),
132
+ migrations.AddConstraint(
133
+ model_name='section',
134
+ constraint=models.UniqueConstraint(fields=('site', 'slug'), name='ordloom_cms_section_slug'),
135
+ ),
136
+ migrations.AddConstraint(
137
+ model_name='tag',
138
+ constraint=models.UniqueConstraint(fields=('site', 'slug'), name='ordloom_cms_tag_slug'),
139
+ ),
140
+ migrations.AddConstraint(
141
+ model_name='article',
142
+ constraint=models.UniqueConstraint(fields=('site', 'slug'), name='ordloom_cms_article_slug'),
143
+ ),
144
+ ]
File without changes
@@ -0,0 +1,127 @@
1
+ from django.conf import settings
2
+ from django.core.exceptions import ValidationError
3
+ from django.db import models,transaction
4
+ from ordloom.editor.fields import MDTextField
5
+
6
+
7
+ class Site(models.Model):
8
+ name = models.CharField('站点名称',max_length=100)
9
+ slug = models.SlugField('站点标识',unique=True)
10
+ domain = models.CharField('公开域名(可留空)',max_length=253,blank=True)
11
+ public_enabled = models.BooleanField('公开访问',default=False)
12
+ owner = models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.PROTECT,related_name='ordloom_cms_sites',verbose_name='负责人')
13
+ editors = models.ManyToManyField(settings.AUTH_USER_MODEL,blank=True,related_name='ordloom_cms_editing_sites',verbose_name='编辑成员')
14
+ class Meta:
15
+ verbose_name='内容站点'
16
+ verbose_name_plural=verbose_name
17
+ permissions=[('publish_site','可开启内容站点公开访问')]
18
+ def __str__(self):return self.name
19
+ def clean(self):
20
+ super().clean()
21
+ import re
22
+ if self.domain and not re.fullmatch(r'[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?',self.domain):raise ValidationError({'domain':'请填写不含协议、端口和路径的域名'})
23
+ if self.pk:
24
+ old=type(self).objects.get(pk=self.pk)
25
+ if old.slug!=self.slug and ArticleVersion.objects.filter(article__site=self).exists():raise ValidationError({'slug':'已有发布历史的站点标识固定'})
26
+
27
+
28
+ class Section(models.Model):
29
+ site=models.ForeignKey(Site,on_delete=models.CASCADE,related_name='sections',verbose_name='站点')
30
+ name=models.CharField('栏目名称',max_length=100)
31
+ slug=models.SlugField('栏目标识')
32
+ class Meta:
33
+ verbose_name='栏目'
34
+ verbose_name_plural=verbose_name
35
+ constraints=[models.UniqueConstraint(fields=['site','slug'],name='ordloom_cms_section_slug')]
36
+ def __str__(self):return self.name
37
+
38
+
39
+ class Tag(models.Model):
40
+ site=models.ForeignKey(Site,on_delete=models.CASCADE,related_name='tags',verbose_name='站点')
41
+ name=models.CharField('标签名称',max_length=80)
42
+ slug=models.SlugField('标签标识')
43
+ class Meta:
44
+ verbose_name='内容标签'
45
+ verbose_name_plural=verbose_name
46
+ constraints=[models.UniqueConstraint(fields=['site','slug'],name='ordloom_cms_tag_slug')]
47
+ def __str__(self):return self.name
48
+
49
+
50
+ class Article(models.Model):
51
+ site=models.ForeignKey(Site,on_delete=models.PROTECT,related_name='articles',verbose_name='站点')
52
+ section=models.ForeignKey(Section,on_delete=models.SET_NULL,null=True,blank=True,verbose_name='栏目')
53
+ title=models.CharField('标题',max_length=200)
54
+ slug=models.SlugField('文章标识',max_length=120)
55
+ excerpt=models.CharField('摘要',max_length=500,blank=True)
56
+ body=MDTextField('正文',blank=True,max_length=100000)
57
+ cover=models.ImageField('封面图片',upload_to='ordloom-cms/covers/',blank=True)
58
+ tags=models.ManyToManyField(Tag,blank=True,verbose_name='标签')
59
+ access_level=models.CharField('访问范围',max_length=12,choices=[('public','公开'),('members','登录会员')],default='public')
60
+ state=models.CharField('草稿状态',max_length=12,choices=[('draft','草稿'),('review','待审核'),('published','已发布')],default='draft',editable=False)
61
+ draft_revision=models.PositiveIntegerField(default=1,editable=False)
62
+ published_version=models.ForeignKey('ArticleVersion',on_delete=models.SET_NULL,null=True,blank=True,editable=False,related_name='+')
63
+ created_by=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.SET_NULL,null=True,blank=True,related_name='ordloom_cms_articles',verbose_name='创建人')
64
+ updated_at=models.DateTimeField('更新时间',auto_now=True)
65
+ class Meta:
66
+ verbose_name='文章'
67
+ verbose_name_plural=verbose_name
68
+ ordering=['-updated_at','-pk']
69
+ constraints=[models.UniqueConstraint(fields=['site','slug'],name='ordloom_cms_article_slug')]
70
+ permissions=[('publish_article','可审核发布及下线文章')]
71
+ def __str__(self):return self.title
72
+ def clean(self):
73
+ super().clean()
74
+ if self.section_id and self.section.site_id!=self.site_id:raise ValidationError({'section':'栏目须属于当前站点'})
75
+ if self.pk and self.versions.exists():
76
+ old=type(self).objects.get(pk=self.pk)
77
+ if old.site_id!=self.site_id or old.slug!=self.slug:raise ValidationError('已有发布历史的文章标识和站点固定')
78
+ def save(self,*args,**kwargs):
79
+ if kwargs.get('update_fields') is not None and not kwargs['update_fields']:return
80
+ with transaction.atomic(using=kwargs.get('using') or 'default'):
81
+ if self.pk:
82
+ previous=type(self).objects.select_for_update().filter(pk=self.pk).first()
83
+ if previous:
84
+ if previous.versions.exists() and (previous.site_id!=self.site_id or previous.slug!=self.slug):
85
+ raise ValidationError('已有发布历史的文章标识和站点固定')
86
+ self.published_version_id=previous.published_version_id
87
+ fields={'site_id','section_id','title','slug','excerpt','body','cover'}
88
+ if kwargs.get('update_fields') is not None:
89
+ changed_fields=set(kwargs['update_fields'])
90
+ fields={key for key in fields if key in changed_fields or (key[:-3] if key.endswith('_id') else key) in changed_fields}
91
+ changed=any(str(getattr(self,key))!=str(getattr(previous,key)) for key in fields)
92
+ self.draft_revision=previous.draft_revision+int(changed)
93
+ self.state='draft' if changed else previous.state
94
+ if kwargs.get('update_fields') is not None:kwargs['update_fields']=[*kwargs['update_fields'],'draft_revision','state']
95
+ return super().save(*args,**kwargs)
96
+
97
+
98
+ class ImmutableVersions(models.QuerySet):
99
+ def update(self,**kwargs):raise ValidationError('内容发布版本不可修改')
100
+ def bulk_update(self,*args,**kwargs):raise ValidationError('内容发布版本不可修改')
101
+ def delete(self):raise ValidationError('内容发布版本不可单独删除')
102
+ def bulk_create(self,objs,**kwargs):
103
+ if kwargs.get('update_conflicts'):raise ValidationError('内容发布版本不可覆盖')
104
+ return super().bulk_create(objs,**kwargs)
105
+
106
+
107
+ class ArticleVersion(models.Model):
108
+ article=models.ForeignKey(Article,on_delete=models.CASCADE,related_name='versions')
109
+ number=models.PositiveIntegerField()
110
+ title=models.CharField(max_length=200)
111
+ excerpt=models.CharField(max_length=500,blank=True)
112
+ body=models.TextField(blank=True)
113
+ cover_url=models.CharField(max_length=2000,blank=True)
114
+ section_slug=models.SlugField(blank=True)
115
+ section_name=models.CharField(max_length=100,blank=True)
116
+ tags=models.JSONField(default=list)
117
+ fingerprint=models.CharField(max_length=64)
118
+ created_by=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.SET_NULL,null=True,related_name='ordloom_cms_versions')
119
+ created_at=models.DateTimeField(auto_now_add=True)
120
+ objects=ImmutableVersions.as_manager()
121
+ class Meta:
122
+ ordering=['-number']
123
+ constraints=[models.UniqueConstraint(fields=['article','number'],name='ordloom_cms_version_number')]
124
+ def save(self,*args,**kwargs):
125
+ if not self._state.adding or kwargs.get('update_fields') is not None:raise ValidationError('内容发布版本不可修改')
126
+ kwargs['force_insert']=True;return super().save(*args,**kwargs)
127
+ def delete(self,*args,**kwargs):raise ValidationError('内容发布版本不可单独删除')
@@ -0,0 +1,64 @@
1
+ """Content publishing is explicit; public content never executes staff queries."""
2
+ import hashlib,json
3
+ from django.apps import apps
4
+ from django.conf import settings
5
+ from django.contrib import admin
6
+ from django.core.exceptions import PermissionDenied,ValidationError
7
+ from django.db import transaction
8
+ from django.db.models import Q
9
+ from .models import Site,Article,ArticleVersion
10
+
11
+
12
+ def site_scope(request):
13
+ rows=Site.objects.all()
14
+ if request.user.is_superuser:return rows
15
+ return rows.filter(Q(owner=request.user)|Q(editors=request.user)).distinct()
16
+
17
+
18
+ def article_scope(request):return Article.objects.filter(site__in=site_scope(request))
19
+
20
+
21
+ def require(request,article,action):
22
+ manager=admin.site._registry.get(Article)
23
+ if manager is None or not request.user.is_active or not (request.user.is_staff or request.user.is_superuser) or not manager.has_module_permission(request):raise PermissionDenied('无内容管理权限')
24
+ allowed=manager.has_change_permission(request,article) if action=='submit' else manager.has_publish_permission(request,article)
25
+ if not allowed or not article_scope(request).filter(pk=article.pk).exists():raise PermissionDenied('无当前文章操作权限')
26
+
27
+
28
+ def transition(request,article_id,action):
29
+ if action not in ('submit','publish','unpublish'):raise ValidationError('不支持的发布操作')
30
+ with transaction.atomic():
31
+ article=Article.objects.select_for_update().get(pk=article_id)
32
+ require(request,article,action)
33
+ if 'cms' in getattr(settings,'ORDLOOM_DISABLED_APPS',[]):raise PermissionDenied('内容应用未启用')
34
+ if action=='submit':
35
+ article.full_clean(exclude=['published_version'])
36
+ Article.objects.filter(pk=article.pk).update(state='review')
37
+ return None
38
+ if action=='unpublish':
39
+ Article.objects.filter(pk=article.pk).update(published_version=None,state='draft');return None
40
+ if article.state not in ('review','published'):raise ValidationError('请先送审当前草稿')
41
+ article.full_clean(exclude=['published_version'])
42
+ if article.tags.exclude(site=article.site).exists():raise ValidationError('标签须属于当前站点')
43
+ snapshot={'title':article.title,'excerpt':article.excerpt,'body':article.body,'cover_url':article.cover.url if article.cover else '',
44
+ 'section_slug':article.section.slug if article.section else '', 'section_name':article.section.name if article.section else '',
45
+ 'tags':list(article.tags.order_by('slug').values('slug','name'))}
46
+ digest=hashlib.sha256(json.dumps(snapshot,ensure_ascii=False,sort_keys=True).encode()).hexdigest()
47
+ latest=article.versions.first()
48
+ if latest is None or latest.fingerprint!=digest:
49
+ latest=ArticleVersion.objects.create(article=article,number=latest.number+1 if latest else 1,fingerprint=digest,created_by=request.user,**snapshot)
50
+ Article.objects.filter(pk=article.pk).update(published_version=latest,state='published')
51
+ return latest
52
+
53
+
54
+ def member_access(request):
55
+ if not apps.is_installed('ordloom_members'):return False
56
+ from ordloom_members.services import current_member
57
+ return current_member(request) is not None
58
+
59
+
60
+ def rendered_body(value):
61
+ import markdown,bleach
62
+ html=markdown.markdown(value,extensions=['fenced_code','tables'])
63
+ return bleach.clean(html,tags={'p','br','hr','h1','h2','h3','h4','h5','h6','strong','em','blockquote','ul','ol','li','pre','code','a','img','table','thead','tbody','tr','th','td'},
64
+ attributes={'a':['href','title'],'img':['src','alt','title'],'code':['class'],'th':['align'],'td':['align']},protocols={'http','https','mailto'},strip=True)
@@ -0,0 +1,14 @@
1
+ from django.db import transaction
2
+ from django.db.models import F
3
+ from django.db.models.signals import m2m_changed
4
+ from django.dispatch import receiver
5
+ from .models import Article
6
+
7
+
8
+ @receiver(m2m_changed,sender=Article.tags.through,dispatch_uid='ordloom-cms-draft-tags')
9
+ def tags_changed(sender,instance,action,reverse,pk_set,using,**kwargs):
10
+ if action not in ('pre_add','pre_remove','pre_clear'):return
11
+ ids=(list(pk_set) if pk_set is not None else list(sender.objects.using(using).filter(tag_id=instance.pk).values_list('article_id',flat=True))) if reverse else [instance.pk]
12
+ with transaction.atomic(using=using):
13
+ list(Article.objects.using(using).select_for_update().filter(pk__in=ids).order_by('pk').values_list('pk',flat=True))
14
+ Article.objects.using(using).filter(pk__in=ids).update(state='draft',draft_revision=F('draft_revision')+1)
@@ -0,0 +1,6 @@
1
+ export function activate({React,ui}){
2
+ const h=React.createElement
3
+ function Overview({data}){return h(ui.Card,{title:data.title},h('p',null,data.description),h(ui.Space,{wrap:true},h(ui.Statistic,{title:'草稿',value:data.drafts}),h(ui.Statistic,{title:'待审核',value:data.review}),h(ui.Statistic,{title:'公开版本',value:data.published})),h('p',null,h('a',{href:'#page=m%3Aordloom_cms.article'},'管理文章与发布操作')),h('div',null,...data.articles.map(a=>h('section',{key:a.id,style:{borderTop:'1px solid #e2e7f0',padding:'16px 0'}},h('strong',null,a.title),h('p',null,a.state+' · '+(a.published_version?'公开 v'+a.published_version:'尚未公开')),a.url?h('a',{href:a.url,target:'_blank',rel:'noopener noreferrer'},'查看公开页面'):null,h('details',null,h('summary',null,'最近发布历史('+a.history.length+')'),h('ul',null,...a.history.map(v=>h('li',{key:v.number},'v'+v.number+' · '+v.title+' · '+v.created_at))))))))}
4
+ function ContentLink({props:p}){const safe=typeof p.path==='string'&&/^\/(?!\/)/.test(p.path)&&!/[\\\u0000-\u0020]/.test(p.path);return h('article',{style:{padding:24,height:'100%',boxSizing:'border-box',background:p.dark?'#14243a':'#f4f5fc',color:p.dark?'#edf2fa':'#233147'}},h('h3',null,p.title),h('p',null,p.description),safe?h('a',{href:p.path,style:{color:'#6366f1'}},'前往阅读 →'):h('span',null,'请配置站内内容路径'))}
5
+ return {pages:{overview:Overview},widgets:{contentlink:ContentLink}}
6
+ }
@@ -0,0 +1 @@
1
+ {% extends 'ordloom_cms/base.html' %}{% block title %}{{ article.title }} · {{ site.name }}{% endblock %}{% block meta %}<meta name="description" content="{{ article.excerpt }}"><link rel="canonical" href="{{ canonical }}">{% endblock %}{% block content %}<article class="paper"><p class="muted">{{ article.section.name }} · <time datetime="{{ article.published_at }}">{{ article.published_at|slice:':10' }}</time></p><h1>{{ article.title }}</h1><p>{{ article.excerpt }}</p>{% if article.cover_url %}<img class="cover" src="{{ article.cover_url }}" alt="">{% endif %}<div class="body">{{ article.body_html|safe }}</div><p>{% for tag in article.tags %}<span class="tag">{{ tag.name }}</span> {% endfor %}</p></article>{% endblock %}
@@ -0,0 +1,3 @@
1
+ <!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{% block title %}{{ site.name }}{% endblock %}</title>{% block meta %}{% endblock %}<style>
2
+ *{box-sizing:border-box}body{margin:0;background:#f5f7fb;color:#243247;font:16px/1.8 system-ui,sans-serif}main{max-width:1040px;margin:0 auto;padding:32px 24px}header{border-bottom:1px solid #e0e6f0;margin-bottom:32px;padding-bottom:16px}h1{font-size:34px;line-height:1.35}a{color:#5554d6;text-decoration:none}.muted{color:#708098}article.card,.paper{background:white;border:1px solid #e2e7f0;border-radius:16px;padding:28px;margin:20px 0}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:20px}.card h2{font-size:24px;line-height:1.4}.cover{width:100%;max-height:400px;object-fit:cover;border-radius:12px}.body{overflow-wrap:anywhere}.body img{max-width:100%;height:auto}.body pre{overflow:auto;background:#f4f6fa;padding:18px;border-radius:8px}.body table{border-collapse:collapse;max-width:100%;display:block;overflow:auto}.body td,.body th{border:1px solid #d8dfeb;padding:8px}.tag{display:inline-block;border-radius:20px;padding:2px 10px;background:#eef0fc;color:#5757bd;font-size:13px}input{max-width:100%;border:1px solid #d4dce8;border-radius:8px;padding:10px;font:inherit}button{background:#5757db;color:white;border:0;border-radius:8px;padding:11px 20px;font:inherit}footer{padding:24px 0;color:#708098}@media(max-width:500px){main{padding:20px 16px}h1{font-size:28px}.paper{padding:20px}}
3
+ </style></head><body><main><header><p class="muted">ORDLOOM CMS</p><a href="{{ home_url }}">{{ site.name }}</a></header>{% block content %}{% endblock %}<footer>Ordloom · 内容服务</footer></main></body></html>
@@ -0,0 +1 @@
1
+ {% extends 'ordloom_cms/base.html' %}{% block content %}<h1>{{ site.name }}</h1><p class="muted">文章、动态与精选内容</p><form><input name="q" aria-label="搜索文章" placeholder="搜索文章" value="{{ request.GET.q }}"><button>搜索</button></form><div class="cards">{% for article in data.items %}<article class="card">{% if article.cover_url %}<img class="cover" src="{{ article.cover_url }}" alt="">{% endif %}<span class="tag">{{ article.section.name|default:'文章' }}</span><h2><a href="{{ article.url }}">{{ article.title }}</a></h2><p>{{ article.excerpt }}</p><a href="{{ article.url }}">阅读全文 →</a></article>{% empty %}<p>暂无已发布内容。</p>{% endfor %}</div><p>第 {{ data.page }} 页 · {{ data.total }} 篇</p>{% if data.has_next %}<a href="?page={{ data.page|add:1 }}&q={{ request.GET.q|urlencode }}&section={{ request.GET.section|urlencode }}">下一页 →</a>{% endif %}{% endblock %}
@@ -0,0 +1 @@
1
+ <!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>会员专享内容</title></head><body style="font:16px/1.8 system-ui;background:#f5f7fb;color:#243247"><main style="max-width:600px;margin:10vh auto;padding:32px;background:white;border-radius:16px"><h1>会员专享内容</h1><p>请先登录有效会员账号,再返回此页面阅读。</p>{% if login_url %}<a href="{{ login_url }}">前往会员中心</a>{% endif %}</main></body></html>
@@ -0,0 +1,139 @@
1
+ import copy
2
+ from django.apps import apps
3
+ from django.contrib.auth import get_user_model
4
+ from django.contrib.auth.models import Permission
5
+ from django.core.cache import cache
6
+ from django.core.exceptions import PermissionDenied,ValidationError
7
+ from django.test import TestCase,RequestFactory,override_settings,Client
8
+ from .models import Site,Section,Tag,Article,ArticleVersion
9
+ from .services import transition
10
+
11
+
12
+ class CMSTests(TestCase):
13
+ def setUp(self):
14
+ cache.clear();User=get_user_model();self.User=User
15
+ self.root=User._default_manager.create_user(**{User.USERNAME_FIELD:'cms-root@example.test'},is_staff=True,is_superuser=True)
16
+ self.editor=User._default_manager.create_user(**{User.USERNAME_FIELD:'cms-editor@example.test'},is_staff=True)
17
+ self.other=User._default_manager.create_user(**{User.USERNAME_FIELD:'cms-other@example.test'},is_staff=True)
18
+ self.editor.user_permissions.add(*Permission.objects.filter(content_type__app_label='ordloom_cms'))
19
+ self.site=Site.objects.create(name='示例站点',slug='main',owner=self.editor,public_enabled=True)
20
+ self.foreign=Site.objects.create(name='其他站点',slug='other',owner=self.other,public_enabled=True)
21
+ self.section=Section.objects.create(site=self.site,name='新闻',slug='news')
22
+ self.article=Article.objects.create(site=self.site,section=self.section,title='第一篇文章',slug='first',body='## 正文\n\n**内容**',excerpt='公开摘要',created_by=self.editor)
23
+ self.client.force_login(self.editor)
24
+ def request(self,user=None):
25
+ request=RequestFactory().post('/');request.user=user or self.editor;return request
26
+ def publish(self,article=None,user=None):
27
+ article=article or self.article;transition(self.request(user),article.pk,'submit');return transition(self.request(user),article.pk,'publish')
28
+ def public(self,slug='first',expected=200,client=None):
29
+ response=(client or self.client).get('/content/api/v1/main/articles/'+slug+'/');self.assertEqual(response.status_code,expected,response.content);return response
30
+ def test_draft_is_not_public_and_published_content_uses_immutable_snapshot(self):
31
+ self.public(expected=404);version=self.publish()
32
+ self.assertEqual(self.public().json()['data']['title'],'第一篇文章')
33
+ self.article.title='未发布的修改';self.article.body='private-draft';self.article.save()
34
+ result=self.public().json()['data'];self.assertEqual(result['title'],'第一篇文章');self.assertNotIn('private-draft',result['body'])
35
+ self.assertEqual(ArticleVersion.objects.get(pk=version.pk).title,'第一篇文章')
36
+ def test_native_admin_actions_reuse_existing_permissions_and_csrf(self):
37
+ url='/admin/api/action/ordloom_cms.article/'
38
+ response=self.client.post(url,{'name':'submit_articles','ids':[self.article.pk]},content_type='application/json')
39
+ self.assertEqual(response.status_code,200,response.content)
40
+ response=self.client.post(url,{'name':'publish_articles','ids':[self.article.pk]},content_type='application/json')
41
+ self.assertEqual(response.status_code,200,response.content);self.public()
42
+ self.client.force_login(self.other)
43
+ self.assertEqual(self.client.post(url,{'name':'unpublish_articles','ids':[self.article.pk]},content_type='application/json').status_code,403)
44
+ csrf=Client(enforce_csrf_checks=True);csrf.force_login(self.root)
45
+ self.assertEqual(csrf.post(url,{'name':'unpublish_articles','ids':[self.article.pk]},content_type='application/json').status_code,403)
46
+
47
+ def test_unchanged_save_preserves_review_and_published_pointer(self):
48
+ self.publish();self.article.refresh_from_db();revision=self.article.draft_revision
49
+ self.article.save();self.article.refresh_from_db()
50
+ self.assertEqual(self.article.state,'published');self.assertEqual(self.article.draft_revision,revision)
51
+
52
+ def test_review_is_required_and_new_edits_require_new_review(self):
53
+ with self.assertRaises(ValidationError):transition(self.request(),self.article.pk,'publish')
54
+ transition(self.request(),self.article.pk,'submit');self.article.body='changed';self.article.save()
55
+ with self.assertRaises(ValidationError):transition(self.request(),self.article.pk,'publish')
56
+ self.publish();self.assertEqual(ArticleVersion.objects.count(),1)
57
+ def test_republish_identical_content_reuses_latest_version(self):
58
+ version=self.publish();again=transition(self.request(),self.article.pk,'publish');self.assertEqual(version.pk,again.pk)
59
+ self.assertEqual(ArticleVersion.objects.count(),1)
60
+ def test_snapshot_cannot_be_mutated_and_unpublish_keeps_history(self):
61
+ version=self.publish()
62
+ with self.assertRaises(ValidationError):ArticleVersion.objects.filter(pk=version.pk).update(title='tamper')
63
+ with self.assertRaises(ValidationError):version.save()
64
+ transition(self.request(),self.article.pk,'unpublish');self.public(expected=404)
65
+ self.assertEqual(ArticleVersion.objects.count(),1)
66
+ def test_current_access_policy_and_site_switch_are_not_restored_by_content_history(self):
67
+ self.publish();self.article.refresh_from_db();self.article.access_level='members';self.article.save()
68
+ self.public(expected=403)
69
+ self.site.public_enabled=False;self.site.save();self.public(expected=404)
70
+ def test_members_only_content_requires_current_valid_customer_and_not_staff_session(self):
71
+ self.article.access_level='members';self.article.save();self.publish();self.public(expected=403)
72
+ if not apps.is_installed('ordloom_members'):return
73
+ from ordloom_members.models import Member
74
+ identity='reader@example.test';user=self.User._default_manager.create_user(**{self.User.USERNAME_FIELD:identity},password='reader-password-456')
75
+ member=Member.objects.create(user=user,display_name='读者');client=Client()
76
+ response=client.post('/members/api/v1/login/',{'identity':identity,'password':'reader-password-456'},content_type='application/json');self.assertEqual(response.status_code,200)
77
+ self.public(client=client);member.enabled=False;member.save(update_fields=['enabled']);self.public(expected=403,client=client)
78
+ def test_unpublished_and_members_content_never_leak_through_list_search_or_sitemap(self):
79
+ self.publish();private=Article.objects.create(site=self.site,title='private-secret',slug='secret',body='hidden-body',access_level='members');self.publish(private)
80
+ body=self.client.get('/content/api/v1/main/articles/').content.decode();self.assertNotIn('private-secret',body)
81
+ sitemap=self.client.get('/content/main/sitemap.xml').content.decode();self.assertNotIn('/secret/',sitemap);self.assertIn('/first/',sitemap)
82
+ self.assertEqual(self.client.get('/content/api/v1/main/articles/?q=private-secret').json()['data']['total'],0)
83
+ def test_domain_bound_sites_reject_wrong_host(self):
84
+ self.publish();self.site.domain='public.example.test';self.site.save();self.public(expected=404)
85
+ def test_site_scope_prevents_foreign_read_edit_and_publish(self):
86
+ foreign=Article.objects.create(site=self.foreign,title='别人的草稿',slug='foreign')
87
+ self.client.force_login(self.editor)
88
+ with self.assertRaises(PermissionDenied):transition(self.request(),foreign.pk,'submit')
89
+ from .admin import ArticleAdmin
90
+ from django.contrib import admin
91
+ manager=admin.site._registry[Article]
92
+ self.assertNotIn(foreign.pk,list(manager.get_queryset(self.request()).values_list('pk',flat=True)))
93
+ self.assertFalse(manager.has_change_permission(self.request(),foreign))
94
+ def test_revoked_site_editor_and_revoked_publish_permission_take_effect(self):
95
+ self.site.editors.add(self.other);self.other.user_permissions.add(*Permission.objects.filter(content_type__app_label='ordloom_cms'))
96
+ self.publish(user=self.other);self.site.editors.remove(self.other)
97
+ with self.assertRaises(PermissionDenied):transition(self.request(self.other),self.article.pk,'unpublish')
98
+ self.editor.user_permissions.remove(Permission.objects.get(content_type__app_label='ordloom_cms',codename='publish_article'))
99
+ fresh=self.User._default_manager.get(pk=self.editor.pk)
100
+ with self.assertRaises(PermissionDenied):transition(self.request(fresh),self.article.pk,'unpublish')
101
+ def test_cross_site_section_and_tags_are_rejected(self):
102
+ self.article.section=Section.objects.create(site=self.foreign,name='其他',slug='x')
103
+ with self.assertRaises(ValidationError):self.article.full_clean()
104
+ self.article.refresh_from_db();self.article.tags.add(Tag.objects.create(site=self.foreign,name='其他标签',slug='bad'))
105
+ with self.assertRaises(ValidationError):self.publish()
106
+ def test_changing_tags_invalidates_review_without_changing_public_snapshot(self):
107
+ self.publish();self.article.tags.add(Tag.objects.create(site=self.site,name='新标签',slug='new'))
108
+ self.article.refresh_from_db();self.assertEqual(self.article.state,'draft')
109
+ self.assertEqual(self.public().json()['data']['tags'],[])
110
+ with self.assertRaises(ValidationError):transition(self.request(),self.article.pk,'publish')
111
+ def test_published_article_route_is_stable_and_stale_save_does_not_remove_publication(self):
112
+ stale=Article.objects.get(pk=self.article.pk);version=self.publish();stale.body='new-draft';stale.save()
113
+ self.assertEqual(Article.objects.get(pk=stale.pk).published_version_id,version.pk)
114
+ stale.slug='renamed'
115
+ with self.assertRaises(ValidationError):stale.save()
116
+ def test_sanitized_markdown_blocks_scripts_event_attributes_and_unsafe_protocols(self):
117
+ self.article.body='<script>evil()</script>\n\n<img src="x" onerror="evil()">\n\n[link](javascript:evil)\n\n**safe**';self.article.save();self.publish()
118
+ value=self.public().json()['data']['body_html']
119
+ self.assertNotIn('<script',value);self.assertNotIn('onerror',value);self.assertNotIn('javascript:',value);self.assertIn('<strong>safe</strong>',value)
120
+ response=self.client.get('/content/main/first/');self.assertEqual(response.status_code,200);self.assertNotIn(b'<script',response.content)
121
+ def test_published_section_filter_uses_snapshot_not_new_draft_section(self):
122
+ self.publish();self.article.section=Section.objects.create(site=self.site,name='新栏目',slug='new');self.article.save()
123
+ self.assertEqual(self.client.get('/content/api/v1/main/articles/?section=new').json()['data']['total'],0)
124
+ self.assertEqual(self.client.get('/content/api/v1/main/articles/?section=news').json()['data']['total'],1)
125
+ @override_settings(ORDLOOM_DISABLED_APPS=['cms'])
126
+ def test_disabled_app_blocks_public_pages_and_model_module(self):
127
+ self.assertEqual(self.client.get('/content/main/').status_code,404)
128
+ from django.contrib import admin
129
+ self.assertFalse(admin.site._registry[Article].has_module_permission(self.request()))
130
+ def test_sdk_dashboard_and_component_are_installed_without_core_patching(self):
131
+ response=self.client.get('/admin/api/extensions/cms/pages/overview/');self.assertEqual(response.status_code,200,response.content)
132
+ self.assertEqual(response.json()['data']['drafts'],1)
133
+ apps=self.client.get('/admin/api/extensions/').json()['data']['applications']
134
+ self.assertTrue(any(a['id']=='cms' and a['widgets'][0]['type']=='app:cms.contentlink' for a in apps))
135
+ def test_public_listing_is_paginated_and_methods_are_readonly(self):
136
+ self.publish();self.assertEqual(self.client.get('/content/main/').status_code,200)
137
+ self.assertEqual(self.client.post('/content/main/',{}).status_code,405)
138
+ self.assertEqual(self.client.post('/content/api/v1/main/articles/',{}).status_code,405)
139
+ self.assertEqual(self.client.get('/content/api/v1/main/articles/?page=0').status_code,404)
@@ -0,0 +1,7 @@
1
+ from django.urls import path
2
+ from . import views
3
+ app_name='ordloom_cms'
4
+ urlpatterns=[path('api/v1/<slug:site_slug>/articles/',views.api,name='api-list'),
5
+ path('api/v1/<slug:site_slug>/articles/<slug:slug>/',views.api,name='api-article'),
6
+ path('<slug:site_slug>/sitemap.xml',views.sitemap,name='sitemap'),
7
+ path('<slug:site_slug>/',views.index,name='site'),path('<slug:site_slug>/<slug:slug>/',views.article,name='article')]
@@ -0,0 +1,98 @@
1
+ from urllib.parse import urlsplit
2
+ from django.conf import settings
3
+ from django.db.models import Q
4
+ from django.http import JsonResponse,Http404,HttpResponse
5
+ from django.shortcuts import render
6
+ from django.urls import reverse,NoReverseMatch
7
+ from django.views.decorators.cache import never_cache
8
+ from django.utils.html import escape
9
+ from .models import Site,Article
10
+ from .services import member_access,rendered_body
11
+
12
+
13
+ def enabled():return 'cms' not in getattr(settings,'ORDLOOM_DISABLED_APPS',[])
14
+
15
+
16
+ def site_for(request,slug):
17
+ if not enabled():raise Http404()
18
+ site=Site.objects.filter(slug=slug,public_enabled=True).first()
19
+ if site is None or site.domain and site.domain.lower()!=urlsplit('//'+request.get_host()).hostname.lower():raise Http404()
20
+ return site
21
+
22
+
23
+ def public_rows(request,site):
24
+ rows=Article.objects.filter(site=site,published_version__isnull=False).select_related('published_version','site')
25
+ if not member_access(request):rows=rows.filter(access_level='public')
26
+ return rows.order_by('-published_version__created_at','-pk')
27
+
28
+
29
+ def path_for(request,name,**kwargs):
30
+ namespace=request.resolver_match.namespace or 'ordloom_cms'
31
+ return reverse(namespace+':'+name,kwargs=kwargs)
32
+
33
+
34
+ def item(request,row,body=False):
35
+ version=row.published_version
36
+ value={'slug':row.slug,'title':version.title,'excerpt':version.excerpt,'cover_url':version.cover_url,
37
+ 'section':{'slug':version.section_slug,'name':version.section_name},'tags':version.tags,
38
+ 'version':version.number,'published_at':version.created_at.isoformat(),
39
+ 'url':path_for(request,'article',site_slug=row.site.slug,slug=row.slug)}
40
+ if body:value.update(body=version.body,body_html=rendered_body(version.body))
41
+ return value
42
+
43
+
44
+ def listing(request,site):
45
+ try:page=int(request.GET.get('page','1'));assert 1<=page<=10000
46
+ except (ValueError,AssertionError):raise Http404()
47
+ query=request.GET.get('q','')
48
+ if len(query)>100:raise Http404()
49
+ rows=public_rows(request,site)
50
+ if query:rows=rows.filter(Q(published_version__title__icontains=query)|Q(published_version__excerpt__icontains=query))
51
+ section=request.GET.get('section','')
52
+ if len(section)>50:raise Http404()
53
+ if section:rows=rows.filter(published_version__section_slug=section)
54
+ total=rows.count();items=[item(request,row) for row in rows[(page-1)*20:page*20]]
55
+ return {'site':{'name':site.name,'slug':site.slug},'items':items,'page':page,'total':total,'has_next':page*20<total}
56
+
57
+
58
+ @never_cache
59
+ def api(request,site_slug,slug=None):
60
+ if request.method!='GET':return JsonResponse({'ok':False,'message':'仅支持 GET'},status=405)
61
+ site=site_for(request,site_slug)
62
+ if slug is None:return JsonResponse({'ok':True,'data':listing(request,site)})
63
+ row=Article.objects.select_related('published_version','site').filter(site=site,slug=slug,published_version__isnull=False).first()
64
+ if row is None:raise Http404()
65
+ if row.access_level=='members' and not member_access(request):return JsonResponse({'ok':False,'message':'请先登录有效会员账号'},status=403)
66
+ return JsonResponse({'ok':True,'data':item(request,row,body=True)})
67
+
68
+
69
+ @never_cache
70
+ def index(request,site_slug):
71
+ if request.method!='GET':return HttpResponse(status=405)
72
+ site=site_for(request,site_slug);data=listing(request,site)
73
+ return render(request,'ordloom_cms/index.html',{'site':site,'data':data,'home_url':path_for(request,'site',site_slug=site.slug)})
74
+
75
+
76
+ @never_cache
77
+ def article(request,site_slug,slug):
78
+ if request.method!='GET':return HttpResponse(status=405)
79
+ site=site_for(request,site_slug)
80
+ row=Article.objects.select_related('published_version','site').filter(site=site,slug=slug,published_version__isnull=False).first()
81
+ if row is None:raise Http404()
82
+ if row.access_level=='members' and not member_access(request):
83
+ try:login_url=reverse('ordloom_members:portal')
84
+ except NoReverseMatch:login_url=None
85
+ return render(request,'ordloom_cms/locked.html',{'login_url':login_url},status=403)
86
+ data=item(request,row,body=True)
87
+ return render(request,'ordloom_cms/article.html',{'site':site,'article':data,'canonical':request.build_absolute_uri(data['url']),'home_url':path_for(request,'site',site_slug=site.slug)})
88
+
89
+
90
+ @never_cache
91
+ def sitemap(request,site_slug):
92
+ if request.method!='GET':return HttpResponse(status=405)
93
+ site=site_for(request,site_slug)
94
+ # Never list member-only URLs, including while an editor is logged in.
95
+ rows=Article.objects.filter(site=site,published_version__isnull=False,access_level='public').order_by('pk')[:10001]
96
+ if len(rows)>10000:return HttpResponse('站点地图超过本版上限',status=413)
97
+ urls=''.join('<url><loc>'+str(escape(request.build_absolute_uri(path_for(request,'article',site_slug=site.slug,slug=row.slug))))+'</loc></url>' for row in rows)
98
+ return HttpResponse('<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'+urls+'</urlset>',content_type='application/xml')
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.4
2
+ Name: ordloom-cms
3
+ Version: 0.1.0
4
+ Summary: Ordloom 内容管理子应用
5
+ Project-URL: Homepage, https://neekin.github.io/ordloom-docs/
6
+ Project-URL: Documentation, https://neekin.github.io/ordloom-docs/guides/cms-and-members.html
7
+ Project-URL: Repository, https://github.com/neekin/simpleui-antd
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: ordloom<2,>=1.1.0
11
+ Requires-Dist: Markdown<4,>=3.7
12
+ Requires-Dist: bleach<7,>=6.2
13
+
14
+ # ordloom-cms
15
+
16
+ Ordloom 子应用 SDK 的内容管理 MVP:站点、栏目、标签、Markdown 文章、封面、送审/发布/下线、不可变发布快照、公开 HTML/JSON、搜索/分页、Sitemap,以及可选会员内容。
17
+
18
+ 版本 0.1.0,依赖 Ordloom 1.1.0+ 的 SDK v1。独立安装:
19
+
20
+ ```bash
21
+ python -m pip install ordloom-cms==0.1.0
22
+ ```
23
+
24
+ 注册 `ordloom_cms.apps.ExtensionConfig`,挂载 `include("ordloom_cms.urls")`,执行 `migrate` 与 `collectstatic`。站点默认不公开。管理沿用 Django 模型权限,并限制负责人/编辑成员的站点范围;公开发布另需 `publish_article` / `publish_site`。不安装会员模块时,会员内容保持关闭。
25
+
26
+ 完整接入与边界见 [CMS 与会员指南](https://neekin.github.io/ordloom-docs/guides/cms-and-members.html)。
@@ -0,0 +1,24 @@
1
+ README.md
2
+ pyproject.toml
3
+ ordloom_cms/__init__.py
4
+ ordloom_cms/admin.py
5
+ ordloom_cms/apps.py
6
+ ordloom_cms/extension.py
7
+ ordloom_cms/models.py
8
+ ordloom_cms/services.py
9
+ ordloom_cms/signals.py
10
+ ordloom_cms/tests.py
11
+ ordloom_cms/urls.py
12
+ ordloom_cms/views.py
13
+ ordloom_cms.egg-info/PKG-INFO
14
+ ordloom_cms.egg-info/SOURCES.txt
15
+ ordloom_cms.egg-info/dependency_links.txt
16
+ ordloom_cms.egg-info/requires.txt
17
+ ordloom_cms.egg-info/top_level.txt
18
+ ordloom_cms/migrations/0001_initial.py
19
+ ordloom_cms/migrations/__init__.py
20
+ ordloom_cms/static/ordloom_cms/extension.js
21
+ ordloom_cms/templates/ordloom_cms/article.html
22
+ ordloom_cms/templates/ordloom_cms/base.html
23
+ ordloom_cms/templates/ordloom_cms/index.html
24
+ ordloom_cms/templates/ordloom_cms/locked.html
@@ -0,0 +1,3 @@
1
+ ordloom<2,>=1.1.0
2
+ Markdown<4,>=3.7
3
+ bleach<7,>=6.2
@@ -0,0 +1 @@
1
+ ordloom_cms
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ordloom-cms"
7
+ version = "0.1.0"
8
+ description = "Ordloom 内容管理子应用"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ dependencies = ["ordloom>=1.1.0,<2", "Markdown>=3.7,<4", "bleach>=6.2,<7"]
12
+
13
+ [project.urls]
14
+ Homepage = "https://neekin.github.io/ordloom-docs/"
15
+ Documentation = "https://neekin.github.io/ordloom-docs/guides/cms-and-members.html"
16
+ Repository = "https://github.com/neekin/simpleui-antd"
17
+
18
+ [tool.setuptools.packages.find]
19
+ include = ["ordloom_cms*"]
20
+
21
+ [tool.setuptools.package-data]
22
+ ordloom_cms = ["static/ordloom_cms/*.js", "templates/ordloom_cms/*.html"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+