picata 0.0.1__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
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."""