picata 0.0.1__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.
Files changed (94) hide show
  1. LICENSE.md +24 -0
  2. README.md +59 -0
  3. components/HelloWorld.tsx +11 -0
  4. entrypoint.tsx +268 -0
  5. manage.py +15 -0
  6. picata/__init__.py +1 -0
  7. picata/apps.py +33 -0
  8. picata/blocks.py +175 -0
  9. picata/helpers/__init__.py +70 -0
  10. picata/helpers/wagtail.py +61 -0
  11. picata/log_utils.py +47 -0
  12. picata/middleware.py +54 -0
  13. picata/migrations/0001_initial.py +264 -0
  14. picata/migrations/0002_alter_article_content_alter_basicpage_content.py +112 -0
  15. picata/migrations/0003_alter_article_content_alter_basicpage_content.py +104 -0
  16. picata/migrations/0004_alter_article_content_alter_basicpage_content.py +105 -0
  17. picata/migrations/0005_socialsettings.py +48 -0
  18. picata/migrations/0006_alter_article_content.py +71 -0
  19. picata/migrations/0007_splitviewpage.py +69 -0
  20. picata/migrations/0008_alter_splitviewpage_content.py +96 -0
  21. picata/migrations/0009_alter_splitviewpage_content.py +111 -0
  22. picata/migrations/0010_alter_splitviewpage_content.py +105 -0
  23. picata/migrations/0011_alter_splitviewpage_options_and_more.py +113 -0
  24. picata/migrations/0012_alter_splitviewpage_content.py +109 -0
  25. picata/migrations/0013_alter_article_content.py +43 -0
  26. picata/migrations/0014_alter_article_content_alter_article_summary.py +24 -0
  27. picata/migrations/0015_alter_article_options_article_tagline_and_more.py +28 -0
  28. picata/migrations/0016_alter_article_options_alter_articletag_options_and_more.py +33 -0
  29. picata/migrations/0017_articletagrelation_alter_article_tags_and_more.py +35 -0
  30. picata/migrations/0018_rename_articletag_pagetag_and_more.py +21 -0
  31. picata/migrations/0019_rename_name_plural_articletype__name_plural.py +18 -0
  32. picata/migrations/0020_rename__name_plural_articletype__pluralised_name.py +18 -0
  33. picata/migrations/0021_rename_article_type_article_page_type.py +18 -0
  34. picata/migrations/0022_homepage.py +28 -0
  35. picata/migrations/__init__.py +0 -0
  36. picata/models.py +486 -0
  37. picata/settings/__init__.py +1 -0
  38. picata/settings/base.py +345 -0
  39. picata/settings/dev.py +94 -0
  40. picata/settings/mypy.py +7 -0
  41. picata/settings/prod.py +12 -0
  42. picata/settings/test.py +6 -0
  43. picata/static/picata/ada-profile.jpg +0 -0
  44. picata/static/picata/ada-social-bear.jpg +0 -0
  45. picata/static/picata/favicon.ico +0 -0
  46. picata/static/picata/fonts/Bitter-Light.ttf +0 -0
  47. picata/static/picata/fonts/Bitter-LightItalic.ttf +0 -0
  48. picata/static/picata/fonts/FiraCode-Light.ttf +0 -0
  49. picata/static/picata/fonts/FiraCode-SemiBold.ttf +0 -0
  50. picata/static/picata/fonts/Sacramento-Regular.ttf +0 -0
  51. picata/static/picata/fonts/ZillaSlab-Bold.ttf +0 -0
  52. picata/static/picata/fonts/ZillaSlab-BoldItalic.ttf +0 -0
  53. picata/static/picata/fonts/ZillaSlab-Light.ttf +0 -0
  54. picata/static/picata/fonts/ZillaSlab-LightItalic.ttf +0 -0
  55. picata/static/picata/fonts/ZillaSlabHighlight-Bold.ttf +0 -0
  56. picata/static/picata/icons.svg +56 -0
  57. picata/templates/picata/3_column.html +28 -0
  58. picata/templates/picata/404.html +11 -0
  59. picata/templates/picata/500.html +13 -0
  60. picata/templates/picata/_post_list.html +24 -0
  61. picata/templates/picata/article.html +20 -0
  62. picata/templates/picata/base.html +135 -0
  63. picata/templates/picata/basic_page.html +10 -0
  64. picata/templates/picata/blocks/icon_link_item.html +7 -0
  65. picata/templates/picata/blocks/icon_link_list.html +4 -0
  66. picata/templates/picata/blocks/icon_link_list_stream.html +3 -0
  67. picata/templates/picata/dl_view.html +18 -0
  68. picata/templates/picata/home_page.html +21 -0
  69. picata/templates/picata/post_listing.html +17 -0
  70. picata/templates/picata/previews/3col.html +73 -0
  71. picata/templates/picata/previews/dl.html +10 -0
  72. picata/templates/picata/previews/split.html +10 -0
  73. picata/templates/picata/previews/theme_gallery.html +158 -0
  74. picata/templates/picata/search_results.html +28 -0
  75. picata/templates/picata/split_view.html +15 -0
  76. picata/templates/picata/tags/site_menu.html +8 -0
  77. picata/templatetags/__init__.py +1 -0
  78. picata/templatetags/absolute_static.py +15 -0
  79. picata/templatetags/menu_tags.py +42 -0
  80. picata/templatetags/stringify.py +23 -0
  81. picata/transformers.py +60 -0
  82. picata/typing/__init__.py +19 -0
  83. picata/typing/wagtail.py +31 -0
  84. picata/urls.py +48 -0
  85. picata/validators.py +36 -0
  86. picata/views.py +80 -0
  87. picata/wagtail_hooks.py +43 -0
  88. picata/wsgi.py +15 -0
  89. picata-0.0.1.dist-info/METADATA +87 -0
  90. picata-0.0.1.dist-info/RECORD +94 -0
  91. picata-0.0.1.dist-info/WHEEL +4 -0
  92. picata-0.0.1.dist-info/licenses/LICENSE.md +24 -0
  93. pygments.sass +382 -0
  94. styles.sass +300 -0
picata/models.py ADDED
@@ -0,0 +1,486 @@
1
+ """Django models; mostly subclassed Wagtail classes."""
2
+
3
+ # NB: Django's meta-class shenanigans over-complicate type hinting when QuerySets get involved.
4
+ # pyright: reportAttributeAccessIssue=false
5
+
6
+ from collections import OrderedDict
7
+ from datetime import timedelta
8
+ from typing import Any, ClassVar, cast
9
+
10
+ from django.contrib.auth.models import AnonymousUser, User
11
+ from django.db.models import (
12
+ CASCADE,
13
+ SET_NULL,
14
+ CharField,
15
+ ForeignKey,
16
+ Model,
17
+ SlugField,
18
+ TextField,
19
+ )
20
+ from django.db.models.functions import Coalesce, ExtractYear
21
+ from django.http import HttpRequest
22
+ from django.urls import reverse
23
+ from hpk.typing import Args, Kwargs
24
+ from hpk.typing.wagtail import PageContext
25
+ from modelcluster.contrib.taggit import ClusterTaggableManager
26
+ from modelcluster.fields import ParentalKey
27
+ from taggit.models import TagBase, TaggedItemBase
28
+ from wagtail.admin.panels import FieldPanel, Panel
29
+ from wagtail.blocks import RichTextBlock
30
+ from wagtail.contrib.routable_page.models import RoutablePageMixin
31
+ from wagtail.contrib.settings.models import BaseSiteSetting, register_setting
32
+ from wagtail.fields import RichTextField, StreamField
33
+ from wagtail.images.blocks import ImageChooserBlock
34
+ from wagtail.images.models import Image
35
+ from wagtail.models import Page, PageManager
36
+ from wagtail.query import PageQuerySet
37
+ from wagtail.search import index
38
+ from wagtail_modeladmin.options import ModelAdmin
39
+
40
+ from .blocks import (
41
+ CodeBlock,
42
+ StaticIconLinkListsBlock,
43
+ WrappedImageChooserBlock,
44
+ )
45
+
46
+
47
+ class BasePageContext(PageContext, total=False):
48
+ """Return-type for an `Article`'s context dictionary."""
49
+
50
+ url: str
51
+ published: bool | str
52
+ updated: bool | str
53
+ latest_draft: str
54
+ draft_url: str
55
+ title: str
56
+
57
+
58
+ class BasePage(Page):
59
+ """Mixin for `Page`-types offering previews of themselves on other `Page`s."""
60
+
61
+ @property
62
+ def preview_data(self) -> dict[str, Any]:
63
+ """Return a dictionary of data used in previewing this page type."""
64
+ return {"title": self.seo_title or self.title, "summary": self.search_description}
65
+
66
+ def get_publication_data(self, request: HttpRequest | None = None) -> dict[str, str]:
67
+ """Helper method to calculate and format relevant dates for previews."""
68
+ site = self.get_site()
69
+ last_edited = self.latest_revision.created_at
70
+ year = self.first_published_at.year if self.first_published_at else last_edited.year
71
+ published, updated = self.first_published_at, self.last_published_at
72
+
73
+ # Convert datetime objects to strings like "3 Jan, '25", or False, and
74
+ # give a grace-period of one week for edits before marking the post as "updated"
75
+ published_str = f"{published.day} {published:%b '%y}" if published else False
76
+ updated_str = (
77
+ f"{updated.day} {updated:%b '%y}"
78
+ if published and updated and (updated >= published + timedelta(weeks=1))
79
+ else False
80
+ )
81
+
82
+ data = {
83
+ "year": year,
84
+ "url": self.relative_url(site),
85
+ "published": published_str,
86
+ "updated": updated_str,
87
+ }
88
+
89
+ # Add last draft date & preview URL if there's an unpublished draft, for logged-in users
90
+ if (
91
+ (request and request.user.is_authenticated)
92
+ and (not published or (updated and last_edited > updated))
93
+ and hasattr(self, "id")
94
+ ):
95
+ data.update(
96
+ {
97
+ "latest_draft": f"{last_edited.day} {last_edited:%b '%y}",
98
+ "draft_url": reverse("wagtailadmin_pages:preview_on_edit", args=[self.id]),
99
+ }
100
+ )
101
+
102
+ return data
103
+
104
+ def get_context(self, request: HttpRequest, *args: Args, **kwargs: Kwargs) -> BasePageContext:
105
+ """Gather any publication and preview data available for the page into the context."""
106
+ from hpk.helpers.wagtail import page_preview_data
107
+
108
+ context = super().get_context(request, *args, **kwargs)
109
+ context.update(page_preview_data(request, self))
110
+ return cast(BasePageContext, {**context})
111
+
112
+ class Meta:
113
+ """Declare `BasePage` as an abstract `Page` class."""
114
+
115
+ abstract = True
116
+
117
+
118
+ # @register_snippet
119
+ class PageTag(TagBase):
120
+ """Custom tag model for articles."""
121
+
122
+ def __str__(self) -> str:
123
+ """String representation of the tag."""
124
+ return self.name
125
+
126
+
127
+ class PageTagRelation(TaggedItemBase):
128
+ """Associates an PageTag with an Page."""
129
+
130
+ tag: ForeignKey[PageTag] = ForeignKey(
131
+ PageTag,
132
+ related_name="tagged_items",
133
+ on_delete=CASCADE,
134
+ )
135
+ content_object = ParentalKey(
136
+ "Article",
137
+ on_delete=CASCADE,
138
+ related_name="tagged_items",
139
+ )
140
+
141
+
142
+ class TaggedPage(BasePage):
143
+ """Abstract base for a `Page` type supporting tags."""
144
+
145
+ tags = ClusterTaggableManager(
146
+ through=PageTagRelation,
147
+ blank=True,
148
+ help_text="Tags for the article.",
149
+ )
150
+
151
+ content_panels: ClassVar[list[Panel]] = [
152
+ *BasePage.content_panels,
153
+ FieldPanel("tags"),
154
+ ]
155
+
156
+ class Meta:
157
+ """Declare `BasePage` as an abstract `Page` class."""
158
+
159
+ abstract = True
160
+
161
+
162
+ class BasicPage(BasePage):
163
+ """A basic page model for static content."""
164
+
165
+ template = "picata/basic_page.html"
166
+
167
+ content = StreamField(
168
+ [
169
+ ("rich_text", RichTextBlock()),
170
+ ("code", CodeBlock()),
171
+ ("image", ImageChooserBlock()),
172
+ ],
173
+ use_json_field=True,
174
+ blank=True,
175
+ help_text="Main content for the page.",
176
+ )
177
+
178
+ content_panels: ClassVar[list[FieldPanel]] = [
179
+ *BasePage.content_panels,
180
+ FieldPanel("content"),
181
+ ]
182
+
183
+ search_fields: ClassVar[list[index.SearchField]] = [
184
+ *Page.search_fields,
185
+ index.SearchField("content"),
186
+ ]
187
+
188
+
189
+ class SplitViewPage(BasePage):
190
+ """A page with 50%-width divs, split down the middle."""
191
+
192
+ template = "picata/split_view.html"
193
+
194
+ content = StreamField(
195
+ [
196
+ ("rich_text", RichTextBlock()),
197
+ ("code", CodeBlock()),
198
+ ("image", WrappedImageChooserBlock()),
199
+ ("icon_link_lists", StaticIconLinkListsBlock()),
200
+ ],
201
+ use_json_field=True,
202
+ blank=True,
203
+ help_text="Main content for the split-view page.",
204
+ )
205
+
206
+ content_panels: ClassVar[list[FieldPanel]] = [
207
+ *BasePage.content_panels,
208
+ FieldPanel("content"),
209
+ ]
210
+
211
+ search_fields: ClassVar[list[index.SearchField]] = [
212
+ *Page.search_fields,
213
+ index.SearchField("content"),
214
+ ]
215
+
216
+ class Meta:
217
+ """Declare explicit human-readable names for the page type."""
218
+
219
+ verbose_name = "split-view page"
220
+ verbose_name_plural = "split-view pages"
221
+
222
+
223
+ class ArticleType(Model): # type: ignore[django-manager-missing]
224
+ """Defines a type of article, like Blog Post, Review, or Guide."""
225
+
226
+ name = CharField(max_length=100, unique=True, help_text="Name of the article type.")
227
+ _Pluralised_name = CharField(
228
+ max_length=100,
229
+ blank=True,
230
+ help_text="Plural form of the article type name (optional). Defaults to appending 's'.",
231
+ )
232
+ slug = SlugField(unique=True, max_length=100)
233
+ description = TextField(blank=True, help_text="Optional description of this type.")
234
+
235
+ def __str__(self) -> str:
236
+ """Return the name of the ArticleType."""
237
+ return self.name
238
+
239
+ @property
240
+ def name_plural(self) -> str:
241
+ """Return the plural name of the article type."""
242
+ return self._Pluralised_name or f"{self.name}s"
243
+
244
+ @property
245
+ def indefinite_article(self) -> str:
246
+ """Return a string like 'a guide' or 'an article'."""
247
+ name_lower = self.name.lower()
248
+ return f"{'an' if name_lower[0] in 'aeiou' else 'a'} {name_lower}"
249
+
250
+
251
+ class ArticleTypeAdmin(ModelAdmin):
252
+ """Wagtail admin integration for managing article types."""
253
+
254
+ model = ArticleType
255
+ menu_label = "Article Types" # Label for the menu item
256
+ menu_icon = "tag" # Icon for the menu item (from Wagtail icon set)
257
+ add_to_settings_menu = True # Whether to add to the "Settings" menu
258
+ list_display = ("name", "slug") # Fields to display in the listing
259
+ search_fields = ("name", "slug") # Fields to include in the search bar
260
+
261
+
262
+ class ArticleContext(BasePageContext):
263
+ """Return-type for an `Article`'s context dictionary."""
264
+
265
+ content: str
266
+
267
+
268
+ class ArticleQuerySet(PageQuerySet):
269
+ """Default `QuerySet` for all `Article`-type pages."""
270
+
271
+ def with_effective_date(self) -> PageQuerySet:
272
+ """Annotate with 'effective_date' to allow date-ordering to consider recent drafts."""
273
+ return self.annotate(
274
+ effective_date=Coalesce("first_published_at", "latest_revision_created_at")
275
+ )
276
+
277
+ def by_date(self) -> PageQuerySet:
278
+ """Return all `Article` pages, ordered by decending "effective" date."""
279
+ return self.with_effective_date().order_by("-effective_date")
280
+
281
+ def live_for_user(self, user: AnonymousUser | User) -> PageQuerySet:
282
+ """Filter out non-live pages for non-authenticated users."""
283
+ return self if user.is_authenticated else self.live()
284
+
285
+
286
+ class Article(TaggedPage):
287
+ """Class for article-like pages."""
288
+
289
+ template = "picata/article.html"
290
+ objects = PageManager.from_queryset(ArticleQuerySet)()
291
+
292
+ tagline: CharField = CharField(blank=True, help_text="A short tagline for the article.")
293
+ summary = RichTextField(blank=True, help_text="A summary to be displayed in previews.")
294
+ content = StreamField(
295
+ [
296
+ ("rich_text", RichTextBlock()),
297
+ ("code", CodeBlock()),
298
+ ("image", ImageChooserBlock()),
299
+ ],
300
+ use_json_field=True,
301
+ blank=True,
302
+ help_text="Main content for the article.",
303
+ )
304
+
305
+ page_type: ForeignKey[ArticleType | None] = ForeignKey(
306
+ ArticleType,
307
+ null=True,
308
+ blank=True,
309
+ on_delete=SET_NULL,
310
+ related_name="articles",
311
+ help_text="Select the type of article.",
312
+ )
313
+
314
+ content_panels: ClassVar[list[Panel]] = [
315
+ *TaggedPage.content_panels,
316
+ FieldPanel("tagline"),
317
+ FieldPanel("summary"),
318
+ FieldPanel("content"),
319
+ FieldPanel("page_type"),
320
+ ]
321
+
322
+ search_fields: ClassVar[list[index.SearchField]] = [
323
+ *TaggedPage.search_fields,
324
+ index.SearchField("tagline"),
325
+ index.SearchField("summary"),
326
+ index.SearchField("content"),
327
+ index.SearchField("tags"),
328
+ index.SearchField("page_type"),
329
+ ]
330
+
331
+ @property
332
+ def preview_data(self) -> dict[str, Any]:
333
+ """Return data required to render a preview of this article."""
334
+ return {
335
+ **super().preview_data,
336
+ "tagline": self.tagline,
337
+ "summary": self.summary,
338
+ "page_type": self.page_type,
339
+ "tags": list(self.tags.all()),
340
+ }
341
+
342
+ def get_context(self, request: HttpRequest, *args: Args, **kwargs: Kwargs) -> ArticleContext:
343
+ """Provide extra context needed for the `Article` to render itself."""
344
+ context = dict(super().get_context(request, *args, **kwargs))
345
+ context.update({"content": self.content})
346
+ return cast(ArticleContext, context)
347
+
348
+
349
+ class PostGroupePageContext(PageContext):
350
+ """Return-type for a `PostGroupPage`'s context dictionary."""
351
+
352
+ posts: OrderedDict[int, list[dict[str, str]]]
353
+
354
+
355
+ class PostGroupPage(RoutablePageMixin, Page):
356
+ """A top-level page for grouping various types of posts or articles."""
357
+
358
+ template = "picata/post_listing.html"
359
+ subpage_types: ClassVar[list[str]] = ["hpk.Article"]
360
+
361
+ intro = RichTextField(blank=True, help_text="An optional introduction to this group.")
362
+
363
+ content_panels: ClassVar[list[Panel]] = [*BasePage.content_panels, FieldPanel("intro")]
364
+
365
+ def get_context(
366
+ self, request: HttpRequest, *args: Args, **kwargs: Kwargs
367
+ ) -> PostGroupePageContext:
368
+ """Add a dictionary of posts grouped by year to the context dict."""
369
+ children = self.get_children()
370
+ if not request.user.is_authenticated:
371
+ children = children.live()
372
+ children = children.specific()
373
+ children = children.annotate(
374
+ effective_date=Coalesce("first_published_at", "latest_revision_created_at"),
375
+ year_published=ExtractYear("first_published_at"),
376
+ )
377
+
378
+ # Create an OrderedDict grouping posts by year in reverse chronological order
379
+ posts_by_year: OrderedDict = OrderedDict()
380
+ for child in children.order_by("-effective_date"):
381
+ post_data = getattr(child, "preview_data", {}).copy()
382
+ post_data.update(**child.get_publication_data(request))
383
+
384
+ # Group posts by year, defaulting to last-draft year if unpublished
385
+ if post_data["year"] not in posts_by_year:
386
+ posts_by_year[post_data["year"]] = []
387
+ posts_by_year[post_data["year"]].append(post_data)
388
+
389
+ return cast(
390
+ PostGroupePageContext,
391
+ {**super().get_context(request, *args, **kwargs), "posts_by_year": posts_by_year},
392
+ )
393
+
394
+ class Meta:
395
+ """Declare more human-friendly names for the page type."""
396
+
397
+ verbose_name: str = "post listing"
398
+ verbose_name_plural: str = "post listings"
399
+
400
+
401
+ @register_setting
402
+ class SocialSettings(BaseSiteSetting):
403
+ """Site-wide social media configuration."""
404
+
405
+ default_social_image: ForeignKey[Image] = ForeignKey(
406
+ Image,
407
+ null=True,
408
+ blank=True,
409
+ on_delete=SET_NULL,
410
+ help_text="Default image for social media previews.",
411
+ related_name="+",
412
+ )
413
+
414
+ panels: ClassVar[list[Panel]] = [
415
+ FieldPanel("default_social_image"),
416
+ ]
417
+
418
+
419
+ class HomePageContext(BasePageContext):
420
+ """Return-type for the `HomePage`'s context dictionary."""
421
+
422
+ top_content: str
423
+ bottom_content: str
424
+ recent_posts: list[BasePage]
425
+
426
+
427
+ class HomePage(BasePage):
428
+ """Single-use specialised page for the root of the site."""
429
+
430
+ template = "picata/home_page.html"
431
+
432
+ top_content = StreamField(
433
+ [
434
+ ("rich_text", RichTextBlock()),
435
+ ("image", WrappedImageChooserBlock()),
436
+ ("icon_link_lists", StaticIconLinkListsBlock()),
437
+ ],
438
+ use_json_field=True,
439
+ blank=True,
440
+ help_text="Content stream above 'Recent posts'",
441
+ )
442
+
443
+ bottom_content = StreamField(
444
+ [
445
+ ("rich_text", RichTextBlock()),
446
+ ("image", WrappedImageChooserBlock()),
447
+ ("icon_link_lists", StaticIconLinkListsBlock()),
448
+ ],
449
+ use_json_field=True,
450
+ blank=True,
451
+ help_text="Content stream rendered under 'Recent posts'",
452
+ )
453
+
454
+ content_panels: ClassVar[list[FieldPanel]] = [
455
+ *BasePage.content_panels,
456
+ FieldPanel("top_content"),
457
+ FieldPanel("bottom_content"),
458
+ ]
459
+
460
+ search_fields: ClassVar[list[index.SearchField]] = [
461
+ *Page.search_fields,
462
+ index.SearchField("top_content"),
463
+ index.SearchField("bottom_content"),
464
+ ]
465
+
466
+ def get_context(self, request: HttpRequest, *args: Args, **kwargs: Kwargs) -> HomePageContext:
467
+ """Add content streams and a recent posts list to the context."""
468
+ from hpk.helpers.wagtail import page_preview_data
469
+
470
+ recent_posts = Article.objects.live_for_user(request.user).by_date()
471
+ recent_posts = [page_preview_data(request, post) for post in recent_posts]
472
+
473
+ return cast(
474
+ HomePageContext,
475
+ {
476
+ **dict(super().get_context(request, *args, **kwargs)),
477
+ "top_content": self.top_content,
478
+ "bottom_content": self.bottom_content,
479
+ "recent_posts": recent_posts,
480
+ },
481
+ )
482
+
483
+ class Meta:
484
+ """Declare explicit human-readable names for the page type."""
485
+
486
+ verbose_name = "home page"
@@ -0,0 +1 @@
1
+ """Django settings package for the project."""