picata 0.0.11__py3-none-any.whl → 0.0.13__py3-none-any.whl
Sign up to get free protection for your applications and to get access to all the features.
- 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})
|