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.
@@ -1,4 +1,5 @@
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
 
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(request: HttpRequest) -> PageQuerySet:
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 request.user.is_authenticated:
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, request: HttpRequest | None) -> dict[str, str]:
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 = getattr(page, "preview_data", {}).copy()
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(request))
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-24 00:18
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': [('python', 'Python'), ('javascript', 'JavaScript'), ('html', 'HTML'), ('css', 'CSS'), ('bash', 'Bash'), ('plaintext', 'Plain Text')], 'required': False}), 3: ('wagtail.blocks.StructBlock', [[('code', 1), ('language', 2)]], {}), 4: ('wagtail.images.blocks.ImageChooserBlock', (), {})}, help_text='Main content for the 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': [('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': [('python', 'Python'), ('javascript', 'JavaScript'), ('html', 'HTML'), ('css', 'CSS'), ('bash', 'Bash'), ('plaintext', 'Plain Text')], '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.')),
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': [('python', 'Python'), ('javascript', 'JavaScript'), ('html', 'HTML'), ('css', 'CSS'), ('bash', 'Bash'), ('plaintext', 'Plain Text')], 'required': False}), 3: ('wagtail.blocks.StructBlock', [[('code', 1), ('language', 2)]], {}), 4: ('wagtail.images.blocks.ImageChooserBlock', (), {})}, help_text='Main content for the article.')),
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.contrib.auth.models import AnonymousUser, User
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, ExtractYear
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
- """Mixin for `Page`-types offering previews of themselves on other `Page`s."""
108
+ """Base for all Picata pages, offering publication and preview data methods."""
61
109
 
62
- @property
63
- def preview_data(self) -> dict[str, Any]:
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 = self.latest_revision.created_at
74
- year = self.first_published_at.year if self.first_published_at else last_edited.year
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
- "year": year,
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
- content_panels: ClassVar[list[Panel]] = [
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 ArticleContext(BasePageContext):
267
- """Return-type for an `Article`'s context dictionary."""
324
+ class SeriesPostMixinContext(TypedDict, total=False):
325
+ """Potential context data for page including `SeriesPostMixin`."""
268
326
 
269
- content: str
327
+ series: dict[str, Any]
270
328
 
271
329
 
272
- class ArticleQuerySet(PageQuerySet):
273
- """Default `QuerySet` for all `Article`-type pages."""
330
+ class SeriesPostMixin:
331
+ """Mixin for articles that belong to a PostSeries."""
274
332
 
275
- def with_effective_date(self) -> PageQuerySet:
276
- """Annotate with 'effective_date' to allow date-ordering to consider recent drafts."""
277
- return self.annotate(
278
- effective_date=Coalesce("first_published_at", "latest_revision_created_at")
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
- def live_for_user(self, user: AnonymousUser | User) -> PageQuerySet:
286
- """Filter out non-live pages for non-authenticated users."""
287
- return self if user.is_authenticated else self.live()
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(blank=True, help_text="A short tagline for the article.")
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
- @property
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().preview_data,
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 PostGroupePageContext(PageContext):
418
+ class PostGroupPageContext(BasePageContext):
354
419
  """Return-type for a `PostGroupPage`'s context dictionary."""
355
420
 
356
- posts: OrderedDict[int, list[dict[str, str]]]
421
+ posts_by_year: OrderedDict[int, list[dict[str, str]]]
357
422
 
358
423
 
359
- class PostGroupPage(RoutablePageMixin, Page):
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
- ) -> PostGroupePageContext:
436
+ ) -> PostGroupPageContext:
372
437
  """Add a dictionary of posts grouped by year to the context dict."""
373
- children = self.get_children()
374
- if not request.user.is_authenticated:
375
- children = children.live()
376
- children = children.specific()
377
- children = children.annotate(
378
- effective_date=Coalesce("first_published_at", "latest_revision_created_at"),
379
- year_published=ExtractYear("first_published_at"),
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 children.order_by("-effective_date"):
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 post_data["year"] not in posts_by_year:
390
- posts_by_year[post_data["year"]] = []
391
- posts_by_year[post_data["year"]].append(post_data)
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
- PostGroupePageContext,
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
- if heading.get("id"):
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})
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: picata
3
- Version: 0.0.10
3
+ Version: 0.0.12
4
4
  Summary: Ada's Wagtail-based CMS & blog
5
5
  Project-URL: Documentation, https://github.com/hipikat/picata#readme
6
6
  Project-URL: Issues, https://github.com/hipikat/picata/issues