picata 0.0.10__py3-none-any.whl → 0.0.12__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.
- 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})
|