picata 0.0.10__py3-none-any.whl → 0.0.12__py3-none-any.whl
Sign up to get free protection for your applications and to get access to all the features.
- manage.py +4 -0
- node_modules/@csstools/postcss-initial/README.md +2 -2
- node_modules/@eslint/config-array/README.md +3 -3
- node_modules/@eslint/core/README.md +2 -2
- node_modules/@eslint/object-schema/README.md +3 -3
- node_modules/@eslint/plugin-kit/node_modules/@eslint/core/README.md +29 -0
- node_modules/@types/node/README.md +1 -1
- node_modules/enhanced-resolve/README.md +1 -1
- node_modules/eslint/README.md +12 -11
- node_modules/possible-typed-array-names/README.md +4 -2
- node_modules/prettier/README.md +6 -7
- node_modules/terser/README.md +2 -5
- node_modules/webpack/README.md +341 -392
- picata/helpers/__init__.py +1 -0
- picata/helpers/wagtail.py +9 -8
- picata/migrations/0001_initial.py +5 -5
- picata/migrations/0002_postseries.py +27 -0
- picata/migrations/0003_postseries_summary_alter_postseries_introduction.py +24 -0
- picata/models.py +208 -60
- picata/transformers.py +2 -1
- picata/typing/__init__.py +4 -0
- picata/views.py +2 -2
- {picata-0.0.10.dist-info → picata-0.0.12.dist-info}/METADATA +1 -1
- {picata-0.0.10.dist-info → picata-0.0.12.dist-info}/RECORD +26 -26
- node_modules/ajv-keywords/README.md +0 -836
- node_modules/ajv-keywords/keywords/dotjs/README.md +0 -3
- node_modules/webpack/node_modules/schema-utils/README.md +0 -290
- {picata-0.0.10.dist-info → picata-0.0.12.dist-info}/WHEEL +0 -0
- {picata-0.0.10.dist-info → picata-0.0.12.dist-info}/licenses/LICENSE.md +0 -0
picata/helpers/__init__.py
CHANGED
picata/helpers/wagtail.py
CHANGED
@@ -1,24 +1,25 @@
|
|
1
1
|
"""Generic helper-functions."""
|
2
|
+
|
2
3
|
# NB: Django's meta-class shenanigans over-complicate type hinting when QuerySets get involved.
|
3
4
|
# pyright: reportAttributeAccessIssue=false
|
4
5
|
|
5
|
-
from typing import cast
|
6
|
+
from typing import Any, cast
|
6
7
|
|
7
|
-
from django.http import HttpRequest
|
8
8
|
from wagtail.models import Page
|
9
9
|
from wagtail.query import PageQuerySet
|
10
10
|
|
11
11
|
from picata.models import TaggedPage
|
12
|
+
from picata.typing import UserOrNot
|
12
13
|
|
13
14
|
from . import get_models_of_type
|
14
15
|
|
15
16
|
TAGGED_PAGE_TYPES = get_models_of_type(TaggedPage)
|
16
17
|
|
17
18
|
|
18
|
-
def visible_pages_qs(
|
19
|
+
def visible_pages_qs(user: UserOrNot = None, page_qs: PageQuerySet | None = None) -> PageQuerySet:
|
19
20
|
"""Return a QuerySet of all pages derived from `Page` visible to the user."""
|
20
|
-
pages = cast(PageQuerySet, Page.objects.all())
|
21
|
-
if not
|
21
|
+
pages = page_qs if page_qs else cast(PageQuerySet, Page.objects.all())
|
22
|
+
if not user or not user.is_authenticated:
|
22
23
|
pages = pages.live()
|
23
24
|
return pages
|
24
25
|
|
@@ -53,9 +54,9 @@ def filter_pages_by_type(pages: list[Page], page_type_slugs: set[str]) -> list[P
|
|
53
54
|
return filtered_pages
|
54
55
|
|
55
56
|
|
56
|
-
def page_preview_data(page: Page,
|
57
|
+
def page_preview_data(page: Page, user: UserOrNot) -> dict[str, Any]:
|
57
58
|
"""Return a dictionary of available publication and preview data for a page."""
|
58
|
-
page_data =
|
59
|
+
page_data = page.get_preview_fields(user) if hasattr(page, "get_preview_fields") else {}
|
59
60
|
if hasattr(page, "get_publication_data"):
|
60
|
-
page_data.update(page.get_publication_data(
|
61
|
+
page_data.update(page.get_publication_data())
|
61
62
|
return page_data
|
@@ -1,4 +1,4 @@
|
|
1
|
-
# Generated by Django 5.1.5 on 2025-01
|
1
|
+
# Generated by Django 5.1.5 on 2025-02-01 01:16
|
2
2
|
|
3
3
|
import django.db.models.deletion
|
4
4
|
import modelcluster.contrib.taggit
|
@@ -32,7 +32,7 @@ class Migration(migrations.Migration):
|
|
32
32
|
name='BasicPage',
|
33
33
|
fields=[
|
34
34
|
('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')),
|
35
|
-
('content', wagtail.fields.StreamField([('rich_text', 0), ('code', 3), ('image', 4)], blank=True, block_lookup={0: ('wagtail.blocks.RichTextBlock', (), {}), 1: ('wagtail.blocks.TextBlock', (), {'help_text': None, 'required': True}), 2: ('wagtail.blocks.ChoiceBlock', [], {'choices': [('
|
35
|
+
('content', wagtail.fields.StreamField([('rich_text', 0), ('code', 3), ('image', 4)], blank=True, block_lookup={0: ('wagtail.blocks.RichTextBlock', (), {}), 1: ('wagtail.blocks.TextBlock', (), {'help_text': None, 'required': True}), 2: ('wagtail.blocks.ChoiceBlock', [], {'choices': [('plaintext', 'Plain Text'), ('bash', 'Bash'), ('css', 'CSS'), ('javascript', 'JavaScript'), ('html', 'HTML'), ('python', 'Python'), ('toml', 'TOML'), ('yaml', 'YAML')], 'required': False}), 3: ('wagtail.blocks.StructBlock', [[('code', 1), ('language', 2)]], {}), 4: ('wagtail.images.blocks.ImageChooserBlock', (), {})}, help_text='Main content for the page.')),
|
36
36
|
],
|
37
37
|
options={
|
38
38
|
'abstract': False,
|
@@ -78,7 +78,7 @@ class Migration(migrations.Migration):
|
|
78
78
|
name='SplitViewPage',
|
79
79
|
fields=[
|
80
80
|
('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')),
|
81
|
-
('content', wagtail.fields.StreamField([('rich_text', 0), ('code', 3), ('image', 4), ('icon_link_lists', 14)], blank=True, block_lookup={0: ('wagtail.blocks.RichTextBlock', (), {}), 1: ('wagtail.blocks.TextBlock', (), {'help_text': None, 'required': True}), 2: ('wagtail.blocks.ChoiceBlock', [], {'choices': [('
|
81
|
+
('content', wagtail.fields.StreamField([('rich_text', 0), ('code', 3), ('image', 4), ('icon_link_lists', 14)], blank=True, block_lookup={0: ('wagtail.blocks.RichTextBlock', (), {}), 1: ('wagtail.blocks.TextBlock', (), {'help_text': None, 'required': True}), 2: ('wagtail.blocks.ChoiceBlock', [], {'choices': [('plaintext', 'Plain Text'), ('bash', 'Bash'), ('css', 'CSS'), ('javascript', 'JavaScript'), ('html', 'HTML'), ('python', 'Python'), ('toml', 'TOML'), ('yaml', 'YAML')], 'required': False}), 3: ('wagtail.blocks.StructBlock', [[('code', 1), ('language', 2)]], {}), 4: ('picata.blocks.WrappedImageChooserBlock', (), {}), 5: ('wagtail.blocks.CharBlock', (), {'help_text': 'Optional heading for this list (e.g., Social Links).', 'required': False}), 6: ('wagtail.blocks.IntegerBlock', (), {'default': 2, 'help_text': 'Heading level for the list (1-6).', 'max_value': 6, 'min_value': 1, 'required': False}), 7: ('picata.blocks.HREFBlock', (), {'help_text': 'An optional link field', 'max_length': 255, 'required': False}), 8: ('wagtail.blocks.CharBlock', (), {'help_text': 'The title for the list item.', 'max_length': 50, 'required': True}), 9: ('wagtail.blocks.CharBlock', (), {'help_text': 'Id of the icon in the static/icons.svg file.', 'max_length': 255, 'required': False}), 10: ('wagtail.blocks.StructBlock', [[('href', 7), ('label', 8), ('icon', 9)]], {}), 11: ('wagtail.blocks.ListBlock', (10,), {'help_text': 'The list of items.'}), 12: ('wagtail.blocks.StructBlock', [[('heading', 5), ('heading_level', 6), ('items', 11)]], {}), 13: ('wagtail.blocks.StreamBlock', [[('link_list', 12)]], {'help_text': 'Add one or more heading-and-link-list blocks.', 'required': False}), 14: ('wagtail.blocks.StructBlock', [[('lists', 13)]], {})}, help_text='Main content for the split-view page.')),
|
82
82
|
],
|
83
83
|
options={
|
84
84
|
'verbose_name': 'split-view page',
|
@@ -90,9 +90,9 @@ class Migration(migrations.Migration):
|
|
90
90
|
name='Article',
|
91
91
|
fields=[
|
92
92
|
('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')),
|
93
|
-
('tagline', models.CharField(blank=True, help_text='A short tagline for the article.')),
|
93
|
+
('tagline', models.CharField(blank=True, help_text='A short tagline for the article.', max_length=255)),
|
94
94
|
('summary', wagtail.fields.RichTextField(blank=True, help_text='A summary to be displayed in previews.')),
|
95
|
-
('content', wagtail.fields.StreamField([('rich_text', 0), ('code', 3), ('image', 4)], blank=True, block_lookup={0: ('wagtail.blocks.RichTextBlock', (), {}), 1: ('wagtail.blocks.TextBlock', (), {'help_text': None, 'required': True}), 2: ('wagtail.blocks.ChoiceBlock', [], {'choices': [('
|
95
|
+
('content', wagtail.fields.StreamField([('rich_text', 0), ('code', 3), ('image', 4)], blank=True, block_lookup={0: ('wagtail.blocks.RichTextBlock', (), {}), 1: ('wagtail.blocks.TextBlock', (), {'help_text': None, 'required': True}), 2: ('wagtail.blocks.ChoiceBlock', [], {'choices': [('plaintext', 'Plain Text'), ('bash', 'Bash'), ('css', 'CSS'), ('javascript', 'JavaScript'), ('html', 'HTML'), ('python', 'Python'), ('toml', 'TOML'), ('yaml', 'YAML')], 'required': False}), 3: ('wagtail.blocks.StructBlock', [[('code', 1), ('language', 2)]], {}), 4: ('wagtail.images.blocks.ImageChooserBlock', (), {})}, help_text='Main content for the article.')),
|
96
96
|
('page_type', models.ForeignKey(blank=True, help_text='Select the type of article.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='articles', to='picata.articletype')),
|
97
97
|
],
|
98
98
|
options={
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# Generated by Django 5.1.5 on 2025-02-05 01:16
|
2
|
+
|
3
|
+
import django.db.models.deletion
|
4
|
+
import wagtail.fields
|
5
|
+
from django.db import migrations, models
|
6
|
+
|
7
|
+
|
8
|
+
class Migration(migrations.Migration):
|
9
|
+
|
10
|
+
dependencies = [
|
11
|
+
('picata', '0001_initial'),
|
12
|
+
('wagtailcore', '0094_alter_page_locale'),
|
13
|
+
]
|
14
|
+
|
15
|
+
operations = [
|
16
|
+
migrations.CreateModel(
|
17
|
+
name='PostSeries',
|
18
|
+
fields=[
|
19
|
+
('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')),
|
20
|
+
('introduction', wagtail.fields.StreamField([('rich_text', 0), ('image', 1)], blank=True, block_lookup={0: ('wagtail.blocks.RichTextBlock', (), {}), 1: ('picata.blocks.WrappedImageChooserBlock', (), {})})),
|
21
|
+
],
|
22
|
+
options={
|
23
|
+
'abstract': False,
|
24
|
+
},
|
25
|
+
bases=('wagtailcore.page',),
|
26
|
+
),
|
27
|
+
]
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# Generated by Django 5.1.5 on 2025-02-11 02:39
|
2
|
+
|
3
|
+
import wagtail.fields
|
4
|
+
from django.db import migrations
|
5
|
+
|
6
|
+
|
7
|
+
class Migration(migrations.Migration):
|
8
|
+
|
9
|
+
dependencies = [
|
10
|
+
('picata', '0002_postseries'),
|
11
|
+
]
|
12
|
+
|
13
|
+
operations = [
|
14
|
+
migrations.AddField(
|
15
|
+
model_name='postseries',
|
16
|
+
name='summary',
|
17
|
+
field=wagtail.fields.RichTextField(blank=True, help_text='A summary to be displayed in previews.'),
|
18
|
+
),
|
19
|
+
migrations.AlterField(
|
20
|
+
model_name='postseries',
|
21
|
+
name='introduction',
|
22
|
+
field=wagtail.fields.StreamField([('rich_text', 0), ('code', 3), ('image', 4)], blank=True, block_lookup={0: ('wagtail.blocks.RichTextBlock', (), {}), 1: ('wagtail.blocks.TextBlock', (), {'help_text': None, 'required': True}), 2: ('wagtail.blocks.ChoiceBlock', [], {'choices': [('plaintext', 'Plain Text'), ('bash', 'Bash'), ('css', 'CSS'), ('javascript', 'JavaScript'), ('html', 'HTML'), ('python', 'Python'), ('toml', 'TOML'), ('yaml', 'YAML')], 'required': False}), 3: ('wagtail.blocks.StructBlock', [[('code', 1), ('language', 2)]], {}), 4: ('picata.blocks.WrappedImageChooserBlock', (), {})}, help_text='Content to introduce the series of articles.'),
|
23
|
+
),
|
24
|
+
]
|
picata/models.py
CHANGED
@@ -4,10 +4,11 @@
|
|
4
4
|
# pyright: reportAttributeAccessIssue=false
|
5
5
|
|
6
6
|
from collections import OrderedDict
|
7
|
-
from datetime import timedelta
|
8
|
-
from typing import Any, ClassVar, cast
|
7
|
+
from datetime import datetime, timedelta
|
8
|
+
from typing import Any, ClassVar, TypedDict, cast
|
9
9
|
|
10
|
-
from django.
|
10
|
+
from django.apps import apps
|
11
|
+
from django.contrib.auth.models import AbstractUser
|
11
12
|
from django.db.models import (
|
12
13
|
CASCADE,
|
13
14
|
SET_NULL,
|
@@ -17,15 +18,15 @@ from django.db.models import (
|
|
17
18
|
SlugField,
|
18
19
|
TextField,
|
19
20
|
)
|
20
|
-
from django.db.models.functions import Coalesce
|
21
|
+
from django.db.models.functions import Coalesce
|
21
22
|
from django.http import HttpRequest
|
22
23
|
from django.urls import reverse
|
24
|
+
from django.utils.timezone import now
|
23
25
|
from modelcluster.contrib.taggit import ClusterTaggableManager
|
24
26
|
from modelcluster.fields import ParentalKey
|
25
27
|
from taggit.models import TagBase, TaggedItemBase
|
26
28
|
from wagtail.admin.panels import FieldPanel, Panel
|
27
29
|
from wagtail.blocks import RichTextBlock
|
28
|
-
from wagtail.contrib.routable_page.models import RoutablePageMixin
|
29
30
|
from wagtail.contrib.settings.models import BaseSiteSetting, register_setting
|
30
31
|
from wagtail.fields import RichTextField, StreamField
|
31
32
|
from wagtail.images.blocks import ImageChooserBlock
|
@@ -35,7 +36,7 @@ from wagtail.query import PageQuerySet
|
|
35
36
|
from wagtail.search import index
|
36
37
|
from wagtail_modeladmin.options import ModelAdmin
|
37
38
|
|
38
|
-
from picata.typing import Args, Kwargs
|
39
|
+
from picata.typing import Args, Kwargs, UserOrNot
|
39
40
|
from picata.typing.wagtail import PageContext
|
40
41
|
|
41
42
|
from .blocks import (
|
@@ -45,6 +46,53 @@ from .blocks import (
|
|
45
46
|
)
|
46
47
|
|
47
48
|
|
49
|
+
class ChronoPageQuerySet(PageQuerySet):
|
50
|
+
"""QuerySet for pages that can be ordered based on dates."""
|
51
|
+
|
52
|
+
def with_effective_date(self) -> "ChronoPageQuerySet":
|
53
|
+
"""Annotate pages with 'effective_date' to allow date-based ordering."""
|
54
|
+
return self.annotate(
|
55
|
+
effective_date=Coalesce("last_published_at", "latest_revision_created_at", now())
|
56
|
+
)
|
57
|
+
|
58
|
+
def by_date(self) -> "ChronoPageQuerySet":
|
59
|
+
"""Return all pages ordered by descending 'effective_date'."""
|
60
|
+
return self.with_effective_date().order_by("-effective_date")
|
61
|
+
|
62
|
+
def live_for_user(self, user: UserOrNot = None) -> "ChronoPageQuerySet":
|
63
|
+
"""Filter out non-live pages for non-authenticated users."""
|
64
|
+
return self if user and user.is_authenticated else self.live()
|
65
|
+
|
66
|
+
def descendants_of_page(self, page: Page) -> "ChronoPageQuerySet":
|
67
|
+
"""Return all Article and PostSeries pages under a given page."""
|
68
|
+
from picata.models import Article, PostSeries # Avoid circular imports
|
69
|
+
|
70
|
+
qs = Page.objects.descendant_of(page).type(Article, PostSeries) # ✅ Correct approach!
|
71
|
+
return qs.specific() # Ensures the QuerySet remains correctly typed.
|
72
|
+
|
73
|
+
|
74
|
+
class ChronoPageManager(PageManager.from_queryset(ChronoPageQuerySet)): # type: ignore[misc]
|
75
|
+
"""Custom manager to ensure QuerySet methods are always available."""
|
76
|
+
|
77
|
+
|
78
|
+
class CorePublicationData(TypedDict):
|
79
|
+
"""Guaranteed keys for publication data on a page derived from `BasePage`."""
|
80
|
+
|
81
|
+
live: bool
|
82
|
+
url: str | None
|
83
|
+
published: str | None
|
84
|
+
updated: str | None
|
85
|
+
year: int
|
86
|
+
list_date: datetime
|
87
|
+
|
88
|
+
|
89
|
+
class BasePublicationData(CorePublicationData, total=False):
|
90
|
+
"""Publication data that may be included on pages derived from `BasePage`."""
|
91
|
+
|
92
|
+
latest_draft: str
|
93
|
+
draft_url: str
|
94
|
+
|
95
|
+
|
48
96
|
class BasePageContext(PageContext, total=False):
|
49
97
|
"""Return-type for an `Article`'s context dictionary."""
|
50
98
|
|
@@ -57,10 +105,11 @@ class BasePageContext(PageContext, total=False):
|
|
57
105
|
|
58
106
|
|
59
107
|
class BasePage(Page):
|
60
|
-
"""
|
108
|
+
"""Base for all Picata pages, offering publication and preview data methods."""
|
61
109
|
|
62
|
-
|
63
|
-
|
110
|
+
objects = ChronoPageManager()
|
111
|
+
|
112
|
+
def get_preview_fields(self, user: UserOrNot = None) -> dict[str, Any]:
|
64
113
|
"""Return a dictionary of data used in previewing this page type."""
|
65
114
|
return {
|
66
115
|
"title": self.seo_title or self.title,
|
@@ -70,9 +119,15 @@ class BasePage(Page):
|
|
70
119
|
def get_publication_data(self, request: HttpRequest | None = None) -> dict[str, str]:
|
71
120
|
"""Helper method to calculate and format relevant dates for previews."""
|
72
121
|
site = self.get_site()
|
73
|
-
last_edited =
|
74
|
-
|
122
|
+
last_edited = (
|
123
|
+
self.latest_revision.created_at if self.latest_revision else self.last_published_at
|
124
|
+
)
|
75
125
|
published, updated = self.first_published_at, self.last_published_at
|
126
|
+
year = (
|
127
|
+
self.first_published_at.year
|
128
|
+
if self.first_published_at
|
129
|
+
else (last_edited.year if last_edited else now().year)
|
130
|
+
)
|
76
131
|
|
77
132
|
# Convert datetime objects to strings like "3 Jan, '25", or False, and
|
78
133
|
# give a grace-period of one week for edits before marking the post as "updated"
|
@@ -84,16 +139,19 @@ class BasePage(Page):
|
|
84
139
|
)
|
85
140
|
|
86
141
|
data = {
|
87
|
-
"
|
142
|
+
"live": self.live,
|
88
143
|
"url": self.relative_url(site),
|
89
144
|
"published": published_str,
|
90
145
|
"updated": updated_str,
|
146
|
+
"year": year,
|
147
|
+
"list_date": published if published else last_edited if last_edited else now(),
|
91
148
|
}
|
92
149
|
|
93
150
|
# Add last draft date & preview URL if there's an unpublished draft, for logged-in users
|
94
151
|
if (
|
95
152
|
(request and request.user.is_authenticated)
|
96
153
|
and (not published or (updated and last_edited > updated))
|
154
|
+
and last_edited
|
97
155
|
and hasattr(self, "id")
|
98
156
|
):
|
99
157
|
data.update(
|
@@ -110,7 +168,7 @@ class BasePage(Page):
|
|
110
168
|
from picata.helpers.wagtail import page_preview_data
|
111
169
|
|
112
170
|
context = super().get_context(request, *args, **kwargs)
|
113
|
-
context.update(page_preview_data(self, request))
|
171
|
+
context.update(page_preview_data(self, request.user))
|
114
172
|
return cast(BasePageContext, {**context})
|
115
173
|
|
116
174
|
class Meta:
|
@@ -152,9 +210,9 @@ class TaggedPage(BasePage):
|
|
152
210
|
help_text="Tags for the article.",
|
153
211
|
)
|
154
212
|
|
155
|
-
|
156
|
-
*BasePage.content_panels,
|
213
|
+
promote_panels: ClassVar[list[Panel]] = [
|
157
214
|
FieldPanel("tags"),
|
215
|
+
*BasePage.promote_panels,
|
158
216
|
]
|
159
217
|
|
160
218
|
class Meta:
|
@@ -263,37 +321,41 @@ class ArticleTypeAdmin(ModelAdmin):
|
|
263
321
|
search_fields = ("name", "slug") # Fields to include in the search bar
|
264
322
|
|
265
323
|
|
266
|
-
class
|
267
|
-
"""
|
324
|
+
class SeriesPostMixinContext(TypedDict, total=False):
|
325
|
+
"""Potential context data for page including `SeriesPostMixin`."""
|
268
326
|
|
269
|
-
|
327
|
+
series: dict[str, Any]
|
270
328
|
|
271
329
|
|
272
|
-
class
|
273
|
-
"""
|
330
|
+
class SeriesPostMixin:
|
331
|
+
"""Mixin for articles that belong to a PostSeries."""
|
274
332
|
|
275
|
-
def
|
276
|
-
|
277
|
-
|
278
|
-
|
279
|
-
)
|
333
|
+
def get_context(
|
334
|
+
self, request: HttpRequest, *args: Args, **kwargs: Kwargs
|
335
|
+
) -> SeriesPostMixinContext:
|
336
|
+
"""Return context about the series this page belongs to (if any)."""
|
337
|
+
context = cast(Page, super()).get_context(request, *args, **kwargs)
|
338
|
+
parent = context["page"].get_parent()
|
339
|
+
PostSeries = apps.get_model("picata", "PostSeries")
|
340
|
+
if isinstance(parent, PostSeries):
|
341
|
+
context["series"] = parent.get_preview_fields(request.user, relative_to=self)
|
342
|
+
return cast(SeriesPostMixinContext, context)
|
280
343
|
|
281
|
-
def by_date(self) -> PageQuerySet:
|
282
|
-
"""Return all `Article` pages, ordered by decending "effective" date."""
|
283
|
-
return self.with_effective_date().order_by("-effective_date")
|
284
344
|
|
285
|
-
|
286
|
-
|
287
|
-
|
345
|
+
class ArticleContext(SeriesPostMixinContext, BasePageContext):
|
346
|
+
"""Return-type for an `Article`'s context dictionary."""
|
347
|
+
|
348
|
+
content: str
|
288
349
|
|
289
350
|
|
290
|
-
class Article(TaggedPage):
|
351
|
+
class Article(SeriesPostMixin, TaggedPage):
|
291
352
|
"""Class for article-like pages."""
|
292
353
|
|
293
354
|
template = "picata/article.html"
|
294
|
-
objects = PageManager.from_queryset(ArticleQuerySet)()
|
295
355
|
|
296
|
-
tagline: CharField = CharField(
|
356
|
+
tagline: CharField = CharField(
|
357
|
+
blank=True, help_text="A short tagline for the article.", max_length=255
|
358
|
+
)
|
297
359
|
summary = RichTextField(blank=True, help_text="A summary to be displayed in previews.")
|
298
360
|
content = StreamField(
|
299
361
|
[
|
@@ -315,12 +377,16 @@ class Article(TaggedPage):
|
|
315
377
|
help_text="Select the type of article.",
|
316
378
|
)
|
317
379
|
|
380
|
+
promote_panels: ClassVar[list[Panel]] = [
|
381
|
+
FieldPanel("summary"),
|
382
|
+
FieldPanel("page_type"),
|
383
|
+
*TaggedPage.promote_panels,
|
384
|
+
]
|
385
|
+
|
318
386
|
content_panels: ClassVar[list[Panel]] = [
|
319
387
|
*TaggedPage.content_panels,
|
320
388
|
FieldPanel("tagline"),
|
321
|
-
FieldPanel("summary"),
|
322
389
|
FieldPanel("content"),
|
323
|
-
FieldPanel("page_type"),
|
324
390
|
]
|
325
391
|
|
326
392
|
search_fields: ClassVar[list[index.SearchField]] = [
|
@@ -332,11 +398,10 @@ class Article(TaggedPage):
|
|
332
398
|
index.SearchField("page_type"),
|
333
399
|
]
|
334
400
|
|
335
|
-
|
336
|
-
def preview_data(self) -> dict[str, Any]:
|
401
|
+
def get_preview_fields(self, user: UserOrNot = None) -> dict[str, Any]:
|
337
402
|
"""Return data required to render a preview of this article."""
|
338
403
|
return {
|
339
|
-
**super().
|
404
|
+
**super().get_preview_fields(user),
|
340
405
|
"tagline": self.tagline,
|
341
406
|
"summary": self.summary,
|
342
407
|
"page_type": self.page_type,
|
@@ -350,17 +415,17 @@ class Article(TaggedPage):
|
|
350
415
|
return cast(ArticleContext, context)
|
351
416
|
|
352
417
|
|
353
|
-
class
|
418
|
+
class PostGroupPageContext(BasePageContext):
|
354
419
|
"""Return-type for a `PostGroupPage`'s context dictionary."""
|
355
420
|
|
356
|
-
|
421
|
+
posts_by_year: OrderedDict[int, list[dict[str, str]]]
|
357
422
|
|
358
423
|
|
359
|
-
class PostGroupPage(
|
424
|
+
class PostGroupPage(BasePage):
|
360
425
|
"""A top-level page for grouping various types of posts or articles."""
|
361
426
|
|
362
427
|
template = "picata/post_listing.html"
|
363
|
-
subpage_types: ClassVar[list[str]] = ["picata.Article"]
|
428
|
+
subpage_types: ClassVar[list[str]] = ["picata.Article", "picata.PostSeries"]
|
364
429
|
|
365
430
|
intro = RichTextField(blank=True, help_text="An optional introduction to this group.")
|
366
431
|
|
@@ -368,30 +433,30 @@ class PostGroupPage(RoutablePageMixin, Page):
|
|
368
433
|
|
369
434
|
def get_context(
|
370
435
|
self, request: HttpRequest, *args: Args, **kwargs: Kwargs
|
371
|
-
) ->
|
436
|
+
) -> PostGroupPageContext:
|
372
437
|
"""Add a dictionary of posts grouped by year to the context dict."""
|
373
|
-
|
374
|
-
|
375
|
-
|
376
|
-
|
377
|
-
|
378
|
-
|
379
|
-
|
438
|
+
from picata.helpers.wagtail import page_preview_data, visible_pages_qs
|
439
|
+
|
440
|
+
children = visible_pages_qs(
|
441
|
+
cast(AbstractUser, request.user), cast(PageQuerySet, self.get_children())
|
442
|
+
).specific()
|
443
|
+
|
444
|
+
child_data = sorted(
|
445
|
+
[page_preview_data(child, request.user) for child in children],
|
446
|
+
key=lambda p: p["list_date"],
|
447
|
+
reverse=True,
|
380
448
|
)
|
381
449
|
|
382
450
|
# Create an OrderedDict grouping posts by year in reverse chronological order
|
383
451
|
posts_by_year: OrderedDict = OrderedDict()
|
384
|
-
for child in
|
385
|
-
post_data = getattr(child, "preview_data", {}).copy()
|
386
|
-
post_data.update(**child.get_publication_data(request))
|
387
|
-
|
452
|
+
for child in child_data:
|
388
453
|
# Group posts by year, defaulting to last-draft year if unpublished
|
389
|
-
if
|
390
|
-
posts_by_year[
|
391
|
-
posts_by_year[
|
454
|
+
if child["year"] not in posts_by_year:
|
455
|
+
posts_by_year[child["year"]] = []
|
456
|
+
posts_by_year[child["year"]].append(child)
|
392
457
|
|
393
458
|
return cast(
|
394
|
-
|
459
|
+
PostGroupPageContext,
|
395
460
|
{**super().get_context(request, *args, **kwargs), "posts_by_year": posts_by_year},
|
396
461
|
)
|
397
462
|
|
@@ -472,7 +537,7 @@ class HomePage(BasePage):
|
|
472
537
|
from picata.helpers.wagtail import page_preview_data
|
473
538
|
|
474
539
|
recent_posts = Article.objects.live_for_user(request.user).by_date()
|
475
|
-
recent_posts = [page_preview_data(post, request) for post in recent_posts]
|
540
|
+
recent_posts = [page_preview_data(post, request.user) for post in recent_posts]
|
476
541
|
|
477
542
|
return cast(
|
478
543
|
HomePageContext,
|
@@ -488,3 +553,86 @@ class HomePage(BasePage):
|
|
488
553
|
"""Declare explicit human-readable names for the page type."""
|
489
554
|
|
490
555
|
verbose_name = "home page"
|
556
|
+
|
557
|
+
|
558
|
+
class PostSeriesContext(BasePageContext):
|
559
|
+
"""Return-type for a `PostSeries`'s context dictionary."""
|
560
|
+
|
561
|
+
introduction: str
|
562
|
+
|
563
|
+
|
564
|
+
class PostSeries(BasePage):
|
565
|
+
"""A container for a series of related articles."""
|
566
|
+
|
567
|
+
summary = RichTextField(blank=True, help_text="A summary to be displayed in previews.")
|
568
|
+
introduction = StreamField(
|
569
|
+
[
|
570
|
+
("rich_text", RichTextBlock()),
|
571
|
+
("code", CodeBlock()),
|
572
|
+
("image", WrappedImageChooserBlock()),
|
573
|
+
],
|
574
|
+
blank=True,
|
575
|
+
use_json_field=True,
|
576
|
+
help_text="Content to introduce the series of articles.",
|
577
|
+
)
|
578
|
+
|
579
|
+
promote_panels: ClassVar[list[Panel]] = [
|
580
|
+
FieldPanel("summary"),
|
581
|
+
*BasePage.promote_panels,
|
582
|
+
]
|
583
|
+
|
584
|
+
content_panels: ClassVar[list[FieldPanel]] = [
|
585
|
+
*BasePage.content_panels,
|
586
|
+
FieldPanel("introduction"),
|
587
|
+
]
|
588
|
+
|
589
|
+
parent_page_types: ClassVar[list[str]] = ["PostGroupPage"]
|
590
|
+
subpage_types: ClassVar[list[str]] = ["Article"]
|
591
|
+
|
592
|
+
def get_publication_data(self, request: HttpRequest | None = None) -> dict[str, Any]:
|
593
|
+
"""Return publication data, using the most recent child article's data for sorting."""
|
594
|
+
data = super().get_publication_data(request)
|
595
|
+
children = (
|
596
|
+
Article.objects.child_of(self)
|
597
|
+
.by_date()
|
598
|
+
.live_for_user(request.user if request else None)
|
599
|
+
)
|
600
|
+
child_publication_data = [child.get_publication_data(request) for child in children]
|
601
|
+
|
602
|
+
if child_publication_data:
|
603
|
+
latest_child = max(child_publication_data, key=lambda p: p["list_date"])
|
604
|
+
data["published"] = latest_child["published"]
|
605
|
+
data["updated"] = latest_child["updated"]
|
606
|
+
data["list_date"] = latest_child["list_date"]
|
607
|
+
|
608
|
+
return data
|
609
|
+
|
610
|
+
def get_preview_fields(
|
611
|
+
self, user: UserOrNot = None, relative_to: Page | None = None
|
612
|
+
) -> dict[str, Any]:
|
613
|
+
"""Return preview data, including a sorted list of child articles as 'parts'."""
|
614
|
+
from picata.helpers.wagtail import page_preview_data
|
615
|
+
|
616
|
+
site = self.get_site()
|
617
|
+
data = {
|
618
|
+
**super().get_preview_fields(user),
|
619
|
+
"summary": self.summary,
|
620
|
+
}
|
621
|
+
children = list(Article.objects.child_of(self).by_date().live_for_user(user))
|
622
|
+
part_previews = [page_preview_data(child, user) for child in children]
|
623
|
+
data["url"] = self.relative_url(site)
|
624
|
+
data["parts"] = part_previews
|
625
|
+
if relative_to and relative_to in children:
|
626
|
+
data["this_part"] = children.index(relative_to) + 1
|
627
|
+
return data
|
628
|
+
|
629
|
+
def get_context(self, request: HttpRequest, *args: Args, **kwargs: Kwargs) -> PostSeriesContext:
|
630
|
+
"""Add content streams and a recent posts list to the context."""
|
631
|
+
return cast(
|
632
|
+
PostSeriesContext,
|
633
|
+
{
|
634
|
+
**super().get_context(request, *args, **kwargs),
|
635
|
+
**self.get_preview_fields(request.user),
|
636
|
+
"introduction": self.introduction,
|
637
|
+
},
|
638
|
+
)
|
picata/transformers.py
CHANGED
@@ -13,7 +13,8 @@ def add_heading_ids(tree: etree._Element) -> None:
|
|
13
13
|
return
|
14
14
|
|
15
15
|
for heading in main[0].xpath(".//h1|//h2|//h3|//h4|//h5|//h6"):
|
16
|
-
|
16
|
+
# Exclude headings in <nav> tags and those already having an id.
|
17
|
+
if heading.xpath("ancestor::nav") or heading.get("id"):
|
17
18
|
continue
|
18
19
|
heading_text = get_full_text(heading)
|
19
20
|
slug = heading_text.lower().replace(" ", "-")
|
picata/typing/__init__.py
CHANGED
@@ -2,6 +2,7 @@
|
|
2
2
|
|
3
3
|
from typing import Any, TypedDict
|
4
4
|
|
5
|
+
from django.contrib.auth.models import AbstractBaseUser, AnonymousUser
|
5
6
|
from django.http import HttpRequest
|
6
7
|
|
7
8
|
# Generic arguments and keyword arguments
|
@@ -9,6 +10,9 @@ Args = tuple[Any, ...]
|
|
9
10
|
Kwargs = dict[str, Any]
|
10
11
|
|
11
12
|
|
13
|
+
UserOrNot = AbstractBaseUser | AnonymousUser | None
|
14
|
+
|
15
|
+
|
12
16
|
class Context(TypedDict):
|
13
17
|
"""Base class for context dicts passed all around the system."""
|
14
18
|
|
picata/views.py
CHANGED
@@ -88,7 +88,7 @@ def search(request: HttpRequest) -> HttpResponse:
|
|
88
88
|
results: dict[str, str | list[str] | set[str]] = {}
|
89
89
|
|
90
90
|
# Base QuerySet for all pages
|
91
|
-
pages: PageQuerySet = visible_pages_qs(request)
|
91
|
+
pages: PageQuerySet = visible_pages_qs(request.user)
|
92
92
|
|
93
93
|
# Perform search by query
|
94
94
|
query_string = request.GET.get("query")
|
@@ -119,6 +119,6 @@ def search(request: HttpRequest) -> HttpResponse:
|
|
119
119
|
specific_pages = []
|
120
120
|
|
121
121
|
# Enhance pages with preview and publication data
|
122
|
-
page_previews = [page_preview_data(page, request) for page in specific_pages]
|
122
|
+
page_previews = [page_preview_data(page, request.user) for page in specific_pages]
|
123
123
|
|
124
124
|
return render(request, "picata/search_results.html", {**results, "pages": page_previews})
|