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
LICENSE.md ADDED
@@ -0,0 +1,24 @@
1
+ # The MIT License (MIT)
2
+
3
+ Copyright © `2024` `Ada Wright <ada@hpk.io>`
4
+
5
+ Permission is hereby granted, free of charge, to any person
6
+ obtaining a copy of this software and associated documentation
7
+ files (the “Software”), to deal in the Software without
8
+ restriction, including without limitation the rights to use,
9
+ copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the
11
+ Software is furnished to do so, subject to the following
12
+ conditions:
13
+
14
+ The above copyright notice and this permission notice shall be
15
+ included in all copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
18
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
19
+ OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
20
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
21
+ HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
22
+ WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
23
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
24
+ OTHER DEALINGS IN THE SOFTWARE.
README.md ADDED
@@ -0,0 +1,59 @@
1
+ # Ada's website
2
+
3
+ Wherein I set up a little website, and learn a bunch of stuff as I go.
4
+
5
+ ## What it's made of
6
+
7
+ ### Inside the box
8
+
9
+ - [Wagtail](https://wagtail.org) (on [Django](https://www.djangoproject.com)) is the web framework
10
+ <!-- - [Tailwind CSS](https://tailwindcss.com) for styling -->
11
+
12
+ ### Holding things together
13
+
14
+ - [UV](https://github.com/astral-sh/uv) for all Python project management
15
+ - [Just](https://just.systems) as a command runner
16
+ - [OpenTofu](https://opentofu.org) for DevOps
17
+ - [Postgres](https://www.postgresql.org) for the database
18
+ - [Docker](https://www.docker.com) for local development
19
+
20
+ ## Quickstart
21
+
22
+ ### Requirements
23
+
24
+ - On a Mac:
25
+
26
+ ```shell
27
+ brew install colima docker
28
+ ```
29
+
30
+ ### Run a development server
31
+
32
+ ```shell
33
+ just tofu workspace select dev
34
+ just tofu apply
35
+ ```
36
+
37
+ This will spin up a box on DigitalOcean using the settings defined in
38
+ [infra/variables.tf](infra/variables.tf), and create a DNS A record at
39
+ (workspace).for.(tld), (i.e. dev.for.hpk.io) pointing to the box. The variables
40
+ `do_token` and `ssh_fingerprint` should be defined in
41
+ [infra/secrets.tfvars](infra/secrets.tfvars). Workspace-specific variables are
42
+ defined in infra/envs/(workspace).tfvars; e.g.
43
+ [infra/envs/dev.tfvars](infra/envs/dev.tfvars) defines the 'tags' list for the
44
+ box as `[development]` and sets `cloud_init_config` to point to the
45
+ [cloud-init](https://cloud-init.io) script
46
+ [config/cloud-init-dev.yml](config/cloud-init-dev.yml).
47
+
48
+ The development cloud-init script will:
49
+
50
+ - Install the system packages [`just`](https://just.systems), [`zsh`](https://www.zsh.org),
51
+ [`gunicorn`](https://gunicorn.org), and `tree`
52
+ - Create a 'wagtail' user, with UID 1500
53
+ - Create the 'ada' user, and:
54
+ - install their SSH public keys,
55
+ - install their dotfiles,
56
+ - add them to the 'sudo' and 'wagtail' groups
57
+ - Install [Node](http://nodejs.org) on the system, from the `TF_VAR_NODE_VERSION`
58
+ defined in [.env](.env)
59
+ - Checkout this repository into `/app`, setting the owner and group to 'wagtail'.
@@ -0,0 +1,11 @@
1
+ import React from "react";
2
+
3
+ type Props = {
4
+ name: string;
5
+ };
6
+
7
+ const HelloWorld: React.FC<Props> = ({ name }) => {
8
+ return <h1>Hello, {name}!</h1>;
9
+ };
10
+
11
+ export default HelloWorld;
entrypoint.tsx ADDED
@@ -0,0 +1,268 @@
1
+ import "./styles.sass";
2
+
3
+ const THEMES = {
4
+ light: "fl",
5
+ dark: "ad",
6
+ };
7
+
8
+ // Set listeners on data-set-theme attributes to change the theme
9
+ import { themeChange } from "theme-change";
10
+ themeChange();
11
+
12
+ //
13
+ // Theme "reset to system defaults", and "light"/"dark" data-theme-mode logic
14
+ //
15
+ function initializeThemeReset() {
16
+ const themeReset = document.querySelector<HTMLSpanElement>("#theme-reset");
17
+ const themeButtons = document.querySelectorAll<HTMLButtonElement>("[data-set-theme]");
18
+
19
+ const updateThemeMode = () => {
20
+ const theme = document.documentElement.getAttribute("data-theme");
21
+ if (theme === THEMES.dark || theme === THEMES.light) {
22
+ document.documentElement.setAttribute(
23
+ "data-theme-mode",
24
+ theme === THEMES.dark ? "dark" : "light",
25
+ );
26
+ } else {
27
+ const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
28
+ document.documentElement.setAttribute("data-theme-mode", prefersDark ? "dark" : "light");
29
+ }
30
+ };
31
+
32
+ const updateThemeResetButtonVisibility = () => {
33
+ if (themeReset) {
34
+ const isThemeSet = document.documentElement.hasAttribute("data-theme");
35
+ if (isThemeSet) {
36
+ themeReset.classList.remove("hidden", "pointer-events-none");
37
+ } else {
38
+ themeReset.classList.add("hidden", "pointer-events-none");
39
+ }
40
+ }
41
+ };
42
+
43
+ // Initialize on page load
44
+ updateThemeMode();
45
+ updateThemeResetButtonVisibility();
46
+
47
+ // Add a listener for system preference changes
48
+ const prefersDarkQuery = window.matchMedia("(prefers-color-scheme: dark)");
49
+ prefersDarkQuery.addEventListener("change", () => {
50
+ if (!document.documentElement.getAttribute("data-theme")) {
51
+ updateThemeMode();
52
+ }
53
+ });
54
+
55
+ // Monitor changes to the data-theme attribute
56
+ const themeChangeObserver = new MutationObserver(() => {
57
+ updateThemeMode();
58
+ updateThemeResetButtonVisibility();
59
+ });
60
+ themeChangeObserver.observe(document.documentElement, {
61
+ attributes: true,
62
+ attributeFilter: ["data-theme"],
63
+ });
64
+
65
+ // Add click listener for the reset button
66
+ if (themeReset) {
67
+ themeReset.addEventListener("click", () => {
68
+ document.documentElement.removeAttribute("data-theme");
69
+ localStorage.removeItem("theme");
70
+
71
+ themeButtons.forEach((button) => {
72
+ button.classList.remove("btn-active");
73
+ });
74
+
75
+ updateThemeMode();
76
+ updateThemeResetButtonVisibility();
77
+ });
78
+ } else {
79
+ console.error("Could not find #theme-reset element.");
80
+ }
81
+
82
+ // Add listeners to theme buttons to toggle "btn-active" class
83
+ themeButtons.forEach((button) => {
84
+ button.addEventListener("click", () => {
85
+ const newTheme = button.getAttribute("data-set-theme");
86
+ if (newTheme) {
87
+ document.documentElement.setAttribute("data-theme", newTheme);
88
+ localStorage.setItem("theme", newTheme);
89
+ }
90
+
91
+ themeButtons.forEach((btn) => btn.classList.remove("btn-active"));
92
+ button.classList.add("btn-active");
93
+
94
+ updateThemeMode();
95
+ });
96
+ });
97
+ }
98
+
99
+ //
100
+ // Search field toggling logic
101
+ //
102
+ function initializeSearchFieldToggle() {
103
+ const searchToggleButton = document.getElementById("search-toggle") as HTMLButtonElement | null;
104
+ const searchField = document.getElementById("search-field") as HTMLElement | null;
105
+
106
+ if (!searchToggleButton || !searchField) {
107
+ console.error("Search toggle or search field elements not found.");
108
+ return;
109
+ }
110
+
111
+ searchToggleButton.addEventListener("click", () => {
112
+ const isVisible = searchField.classList.toggle("search-visible");
113
+ searchField.classList.toggle("search-hidden", !isVisible);
114
+ searchField.setAttribute("tabindex", isVisible ? "0" : "-1");
115
+ searchToggleButton.classList.toggle("!rounded-r-none", isVisible);
116
+ searchToggleButton.classList.toggle("!rounded-r-full", !isVisible);
117
+ searchToggleButton.setAttribute("aria-expanded", isVisible.toString());
118
+ if (isVisible) {
119
+ searchField.focus();
120
+ }
121
+ });
122
+ }
123
+
124
+ //
125
+ // Apply shadows to the right of code blocks when they overflow their container
126
+ //
127
+ function initializeCodeBlockOverflowWatchers(): void {
128
+ const pygmentsDivs: NodeListOf<HTMLDivElement> = document.querySelectorAll(".pygments");
129
+
130
+ const applyOverflowClass = (div: HTMLDivElement) => {
131
+ const pre = div.querySelector("pre");
132
+ if (!pre) return;
133
+
134
+ if (pre.scrollWidth > pre.clientWidth) {
135
+ div.classList.add("shadow-fade-right");
136
+ } else {
137
+ div.classList.remove("shadow-fade-right");
138
+ }
139
+ };
140
+
141
+ // Apply initial shadows
142
+ pygmentsDivs.forEach(applyOverflowClass);
143
+
144
+ // Add resize listener to recheck on window resize
145
+ window.addEventListener("resize", () => {
146
+ pygmentsDivs.forEach(applyOverflowClass);
147
+ });
148
+ }
149
+
150
+ //
151
+ // Create the nested list of internal page links for a 'Page Contents' container
152
+ //
153
+ function renderPageContents(): void {
154
+ const tocContainer = document.querySelector("main nav .toc");
155
+ if (!tocContainer) return;
156
+
157
+ // Create the header for the navigation
158
+ const tocHeader = document.createElement("h2");
159
+ tocHeader.textContent = "In this page";
160
+ tocContainer.appendChild(tocHeader);
161
+
162
+ // Create the root list
163
+ const tocList = document.createElement("ul");
164
+ tocContainer.appendChild(tocList);
165
+
166
+ // Stack to track the current list level
167
+ const listStack: HTMLUListElement[] = [tocList];
168
+ let currentLevel = 1;
169
+
170
+ // Find all anchor-linked headings
171
+ const headings = document.querySelectorAll<HTMLElement>(
172
+ "h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]",
173
+ );
174
+ headings.forEach((heading) => {
175
+ const headingLevel = parseInt(heading.tagName.substring(1)); // Extract the heading level (e.g., "1" for "H1")
176
+
177
+ // Adjust the stack to match the heading level
178
+ while (headingLevel > currentLevel) {
179
+ // Create intermediate sub-lists for skipped levels
180
+ const newList = document.createElement("ul");
181
+ const lastItem = listStack[listStack.length - 1].lastElementChild;
182
+
183
+ if (lastItem) {
184
+ lastItem.appendChild(newList);
185
+ listStack.push(newList);
186
+ } else {
187
+ // If no previous item exists, append directly to the current list
188
+ listStack[listStack.length - 1].appendChild(newList);
189
+ listStack.push(newList);
190
+ }
191
+ currentLevel++;
192
+ }
193
+
194
+ while (headingLevel < currentLevel) {
195
+ // Pop back to the parent list
196
+ listStack.pop();
197
+ currentLevel--;
198
+ }
199
+
200
+ // Add the heading to the current list
201
+ const listItem = document.createElement("li");
202
+ const link = document.createElement("a");
203
+
204
+ // Get heading text without pilcrow
205
+ link.href = `#${heading.id}`;
206
+ link.textContent = heading.textContent?.replace("¶", "").trim() || "Untitled"; // Remove pilcrow
207
+ listItem.appendChild(link);
208
+
209
+ listStack[listStack.length - 1].appendChild(listItem);
210
+ });
211
+ }
212
+
213
+ // function enableStickyTOC(): void {
214
+ // const tocContainer = document.querySelector<HTMLElement>(".toc > div");
215
+ // if (!tocContainer) return;
216
+
217
+ // const parentContainer = tocContainer.parentElement;
218
+ // if (!parentContainer) return;
219
+
220
+ // const offsetTop = 16; // Equivalent to Tailwind's `top-4`
221
+ // const marginRight = 16; // Equivalent to Tailwind's `-mr-4`
222
+
223
+ // const initialTop = parentContainer.getBoundingClientRect().top + window.scrollY;
224
+ // const parentStyles = getComputedStyle(parentContainer);
225
+
226
+ // window.addEventListener("scroll", () => {
227
+ // const currentScroll = window.scrollY;
228
+ // const stickyStart = initialTop - offsetTop;
229
+
230
+ // if (currentScroll >= stickyStart) {
231
+ // tocContainer.classList.add("is-fixed");
232
+
233
+ // tocContainer.style.position = "fixed";
234
+ // tocContainer.style.top = `${offsetTop}px`;
235
+ // tocContainer.style.maxHeight = `calc(100vh - ${offsetTop}px)`;
236
+ // tocContainer.style.overflowY = "auto";
237
+
238
+ // // Dynamically calculate width and right offset
239
+ // const parentWidth = parentContainer.getBoundingClientRect().width;
240
+ // tocContainer.style.width = `${parentWidth}px`;
241
+ // tocContainer.style.padding = parentStyles.padding; // Preserve padding
242
+ // tocContainer.style.right = `${marginRight}px`; // Apply negative right margin
243
+ // } else {
244
+ // tocContainer.classList.remove("is-fixed");
245
+
246
+ // tocContainer.style.position = "relative";
247
+ // tocContainer.style.top = "initial";
248
+ // tocContainer.style.maxHeight = "initial";
249
+ // tocContainer.style.overflowY = "initial";
250
+
251
+ // // Reset dynamically applied styles
252
+ // tocContainer.style.width = "initial";
253
+ // tocContainer.style.padding = "initial";
254
+ // tocContainer.style.right = "initial"; // Reset right offset
255
+ // }
256
+ // });
257
+ // }
258
+
259
+ //
260
+ // Main DOMContentLoaded Listener
261
+ //
262
+ document.addEventListener("DOMContentLoaded", () => {
263
+ initializeThemeReset();
264
+ initializeSearchFieldToggle();
265
+ initializeCodeBlockOverflowWatchers();
266
+ renderPageContents();
267
+ // enableStickyTOC();
268
+ });
manage.py ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env python
2
+ """Entry-point for Django management commands."""
3
+
4
+ from os import environ
5
+ from sys import argv
6
+
7
+ if __name__ == "__main__":
8
+ environ.setdefault("DJANGO_SETTINGS_MODULE", "hpk.settings.dev")
9
+
10
+ if len(argv) >= 2: # noqa: PLR2004
11
+ environ.setdefault("DJANGO_MANAGEMENT_COMMAND", argv[1])
12
+
13
+ from django.core.management import execute_from_command_line
14
+
15
+ execute_from_command_line(argv)
picata/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Main "umbrella" package for custom code running hpk.io."""
picata/apps.py ADDED
@@ -0,0 +1,33 @@
1
+ """Application configuration for the hpk Django app."""
2
+
3
+ from django.apps import AppConfig
4
+
5
+
6
+ class Config(AppConfig):
7
+ """Configuration class for the hpk Django application."""
8
+
9
+ default_auto_field = "django.db.models.BigAutoField"
10
+ name = "hpk"
11
+
12
+ def ready(self) -> None:
13
+ """Configure Wagtail admin with custom models, and register document transformers."""
14
+ #
15
+ # Register the 'custom article type' model with the Wagtail admin
16
+ from wagtail_modeladmin.options import modeladmin_register
17
+
18
+ from hpk.models import ArticleTypeAdmin
19
+
20
+ modeladmin_register(ArticleTypeAdmin)
21
+
22
+ # Add document transformers to the HTMLProcessingMiddleware
23
+ from hpk.middleware import HTMLProcessingMiddleware
24
+ from hpk.transformers import AnchorInserter, add_heading_ids
25
+
26
+ ## Add ids to all headings missing them within html > body > main
27
+ HTMLProcessingMiddleware.add_transformer(add_heading_ids)
28
+
29
+ ## Add anchored pillcrows to headings in designated pages
30
+ anchor_inserter = AnchorInserter(
31
+ root="//main/article", targets=".//h1 | .//h2 | .//h3 | .//h4 | .//h5 | .//h6"
32
+ )
33
+ HTMLProcessingMiddleware.add_transformer(anchor_inserter)
picata/blocks.py ADDED
@@ -0,0 +1,175 @@
1
+ """Wagtail "blocks"."""
2
+
3
+ import pygments
4
+ from django.forms import CharField
5
+ from django.utils.html import format_html
6
+ from hpk.typing.wagtail import BlockRenderContext, BlockRenderValue
7
+ from hpk.validators import HREFValidator
8
+ from pygments import formatters, lexers
9
+ from pygments.util import ClassNotFound
10
+ from wagtail.blocks import (
11
+ CharBlock,
12
+ ChoiceBlock,
13
+ IntegerBlock,
14
+ ListBlock,
15
+ RichTextBlock,
16
+ StreamBlock,
17
+ StructBlock,
18
+ TextBlock,
19
+ URLBlock,
20
+ )
21
+ from wagtail.images.blocks import ImageChooserBlock
22
+
23
+
24
+ class HREFField(CharField):
25
+ """Custom field for href attributes (i.e. URLs but also schemes like 'mailto:')."""
26
+
27
+ def __init__(self, *args, **kwargs) -> None: # noqa: ANN002, ANN003
28
+ """Initialise a CharField with `HREFValidator` as the only validator."""
29
+ kwargs["validators"] = [HREFValidator()]
30
+ super().__init__(*args, **kwargs)
31
+
32
+
33
+ class HREFBlock(URLBlock):
34
+ """Custom block for href attributes (i.e. a `URLBlock` using the extended `HREFField`)."""
35
+
36
+ def __init__(self, *args, **kwargs) -> None: # noqa: ANN002, ANN003
37
+ """Initialise the `URLBlock` superclass and replace our field with a `HREFField`."""
38
+ super().__init__(*args, **kwargs)
39
+ self.field = HREFField()
40
+
41
+
42
+ class StaticIconLinkItemBlock(StructBlock):
43
+ """A single list item with an optional icon and surrounding anchor."""
44
+
45
+ href = HREFBlock(
46
+ required=False,
47
+ help_text="An optional link field",
48
+ max_length=255,
49
+ )
50
+ label = CharBlock(required=True, max_length=50, help_text="The title for the list item.")
51
+ icon = CharBlock(
52
+ required=False,
53
+ max_length=255,
54
+ help_text="Id of the icon in the static/icons.svg file.",
55
+ )
56
+
57
+ class Meta:
58
+ """Meta information."""
59
+
60
+ template = "picata/blocks/icon_link_item.html"
61
+
62
+
63
+ class StaticIconLinkListBlock(StructBlock):
64
+ """A list of optionally-linked list items with an optional heading."""
65
+
66
+ heading = CharBlock(
67
+ required=False,
68
+ help_text="Optional heading for this list (e.g., Social Links).",
69
+ )
70
+ heading_level = IntegerBlock(
71
+ required=False,
72
+ min_value=1,
73
+ max_value=6,
74
+ default=2,
75
+ help_text="Heading level for the list (1-6).",
76
+ )
77
+ items = ListBlock(
78
+ StaticIconLinkItemBlock(),
79
+ help_text="The list of items.",
80
+ )
81
+
82
+ class Meta:
83
+ """Meta information."""
84
+
85
+ template = "picata/blocks/icon_link_list.html"
86
+
87
+
88
+ class StaticIconLinkListsBlock(StructBlock):
89
+ """A wrapper for multiple heading-and-link-list blocks."""
90
+
91
+ lists = StreamBlock(
92
+ [
93
+ ("link_list", StaticIconLinkListBlock()),
94
+ ],
95
+ required=False,
96
+ help_text="Add one or more heading-and-link-list blocks.",
97
+ )
98
+
99
+ class Meta:
100
+ """Meta information."""
101
+
102
+ template = "picata/blocks/icon_link_list_stream.html"
103
+
104
+
105
+ class CodeBlock(StructBlock):
106
+ """A block for displaying code with optional syntax highlighting."""
107
+
108
+ code = TextBlock(required=True, help_text=None)
109
+ language = ChoiceBlock(
110
+ required=False,
111
+ choices=[
112
+ ("python", "Python"),
113
+ ("javascript", "JavaScript"),
114
+ ("html", "HTML"),
115
+ ("css", "CSS"),
116
+ ("bash", "Bash"),
117
+ ("plaintext", "Plain Text"),
118
+ ],
119
+ help_text=None,
120
+ )
121
+
122
+ def render_basic(self, value: BlockRenderValue, context: BlockRenderContext = None) -> str:
123
+ """Render the code block with syntax highlighting."""
124
+ code = value.get("code", "")
125
+ language = value.get("language", "plaintext")
126
+ try:
127
+ lexer = lexers.get_lexer_by_name(language)
128
+ formatter = formatters.HtmlFormatter(cssclass="pygments")
129
+ highlighted_code = pygments.highlight(code, lexer, formatter)
130
+ except ClassNotFound:
131
+ highlighted_code = f"<pre><code>{code}</code></pre>"
132
+
133
+ return format_html(highlighted_code)
134
+
135
+ class Meta:
136
+ """Meta information."""
137
+
138
+ icon = "code"
139
+ label = "Code Block"
140
+
141
+
142
+ class SectionBlock(StructBlock):
143
+ """A page section, with a heading rendered at a defined level."""
144
+
145
+ heading = CharBlock(
146
+ required=True, help_text='Heading for this section, included in "page contents".'
147
+ )
148
+ level = IntegerBlock(required=True, min_value=1, max_value=6, help_text="Heading level")
149
+ content = StreamBlock(
150
+ [
151
+ ("rich_text", RichTextBlock()),
152
+ ("image", ImageChooserBlock()),
153
+ ],
154
+ required=False,
155
+ help_text=None,
156
+ )
157
+
158
+ class Meta:
159
+ """Meta-info for the block."""
160
+
161
+ icon = "folder"
162
+ label = "Section"
163
+
164
+
165
+ class WrappedImageChooserBlock(ImageChooserBlock):
166
+ """An ImageChooserBlock that wraps the output in a div."""
167
+
168
+ def render_basic(self, value: BlockRenderValue, context: BlockRenderContext = None) -> str:
169
+ """Render the image wrapped in a div with a custom class."""
170
+ if not value: # If no image is selected, return an empty string
171
+ return ""
172
+
173
+ # Use Wagtail's default rendering for the image, wrapped in a <div>
174
+ image_tag = super().render_basic(value, context)
175
+ return f'<div class="image-wrapper">{image_tag}</div>'
@@ -0,0 +1,70 @@
1
+ """Generic helper-functions."""
2
+ # NB: Django's meta-class shenanigans over-complicate type hinting when QuerySets get involved.
3
+ # pyright: reportAttributeAccessIssue=false
4
+
5
+ import re
6
+ from ipaddress import AddressValueError, IPv4Address
7
+
8
+ from django.apps import apps
9
+ from django.db.models import Model
10
+ from django.http import HttpResponse, StreamingHttpResponse
11
+ from lxml.etree import _Element
12
+
13
+ # Pre-compile commonly used regular expressions
14
+ ALPHANUMERIC_REGEX = re.compile(r"[^a-zA-Z0-9]")
15
+
16
+
17
+ def get_models_of_type(base_type: type[Model]) -> list[type[Model]]:
18
+ """Retrieve all concrete subclasses of the given base Model type."""
19
+ all_models = apps.get_models()
20
+
21
+ return [
22
+ model
23
+ for model in all_models
24
+ if issubclass(model, base_type) and not model._meta.abstract # noqa: SLF001
25
+ ]
26
+
27
+
28
+ def get_public_ip() -> IPv4Address | None:
29
+ """Fetch the public-facing IP of the current host."""
30
+ import socket
31
+
32
+ import psutil
33
+
34
+ for addrs in psutil.net_if_addrs().values():
35
+ for addr in addrs:
36
+ if addr.family == socket.AF_INET:
37
+ ip = addr.address
38
+ if not ip.startswith(("10.", "192.168.", "172.", "127.")):
39
+ try:
40
+ return IPv4Address(ip)
41
+ except AddressValueError:
42
+ pass
43
+ return None
44
+
45
+
46
+ def get_full_text(element: _Element) -> str:
47
+ """Extract text from an element and its descendants, concatenate it, and trim whitespace."""
48
+ return "".join(element.xpath(".//text()")).strip()
49
+
50
+
51
+ def make_response(
52
+ original_response: HttpResponse,
53
+ new_content: str | bytes,
54
+ ) -> HttpResponse:
55
+ """Create a new HttpResponse while preserving attributes from the original response."""
56
+ if isinstance(original_response, StreamingHttpResponse):
57
+ raise TypeError("StreamingHttpResponse objects are not supported.")
58
+ new_response = HttpResponse(
59
+ content=new_content,
60
+ content_type=original_response.get("Content-Type", None),
61
+ status=original_response.status_code,
62
+ )
63
+ for key, value in original_response.headers.items():
64
+ new_response[key] = value
65
+ new_response.cookies = original_response.cookies
66
+ for attr in dir(original_response):
67
+ if not attr.startswith("_") and not hasattr(HttpResponse, attr):
68
+ setattr(new_response, attr, getattr(original_response, attr))
69
+
70
+ return new_response