picata 0.0.11__py3-none-any.whl → 0.0.13__py3-none-any.whl
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.
- 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/blocks.py +1 -1
- picata/helpers/__init__.py +1 -0
- picata/helpers/wagtail.py +9 -8
- picata/migrations/0002_postseries.py +27 -0
- picata/migrations/0003_postseries_summary_alter_postseries_introduction.py +24 -0
- picata/models.py +205 -66
- picata/transformers.py +2 -1
- picata/typing/__init__.py +4 -0
- picata/views.py +10 -2
- {picata-0.0.11.dist-info → picata-0.0.13.dist-info}/METADATA +1 -1
- {picata-0.0.11.dist-info → picata-0.0.13.dist-info}/RECORD +25 -25
- 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.11.dist-info → picata-0.0.13.dist-info}/WHEEL +0 -0
- {picata-0.0.11.dist-info → picata-0.0.13.dist-info}/licenses/LICENSE.md +0 -0
picata/blocks.py
CHANGED
@@ -113,8 +113,8 @@ class CodeBlock(StructBlock):
|
|
113
113
|
("plaintext", "Plain Text"),
|
114
114
|
("bash", "Bash"),
|
115
115
|
("css", "CSS"),
|
116
|
-
("javascript", "JavaScript"),
|
117
116
|
("html", "HTML"),
|
117
|
+
("javascript", "JavaScript"),
|
118
118
|
("python", "Python"),
|
119
119
|
("toml", "TOML"),
|
120
120
|
("yaml", "YAML"),
|
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
|
@@ -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,25 +18,25 @@ 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
|
32
33
|
from wagtail.images.models import Image
|
33
|
-
from wagtail.models import Page, PageManager
|
34
|
+
from wagtail.models import Page, PageManager, PanelPlaceholder
|
34
35
|
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,
|
@@ -73,14 +122,12 @@ class BasePage(Page):
|
|
73
122
|
last_edited = (
|
74
123
|
self.latest_revision.created_at if self.latest_revision else self.last_published_at
|
75
124
|
)
|
125
|
+
published, updated = self.first_published_at, self.last_published_at
|
76
126
|
year = (
|
77
127
|
self.first_published_at.year
|
78
128
|
if self.first_published_at
|
79
|
-
else last_edited.year
|
80
|
-
if last_edited
|
81
|
-
else None
|
129
|
+
else (last_edited.year if last_edited else now().year)
|
82
130
|
)
|
83
|
-
published, updated = self.first_published_at, self.last_published_at
|
84
131
|
|
85
132
|
# Convert datetime objects to strings like "3 Jan, '25", or False, and
|
86
133
|
# give a grace-period of one week for edits before marking the post as "updated"
|
@@ -97,6 +144,7 @@ class BasePage(Page):
|
|
97
144
|
"published": published_str,
|
98
145
|
"updated": updated_str,
|
99
146
|
"year": year,
|
147
|
+
"list_date": published if published else last_edited if last_edited else now(),
|
100
148
|
}
|
101
149
|
|
102
150
|
# Add last draft date & preview URL if there's an unpublished draft, for logged-in users
|
@@ -120,7 +168,7 @@ class BasePage(Page):
|
|
120
168
|
from picata.helpers.wagtail import page_preview_data
|
121
169
|
|
122
170
|
context = super().get_context(request, *args, **kwargs)
|
123
|
-
context.update(page_preview_data(self, request))
|
171
|
+
context.update(page_preview_data(self, request.user))
|
124
172
|
return cast(BasePageContext, {**context})
|
125
173
|
|
126
174
|
class Meta:
|
@@ -162,9 +210,9 @@ class TaggedPage(BasePage):
|
|
162
210
|
help_text="Tags for the article.",
|
163
211
|
)
|
164
212
|
|
165
|
-
|
166
|
-
*BasePage.content_panels,
|
213
|
+
promote_panels: ClassVar[list[PanelPlaceholder | FieldPanel]] = [
|
167
214
|
FieldPanel("tags"),
|
215
|
+
*BasePage.promote_panels,
|
168
216
|
]
|
169
217
|
|
170
218
|
class Meta:
|
@@ -189,7 +237,7 @@ class BasicPage(BasePage):
|
|
189
237
|
help_text="Main content for the page.",
|
190
238
|
)
|
191
239
|
|
192
|
-
content_panels: ClassVar[list[FieldPanel]] = [
|
240
|
+
content_panels: ClassVar[list[PanelPlaceholder | FieldPanel]] = [
|
193
241
|
*BasePage.content_panels,
|
194
242
|
FieldPanel("content"),
|
195
243
|
]
|
@@ -217,7 +265,7 @@ class SplitViewPage(BasePage):
|
|
217
265
|
help_text="Main content for the split-view page.",
|
218
266
|
)
|
219
267
|
|
220
|
-
content_panels: ClassVar[list[FieldPanel]] = [
|
268
|
+
content_panels: ClassVar[list[PanelPlaceholder | FieldPanel]] = [
|
221
269
|
*BasePage.content_panels,
|
222
270
|
FieldPanel("content"),
|
223
271
|
]
|
@@ -273,35 +321,37 @@ class ArticleTypeAdmin(ModelAdmin):
|
|
273
321
|
search_fields = ("name", "slug") # Fields to include in the search bar
|
274
322
|
|
275
323
|
|
276
|
-
class
|
277
|
-
"""
|
324
|
+
class SeriesPostMixinContext(TypedDict, total=False):
|
325
|
+
"""Potential context data for page including `SeriesPostMixin`."""
|
278
326
|
|
279
|
-
|
327
|
+
series: dict[str, Any]
|
280
328
|
|
281
329
|
|
282
|
-
class
|
283
|
-
"""
|
330
|
+
class SeriesPostMixin:
|
331
|
+
"""Mixin for articles that belong to a PostSeries."""
|
284
332
|
|
285
|
-
def
|
286
|
-
|
287
|
-
|
288
|
-
|
289
|
-
)
|
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)
|
290
343
|
|
291
|
-
def by_date(self) -> PageQuerySet:
|
292
|
-
"""Return all `Article` pages, ordered by decending "effective" date."""
|
293
|
-
return self.with_effective_date().order_by("-effective_date")
|
294
344
|
|
295
|
-
|
296
|
-
|
297
|
-
|
345
|
+
class ArticleContext(SeriesPostMixinContext, BasePageContext):
|
346
|
+
"""Return-type for an `Article`'s context dictionary."""
|
347
|
+
|
348
|
+
content: str
|
298
349
|
|
299
350
|
|
300
|
-
class Article(TaggedPage):
|
351
|
+
class Article(SeriesPostMixin, TaggedPage):
|
301
352
|
"""Class for article-like pages."""
|
302
353
|
|
303
354
|
template = "picata/article.html"
|
304
|
-
objects = PageManager.from_queryset(ArticleQuerySet)()
|
305
355
|
|
306
356
|
tagline: CharField = CharField(
|
307
357
|
blank=True, help_text="A short tagline for the article.", max_length=255
|
@@ -327,12 +377,16 @@ class Article(TaggedPage):
|
|
327
377
|
help_text="Select the type of article.",
|
328
378
|
)
|
329
379
|
|
330
|
-
|
380
|
+
promote_panels: ClassVar[list[PanelPlaceholder | FieldPanel]] = [
|
381
|
+
FieldPanel("summary"),
|
382
|
+
FieldPanel("page_type"),
|
383
|
+
*TaggedPage.promote_panels,
|
384
|
+
]
|
385
|
+
|
386
|
+
content_panels: ClassVar[list[PanelPlaceholder | FieldPanel]] = [
|
331
387
|
*TaggedPage.content_panels,
|
332
388
|
FieldPanel("tagline"),
|
333
|
-
FieldPanel("summary"),
|
334
389
|
FieldPanel("content"),
|
335
|
-
FieldPanel("page_type"),
|
336
390
|
]
|
337
391
|
|
338
392
|
search_fields: ClassVar[list[index.SearchField]] = [
|
@@ -344,11 +398,10 @@ class Article(TaggedPage):
|
|
344
398
|
index.SearchField("page_type"),
|
345
399
|
]
|
346
400
|
|
347
|
-
|
348
|
-
def preview_data(self) -> dict[str, Any]:
|
401
|
+
def get_preview_fields(self, user: UserOrNot = None) -> dict[str, Any]:
|
349
402
|
"""Return data required to render a preview of this article."""
|
350
403
|
return {
|
351
|
-
**super().
|
404
|
+
**super().get_preview_fields(user),
|
352
405
|
"tagline": self.tagline,
|
353
406
|
"summary": self.summary,
|
354
407
|
"page_type": self.page_type,
|
@@ -362,48 +415,51 @@ class Article(TaggedPage):
|
|
362
415
|
return cast(ArticleContext, context)
|
363
416
|
|
364
417
|
|
365
|
-
class
|
418
|
+
class PostGroupPageContext(BasePageContext):
|
366
419
|
"""Return-type for a `PostGroupPage`'s context dictionary."""
|
367
420
|
|
368
|
-
|
421
|
+
posts_by_year: OrderedDict[int, list[dict[str, str]]]
|
369
422
|
|
370
423
|
|
371
|
-
class PostGroupPage(
|
424
|
+
class PostGroupPage(BasePage):
|
372
425
|
"""A top-level page for grouping various types of posts or articles."""
|
373
426
|
|
374
427
|
template = "picata/post_listing.html"
|
375
|
-
subpage_types: ClassVar[list[str]] = ["picata.Article"]
|
428
|
+
subpage_types: ClassVar[list[str]] = ["picata.Article", "picata.PostSeries"]
|
376
429
|
|
377
430
|
intro = RichTextField(blank=True, help_text="An optional introduction to this group.")
|
378
431
|
|
379
|
-
content_panels: ClassVar[list[
|
432
|
+
content_panels: ClassVar[list[PanelPlaceholder | FieldPanel]] = [
|
433
|
+
*BasePage.content_panels,
|
434
|
+
FieldPanel("intro"),
|
435
|
+
]
|
380
436
|
|
381
437
|
def get_context(
|
382
438
|
self, request: HttpRequest, *args: Args, **kwargs: Kwargs
|
383
|
-
) ->
|
439
|
+
) -> PostGroupPageContext:
|
384
440
|
"""Add a dictionary of posts grouped by year to the context dict."""
|
385
|
-
|
386
|
-
|
387
|
-
|
388
|
-
|
389
|
-
|
390
|
-
|
391
|
-
|
441
|
+
from picata.helpers.wagtail import page_preview_data, visible_pages_qs
|
442
|
+
|
443
|
+
children = visible_pages_qs(
|
444
|
+
cast(AbstractUser, request.user), cast(PageQuerySet, self.get_children())
|
445
|
+
).specific()
|
446
|
+
|
447
|
+
child_data = sorted(
|
448
|
+
[page_preview_data(child, request.user) for child in children],
|
449
|
+
key=lambda p: p["list_date"],
|
450
|
+
reverse=True,
|
392
451
|
)
|
393
452
|
|
394
453
|
# Create an OrderedDict grouping posts by year in reverse chronological order
|
395
454
|
posts_by_year: OrderedDict = OrderedDict()
|
396
|
-
for child in
|
397
|
-
post_data = getattr(child, "preview_data", {}).copy()
|
398
|
-
post_data.update(**child.get_publication_data(request))
|
399
|
-
|
455
|
+
for child in child_data:
|
400
456
|
# Group posts by year, defaulting to last-draft year if unpublished
|
401
|
-
if
|
402
|
-
posts_by_year[
|
403
|
-
posts_by_year[
|
457
|
+
if child["year"] not in posts_by_year:
|
458
|
+
posts_by_year[child["year"]] = []
|
459
|
+
posts_by_year[child["year"]].append(child)
|
404
460
|
|
405
461
|
return cast(
|
406
|
-
|
462
|
+
PostGroupPageContext,
|
407
463
|
{**super().get_context(request, *args, **kwargs), "posts_by_year": posts_by_year},
|
408
464
|
)
|
409
465
|
|
@@ -467,7 +523,7 @@ class HomePage(BasePage):
|
|
467
523
|
help_text="Content stream rendered under 'Recent posts'",
|
468
524
|
)
|
469
525
|
|
470
|
-
content_panels: ClassVar[list[FieldPanel]] = [
|
526
|
+
content_panels: ClassVar[list[PanelPlaceholder | FieldPanel]] = [
|
471
527
|
*BasePage.content_panels,
|
472
528
|
FieldPanel("top_content"),
|
473
529
|
FieldPanel("bottom_content"),
|
@@ -484,7 +540,7 @@ class HomePage(BasePage):
|
|
484
540
|
from picata.helpers.wagtail import page_preview_data
|
485
541
|
|
486
542
|
recent_posts = Article.objects.live_for_user(request.user).by_date()
|
487
|
-
recent_posts = [page_preview_data(post, request) for post in recent_posts]
|
543
|
+
recent_posts = [page_preview_data(post, request.user) for post in recent_posts]
|
488
544
|
|
489
545
|
return cast(
|
490
546
|
HomePageContext,
|
@@ -500,3 +556,86 @@ class HomePage(BasePage):
|
|
500
556
|
"""Declare explicit human-readable names for the page type."""
|
501
557
|
|
502
558
|
verbose_name = "home page"
|
559
|
+
|
560
|
+
|
561
|
+
class PostSeriesContext(BasePageContext):
|
562
|
+
"""Return-type for a `PostSeries`'s context dictionary."""
|
563
|
+
|
564
|
+
introduction: str
|
565
|
+
|
566
|
+
|
567
|
+
class PostSeries(BasePage):
|
568
|
+
"""A container for a series of related articles."""
|
569
|
+
|
570
|
+
summary = RichTextField(blank=True, help_text="A summary to be displayed in previews.")
|
571
|
+
introduction = StreamField(
|
572
|
+
[
|
573
|
+
("rich_text", RichTextBlock()),
|
574
|
+
("code", CodeBlock()),
|
575
|
+
("image", WrappedImageChooserBlock()),
|
576
|
+
],
|
577
|
+
blank=True,
|
578
|
+
use_json_field=True,
|
579
|
+
help_text="Content to introduce the series of articles.",
|
580
|
+
)
|
581
|
+
|
582
|
+
promote_panels: ClassVar[list[PanelPlaceholder | FieldPanel]] = [
|
583
|
+
FieldPanel("summary"),
|
584
|
+
*BasePage.promote_panels,
|
585
|
+
]
|
586
|
+
|
587
|
+
content_panels: ClassVar[list[PanelPlaceholder | FieldPanel]] = [
|
588
|
+
*BasePage.content_panels,
|
589
|
+
FieldPanel("introduction"),
|
590
|
+
]
|
591
|
+
|
592
|
+
parent_page_types: ClassVar[list[str]] = ["PostGroupPage"]
|
593
|
+
subpage_types: ClassVar[list[str]] = ["Article"]
|
594
|
+
|
595
|
+
def get_publication_data(self, request: HttpRequest | None = None) -> dict[str, Any]:
|
596
|
+
"""Return publication data, using the most recent child article's data for sorting."""
|
597
|
+
data = super().get_publication_data(request)
|
598
|
+
children = (
|
599
|
+
Article.objects.child_of(self)
|
600
|
+
.by_date()
|
601
|
+
.live_for_user(request.user if request else None)
|
602
|
+
)
|
603
|
+
child_publication_data = [child.get_publication_data(request) for child in children]
|
604
|
+
|
605
|
+
if child_publication_data:
|
606
|
+
latest_child = max(child_publication_data, key=lambda p: p["list_date"])
|
607
|
+
data["published"] = latest_child["published"]
|
608
|
+
data["updated"] = latest_child["updated"]
|
609
|
+
data["list_date"] = latest_child["list_date"]
|
610
|
+
|
611
|
+
return data
|
612
|
+
|
613
|
+
def get_preview_fields(
|
614
|
+
self, user: UserOrNot = None, relative_to: Page | None = None
|
615
|
+
) -> dict[str, Any]:
|
616
|
+
"""Return preview data, including a sorted list of child articles as 'parts'."""
|
617
|
+
from picata.helpers.wagtail import page_preview_data
|
618
|
+
|
619
|
+
site = self.get_site()
|
620
|
+
data = {
|
621
|
+
**super().get_preview_fields(user),
|
622
|
+
"summary": self.summary,
|
623
|
+
}
|
624
|
+
children = list(Article.objects.child_of(self).by_date().live_for_user(user))
|
625
|
+
part_previews = [page_preview_data(child, user) for child in children]
|
626
|
+
data["url"] = self.relative_url(site)
|
627
|
+
data["parts"] = part_previews
|
628
|
+
if relative_to and relative_to in children:
|
629
|
+
data["this_part"] = children.index(relative_to) + 1
|
630
|
+
return data
|
631
|
+
|
632
|
+
def get_context(self, request: HttpRequest, *args: Args, **kwargs: Kwargs) -> PostSeriesContext:
|
633
|
+
"""Add content streams and a recent posts list to the context."""
|
634
|
+
return cast(
|
635
|
+
PostSeriesContext,
|
636
|
+
{
|
637
|
+
**super().get_context(request, *args, **kwargs),
|
638
|
+
**self.get_preview_fields(request.user),
|
639
|
+
"introduction": self.introduction,
|
640
|
+
},
|
641
|
+
)
|
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
@@ -53,6 +53,14 @@ class PostsFeed(Feed):
|
|
53
53
|
"""Return the article creation date."""
|
54
54
|
return item.first_published_at
|
55
55
|
|
56
|
+
def item_updateddate(self, item: Article) -> datetime:
|
57
|
+
"""Return the article creation date."""
|
58
|
+
return item.last_published_at
|
59
|
+
|
60
|
+
def item_author_name(self, item: Article) -> str:
|
61
|
+
"""Return the name of the author."""
|
62
|
+
return "Ada Wright"
|
63
|
+
|
56
64
|
|
57
65
|
class RSSArticleFeed(PostsFeed):
|
58
66
|
"""RSS feed for articles."""
|
@@ -88,7 +96,7 @@ def search(request: HttpRequest) -> HttpResponse:
|
|
88
96
|
results: dict[str, str | list[str] | set[str]] = {}
|
89
97
|
|
90
98
|
# Base QuerySet for all pages
|
91
|
-
pages: PageQuerySet = visible_pages_qs(request)
|
99
|
+
pages: PageQuerySet = visible_pages_qs(request.user)
|
92
100
|
|
93
101
|
# Perform search by query
|
94
102
|
query_string = request.GET.get("query")
|
@@ -119,6 +127,6 @@ def search(request: HttpRequest) -> HttpResponse:
|
|
119
127
|
specific_pages = []
|
120
128
|
|
121
129
|
# Enhance pages with preview and publication data
|
122
|
-
page_previews = [page_preview_data(page, request) for page in specific_pages]
|
130
|
+
page_previews = [page_preview_data(page, request.user) for page in specific_pages]
|
123
131
|
|
124
132
|
return render(request, "picata/search_results.html", {**results, "pages": page_previews})
|