65 Commits

Author SHA1 Message Date
ponas 0b0fba174e dnscms: remove some wordpress import functionality, keep fields around but defer 2026-05-23 01:31:44 +02:00
ponas 10763f0b5d dnscms: add missing migration for help text changes 2026-05-23 01:28:22 +02:00
ponas f536cfc591 web: add lightspeed tools 2026-05-20 01:02:02 +02:00
ponas 80f7641e74 web: fix typo in media query for venue items 2026-05-20 01:01:35 +02:00
ponas 8b5caa2bea web: dynamically load swiper 2026-05-20 00:43:50 +02:00
ponas 5ac3c71ff0 web: disable hidden NeonChillPig 2026-05-20 00:39:54 +02:00
ponas a3d71b18da web: enable avif image format 2026-05-20 00:06:04 +02:00
ponas 7f95d8e252 web: set high fetch priority for top two featured events (above the fold on mobile) 2026-05-20 00:05:09 +02:00
ponas e4c0558527 web: fetch less data when just listing events and news articles on home page 2026-05-19 23:47:30 +02:00
ponas 80b9cdbc33 dnscms: prefetch and select related when quering future events 2026-05-19 23:46:13 +02:00
ponas 154338057d web: preconnect to typekit 2026-05-19 23:02:50 +02:00
ponas 337407c771 web: optimize favicon.ico and icon.png 2026-05-19 22:59:08 +02:00
ponas cb9b108526 web: don't start loading pig pattern until user starts to approach it 2026-05-19 22:50:33 +02:00
ponas 6d712d31be web: optimize delivery of pig pattern in footer 2026-05-19 22:09:25 +02:00
ponas 447e1bd3ff dnscms: improve news app 2026-05-19 22:00:11 +02:00
ponas 29c61ffc76 dnscms: improve associations app 2026-05-19 21:49:38 +02:00
ponas 4a264c589d web: add eager loading to featured event images on home page 2026-05-19 21:32:56 +02:00
ponas 9ca9f5db11 dnscms: translate events app 2026-05-19 21:25:28 +02:00
ponas 696e6b8f11 dnscms: move sample legacy events to separate file 2026-05-19 21:00:21 +02:00
ponas 5f354972d9 dnscms: start adding locales, set and translate verbose names for events 2026-05-19 20:55:53 +02:00
ponas 6a9fff8917 dnscms: improve events listing view 2026-05-19 20:35:46 +02:00
ponas 1073adacbb dnscms: bump wagtail 2026-05-19 19:47:46 +02:00
ponas 7e114adcc3 web: change preview banner styling 2026-05-19 18:46:41 +02:00
ponas 0e074b5f1f web: dangerously allow local IP image proxying during development 2026-05-19 18:46:12 +02:00
ponas e960da6f1c web: centralize prop fetching for live + preview, fix preview banner breakage 2026-05-19 18:37:58 +02:00
ponas a5ebb897f1 add support for previewing pages 2026-05-19 17:48:33 +02:00
ponas f91c67f526 web: remove redundant on self wrapping in queries and add typename 2026-05-19 17:41:51 +02:00
ponas 0c5a9876d6 web: move page rendering logic from page.tsx to components 2026-05-19 17:00:12 +02:00
ponas cf945d8647 don't run pre commit hooks on autogenerated files 2026-05-19 16:59:08 +02:00
ponas 0e5f9f7769 web: render studio page 2026-05-19 06:52:47 +02:00
ponas 10c8ce194c dnscms, web: try supporting svgs 2026-05-19 06:15:36 +02:00
ponas 509a50c321 dnscms: add studio page 2026-05-19 06:09:54 +02:00
ponas 843062bb13 dnscms: add more tests 2026-05-19 04:42:27 +02:00
ponas f7e0200a0a dnscms: add pytest-cov 2026-05-19 04:11:39 +02:00
ponas b09ce9808d web: colocate graphql fragments, unmask where needed, more idiomatic client-preset use 2026-05-19 01:49:58 +02:00
ponas bc8642b1fc web: bump deps 2026-05-15 14:04:10 +02:00
ponas 2155a149e8 consolidate readmes + mise config, add prek 2026-05-15 03:26:46 +02:00
ponas 676e58c361 dnscms: use timezone aware date when filtering for future events 2026-05-15 03:08:57 +02:00
ponas a9e59b947a dnscms: bump deps, notably wagtail to v7.4 2026-05-15 03:01:56 +02:00
ponas 202cfe47f3 dnscms: add some basic tests 2026-05-15 02:58:41 +02:00
ponas d0a886a4ae web: bump deps 2026-03-05 22:02:29 +01:00
ponas cc5b53011d dnscms: bump deps 2026-03-05 21:54:58 +01:00
ponas f769898698 dnscms: add django.contrib.postgres 2026-02-16 02:43:23 +01:00
ponas e71db3a0cc dnscms: switch from 1 to 3 gunicorn workers 2026-02-16 02:38:45 +01:00
ponas 355c0b38a5 dnscms: bump deps 2026-02-16 02:37:33 +01:00
ponas bba98fe868 web: fix missing content for accordion blocks inside page section blocks 2026-02-04 20:44:39 +01:00
ponas 978aae4fc3 web: bump deps 2026-02-04 19:59:29 +01:00
ponas ffc3a583b2 web: bump all deps 2025-12-12 15:25:53 +01:00
ponas 12aea04779 web: bump all deps, notably next to v16.0.7 to fix CVE-2025-55182 2025-12-03 21:08:44 +01:00
leander 6bcf9bbfbd fix url parameter organizer replacing first dropdown item
add organizer at the end instead of during filtering. Probably not the
optimal way to do this but works for now.
2025-11-19 23:24:08 +01:00
leander 2e4ca34f5c make association featured link in navbar 2025-11-19 23:12:45 +01:00
leander d76b16781d switch member and volunteer links in navbar 2025-11-19 23:06:04 +01:00
ponas 8942bcc9da web: allow filtering on venues and organizers with no upcoming events, reset filter if invalid 2025-11-19 21:08:56 +01:00
ponas 196f000a2d web: fix organizer filtering 2025-11-19 20:23:32 +01:00
ponas 4655f67a9e web: set turbopack root 2025-11-19 20:23:04 +01:00
ponas 8506fd1c2d dnscms: increase page size and max page size for wagtail-grapple 2025-11-19 20:09:55 +01:00
ponas ca341f5f22 workaround number of event occurrences somehow being limited to 10 2025-11-17 03:34:04 +01:00
ponas 1431b8d6ff web: regenerate types 2025-11-14 02:42:13 +01:00
ponas 3c225aa68a web: bump everything, notably node to v14 and nextjs to v16.0.3 2025-11-14 02:41:49 +01:00
ponas 16629a2fc0 dnscms: bump python to 3.14, bump deps, notably wagtail to 7.2 2025-11-14 02:32:43 +01:00
ponas b18f9ec54f fix build 2025-10-06 21:53:25 +02:00
ponas 2a00d21717 temporarily use the DNS pig with hat 2025-10-06 21:08:48 +02:00
ponas 09e69a7093 avoid double timezone conversion to fix showing the wrong date for faraway spiders and users 2025-09-14 01:21:39 +02:00
ponas d3f8b8f0bb allow wider paragraph in newsletter block 2025-08-11 18:57:21 +02:00
ponas fcd5231c28 change newsletter text 2025-08-11 18:50:32 +02:00
159 changed files with 12590 additions and 9745 deletions
+1
View File
@@ -1,3 +1,4 @@
.vscode .vscode
.DS_Store .DS_Store
*.swp *.swp
scratch/
+36
View File
@@ -0,0 +1,36 @@
# neuf-www
The neuf.no website. Wagtail CMS backend (`dnscms/`) feeding a Next.js frontend (`web/`) over GraphQL.
Tools are managed by [mise](https://mise.jdx.dev/). Run `mise install` to get python, uv, node, and prek.
## Backend (`dnscms/`)
```bash
cd dnscms
uv sync
uv run ./manage.py migrate
uv run ./manage.py runserver
uv run pytest
```
GraphQL endpoint: <http://127.0.0.1:8000/api/graphql/>.
## Frontend (`web/`)
```bash
cd web
npm install
npm run dev # http://localhost:3000
npm run codegen # regenerate GraphQL types (needs the backend running)
npm run build
```
## Pre-commit hooks
[prek](https://github.com/j178/prek) runs ruff lint + format on `dnscms/**/*.py` plus a few sanity hooks. Hooks are configured in [prek.toml](prek.toml).
```bash
prek install # registers the git hook
prek run --all-files # run on everything
```
+1
View File
@@ -9,6 +9,7 @@ __pycache__
.DS_Store .DS_Store
*.swp *.swp
/venv/ /venv/
/.venv/
/tmp/ /tmp/
/.vagrant/ /.vagrant/
/Vagrantfile.local /Vagrantfile.local
+1
View File
@@ -2,6 +2,7 @@
.DS_Store .DS_Store
*.swp *.swp
.vscode/ .vscode/
.coverage
/venv/ /venv/
/.venv/ /.venv/
/static/ /static/
+2 -2
View File
@@ -1,4 +1,4 @@
FROM python:3.12-slim-bullseye FROM python:3.14-slim-trixie
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
RUN useradd wagtail RUN useradd wagtail
@@ -37,4 +37,4 @@ USER wagtail
ENV PATH="/app/.venv/bin:$PATH" ENV PATH="/app/.venv/bin:$PATH"
RUN python manage.py collectstatic --noinput --clear RUN python manage.py collectstatic --noinput --clear
CMD ["sh", "-c", "set -xe; python manage.py migrate --noinput && gunicorn dnscms.wsgi:application"] CMD ["sh", "-c", "set -xe; python manage.py migrate --noinput && gunicorn dnscms.wsgi:application --workers 3"]
+46
View File
@@ -0,0 +1,46 @@
from django.utils.translation import gettext_lazy as _
from wagtail.admin.ui.tables import Column, DateColumn
from wagtail.admin.ui.tables.pages import PageStatusColumn, PageTitleColumn
from wagtail.admin.viewsets.pages import PageListingViewSet
from associations.models import AssociationPage
from dnscms.admin import ListingRedirectChooseParentView
class AssociationTypeColumn(Column):
def get_value(self, instance):
return instance.get_association_type_display()
class AssociationChooseParentView(ListingRedirectChooseParentView):
listing_url_name = "associations:index"
class AssociationPageListingViewSet(PageListingViewSet):
model = AssociationPage
choose_parent_view_class = AssociationChooseParentView
icon = "group"
menu_label = _("Associations")
menu_order = 2
add_to_admin_menu = True
ordering = "title"
columns = [
PageTitleColumn("title", label=_("Title"), sort_key="title", classname="title"),
AssociationTypeColumn(
"association_type",
label=_("Type"),
sort_key="association_type",
width="15%",
),
DateColumn(
"latest_revision_created_at",
label=_("Updated"),
sort_key="latest_revision_created_at",
width="10%",
),
PageStatusColumn("status", label=_("Status"), sort_key="live", width="10%"),
]
association_page_listing_viewset = AssociationPageListingViewSet("associations")
@@ -0,0 +1,26 @@
# Generated by Django 6.0.5 on 2026-05-19 19:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('associations', '0025_associationpage_lead'),
]
operations = [
migrations.AlterModelOptions(
name='associationindex',
options={'verbose_name': 'association index', 'verbose_name_plural': 'association indexes'},
),
migrations.AlterModelOptions(
name='associationpage',
options={'verbose_name': 'association', 'verbose_name_plural': 'associations'},
),
migrations.AlterField(
model_name='associationpage',
name='association_type',
field=models.CharField(choices=[('forening', 'Association'), ('utvalg', 'Committee')], default='forening', max_length=64),
),
]
+29 -43
View File
@@ -1,4 +1,5 @@
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _
from grapple.helpers import register_singular_query_field from grapple.helpers import register_singular_query_field
from grapple.models import ( from grapple.models import (
GraphQLImage, GraphQLImage,
@@ -8,15 +9,20 @@ from grapple.models import (
) )
from wagtail.admin.panels import FieldPanel from wagtail.admin.panels import FieldPanel
from wagtail.fields import RichTextField from wagtail.fields import RichTextField
from wagtail.models import Page from wagtail.models import Page, PageManager
from wagtail.search import index from wagtail.search import index
from wagtail_headless_preview.models import HeadlessMixin
from dnscms.fields import CommonStreamField from dnscms.fields import CommonStreamField
from dnscms.wordpress.models import WPImportedPageMixin from dnscms.wordpress.models import DeferWPFieldsManagerMixin, WPImportedPageMixin
class AssociationPageManager(DeferWPFieldsManagerMixin, PageManager):
pass
@register_singular_query_field("associationIndex") @register_singular_query_field("associationIndex")
class AssociationIndex(Page): class AssociationIndex(HeadlessMixin, Page):
max_count = 1 max_count = 1
subpage_types = ["associations.AssociationPage"] subpage_types = ["associations.AssociationPage"]
@@ -25,8 +31,8 @@ class AssociationIndex(Page):
body = CommonStreamField body = CommonStreamField
content_panels = Page.content_panels + [ content_panels = Page.content_panels + [
FieldPanel("lead", heading="Ingress"), FieldPanel("lead", heading=_("Lead")),
FieldPanel("body", heading="Innhold"), FieldPanel("body", heading=_("Content")),
] ]
graphql_fields = [ graphql_fields = [
@@ -36,15 +42,21 @@ class AssociationIndex(Page):
search_fields = Page.search_fields search_fields = Page.search_fields
class Meta:
verbose_name = _("association index")
verbose_name_plural = _("association indexes")
class AssociationPage(WPImportedPageMixin, Page):
class AssociationPage(HeadlessMixin, WPImportedPageMixin, Page):
subpage_types = [] subpage_types = []
parent_page_types = ["associations.AssociationIndex"] parent_page_types = ["associations.AssociationIndex"]
show_in_menus = False show_in_menus = False
objects = AssociationPageManager()
class AssociationType(models.TextChoices): class AssociationType(models.TextChoices):
FORENING = "forening", "Forening" FORENING = "forening", _("Association")
UTVALG = "utvalg", "Utvalg" UTVALG = "utvalg", _("Committee")
excerpt = models.TextField(max_length=512, blank=False) excerpt = models.TextField(max_length=512, blank=False)
lead = RichTextField(features=["italic", "link"], blank=True) lead = RichTextField(features=["italic", "link"], blank=True)
@@ -64,14 +76,14 @@ class AssociationPage(WPImportedPageMixin, Page):
content_panels = Page.content_panels + [ content_panels = Page.content_panels + [
FieldPanel( FieldPanel(
"excerpt", "excerpt",
heading="Utdrag", heading=_("Excerpt"),
help_text="En veldig kort oppsummering av innholdet nedenfor. Brukes i listevisninger.", help_text=_("A very short summary of the content below. Used in listing views."),
), ),
FieldPanel("lead", heading="Ingress"), FieldPanel("lead", heading=_("Lead")),
FieldPanel("body", heading="Innhold"), FieldPanel("body", heading=_("Content")),
FieldPanel("logo"), FieldPanel("logo"),
FieldPanel("association_type", heading="Type"), FieldPanel("association_type", heading=_("Type")),
FieldPanel("website_url", heading="Nettside"), FieldPanel("website_url", heading=_("Website")),
] ]
graphql_fields = [ graphql_fields = [
@@ -88,32 +100,6 @@ class AssociationPage(WPImportedPageMixin, Page):
index.SearchField("body"), index.SearchField("body"),
] ]
def import_wordpress_data(self, data): class Meta:
import html verbose_name = _("association")
verbose_name_plural = _("associations")
# Wagtail page model fields
self.title = html.unescape(data["title"])
self.slug = data["slug"]
self.first_published_at = data["first_published_at"]
self.last_published_at = data["last_published_at"]
self.latest_revision_created_at = data["latest_revision_created_at"]
self.search_description = data["search_description"]
# debug fields
self.wp_post_id = data["wp_post_id"]
self.wp_post_type = data["wp_post_type"]
self.wp_link = data["wp_link"]
self.wp_raw_content = data["wp_raw_content"]
self.wp_block_json = data["wp_block_json"]
self.wp_processed_content = data["wp_processed_content"]
self.wp_normalized_styles = data["wp_normalized_styles"]
self.wp_post_meta = data["wp_post_meta"]
# own model fields
self.body = data["body"] or ""
meta = data["wp_post_meta"]
self.association_type = meta.get("neuf_associations_type").lower()
self.website_url = meta.get("neuf_associations_homepage") or ""
self.excerpt = meta.get("excerpt_encoded") or "TODO"
-2
View File
@@ -1,2 +0,0 @@
# Create your tests here.
+4 -3
View File
@@ -1,12 +1,13 @@
from django.utils.translation import gettext_lazy as _
from wagtail.admin.viewsets.chooser import ChooserViewSet from wagtail.admin.viewsets.chooser import ChooserViewSet
class AssociationChooserViewSet(ChooserViewSet): class AssociationChooserViewSet(ChooserViewSet):
model = "associations.AssociationPage" model = "associations.AssociationPage"
icon = "group" icon = "group"
choose_one_text = "Choose an association" choose_one_text = _("Choose an association")
choose_another_text = "Choose another association" choose_another_text = _("Choose another association")
edit_item_text = "Edit this association" edit_item_text = _("Edit this association")
# form_fields = ["name"] # form_fields = ["name"]
+6
View File
@@ -1,8 +1,14 @@
from wagtail import hooks from wagtail import hooks
from .admin import association_page_listing_viewset
from .views import association_chooser_viewset from .views import association_chooser_viewset
@hooks.register("register_admin_viewset") @hooks.register("register_admin_viewset")
def register_viewset(): def register_viewset():
return association_chooser_viewset return association_chooser_viewset
@hooks.register("register_admin_viewset")
def register_association_page_listing_viewset():
return association_page_listing_viewset
+2 -1
View File
@@ -11,6 +11,7 @@ from wagtail.fields import RichTextField, StreamField
from wagtail.models import Page from wagtail.models import Page
from wagtail.search import index from wagtail.search import index
from wagtail.snippets.models import register_snippet from wagtail.snippets.models import register_snippet
from wagtail_headless_preview.models import HeadlessMixin
from contacts.blocks import ContactSectionBlock from contacts.blocks import ContactSectionBlock
from dnscms.blocks import BASE_BLOCKS from dnscms.blocks import BASE_BLOCKS
@@ -22,7 +23,7 @@ PHONE_REGEX_VALIDATOR = RegexValidator(
@register_singular_query_field("contactIndex") @register_singular_query_field("contactIndex")
class ContactIndex(Page): class ContactIndex(HeadlessMixin, Page):
max_count = 1 max_count = 1
subpage_types = [] subpage_types = []
+27
View File
@@ -0,0 +1,27 @@
from urllib.parse import urlencode
from django.urls import reverse
from wagtail.admin.views.pages.choose_parent import ChooseParentView
class ListingRedirectChooseParentView(ChooseParentView):
"""ChooseParentView that redirects new pages back to a listing viewset.
Subclasses set ``listing_url_name`` (e.g. ``"events:index"``).
"""
listing_url_name: str
def _with_next(self, response):
if response.status_code != 302:
return response
url = response["Location"]
sep = "&" if "?" in url else "?"
response["Location"] = f"{url}{sep}{urlencode({'next': reverse(self.listing_url_name)})}"
return response
def get(self, request, *args, **kwargs):
return self._with_next(super().get(request, *args, **kwargs))
def form_valid(self, form):
return self._with_next(super().form_valid(form))
+27 -76
View File
@@ -12,7 +12,6 @@ https://docs.djangoproject.com/en/4.2/ref/settings/
# Build paths inside the project like this: os.path.join(BASE_DIR, ...) # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
import os import os
import re
PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BASE_DIR = os.path.dirname(PROJECT_DIR) BASE_DIR = os.path.dirname(PROJECT_DIR)
@@ -37,9 +36,11 @@ INSTALLED_APPS = [
"news", "news",
"openinghours", "openinghours",
"sponsors", "sponsors",
"studio",
# end cms apps # end cms apps
"grapple", "grapple",
"graphene_django", "graphene_django",
"wagtail_headless_preview",
"wagtail.contrib.forms", "wagtail.contrib.forms",
"wagtail.contrib.redirects", "wagtail.contrib.redirects",
"wagtail.contrib.settings", "wagtail.contrib.settings",
@@ -61,6 +62,7 @@ INSTALLED_APPS = [
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"django.contrib.postgres",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@@ -141,6 +143,8 @@ USE_L10N = True
USE_TZ = True USE_TZ = True
LOCALE_PATHS = [os.path.join(BASE_DIR, "locale")]
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.2/howto/static-files/ # https://docs.djangoproject.com/en/4.2/howto/static-files/
@@ -172,6 +176,7 @@ WAGTAIL_SITE_NAME = "dnscms"
WAGTAIL_ALLOW_UNICODE_SLUGS = False WAGTAIL_ALLOW_UNICODE_SLUGS = False
WAGTAILIMAGES_IMAGE_MODEL = "images.CustomImage" WAGTAILIMAGES_IMAGE_MODEL = "images.CustomImage"
WAGTAILIMAGES_EXTENSIONS = ["avif", "gif", "jpg", "jpeg", "png", "webp", "svg"]
# Search # Search
# https://docs.wagtail.org/en/stable/topics/search/backends.html # https://docs.wagtail.org/en/stable/topics/search/backends.html
@@ -182,11 +187,25 @@ WAGTAILSEARCH_BACKENDS = {
} }
# Base URL to use when referring to full URLs within the Wagtail admin backend - # Base URL to use when referring to full URLs within the Wagtail admin backend -
# e.g. in notification emails. Don't include '/admin' or a trailing slash # e.g. in notification emails. Don't include '/admin' or a trailing slash.
WAGTAILADMIN_BASE_URL = "http://example.com" # Also used by wagtail-grapple to make image URLs absolute.
WAGTAIL_BASE_URL = os.environ.get("WAGTAIL_BASE_URL", "http://127.0.0.1:8000").rstrip("/")
WAGTAILADMIN_BASE_URL = WAGTAIL_BASE_URL
BASE_URL = WAGTAIL_BASE_URL
# Required by wagtail-grapple to make image URLs absolute # Public URL of the Next.js frontend. Used to direct preview iframes and to
BASE_URL = "http://example.com" # redirect "View Live" clicks on the CMS host over to the headless frontend.
FRONTEND_BASE_URL = os.environ.get("FRONTEND_BASE_URL", "http://localhost:3000").rstrip("/")
WAGTAIL_HEADLESS_PREVIEW = {
"CLIENT_URLS": {"default": f"{FRONTEND_BASE_URL}/api/preview"},
"SERVE_BASE_URL": FRONTEND_BASE_URL,
"ENFORCE_TRAILING_SLASH": False,
"REDIRECT_ON_PREVIEW": False,
}
# https://docs.wagtail.org/en/latest/releases/6.4.html#data-upload-max-number-fields-update
DATA_UPLOAD_MAX_NUMBER_FIELDS = 10_000
# GraphQL # GraphQL
GRAPHENE = {"SCHEMA": "grapple.schema.schema"} GRAPHENE = {"SCHEMA": "grapple.schema.schema"}
@@ -202,77 +221,9 @@ GRAPPLE = {
"news", "news",
"openinghours", "openinghours",
"sponsors", "sponsors",
"studio",
], ],
"EXPOSE_GRAPHIQL": True, "EXPOSE_GRAPHIQL": True,
"PAGE_SIZE": 100,
"MAX_PAGE_SIZE": 5000,
} }
# Wgtail WordPress import
WAGTAIL_WORDPRESS_IMPORTER_SOURCE_DOMAIN = "https://studentersamfundet.no/"
WAGTAIL_WORDPRESS_IMPORTER_CONVERT_HTML_TAGS_TO_BLOCKS = {
# "h1": "wagtail_wordpress_import.block_builder_defaults.build_heading_block",
"table": "wagtail_wordpress_import.block_builder_defaults.build_table_block",
# "iframe": "wagtail_wordpress_import.block_builder_defaults.build_iframe_block",
# "form": "wagtail_wordpress_import.block_builder_defaults.build_form_block",
# "img": "wagtail_wordpress_import.block_builder_defaults.build_image_block",
# "blockquote": "wagtail_wordpress_import.block_builder_defaults.build_block_quote_block",
}
WAGTAIL_WORDPRESS_IMPORTER_FALLBACK_BLOCK = (
"dnscms.wordpress.block_builder.build_richtext_block_content"
)
WORDPRESS_IMPORT_HOOKS_ITEMS_TO_CACHE = {
"attachment": {
"DATA_TAG": "thumbnail_id",
"FUNCTION": "dnscms.wordpress.import_hooks.header_image_processor",
}
}
# WORDPRESS_IMPORT_HOOKS_TAGS_TO_CACHE = {
# "wp:term": {
# "DATA_TAG": "category",
# "FUNCTION": "dnscms.wordpress.import_hooks.categories_processor",
# }
# }
WAGTAIL_WORDPRESS_IMPORTER_PROMOTE_CHILD_TAGS = {
"TAGS_TO_PROMOTE": [],
"PARENTS_TO_REMOVE": ["p", "div", "span"],
}
WAGTAIL_WORDPRESS_IMPORT_PREFILTERS = [
{
"FUNCTION": "wagtail_wordpress_import.prefilters.linebreaks_wp",
},
{
"FUNCTION": "wagtail_wordpress_import.prefilters.transform_shortcodes",
},
{
"FUNCTION": "wagtail_wordpress_import.prefilters.transform_inline_styles",
"OPTIONS": {
"TRANSFORM_STYLES_MAPPING": [
(
re.compile(r"font-style:italic;font-weight:bold;", re.IGNORECASE),
"wagtail_wordpress_import.prefilters.transform_styles_defaults.transform_style_bold_italic",
),
(
re.compile(r"font-weight:bold;", re.IGNORECASE),
"wagtail_wordpress_import.prefilters.transform_styles_defaults.transform_style_bold",
),
(
re.compile(r"font-style:italic;", re.IGNORECASE),
"wagtail_wordpress_import.prefilters.transform_styles_defaults.transform_style_italic",
),
# (
# re.compile(
# r"text-align:center;",
# re.IGNORECASE,
# ),
# transform_style_center,
# ),
# (re.compile(r"text-align:left;", re.IGNORECASE), transform_style_left),
# (re.compile(r"text-align:right;", re.IGNORECASE), transform_style_right),
# (re.compile(r"float:left;", re.IGNORECASE), transform_float_left),
# (re.compile(r"float:right;", re.IGNORECASE), transform_float_right),
],
},
},
{
"FUNCTION": "wagtail_wordpress_import.prefilters.bleach_clean",
},
]
-7
View File
@@ -11,13 +11,6 @@ ALLOWED_HOSTS = ["*"]
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
# Base URL to use when referring to full URLs within the Wagtail admin backend -
# e.g. in notification emails. Don't include '/admin' or a trailing slash
WAGTAILADMIN_BASE_URL = "http://127.0.0.1:8000"
# Required by wagtail-grapple to make image URLs absolute
BASE_URL = "http://127.0.0.1:8000"
try: try:
from .local import * from .local import *
except ImportError: except ImportError:
+23
View File
@@ -0,0 +1,23 @@
from .base import * # noqa: F401, F403
SECRET_KEY = "test-secret-key"
DEBUG = False
ALLOWED_HOSTS = ["*"]
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": ":memory:",
}
}
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
},
}
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
-34
View File
@@ -1,13 +1,6 @@
from django.contrib.admin.utils import quote
from django.templatetags.static import static from django.templatetags.static import static
from django.urls import reverse
from django.utils.html import format_html from django.utils.html import format_html
from wagtail import hooks from wagtail import hooks
from wagtail.admin.menu import MenuItem
from associations.models import AssociationIndex
from events.models import EventIndex
from news.models import NewsIndex
@hooks.register("register_rich_text_features") @hooks.register("register_rich_text_features")
@@ -15,33 +8,6 @@ def enable_additional_rich_text_features(features):
features.default_features.extend(["h5", "h6", "blockquote"]) features.default_features.extend(["h5", "h6", "blockquote"])
@hooks.register("register_admin_menu_item")
def register_events_menu_item():
page = EventIndex.objects.first()
events_url = "#"
if page:
events_url = reverse("wagtailadmin_explore", args=(quote(page.pk),))
return MenuItem("Arrangementer", events_url, icon_name="date", order=1)
@hooks.register("register_admin_menu_item")
def register_associations_menu_item():
page = AssociationIndex.objects.first()
associations_url = "#"
if page:
associations_url = reverse("wagtailadmin_explore", args=(quote(page.pk),))
return MenuItem("Foreninger", associations_url, icon_name="group", order=2)
@hooks.register("register_admin_menu_item")
def register_associations_menu_item():
page = NewsIndex.objects.first()
news_url = "#"
if page:
news_url = reverse("wagtailadmin_explore", args=(quote(page.pk),))
return MenuItem("Nyheter", news_url, icon_name="info-circle", order=3)
@hooks.register("construct_page_action_menu") @hooks.register("construct_page_action_menu")
def make_publish_default_action(menu_items, request, context): def make_publish_default_action(menu_items, request, context):
for index, item in enumerate(menu_items): for index, item in enumerate(menu_items):
-24
View File
@@ -1,24 +0,0 @@
from django.conf import settings
from wagtail_wordpress_import.block_builder_defaults import (
document_linker,
image_linker,
import_string,
)
def build_richtext_block_content(html, blocks):
"""
image_linker is called to link up and retrive the remote image
document_linker is called to link up and retrive the remote documents
filters are called to replace inline shortcodes
"""
html = image_linker(html)
html = document_linker(html)
for inline_shortcode_handler in getattr(
settings, "WAGTAIL_WORDPRESS_IMPORTER_INLINE_SHORTCODE_HANDLERS", []
):
function = import_string(inline_shortcode_handler).construct_html_tag
html = function(html)
blocks.append({"type": "paragraph", "value": html})
html = ""
return html
-51
View File
@@ -1,51 +0,0 @@
from wagtail_wordpress_import.block_builder_defaults import get_or_save_image
def header_image_processor(imported_pages, data_tag, items_cache):
"""
imported_pages:
Is a specific() page model queryset of all imported pages.
data_tag:
Is the value of the `DATA_TAG` key from the configuration above.
items_cache:
Is a list of dictionaries, one for each item in the XML file.
"""
# See note above about leading _ and : characters in the XML value
lookup = f"wp_post_meta__{data_tag}"
for attachment in items_cache:
# The id of the cached item used in the filter
thumbnail_id = attachment.get("wp:post_id")
# Filter the imported_pages for only pages that include the
# matching thumbnail_id in the wp_post_meta field
pages = imported_pages.filter(**{lookup: thumbnail_id})
if pages.exists():
# guid is the url of the image to fetch, the get_or_save_image()
# function will fetch the image if it doesn't exist
image_url = attachment.get("guid")
# fix cases where the /wp prefix is missing from the image url
if image_url.startswith("https://studentersamfundet.no/wp-content/uploads/"):
image_url = image_url.replace(
"https://studentersamfundet.no/wp-content/uploads/",
"https://studentersamfundet.no/wp/wp-content/uploads/",
)
try:
image = get_or_save_image(image_url)
except Exception as e:
print("Error with image", image_url, "associated with pages:", pages)
print(e)
continue
print("Attaching header images to pages:", pages)
try:
pages.update(featured_image=image)
except Exception:
pass
try:
pages.update(logo=image)
except Exception:
pass
+73 -6
View File
@@ -1,17 +1,84 @@
from django.core.exceptions import FieldDoesNotExist
from django.db import models from django.db import models
from wagtail.models import Page from wagtail.models import Page
# Field names declared by WPImportedPageMixin. Concrete models that mix it in
# get a manager that .defer()s these so they're never loaded by default — the
# columns stay in the database (no migration), and any code path that
# explicitly reads them still works via Django's lazy-load.
WP_IMPORT_FIELDS = (
"wp_post_id",
"wp_post_type",
"wp_link",
"wp_raw_content",
"wp_processed_content",
"wp_block_json",
"wp_normalized_styles",
"wp_post_meta",
)
# https://github.com/wagtail/wagtail-wordpress-import/blob/main/wagtail_wordpress_import/models.py # https://github.com/wagtail/wagtail-wordpress-import/blob/main/wagtail_wordpress_import/models.py
# DJ001 (null=True on string fields) is suppressed: the schema mirrors the
# upstream mixin and changing nullability would force a migration we avoid.
class WPImportedPageMixin(Page): class WPImportedPageMixin(Page):
wp_post_id = models.IntegerField(blank=True, null=True) wp_post_id = models.IntegerField(blank=True, null=True)
wp_post_type = models.CharField(max_length=255, blank=True, null=True) wp_post_type = models.CharField(max_length=255, blank=True, null=True) # noqa: DJ001
wp_link = models.TextField(blank=True, null=True) wp_link = models.TextField(blank=True, null=True) # noqa: DJ001
wp_raw_content = models.TextField(blank=True, null=True) wp_raw_content = models.TextField(blank=True, null=True) # noqa: DJ001
wp_processed_content = models.TextField(blank=True, null=True) wp_processed_content = models.TextField(blank=True, null=True) # noqa: DJ001
wp_block_json = models.TextField(blank=True, null=True) wp_block_json = models.TextField(blank=True, null=True) # noqa: DJ001
wp_normalized_styles = models.TextField(blank=True, null=True) wp_normalized_styles = models.TextField(blank=True, null=True) # noqa: DJ001
wp_post_meta = models.JSONField(blank=True, null=True) wp_post_meta = models.JSONField(blank=True, null=True)
class Meta: class Meta:
abstract = True abstract = True
class DeferWPFieldsManagerMixin:
"""
Manager mixin that always .defer()s the wp_* import columns, so they are
never SELECTed by default queries. The columns remain in the database;
accessing one on an instance still works (Django lazy-loads it).
"""
def get_queryset(self):
return super().get_queryset().defer(*WP_IMPORT_FIELDS)
def _resolve_related_model(model, lookup_path):
"""Walk a ``foo__bar`` lookup path and return the final related model, or None."""
current = model
for part in lookup_path.split("__"):
try:
field = current._meta.get_field(part)
except FieldDoesNotExist:
return None
current = getattr(field, "related_model", None)
if current is None:
return None
return current
class WPAwareQuerySet(models.QuerySet):
"""
QuerySet whose ``select_related()`` auto-defers wp_* columns when a join
targets a WPImportedPageMixin model. Without this, ``select_related``
builds a JOIN that ignores the related model's manager and SELECTs every
column including the wp_* blobs. Apply via ``objects = WPAwareManager()``
on any model that has a ForeignKey into a WPImported page.
"""
def select_related(self, *fields):
qs = super().select_related(*fields)
defers = [
f"{path}__{name}"
for path in fields
if (related := _resolve_related_model(qs.model, path)) is not None
and issubclass(related, WPImportedPageMixin)
for name in WP_IMPORT_FIELDS
]
return qs.defer(*defers) if defers else qs
WPAwareManager = models.Manager.from_queryset(WPAwareQuerySet)
+72
View File
@@ -0,0 +1,72 @@
from django.utils import timezone
from django.utils.translation import gettext, gettext_lazy as _
from django.utils.translation import ngettext
from wagtail.admin.ui.tables import Column, DateColumn
from wagtail.admin.ui.tables.pages import PageStatusColumn, PageTitleColumn
from wagtail.admin.views.pages.listing import IndexView
from wagtail.admin.viewsets.pages import PageListingViewSet
from dnscms.admin import ListingRedirectChooseParentView
from events.models import EventPage
class EventDateColumn(Column):
def get_value(self, instance):
occurrences = list(instance.occurrences.order_by("start"))
if not occurrences:
return ""
if len(occurrences) == 1:
local = timezone.localtime(occurrences[0].start)
return local.strftime(gettext("%Y-%m-%d at %H:%M"))
count = len(occurrences)
return ngettext("%(count)d occurrence", "%(count)d occurrences", count) % {"count": count}
class OrganizersColumn(Column):
def get_value(self, instance):
names = list(instance.organizers.values_list("name", flat=True))
if not names:
return ""
if len(names) == 1:
return names[0]
return f"{names[0]} (+{len(names) - 1})"
class EventPageIndexView(IndexView):
def annotate_queryset(self, pages):
pages = super().annotate_queryset(pages)
return pages.prefetch_related(
"occurrences",
"organizer_links__organizer",
)
class EventChooseParentView(ListingRedirectChooseParentView):
listing_url_name = "events:index"
class EventPageListingViewSet(PageListingViewSet):
model = EventPage
index_view_class = EventPageIndexView
choose_parent_view_class = EventChooseParentView
icon = "date"
menu_label = _("Events")
menu_order = 1
add_to_admin_menu = True
ordering = "-latest_revision_created_at"
columns = [
PageTitleColumn("title", label=_("Title"), sort_key="title", classname="title"),
EventDateColumn("event_date", label=_("Date"), width="13%"),
OrganizersColumn("organizers", label=_("Organizers"), width="12%"),
DateColumn(
"latest_revision_created_at",
label=_("Updated"),
sort_key="latest_revision_created_at",
width="10%",
),
PageStatusColumn("status", label=_("Status"), sort_key="live", width="10%"),
]
event_page_listing_viewset = EventPageListingViewSet("events")
@@ -0,0 +1,17 @@
# Generated by Django 6.0.5 on 2026-05-19 18:40
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('events', '0053_eventpage_lead'),
]
operations = [
migrations.AlterModelOptions(
name='eventpage',
options={'verbose_name': 'event', 'verbose_name_plural': 'events'},
),
]
@@ -0,0 +1,82 @@
# Generated by Django 6.0.5 on 2026-05-22 23:27
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('associations', '0026_alter_association_options'),
('events', '0054_alter_eventpage_options'),
('images', '0005_customimage_description'),
]
operations = [
migrations.AlterModelOptions(
name='eventcategory',
options={'ordering': ['name'], 'verbose_name': 'event category', 'verbose_name_plural': 'event categories'},
),
migrations.AlterModelOptions(
name='eventoccurrence',
options={'verbose_name': 'occurrence', 'verbose_name_plural': 'occurrences'},
),
migrations.AlterModelOptions(
name='eventorganizer',
options={'ordering': ['name'], 'verbose_name': 'event organizer', 'verbose_name_plural': 'event organizers'},
),
migrations.AlterModelOptions(
name='eventorganizerlink',
options={'verbose_name': 'organizer', 'verbose_name_plural': 'organizers'},
),
migrations.AlterField(
model_name='eventcategory',
name='pig',
field=models.CharField(blank=True, choices=[('', 'None'), ('logo', 'Logogrisen'), ('music', 'Musikergrisen'), ('drink', 'Drikkegrisen'), ('dance', 'Dansegrisen'), ('point', 'Pekegrisen'), ('student', 'Studentgrisen'), ('listen', 'Lyttegrisen'), ('guard', 'Vaktgrisen'), ('key', 'Nøkkelgrisen'), ('chill', 'Liggegrisen'), ('peek', 'Tittegrisen')], default='', help_text='Default pig for events of this kind.', max_length=32),
),
migrations.AlterField(
model_name='eventcategory',
name='show_in_filters',
field=models.BooleanField(default=False, help_text='Should this category be available as a filter in the event programme?'),
),
migrations.AlterField(
model_name='eventoccurrence',
name='venue_custom',
field=models.CharField(blank=True, help_text='Use this <em>if none of the venues that can be selected on the left</em> fit. E.g. <em>Frederikkeplassen</em> or <em>Sirkusteltet</em>.', max_length=128),
),
migrations.AlterField(
model_name='eventorganizer',
name='association',
field=models.ForeignKey(blank=True, help_text='If a DNS association or committee is behind it, choose it here.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='organizers', to='associations.associationpage'),
),
migrations.AlterField(
model_name='eventorganizer',
name='external_url',
field=models.URLField(blank=True, help_text="Link to the external organizer's website", max_length=512),
),
migrations.AlterField(
model_name='eventpage',
name='facebook_url',
field=models.URLField(blank=True, help_text='Direct link to the event on Facebook', max_length=1024),
),
migrations.AlterField(
model_name='eventpage',
name='featured_image',
field=models.ForeignKey(blank=True, help_text="Choose an image for use in the programme and other surfaces. Should be a photo or an illustration without too much text don't reuse a Facebook cover uncritically!", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='images.customimage'),
),
migrations.AlterField(
model_name='eventpage',
name='pig',
field=models.CharField(blank=True, choices=[('', 'None'), ('automatic', 'Automatic'), ('logo', 'Logogrisen'), ('music', 'Musikergrisen'), ('drink', 'Drikkegrisen'), ('dance', 'Dansegrisen'), ('point', 'Pekegrisen'), ('student', 'Studentgrisen'), ('listen', 'Lyttegrisen'), ('guard', 'Vaktgrisen'), ('key', 'Nøkkelgrisen'), ('chill', 'Liggegrisen'), ('peek', 'Tittegrisen')], default='automatic', help_text="The pig that hangs out on the event page. Automatic causes one to be chosen based on the event's category.", max_length=32),
),
migrations.AlterField(
model_name='eventpage',
name='subtitle',
field=models.CharField(blank=True, help_text='A short text that appears right below the title. Feel free to leave it empty if you fit most of it in the main title.', max_length=128),
),
migrations.AlterField(
model_name='eventpage',
name='ticket_url',
field=models.URLField(blank=True, help_text='Direct link to ticket purchase, e.g. TicketCo, Billetto or Ticketmaster', max_length=1024),
),
]
+116 -316
View File
@@ -1,7 +1,7 @@
from django import forms from django import forms
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.db.models import Min, Q, UniqueConstraint from django.db.models import Min, Prefetch, Q, UniqueConstraint
from django.utils import timezone from django.utils import timezone
from django.utils.html import mark_safe from django.utils.html import mark_safe
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -30,21 +30,42 @@ from wagtail.fields import RichTextField
from wagtail.models import Orderable, Page, PageManager, PageQuerySet from wagtail.models import Orderable, Page, PageManager, PageQuerySet
from wagtail.search import index from wagtail.search import index
from wagtail.snippets.models import register_snippet from wagtail.snippets.models import register_snippet
from wagtail_headless_preview.models import HeadlessMixin
from associations.widgets import AssociationChooserWidget from associations.widgets import AssociationChooserWidget
from dnscms.fields import CommonStreamField from dnscms.fields import CommonStreamField
from dnscms.options import ALL_PIGS from dnscms.options import ALL_PIGS
from dnscms.wordpress.models import WPImportedPageMixin from dnscms.wordpress.models import (
DeferWPFieldsManagerMixin,
WPAwareManager,
WPImportedPageMixin,
)
from venues.models import VenuePage from venues.models import VenuePage
@register_singular_query_field("eventIndex") @register_singular_query_field("eventIndex")
class EventIndex(Page): class EventIndex(HeadlessMixin, Page):
max_count = 1 max_count = 1
subpage_types = ["events.EventPage"] subpage_types = ["events.EventPage"]
def future_events(self, info, **kwargs): def future_events(self, info, **kwargs):
return EventPage.objects.live().future().order_by("next_occurrence") return (
EventPage.objects.live()
.future()
.order_by("next_occurrence")
.select_related("featured_image")
.prefetch_related(
Prefetch(
"occurrences",
queryset=EventOccurrence.objects.select_related("venue"),
),
"categories",
Prefetch(
"organizers",
queryset=EventOrganizer.objects.select_related("association"),
),
)
)
graphql_fields = [ graphql_fields = [
GraphQLCollection( GraphQLCollection(
@@ -74,11 +95,12 @@ class EventCategory(models.Model):
help_text=_("The name of the category as it will appear in URLs."), help_text=_("The name of the category as it will appear in URLs."),
) )
show_in_filters = models.BooleanField( show_in_filters = models.BooleanField(
default=False, help_text="Skal denne kategorien være mulig å filtrere på i programmet?" default=False,
help_text=_("Should this category be available as a filter in the event programme?"),
) )
PIG_CHOICES = [ PIG_CHOICES = [
("", "Ingen"), ("", _("None")),
] + ALL_PIGS ] + ALL_PIGS
pig = models.CharField( pig = models.CharField(
@@ -86,14 +108,14 @@ class EventCategory(models.Model):
choices=PIG_CHOICES, choices=PIG_CHOICES,
default="", default="",
blank=True, blank=True,
help_text="Standardgris for arrangementer av denne typen.", help_text=_("Default pig for events of this kind."),
) )
panels = [ panels = [
TitleFieldPanel("name"), TitleFieldPanel("name"),
FieldPanel("slug"), FieldPanel("slug"),
FieldPanel("show_in_filters"), FieldPanel("show_in_filters"),
FieldPanel("pig", heading="Gris"), FieldPanel("pig", heading=_("Pig")),
] ]
graphql_fields = [ graphql_fields = [
@@ -104,8 +126,8 @@ class EventCategory(models.Model):
] ]
class Meta: class Meta:
verbose_name = "Event category" verbose_name = _("event category")
verbose_name_plural = "Event categories" verbose_name_plural = _("event categories")
ordering = ["name"] ordering = ["name"]
def __str__(self): def __str__(self):
@@ -133,8 +155,8 @@ class EventOrganizerLink(Orderable):
return f"{self.organizer.name}" return f"{self.organizer.name}"
class Meta: class Meta:
verbose_name = "Arrangør" verbose_name = _("organizer")
verbose_name_plural = "Arrangører" verbose_name_plural = _("organizers")
constraints = [ constraints = [
UniqueConstraint( UniqueConstraint(
"event", "organizer", name="event_organizer_link_event_organizer_unique" "event", "organizer", name="event_organizer_link_event_organizer_unique"
@@ -145,6 +167,8 @@ class EventOrganizerLink(Orderable):
@register_snippet @register_snippet
@register_query_field("eventOrganizer", "eventOrganizers") @register_query_field("eventOrganizer", "eventOrganizers")
class EventOrganizer(ClusterableModel): class EventOrganizer(ClusterableModel):
objects = WPAwareManager()
name = models.CharField( name = models.CharField(
max_length=100, max_length=100,
null=False, null=False,
@@ -162,13 +186,13 @@ class EventOrganizer(ClusterableModel):
blank=True, blank=True,
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name="organizers", related_name="organizers",
help_text="Om en samfundsforening eller et utvalg står bak, velg det her.", help_text=_("If a DNS association or committee is behind it, choose it here."),
) )
external_url = models.URLField( external_url = models.URLField(
blank=True, blank=True,
max_length=512, max_length=512,
help_text="Lenke til nettstedet til ekstern arrangør", help_text=_("Link to the external organizer's website"),
) )
panels = [ panels = [
@@ -177,15 +201,15 @@ class EventOrganizer(ClusterableModel):
FieldPanel( FieldPanel(
"association", "association",
widget=AssociationChooserWidget(linked_fields={"association": "#id_association"}), widget=AssociationChooserWidget(linked_fields={"association": "#id_association"}),
heading="Intern arrangør", heading=_("Internal organizer"),
), ),
MultiFieldPanel( MultiFieldPanel(
heading="Ekstern arrangør", heading=_("External organizer"),
children=[ children=[
FieldPanel( FieldPanel(
"external_url", "external_url",
heading="Nettsted", heading=_("Website"),
help_text="La denne stå tom om arrangøren finnes i lista over.", help_text=_("Leave this empty if the organizer exists in the list above."),
), ),
], ],
), ),
@@ -199,8 +223,8 @@ class EventOrganizer(ClusterableModel):
] ]
class Meta: class Meta:
verbose_name = "Event organizer" verbose_name = _("event organizer")
verbose_name_plural = "Event organizers" verbose_name_plural = _("event organizers")
ordering = ["name"] ordering = ["name"]
def __str__(self): def __str__(self):
@@ -209,15 +233,22 @@ class EventOrganizer(ClusterableModel):
class EventPageQuerySet(PageQuerySet): class EventPageQuerySet(PageQuerySet):
def future(self): def future(self):
today = timezone.localtime(timezone.now()).date() now = timezone.now()
next_occurrence = Min("occurrences__start", filter=Q(occurrences__start__gte=today)) today_start = timezone.localtime(now).replace(hour=0, minute=0, second=0, microsecond=0)
return self.filter(occurrences__start__gte=today).annotate(next_occurrence=next_occurrence) next_occurrence = Min("occurrences__start", filter=Q(occurrences__start__gte=today_start))
return self.filter(occurrences__start__gte=today_start).annotate(
next_occurrence=next_occurrence
)
EventPageManager = PageManager.from_queryset(EventPageQuerySet) class EventPageManager(
DeferWPFieldsManagerMixin,
PageManager.from_queryset(EventPageQuerySet),
):
pass
class EventPage(WPImportedPageMixin, Page): class EventPage(HeadlessMixin, WPImportedPageMixin, Page):
subpage_types = [] subpage_types = []
parent_page_types = ["events.EventIndex"] parent_page_types = ["events.EventIndex"]
show_in_menus = False show_in_menus = False
@@ -230,19 +261,19 @@ class EventPage(WPImportedPageMixin, Page):
blank=True, blank=True,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
related_name="+", related_name="+",
help_text=( help_text=_(
"Velg et bilde til bruk i programmet og andre visningsflater. " "Choose an image for use in the programme and other surfaces. "
"Bør være et bilde eller en illustrasjon uten tekst " "Should be a photo or an illustration without too much text "
" ikke gjenbruk et Facebook-cover ukritisk!" " don't reuse a Facebook cover uncritically!"
), ),
) )
subtitle = models.CharField( subtitle = models.CharField(
blank=True, blank=True,
max_length=128, max_length=128,
help_text=( help_text=_(
"En kort tekst som kommer rett under under tittelen. " "A short text that appears right below the title. "
"La denne gjerne stå tom om du fikk plass til det meste i tittelen." "Feel free to leave it empty if you fit most of it in the main title."
), ),
) )
lead = RichTextField(features=["italic", "link"], blank=True) lead = RichTextField(features=["italic", "link"], blank=True)
@@ -258,8 +289,8 @@ class EventPage(WPImportedPageMixin, Page):
) )
PIG_CHOICES = [ PIG_CHOICES = [
("", "Ingen"), ("", _("None")),
("automatic", "Automatisk"), ("automatic", _("Automatic")),
] + ALL_PIGS ] + ALL_PIGS
pig = models.CharField( pig = models.CharField(
@@ -267,21 +298,21 @@ class EventPage(WPImportedPageMixin, Page):
choices=PIG_CHOICES, choices=PIG_CHOICES,
default="automatic", default="automatic",
blank=True, blank=True,
help_text=( help_text=_(
"Grisen som henger på arrangementssiden. " "The pig that hangs out on the event page. "
"Automatisk fører til at en velges basert på arrangementets kategori." "Automatic causes one to be chosen based on the event's category."
), ),
) )
ticket_url = models.URLField( ticket_url = models.URLField(
blank=True, blank=True,
max_length=1024, max_length=1024,
help_text="Lenke direkte til billettkjøp, f.eks. TicketCo eller Ticketmaster", help_text=_("Direct link to ticket purchase, e.g. TicketCo, Billetto or Ticketmaster"),
) )
facebook_url = models.URLField( facebook_url = models.URLField(
blank=True, blank=True,
max_length=1024, max_length=1024,
help_text="Lenke direkte til arrangementet på Facebook", help_text=_("Direct link to the event on Facebook"),
) )
free = models.BooleanField(null=False, default=False) free = models.BooleanField(null=False, default=False)
@@ -290,62 +321,65 @@ class EventPage(WPImportedPageMixin, Page):
price_member = models.CharField(max_length=32, blank=True) price_member = models.CharField(max_length=32, blank=True)
ticket_panels = [ ticket_panels = [
FieldPanel("free", heading="Gratis", help_text="Er dette arrangementet gratis for alle?"), FieldPanel("free", heading=_("Free"), help_text=_("Is this event free for everyone?")),
MultiFieldPanel( MultiFieldPanel(
children=[ children=[
FieldRowPanel( FieldRowPanel(
children=[ children=[
FieldPanel("price_regular", heading="Ordinær pris"), FieldPanel("price_regular", heading=_("Regular price")),
FieldPanel("price_student", heading="Pris for studenter"), FieldPanel("price_student", heading=_("Price for students")),
FieldPanel("price_member", heading="Pris for medlemmer av DNS"), FieldPanel("price_member", heading=_("Price for DNS members")),
], ],
help_text="", help_text="",
), ),
HelpPanel( HelpPanel(
content=mark_safe( content=mark_safe(
"Skriv <strong>0</strong> om gratis. Tomt felt skjuler priskategorien. Om mulig, skriv kun tall." _(
"Write <strong>0</strong> for free. "
"An empty field hides the price category. "
"If possible, write digits only."
)
) )
), ),
], ],
attrs={"id": "specific_pricing_panel"}, attrs={"id": "specific_pricing_panel"},
), ),
FieldPanel("ticket_url", heading="Billettkjøpslenke"), FieldPanel("ticket_url", heading=_("Ticket purchase link")),
] ]
content_panels = Page.content_panels + [ content_panels = Page.content_panels + [
FieldPanel("subtitle", heading="Undertittel"), FieldPanel("subtitle", heading=_("Subtitle")),
FieldPanel("featured_image"), FieldPanel("featured_image"),
FieldPanel("lead", heading="Ingress"), FieldPanel("lead", heading=_("Lead")),
FieldPanel("body"), FieldPanel("body"),
FieldPanel("categories", widget=forms.CheckboxSelectMultiple), FieldPanel("categories", widget=forms.CheckboxSelectMultiple),
MultiFieldPanel( MultiFieldPanel(
heading="Arrangører", heading=_("Organizers"),
children=[ children=[
HelpPanel( HelpPanel(
content=("Hvem står bak arrangementet?"), content=_("Who is behind the event?"),
), ),
MultipleChooserPanel( MultipleChooserPanel(
"organizer_links", chooser_field_name="organizer", label="Arrangør" "organizer_links", chooser_field_name="organizer", label=_("Organizer")
), ),
], ],
), ),
FieldPanel("pig", heading="Gris"), FieldPanel("pig", heading=_("Pig")),
FieldPanel( FieldPanel(
"facebook_url", "facebook_url",
heading="Facebook-lenke", heading=_("Facebook link"),
help_text="Lenke direkte til arrangementet på Facebook.", help_text=_("Direct link to the event on Facebook."),
), ),
MultiFieldPanel(heading="Priser og billettkjøp", children=ticket_panels), MultiFieldPanel(heading=_("Pricing and tickets"), children=ticket_panels),
MultiFieldPanel( MultiFieldPanel(
heading="Dato, tid og lokale", heading=_("Date, time and venue"),
children=[ children=[
HelpPanel( HelpPanel(
content=( content=_(
"Om arrangementet går over flere dager, " "If the event spans several days, add each day as a separate occurrence."
"legg inn hver dag som en egen forekomst."
), ),
), ),
InlinePanel("occurrences", min_num=1, label="Forekomst"), InlinePanel("occurrences", min_num=1, label=_("Occurrence")),
], ],
), ),
] ]
@@ -355,7 +389,7 @@ class EventPage(WPImportedPageMixin, Page):
GraphQLImage("featured_image"), GraphQLImage("featured_image"),
GraphQLRichText("lead"), GraphQLRichText("lead"),
GraphQLStreamfield("body"), GraphQLStreamfield("body"),
GraphQLString("pig"), GraphQLString("pig", required=True),
GraphQLString("ticket_url"), GraphQLString("ticket_url"),
GraphQLString("facebook_url"), GraphQLString("facebook_url"),
GraphQLBoolean("free"), GraphQLBoolean("free"),
@@ -387,6 +421,10 @@ class EventPage(WPImportedPageMixin, Page):
search_fields = Page.search_fields + [index.SearchField("body")] search_fields = Page.search_fields + [index.SearchField("body")]
class Meta:
verbose_name = _("event")
verbose_name_plural = _("events")
def clean(self): def clean(self):
super().clean() super().clean()
@@ -406,130 +444,10 @@ class EventPage(WPImportedPageMixin, Page):
self.price_student = "" self.price_student = ""
self.price_member = "" self.price_member = ""
def import_wordpress_data(self, data):
import datetime
import html
from zoneinfo import ZoneInfo
from django.core.validators import URLValidator
validate_url = URLValidator(schemes=["http", "https"])
def fix_url(url):
if not url:
return None
url = url.strip()
try:
validate_url(url)
except Exception:
print(f"Bogus URL for {self.wp_post_id}: {url}")
return None
return url
# Wagtail page model fields
self.title = html.unescape(data["title"])
self.slug = data["slug"]
self.first_published_at = data["first_published_at"]
self.last_published_at = data["last_published_at"]
self.latest_revision_created_at = data["latest_revision_created_at"]
self.search_description = data["search_description"]
# debug fields
self.wp_post_id = data["wp_post_id"]
self.wp_post_type = data["wp_post_type"]
self.wp_link = data["wp_link"]
self.wp_raw_content = data["wp_raw_content"]
self.wp_block_json = data["wp_block_json"]
self.wp_processed_content = data["wp_processed_content"]
self.wp_normalized_styles = data["wp_normalized_styles"]
self.wp_post_meta = data["wp_post_meta"]
# own model fields
self.body = data["body"] or ""
# categories (organizers and event types)
wp_categories = data["wp_categories"]
# organizers
organizer_cats = [x for x in wp_categories if x["domain"] == "event_organizer"]
organizers = []
for x in organizer_cats:
try:
organizer = EventOrganizer.objects.get(slug=x["nicename"])
except EventOrganizer.DoesNotExist:
organizer = EventOrganizer.objects.create(name=x["name"], slug=x["nicename"])
organizers.append(organizer)
self.organizer_links.set(
[EventOrganizerLink(event=self, organizer=organizer) for organizer in organizers]
)
## event types
# type_cats = [x for x in wp_categories if x["domain"] == "event_type"]
# event_categories = []
# for x in type_cats:
# try:
# event_category = EventCategory.objects.get(slug=x["nicename"])
# except EventCategory.DoesNotExist:
# event_category = EventCategory.objects.create(
# name=x["name"], slug=x["nicename"], show_in_filters=False
# )
# event_categories.append(event_category)
# self.categories.set(event_categories)
meta = data["wp_post_meta"]
start_ts = meta.get("neuf_events_starttime") or 1337
end_ts = meta.get("neuf_events_endtime")
tz = ZoneInfo("Europe/Oslo")
start = start_ts and datetime.datetime.fromtimestamp(start_ts, datetime.UTC).replace(
tzinfo=tz
)
end = end_ts and datetime.datetime.fromtimestamp(end_ts, datetime.UTC).replace(tzinfo=tz)
venue_id = meta.get("neuf_events_venue_id")
venue_custom = meta.get("neuf_events_venue")
venue = None
if venue_id:
venue = VenuePage.objects.get(wp_post_id=venue_id)
venue_custom = ""
else:
venue_custom = venue_custom or ""
occurrence = EventOccurrence(
event=self, start=start, end=end, venue=venue, venue_custom=venue_custom
)
self.occurrences.set([occurrence])
self.ticket_url = fix_url(meta.get("neuf_events_bs_url")) or ""
self.facebook_url = fix_url(meta.get("neuf_events_fb_url")) or ""
def parse_price(price):
if price is None:
return ""
if type(price) is int:
return price
p = price.strip()
if p == "":
return ""
try:
return int(p)
except ValueError:
pass
free = ["gratis", "free", "gratis/free", "free/gratis"]
if p.lower() in free:
return 0
return price
price_regular = parse_price(meta.get("neuf_events_price_regular"))
price_member = parse_price(meta.get("neuf_events_price_member"))
if not price_regular and not price_member:
self.free = True
else:
self.price_regular = parse_price(meta.get("neuf_events_price_regular"))
self.price_member = parse_price(meta.get("neuf_events_price_member"))
class EventOccurrence(Orderable): class EventOccurrence(Orderable):
objects = WPAwareManager()
event = ParentalKey(EventPage, on_delete=models.CASCADE, related_name="occurrences") event = ParentalKey(EventPage, on_delete=models.CASCADE, related_name="occurrences")
start = models.DateTimeField() start = models.DateTimeField()
end = models.DateTimeField(null=True, blank=True) end = models.DateTimeField(null=True, blank=True)
@@ -544,22 +462,24 @@ class EventOccurrence(Orderable):
blank=True, blank=True,
max_length=128, max_length=128,
help_text=mark_safe( help_text=mark_safe(
"Bruk denne <em>om ingen av lokalene som kan velges til venstre</em> passer. " _(
"F.eks. <em>Frederikkeplassen</em> eller <em>Sirkusteltet</em>." "Use this <em>if none of the venues that can be selected on the left</em> fit. "
"E.g. <em>Frederikkeplassen</em> or <em>Sirkusteltet</em>."
)
), ),
) )
panels = [ panels = [
FieldRowPanel( FieldRowPanel(
children=[ children=[
FieldPanel("start", heading="Start"), FieldPanel("start", heading=_("Start")),
FieldPanel("end", heading="Slutt"), FieldPanel("end", heading=_("End")),
], ],
), ),
FieldRowPanel( FieldRowPanel(
children=[ children=[
FieldPanel("venue", heading="Lokale"), FieldPanel("venue", heading=_("Venue")),
FieldPanel("venue_custom", heading="Lokale som fritekst"), FieldPanel("venue_custom", heading=_("Venue as free text")),
], ],
), ),
] ]
@@ -574,138 +494,18 @@ class EventOccurrence(Orderable):
def clean(self): def clean(self):
if self.venue and self.venue_custom: if self.venue and self.venue_custom:
raise ValidationError( raise ValidationError(
{"venue_custom": "Du kan ikke både velge et lokale og skrive noe i dette feltet."} {
"venue_custom": _(
"You can't both pick a venue and write something in this field."
)
}
) )
if not self.venue and not self.venue_custom: if not self.venue and not self.venue_custom:
raise ValidationError({"venue": "Lokale er påkrevd."}) raise ValidationError({"venue": _("Venue is required.")})
def __str__(self): def __str__(self):
return f"{self.start}--{self.end}" return f"{self.start}--{self.end}"
class Meta: class Meta:
verbose_name = "Forekomst" verbose_name = _("occurrence")
verbose_name_plural = "Forekomster" verbose_name_plural = _("occurrences")
sample_legacy_event_json = """
{
"id": 64573,
"date": "2023-12-27T11:28:34",
"date_gmt": "2023-12-27T10:28:34",
"guid": {
"rendered": "https://studentersamfundet.no/?post_type=event&#038;p=64573"
},
"modified": "2023-12-27T11:44:11",
"modified_gmt": "2023-12-27T10:44:11",
"slug": "quiz-147-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-3-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-3-2-2-2-2-2-2-2-2-2-2-2-2-2",
"status": "publish",
"type": "event",
"link": "https://studentersamfundet.no/arrangement/quiz-147-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-3-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-3-2-2-2-2-2-2-2-2-2-2-2-2-2/",
"title": {
"rendered": "QUIZ",
"decoded": "QUIZ"
},
"content": {
"rendered": "\n<p>Det Norske Studentersamfund inviterer til quiz hver tirsdag kl. 19:00.</p>\n\n\n\n<p>Vi serverer 50 spørsmål som kan spenne seg fra one hit wonders fra 80-tallet, universets uendelighet, dyrelivets merkverdigheter og mye, mye mer!</p>\n\n\n\n<p>Quiz på Chateau Neuf er åpent for alle. Vinnere og &#8220;lucky losers&#8221; vil bli utnevnt hver kveld. Lag som er over seks personer er tillatt, men da trekkes dere for ett poeng per deltaker per runde.</p>\n\n\n\n<p>For de som ønsker å være med på sammenlagtkonkurransen for høsten vil den regnes ut for de tolv beste prestasjonene laget leverer. Så det vil fremdeles være god sjanse for å vinne sammenlagt selv dere må droppe en quiz eller to for eksamener eller andre forpliktelser.</p>\n\n\n\n<p>Velkommen quizglade mennesker!</p>\n\n\n\n<p>Gratis inngang!</p>\n",
"protected": false
},
"excerpt": {
"rendered": "<p>Det Norske Studentersamfund inviterer til quiz hver tirsdag kl. 19:00. Vi serverer 50 spørsmål som kan spenne seg fra one hit wonders fra 80-tallet, universets uendelighet, dyrelivets merkverdigheter og mye, mye mer! Quiz på Chateau Neuf er åpent for alle. Vinnere og &#8220;lucky losers&#8221; vil bli utnevnt hver kveld. Lag som er over seks personer [&hellip;]</p>\n",
"protected": false
},
"author": 2150,
"featured_media": 64585,
"template": "",
"meta": [],
"event_types": [13],
"event_organizers": [390, 322],
"facebook_url": "https://fb.me/e/2RDR5pZdr",
"ticket_url": "",
"price_regular": "",
"price_member": "",
"start_time": "2024-05-07T17:00:00+00:00",
"end_time": "2024-05-07T20:00:00+00:00",
"venue": "Glassbaren",
"venue_id": "55063",
"thumbnail": {
"thumbnail": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-150x150.png",
"medium": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-300x169.png",
"medium_large": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-768x433.png",
"large": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-1280x720.png",
"1536x1536": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-1536x865.png",
"2048x2048": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1.png",
"four-column": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-393x342.png",
"six-column": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-608x342.png",
"extra-large": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-1600x901.png",
"newsletter-half": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-320x190.png",
"newsletter-third": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-213x126.png",
"featured": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-1200x480.png"
},
"_links": {
"self": [
{
"href": "https://studentersamfundet.no/wp-json/wp/v2/events/64573"
}
],
"collection": [
{
"href": "https://studentersamfundet.no/wp-json/wp/v2/events"
}
],
"about": [
{
"href": "https://studentersamfundet.no/wp-json/wp/v2/types/event"
}
],
"author": [
{
"embeddable": true,
"href": "https://studentersamfundet.no/wp-json/wp/v2/users/2150"
}
],
"version-history": [
{
"count": 1,
"href": "https://studentersamfundet.no/wp-json/wp/v2/events/64573/revisions"
}
],
"predecessor-version": [
{
"id": 64574,
"href": "https://studentersamfundet.no/wp-json/wp/v2/events/64573/revisions/64574"
}
],
"wp:featuredmedia": [
{
"embeddable": true,
"href": "https://studentersamfundet.no/wp-json/wp/v2/media/64585"
}
],
"wp:attachment": [
{
"href": "https://studentersamfundet.no/wp-json/wp/v2/media?parent=64573"
}
],
"wp:term": [
{
"taxonomy": "event_type",
"embeddable": true,
"href": "https://studentersamfundet.no/wp-json/wp/v2/event_types?post=64573"
},
{
"taxonomy": "event_organizer",
"embeddable": true,
"href": "https://studentersamfundet.no/wp-json/wp/v2/event_organizers?post=64573"
}
],
"curies": [
{
"name": "wp",
"href": "https://api.w.org/{rel}",
"templated": true
}
]
}
}
"""
+119
View File
@@ -0,0 +1,119 @@
{
"id": 64573,
"date": "2023-12-27T11:28:34",
"date_gmt": "2023-12-27T10:28:34",
"guid": {
"rendered": "https://studentersamfundet.no/?post_type=event&#038;p=64573"
},
"modified": "2023-12-27T11:44:11",
"modified_gmt": "2023-12-27T10:44:11",
"slug": "quiz-147-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-3-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-3-2-2-2-2-2-2-2-2-2-2-2-2-2",
"status": "publish",
"type": "event",
"link": "https://studentersamfundet.no/arrangement/quiz-147-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-3-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-2-3-2-2-2-2-2-2-2-2-2-2-2-2-2/",
"title": {
"rendered": "QUIZ",
"decoded": "QUIZ"
},
"content": {
"rendered": "\n<p>Det Norske Studentersamfund inviterer til quiz hver tirsdag kl. 19:00.</p>\n\n\n\n<p>Vi serverer 50 spørsmål som kan spenne seg fra one hit wonders fra 80-tallet, universets uendelighet, dyrelivets merkverdigheter og mye, mye mer!</p>\n\n\n\n<p>Quiz på Chateau Neuf er åpent for alle. Vinnere og &#8220;lucky losers&#8221; vil bli utnevnt hver kveld. Lag som er over seks personer er tillatt, men da trekkes dere for ett poeng per deltaker per runde.</p>\n\n\n\n<p>For de som ønsker å være med på sammenlagtkonkurransen for høsten vil den regnes ut for de tolv beste prestasjonene laget leverer. Så det vil fremdeles være god sjanse for å vinne sammenlagt selv dere må droppe en quiz eller to for eksamener eller andre forpliktelser.</p>\n\n\n\n<p>Velkommen quizglade mennesker!</p>\n\n\n\n<p>Gratis inngang!</p>\n",
"protected": false
},
"excerpt": {
"rendered": "<p>Det Norske Studentersamfund inviterer til quiz hver tirsdag kl. 19:00. Vi serverer 50 spørsmål som kan spenne seg fra one hit wonders fra 80-tallet, universets uendelighet, dyrelivets merkverdigheter og mye, mye mer! Quiz på Chateau Neuf er åpent for alle. Vinnere og &#8220;lucky losers&#8221; vil bli utnevnt hver kveld. Lag som er over seks personer [&hellip;]</p>\n",
"protected": false
},
"author": 2150,
"featured_media": 64585,
"template": "",
"meta": [],
"event_types": [13],
"event_organizers": [390, 322],
"facebook_url": "https://fb.me/e/2RDR5pZdr",
"ticket_url": "",
"price_regular": "",
"price_member": "",
"start_time": "2024-05-07T17:00:00+00:00",
"end_time": "2024-05-07T20:00:00+00:00",
"venue": "Glassbaren",
"venue_id": "55063",
"thumbnail": {
"thumbnail": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-150x150.png",
"medium": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-300x169.png",
"medium_large": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-768x433.png",
"large": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-1280x720.png",
"1536x1536": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-1536x865.png",
"2048x2048": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1.png",
"four-column": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-393x342.png",
"six-column": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-608x342.png",
"extra-large": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-1600x901.png",
"newsletter-half": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-320x190.png",
"newsletter-third": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-213x126.png",
"featured": "https://studentersamfundet.no/wp/wp-content/uploads/2023/12/quiz-header-1-1200x480.png"
},
"_links": {
"self": [
{
"href": "https://studentersamfundet.no/wp-json/wp/v2/events/64573"
}
],
"collection": [
{
"href": "https://studentersamfundet.no/wp-json/wp/v2/events"
}
],
"about": [
{
"href": "https://studentersamfundet.no/wp-json/wp/v2/types/event"
}
],
"author": [
{
"embeddable": true,
"href": "https://studentersamfundet.no/wp-json/wp/v2/users/2150"
}
],
"version-history": [
{
"count": 1,
"href": "https://studentersamfundet.no/wp-json/wp/v2/events/64573/revisions"
}
],
"predecessor-version": [
{
"id": 64574,
"href": "https://studentersamfundet.no/wp-json/wp/v2/events/64573/revisions/64574"
}
],
"wp:featuredmedia": [
{
"embeddable": true,
"href": "https://studentersamfundet.no/wp-json/wp/v2/media/64585"
}
],
"wp:attachment": [
{
"href": "https://studentersamfundet.no/wp-json/wp/v2/media?parent=64573"
}
],
"wp:term": [
{
"taxonomy": "event_type",
"embeddable": true,
"href": "https://studentersamfundet.no/wp-json/wp/v2/event_types?post=64573"
},
{
"taxonomy": "event_organizer",
"embeddable": true,
"href": "https://studentersamfundet.no/wp-json/wp/v2/event_organizers?post=64573"
}
],
"curies": [
{
"name": "wp",
"href": "https://api.w.org/{rel}",
"templated": true
}
]
}
}
-2
View File
@@ -1,2 +0,0 @@
# Create your tests here.
+5 -4
View File
@@ -1,3 +1,4 @@
from django.utils.translation import gettext_lazy as _
from wagtail.admin.viewsets.chooser import ChooserViewSet from wagtail.admin.viewsets.chooser import ChooserViewSet
@@ -5,10 +6,10 @@ class EventOrganizerChooserViewSet(ChooserViewSet):
model = "events.EventOrganizer" model = "events.EventOrganizer"
icon = "group" icon = "group"
per_page = 30 per_page = 30
page_title = "Choose organizers" page_title = _("Choose organizers")
choose_one_text = "Choose an organizer" choose_one_text = _("Choose an organizer")
choose_another_text = "Choose another organizer" choose_another_text = _("Choose another organizer")
edit_item_text = "Edit this organizer" edit_item_text = _("Edit this organizer")
form_fields = ["name", "association", "external_url"] form_fields = ["name", "association", "external_url"]
+6
View File
@@ -1,8 +1,14 @@
from wagtail import hooks from wagtail import hooks
from .admin import event_page_listing_viewset
from .views import event_organizer_chooser_viewset from .views import event_organizer_chooser_viewset
@hooks.register("register_admin_viewset") @hooks.register("register_admin_viewset")
def register_viewset(): def register_viewset():
return event_organizer_chooser_viewset return event_organizer_chooser_viewset
@hooks.register("register_admin_viewset")
def register_event_page_listing_viewset():
return event_page_listing_viewset
+2 -1
View File
@@ -4,13 +4,14 @@ from wagtail.admin.panels import FieldPanel
from wagtail.fields import RichTextField, StreamField from wagtail.fields import RichTextField, StreamField
from wagtail.models import Page from wagtail.models import Page
from wagtail.search import index from wagtail.search import index
from wagtail_headless_preview.models import HeadlessMixin
from dnscms.blocks import PageSectionBlock from dnscms.blocks import PageSectionBlock
from dnscms.fields import BASE_BLOCKS from dnscms.fields import BASE_BLOCKS
from dnscms.options import ALL_PIGS from dnscms.options import ALL_PIGS
class GenericPage(Page): class GenericPage(HeadlessMixin, Page):
subpage_types = ["generic.GenericPage"] subpage_types = ["generic.GenericPage"]
show_in_menus = True show_in_menus = True
+2 -1
View File
@@ -10,9 +10,10 @@ from wagtail.admin.panels import (
PageChooserPanel, PageChooserPanel,
) )
from wagtail.models import Orderable, Page from wagtail.models import Orderable, Page
from wagtail_headless_preview.models import HeadlessMixin
class HomePage(Page): class HomePage(HeadlessMixin, Page):
max_count = 1 max_count = 1
content_panels = Page.content_panels + [ content_panels = Page.content_panels + [
-21
View File
@@ -1,21 +0,0 @@
{% extends "base.html" %}
{% load static %}
{% block body_class %}template-homepage{% endblock %}
{% block extra_css %}
{% comment %}
Delete the line below if you're just getting started and want to remove the welcome screen!
{% endcomment %}
<link rel="stylesheet" href="{% static 'css/welcome_page.css' %}">
{% endblock extra_css %}
{% block content %}
{% comment %}
Delete the line below if you're just getting started and want to remove the welcome screen!
{% endcomment %}
{% include 'home/welcome_page.html' %}
{% endblock content %}
@@ -1,52 +0,0 @@
{% load i18n wagtailcore_tags %}
<header class="header">
<div class="logo">
<a href="https://wagtail.org/">
<svg class="figure-logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 342.5 126.2"><title>{% trans "Visit the Wagtail website" %}</title><path fill="#FFF" d="M84 1.9v5.7s-10.2-3.8-16.8 3.1c-4.8 5-5.2 10.6-3 18.1 21.6 0 25 12.1 25 12.1L87 27l6.8-8.3c0-9.8-8.1-16.3-9.8-16.8z"/><circle cx="85.9" cy="15.9" r="2.6"/><path d="M89.2 40.9s-3.3-16.6-24.9-12.1c-2.2-7.5-1.8-13 3-18.1C73.8 3.8 84 7.6 84 7.6V1.9C80.4.3 77 0 73.2 0 59.3 0 51.6 10.4 48.3 17.4L9.2 89.3l11-2.1-20.2 39 14.1-2.5L24.9 93c30.6 0 69.8-11 64.3-52.1z"/><path d="M102.4 27l-8.6-8.3L87 27z"/><path fill="#FFF" d="M30 84.1s1-.2 2.8-.6c1.8-.4 4.3-1 7.3-1.8 1.5-.4 3.1-.9 4.8-1.5 1.7-.6 3.5-1.2 5.2-2 1.8-.7 3.6-1.6 5.4-2.6 1.8-1 3.5-2.1 5.1-3.4.4-.3.8-.6 1.2-1l1.2-1c.7-.7 1.5-1.4 2.2-2.2.7-.7 1.3-1.5 1.9-2.3l.9-1.2.4-.6.4-.6c.2-.4.5-.8.7-1.2.2-.4.4-.8.7-1.2l.3-.6.3-.6c.2-.4.4-.8.5-1.2l.9-2.4c.2-.8.5-1.6.7-2.3.2-.7.3-1.5.5-2.1.1-.7.2-1.3.3-2 .1-.6.2-1.2.2-1.7.1-.5.1-1 .2-1.5.1-1.8.1-2.8.1-2.8l1.6.1s-.1 1.1-.2 2.9c-.1.5-.1 1-.2 1.5-.1.6-.1 1.2-.3 1.8-.1.6-.3 1.3-.4 2-.2.7-.4 1.4-.6 2.2-.2.8-.5 1.5-.8 2.4-.3.8-.6 1.6-1 2.5l-.6 1.2-.3.6-.3.6c-.2.4-.5.8-.7 1.3-.3.4-.5.8-.8 1.2-.1.2-.3.4-.4.6l-.4.6-.9 1.2c-.7.8-1.3 1.6-2.1 2.3-.7.8-1.5 1.4-2.3 2.2l-1.2 1c-.4.3-.8.6-1.3.9-1.7 1.2-3.5 2.3-5.3 3.3-1.8.9-3.7 1.8-5.5 2.5-1.8.7-3.6 1.3-5.3 1.8-1.7.5-3.3 1-4.9 1.3-3 .7-5.6 1.3-7.4 1.6-1.6.6-2.6.8-2.6.8z"/><g fill="#231F20"><path d="M127 83.9h-8.8l-12.6-36.4h7.9l9 27.5 9-27.5h7.9l9 27.5 9-27.5h7.9L153 83.9h-8.8L135.6 59 127 83.9zM200.1 83.9h-7V79c-3 3.6-7 5.4-12.1 5.4-3.8 0-6.9-1.1-9.4-3.2s-3.7-5-3.7-8.6c0-3.6 1.3-6.3 4-8 2.6-1.8 6.2-2.7 10.7-2.7h9.9v-1.4c0-4.8-2.7-7.3-8.1-7.3-3.4 0-6.9 1.2-10.5 3.7l-3.4-4.8c4.4-3.5 9.4-5.3 15.1-5.3 4.3 0 7.8 1.1 10.5 3.2 2.7 2.2 4.1 5.6 4.1 10.2v23.7zm-7.7-13.6v-3.1h-8.6c-5.5 0-8.3 1.7-8.3 5.2 0 1.8.7 3.1 2.1 4.1 1.4.9 3.3 1.4 5.7 1.4 2.4 0 4.6-.7 6.4-2.1 1.8-1.3 2.7-3.1 2.7-5.5zM241.7 47.5v31.7c0 6.4-1.7 11.3-5.2 14.5-3.5 3.2-8 4.8-13.4 4.8-5.5 0-10.4-1.7-14.8-5.1l3.6-5.8c3.6 2.7 7.1 4 10.8 4 3.6 0 6.5-.9 8.6-2.8 2.1-1.9 3.2-4.9 3.2-9v-4.7c-1.1 2.1-2.8 3.9-4.9 5.1-2.1 1.3-4.5 1.9-7.1 1.9-4.8 0-8.8-1.7-11.9-5.1-3.1-3.4-4.7-7.6-4.7-12.6s1.6-9.2 4.7-12.6c3.1-3.4 7.1-5.1 11.9-5.1 4.8 0 8.7 2 11.7 6v-5.4h7.5zm-28.4 16.8c0 3 .9 5.6 2.8 7.7 1.8 2.2 4.3 3.2 7.5 3.2 3.1 0 5.7-1 7.6-3.1 1.9-2.1 2.9-4.7 2.9-7.8 0-3.1-1-5.8-2.9-7.9-2-2.2-4.5-3.2-7.6-3.2-3.1 0-5.6 1.1-7.4 3.4-2 2.1-2.9 4.7-2.9 7.7zM260.9 53.6v18.5c0 1.7.5 3.1 1.4 4.1.9 1 2.2 1.5 3.8 1.5 1.6 0 3.2-.8 4.7-2.4l3.1 5.4c-2.7 2.4-5.7 3.6-8.9 3.6-3.3 0-6-1.1-8.3-3.4-2.3-2.3-3.5-5.3-3.5-9.1V53.6h-4.6v-6.2h4.6V36.1h7.7v11.4h9.6v6.2h-9.6zM309.5 83.9h-7V79c-3 3.6-7 5.4-12.1 5.4-3.8 0-6.9-1.1-9.4-3.2s-3.7-5-3.7-8.6c0-3.6 1.3-6.3 4-8 2.6-1.8 6.2-2.7 10.7-2.7h9.9v-1.4c0-4.8-2.7-7.3-8.1-7.3-3.4 0-6.9 1.2-10.5 3.7l-3.4-4.8c4.4-3.5 9.4-5.3 15.1-5.3 4.3 0 7.8 1.1 10.5 3.2 2.7 2.2 4.1 5.6 4.1 10.2v23.7zm-7.7-13.6v-3.1h-8.6c-5.5 0-8.3 1.7-8.3 5.2 0 1.8.7 3.1 2.1 4.1 1.4.9 3.3 1.4 5.7 1.4 2.4 0 4.6-.7 6.4-2.1 1.8-1.3 2.7-3.1 2.7-5.5zM319.3 40.2c-1-1-1.4-2.1-1.4-3.4 0-1.3.5-2.5 1.4-3.4 1-1 2.1-1.4 3.4-1.4 1.3 0 2.5.5 3.4 1.4 1 1 1.4 2.1 1.4 3.4 0 1.3-.5 2.5-1.4 3.4s-2.1 1.4-3.4 1.4c-1.3.1-2.4-.4-3.4-1.4zm7.2 43.7h-7.7V47.5h7.7v36.4zM342.5 83.9h-7.7V33.1h7.7v50.8z"/></g></svg>
</a>
</div>
<div class="header-link">
{% comment %}
This works for all cases but prerelease versions:
{% endcomment %}
<a href="{% wagtail_documentation_path %}/releases/{% wagtail_release_notes_path %}">
{% trans "View the release notes" %}
</a>
</div>
</header>
<main class="main">
<div class="figure">
<svg class="figure-space" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300" aria-hidden="true">
<path class="egg" fill="currentColor" d="M150 250c-42.741 0-75-32.693-75-90s42.913-110 75-110c32.088 0 75 52.693 75 110s-32.258 90-75 90z"/>
<ellipse fill="#ddd" cx="150" cy="270" rx="40" ry="7"/>
</svg>
</div>
<div class="main-text">
<h1>{% trans "Welcome to your new Wagtail site!" %}</h1>
<p>{% trans 'Please feel free to <a href="https://github.com/wagtail/wagtail/wiki/Slack">join our community on Slack</a>, or get started with one of the links below.' %}</p>
</div>
</main>
<footer class="footer" role="contentinfo">
<a class="option option-one" href="{% wagtail_documentation_path %}/">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 21c0 .5.4 1 1 1h4c.6 0 1-.5 1-1v-1H9v1zm3-19C8.1 2 5 5.1 5 9c0 2.4 1.2 4.5 3 5.7V17c0 .5.4 1 1 1h6c.6 0 1-.5 1-1v-2.3c1.8-1.3 3-3.4 3-5.7 0-3.9-3.1-7-7-7zm2.9 11.1l-.9.6V16h-4v-2.3l-.9-.6C7.8 12.2 7 10.6 7 9c0-2.8 2.2-5 5-5s5 2.2 5 5c0 1.6-.8 3.2-2.1 4.1z"/></svg>
<div>
<h2>{% trans "Wagtail Documentation" %}</h2>
<p>{% trans "Topics, references, & how-tos" %}</p>
</div>
</a>
<a class="option option-two" href="{% wagtail_documentation_path %}/getting_started/tutorial.html">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"/></svg>
<div>
<h2>{% trans "Tutorial" %}</h2>
<p>{% trans "Build your first Wagtail site" %}</p>
</div>
</a>
<a class="option option-three" href="{% url 'wagtailadmin_home' %}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M0 0h24v24H0z" fill="none"/><path d="M16.5 13c-1.2 0-3.07.34-4.5 1-1.43-.67-3.3-1-4.5-1C5.33 13 1 14.08 1 16.25V19h22v-2.75c0-2.17-4.33-3.25-6.5-3.25zm-4 4.5h-10v-1.25c0-.54 2.56-1.75 5-1.75s5 1.21 5 1.75v1.25zm9 0H14v-1.25c0-.46-.2-.86-.52-1.22.88-.3 1.96-.53 3.02-.53 2.44 0 5 1.21 5 1.75v1.25zM7.5 12c1.93 0 3.5-1.57 3.5-3.5S9.43 5 7.5 5 4 6.57 4 8.5 5.57 12 7.5 12zm0-5.5c1.1 0 2 .9 2 2s-.9 2-2 2-2-.9-2-2 .9-2 2-2zm9 5.5c1.93 0 3.5-1.57 3.5-3.5S18.43 5 16.5 5 13 6.57 13 8.5s1.57 3.5 3.5 3.5zm0-5.5c1.1 0 2 .9 2 2s-.9 2-2 2-2-.9-2-2 .9-2 2-2z"/></svg>
<div>
<h2>{% trans "Admin Interface" %}</h2>
<p>{% trans "Create your superuser first!" %}</p>
</div>
</a>
</footer>
@@ -0,0 +1,18 @@
# Generated by Django 5.2.5 on 2025-08-12 21:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('images', '0004_alter_customimage_alt_alter_customimage_attribution'),
]
operations = [
migrations.AddField(
model_name='customimage',
name='description',
field=models.CharField(blank=True, default='', max_length=255, verbose_name='description'),
),
]
Binary file not shown.
+413
View File
@@ -0,0 +1,413 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: dnscms\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-19 21:55+0200\n"
"Language: nb\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: associations/admin.py:23
msgid "Associations"
msgstr "Foreninger"
#: associations/admin.py:29 events/admin.py:59 news/admin.py:24
msgid "Title"
msgstr "Tittel"
#: associations/admin.py:32 associations/models.py:79
msgid "Type"
msgstr "Type"
#: associations/admin.py:38 events/admin.py:64 news/admin.py:27
msgid "Updated"
msgstr "Oppdatert"
#: associations/admin.py:42 events/admin.py:68 news/admin.py:31
msgid "Status"
msgstr "Status"
#: associations/models.py:30 associations/models.py:76 events/models.py:327
#: news/models.py:23 news/models.py:69
msgid "Lead"
msgstr "Ingress"
#: associations/models.py:31 associations/models.py:77
msgid "Content"
msgstr "Innhold"
#: associations/models.py:42
msgid "association index"
msgstr "foreningsoversikt"
#: associations/models.py:43
msgid "association indexes"
msgstr "foreningsoversikter"
#: associations/models.py:52
msgid "Association"
msgstr "Forening"
#: associations/models.py:53
msgid "Committee"
msgstr "Utvalg"
#: associations/models.py:73 news/models.py:60
msgid "Excerpt"
msgstr "Utdrag"
#: associations/models.py:74
msgid "A very short summary of the content below. Used in listing views."
msgstr ""
"En veldig kort oppsummering av innholdet nedenfor. Brukes i listevisninger."
#: associations/models.py:80 events/models.py:189
msgid "Website"
msgstr "Nettsted"
#: associations/models.py:98
msgid "association"
msgstr "forening"
#: associations/models.py:99
msgid "associations"
msgstr "foreninger"
#: associations/views.py:8
msgid "Choose an association"
msgstr "Velg en forening"
#: associations/views.py:9
msgid "Choose another association"
msgstr "Velg en annen forening"
#: associations/views.py:10
msgid "Edit this association"
msgstr "Rediger denne foreningen"
#: events/admin.py:20
msgid "%Y-%m-%d at %H:%M"
msgstr "%Y-%m-%d kl %H:%M"
#: events/admin.py:22
#, python-format
msgid "%(count)d occurrence"
msgid_plural "%(count)d occurrences"
msgstr[0] "%(count)d forekomst"
msgstr[1] "%(count)d forekomster"
#: events/admin.py:53
msgid "Events"
msgstr "Arrangementer"
#: events/admin.py:60
msgid "Date"
msgstr "Dato"
#: events/admin.py:61 events/models.py:331
msgid "Organizers"
msgstr "Arrangører"
#: events/models.py:73 events/models.py:156
msgid "slug"
msgstr "permalenke"
#: events/models.py:75
msgid "The name of the category as it will appear in URLs."
msgstr "Navnet på kategorien slik det vil vises i URL-er."
#: events/models.py:79
msgid "Should this category be available as a filter in the event programme?"
msgstr "Skal denne kategorien være mulig å filtrere på i programmet?"
#: events/models.py:83 events/models.py:266
msgid "None"
msgstr "Ingen"
#: events/models.py:91
msgid "Default pig for events of this kind."
msgstr "Standardgris for arrangementer av denne typen."
#: events/models.py:98 events/models.py:341
msgid "Pig"
msgstr "Gris"
#: events/models.py:109
msgid "event category"
msgstr "arrangementskategori"
#: events/models.py:110
msgid "event categories"
msgstr "arrangementskategorier"
#: events/models.py:138
msgid "organizer"
msgstr "arrangør"
#: events/models.py:139
msgid "organizers"
msgstr "arrangører"
#: events/models.py:158
msgid "The name of the organizer as it will appear in URLs."
msgstr "Navnet på arrangøren slik det vil vises i URL-er."
#: events/models.py:167
msgid "If a DNS association or committee is behind it, choose it here."
msgstr "Om en samfundsforening eller -utvalg står bak, velg det her."
#: events/models.py:173
msgid "Link to the external organizer's website"
msgstr "Lenke til nettstedet til ekstern arrangør"
#: events/models.py:182
msgid "Internal organizer"
msgstr "Intern arrangør"
#: events/models.py:185
msgid "External organizer"
msgstr "Ekstern arrangør"
#: events/models.py:190
msgid "Leave this empty if the organizer exists in the list above."
msgstr "La denne stå tom om arrangøren finnes i lista over."
#: events/models.py:204
msgid "event organizer"
msgstr "arrangør"
#: events/models.py:205
msgid "event organizers"
msgstr "arrangører"
#: events/models.py:239
msgid ""
"Choose an image for use in the programme and other surfaces. Should be a "
"photo or an illustration without too much text don't reuse a Facebook "
"cover uncritically!"
msgstr ""
"Velg et bilde til bruk i programmet og andre visningsflater. Bør være et "
"bilde eller en illustrasjon uten for mye tekst ikke gjenbruk et Facebook-"
"cover ukritisk!"
#: events/models.py:249
msgid ""
"A short text that appears right below the title. Feel free to leave it empty "
"if you fit most of it in the main title."
msgstr ""
"En kort tekst som kommer rett under tittelen. La denne gjerne stå tom om du "
"fikk plass til det meste i hovedtittelen."
#: events/models.py:267
msgid "Automatic"
msgstr "Automatisk"
#: events/models.py:276
msgid ""
"The pig that hangs out on the event page. Automatic causes one to be chosen "
"based on the event's category."
msgstr ""
"Grisen som henger på arrangementssiden. Automatisk fører til at en velges "
"basert på arrangementets kategori."
#: events/models.py:284
msgid "Direct link to ticket purchase, e.g. TicketCo, Billetto or Ticketmaster"
msgstr ""
"Lenke direkte til billettkjøp, f.eks. TicketCo, Billetto eller Ticketmaster"
#: events/models.py:289
msgid "Direct link to the event on Facebook"
msgstr "Lenke direkte til arrangementet på Facebook"
#: events/models.py:298
msgid "Free"
msgstr "Gratis"
#: events/models.py:298
msgid "Is this event free for everyone?"
msgstr "Er dette arrangementet gratis for alle?"
#: events/models.py:303
msgid "Regular price"
msgstr "Ordinær pris"
#: events/models.py:304
msgid "Price for students"
msgstr "Pris for studenter"
#: events/models.py:305
msgid "Price for DNS members"
msgstr "Pris for medlemmer av DNS"
#: events/models.py:312
msgid ""
"Write <strong>0</strong> for free. An empty field hides the price category. "
"If possible, write digits only."
msgstr ""
"Skriv <strong>0</strong> om gratis. Tomt felt skjuler priskategorien. Om "
"mulig, skriv kun tall."
#: events/models.py:321
msgid "Ticket purchase link"
msgstr "Billettkjøpslenke"
#: events/models.py:325
msgid "Subtitle"
msgstr "Undertittel"
#: events/models.py:334
msgid "Who is behind the event?"
msgstr "Hvem står bak arrangementet?"
#: events/models.py:337
msgid "Organizer"
msgstr "Arrangør"
#: events/models.py:344
msgid "Facebook link"
msgstr "Facebook-lenke"
#: events/models.py:345
msgid "Direct link to the event on Facebook."
msgstr "Lenke direkte til arrangementet på Facebook."
#: events/models.py:347
msgid "Pricing and tickets"
msgstr "Priser og billettkjøp"
#: events/models.py:349
msgid "Date, time and venue"
msgstr "Dato, tid og lokale"
#: events/models.py:353
msgid "If the event spans several days, add each day as a separate occurrence."
msgstr ""
"Om arrangementet går over flere dager, legg inn hver dag som en egen "
"forekomst."
#: events/models.py:356
msgid "Occurrence"
msgstr "Forekomst"
#: events/models.py:399
msgid "event"
msgstr "arrangement"
#: events/models.py:400
msgid "events"
msgstr "arrangementer"
#: events/models.py:560
msgid ""
"Use this <em>if none of the venues that can be selected on the left</em> "
"fit. E.g. <em>Frederikkeplassen</em> or <em>Sirkusteltet</em>."
msgstr ""
"Bruk denne <em>om ingen av lokalene som kan velges til venstre</em> passer. "
"F.eks. <em>Frederikkeplassen</em> eller <em>Sirkusteltet</em>."
#: events/models.py:569
msgid "Start"
msgstr "Start"
#: events/models.py:570
msgid "End"
msgstr "Slutt"
#: events/models.py:575
msgid "Venue"
msgstr "Lokale"
#: events/models.py:576
msgid "Venue as free text"
msgstr "Lokale som fritekst"
#: events/models.py:593
msgid "You can't both pick a venue and write something in this field."
msgstr "Du kan ikke både velge et lokale og skrive noe i dette feltet."
#: events/models.py:598
msgid "Venue is required."
msgstr "Lokale er påkrevd."
#: events/models.py:604
msgid "occurrence"
msgstr "forekomst"
#: events/models.py:605
msgid "occurrences"
msgstr "forekomster"
#: events/views.py:9
msgid "Choose organizers"
msgstr "Velg arrangører"
#: events/views.py:10
msgid "Choose an organizer"
msgstr "Velg en arrangør"
#: events/views.py:11
msgid "Choose another organizer"
msgstr "Velg en annen arrangør"
#: events/views.py:12
msgid "Edit this organizer"
msgstr "Rediger denne arrangøren"
#: images/models.py:40
msgid "image"
msgstr "bilde"
#: images/models.py:41
msgid "images"
msgstr "bilder"
#: news/admin.py:18
msgid "News"
msgstr "Nyheter"
#: news/models.py:33
msgid "news index"
msgstr "nyhetsoversikt"
#: news/models.py:34
msgid "news indexes"
msgstr "nyhetsoversikter"
#: news/models.py:52
msgid ""
"Choose an image for use on the front page and other surfaces. Should be a "
"photo or an illustration without too much text."
msgstr ""
"Velg et bilde til bruk på forsiden og andre visningsflater. Bør være et "
"bilde eller en illustrasjon uten for mye tekst."
#: news/models.py:62
msgid ""
"A very short summary of the article's content. Used on the front page and in "
"the article listing."
msgstr ""
"En veldig kort oppsummering av innholdet i artikkelen. Brukes på forsiden "
"og i artikkeloversikten."
#: news/models.py:71
msgid ""
"A brief, introductory paragraph that summarizes the main content of the "
"article."
msgstr ""
"Et kortfattet, innledende avsnitt som oppsummerer hovedinnholdet i "
"artikkelen."
#: news/models.py:92
msgid "news article"
msgstr "nyhetsartikkel"
#: news/models.py:93
msgid "news articles"
msgstr "nyhetsartikler"
+8 -3
View File
@@ -1,3 +1,8 @@
[tools] # Translation tasks
python = "3.13" [tasks.compilemessages]
uv = "latest" description = "Compile translation files for Norwegian Bokmal"
run = "uv run manage.py compilemessages -l nb"
[tasks.makemessages]
description = "Extract translation strings for Norwegian Bokmal"
run = "uv run manage.py makemessages -l nb"
+35
View File
@@ -0,0 +1,35 @@
from django.utils.translation import gettext_lazy as _
from wagtail.admin.ui.tables import DateColumn
from wagtail.admin.ui.tables.pages import PageStatusColumn, PageTitleColumn
from wagtail.admin.viewsets.pages import PageListingViewSet
from dnscms.admin import ListingRedirectChooseParentView
from news.models import NewsPage
class NewsChooseParentView(ListingRedirectChooseParentView):
listing_url_name = "news:index"
class NewsPageListingViewSet(PageListingViewSet):
model = NewsPage
choose_parent_view_class = NewsChooseParentView
icon = "info-circle"
menu_label = _("News")
menu_order = 3
add_to_admin_menu = True
ordering = "-latest_revision_created_at"
columns = [
PageTitleColumn("title", label=_("Title"), sort_key="title", classname="title"),
DateColumn(
"latest_revision_created_at",
label=_("Updated"),
sort_key="latest_revision_created_at",
width="10%",
),
PageStatusColumn("status", label=_("Status"), sort_key="live", width="10%"),
]
news_page_listing_viewset = NewsPageListingViewSet("news")
@@ -0,0 +1,28 @@
# Generated by Django 6.0.5 on 2026-05-19 19:55
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('images', '0005_customimage_description'),
('news', '0018_newspage_wp_block_json_newspage_wp_link_and_more'),
]
operations = [
migrations.AlterModelOptions(
name='newsindex',
options={'verbose_name': 'news index', 'verbose_name_plural': 'news indexes'},
),
migrations.AlterModelOptions(
name='newspage',
options={'verbose_name': 'news article', 'verbose_name_plural': 'news articles'},
),
migrations.AlterField(
model_name='newspage',
name='featured_image',
field=models.ForeignKey(blank=True, help_text='Choose an image for use on the front page and other surfaces. Should be a photo or an illustration without too much text.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='images.customimage'),
),
]
+32 -60
View File
@@ -1,24 +1,30 @@
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _
from grapple.helpers import register_singular_query_field from grapple.helpers import register_singular_query_field
from grapple.models import GraphQLImage, GraphQLRichText, GraphQLStreamfield, GraphQLString from grapple.models import GraphQLImage, GraphQLRichText, GraphQLStreamfield, GraphQLString
from wagtail.admin.panels import FieldPanel from wagtail.admin.panels import FieldPanel
from wagtail.fields import RichTextField from wagtail.fields import RichTextField
from wagtail.models import Page from wagtail.models import Page, PageManager
from wagtail.search import index from wagtail.search import index
from wagtail_headless_preview.models import HeadlessMixin
from dnscms.fields import CommonStreamField from dnscms.fields import CommonStreamField
from dnscms.wordpress.models import WPImportedPageMixin from dnscms.wordpress.models import DeferWPFieldsManagerMixin, WPImportedPageMixin
class NewsPageManager(DeferWPFieldsManagerMixin, PageManager):
pass
@register_singular_query_field("newsIndex") @register_singular_query_field("newsIndex")
class NewsIndex(Page): class NewsIndex(HeadlessMixin, Page):
max_count = 1 max_count = 1
subpage_types = ["news.NewsPage"] subpage_types = ["news.NewsPage"]
lead = RichTextField(features=["italic", "link"], blank=True) lead = RichTextField(features=["italic", "link"], blank=True)
content_panels = Page.content_panels + [ content_panels = Page.content_panels + [
FieldPanel("lead", heading="Ingress"), FieldPanel("lead", heading=_("Lead")),
] ]
graphql_fields = [ graphql_fields = [
@@ -27,12 +33,18 @@ class NewsIndex(Page):
search_fields = [] search_fields = []
class Meta:
verbose_name = _("news index")
verbose_name_plural = _("news indexes")
class NewsPage(WPImportedPageMixin, Page):
class NewsPage(HeadlessMixin, WPImportedPageMixin, Page):
subpage_types = [] subpage_types = []
parent_page_types = ["news.NewsIndex"] parent_page_types = ["news.NewsIndex"]
show_in_menus = False show_in_menus = False
objects = NewsPageManager()
excerpt = models.TextField(max_length=512, blank=False) excerpt = models.TextField(max_length=512, blank=False)
lead = RichTextField(features=["italic", "link"], blank=True) lead = RichTextField(features=["italic", "link"], blank=True)
body = CommonStreamField body = CommonStreamField
@@ -42,23 +54,28 @@ class NewsPage(WPImportedPageMixin, Page):
blank=True, blank=True,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
related_name="+", related_name="+",
help_text=( help_text=_(
"Velg et bilde til bruk i på forsiden og andre visningsflater. " "Choose an image for use on the front page and other surfaces. "
"Bør være et bilde eller en illustrasjon uten tekst." "Should be a photo or an illustration without too much text."
), ),
) )
content_panels = Page.content_panels + [ content_panels = Page.content_panels + [
FieldPanel( FieldPanel(
"excerpt", "excerpt",
heading="Utdrag", heading=_("Excerpt"),
help_text="En veldig kort oppsummering av innholdet i artikkelen. Brukes på forsiden og i artikkeloversikten.", help_text=_(
"A very short summary of the article's content. "
"Used on the front page and in the article listing."
),
), ),
FieldPanel("featured_image"), FieldPanel("featured_image"),
FieldPanel( FieldPanel(
"lead", "lead",
heading="Ingress", heading=_("Lead"),
help_text="Et kortfattet, innledende avsnitt som oppsummerer hovedinnholdet i artikkelen.", help_text=_(
"A brief, introductory paragraph that summarizes the main content of the article."
),
), ),
FieldPanel("body"), FieldPanel("body"),
] ]
@@ -76,51 +93,6 @@ class NewsPage(WPImportedPageMixin, Page):
index.SearchField("body"), index.SearchField("body"),
] ]
def import_wordpress_data(self, data): class Meta:
import html verbose_name = _("news article")
verbose_name_plural = _("news articles")
from bs4 import BeautifulSoup
def generate_excerpt(html_content):
soup = BeautifulSoup(html_content, features="lxml")
VALID_TAGS = ["div", "p"]
for tag in soup.findAll("p"):
if tag.name not in VALID_TAGS:
tag.remove()
text = soup.get_text().strip()
words = text.split(" ")
if len(words) < 26:
return text
return " ".join(words[:25]) + " [...]"
# Wagtail page model fields
self.title = html.unescape(data["title"])
self.slug = data["slug"]
self.first_published_at = data["first_published_at"]
self.last_published_at = data["last_published_at"]
self.latest_revision_created_at = data["latest_revision_created_at"]
self.search_description = data["search_description"]
# debug fields
self.wp_post_id = data["wp_post_id"]
self.wp_post_type = data["wp_post_type"]
self.wp_link = data["wp_link"]
self.wp_raw_content = data["wp_raw_content"]
self.wp_block_json = data["wp_block_json"]
self.wp_processed_content = data["wp_processed_content"]
self.wp_normalized_styles = data["wp_normalized_styles"]
self.wp_post_meta = data["wp_post_meta"]
# own model fields
self.body = data["body"] or ""
meta = data["wp_post_meta"]
written_excerpt = meta.get("excerpt_encoded")
generated_excerpt = ""
if not written_excerpt:
generated_excerpt = generate_excerpt(self.wp_processed_content)
self.excerpt = written_excerpt or generated_excerpt or "[...]"
+8
View File
@@ -0,0 +1,8 @@
from wagtail import hooks
from .admin import news_page_listing_viewset
@hooks.register("register_admin_viewset")
def register_news_page_listing_viewset():
return news_page_listing_viewset
+2 -1
View File
@@ -6,7 +6,8 @@ from modelcluster.fields import ParentalKey
from modelcluster.models import ClusterableModel from modelcluster.models import ClusterableModel
from wagtail import blocks from wagtail import blocks
from wagtail.admin.panels import FieldPanel, InlinePanel, MultiFieldPanel, TitleFieldPanel from wagtail.admin.panels import FieldPanel, InlinePanel, MultiFieldPanel, TitleFieldPanel
from wagtail.models import Orderable, StreamField from wagtail.fields import StreamField
from wagtail.models import Orderable
from wagtail.snippets.models import register_snippet from wagtail.snippets.models import register_snippet
-3
View File
@@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.
+32 -10
View File
@@ -3,20 +3,26 @@ name = "dnscms"
version = "0.1.0" version = "0.1.0"
description = "" description = ""
authors = [{ name = "EDB", email = "edb@neuf.no" }] authors = [{ name = "EDB", email = "edb@neuf.no" }]
requires-python = ">=3.12, <3.13" requires-python = ">=3.14, <3.15"
readme = "README.md"
dependencies = [ dependencies = [
"wagtail>=6.1.3,<7", "wagtail>=7.4.1,<8",
"django>=5.0.7,<6", "wagtail-grapple>=0.31.0,<0.32",
"wagtail-grapple>=0.26.0,<0.27", "wagtail-headless-preview>=0.8,<0.9",
"psycopg2-binary>=2.9.10,<3", "django>=6.0.5,<7",
"django-extensions>=3.2.3,<4", "django-extensions>=4.1,<5",
"whitenoise>=6.7.0,<7", "psycopg2-binary>=2.9.12,<3",
"gunicorn>=23.0.0", "gunicorn>=26.0.0,<27",
"whitenoise>=6.12.0,<7",
] ]
[dependency-groups] [dependency-groups]
dev = ["ruff"] dev = [
"ruff>=0.15.13,<0.16",
"pytest>=9.0.3,<10",
"pytest-cov>=7.0.0,<8",
"pytest-django>=4.12.0,<5",
"wagtail-factories>=4.4.0,<5",
]
[tool.uv] [tool.uv]
package = false package = false
@@ -32,3 +38,19 @@ line-length = 99
select = ["F", "E", "W", "Q", "UP", "DJ"] select = ["F", "E", "W", "Q", "UP", "DJ"]
ignore = [] ignore = []
exclude = ["**/migrations/*.py"] exclude = ["**/migrations/*.py"]
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "dnscms.settings.test"
python_files = ["test_*.py"]
testpaths = ["tests"]
addopts = "--cov=. --cov-report=term-missing"
[tool.coverage.run]
omit = [
"*/migrations/*",
"tests/*",
"manage.py",
"dnscms/settings/*",
"dnscms/wsgi.py",
"dnscms/asgi.py",
]
+2 -1
View File
@@ -8,6 +8,7 @@ from wagtail.fields import RichTextField, StreamField
from wagtail.images.blocks import ImageChooserBlock from wagtail.images.blocks import ImageChooserBlock
from wagtail.models import Page from wagtail.models import Page
from wagtail.search import index from wagtail.search import index
from wagtail_headless_preview.models import HeadlessMixin
from dnscms.blocks import BASE_BLOCKS from dnscms.blocks import BASE_BLOCKS
@@ -34,7 +35,7 @@ class SponsorBlock(blocks.StructBlock):
@register_singular_query_field("sponsorsPage") @register_singular_query_field("sponsorsPage")
class SponsorsPage(Page): class SponsorsPage(HeadlessMixin, Page):
max_count = 1 max_count = 1
subpage_types = [] subpage_types = []
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class StudioConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "studio"
+32
View File
@@ -0,0 +1,32 @@
# Generated by Django 6.0.5 on 2026-05-19 02:59
import django.db.models.deletion
import wagtail.fields
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('images', '0005_customimage_description'),
('wagtailcore', '0097_baselogentry_uuid_action_timestamp_indexes'),
]
operations = [
migrations.CreateModel(
name='StudioPage',
fields=[
('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')),
('lead', wagtail.fields.RichTextField(blank=True)),
('body', wagtail.fields.StreamField([('paragraph', 0), ('image', 4), ('image_slider', 8), ('horizontal_rule', 10), ('featured', 18), ('page_section_navigation', 19), ('accordion', 23), ('fact_box', 26), ('embed', 27), ('raw_html', 28), ('page_section', 33)], block_lookup={0: ('wagtail.blocks.RichTextBlock', (), {'label': 'Rik tekst'}), 1: ('wagtail.images.blocks.ImageChooserBlock', (), {'label': 'Bilde'}), 2: ('wagtail.blocks.ChoiceBlock', [], {'choices': [('fullwidth', 'Fullbredde'), ('bleed', 'Utfallende'), ('original', 'Uendret størrelse')], 'icon': 'cup', 'label': 'Bildeformat'}), 3: ('wagtail.blocks.CharBlock', (), {'label': 'Bildetekst', 'max_length': 512, 'required': False}), 4: ('wagtail.blocks.StructBlock', [[('image', 1), ('image_format', 2), ('text', 3)]], {}), 5: ('wagtail.blocks.CharBlock', (), {'label': 'Tekst', 'max_length': 512, 'required': False}), 6: ('wagtail.blocks.StructBlock', [[('image', 1), ('text', 5)]], {}), 7: ('wagtail.blocks.ListBlock', (6,), {'label': 'Bilder', 'min_num': 1}), 8: ('wagtail.blocks.StructBlock', [[('images', 7)]], {}), 9: ('wagtail.blocks.ChoiceBlock', [], {'choices': [('deepBrick', 'Dyp tegl'), ('neufPink', 'Griserosa'), ('goldenOrange', 'Gyllen oransje'), ('goldenBeige', 'Gyllen beige'), ('chateauBlue', 'Slottsblå')], 'label': 'Farge', 'required': False}), 10: ('wagtail.blocks.StructBlock', [[('color', 9)]], {}), 11: ('wagtail.blocks.CharBlock', (), {'label': 'Tittel', 'max_length': 64, 'required': True}), 12: ('wagtail.blocks.RichTextBlock', (), {'features': ['bold', 'italic', 'link'], 'label': 'Tekst', 'required': True}), 13: ('wagtail.blocks.PageChooserBlock', (), {'header': 'Fremhevet side', 'required': True}), 14: ('wagtail.blocks.CharBlock', (), {'default': 'Les mer', 'help_text': 'Lenketeksten som tar deg videre til siden. Tips: Ikke start med "Trykk her"', 'label': 'Lenketekst', 'max_length': 64, 'required': True}), 15: ('wagtail.blocks.ChoiceBlock', [], {'choices': [('betongGray', 'Betonggrå'), ('deepBrick', 'Dyp tegl'), ('neufPink', 'Griserosa'), ('goldenOrange', 'Gyllen oransje'), ('goldenBeige', 'Gyllen beige'), ('chateauBlue', 'Slottsblå')], 'label': 'Bakgrunnsfarge'}), 16: ('wagtail.blocks.ChoiceBlock', [], {'choices': [('left', 'Venstre'), ('right', 'Høyre')], 'label': 'Bildeplassering'}), 17: ('wagtail.images.blocks.ImageChooserBlock', (), {'header': 'Overstyr bilde', 'help_text': 'Bildet som er tilknyttet undersiden du vil fremheve, vil automatisk brukes. Om det mangler eller du vil overstyre hvilket bilde som et brukes, kan du velge et her.', 'required': False}), 18: ('wagtail.blocks.StructBlock', [[('title', 11), ('text', 12), ('featured_page', 13), ('link_text', 14), ('background_color', 15), ('image_position', 16), ('featured_image_override', 17)]], {}), 19: ('dnscms.blocks.PageSectionNavigationBlock', (), {}), 20: ('wagtail.blocks.CharBlock', (), {'label': 'Overskrift', 'max_length': 64, 'required': True}), 21: ('wagtail.blocks.StructBlock', [[('image', 1), ('image_format', 2), ('text', 3)]], {'label': 'Bilde'}), 22: ('wagtail.blocks.StreamBlock', [[('paragraph', 0), ('image', 21)]], {}), 23: ('wagtail.blocks.StructBlock', [[('heading', 20), ('body', 22)]], {}), 24: ('wagtail.blocks.ChoiceBlock', [], {'choices': [('betongGray', 'Betonggrå'), ('deepBrick', 'Dyp tegl'), ('neufPink', 'Griserosa'), ('goldenOrange', 'Gyllen oransje'), ('goldenBeige', 'Gyllen beige'), ('chateauBlue', 'Slottsblå')], 'label': 'Bakgrunnsfarge', 'required': False}), 25: ('wagtail.blocks.RichTextBlock', (), {'features': ['bold', 'italic', 'link', 'ol', 'ul', 'h2', 'h3'], 'label': 'Innhold'}), 26: ('wagtail.blocks.StructBlock', [[('background_color', 24), ('body', 25)]], {}), 27: ('wagtail.embeds.blocks.EmbedBlock', (), {}), 28: ('wagtail.blocks.RawHTMLBlock', (), {}), 29: ('wagtail.blocks.ChoiceBlock', [], {'choices': [('pigHeadLogo', 'Grisehodelogo'), ('key', 'Nøkkel'), ('ticket', 'Billett'), ('shield', 'Skjold'), ('bottle', 'Flaske'), ('lostProperty', 'Hittegods'), ('pigsty', 'Grisebinge'), ('wheelchair', 'Rullestol'), ('clock', 'Klokke'), ('parking', 'Parkering'), ('coins', 'Mynter')], 'label': 'Ikon', 'required': False}), 30: ('dnscms.blocks.NeufAddressSectionBlock', (), {}), 31: ('dnscms.blocks.OpeningHoursSectionBlock', (), {}), 32: ('wagtail.blocks.StreamBlock', [[('paragraph', 0), ('image', 4), ('image_slider', 8), ('horizontal_rule', 10), ('featured', 18), ('accordion', 23), ('fact_box', 26), ('embed', 27), ('raw_html', 28), ('neuf_address', 30), ('opening_hours', 31)]], {}), 33: ('wagtail.blocks.StructBlock', [[('title', 11), ('background_color', 24), ('icon', 29), ('body', 32)]], {})})),
('pig', models.CharField(blank=True, choices=[('', 'Ingen'), ('logo', 'Logogrisen'), ('music', 'Musikergrisen'), ('drink', 'Drikkegrisen'), ('dance', 'Dansegrisen'), ('point', 'Pekegrisen'), ('student', 'Studentgrisen'), ('listen', 'Lyttegrisen'), ('guard', 'Vaktgrisen'), ('key', 'Nøkkelgrisen'), ('chill', 'Liggegrisen'), ('peek', 'Tittegrisen')], default='', help_text='Grisen nedi hjørnet.', max_length=32)),
('logo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='images.customimage')),
],
options={
'abstract': False,
},
bases=('wagtailcore.page',),
),
]
+64
View File
@@ -0,0 +1,64 @@
from django.db import models
from grapple.helpers import register_singular_query_field
from grapple.models import (
GraphQLImage,
GraphQLRichText,
GraphQLStreamfield,
GraphQLString,
)
from wagtail.admin.panels import FieldPanel
from wagtail.fields import RichTextField, StreamField
from wagtail.models import Page
from wagtail.search import index
from wagtail_headless_preview.models import HeadlessMixin
from dnscms.blocks import BASE_BLOCKS, PageSectionBlock
from dnscms.options import ALL_PIGS
@register_singular_query_field("studioPage")
class StudioPage(HeadlessMixin, Page):
max_count = 1
subpage_types = []
show_in_menus = True
logo = models.ForeignKey(
"images.CustomImage",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
)
lead = RichTextField(features=["link"], blank=True)
body = StreamField(BASE_BLOCKS + [("page_section", PageSectionBlock())])
PIG_CHOICES = [
("", "Ingen"),
] + ALL_PIGS
pig = models.CharField(
max_length=32,
choices=PIG_CHOICES,
default="",
blank=True,
help_text="Grisen nedi hjørnet.",
)
content_panels = Page.content_panels + [
FieldPanel("logo"),
FieldPanel("lead", heading="Ingress"),
FieldPanel("body", heading="Innhold"),
FieldPanel("pig", heading="Gris"),
]
graphql_fields = [
GraphQLImage("logo"),
GraphQLRichText("lead"),
GraphQLStreamfield("body"),
GraphQLString("pig", required=True),
]
search_fields = Page.search_fields + [
index.SearchField("lead"),
index.SearchField("body"),
]
View File
+144
View File
@@ -0,0 +1,144 @@
import json
import factory
import pytest
import wagtail_factories
from wagtail.models import Page
from associations.models import AssociationIndex, AssociationPage
from events.models import EventIndex, EventPage
from generic.models import GenericPage
from images.models import CustomImage
from news.models import NewsIndex, NewsPage
from studio.models import StudioPage
from venues.models import VenueIndex, VenuePage
class CustomImageFactory(wagtail_factories.ImageFactory):
class Meta:
model = CustomImage
class AssociationIndexFactory(wagtail_factories.PageFactory):
title = factory.Sequence(lambda n: f"Associations {n}")
lead = "<p>Foreninger og utvalg.</p>"
class Meta:
model = AssociationIndex
class AssociationPageFactory(wagtail_factories.PageFactory):
title = factory.Sequence(lambda n: f"Association {n}")
excerpt = "Et utdrag."
class Meta:
model = AssociationPage
class EventIndexFactory(wagtail_factories.PageFactory):
title = factory.Sequence(lambda n: f"Events {n}")
class Meta:
model = EventIndex
class EventPageFactory(wagtail_factories.PageFactory):
title = factory.Sequence(lambda n: f"Event {n}")
class Meta:
model = EventPage
class GenericPageFactory(wagtail_factories.PageFactory):
title = factory.Sequence(lambda n: f"Page {n}")
lead = "<p>Ingress.</p>"
class Meta:
model = GenericPage
class StudioPageFactory(wagtail_factories.PageFactory):
title = factory.Sequence(lambda n: f"Studio {n}")
class Meta:
model = StudioPage
class NewsIndexFactory(wagtail_factories.PageFactory):
title = factory.Sequence(lambda n: f"News {n}")
class Meta:
model = NewsIndex
class NewsPageFactory(wagtail_factories.PageFactory):
title = factory.Sequence(lambda n: f"Article {n}")
excerpt = "Et utdrag."
class Meta:
model = NewsPage
class VenueIndexFactory(wagtail_factories.PageFactory):
title = factory.Sequence(lambda n: f"Venues {n}")
class Meta:
model = VenueIndex
class VenuePageFactory(wagtail_factories.PageFactory):
title = factory.Sequence(lambda n: f"Venue {n}")
class Meta:
model = VenuePage
@pytest.fixture
def root_page(db):
return Page.objects.get(depth=1)
@pytest.fixture
def home_page(root_page):
# Wagtail's initial migration creates a default "Welcome" page at depth=2.
# Reuse it so we don't fight slug collisions across tests.
return root_page.get_children().first() or root_page.add_child(
instance=Page(title="Home", slug="home")
)
@pytest.fixture
def event_index(home_page):
return EventIndexFactory(parent=home_page)
@pytest.fixture
def news_index(home_page):
return NewsIndexFactory(parent=home_page)
@pytest.fixture
def association_index(home_page):
return AssociationIndexFactory(parent=home_page)
@pytest.fixture
def venue(home_page):
venue_index = VenueIndexFactory(parent=home_page)
return VenuePageFactory(parent=venue_index)
@pytest.fixture
def graphql_post(client):
def _post(query, variables=None):
payload = {"query": query}
if variables is not None:
payload["variables"] = variables
response = client.post(
"/api/graphql/",
data=json.dumps(payload),
content_type="application/json",
)
return response, response.json()
return _post
+53
View File
@@ -0,0 +1,53 @@
from associations.admin import AssociationTypeColumn
from associations.models import AssociationPage
from tests.conftest import AssociationPageFactory
def test_associationpage_persists_via_factory(association_index):
page = AssociationPageFactory(
parent=association_index,
title="EDB-gjengen",
excerpt="WOW FLINKE",
association_type=AssociationPage.AssociationType.UTVALG,
)
reloaded = AssociationPage.objects.get(pk=page.pk)
assert reloaded.title == "EDB-gjengen"
assert reloaded.excerpt == "WOW FLINKE"
assert reloaded.association_type == "utvalg"
def test_association_type_column_renders_forening_display(association_index):
page = AssociationPageFactory(
parent=association_index,
association_type=AssociationPage.AssociationType.FORENING,
)
column = AssociationTypeColumn("association_type")
assert column.get_value(page) == "Forening"
def test_association_type_column_renders_utvalg_display(association_index):
page = AssociationPageFactory(
parent=association_index,
association_type=AssociationPage.AssociationType.UTVALG,
)
column = AssociationTypeColumn("association_type")
assert column.get_value(page) == "Utvalg"
def test_graphql_association_index_query(association_index, graphql_post):
response, body = graphql_post(
"""
query {
associationIndex {
title
}
}
"""
)
assert response.status_code == 200
assert "errors" not in body, body
assert body["data"]["associationIndex"]["title"] == association_index.title
+522
View File
@@ -0,0 +1,522 @@
from datetime import datetime, timedelta
import pytest
from django.core.exceptions import ValidationError
from django.db import connection
from django.test.utils import CaptureQueriesContext
from django.utils import timezone
from events.admin import EventDateColumn, OrganizersColumn
from events.models import (
EventCategory,
EventOccurrence,
EventOrganizer,
EventOrganizerLink,
EventPage,
)
from tests.conftest import (
AssociationPageFactory,
CustomImageFactory,
EventPageFactory,
)
def test_eventpage_clean_unsets_specific_pricing_when_free():
page = EventPage(
title="Free event",
slug="free-event",
free=True,
price_regular="100",
price_student="50",
price_member="25",
)
page.clean()
assert page.price_regular == ""
assert page.price_student == ""
assert page.price_member == ""
def test_eventpage_clean_keeps_specific_pricing_when_not_free():
page = EventPage(
title="Paid event",
slug="paid-event",
free=False,
price_regular="100",
price_student="50",
price_member="25",
)
page.clean()
assert page.price_regular == "100"
assert page.price_student == "50"
assert page.price_member == "25"
def test_eventpage_clean_dedupes_organizers_by_name(event_index):
org_a = EventOrganizer.objects.create(name="DNS", slug="dns-a")
org_b = EventOrganizer.objects.create(name="DNS", slug="dns-b")
event = EventPageFactory(parent=event_index)
EventOrganizerLink.objects.create(event=event, organizer=org_a)
EventOrganizerLink.objects.create(event=event, organizer=org_b)
event = EventPage.objects.get(pk=event.pk)
assert event.organizer_links.count() == 2
event.clean()
assert event.organizer_links.count() == 1
def test_eventpage_clean_dedupes_three_duplicates_and_keeps_distinct(event_index):
dup_1 = EventOrganizer.objects.create(name="DNS", slug="dns-1")
dup_2 = EventOrganizer.objects.create(name="DNS", slug="dns-2")
dup_3 = EventOrganizer.objects.create(name="DNS", slug="dns-3")
distinct = EventOrganizer.objects.create(name="Studentersamfundet", slug="ss")
event = EventPageFactory(parent=event_index)
for organizer in (dup_1, dup_2, dup_3, distinct):
EventOrganizerLink.objects.create(event=event, organizer=organizer)
event = EventPage.objects.get(pk=event.pk)
assert event.organizer_links.count() == 4
event.clean()
names = sorted(link.organizer.name for link in event.organizer_links.all())
assert names == ["DNS", "Studentersamfundet"]
def test_eventoccurrence_clean_rejects_both_venue_and_venue_custom(event_index, venue):
event = EventPageFactory(parent=event_index)
occurrence = EventOccurrence(
event=event,
start=timezone.now(),
venue=venue,
venue_custom="Frederikkeplassen",
)
with pytest.raises(ValidationError) as exc:
occurrence.clean()
assert "venue_custom" in exc.value.message_dict
def test_eventoccurrence_clean_requires_venue_or_venue_custom(event_index):
event = EventPageFactory(parent=event_index)
occurrence = EventOccurrence(event=event, start=timezone.now())
with pytest.raises(ValidationError) as exc:
occurrence.clean()
assert "venue" in exc.value.message_dict
def test_eventpage_manager_future_filters_past_and_annotates(event_index):
now = timezone.now()
past = EventPageFactory(parent=event_index, title="Past")
EventOccurrence.objects.create(event=past, start=now - timedelta(days=7), venue_custom="Old")
future = EventPageFactory(parent=event_index, title="Future")
EventOccurrence.objects.create(event=future, start=now + timedelta(days=7), venue_custom="New")
results = list(EventPage.objects.live().future().order_by("next_occurrence"))
assert [p.pk for p in results] == [future.pk]
assert results[0].next_occurrence is not None
def test_future_includes_occurrence_late_today(event_index):
today_start = timezone.localtime(timezone.now()).replace(
hour=0, minute=0, second=0, microsecond=0
)
late_today = today_start + timedelta(hours=23, minutes=59)
event = EventPageFactory(parent=event_index, title="Late today")
EventOccurrence.objects.create(event=event, start=late_today, venue_custom="X")
assert event.pk in EventPage.objects.future().values_list("pk", flat=True)
def test_future_excludes_occurrence_just_before_today(event_index):
today_start = timezone.localtime(timezone.now()).replace(
hour=0, minute=0, second=0, microsecond=0
)
just_before_today = today_start - timedelta(seconds=1)
event = EventPageFactory(parent=event_index, title="Just past")
EventOccurrence.objects.create(event=event, start=just_before_today, venue_custom="X")
assert event.pk not in EventPage.objects.future().values_list("pk", flat=True)
def test_future_next_occurrence_picks_earliest_future_ignoring_past(event_index):
now = timezone.now()
soonest_future = now + timedelta(days=3)
event = EventPageFactory(parent=event_index, title="With history")
EventOccurrence.objects.create(event=event, start=now - timedelta(days=30), venue_custom="X")
EventOccurrence.objects.create(event=event, start=soonest_future, venue_custom="X")
EventOccurrence.objects.create(event=event, start=now + timedelta(days=10), venue_custom="X")
annotated = EventPage.objects.future().filter(pk=event.pk).first()
assert annotated is not None
assert abs((annotated.next_occurrence - soonest_future).total_seconds()) < 1
def test_graphql_event_index_future_events_query(event_index, graphql_post):
upcoming = EventPageFactory(parent=event_index, title="Upcoming gig")
EventOccurrence.objects.create(
event=upcoming,
start=timezone.now() + timedelta(days=3),
venue_custom="Storsalen",
)
response, body = graphql_post(
"""
query {
eventIndex {
futureEvents { title }
}
}
"""
)
assert response.status_code == 200
assert "errors" not in body, body
titles = [e["title"] for e in body["data"]["eventIndex"]["futureEvents"]]
assert "Upcoming gig" in titles
def test_future_events_does_not_have_n_plus_one_queries(
event_index, venue, association_index, graphql_post
):
"""Regression test: query count for futureEvents stays bounded as events grow."""
konsert = EventCategory.objects.create(name="Konsert", slug="konsert")
association = AssociationPageFactory(parent=association_index, title="DNS")
org = EventOrganizer.objects.create(name="Forening", slug="forening", association=association)
image = CustomImageFactory(title="Cover")
now = timezone.now()
for i in range(5):
event = EventPageFactory(
parent=event_index,
title=f"Event {i}",
body=[("paragraph", "<p>x</p>")],
featured_image=image,
)
event.categories.add(konsert)
EventOrganizerLink.objects.create(event=event, organizer=org)
EventOccurrence.objects.create(
event=event,
start=now + timedelta(days=i + 1),
venue=venue,
)
home_query = """
query {
eventIndex {
futureEvents {
id
title
subtitle
body { blockType }
featuredImage { url }
occurrences { start end venueCustom venue { title } }
categories { name slug }
organizers { name slug association { title } }
}
}
}
"""
with CaptureQueriesContext(connection) as ctx:
response, body = graphql_post(home_query)
assert response.status_code == 200
assert "errors" not in body, body
assert len(body["data"]["eventIndex"]["futureEvents"]) == 5
# Bump only alongside an intentional resolver change.
max_queries = 6
assert len(ctx) <= max_queries, (
f"futureEvents took {len(ctx)} queries for 5 events — likely N+1. "
f"Captured queries:\n"
+ "\n".join(f" {i + 1}. {q['sql'][:120]}" for i, q in enumerate(ctx.captured_queries))
)
def test_future_events_does_not_load_wp_import_fields(event_index, graphql_post):
"""wp_* columns must stay deferred and lazy-load on explicit access."""
event = EventPageFactory(parent=event_index, wp_raw_content="marker")
EventOccurrence.objects.create(
event=event, start=timezone.now() + timedelta(days=1), venue_custom="X"
)
with CaptureQueriesContext(connection) as ctx:
response, body = graphql_post("{ eventIndex { futureEvents { id } } }")
assert response.status_code == 200 and "errors" not in body, body
sql = "\n".join(q["sql"] for q in ctx.captured_queries)
assert "wp_raw_content" not in sql, f"wp_* must be deferred. SQL:\n{sql}"
assert EventPage.objects.get(pk=event.pk).wp_raw_content == "marker"
def test_graphql_event_index_future_events_ordered_by_next_occurrence(event_index, graphql_post):
now = timezone.now()
later = EventPageFactory(parent=event_index, title="Later gig")
EventOccurrence.objects.create(event=later, start=now + timedelta(days=10), venue_custom="X")
sooner = EventPageFactory(parent=event_index, title="Sooner gig")
EventOccurrence.objects.create(event=sooner, start=now + timedelta(days=3), venue_custom="X")
response, body = graphql_post(
"""
query {
eventIndex {
futureEvents { title }
}
}
"""
)
assert response.status_code == 200
assert "errors" not in body, body
titles = [e["title"] for e in body["data"]["eventIndex"]["futureEvents"]]
assert titles.index("Sooner gig") < titles.index("Later gig")
def test_event_date_column_no_occurrences(event_index):
event = EventPageFactory(parent=event_index)
column = EventDateColumn("event_date")
assert column.get_value(event) == ""
def test_event_date_column_single_occurrence(event_index):
event = EventPageFactory(parent=event_index)
start = timezone.make_aware(datetime(2025, 7, 22, 19, 30))
EventOccurrence.objects.create(event=event, start=start, venue_custom="X")
column = EventDateColumn("event_date")
assert column.get_value(event) == "2025-07-22 kl 19:30"
def test_event_date_column_multiple_occurrences_shows_count(event_index):
event = EventPageFactory(parent=event_index)
now = timezone.now()
EventOccurrence.objects.create(event=event, start=now, venue_custom="X")
EventOccurrence.objects.create(event=event, start=now + timedelta(days=1), venue_custom="X")
EventOccurrence.objects.create(event=event, start=now + timedelta(days=2), venue_custom="X")
column = EventDateColumn("event_date")
assert column.get_value(event) == "3 forekomster"
def test_organizers_column_no_organizers(event_index):
event = EventPageFactory(parent=event_index)
column = OrganizersColumn("organizers")
assert column.get_value(event) == ""
def test_organizers_column_single_organizer_shows_name(event_index):
org = EventOrganizer.objects.create(name="Forening A", slug="forening-a")
event = EventPageFactory(parent=event_index)
EventOrganizerLink.objects.create(event=event, organizer=org)
column = OrganizersColumn("organizers")
assert column.get_value(event) == "Forening A"
def test_organizers_column_multiple_organizers_truncates_with_count(event_index):
org_a = EventOrganizer.objects.create(name="Forening A", slug="forening-a")
org_b = EventOrganizer.objects.create(name="Forening B", slug="forening-b")
org_c = EventOrganizer.objects.create(name="Forening C", slug="forening-c")
event = EventPageFactory(parent=event_index)
EventOrganizerLink.objects.create(event=event, organizer=org_a, sort_order=0)
EventOrganizerLink.objects.create(event=event, organizer=org_b, sort_order=1)
EventOrganizerLink.objects.create(event=event, organizer=org_c, sort_order=2)
column = OrganizersColumn("organizers")
assert column.get_value(event) == "Forening A (+2)"
@pytest.fixture
def comprehensive_event(event_index, venue, association_index):
"""A fully-populated paid EventPage exercising every field exposed via GraphQL."""
image = CustomImageFactory(
title="Cover",
alt="Et fotografi av en gris med solbriller",
attribution="Foto: Test",
)
konsert = EventCategory.objects.create(
name="Konsert", slug="konsert", show_in_filters=True, pig="pigHeadLogo"
)
klubb = EventCategory.objects.create(name="Klubb", slug="klubb")
association = AssociationPageFactory(
parent=association_index,
title="Internal",
association_type="forening",
)
internal_org = EventOrganizer.objects.create(
name="Internal", slug="internal", association=association
)
external_org = EventOrganizer.objects.create(
name="External",
slug="external",
external_url="https://external.example.com",
)
event = EventPageFactory(
parent=event_index,
title="Et arrangement",
slug="et-arrangement",
subtitle="En undertekst",
lead="<p>Ingress.</p>",
body=[("paragraph", "<p>Body content.</p>")],
pig="automatic",
free=False,
price_regular="150",
price_student="100",
price_member="75",
ticket_url="https://example.com/tickets",
facebook_url="https://facebook.com/example",
featured_image=image,
)
event.categories.add(konsert, klubb)
EventOrganizerLink.objects.create(event=event, organizer=internal_org)
EventOrganizerLink.objects.create(event=event, organizer=external_org)
now = timezone.now()
EventOccurrence.objects.create(
event=event,
start=now + timedelta(days=5),
end=now + timedelta(days=5, hours=3),
venue=venue,
)
EventOccurrence.objects.create(
event=event,
start=now + timedelta(days=12),
end=now + timedelta(days=12, hours=2),
venue_custom="Frederikkeplassen",
)
event.save()
return event
def test_graphql_event_index_returns_all_fields_for_comprehensive_event(
comprehensive_event, graphql_post
):
response, body = graphql_post(
"""
query {
eventIndex {
futureEvents {
title
slug
subtitle
lead
body {
blockType
field
... on RichTextBlock {
value
}
}
pig
free
priceRegular
priceStudent
priceMember
ticketUrl
facebookUrl
featuredImage {
alt
attribution
}
categories {
name
slug
showInFilters
pig
}
organizers {
name
slug
externalUrl
association {
title
}
}
occurrences {
start
end
venueCustom
venue {
title
}
}
}
}
}
"""
)
assert response.status_code == 200
assert "errors" not in body, body
events = body["data"]["eventIndex"]["futureEvents"]
event = next(e for e in events if e["title"] == "Et arrangement")
assert event["slug"] == "et-arrangement"
assert event["subtitle"] == "En undertekst"
assert "Ingress." in event["lead"]
assert event["pig"] == "automatic"
assert event["free"] is False
assert event["priceRegular"] == "150"
assert event["priceStudent"] == "100"
assert event["priceMember"] == "75"
assert event["ticketUrl"] == "https://example.com/tickets"
assert event["facebookUrl"] == "https://facebook.com/example"
assert event["featuredImage"]["alt"] == "Et fotografi av en gris med solbriller"
assert event["featuredImage"]["attribution"] == "Foto: Test"
assert event["body"][0]["blockType"] == "RichTextBlock"
assert "Body content." in event["body"][0]["value"]
categories_by_name = {c["name"]: c for c in event["categories"]}
assert set(categories_by_name) == {"Konsert", "Klubb"}
assert categories_by_name["Konsert"]["slug"] == "konsert"
assert categories_by_name["Konsert"]["showInFilters"] is True
assert categories_by_name["Konsert"]["pig"] == "pigHeadLogo"
assert categories_by_name["Klubb"]["showInFilters"] is False
organizers_by_name = {o["name"]: o for o in event["organizers"]}
assert set(organizers_by_name) == {"Internal", "External"}
assert organizers_by_name["Internal"]["association"]["title"] == "Internal"
assert organizers_by_name["Internal"]["externalUrl"] == ""
assert organizers_by_name["External"]["association"] is None
assert organizers_by_name["External"]["externalUrl"] == "https://external.example.com"
assert len(event["occurrences"]) == 2
venue_occ = next(o for o in event["occurrences"] if o["venue"] is not None)
custom_occ = next(o for o in event["occurrences"] if o["venueCustom"])
assert venue_occ["venueCustom"] == ""
assert venue_occ["venue"]["title"]
assert custom_occ["venue"] is None
assert custom_occ["venueCustom"] == "Frederikkeplassen"
venue_occ_db = comprehensive_event.occurrences.exclude(venue=None).get()
custom_occ_db = comprehensive_event.occurrences.exclude(venue_custom="").get()
assert datetime.fromisoformat(venue_occ["start"]) == venue_occ_db.start
assert datetime.fromisoformat(venue_occ["end"]) == venue_occ_db.end
assert datetime.fromisoformat(custom_occ["start"]) == custom_occ_db.start
assert datetime.fromisoformat(custom_occ["end"]) == custom_occ_db.end
+71
View File
@@ -0,0 +1,71 @@
from generic.models import GenericPage
from tests.conftest import GenericPageFactory
def test_generic_page_persists_via_factory(home_page):
page = GenericPageFactory(
parent=home_page,
title="Om oss",
slug="om-oss",
lead="<p>Ingress.</p>",
body=[("paragraph", "<p>Body content.</p>")],
pig="drink",
)
reloaded = GenericPage.objects.get(pk=page.pk)
assert reloaded.title == "Om oss"
assert reloaded.slug == "om-oss"
assert "Ingress." in reloaded.lead
assert reloaded.pig == "drink"
assert reloaded.body[0].block_type == "paragraph"
def test_generic_page_allows_recursive_children(home_page):
parent = GenericPageFactory(parent=home_page, title="Parent", slug="parent")
child = GenericPageFactory(parent=parent, title="Child", slug="child")
assert child.get_parent().specific == parent
assert list(parent.get_children().specific()) == [child]
def test_graphql_generic_page_query(home_page, graphql_post):
GenericPageFactory(
parent=home_page,
title="Om oss",
slug="om-oss",
lead="<p>Ingress text.</p>",
body=[("paragraph", "<p>Body content.</p>")],
pig="drink",
)
response, body = graphql_post(
"""
query {
page(slug: "om-oss", contentType: "generic.GenericPage") {
title
slug
... on GenericPage {
lead
pig
body {
blockType
field
... on RichTextBlock {
value
}
}
}
}
}
"""
)
assert response.status_code == 200
assert "errors" not in body, body
data = body["data"]["page"]
assert data["title"] == "Om oss"
assert data["slug"] == "om-oss"
assert "Ingress text." in data["lead"]
assert data["pig"] == "drink"
assert data["body"][0]["blockType"] == "RichTextBlock"
assert "Body content." in data["body"][0]["value"]
+6
View File
@@ -0,0 +1,6 @@
def test_graphql_endpoint_responds(db, graphql_post):
response, body = graphql_post("{ __schema { queryType { name } } }")
assert response.status_code == 200
assert "errors" not in body
assert body["data"]["__schema"]["queryType"]["name"] == "Query"
+32
View File
@@ -0,0 +1,32 @@
from news.admin import NewsPageListingViewSet
from news.models import NewsPage
from tests.conftest import NewsPageFactory
def test_news_page_persists_via_factory(news_index):
page = NewsPageFactory(parent=news_index, title="Big news", excerpt="Short summary")
reloaded = NewsPage.objects.get(pk=page.pk)
assert reloaded.title == "Big news"
assert reloaded.excerpt == "Short summary"
def test_news_listing_viewset_wired_to_newspage():
assert NewsPageListingViewSet.model is NewsPage
assert NewsPageListingViewSet.add_to_admin_menu is True
def test_graphql_news_index_query(news_index, graphql_post):
response, body = graphql_post(
"""
query {
newsIndex {
title
}
}
"""
)
assert response.status_code == 200
assert "errors" not in body, body
assert body["data"]["newsIndex"]["title"] == news_index.title
+91
View File
@@ -0,0 +1,91 @@
import datetime
import pytest
from openinghours.models import OpeningHoursItem, OpeningHoursSet
@pytest.fixture
def opening_hours_set(db):
return OpeningHoursSet.objects.create(
name="Vanlige åpningstider",
effective_from=datetime.date(2025, 1, 1),
)
def test_opening_hours_set_str_with_end_date():
ohs = OpeningHoursSet(
name="Sommer",
effective_from=datetime.date(2025, 6, 1),
effective_to=datetime.date(2025, 8, 31),
)
assert str(ohs) == "Sommer (2025-06-01 - 2025-08-31)"
def test_opening_hours_set_str_uses_infinity_when_open_ended():
ohs = OpeningHoursSet(
name="Forever",
effective_from=datetime.date(2025, 1, 1),
effective_to=None,
)
assert str(ohs) == "Forever (2025-01-01 - ∞)"
def test_opening_hours_streamfield_week_roundtrip(opening_hours_set):
OpeningHoursItem.objects.create(
opening_hours_set=opening_hours_set,
function="glassbaren",
week=[
(
"week",
{
"monday": {
"time_from": datetime.time(15, 0),
"time_to": datetime.time(23, 0),
"custom": "",
},
"tuesday": {"time_from": None, "time_to": None, "custom": "Stengt"},
"wednesday": {"time_from": None, "time_to": None, "custom": ""},
"thursday": {"time_from": None, "time_to": None, "custom": ""},
"friday": {"time_from": None, "time_to": None, "custom": ""},
"saturday": {"time_from": None, "time_to": None, "custom": ""},
"sunday": {"time_from": None, "time_to": None, "custom": ""},
},
),
],
)
reloaded = OpeningHoursSet.objects.get(pk=opening_hours_set.pk)
item = reloaded.items.get()
assert item.function == "glassbaren"
week_block = item.week[0]
assert week_block.block_type == "week"
assert week_block.value["monday"]["time_from"] == datetime.time(15, 0)
assert week_block.value["monday"]["time_to"] == datetime.time(23, 0)
assert week_block.value["tuesday"]["custom"] == "Stengt"
def test_graphql_opening_hours_sets_query(db, graphql_post):
OpeningHoursSet.objects.create(
name="Sommer 2025",
effective_from=datetime.date(2025, 6, 1),
effective_to=datetime.date(2025, 8, 31),
)
response, body = graphql_post(
"""
query {
openingHoursSets {
name
effectiveFrom
effectiveTo
}
}
"""
)
assert response.status_code == 200
assert "errors" not in body, body
names = [s["name"] for s in body["data"]["openingHoursSets"]]
assert "Sommer 2025" in names
+37
View File
@@ -0,0 +1,37 @@
"""Round-trip tests for wagtail-headless-preview token resolution via grapple."""
from generic.models import GenericPage
from tests.conftest import GenericPageFactory
def test_generic_page_preview_token_resolves_draft(home_page, graphql_post):
"""A minted preview token returns the unsaved draft via grapple's page(token: …)."""
# Publish a baseline so there's a live revision to diverge from.
page = GenericPageFactory(parent=home_page, title="Original title", slug="generic-preview")
# Mutate in-memory to simulate unsaved editor state, then mint a token.
# create_page_preview() snapshots the current to_json() into a PagePreview row.
page.title = "Edited title (draft)"
preview = page.create_page_preview()
response, body = graphql_post(
"""
query previewPage($token: String!) {
page: page(token: $token) {
__typename
... on GenericPage {
title
}
}
}
""",
variables={"token": preview.token},
)
assert response.status_code == 200
assert "errors" not in body, body
assert body["data"]["page"]["__typename"] == "GenericPage"
assert body["data"]["page"]["title"] == "Edited title (draft)"
# Live revision is unchanged — token short-circuits the published query.
assert GenericPage.objects.get(pk=page.pk).title == "Original title"
+78
View File
@@ -0,0 +1,78 @@
from studio.models import StudioPage
from tests.conftest import CustomImageFactory, StudioPageFactory
def test_studio_page_persists_via_factory(home_page):
logo = CustomImageFactory()
page = StudioPageFactory(
parent=home_page,
title="STUDiO",
slug="studio",
lead="<p>Ingress.</p>",
body=[("paragraph", "<p>Body content.</p>")],
pig="drink",
logo=logo,
)
reloaded = StudioPage.objects.get(pk=page.pk)
assert reloaded.title == "STUDiO"
assert reloaded.slug == "studio"
assert "Ingress." in reloaded.lead
assert reloaded.pig == "drink"
assert reloaded.body[0].block_type == "paragraph"
assert reloaded.logo == logo
def test_studio_page_is_singleton(home_page):
StudioPageFactory(parent=home_page, slug="studio")
assert StudioPage.can_create_at(home_page) is False
def test_graphql_studio_page_query(home_page, graphql_post):
logo = CustomImageFactory(alt="STUDiO-logo")
StudioPageFactory(
parent=home_page,
title="STUDiO",
slug="studio",
lead="<p>Ingress text.</p>",
body=[("paragraph", "<p>Body content.</p>")],
pig="drink",
logo=logo,
)
response, body = graphql_post(
"""
query {
page: studioPage {
... on StudioPage {
title
slug
lead
pig
logo {
alt
}
body {
blockType
field
... on RichTextBlock {
value
}
}
}
}
}
"""
)
assert response.status_code == 200
assert "errors" not in body, body
data = body["data"]["page"]
assert data["title"] == "STUDiO"
assert data["slug"] == "studio"
assert "Ingress text." in data["lead"]
assert data["pig"] == "drink"
assert data["logo"]["alt"] == "STUDiO-logo"
assert data["body"][0]["blockType"] == "RichTextBlock"
assert "Body content." in data["body"][0]["value"]
+399 -195
View File
@@ -1,77 +1,113 @@
version = 1 version = 1
revision = 2 revision = 3
requires-python = "==3.12.*" requires-python = "==3.14.*"
[[package]]
name = "aniso8601"
version = "9.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cb/72/be3db445b03944bfbb2b02b82d00cb2a2bcf96275c4543f14bf60fa79e12/aniso8601-9.0.1.tar.gz", hash = "sha256:72e3117667eedf66951bb2d93f4296a56b94b078a8a95905a052611fb3f1b973", size = 47345, upload-time = "2021-03-02T01:33:22.944Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/04/e97c12dc034791d7b504860acfcdd2963fa21ae61eaca1c9d31245f812c3/aniso8601-9.0.1-py2.py3-none-any.whl", hash = "sha256:1d2b7ef82963909e93c4f24ce48d4de9e66009a21bf1c1e1c85bdd0812fe412f", size = 52754, upload-time = "2021-03-02T01:33:20.669Z" },
]
[[package]] [[package]]
name = "anyascii" name = "anyascii"
version = "0.3.2" version = "0.3.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9f/52/93b9ea99063f7cf37fb67f5e3f49480686cbe7f228c48b9d713326223b6e/anyascii-0.3.2.tar.gz", hash = "sha256:9d5d32ef844fe225b8bc7cba7f950534fae4da27a9bf3a6bea2cb0ea46ce4730", size = 214052, upload-time = "2023-03-16T00:24:42.431Z" } sdist = { url = "https://files.pythonhosted.org/packages/db/ba/edebda727008390936da4a9bf677c19cd63b32d51e864656d2cbd1028e25/anyascii-0.3.3.tar.gz", hash = "sha256:c94e9dd9d47b3d9494eca305fef9447d00b4bf1a32aff85aa746fa3ec7fb95c3", size = 264680, upload-time = "2025-06-29T03:33:30.427Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/7b/a9a747e0632271d855da379532b05a62c58e979813814a57fa3b3afeb3a4/anyascii-0.3.2-py3-none-any.whl", hash = "sha256:3b3beef6fc43d9036d3b0529050b0c48bfad8bc960e9e562d7223cfb94fe45d4", size = 289923, upload-time = "2023-03-16T00:24:39.649Z" }, { url = "https://files.pythonhosted.org/packages/c2/76/783b75a21ce3563b8709050de030ae253853b147bd52e141edc1025aa268/anyascii-0.3.3-py3-none-any.whl", hash = "sha256:f5ab5e53c8781a36b5a40e1296a0eeda2f48c649ef10c3921c1381b1d00dee7a", size = 345090, upload-time = "2025-06-29T03:33:28.356Z" },
] ]
[[package]] [[package]]
name = "asgiref" name = "asgiref"
version = "3.8.1" version = "3.9.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/29/38/b3395cc9ad1b56d2ddac9970bc8f4141312dbaec28bc7c218b0dfafd0f42/asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590", size = 35186, upload-time = "2024-03-22T14:39:36.863Z" } sdist = { url = "https://files.pythonhosted.org/packages/90/61/0aa957eec22ff70b830b22ff91f825e70e1ef732c06666a805730f28b36b/asgiref-3.9.1.tar.gz", hash = "sha256:a5ab6582236218e5ef1648f242fd9f10626cfd4de8dc377db215d5d5098e3142", size = 36870, upload-time = "2025-07-08T09:07:43.344Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828, upload-time = "2024-03-22T14:39:34.521Z" }, { url = "https://files.pythonhosted.org/packages/7c/3c/0464dcada90d5da0e71018c04a140ad6349558afb30b3051b4264cc5b965/asgiref-3.9.1-py3-none-any.whl", hash = "sha256:f3bba7092a48005b5f5bacd747d36ee4a5a61f4a269a6df590b43144355ebd2c", size = 23790, upload-time = "2025-07-08T09:07:41.548Z" },
] ]
[[package]] [[package]]
name = "beautifulsoup4" name = "beautifulsoup4"
version = "4.12.3" version = "4.13.4"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "soupsieve" }, { name = "soupsieve" },
{ name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/b3/ca/824b1195773ce6166d388573fc106ce56d4a805bd7427b624e063596ec58/beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051", size = 581181, upload-time = "2024-01-17T16:53:17.902Z" } sdist = { url = "https://files.pythonhosted.org/packages/d8/e4/0c4c39e18fd76d6a628d4dd8da40543d136ce2d1752bd6eeeab0791f4d6b/beautifulsoup4-4.13.4.tar.gz", hash = "sha256:dbb3c4e1ceae6aefebdaf2423247260cd062430a410e38c66f2baa50a8437195", size = 621067, upload-time = "2025-04-15T17:05:13.836Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/b1/fe/e8c672695b37eecc5cbf43e1d0638d88d66ba3a44c4d321c796f4e59167f/beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed", size = 147925, upload-time = "2024-01-17T16:53:12.779Z" }, { url = "https://files.pythonhosted.org/packages/50/cd/30110dc0ffcf3b131156077b90e9f60ed75711223f306da4db08eff8403b/beautifulsoup4-4.13.4-py3-none-any.whl", hash = "sha256:9bbbb14bfde9d79f38b8cd5f8c7c85f4b8f2523190ebed90e950a8dea4cb1c4b", size = 187285, upload-time = "2025-04-15T17:05:12.221Z" },
] ]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2024.8.30" version = "2025.8.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507, upload-time = "2024-08-30T01:55:04.365Z" } sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321, upload-time = "2024-08-30T01:55:02.591Z" }, { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" },
] ]
[[package]] [[package]]
name = "charset-normalizer" name = "charset-normalizer"
version = "3.3.2" version = "3.4.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/63/09/c1bc53dab74b1816a00d8d030de5bf98f724c52c1635e07681d312f20be8/charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5", size = 104809, upload-time = "2023-11-01T04:04:59.997Z" } sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/b2/fcedc8255ec42afee97f9e6f0145c734bbe104aac28300214593eb326f1d/charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8", size = 192892, upload-time = "2023-11-01T04:03:24.135Z" }, { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" },
{ url = "https://files.pythonhosted.org/packages/2e/7d/2259318c202f3d17f3fe6438149b3b9e706d1070fe3fcbb28049730bb25c/charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b", size = 122213, upload-time = "2023-11-01T04:03:25.66Z" }, { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" },
{ url = "https://files.pythonhosted.org/packages/3a/52/9f9d17c3b54dc238de384c4cb5a2ef0e27985b42a0e5cc8e8a31d918d48d/charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6", size = 119404, upload-time = "2023-11-01T04:03:27.04Z" }, { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" },
{ url = "https://files.pythonhosted.org/packages/99/b0/9c365f6d79a9f0f3c379ddb40a256a67aa69c59609608fe7feb6235896e1/charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a", size = 137275, upload-time = "2023-11-01T04:03:28.466Z" }, { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" },
{ url = "https://files.pythonhosted.org/packages/91/33/749df346e93d7a30cdcb90cbfdd41a06026317bfbfb62cd68307c1a3c543/charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389", size = 147518, upload-time = "2023-11-01T04:03:29.82Z" }, { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" },
{ url = "https://files.pythonhosted.org/packages/72/1a/641d5c9f59e6af4c7b53da463d07600a695b9824e20849cb6eea8a627761/charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa", size = 140182, upload-time = "2023-11-01T04:03:31.511Z" }, { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" },
{ url = "https://files.pythonhosted.org/packages/ee/fb/14d30eb4956408ee3ae09ad34299131fb383c47df355ddb428a7331cfa1e/charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b", size = 141869, upload-time = "2023-11-01T04:03:32.887Z" }, { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" },
{ url = "https://files.pythonhosted.org/packages/df/3e/a06b18788ca2eb6695c9b22325b6fde7dde0f1d1838b1792a0076f58fe9d/charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed", size = 144042, upload-time = "2023-11-01T04:03:34.412Z" }, { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" },
{ url = "https://files.pythonhosted.org/packages/45/59/3d27019d3b447a88fe7e7d004a1e04be220227760264cc41b405e863891b/charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26", size = 138275, upload-time = "2023-11-01T04:03:35.759Z" }, { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" },
{ url = "https://files.pythonhosted.org/packages/7b/ef/5eb105530b4da8ae37d506ccfa25057961b7b63d581def6f99165ea89c7e/charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d", size = 144819, upload-time = "2023-11-01T04:03:37.216Z" }, { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" },
{ url = "https://files.pythonhosted.org/packages/a2/51/e5023f937d7f307c948ed3e5c29c4b7a3e42ed2ee0b8cdf8f3a706089bf0/charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068", size = 149415, upload-time = "2023-11-01T04:03:38.694Z" }, { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" },
{ url = "https://files.pythonhosted.org/packages/24/9d/2e3ef673dfd5be0154b20363c5cdcc5606f35666544381bee15af3778239/charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143", size = 141212, upload-time = "2023-11-01T04:03:40.07Z" }, { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" },
{ url = "https://files.pythonhosted.org/packages/5b/ae/ce2c12fcac59cb3860b2e2d76dc405253a4475436b1861d95fe75bdea520/charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4", size = 142167, upload-time = "2023-11-01T04:03:41.491Z" }, ]
{ url = "https://files.pythonhosted.org/packages/ed/3a/a448bf035dce5da359daf9ae8a16b8a39623cc395a2ffb1620aa1bce62b0/charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7", size = 93041, upload-time = "2023-11-01T04:03:42.836Z" },
{ url = "https://files.pythonhosted.org/packages/b6/7c/8debebb4f90174074b827c63242c23851bdf00a532489fba57fef3416e40/charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001", size = 100397, upload-time = "2023-11-01T04:03:44.467Z" }, [[package]]
{ url = "https://files.pythonhosted.org/packages/28/76/e6222113b83e3622caa4bb41032d0b1bf785250607392e1b778aca0b8a7d/charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc", size = 48543, upload-time = "2023-11-01T04:04:58.622Z" }, name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "coverage"
version = "7.14.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1c/18/b9a6586d73992807c26f9a5f274131be3d76b56b18a82b9392e2a25d2e45/coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d", size = 220036, upload-time = "2026-05-10T18:01:33.057Z" },
{ url = "https://files.pythonhosted.org/packages/f3/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63", size = 220368, upload-time = "2026-05-10T18:01:34.705Z" },
{ url = "https://files.pythonhosted.org/packages/69/aa/c12e52a5ba148d9995229d557e3be6e554fe469addc0e9241b2f0956d8ea/coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212", size = 251417, upload-time = "2026-05-10T18:01:36.949Z" },
{ url = "https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3", size = 253924, upload-time = "2026-05-10T18:01:38.985Z" },
{ url = "https://files.pythonhosted.org/packages/33/c4/59c3de0bd1b538824173fd518fed51c1ce740ca5ed68e74545983f4053a9/coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97", size = 255269, upload-time = "2026-05-10T18:01:40.957Z" },
{ url = "https://files.pythonhosted.org/packages/7b/a9/36dfa153a62040296f6e7febfdb20a5720622f6ef5a81a41e8237b9a5344/coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8", size = 257583, upload-time = "2026-05-10T18:01:42.607Z" },
{ url = "https://files.pythonhosted.org/packages/26/7b/cc2c048d4114d9ab1c2409e9ee365e5ae10736df6dffcfc9444effa6c708/coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb", size = 251434, upload-time = "2026-05-10T18:01:44.537Z" },
{ url = "https://files.pythonhosted.org/packages/ee/df/6770eaa576e604575e9a78055313250faef5faa84bd6f71a39fece519c43/coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe", size = 253280, upload-time = "2026-05-10T18:01:46.175Z" },
{ url = "https://files.pythonhosted.org/packages/ad/9e/1c0264514a3f98259a6d64765a397b2c8373e3ba59ee722a4802d3ec0c61/coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa", size = 251241, upload-time = "2026-05-10T18:01:48.732Z" },
{ url = "https://files.pythonhosted.org/packages/64/16/4efdf3e3c4079cdbf0ece56a2fea872df9e8a3e15a13a0af4400e1075944/coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5", size = 255516, upload-time = "2026-05-10T18:01:50.819Z" },
{ url = "https://files.pythonhosted.org/packages/93/69/b1de96346603881b3d1bc8d6447c83200e1c9700ffbaff926ba01ff5724c/coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c", size = 251059, upload-time = "2026-05-10T18:01:52.773Z" },
{ url = "https://files.pythonhosted.org/packages/a4/66/2881853e0363a5e0a724d1103e53650795367471b6afb234f8b49e713bc6/coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca", size = 252716, upload-time = "2026-05-10T18:01:54.506Z" },
{ url = "https://files.pythonhosted.org/packages/55/5c/0d3305d002c41dcde873dbe456491e663dc55152ca526b630b5c47efd62f/coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828", size = 222788, upload-time = "2026-05-10T18:01:56.487Z" },
{ url = "https://files.pythonhosted.org/packages/f9/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d", size = 223600, upload-time = "2026-05-10T18:01:58.497Z" },
{ url = "https://files.pythonhosted.org/packages/00/70/a18c408e674bc26281cadaedc7351f929bd2094e191e4b15271c30b084cc/coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9", size = 222168, upload-time = "2026-05-10T18:02:00.411Z" },
{ url = "https://files.pythonhosted.org/packages/3d/89/2681f071d238b62aff8dfc2ab44fc24cfdb38d1c01f391a80522ff5d3a16/coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1", size = 220766, upload-time = "2026-05-10T18:02:02.313Z" },
{ url = "https://files.pythonhosted.org/packages/bd/c7/c987babafd9207ffa1995e1ef1f9b26762cf4963aa768a66b6f0501e4616/coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c", size = 221035, upload-time = "2026-05-10T18:02:04.017Z" },
{ url = "https://files.pythonhosted.org/packages/5a/e9/d6a5ac3b333088143d6fc877d398a9a674dc03124a2f776e131f03864823/coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84", size = 262405, upload-time = "2026-05-10T18:02:05.915Z" },
{ url = "https://files.pythonhosted.org/packages/38/b1/e70838d29a7c08e22d44398a46db90815bbcbf28de06992bd9210d1a8d8e/coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436", size = 264530, upload-time = "2026-05-10T18:02:07.582Z" },
{ url = "https://files.pythonhosted.org/packages/6b/73/5c31ef97763288d03d9995152b96d5475b527c63d91c84b01caea894b83a/coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a", size = 266932, upload-time = "2026-05-10T18:02:09.401Z" },
{ url = "https://files.pythonhosted.org/packages/e1/76/dd56d80f29c5f05b4d76f7e7c6d47cafacae017189c75c5759d24f9ff0cc/coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f", size = 268062, upload-time = "2026-05-10T18:02:11.399Z" },
{ url = "https://files.pythonhosted.org/packages/6e/c7/27ba85cd5b95614f159ff93ebff1901584a8d192e2e5e24c4943a7453f59/coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb", size = 261504, upload-time = "2026-05-10T18:02:13.257Z" },
{ url = "https://files.pythonhosted.org/packages/13/2e/e8149f60ab5d5684c6eee881bdf34b127115cddbb958b196768dd9d63473/coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490", size = 264398, upload-time = "2026-05-10T18:02:15.063Z" },
{ url = "https://files.pythonhosted.org/packages/d9/7f/1261b025285323225f4b4abffa5a643649dfd67e25ddca7ebcbdea3b7cb3/coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9", size = 262000, upload-time = "2026-05-10T18:02:16.756Z" },
{ url = "https://files.pythonhosted.org/packages/d3/dc/829c54f60b9d08389439c00f813c752781c496fc5788c78d8006db4b4f2b/coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020", size = 265732, upload-time = "2026-05-10T18:02:18.817Z" },
{ url = "https://files.pythonhosted.org/packages/ed/b0/70bd1419941652fa062689cba9c3eeafb8f5e6fbb890bce41c3bdda5dbd6/coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6", size = 260847, upload-time = "2026-05-10T18:02:20.528Z" },
{ url = "https://files.pythonhosted.org/packages/f2/73/be40b2390656c654d35ea0015ea7ba3d945769cf80790ad5e0bb2d56d2ba/coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db", size = 263166, upload-time = "2026-05-10T18:02:22.337Z" },
{ url = "https://files.pythonhosted.org/packages/29/55/4a643f712fcf7cf2881f8ec1e0ccb7b164aff3108f69b51801246c8799f2/coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2", size = 223573, upload-time = "2026-05-10T18:02:24.11Z" },
{ url = "https://files.pythonhosted.org/packages/27/96/3acae5da0953be042c0b4dea6d6789d2f080701c77b88e44d5bd41b9219b/coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644", size = 224680, upload-time = "2026-05-10T18:02:25.896Z" },
{ url = "https://files.pythonhosted.org/packages/93/3d/6ab5d2dd8325d838737c6f8d83d62eb6230e0d70b87b51b57bbfd08fa767/coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b", size = 222703, upload-time = "2026-05-10T18:02:27.822Z" },
{ url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" },
] ]
[[package]] [[package]]
@@ -85,53 +121,52 @@ wheels = [
[[package]] [[package]]
name = "django" name = "django"
version = "5.1" version = "6.0.5"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "asgiref" }, { name = "asgiref" },
{ name = "sqlparse" }, { name = "sqlparse" },
{ name = "tzdata", marker = "sys_platform == 'win32'" }, { name = "tzdata", marker = "sys_platform == 'win32'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/1e/0c/d854d25bb74a8a3b41e642bbd27fe6af12fadd0edfd07d487809cf0ef719/Django-5.1.tar.gz", hash = "sha256:848a5980e8efb76eea70872fb0e4bc5e371619c70fffbe48e3e1b50b2c09455d", size = 10681050, upload-time = "2024-08-07T13:34:10.857Z" } sdist = { url = "https://files.pythonhosted.org/packages/5e/f1/bf85f0d29ef76abf901f193fe8fef4769d3da7794197832bc30151c071d8/django-6.0.5.tar.gz", hash = "sha256:bc6d6872e98a2864c836e42edd644b362db311147dd5aa8d5b82ba7a032f5269", size = 10924131, upload-time = "2026-05-05T13:54:39.329Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/28/b4/110532cebfea2244d76119904da98c6fa045ebb202aee9ec7cbf36ea3cad/Django-5.1-py3-none-any.whl", hash = "sha256:d3b811bf5371a26def053d7ee42a9df1267ef7622323fe70a601936725aa4557", size = 8246099, upload-time = "2024-08-07T13:33:52.959Z" }, { url = "https://files.pythonhosted.org/packages/94/5b/1328f8b84fce040c404f76822bf8c57d254e368e8cbd8bd67ec2b26d75f5/django-6.0.5-py3-none-any.whl", hash = "sha256:9d58a7cb49244e74c8e161d5e403a46d6209f1009ba40f5a66d6aa0d0786a8f0", size = 8368680, upload-time = "2026-05-05T13:54:33.532Z" },
] ]
[[package]] [[package]]
name = "django-extensions" name = "django-extensions"
version = "3.2.3" version = "4.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "django" }, { name = "django" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/8a/f1/318684c9466968bf9a9c221663128206e460c1a67f595055be4b284cde8a/django-extensions-3.2.3.tar.gz", hash = "sha256:44d27919d04e23b3f40231c4ab7af4e61ce832ef46d610cc650d53e68328410a", size = 277216, upload-time = "2023-06-05T17:09:01.447Z" } sdist = { url = "https://files.pythonhosted.org/packages/6d/b3/ed0f54ed706ec0b54fd251cc0364a249c6cd6c6ec97f04dc34be5e929eac/django_extensions-4.1.tar.gz", hash = "sha256:7b70a4d28e9b840f44694e3f7feb54f55d495f8b3fa6c5c0e5e12bcb2aa3cdeb", size = 283078, upload-time = "2025-04-11T01:15:39.617Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/7e/ba12b9660642663f5273141018d2bec0a1cae1711f4f6d1093920e157946/django_extensions-3.2.3-py3-none-any.whl", hash = "sha256:9600b7562f79a92cbf1fde6403c04fee314608fefbb595502e34383ae8203401", size = 229868, upload-time = "2023-06-05T17:08:58.197Z" }, { url = "https://files.pythonhosted.org/packages/64/96/d967ca440d6a8e3861120f51985d8e5aec79b9a8bdda16041206adfe7adc/django_extensions-4.1-py3-none-any.whl", hash = "sha256:0699a7af28f2523bf8db309a80278519362cd4b6e1fd0a8cd4bf063e1e023336", size = 232980, upload-time = "2025-04-11T01:15:37.701Z" },
] ]
[[package]] [[package]]
name = "django-filter" name = "django-filter"
version = "24.3" version = "25.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "django" }, { name = "django" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/50/bc/dc19ae39c235332926dd0efe0951f663fa1a9fc6be8430737ff7fd566b20/django_filter-24.3.tar.gz", hash = "sha256:d8ccaf6732afd21ca0542f6733b11591030fa98669f8d15599b358e24a2cd9c3", size = 144444, upload-time = "2024-08-02T13:27:58.132Z" } sdist = { url = "https://files.pythonhosted.org/packages/b5/40/c702a6fe8cccac9bf426b55724ebdf57d10a132bae80a17691d0cf0b9bac/django_filter-25.1.tar.gz", hash = "sha256:1ec9eef48fa8da1c0ac9b411744b16c3f4c31176c867886e4c48da369c407153", size = 143021, upload-time = "2025-02-14T16:30:53.238Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/09/b1/92f1c30b47c1ebf510c35a2ccad9448f73437e5891bbd2b4febe357cc3de/django_filter-24.3-py3-none-any.whl", hash = "sha256:c4852822928ce17fb699bcfccd644b3574f1a2d80aeb2b4ff4f16b02dd49dc64", size = 95011, upload-time = "2024-08-02T13:27:55.616Z" }, { url = "https://files.pythonhosted.org/packages/07/a6/70dcd68537c434ba7cb9277d403c5c829caf04f35baf5eb9458be251e382/django_filter-25.1-py3-none-any.whl", hash = "sha256:4fa48677cf5857b9b1347fed23e355ea792464e0fe07244d1fdfb8a806215b80", size = 94114, upload-time = "2025-02-14T16:30:50.435Z" },
] ]
[[package]] [[package]]
name = "django-modelcluster" name = "django-modelcluster"
version = "6.3" version = "6.5"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "django" }, { name = "django" },
{ name = "pytz" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/3d/dd/db1ab2e256f9fda6796ac312686561cbe2740d960c920f74410559ef7739/django-modelcluster-6.3.tar.gz", hash = "sha256:0caed8a0e889f3abb92f144670878a466ef954ffa6c4c7b9c80e6426b720a49d", size = 28903, upload-time = "2024-02-26T19:26:53.185Z" } sdist = { url = "https://files.pythonhosted.org/packages/34/0f/c5fd0c280a10224d619325783bfaab5a54903f82340e41ae21ab3127ebc9/django_modelcluster-6.5.tar.gz", hash = "sha256:459cbf0fb3bbc8c15ea9a2a062abc0d1ef935a38e04aa8ac3cb241c826bbc6dd", size = 29707, upload-time = "2026-05-01T12:25:33.524Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/6d/8deb2dfde2e1177412b759a1c32713be19b1d3af1da686e44e64c165e8c0/django_modelcluster-6.3-py2.py3-none-any.whl", hash = "sha256:a8783d6565a0663f41cd6003ea361c3a5711e8a2a326160f1ec1eceb3e973d4f", size = 29039, upload-time = "2024-02-26T19:26:50.903Z" }, { url = "https://files.pythonhosted.org/packages/cd/57/cdcdea9a56d114e9217d52ce9a8af19ad7fe8a0996faf8480d3f286c02e0/django_modelcluster-6.5-py3-none-any.whl", hash = "sha256:469b380a294027d5211d0e5d5746e0381f445b058d2229977a58fe0f1c58a596", size = 29865, upload-time = "2026-05-01T12:25:31.886Z" },
] ]
[[package]] [[package]]
@@ -146,40 +181,67 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/5b/216157ff053f955b15b9dcdc13bfb6e406666445164fee9e332e141be96d/django_permissionedforms-0.1-py2.py3-none-any.whl", hash = "sha256:d341a961a27cc77fde8cc42141c6ab55cc1f0cb886963cc2d6967b9674fa47d6", size = 5744, upload-time = "2022-02-28T19:40:26.337Z" }, { url = "https://files.pythonhosted.org/packages/e6/5b/216157ff053f955b15b9dcdc13bfb6e406666445164fee9e332e141be96d/django_permissionedforms-0.1-py2.py3-none-any.whl", hash = "sha256:d341a961a27cc77fde8cc42141c6ab55cc1f0cb886963cc2d6967b9674fa47d6", size = 5744, upload-time = "2022-02-28T19:40:26.337Z" },
] ]
[[package]]
name = "django-stubs-ext"
version = "5.2.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/06/5e94715d103e6cc72380cb0d0b6682a7d5ad2c366cee478c94d77aad777d/django_stubs_ext-5.2.2.tar.gz", hash = "sha256:d9d151b919fe2438760f5bd938f03e1cb08c84d0651f9e5917f1313907e42683", size = 6244, upload-time = "2025-07-17T08:34:35.054Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/38/2903676f97f7902ee31984a06756b0e8836e897f4b617e1a03be4a43eb4f/django_stubs_ext-5.2.2-py3-none-any.whl", hash = "sha256:8833bbe32405a2a0ce168d3f75a87168f61bd16939caf0e8bf173bccbd8a44c5", size = 8816, upload-time = "2025-07-17T08:34:33.715Z" },
]
[[package]] [[package]]
name = "django-taggit" name = "django-taggit"
version = "5.0.1" version = "6.1.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "django" }, { name = "django" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/10/eb/269501702e231b552f68d813d0a07ac1ea81fd25a3c92fb0f249818f0f39/django-taggit-5.0.1.tar.gz", hash = "sha256:edcd7db1e0f35c304e082a2f631ddac2e16ef5296029524eb792af7430cab4cc", size = 60372, upload-time = "2023-10-29T11:57:20.124Z" } sdist = { url = "https://files.pythonhosted.org/packages/34/a6/f1beaf8f552fe90c153cc039316ebab942c23dfbc88588dde081fefca816/django_taggit-6.1.0.tar.gz", hash = "sha256:c4d1199e6df34125dd36db5eb0efe545b254dec3980ce5dd80e6bab3e78757c3", size = 38151, upload-time = "2024-09-29T08:07:39.477Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/07/18/94a45c8c50592d5cf7d73a9111053917c3684fda031c988041396bb3e588/django_taggit-5.0.1-py3-none-any.whl", hash = "sha256:a0ca8a28b03c4b26c2630fd762cb76ec39b5e41abf727a7b66f897a625c5e647", size = 61141, upload-time = "2023-10-29T11:56:57.954Z" }, { url = "https://files.pythonhosted.org/packages/6b/34/4185c345530b91d05cb82e05d07148f481a5eb5dc2ac44e092b3daa6f206/django_taggit-6.1.0-py3-none-any.whl", hash = "sha256:ab776264bbc76cb3d7e49e1bf9054962457831bd21c3a42db9138b41956e4cf0", size = 75749, upload-time = "2024-09-29T08:07:14.612Z" },
]
[[package]]
name = "django-tasks"
version = "0.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "django-stubs-ext" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/60/b1/064645bf246a1f5b46d9638755b1869ea44a6d05e7c7c12841fddebb71f6/django_tasks-0.9.0.tar.gz", hash = "sha256:971b3829efeee68147f7deced8d21b907131b11ec7953af83eb94b11f128a24d", size = 32343, upload-time = "2025-10-17T16:21:08.58Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/e1/ce539ce1e21be71696649f84b6dbd508b2c0b89b559a6e620478bb126f7c/django_tasks-0.9.0-py3-none-any.whl", hash = "sha256:fddc344934a605d9eafa08ac8ba32c0cde9da23ef534e03a41f09fa0417b535a", size = 44057, upload-time = "2025-10-17T16:21:07.283Z" },
] ]
[[package]] [[package]]
name = "django-treebeard" name = "django-treebeard"
version = "4.7.1" version = "5.1.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "django" }, { name = "django" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/bb/24/eaccbce17355380cb3a8fe6ad92a85b76453dc1f0ecd04f48bfe8929065b/django-treebeard-4.7.1.tar.gz", hash = "sha256:846e462904b437155f76e04907ba4e48480716855f88b898df4122bdcfbd6e98", size = 294139, upload-time = "2024-01-31T16:35:19.751Z" } sdist = { url = "https://files.pythonhosted.org/packages/f7/16/aa732ea1033586e4f946d8e47be9116ffb22b050485b179a7cafb82dfc34/django_treebeard-5.1.0.tar.gz", hash = "sha256:8ac2ba41307469a679c98188124933bc3baade36b02480343d1a301a1fca0700", size = 302339, upload-time = "2026-05-12T02:06:37.002Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/87/79/259966820614746cc4d81762edf97a53bf1e3b8e74797c010d310c6f4a8f/django_treebeard-4.7.1-py3-none-any.whl", hash = "sha256:995c7120153ab999898fe3043bbdcd8a0fc77cc106eb94de7350e9d02c885135", size = 93210, upload-time = "2024-01-31T16:35:17.843Z" }, { url = "https://files.pythonhosted.org/packages/42/d3/311b9c43950238744f946540c48ea7068bf9e55bea976908a7be13b73082/django_treebeard-5.1.0-py3-none-any.whl", hash = "sha256:d0f68fcf1b49158e38d2322aa62a37bc0b89452d66a16f88e58dbe076d043c28", size = 78591, upload-time = "2026-05-12T02:06:35.62Z" },
] ]
[[package]] [[package]]
name = "djangorestframework" name = "djangorestframework"
version = "3.15.2" version = "3.16.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "django" }, { name = "django" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/2c/ce/31482eb688bdb4e271027076199e1aa8d02507e530b6d272ab8b4481557c/djangorestframework-3.15.2.tar.gz", hash = "sha256:36fe88cd2d6c6bec23dca9804bab2ba5517a8bb9d8f47ebc68981b56840107ad", size = 1067420, upload-time = "2024-06-19T07:59:32.891Z" } sdist = { url = "https://files.pythonhosted.org/packages/8a/95/5376fe618646fde6899b3cdc85fd959716bb67542e273a76a80d9f326f27/djangorestframework-3.16.1.tar.gz", hash = "sha256:166809528b1aced0a17dc66c24492af18049f2c9420dbd0be29422029cfc3ff7", size = 1089735, upload-time = "2025-08-06T17:50:53.251Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/b6/fa99d8f05eff3a9310286ae84c4059b08c301ae4ab33ae32e46e8ef76491/djangorestframework-3.15.2-py3-none-any.whl", hash = "sha256:2b8871b062ba1aefc2de01f773875441a961fefbf79f5eed1e32b2f096944b20", size = 1071235, upload-time = "2024-06-19T07:59:26.106Z" }, { url = "https://files.pythonhosted.org/packages/b0/ce/bf8b9d3f415be4ac5588545b5fcdbbb841977db1c1d923f7568eeabe1689/djangorestframework-3.16.1-py3-none-any.whl", hash = "sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec", size = 1080442, upload-time = "2025-08-06T17:50:50.667Z" },
] ]
[[package]] [[package]]
@@ -193,44 +255,80 @@ dependencies = [
{ name = "psycopg2-binary" }, { name = "psycopg2-binary" },
{ name = "wagtail" }, { name = "wagtail" },
{ name = "wagtail-grapple" }, { name = "wagtail-grapple" },
{ name = "wagtail-headless-preview" },
{ name = "whitenoise" }, { name = "whitenoise" },
] ]
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "pytest-django" },
{ name = "ruff" }, { name = "ruff" },
{ name = "wagtail-factories" },
] ]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "django", specifier = ">=5.0.7,<6" }, { name = "django", specifier = ">=6.0.5,<7" },
{ name = "django-extensions", specifier = ">=3.2.3,<4" }, { name = "django-extensions", specifier = ">=4.1,<5" },
{ name = "gunicorn", specifier = ">=23.0.0" }, { name = "gunicorn", specifier = ">=26.0.0,<27" },
{ name = "psycopg2-binary", specifier = ">=2.9.10,<3" }, { name = "psycopg2-binary", specifier = ">=2.9.12,<3" },
{ name = "wagtail", specifier = ">=6.1.3,<7" }, { name = "wagtail", specifier = ">=7.4.1,<8" },
{ name = "wagtail-grapple", specifier = ">=0.26.0,<0.27" }, { name = "wagtail-grapple", specifier = ">=0.31.0,<0.32" },
{ name = "whitenoise", specifier = ">=6.7.0,<7" }, { name = "wagtail-headless-preview", specifier = ">=0.8,<0.9" },
{ name = "whitenoise", specifier = ">=6.12.0,<7" },
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [{ name = "ruff" }] dev = [
{ name = "pytest", specifier = ">=9.0.3,<10" },
{ name = "pytest-cov", specifier = ">=7.0.0,<8" },
{ name = "pytest-django", specifier = ">=4.12.0,<5" },
{ name = "ruff", specifier = ">=0.15.13,<0.16" },
{ name = "wagtail-factories", specifier = ">=4.4.0,<5" },
]
[[package]] [[package]]
name = "draftjs-exporter" name = "draftjs-exporter"
version = "5.0.0" version = "5.1.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/29/5b/7729b1a95d48d2f51b03feacda1d5b17058e1caec524592e59d0df801a8c/draftjs_exporter-5.0.0.tar.gz", hash = "sha256:2efee45d4bb4c0aaacc3e5ea2983a29a29381e02037f3f92a6b12706d7b87e1e", size = 33271, upload-time = "2022-04-03T22:00:47.094Z" } sdist = { url = "https://files.pythonhosted.org/packages/d1/52/8b98525ab5477410bdbaf279c5fe0a99108211d40818bb769460784c1c41/draftjs_exporter-5.1.0.tar.gz", hash = "sha256:9f44b8dcecb702540e3aab24af2fad8683aec910fe0034c12cfab5d716ac5f84", size = 33500, upload-time = "2025-02-21T17:32:57.175Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/58/f4/95ad9b91194156f0e87877d640428b1c7be70a90979796ecdcb77adcfb4f/draftjs_exporter-5.0.0-py3-none-any.whl", hash = "sha256:8cb9d2d51284233decfe274802f1c53e257158c62b9f53ed2399de3fa80ac561", size = 26320, upload-time = "2022-04-03T22:00:44.647Z" }, { url = "https://files.pythonhosted.org/packages/dc/99/26d5524aaa3e89266e0af19332053aa9f8a61b1c39b29c0dc709f43fbb29/draftjs_exporter-5.1.0-py3-none-any.whl", hash = "sha256:c32932b7933b994fd5ea74c1decf47b5c41e13ba06363f5b69b76ef10137d4e9", size = 26302, upload-time = "2025-02-21T17:32:54.4Z" },
] ]
[[package]] [[package]]
name = "et-xmlfile" name = "et-xmlfile"
version = "1.1.0" version = "2.0.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3d/5d/0413a31d184a20c763ad741cc7852a659bf15094c24840c5bdd1754765cd/et_xmlfile-1.1.0.tar.gz", hash = "sha256:8eb9e2bc2f8c97e37a2dc85a09ecdcdec9d8a396530a6d5a33b30b9a92da0c5c", size = 3218, upload-time = "2021-04-26T13:26:05.068Z" } sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/96/c2/3dd434b0108730014f1b96fd286040dc3bcb70066346f7e01ec2ac95865f/et_xmlfile-1.1.0-py3-none-any.whl", hash = "sha256:a2ba85d1d6a74ef63837eed693bcb89c3f752169b0e3e7ae5b16ca5e1b3deada", size = 4688, upload-time = "2021-04-26T13:26:03.429Z" }, { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" },
]
[[package]]
name = "factory-boy"
version = "3.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "faker" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ba/98/75cacae9945f67cfe323829fc2ac451f64517a8a330b572a06a323997065/factory_boy-3.3.3.tar.gz", hash = "sha256:866862d226128dfac7f2b4160287e899daf54f2612778327dd03d0e2cb1e3d03", size = 164146, upload-time = "2025-02-03T09:49:04.433Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/27/8d/2bc5f5546ff2ccb3f7de06742853483ab75bf74f36a92254702f8baecc79/factory_boy-3.3.3-py2.py3-none-any.whl", hash = "sha256:1c39e3289f7e667c4285433f305f8d506efc2fe9c73aaea4151ebd5cdea394fc", size = 37036, upload-time = "2025-02-03T09:49:01.659Z" },
]
[[package]]
name = "faker"
version = "40.18.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/06/70886e82d8f1d2b73454f3a7c1b7405300128df22e70d85a828951366932/faker-40.18.0.tar.gz", hash = "sha256:2207575c0e8f90e6ccd6dbef764de875c614d16d3db4eee9712d9a00087f2e70", size = 1968243, upload-time = "2026-05-14T16:43:04.834Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/0b/5c0b2d3a4b7a715f1835dd3f963bfbe841a02ae5cad1df8ee0325dfad235/faker-40.18.0-py3-none-any.whl", hash = "sha256:61a6b94b74605ddb090a065deb197a1c585ae7a874c094cf6693671d271e6083", size = 2006355, upload-time = "2026-05-14T16:43:02.489Z" },
] ]
[[package]] [[package]]
@@ -244,21 +342,22 @@ wheels = [
[[package]] [[package]]
name = "graphene" name = "graphene"
version = "3.3" version = "3.4.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "aniso8601" },
{ name = "graphql-core" }, { name = "graphql-core" },
{ name = "graphql-relay" }, { name = "graphql-relay" },
{ name = "python-dateutil" },
{ name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/82/75/02875858c7c09fc156840181cdee27b23408fac75720a2e1e9128f3d48a5/graphene-3.3.tar.gz", hash = "sha256:529bf40c2a698954217d3713c6041d69d3f719ad0080857d7ee31327112446b0", size = 57893, upload-time = "2023-07-26T06:48:39.083Z" } sdist = { url = "https://files.pythonhosted.org/packages/cc/f6/bf62ff950c317ed03e77f3f6ddd7e34aaa98fe89d79ebd660c55343d8054/graphene-3.4.3.tar.gz", hash = "sha256:2a3786948ce75fe7e078443d37f609cbe5bb36ad8d6b828740ad3b95ed1a0aaa", size = 44739, upload-time = "2024-11-09T20:44:25.757Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/24/70/96f6027cdfc9bb89fc07627b615cb43fb1c443c93498412beaeaf157e9f1/graphene-3.3-py2.py3-none-any.whl", hash = "sha256:bb3810be33b54cb3e6969506671eb72319e8d7ba0d5ca9c8066472f75bf35a38", size = 128227, upload-time = "2023-07-26T06:48:37.766Z" }, { url = "https://files.pythonhosted.org/packages/66/e0/61d8e98007182e6b2aca7cf65904721fb2e4bce0192272ab9cb6f69d8812/graphene-3.4.3-py2.py3-none-any.whl", hash = "sha256:820db6289754c181007a150db1f7fff544b94142b556d12e3ebc777a7bf36c71", size = 114894, upload-time = "2024-11-09T20:44:23.851Z" },
] ]
[[package]] [[package]]
name = "graphene-django" name = "graphene-django"
version = "3.2.2" version = "3.2.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "django" }, { name = "django" },
@@ -268,18 +367,18 @@ dependencies = [
{ name = "promise" }, { name = "promise" },
{ name = "text-unidecode" }, { name = "text-unidecode" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/09/f1/4eb1c0ae79814aba03e2347f664cb0bef72816fbe99d4399378bb22bef47/graphene-django-3.2.2.tar.gz", hash = "sha256:059ccf25d9a5159f28d7ebf1a648c993ab34deb064e80b70ca096aa22a609556", size = 87591, upload-time = "2024-06-12T02:58:09.439Z" } sdist = { url = "https://files.pythonhosted.org/packages/6f/7a/8aef131349329dcd167578720f6364412f1728bfda14bba22c1e7b5d8365/graphene-django-3.2.3.tar.gz", hash = "sha256:d831bfe8e9a6e77e477b7854faef4addb318f386119a69ee4c57b74560f3e07d", size = 88393, upload-time = "2025-03-13T08:33:03.949Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/da/71/6800d05144ee4fb57f471571dd80fc9c6b2196e6243d39ed3d3654082824/graphene_django-3.2.2-py2.py3-none-any.whl", hash = "sha256:0fd95c8c1cbe77ae2a5940045ce276803c3acbf200a156731e0c730f2776ae2c", size = 114204, upload-time = "2024-06-12T02:58:07.897Z" }, { url = "https://files.pythonhosted.org/packages/18/35/ab9c668222f6271a0b71efb147c46816229dbb9fa5d15ee6b025eb08d4b2/graphene_django-3.2.3-py2.py3-none-any.whl", hash = "sha256:0c673a4dad315b26b4d18eb379ad0c7027fd6a36d23a1848b7c7c09a14a9271e", size = 114959, upload-time = "2025-03-13T08:33:02.453Z" },
] ]
[[package]] [[package]]
name = "graphql-core" name = "graphql-core"
version = "3.2.3" version = "3.2.6"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/a6/94df9045ca1bac404c7b394094cd06713f63f49c7a4d54d99b773ae81737/graphql-core-3.2.3.tar.gz", hash = "sha256:06d2aad0ac723e35b1cb47885d3e5c45e956a53bc1b209a9fc5369007fe46676", size = 529552, upload-time = "2022-09-23T08:37:13.684Z" } sdist = { url = "https://files.pythonhosted.org/packages/c4/16/7574029da84834349b60ed71614d66ca3afe46e9bf9c7b9562102acb7d4f/graphql_core-3.2.6.tar.gz", hash = "sha256:c08eec22f9e40f0bd61d805907e3b3b1b9a320bc606e23dc145eebca07c8fbab", size = 505353, upload-time = "2025-01-26T16:36:27.374Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/39/e5143e7ec70939d2076c1165ae9d4a3815597019c4d797b7f959cf778600/graphql_core-3.2.3-py3-none-any.whl", hash = "sha256:5766780452bd5ec8ba133f8bf287dc92713e3868ddd83aee4faab9fc3e303dc3", size = 202921, upload-time = "2022-09-23T08:37:11.825Z" }, { url = "https://files.pythonhosted.org/packages/ae/4f/7297663840621022bc73c22d7d9d80dbc78b4db6297f764b545cd5dd462d/graphql_core-3.2.6-py3-none-any.whl", hash = "sha256:78b016718c161a6fb20a7d97bbf107f331cd1afe53e45566c59f776ed7f0b45f", size = 203416, upload-time = "2025-01-26T16:36:24.868Z" },
] ]
[[package]] [[package]]
@@ -296,48 +395,57 @@ wheels = [
[[package]] [[package]]
name = "gunicorn" name = "gunicorn"
version = "23.0.0" version = "26.0.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "packaging" }, { name = "packaging" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" } sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286, upload-time = "2026-05-05T06:38:25.529Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" }, { url = "https://files.pythonhosted.org/packages/e6/40/9c2384fc2be4ad25dd4a49decd5ad9ea5a3639814c11bd40ab77cb9f0a14/gunicorn-26.0.0-py3-none-any.whl", hash = "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc", size = 212009, upload-time = "2026-05-05T06:38:23.007Z" },
] ]
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.8" version = "3.10"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e8/ac/e349c5e6d4543326c6883ee9491e3921e0d07b55fdf3cce184b40d63e72a/idna-3.8.tar.gz", hash = "sha256:d838c2c0ed6fced7693d5e8ab8e734d5f8fda53a039c0164afb0b82e771e3603", size = 189467, upload-time = "2024-08-23T16:01:51.339Z" } sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/22/7e/d71db821f177828df9dea8c42ac46473366f191be53080e552e628aad991/idna-3.8-py3-none-any.whl", hash = "sha256:050b4e5baadcd44d760cedbd2b8e639f2ff89bbc7a5730fcc662954303377aac", size = 66894, upload-time = "2024-08-23T16:01:49.963Z" }, { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
] ]
[[package]] [[package]]
name = "l18n" name = "iniconfig"
version = "2021.3" version = "2.3.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
{ name = "pytz" },
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ff/9c/13d732e16e8fbdef7e68f4f339f3b86618430d4003c7ffe82e15bf925d7c/l18n-2021.3.tar.gz", hash = "sha256:1956e890d673d17135cc20913253c154f6bc1c00266c22b7d503cc1a5a42d848", size = 50712, upload-time = "2021-11-12T09:32:36.255Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/e7/dfa82d0bb2b314950e457a755463b429090127ffc0ab9d8d14ef4563ad44/l18n-2021.3-py3-none-any.whl", hash = "sha256:78495d1df95b6f7dcc694d1ba8994df709c463a1cbac1bf016e1b9a5ce7280b9", size = 51534, upload-time = "2021-11-12T09:32:34.296Z" }, { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
] ]
[[package]] [[package]]
name = "laces" name = "laces"
version = "0.1.1" version = "0.1.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "django" }, { name = "django" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/a2/cb/d69f2cbb248bd6baceb7597d8c6c229797689b546ac2a446fac15b8f455f/laces-0.1.1.tar.gz", hash = "sha256:e45159c46f6adca33010d34e9af869e57201b70675c6dc088e919b16c89456a4", size = 26889, upload-time = "2024-02-10T23:34:22.055Z" } sdist = { url = "https://files.pythonhosted.org/packages/74/9a/9192d6a74e2c6db4f705dd98f56be488e47373172c13f4916aeabc4d68b8/laces-0.1.2.tar.gz", hash = "sha256:3218e09c1889ae5cf3fc7a82f5bb63ec0c7879889b6a9760bfc42323c694b84d", size = 29264, upload-time = "2025-01-14T04:37:34.805Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/dc/ceadbdc5e14aec7bd01bdd6ef8d5bf704d6a12d1b5a25bcaaa066bdb820d/laces-0.1.1-py3-none-any.whl", hash = "sha256:ae2c575b9aaa46154e5518c61c9f86f5a9478f753a51e9c5547c7d275d361242", size = 21349, upload-time = "2024-02-10T23:34:20.37Z" }, { url = "https://files.pythonhosted.org/packages/60/fe/31f76f5cb2579bdda208aa257ce5482653f22ab1bad3e128fe2f803fa2f1/laces-0.1.2-py3-none-any.whl", hash = "sha256:980cdaf9a31e883a2b8198132e2388931a4eb8814f5bfa5d8bba13ff9f657b7c", size = 22462, upload-time = "2025-01-14T04:37:30.636Z" },
]
[[package]]
name = "modelsearch"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "django-tasks" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dd/25/81121175512af4a4b152f6db3ea749535659f5890f0987cd481646527dff/modelsearch-1.3.1.tar.gz", hash = "sha256:f3b2e304dbef9f7c838423f591cfe0c60f4cef584bae8793d2aa8ac35a3c46e0", size = 103935, upload-time = "2026-04-27T23:37:08.841Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/f1/63cf88da72bc557594a543dd19a2c7e219acccc70cb2b1e2639204580fe6/modelsearch-1.3.1-py3-none-any.whl", hash = "sha256:a92847f01788d0d615e8715fabb8e823029288840297fb9e7235ee03ad49b6a8", size = 127463, upload-time = "2026-04-27T23:37:07.651Z" },
] ]
[[package]] [[package]]
@@ -363,39 +471,59 @@ wheels = [
[[package]] [[package]]
name = "pillow" name = "pillow"
version = "10.4.0" version = "11.3.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/74/ad3d526f3bf7b6d3f408b73fde271ec69dfac8b81341a318ce825f2b3812/pillow-10.4.0.tar.gz", hash = "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06", size = 46555059, upload-time = "2024-07-01T09:48:43.583Z" } sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/05/cb/0353013dc30c02a8be34eb91d25e4e4cf594b59e5a55ea1128fde1e5f8ea/pillow-10.4.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:673655af3eadf4df6b5457033f086e90299fdd7a47983a13827acf7459c15d94", size = 3509350, upload-time = "2024-07-01T09:46:17.177Z" }, { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" },
{ url = "https://files.pythonhosted.org/packages/e7/cf/5c558a0f247e0bf9cec92bff9b46ae6474dd736f6d906315e60e4075f737/pillow-10.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:866b6942a92f56300012f5fbac71f2d610312ee65e22f1aa2609e491284e5597", size = 3374980, upload-time = "2024-07-01T09:46:19.169Z" }, { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" },
{ url = "https://files.pythonhosted.org/packages/84/48/6e394b86369a4eb68b8a1382c78dc092245af517385c086c5094e3b34428/pillow-10.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29dbdc4207642ea6aad70fbde1a9338753d33fb23ed6956e706936706f52dd80", size = 4343799, upload-time = "2024-07-01T09:46:21.883Z" }, { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" },
{ url = "https://files.pythonhosted.org/packages/3b/f3/a8c6c11fa84b59b9df0cd5694492da8c039a24cd159f0f6918690105c3be/pillow-10.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf2342ac639c4cf38799a44950bbc2dfcb685f052b9e262f446482afaf4bffca", size = 4459973, upload-time = "2024-07-01T09:46:24.321Z" }, { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" },
{ url = "https://files.pythonhosted.org/packages/7d/1b/c14b4197b80150fb64453585247e6fb2e1d93761fa0fa9cf63b102fde822/pillow-10.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f5b92f4d70791b4a67157321c4e8225d60b119c5cc9aee8ecf153aace4aad4ef", size = 4370054, upload-time = "2024-07-01T09:46:26.825Z" }, { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" },
{ url = "https://files.pythonhosted.org/packages/55/77/40daddf677897a923d5d33329acd52a2144d54a9644f2a5422c028c6bf2d/pillow-10.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:86dcb5a1eb778d8b25659d5e4341269e8590ad6b4e8b44d9f4b07f8d136c414a", size = 4539484, upload-time = "2024-07-01T09:46:29.355Z" }, { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" },
{ url = "https://files.pythonhosted.org/packages/40/54/90de3e4256b1207300fb2b1d7168dd912a2fb4b2401e439ba23c2b2cabde/pillow-10.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:780c072c2e11c9b2c7ca37f9a2ee8ba66f44367ac3e5c7832afcfe5104fd6d1b", size = 4477375, upload-time = "2024-07-01T09:46:31.756Z" }, { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" },
{ url = "https://files.pythonhosted.org/packages/13/24/1bfba52f44193860918ff7c93d03d95e3f8748ca1de3ceaf11157a14cf16/pillow-10.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37fb69d905be665f68f28a8bba3c6d3223c8efe1edf14cc4cfa06c241f8c81d9", size = 4608773, upload-time = "2024-07-01T09:46:33.73Z" }, { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" },
{ url = "https://files.pythonhosted.org/packages/55/04/5e6de6e6120451ec0c24516c41dbaf80cce1b6451f96561235ef2429da2e/pillow-10.4.0-cp312-cp312-win32.whl", hash = "sha256:7dfecdbad5c301d7b5bde160150b4db4c659cee2b69589705b6f8a0c509d9f42", size = 2235690, upload-time = "2024-07-01T09:46:36.587Z" }, { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" },
{ url = "https://files.pythonhosted.org/packages/74/0a/d4ce3c44bca8635bd29a2eab5aa181b654a734a29b263ca8efe013beea98/pillow-10.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d846aea995ad352d4bdcc847535bd56e0fd88d36829d2c90be880ef1ee4668a", size = 2554951, upload-time = "2024-07-01T09:46:38.777Z" }, { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" },
{ url = "https://files.pythonhosted.org/packages/b5/ca/184349ee40f2e92439be9b3502ae6cfc43ac4b50bc4fc6b3de7957563894/pillow-10.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:e553cad5179a66ba15bb18b353a19020e73a7921296a7979c4a2b7f6a5cd57f9", size = 2243427, upload-time = "2024-07-01T09:46:43.15Z" }, { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" },
{ url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" },
{ url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" },
{ url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" },
{ url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" },
{ url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" },
{ url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" },
{ url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" },
{ url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" },
{ url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" },
{ url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" },
{ url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" },
] ]
[[package]] [[package]]
name = "pillow-heif" name = "pillow-heif"
version = "0.18.0" version = "1.1.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "pillow" }, { name = "pillow" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/c9/bb/e7797fe7f5cad447bb470f916ead38f0929e8d28bdf6bd5d9f31dfe1ac26/pillow_heif-0.18.0.tar.gz", hash = "sha256:70318dad9faa76121c6592ac0ab59881ff0dac6ab791a922e70d82c7706cce88", size = 16172675, upload-time = "2024-07-27T16:21:21.889Z" } sdist = { url = "https://files.pythonhosted.org/packages/6f/d4/597cf8c54d1ed494a46cc12e358d2f73a993b5f139402b35681f56beffde/pillow_heif-1.1.0.tar.gz", hash = "sha256:6c0c5f81a780185bbddc56e0d5537c53aa6cb5fb6018f5a60534a47c53f5455d", size = 18271020, upload-time = "2025-08-02T09:58:32.54Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/48/4bdce48d77c307b50bba0c77d6485e156f0fefcca273ce007351ad1deb40/pillow_heif-0.18.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:c795e7ccceea33e01e49ce536139f94cabb1bf017393666f76c05a9daebae2da", size = 5279030, upload-time = "2024-07-27T16:20:00.876Z" }, { url = "https://files.pythonhosted.org/packages/cf/93/e32cd695bca9750f6fa166f28a4c84fd8a23209f99d56712359c0b42f2e6/pillow_heif-1.1.0-cp314-cp314-macosx_13_0_x86_64.whl", hash = "sha256:c47f12a30a86c43f714353d0fb8478dc4da7915d678642e0b6cf6f0fd53d711e", size = 3384304, upload-time = "2025-08-02T09:57:55.997Z" },
{ url = "https://files.pythonhosted.org/packages/3c/d8/8f2f8f44b6fc3689ace680b21655a0eabb1393f8e66b8851b0f95b8aac1c/pillow_heif-0.18.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:4dd5b3ec09be45c1ef63be31773df90e18ee08e5e950018b0a349924b54a24ac", size = 3732529, upload-time = "2024-07-27T16:20:02.849Z" }, { url = "https://files.pythonhosted.org/packages/62/88/3e7cda3383c165a5eebf9a0193450de2b810b7654dbc363c8c26a87be6f2/pillow_heif-1.1.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3cc4a7a808b659f657c5144aeec8152d261d8f3c4a859eba66ef31dab26982d2", size = 2261529, upload-time = "2025-08-02T09:57:57.6Z" },
{ url = "https://files.pythonhosted.org/packages/f8/61/f0aac50a7ad051e1cb71ed7a8d3a94b5e54ea8ed1acb03586f0fcdebd730/pillow_heif-0.18.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eb2eade59c2654c2643a3b637de37c19e75a77c66a3e9a5e0ae26210e4f48aee", size = 6759746, upload-time = "2024-07-27T16:20:04.776Z" }, { url = "https://files.pythonhosted.org/packages/bf/f2/469d9ef5c64efecbc0559aa56ebe6539007f478d8aee83253037dc203567/pillow_heif-1.1.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f3b0df4a03f9c7f150d801f3644961df5af0739503d6fc5d3e0aeb540d16c3d", size = 5780880, upload-time = "2025-08-02T09:57:59.408Z" },
{ url = "https://files.pythonhosted.org/packages/4b/71/a8b3684f64307b96ea0ae14594564b41fdf4d4a6f7df5ef73d180a71db5d/pillow_heif-0.18.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35b59d599bfdb8454739db6b92f0841ecadbe887babb5ed5abd5299587843eef", size = 7586203, upload-time = "2024-07-27T16:20:06.811Z" }, { url = "https://files.pythonhosted.org/packages/13/74/a5c13f96ba06f4ec7d85a6d343b91c2767bd5a3fd90c146cf3c59fbcb12c/pillow_heif-1.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44fcd3a171337788bf672882484725f124143d2f290cfdbf029c22c113a15c79", size = 5502822, upload-time = "2025-08-02T09:58:00.841Z" },
{ url = "https://files.pythonhosted.org/packages/d8/59/3b1c90f7d366fb653f5f072803ae6350e608bea0d0d0ea65d6e7764c470f/pillow_heif-0.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:26a0b8b8e899e7bcc876ee61fcadb0f0b849bd6a0d5c20f0e969c77a43b40568", size = 8119771, upload-time = "2024-07-27T16:20:08.491Z" }, { url = "https://files.pythonhosted.org/packages/57/04/1a6a88647dbb0eec95fa8b9d5cac501f690cdd83479e7cacf56772f1189f/pillow_heif-1.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f07f28e99ae74dffca936e24d8881f3e7e572233cf1f7bc16fe42fb08a795be", size = 6822779, upload-time = "2025-08-02T09:58:02.238Z" },
{ url = "https://files.pythonhosted.org/packages/48/20/a72297dc260d0bf8fce8f209d36ceb498b2f1e8beb478a0ab3f565c9a3c9/pillow_heif-0.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0276a3e0c667677ed0c67f4512cdf2f674065018049307ba4de5cb4648b0a33e", size = 8850943, upload-time = "2024-07-27T16:20:10.262Z" }, { url = "https://files.pythonhosted.org/packages/ae/37/8873625e681505940381cff6a4a191301f98d9a5c1499f60a2e892ad8c89/pillow_heif-1.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7a6de39cfba4037479aa5deb65a6bb0b27da0a11b95f740202f03b578ee8932f", size = 6429866, upload-time = "2025-08-02T09:58:03.637Z" },
{ url = "https://files.pythonhosted.org/packages/12/98/2bcb2790618cfb8ce2054256a4d3bc2597288bf82444cc87fdc815484c38/pillow_heif-0.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:5916fa31f2015626dd2372d14e24521ea6caed11b25be14faa9b9c67731087ce", size = 8547707, upload-time = "2024-07-27T16:20:12.594Z" }, { url = "https://files.pythonhosted.org/packages/6b/3c/63cbbe3a6a54e3ec925d2433bb431a4bf61695f34b5f1e689db642fb20c1/pillow_heif-1.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:514c856230995dc2f918fb12d83906885d42829e911868e23035e2d916c4c7c5", size = 5573890, upload-time = "2025-08-02T09:58:05.049Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
] ]
[[package]] [[package]]
@@ -409,36 +537,89 @@ sdist = { url = "https://files.pythonhosted.org/packages/cf/9c/fb5d48abfe5d791cd
[[package]] [[package]]
name = "psycopg2-binary" name = "psycopg2-binary"
version = "2.9.10" version = "2.9.12"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cb/0e/bdc8274dc0585090b4e3432267d7be4dfbfd8971c0fa59167c711105a6bf/psycopg2-binary-2.9.10.tar.gz", hash = "sha256:4b3df0e6990aa98acda57d983942eff13d824135fe2250e6522edaa782a06de2", size = 385764, upload-time = "2024-10-16T11:24:58.126Z" } sdist = { url = "https://files.pythonhosted.org/packages/2a/60/a3624f79acea344c16fbef3a94d28b89a8042ddfb8f3e4ca83f538671409/psycopg2_binary-2.9.12.tar.gz", hash = "sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c", size = 379686, upload-time = "2026-04-21T09:40:34.304Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/49/7d/465cc9795cf76f6d329efdafca74693714556ea3891813701ac1fee87545/psycopg2_binary-2.9.10-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:880845dfe1f85d9d5f7c412efea7a08946a46894537e4e5d091732eb1d34d9a0", size = 3044771, upload-time = "2024-10-16T11:20:35.234Z" }, { url = "https://files.pythonhosted.org/packages/13/1b/708c0dca874acfad6d65314271859899a79007686f3a1f74e82a2ed4b645/psycopg2_binary-2.9.12-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2", size = 3712428, upload-time = "2026-04-20T23:35:23.453Z" },
{ url = "https://files.pythonhosted.org/packages/8b/31/6d225b7b641a1a2148e3ed65e1aa74fc86ba3fee850545e27be9e1de893d/psycopg2_binary-2.9.10-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9440fa522a79356aaa482aa4ba500b65f28e5d0e63b801abf6aa152a29bd842a", size = 3275336, upload-time = "2024-10-16T11:20:38.742Z" }, { url = "https://files.pythonhosted.org/packages/d6/39/ddbea9d4b4de6aca9431b6ed253f530f8a02d3b8f9bcfd0dbfe2b3de6fe4/psycopg2_binary-2.9.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2", size = 3823184, upload-time = "2026-04-20T23:35:25.92Z" },
{ url = "https://files.pythonhosted.org/packages/30/b7/a68c2b4bff1cbb1728e3ec864b2d92327c77ad52edcd27922535a8366f68/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3923c1d9870c49a2d44f795df0c889a22380d36ef92440ff618ec315757e539", size = 2851637, upload-time = "2024-10-16T11:20:42.145Z" }, { url = "https://files.pythonhosted.org/packages/bf/a0/bc2fef74b106fa345567122a0659e6d94512ed7dc0131ec44c9e5aba3725/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f", size = 4579157, upload-time = "2026-04-20T23:35:28.542Z" },
{ url = "https://files.pythonhosted.org/packages/0b/b1/cfedc0e0e6f9ad61f8657fd173b2f831ce261c02a08c0b09c652b127d813/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b2c956c028ea5de47ff3a8d6b3cc3330ab45cf0b7c3da35a2d6ff8420896526", size = 3082097, upload-time = "2024-10-16T11:20:46.185Z" }, { url = "https://files.pythonhosted.org/packages/57/d7/d4e3b2005d3de607ca4fbb0e8742e248056e52184a6b94ebda3c1c2c329b/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:863f5d12241ebe1c76a72a04c2113b6dc905f90b9cef0e9be0efd994affd9354", size = 4274970, upload-time = "2026-04-20T23:35:30.418Z" },
{ url = "https://files.pythonhosted.org/packages/18/ed/0a8e4153c9b769f59c02fb5e7914f20f0b2483a19dae7bf2db54b743d0d0/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f758ed67cab30b9a8d2833609513ce4d3bd027641673d4ebc9c067e4d208eec1", size = 3264776, upload-time = "2024-10-16T11:20:50.879Z" }, { url = "https://files.pythonhosted.org/packages/2e/42/c9853f8db3967fe08bcde11f53d53b85d351750cae726ce001cb68afa9c1/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a99eaab34a9010f1a086b126de467466620a750634d114d20455f3a824aae033", size = 5895175, upload-time = "2026-04-20T23:35:33.584Z" },
{ url = "https://files.pythonhosted.org/packages/10/db/d09da68c6a0cdab41566b74e0a6068a425f077169bed0946559b7348ebe9/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cd9b4f2cfab88ed4a9106192de509464b75a906462fb846b936eabe45c2063e", size = 3020968, upload-time = "2024-10-16T11:20:56.819Z" }, { url = "https://files.pythonhosted.org/packages/eb/fd/b82b5601a97630308bef079f545ffec481bbbc795c2ba5ec416a01d03f60/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffdd7dc5463ccd61845ac37b7012d0f35a1548df9febe14f8dd549be4a0bc81e", size = 4110658, upload-time = "2026-04-20T23:35:35.638Z" },
{ url = "https://files.pythonhosted.org/packages/94/28/4d6f8c255f0dfffb410db2b3f9ac5218d959a66c715c34cac31081e19b95/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dc08420625b5a20b53551c50deae6e231e6371194fa0651dbe0fb206452ae1f", size = 2872334, upload-time = "2024-10-16T11:21:02.411Z" }, { url = "https://files.pythonhosted.org/packages/62/8c/32ca69b0389ef25dd22937bf9e8fbe2ce27aea20b05ded48c4ce4cb42475/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54a0dfecab1b48731f934e06139dfe11e24219fb6d0ceb32177cf0375f14c7b5", size = 3656251, upload-time = "2026-04-20T23:35:37.854Z" },
{ url = "https://files.pythonhosted.org/packages/05/f7/20d7bf796593c4fea95e12119d6cc384ff1f6141a24fbb7df5a668d29d29/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d7cd730dfa7c36dbe8724426bf5612798734bff2d3c3857f36f2733f5bfc7c00", size = 2822722, upload-time = "2024-10-16T11:21:09.01Z" }, { url = "https://files.pythonhosted.org/packages/c4/29/96992a2b59e3b9d730fcf9612d0a387305025dc867a9fc490a9e496e074e/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:96937c9c5d891f772430f418a7a8b4691a90c3e6b93cf72b5bd7cad8cbca32a5", size = 3301810, upload-time = "2026-04-20T23:35:39.927Z" },
{ url = "https://files.pythonhosted.org/packages/4d/e4/0c407ae919ef626dbdb32835a03b6737013c3cc7240169843965cada2bdf/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:155e69561d54d02b3c3209545fb08938e27889ff5a10c19de8d23eb5a41be8a5", size = 2920132, upload-time = "2024-10-16T11:21:16.339Z" }, { url = "https://files.pythonhosted.org/packages/56/ad/44b06659949b243ae10112cd3b20a197f9bf3e81d5651379b9eb889bfaad/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:77b348775efd4cdab410ec6609d81ccecd1139c90265fa583a7255c8064bc03d", size = 3048977, upload-time = "2026-04-20T23:35:41.806Z" },
{ url = "https://files.pythonhosted.org/packages/2d/70/aa69c9f69cf09a01da224909ff6ce8b68faeef476f00f7ec377e8f03be70/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3cc28a6fd5a4a26224007712e79b81dbaee2ffb90ff406256158ec4d7b52b47", size = 2959312, upload-time = "2024-10-16T11:21:25.584Z" }, { url = "https://files.pythonhosted.org/packages/1d/f2/10a1bcebadb6aa55e280e1f58975c36a7b560ea525184c7aa4064c466633/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:527e6342b3e44c2f0544f6b8e927d60de7f163f5723b8f1dfa7d2a84298738cd", size = 3351466, upload-time = "2026-04-20T23:35:43.993Z" },
{ url = "https://files.pythonhosted.org/packages/d3/bd/213e59854fafe87ba47814bf413ace0dcee33a89c8c8c814faca6bc7cf3c/psycopg2_binary-2.9.10-cp312-cp312-win32.whl", hash = "sha256:ec8a77f521a17506a24a5f626cb2aee7850f9b69a0afe704586f63a464f3cd64", size = 1025191, upload-time = "2024-10-16T11:21:29.912Z" }, { url = "https://files.pythonhosted.org/packages/20/be/b732c8418ffa5bcfda002890f5dc4c869fc17db66ff11f53b17cfe44afc0/psycopg2_binary-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980", size = 2848762, upload-time = "2026-04-20T23:35:46.421Z" },
{ url = "https://files.pythonhosted.org/packages/92/29/06261ea000e2dc1e22907dbbc483a1093665509ea586b29b8986a0e56733/psycopg2_binary-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:18c5ee682b9c6dd3696dad6e54cc7ff3a1a9020df6a5c0f861ef8bfd338c3ca0", size = 1164031, upload-time = "2024-10-16T11:21:34.211Z" },
] ]
[[package]] [[package]]
name = "pytz" name = "pygments"
version = "2024.1" version = "2.20.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/90/26/9f1f00a5d021fff16dee3de13d43e5e978f3d58928e129c3a62cf7eb9738/pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812", size = 316214, upload-time = "2024-02-02T01:18:41.693Z" } sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/3d/a121f284241f08268b21359bd425f7d4825cffc5ac5cd0e1b3d82ffd2b10/pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319", size = 505474, upload-time = "2024-02-02T01:18:37.283Z" }, { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "pytest-cov"
version = "7.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
]
[[package]]
name = "pytest-django"
version = "4.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/13/2b/db9a193df89e5660137f5428063bcc2ced7ad790003b26974adf5c5ceb3b/pytest_django-4.12.0.tar.gz", hash = "sha256:df94ec819a83c8979c8f6de13d9cdfbe76e8c21d39473cfe2b40c9fc9be3c758", size = 91156, upload-time = "2026-02-14T18:40:49.235Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/83/a5/41d091f697c09609e7ef1d5d61925494e0454ebf51de7de05f0f0a728f1d/pytest_django-4.12.0-py3-none-any.whl", hash = "sha256:3ff300c49f8350ba2953b90297d23bf5f589db69545f56f1ec5f8cff5da83e85", size = 26123, upload-time = "2026-02-14T18:40:47.381Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
] ]
[[package]] [[package]]
name = "requests" name = "requests"
version = "2.32.3" version = "2.32.4"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "certifi" }, { name = "certifi" },
@@ -446,61 +627,61 @@ dependencies = [
{ name = "idna" }, { name = "idna" },
{ name = "urllib3" }, { name = "urllib3" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" } sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258, upload-time = "2025-06-09T16:43:07.34Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" }, { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847, upload-time = "2025-06-09T16:43:05.728Z" },
] ]
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.12.4" version = "0.15.13"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9b/ce/8d7dbedede481245b489b769d27e2934730791a9a82765cb94566c6e6abd/ruff-0.12.4.tar.gz", hash = "sha256:13efa16df6c6eeb7d0f091abae50f58e9522f3843edb40d56ad52a5a4a4b6873", size = 5131435, upload-time = "2025-07-17T17:27:19.138Z" } sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size = 4678180, upload-time = "2026-05-14T13:44:37.869Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/9f/517bc5f61bad205b7f36684ffa5415c013862dee02f55f38a217bdbe7aa4/ruff-0.12.4-py3-none-linux_armv6l.whl", hash = "sha256:cb0d261dac457ab939aeb247e804125a5d521b21adf27e721895b0d3f83a0d0a", size = 10188824, upload-time = "2025-07-17T17:26:31.412Z" }, { url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size = 10738279, upload-time = "2026-05-14T13:44:18.7Z" },
{ url = "https://files.pythonhosted.org/packages/28/83/691baae5a11fbbde91df01c565c650fd17b0eabed259e8b7563de17c6529/ruff-0.12.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:55c0f4ca9769408d9b9bac530c30d3e66490bd2beb2d3dae3e4128a1f05c7442", size = 10884521, upload-time = "2025-07-17T17:26:35.084Z" }, { url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size = 11124798, upload-time = "2026-05-14T13:44:06.427Z" },
{ url = "https://files.pythonhosted.org/packages/d6/8d/756d780ff4076e6dd035d058fa220345f8c458391f7edfb1c10731eedc75/ruff-0.12.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a8224cc3722c9ad9044da7f89c4c1ec452aef2cfe3904365025dd2f51daeae0e", size = 10277653, upload-time = "2025-07-17T17:26:37.897Z" }, { url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size = 10460761, upload-time = "2026-05-14T13:44:04.375Z" },
{ url = "https://files.pythonhosted.org/packages/8d/97/8eeee0f48ece153206dce730fc9e0e0ca54fd7f261bb3d99c0a4343a1892/ruff-0.12.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9949d01d64fa3672449a51ddb5d7548b33e130240ad418884ee6efa7a229586", size = 10485993, upload-time = "2025-07-17T17:26:40.68Z" }, { url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size = 10804451, upload-time = "2026-05-14T13:44:25.221Z" },
{ url = "https://files.pythonhosted.org/packages/49/b8/22a43d23a1f68df9b88f952616c8508ea6ce4ed4f15353b8168c48b2d7e7/ruff-0.12.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:be0593c69df9ad1465e8a2d10e3defd111fdb62dcd5be23ae2c06da77e8fcffb", size = 10022824, upload-time = "2025-07-17T17:26:43.564Z" }, { url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size = 10534285, upload-time = "2026-05-14T13:44:08.888Z" },
{ url = "https://files.pythonhosted.org/packages/cd/70/37c234c220366993e8cffcbd6cadbf332bfc848cbd6f45b02bade17e0149/ruff-0.12.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7dea966bcb55d4ecc4cc3270bccb6f87a337326c9dcd3c07d5b97000dbff41c", size = 11524414, upload-time = "2025-07-17T17:26:46.219Z" }, { url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size = 11312063, upload-time = "2026-05-14T13:44:11.274Z" },
{ url = "https://files.pythonhosted.org/packages/14/77/c30f9964f481b5e0e29dd6a1fae1f769ac3fd468eb76fdd5661936edd262/ruff-0.12.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:afcfa3ab5ab5dd0e1c39bf286d829e042a15e966b3726eea79528e2e24d8371a", size = 12419216, upload-time = "2025-07-17T17:26:48.883Z" }, { url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size = 12183079, upload-time = "2026-05-14T13:44:01.634Z" },
{ url = "https://files.pythonhosted.org/packages/6e/79/af7fe0a4202dce4ef62c5e33fecbed07f0178f5b4dd9c0d2fcff5ab4a47c/ruff-0.12.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c057ce464b1413c926cdb203a0f858cd52f3e73dcb3270a3318d1630f6395bb3", size = 11976756, upload-time = "2025-07-17T17:26:51.754Z" }, { url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size = 11440833, upload-time = "2026-05-14T13:43:59.043Z" },
{ url = "https://files.pythonhosted.org/packages/09/d1/33fb1fc00e20a939c305dbe2f80df7c28ba9193f7a85470b982815a2dc6a/ruff-0.12.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64b90d1122dc2713330350626b10d60818930819623abbb56535c6466cce045", size = 11020019, upload-time = "2025-07-17T17:26:54.265Z" }, { url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size = 11434486, upload-time = "2026-05-14T13:44:27.761Z" },
{ url = "https://files.pythonhosted.org/packages/64/f4/e3cd7f7bda646526f09693e2e02bd83d85fff8a8222c52cf9681c0d30843/ruff-0.12.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2abc48f3d9667fdc74022380b5c745873499ff827393a636f7a59da1515e7c57", size = 11277890, upload-time = "2025-07-17T17:26:56.914Z" }, { url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size = 11385189, upload-time = "2026-05-14T13:44:13.704Z" },
{ url = "https://files.pythonhosted.org/packages/5e/d0/69a85fb8b94501ff1a4f95b7591505e8983f38823da6941eb5b6badb1e3a/ruff-0.12.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2b2449dc0c138d877d629bea151bee8c0ae3b8e9c43f5fcaafcd0c0d0726b184", size = 10348539, upload-time = "2025-07-17T17:26:59.381Z" }, { url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size = 10781380, upload-time = "2026-05-14T13:43:56.734Z" },
{ url = "https://files.pythonhosted.org/packages/16/a0/91372d1cb1678f7d42d4893b88c252b01ff1dffcad09ae0c51aa2542275f/ruff-0.12.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:56e45bb11f625db55f9b70477062e6a1a04d53628eda7784dce6e0f55fd549eb", size = 10009579, upload-time = "2025-07-17T17:27:02.462Z" }, { url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size = 10540605, upload-time = "2026-05-14T13:44:20.748Z" },
{ url = "https://files.pythonhosted.org/packages/23/1b/c4a833e3114d2cc0f677e58f1df6c3b20f62328dbfa710b87a1636a5e8eb/ruff-0.12.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:478fccdb82ca148a98a9ff43658944f7ab5ec41c3c49d77cd99d44da019371a1", size = 10942982, upload-time = "2025-07-17T17:27:05.343Z" }, { url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size = 11036554, upload-time = "2026-05-14T13:44:16.256Z" },
{ url = "https://files.pythonhosted.org/packages/ff/ce/ce85e445cf0a5dd8842f2f0c6f0018eedb164a92bdf3eda51984ffd4d989/ruff-0.12.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0fc426bec2e4e5f4c4f182b9d2ce6a75c85ba9bcdbe5c6f2a74fcb8df437df4b", size = 11343331, upload-time = "2025-07-17T17:27:08.652Z" }, { url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size = 11528133, upload-time = "2026-05-14T13:44:22.808Z" },
{ url = "https://files.pythonhosted.org/packages/35/cf/441b7fc58368455233cfb5b77206c849b6dfb48b23de532adcc2e50ccc06/ruff-0.12.4-py3-none-win32.whl", hash = "sha256:4de27977827893cdfb1211d42d84bc180fceb7b72471104671c59be37041cf93", size = 10267904, upload-time = "2025-07-17T17:27:11.814Z" }, { url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size = 10721455, upload-time = "2026-05-14T13:44:35.697Z" },
{ url = "https://files.pythonhosted.org/packages/ce/7e/20af4a0df5e1299e7368d5ea4350412226afb03d95507faae94c80f00afd/ruff-0.12.4-py3-none-win_amd64.whl", hash = "sha256:fe0b9e9eb23736b453143d72d2ceca5db323963330d5b7859d60d101147d461a", size = 11209038, upload-time = "2025-07-17T17:27:14.417Z" }, { url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size = 11900409, upload-time = "2026-05-14T13:44:30.389Z" },
{ url = "https://files.pythonhosted.org/packages/11/02/8857d0dfb8f44ef299a5dfd898f673edefb71e3b533b3b9d2db4c832dd13/ruff-0.12.4-py3-none-win_arm64.whl", hash = "sha256:0618ec4442a83ab545e5b71202a5c0ed7791e8471435b94e655b570a5031a98e", size = 10469336, upload-time = "2025-07-17T17:27:16.913Z" }, { url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" },
] ]
[[package]] [[package]]
name = "six" name = "six"
version = "1.16.0" version = "1.17.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/71/39/171f1c67cd00715f190ba0b100d606d440a28c93c7714febeca8b79af85e/six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", size = 34041, upload-time = "2021-05-05T14:18:18.379Z" } sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", size = 11053, upload-time = "2021-05-05T14:18:17.237Z" }, { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
] ]
[[package]] [[package]]
name = "soupsieve" name = "soupsieve"
version = "2.6" version = "2.7"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/ce/fbaeed4f9fb8b2daa961f90591662df6a86c1abf25c548329a86920aedfb/soupsieve-2.6.tar.gz", hash = "sha256:e2e68417777af359ec65daac1057404a3c8a5455bb8abc36f1a9866ab1a51abb", size = 101569, upload-time = "2024-08-13T13:39:12.166Z" } sdist = { url = "https://files.pythonhosted.org/packages/3f/f4/4a80cd6ef364b2e8b65b15816a843c0980f7a5a2b4dc701fc574952aa19f/soupsieve-2.7.tar.gz", hash = "sha256:ad282f9b6926286d2ead4750552c8a6142bc4c783fd66b0293547c8fe6ae126a", size = 103418, upload-time = "2025-04-20T18:50:08.518Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/c2/fe97d779f3ef3b15f05c94a2f1e3d21732574ed441687474db9d342a7315/soupsieve-2.6-py3-none-any.whl", hash = "sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9", size = 36186, upload-time = "2024-08-13T13:39:10.986Z" }, { url = "https://files.pythonhosted.org/packages/e7/9c/0e6afc12c269578be5c0c1c9f4b49a8d32770a080260c333ac04cc1c832d/soupsieve-2.7-py3-none-any.whl", hash = "sha256:6e60cc5c1ffaf1cebcc12e8188320b72071e922c2e897f737cadce79ad5d30c4", size = 36677, upload-time = "2025-04-20T18:50:07.196Z" },
] ]
[[package]] [[package]]
name = "sqlparse" name = "sqlparse"
version = "0.5.1" version = "0.5.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/73/82/dfa23ec2cbed08a801deab02fe7c904bfb00765256b155941d789a338c68/sqlparse-0.5.1.tar.gz", hash = "sha256:bb6b4df465655ef332548e24f08e205afc81b9ab86cb1c45657a7ff173a3a00e", size = 84502, upload-time = "2024-07-15T19:30:27.085Z" } sdist = { url = "https://files.pythonhosted.org/packages/e5/40/edede8dd6977b0d3da179a342c198ed100dd2aba4be081861ee5911e4da4/sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", size = 84999, upload-time = "2024-12-10T12:05:30.728Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/a5/b2860373aa8de1e626b2bdfdd6df4355f0565b47e51f7d0c54fe70faf8fe/sqlparse-0.5.1-py3-none-any.whl", hash = "sha256:773dcbf9a5ab44a090f3441e2180efe2560220203dc2f8c0b0fa141e18b505e4", size = 44156, upload-time = "2024-07-15T19:30:25.033Z" }, { url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415, upload-time = "2024-12-10T12:05:27.824Z" },
] ]
[[package]] [[package]]
@@ -522,26 +703,35 @@ wheels = [
] ]
[[package]] [[package]]
name = "tzdata" name = "typing-extensions"
version = "2024.1" version = "4.14.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/74/5b/e025d02cb3b66b7b76093404392d4b44343c69101cc85f4d180dd5784717/tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd", size = 190559, upload-time = "2024-02-11T23:22:40.2Z" } sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/65/58/f9c9e6be752e9fcb8b6a0ee9fb87e6e7a1f6bcab2cdc73f02bb7ba91ada0/tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252", size = 345370, upload-time = "2024-02-11T23:22:38.223Z" }, { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" },
]
[[package]]
name = "tzdata"
version = "2025.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" },
] ]
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.2.2" version = "2.5.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/6d/fa469ae21497ddc8bc93e5877702dca7cb8f911e337aca7452b5724f1bb6/urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168", size = 292266, upload-time = "2024-06-17T13:40:11.401Z" } sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ca/1c/89ffc63a9605b583d5df2be791a27bc1a42b7c32bab68d3c8f2f73a98cd4/urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472", size = 121444, upload-time = "2024-06-17T13:40:07.795Z" }, { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
] ]
[[package]] [[package]]
name = "wagtail" name = "wagtail"
version = "6.2.1" version = "7.4.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "anyascii" }, { name = "anyascii" },
@@ -551,34 +741,48 @@ dependencies = [
{ name = "django-modelcluster" }, { name = "django-modelcluster" },
{ name = "django-permissionedforms" }, { name = "django-permissionedforms" },
{ name = "django-taggit" }, { name = "django-taggit" },
{ name = "django-tasks" },
{ name = "django-treebeard" }, { name = "django-treebeard" },
{ name = "djangorestframework" }, { name = "djangorestframework" },
{ name = "draftjs-exporter" }, { name = "draftjs-exporter" },
{ name = "l18n" },
{ name = "laces" }, { name = "laces" },
{ name = "modelsearch" },
{ name = "openpyxl" }, { name = "openpyxl" },
{ name = "pillow" }, { name = "pillow" },
{ name = "requests" }, { name = "requests" },
{ name = "telepath" }, { name = "telepath" },
{ name = "willow", extra = ["heif"] }, { name = "willow", extra = ["heif"] },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/6d/97/97b59c6bcd913f394e9f475dd952466954e9959ca9e10a45d4b251141eae/wagtail-6.2.1.tar.gz", hash = "sha256:0f136ef23b157997a44fa46543a320a31437350951cf13add8ea8b69cc5e8385", size = 6516190, upload-time = "2024-08-20T15:40:59.778Z" } sdist = { url = "https://files.pythonhosted.org/packages/81/da/c060bd9ec00335336e04a610cd1d6762e459ade9c4983f7ecddae15af36d/wagtail-7.4.1.tar.gz", hash = "sha256:c7232cbe16afa5842c236e069b2baead3b3e87e9dc90a7493330b54b69e18b83", size = 6944979, upload-time = "2026-05-19T17:26:18.762Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ca/b0/942f6bca14bfc2fbe786ebbaa90a831b71cbe3592e3da3f34d6be9f775b1/wagtail-6.2.1-py3-none-any.whl", hash = "sha256:31d073ea8acdc973ef45c5719977a1bb122ad0fc3f01348f37e922128200b42a", size = 8976424, upload-time = "2024-08-20T15:40:54.365Z" }, { url = "https://files.pythonhosted.org/packages/53/c8/162fff3c5ef9e158127c9f4aa1f0d38edd8d1fa242629ae1d8af3b6306c7/wagtail-7.4.1-py3-none-any.whl", hash = "sha256:a42dc18b0fb818649c0d704ea50a2dea06d1b75614abdf837e90766263645c76", size = 9597601, upload-time = "2026-05-19T17:26:12.53Z" },
]
[[package]]
name = "wagtail-factories"
version = "4.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "factory-boy" },
{ name = "wagtail" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/8c/2ece22c2757bb3f6ef34f36cf185246b136134cf594b7b1dde92cb0a331f/wagtail_factories-4.4.0.tar.gz", hash = "sha256:c77c13d438a2e999a9220ff1829f060116013aa519a81944577353f460ba8cba", size = 9939, upload-time = "2026-02-10T15:14:13.7Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4c/85/bd5aeaa54a10b6defca927f75bc3890acb7c3a9a23df8e10f3aa42e75807/wagtail_factories-4.4.0-py2.py3-none-any.whl", hash = "sha256:be0abb96d36bf0e3c733d7520fc1944441a379ab06d93af447fc4e71b67e2a01", size = 10754, upload-time = "2026-02-10T15:14:12.773Z" },
] ]
[[package]] [[package]]
name = "wagtail-grapple" name = "wagtail-grapple"
version = "0.26.0" version = "0.31.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "graphene-django" }, { name = "graphene-django" },
{ name = "wagtail" }, { name = "wagtail" },
{ name = "wagtail-headless-preview" }, { name = "wagtail-headless-preview" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/fc/36/2ad789a09d78b786eccf359ff88a596bcacfff4b9dd6505b68ad3c7f62f5/wagtail_grapple-0.26.0.tar.gz", hash = "sha256:68be091f7feb95bfbe0377bd03c1814a64a6150ae964ec593670d995f11780e5", size = 38362, upload-time = "2024-06-26T10:39:30.511Z" } sdist = { url = "https://files.pythonhosted.org/packages/9c/29/8cab682abde7f3a8d78a29aac6e441b7a9a20d9baf850c7fc438ad086c48/wagtail_grapple-0.31.0.tar.gz", hash = "sha256:5a70e88dca5188181e42306b207b1e38cf5df4452aa62e764b9e997e2ea19797", size = 39240, upload-time = "2026-04-21T09:24:28.195Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/7a/81/189560b651d61d71c81841f56249dcea3ab441bfcb2adb5afe7b5e516085/wagtail_grapple-0.26.0-py3-none-any.whl", hash = "sha256:de9d3c336d151279976d67f781c758ac5efc7d2bb003c2e311e6fbeea1554c41", size = 47759, upload-time = "2024-06-26T10:39:28.849Z" }, { url = "https://files.pythonhosted.org/packages/08/05/d80a7675e359fa62d45b92f14e42850fcb5f42c4fec3950951585b0a0975/wagtail_grapple-0.31.0-py3-none-any.whl", hash = "sha256:fed9205df68861d0ec6c5be67ac5aeb00775ebabde6baa38d582e723076befc0", size = 49251, upload-time = "2026-04-21T09:24:26.18Z" },
] ]
[[package]] [[package]]
@@ -595,24 +799,24 @@ wheels = [
[[package]] [[package]]
name = "whitenoise" name = "whitenoise"
version = "6.7.0" version = "6.12.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/64/b8/86451d63ef5e1a9c480b52759d9db25ba85c3420ebdaf039057ed152a4c1/whitenoise-6.7.0.tar.gz", hash = "sha256:58c7a6cd811e275a6c91af22e96e87da0b1109e9a53bb7464116ef4c963bf636", size = 24973, upload-time = "2024-06-19T16:20:09.11Z" } sdist = { url = "https://files.pythonhosted.org/packages/cb/2a/55b3f3a4ec326cd077c1c3defeee656b9298372a69229134d930151acd01/whitenoise-6.12.0.tar.gz", hash = "sha256:f723ebb76a112e98816ff80fcea0a6c9b8ecde835f8ddda25df7a30a3c2db6ad", size = 26841, upload-time = "2026-02-27T00:05:42.028Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/42/68400d8ad59f67a1f7e12c2f39089ce005f08f73333f3e215f3d5ed6453c/whitenoise-6.7.0-py3-none-any.whl", hash = "sha256:a1ae85e01fdc9815d12fa33f17765bc132ed2c54fa76daf9e39e879dd93566f6", size = 19905, upload-time = "2024-06-19T16:20:03.269Z" }, { url = "https://files.pythonhosted.org/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2", size = 20302, upload-time = "2026-02-27T00:05:40.086Z" },
] ]
[[package]] [[package]]
name = "willow" name = "willow"
version = "1.8.0" version = "1.11.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "defusedxml" }, { name = "defusedxml" },
{ name = "filetype" }, { name = "filetype" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/77/c3/9d19db05d3abe0375e8d6bf2d3f7ea6f8df266d5ddabcc5a114c8a7cce1f/willow-1.8.0.tar.gz", hash = "sha256:ef3df6cde80d4914e719188147bef1d71c240edb118340e0c5957ecc8fe08315", size = 113313, upload-time = "2024-01-17T19:11:58.712Z" } sdist = { url = "https://files.pythonhosted.org/packages/a3/bd/2a383be24c3e47423aa9b0aa5b4ca818ef193506b58800dd51e1b89d7bb3/willow-1.11.0.tar.gz", hash = "sha256:70292b2d0cd2d5bb4076f0b3d61308aeaa0b225f3970d00752f08a8fd386c3d1", size = 113827, upload-time = "2025-07-16T08:46:26.939Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/95/7a/a48cf90bbf226f793d830c3bbdb7f0c4aa8087c08ce84cc864a1c882a457/willow-1.8.0-py3-none-any.whl", hash = "sha256:48ccf5ce48ccd29c37a32497cd7af50983f8570543c4de2988b15d583efc66be", size = 119223, upload-time = "2024-01-17T19:11:56.316Z" }, { url = "https://files.pythonhosted.org/packages/c1/05/b3f1b443c31ad871c48e19ea2be189681c2df4ccf594b1dd83d6775c032b/willow-1.11.0-py3-none-any.whl", hash = "sha256:0a4388dbf18726eef8f27449659047689c39b7023045ca5a8a75410d3864ee6f", size = 119459, upload-time = "2025-07-16T08:46:25.596Z" },
] ]
[package.optional-dependencies] [package.optional-dependencies]
+12 -44
View File
@@ -9,16 +9,21 @@ from grapple.models import (
) )
from wagtail.admin.panels import FieldPanel, FieldRowPanel, MultiFieldPanel from wagtail.admin.panels import FieldPanel, FieldRowPanel, MultiFieldPanel
from wagtail.fields import RichTextField, StreamField from wagtail.fields import RichTextField, StreamField
from wagtail.models import Page from wagtail.models import Page, PageManager
from wagtail.search import index from wagtail.search import index
from wagtail_headless_preview.models import HeadlessMixin
from dnscms.blocks import ImageSliderBlock from dnscms.blocks import ImageSliderBlock
from dnscms.fields import CommonStreamField from dnscms.fields import CommonStreamField
from dnscms.wordpress.models import WPImportedPageMixin from dnscms.wordpress.models import DeferWPFieldsManagerMixin, WPImportedPageMixin
class VenuePageManager(DeferWPFieldsManagerMixin, PageManager):
pass
@register_singular_query_field("venueIndex") @register_singular_query_field("venueIndex")
class VenueIndex(Page): class VenueIndex(HeadlessMixin, Page):
# there can only be one venue index page # there can only be one venue index page
max_count = 1 max_count = 1
subpage_types = ["venues.VenuePage"] subpage_types = ["venues.VenuePage"]
@@ -35,7 +40,7 @@ class VenueIndex(Page):
@register_singular_query_field("venueRentalIndex") @register_singular_query_field("venueRentalIndex")
class VenueRentalIndex(Page): class VenueRentalIndex(HeadlessMixin, Page):
# there can only be one venue index page # there can only be one venue index page
max_count = 1 max_count = 1
subpage_types = [] subpage_types = []
@@ -51,13 +56,15 @@ class VenueRentalIndex(Page):
graphql_fields = [GraphQLRichText("lead"), GraphQLStreamfield("body")] graphql_fields = [GraphQLRichText("lead"), GraphQLStreamfield("body")]
class VenuePage(WPImportedPageMixin, Page): class VenuePage(HeadlessMixin, WPImportedPageMixin, Page):
# no children # no children
subpage_types = [] subpage_types = []
parent_page_types = ["venues.VenueIndex"] parent_page_types = ["venues.VenueIndex"]
# should not be able to be shown in menus # should not be able to be shown in menus
show_in_menus = False show_in_menus = False
objects = VenuePageManager()
featured_image = models.ForeignKey( featured_image = models.ForeignKey(
"images.CustomImage", "images.CustomImage",
null=True, null=True,
@@ -177,42 +184,3 @@ class VenuePage(WPImportedPageMixin, Page):
search_fields = Page.search_fields + [ search_fields = Page.search_fields + [
index.SearchField("body"), index.SearchField("body"),
] ]
def import_wordpress_data(self, data):
import html
# Wagtail page model fields
self.title = html.unescape(data["title"])
self.slug = data["slug"]
self.first_published_at = data["first_published_at"]
self.last_published_at = data["last_published_at"]
self.latest_revision_created_at = data["latest_revision_created_at"]
self.search_description = data["search_description"]
# debug fields
self.wp_post_id = data["wp_post_id"]
self.wp_post_type = data["wp_post_type"]
self.wp_link = data["wp_link"]
self.wp_raw_content = data["wp_raw_content"]
self.wp_block_json = data["wp_block_json"]
self.wp_processed_content = data["wp_processed_content"]
self.wp_normalized_styles = data["wp_normalized_styles"]
self.wp_post_meta = data["wp_post_meta"]
# own model fields
self.body = data["body"] or ""
meta = data["wp_post_meta"]
self.show_as_bookable = meta.get("neuf_venues_show_on_booking_page", False)
self.preposition = meta.get("neuf_venues_preposition") or ""
self.floor = meta.get("neuf_venues_floor") or ""
self.used_for = meta.get("neuf_venues_used_for") or ""
self.capability_bar = meta.get("neuf_venues_bar") or ""
self.capability_audio = meta.get("neuf_venues_audio") or ""
self.capability_lighting = meta.get("neuf_venues_lighting") or ""
self.capability_audio_video = meta.get("neuf_venues_audio_video") or ""
self.capacity_legal = meta.get("neuf_venues_capacity_legal") or ""
self.capacity_standing = meta.get("neuf_venues_capacity_standing") or ""
self.capacity_sitting = meta.get("neuf_venues_capacity_sitting") or ""
+5
View File
@@ -0,0 +1,5 @@
[tools]
python = "3.14"
uv = "latest"
node = "24"
prek = "latest"
+29
View File
@@ -0,0 +1,29 @@
exclude = '^web/src/gql/'
[[repos]]
repo = "https://github.com/pre-commit/pre-commit-hooks"
rev = "v6.0.0"
hooks = [
{ id = "end-of-file-fixer" },
{ id = "trailing-whitespace" },
{ id = "check-yaml" },
{ id = "check-toml" },
{ id = "check-merge-conflict" },
{ id = "check-added-large-files" },
{ id = "debug-statements" },
]
[[repos]]
repo = "https://github.com/astral-sh/ruff-pre-commit"
rev = "v0.15.13"
[[repos.hooks]]
id = "ruff-check"
args = ["--fix"]
files = '^dnscms/.*\.py$'
exclude = '/migrations/'
[[repos.hooks]]
id = "ruff-format"
files = '^dnscms/.*\.py$'
exclude = '/migrations/'
+1 -1
View File
@@ -1,2 +1,2 @@
GRAPHQL_ENDPOINT=https://cms.neuf.no/api/graphql/ WAGTAIL_BASE_URL=https://cms.neuf.no
URL=http://localhost:3000 URL=http://localhost:3000
+1 -1
View File
@@ -1,6 +1,6 @@
# Based on https://github.com/vercel/next.js/tree/canary/examples/with-docker # Based on https://github.com/vercel/next.js/tree/canary/examples/with-docker
FROM node:22-alpine AS base FROM node:24-alpine AS base
# Install dependencies only when needed # Install dependencies only when needed
FROM base AS deps FROM base AS deps
-15
View File
@@ -1,15 +0,0 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Development
Run the development server:
```bash
npm run dev
```
Update GraphQL definitions from `http://127.0.0.1:8000/api/graphql/`:
```bash
npm run codegen
```
+19 -1
View File
@@ -3,13 +3,31 @@ import { CodegenConfig } from "@graphql-codegen/cli";
import { loadEnvConfig } from "@next/env"; import { loadEnvConfig } from "@next/env";
loadEnvConfig(process.cwd()); loadEnvConfig(process.cwd());
const wagtailBaseUrl = process.env.WAGTAIL_BASE_URL;
if (!wagtailBaseUrl) {
throw new Error("WAGTAIL_BASE_URL is not set");
}
const graphqlEndpoint = `${wagtailBaseUrl.replace(/\/$/, "")}/api/graphql/`;
const config: CodegenConfig = { const config: CodegenConfig = {
schema: process.env.GRAPHQL_ENDPOINT, schema: graphqlEndpoint,
documents: ["src/**/*.tsx", "src/**/*.ts"], documents: ["src/**/*.tsx", "src/**/*.ts"],
ignoreNoDocuments: true, // for better experience with the watcher ignoreNoDocuments: true, // for better experience with the watcher
generates: { generates: {
"./src/gql/": { "./src/gql/": {
preset: "client", preset: "client",
presetConfig: {
fragmentMasking: { unmaskFunctionName: "unmaskFragment" },
},
config: {
scalars: {
DateTime: "string",
JSONString: "string",
PositiveInt: "number",
RichText: "string",
UUID: "string",
},
},
}, },
}, },
}; };
-2
View File
@@ -1,2 +0,0 @@
[tools]
node = "22"
+9
View File
@@ -1,4 +1,8 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const nextConfig = { const nextConfig = {
output: "standalone", output: "standalone",
images: { images: {
@@ -12,6 +16,11 @@ const nextConfig = {
hostname: "**", hostname: "**",
}, },
], ],
formats: ["image/avif", "image/webp"],
dangerouslyAllowLocalIP: process.env.NODE_ENV === "development",
},
turbopack: {
root: __dirname,
}, },
}; };
+5078 -2490
View File
File diff suppressed because it is too large Load Diff
+34 -22
View File
@@ -7,34 +7,46 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"codegen": "graphql-codegen" "codegen": "graphql-codegen",
"perf:build": "next build",
"perf:serve": "next start -p 3100",
"perf:lh:desktop": "lighthouse http://localhost:3100/ --preset=desktop --output=html --output=json --output-path=../scratch/lighthouse/home-desktop --only-categories=performance --chrome-flags=\"--headless=new\" --quiet",
"perf:lh:mobile": "lighthouse http://localhost:3100/ --output=html --output=json --output-path=../scratch/lighthouse/home-mobile --only-categories=performance --chrome-flags=\"--headless=new\" --quiet",
"perf:lh": "wait-on http://localhost:3100/ && npm run perf:lh:desktop && npm run perf:lh:mobile"
}, },
"dependencies": { "dependencies": {
"@graphql-codegen/cli": "^5.0.7", "@graphql-codegen/cli": "^7.0.0",
"@graphql-codegen/client-preset": "^4.8.3", "@graphql-codegen/client-preset": "^6.0.0",
"@parcel/watcher": "^2.5.1", "@parcel/watcher": "^2.5.6",
"@sindresorhus/slugify": "^2.2.1", "@sindresorhus/slugify": "^3.0.0",
"@urql/next": "^1.1.5", "@urql/next": "^2.0.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0", "date-fns-tz": "^3.2.0",
"graphql": "^16.11.0", "graphql": "^16.14.0",
"next": "^15.4.6", "next": "^16.2.6",
"nuqs": "^2.4.3", "nuqs": "^2.8.9",
"react": "^19.1.1", "react": "19.2.6",
"react-dom": "^19.1.1", "react-dom": "19.2.6",
"react-intersection-observer": "^9.16.0", "react-intersection-observer": "^10.0.3",
"sass": "^1.90.0", "sass": "^1.99.0",
"sharp": "^0.34.3", "sharp": "^0.34.5",
"swiper": "^11.2.10", "swiper": "^12.1.4",
"urql": "^4.2.2", "urql": "^5.0.2",
"use-debounce": "^10.0.5" "use-debounce": "^10.1.1"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22", "@types/node": "^24",
"@types/react": "^19.1.9", "@types/react": "19.2.14",
"@types/react-dom": "^19.1.7", "@types/react-dom": "19.2.3",
"baseline-browser-mapping": "^2.10.29",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.4.6", "eslint-config-next": "16.2.6",
"typescript": "^5" "lighthouse": "^13.3.0",
"typescript": "^6",
"wait-on": "^9.0.10"
},
"overrides": {
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3"
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

@@ -0,0 +1,51 @@
grisemonster image variants
===========================
Source: grisemonster-lossless-original.png (2048x1024 RGBA PNG, 2.0M)
Files in use
------------
grisemonster-2048.png 443K desktop (>1280px viewport)
grisemonster-1280.png 227K tablet (601-1280px)
grisemonster-800.png 97K mobile (<=600px)
The previous single grisemonster.png was 624K served to every device.
Mobile now downloads 97K (-84%); desktop downloads 443K (-29%).
How they were generated
-----------------------
For each width W in {2048, 1280, 800}:
magick grisemonster-lossless-original.png -resize ${W}x base.png
pngquant 64 --quality=50-80 --speed 1 --output grisemonster-${W}.png base.png
oxipng -o max --strip safe grisemonster-${W}.png
pngquant reduces to a 64-colour palette (the image is a flat
illustration with very few distinct colours, so this is visually
lossless at the displayed scale). oxipng then does a lossless
re-encode pass to squeeze the PNG further.
Wired up in src/components/layout/footer.module.scss via two
min-width media queries on `.pigPattern`'s background-image.
Why PNG, not WebP/AVIF
----------------------
Tested at 2048 width:
PNG (pngquant 64 + oxipng) 443K <-- chosen
PNG (pngquant 256 + oxipng) 522K
AVIF q50 493K
AVIF q60 598K
AVIF q75 718K
WebP q75 829K larger than current PNG
WebP q85 903K larger than current PNG
WebP lossless 1.0M larger than current PNG
For this kind of content -- a flat, limited-palette illustration with
large transparent regions -- palette PNG is the smallest format.
WebP and AVIF are tuned for photographic content and lose to a
well-quantised PNG here. Visual quality of the three PNG/AVIF options
above was indistinguishable at the rendered scale.
Conclusion: the win came from responsive sizing, not from changing
format. Stayed on PNG to keep the CSS simple (no image-set() needed).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 610 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 32 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 15 KiB

+10 -68
View File
@@ -1,41 +1,15 @@
import { graphql } from "@/gql"; import { graphql } from "@/gql";
import { GenericFragment } from "@/gql/graphql";
import { getClient } from "@/app/client"; import { getClient } from "@/app/client";
import { Metadata, ResolvingMetadata } from "next"; import { Metadata, ResolvingMetadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { PageHeader } from "@/components/general/PageHeader"; import {
import { PageContent } from "@/components/general/PageContent"; GenericPageView,
import { BgPig } from "@/components/general/BgPig"; loadGenericPageProps,
} from "@/components/general/GenericPageView";
import { getSeoMetadata } from "@/lib/seo"; import { getSeoMetadata } from "@/lib/seo";
export const dynamicParams = false; export const dynamicParams = false;
const GenericFragmentDefinition = graphql(`
fragment Generic on GenericPage {
__typename
id
urlPath
seoTitle
searchDescription
title
lead
pig
body {
...Blocks
}
}
`);
const genericPageByUrlPathQuery = graphql(`
query genericPageByUrl($urlPath: String!) {
page: page(contentType: "generic.GenericPage", urlPath: $urlPath) {
... on GenericPage {
...Generic
}
}
}
`);
function getWagtailUrlPath(url: string[]): string { function getWagtailUrlPath(url: string[]): string {
// for the page /foo/bar we need to look for `/home/foo/bar/` // for the page /foo/bar we need to look for `/home/foo/bar/`
return `/home/${url.join("/")}/`; return `/home/${url.join("/")}/`;
@@ -78,46 +52,14 @@ export async function generateMetadata(
parent: ResolvingMetadata parent: ResolvingMetadata
): Promise<Metadata | null> { ): Promise<Metadata | null> {
const { url } = await params; const { url } = await params;
const urlPath = getWagtailUrlPath(url); const props = await loadGenericPageProps({ urlPath: getWagtailUrlPath(url) });
const { data, error } = await getClient().query(genericPageByUrlPathQuery, { if (!props) return null;
urlPath: urlPath, return getSeoMetadata(props.page, parent);
});
if (error) {
throw new Error(error.message);
}
if (!data?.page) {
return null;
}
const page = data.page as GenericFragment;
const metadata = await getSeoMetadata(page, parent);
return metadata;
} }
export default async function Page({ params }: { params: Params }) { export default async function Page({ params }: { params: Params }) {
const { url } = await params; const { url } = await params;
const urlPath = getWagtailUrlPath(url); const props = await loadGenericPageProps({ urlPath: getWagtailUrlPath(url) });
const { data, error } = await getClient().query(genericPageByUrlPathQuery, { if (!props) return notFound();
urlPath: urlPath, return <GenericPageView {...props} />;
});
if (error) {
throw new Error(error.message);
}
if (!data?.page) {
return notFound();
}
const page = data?.page as GenericFragment;
return (
<>
<main className="site-main" id="main">
<PageHeader heading={page.title} lead={page.lead} />
<PageContent blocks={page.body} />
</main>
{page.pig && <BgPig type={page.pig} color="white" />}
</>
);
} }
+10 -69
View File
@@ -1,24 +1,13 @@
import { Metadata, ResolvingMetadata } from "next"; import { Metadata, ResolvingMetadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getClient } from "@/app/client"; import { getClient } from "@/app/client";
import { Breadcrumb } from "@/components/general/Breadcrumb"; import {
import { ImageFigure } from "@/components/general/Image"; NewsPageView,
import { PageContent } from "@/components/general/PageContent"; loadNewsPageProps,
} from "@/components/news/NewsPageView";
import { graphql } from "@/gql"; import { graphql } from "@/gql";
import { NewsFragment } from "@/gql/graphql";
import { formatDate } from "@/lib/date";
import { getSeoMetadata } from "@/lib/seo"; import { getSeoMetadata } from "@/lib/seo";
const newsBySlugQuery = graphql(`
query newsBySlug($slug: String!) {
news: page(contentType: "news.NewsPage", slug: $slug) {
... on NewsPage {
...News
}
}
}
`);
export async function generateStaticParams() { export async function generateStaticParams() {
const allNewsSlugsQuery = graphql(` const allNewsSlugsQuery = graphql(`
query allNewsSlugs { query allNewsSlugs {
@@ -51,62 +40,14 @@ export async function generateMetadata(
parent: ResolvingMetadata parent: ResolvingMetadata
): Promise<Metadata | null> { ): Promise<Metadata | null> {
const { slug } = await params; const { slug } = await params;
const { data, error } = await getClient().query(newsBySlugQuery, { const props = await loadNewsPageProps({ slug });
slug, if (!props) return null;
}); return getSeoMetadata(props.news, parent);
if (error) {
throw new Error(error.message);
}
if (!data?.news) {
return null;
}
const news = data.news as NewsFragment;
const metadata = await getSeoMetadata(news, parent);
return metadata;
} }
export default async function Page({ params }: { params: Params }) { export default async function Page({ params }: { params: Params }) {
const { slug } = await params; const { slug } = await params;
const { data, error } = await getClient().query(newsBySlugQuery, { const props = await loadNewsPageProps({ slug });
slug, if (!props) return notFound();
}); return <NewsPageView {...props} />;
if (error) {
throw new Error(error.message);
}
if (!data?.news) {
return notFound();
}
const news = data?.news as NewsFragment;
const featuredImage: any = news.featuredImage;
return (
<main className="site-main" id="main">
<section className="news-header">
<Breadcrumb
link="/aktuelt"
text="Nyhet"
date={formatDate(news.firstPublishedAt, "d. MMMM yyyy")}
/>
<h1 className="news-title">{news.title}</h1>
{news.lead && (
<div
className="lead"
dangerouslySetInnerHTML={{ __html: news.lead }}
/>
)}
{featuredImage && (
<ImageFigure
src={featuredImage.url}
alt={featuredImage.alt ?? ""}
width={featuredImage.width}
height={featuredImage.height}
attribution={featuredImage.attribution}
sizes="100vw"
/>
)}
</section>
<PageContent blocks={news.body} />
</main>
);
} }
+9 -29
View File
@@ -1,39 +1,19 @@
import { getClient } from "@/app/client";
import { NewsList } from "@/components/news/NewsList";
import { Metadata, ResolvingMetadata } from "next"; import { Metadata, ResolvingMetadata } from "next";
import { PageHeader } from "@/components/general/PageHeader"; import {
import { newsQuery, NewsFragment, NewsIndexFragment } from "@/lib/news"; NewsIndexView,
loadNewsIndexProps,
} from "@/components/news/NewsIndexView";
import { getSeoMetadata } from "@/lib/seo"; import { getSeoMetadata } from "@/lib/seo";
export async function generateMetadata( export async function generateMetadata(
{ params }: { params: Promise<{}> }, _: unknown,
parent: ResolvingMetadata parent: ResolvingMetadata
): Promise<Metadata | null> { ): Promise<Metadata | null> {
const { data, error } = await getClient().query(newsQuery, {}); const { index } = await loadNewsIndexProps();
if (error) { return getSeoMetadata(index, parent);
throw new Error(error.message);
}
if (!data?.index) {
return null;
}
const index = data.index as NewsIndexFragment;
const metadata = await getSeoMetadata(index, parent);
return metadata;
} }
export default async function Page() { export default async function Page() {
const { data, error } = await getClient().query(newsQuery, {}); const props = await loadNewsIndexProps();
if (error) { return <NewsIndexView {...props} />;
throw new Error(error.message);
}
const news = (data?.news ?? []) as NewsFragment[];
const index = data?.index as NewsIndexFragment;
return (
<main className="site-main" id="main">
<PageHeader heading={index.title} lead={index.lead} align="left" />
<NewsList news={news} />
</main>
);
} }
+7
View File
@@ -0,0 +1,7 @@
import { cookies, draftMode } from "next/headers";
export async function POST() {
(await draftMode()).disable();
(await cookies()).delete("preview-token");
return new Response(null, { status: 204 });
}
+25
View File
@@ -0,0 +1,25 @@
import { cookies, draftMode } from "next/headers";
import { redirect } from "next/navigation";
import { NextRequest } from "next/server";
// Wagtail-headless-preview directs the editor's preview iframe here with
// ?content_type=app.Model&token=<signed>. We stash the token in a cookie,
// enable Next.js draft mode, and redirect to the type-dispatching renderer.
export async function GET(req: NextRequest) {
const token = req.nextUrl.searchParams.get("token");
const contentType = req.nextUrl.searchParams.get("content_type");
if (!token || !contentType) {
return new Response("missing token/content_type", { status: 400 });
}
(await draftMode()).enable();
(await cookies()).set("preview-token", token, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24,
path: "/",
});
redirect("/preview/render");
}
+10 -60
View File
@@ -1,25 +1,13 @@
import { Metadata, ResolvingMetadata } from "next"; import { Metadata, ResolvingMetadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getClient } from "@/app/client"; import { getClient } from "@/app/client";
import { EventDetails } from "@/components/events/EventDetails"; import {
import { EventHeader } from "@/components/events/EventHeader"; EventPageView,
import { BgPig } from "@/components/general/BgPig"; loadEventPageProps,
import { PageContent } from "@/components/general/PageContent"; } from "@/components/events/EventPageView";
import { graphql } from "@/gql"; import { graphql } from "@/gql";
import { EventFragment } from "@/gql/graphql";
import { getEventPig } from "@/lib/event";
import { getSeoMetadata } from "@/lib/seo"; import { getSeoMetadata } from "@/lib/seo";
const eventBySlugQuery = graphql(`
query eventBySlug($slug: String!) {
event: page(contentType: "events.EventPage", slug: $slug) {
... on EventPage {
...Event
}
}
}
`);
export async function generateStaticParams() { export async function generateStaticParams() {
const allEventSlugsQuery = graphql(` const allEventSlugsQuery = graphql(`
query allEventSlugs { query allEventSlugs {
@@ -52,52 +40,14 @@ export async function generateMetadata(
parent: ResolvingMetadata parent: ResolvingMetadata
): Promise<Metadata | null> { ): Promise<Metadata | null> {
const { slug } = await params; const { slug } = await params;
const { data, error } = await getClient().query(eventBySlugQuery, { const props = await loadEventPageProps({ slug });
slug, if (!props) return null;
}); return getSeoMetadata(props.event, parent);
if (error) {
throw new Error(error.message);
}
if (!data?.event) {
return null;
}
const event = data.event as EventFragment;
const metadata = await getSeoMetadata(event, parent);
return metadata;
} }
export default async function Page({ params }: { params: Params }) { export default async function Page({ params }: { params: Params }) {
const { slug } = await params; const { slug } = await params;
const { data, error } = await getClient().query(eventBySlugQuery, { const props = await loadEventPageProps({ slug });
slug, if (!props) return notFound();
}); return <EventPageView {...props} />;
if (error) {
throw new Error(error.message);
}
if (!data?.event) {
return notFound();
}
const event = data.event as EventFragment;
const eventPig = getEventPig(event);
return (
<>
<main className="site-main" id="main">
<EventHeader event={event} />
<EventDetails event={event} />
{event.lead && (
<div
className="lead event-lead"
dangerouslySetInnerHTML={{ __html: event.lead }}
/>
)}
<PageContent blocks={event.body} />
</main>
{eventPig && <BgPig type={eventPig} color="white" />}
</>
);
} }
+11 -55
View File
@@ -1,68 +1,24 @@
import { Suspense } from "react";
import { Metadata, ResolvingMetadata } from "next"; import { Metadata, ResolvingMetadata } from "next";
import { getClient } from "@/app/client"; import { getClient } from "@/app/client";
import { EventContainer } from "@/components/events/EventContainer";
import { import {
eventsOverviewQuery, EventIndexView,
eventIndexMetadataQuery, loadEventIndexProps,
EventFragment, } from "@/components/events/EventIndexView";
EventCategory, import { EventIndexFragment } from "@/gql/graphql";
EventOrganizer, import { eventIndexMetadataQuery } from "@/lib/event";
} from "@/lib/event";
import { PageHeader } from "@/components/general/PageHeader";
import { EventIndexFragment, VenueFragment } from "@/gql/graphql";
import { getSeoMetadata } from "@/lib/seo"; import { getSeoMetadata } from "@/lib/seo";
export async function generateMetadata( export async function generateMetadata(
{ params }: { params: Promise<{}> }, _: unknown,
parent: ResolvingMetadata parent: ResolvingMetadata
): Promise<Metadata | null> { ): Promise<Metadata | null> {
const { data, error } = await getClient().query(eventIndexMetadataQuery, {}); const { data, error } = await getClient().query(eventIndexMetadataQuery, {});
if (error) throw new Error(error.message);
if (error) { if (!data?.index) return null;
throw new Error(error.message); return getSeoMetadata(data.index as EventIndexFragment, parent);
}
if (!data?.index) {
return null;
}
const index = data.index as EventIndexFragment;
const metadata = await getSeoMetadata(index, parent);
return metadata;
} }
export default async function Page() { export default async function Page() {
const { data, error } = await getClient().query(eventsOverviewQuery, {}); const props = await loadEventIndexProps();
if (error) { return <EventIndexView {...props} />;
throw new Error(error.message);
}
if (
!data?.index ||
!data?.events?.futureEvents ||
!data?.eventCategories ||
!data?.eventOrganizers ||
!data?.venues
) {
throw new Error("Failed to render /arrangementer");
}
const index = data?.index as EventIndexFragment;
const events = (data?.events?.futureEvents ?? []) as EventFragment[];
const eventCategories = (data?.eventCategories ?? []) as EventCategory[];
const eventOrganizers = (data?.eventOrganizers ?? []) as EventOrganizer[];
const venues = (data?.venues ?? []) as VenueFragment[];
return (
<main className="site-main" id="main">
<PageHeader heading="Dette skjer på Chateau Neuf" align="left" />
<Suspense>
<EventContainer
events={events}
eventCategories={eventCategories}
eventOrganizers={eventOrganizers}
venues={venues}
/>
</Suspense>
</main>
);
} }
+7 -1
View File
@@ -3,9 +3,15 @@ import "server-only";
import { cacheExchange, createClient, fetchExchange } from "@urql/core"; import { cacheExchange, createClient, fetchExchange } from "@urql/core";
import { registerUrql } from "@urql/next/rsc"; import { registerUrql } from "@urql/next/rsc";
const wagtailBaseUrl = process.env.WAGTAIL_BASE_URL;
if (!wagtailBaseUrl) {
throw new Error("WAGTAIL_BASE_URL is not set");
}
const graphqlEndpoint = `${wagtailBaseUrl.replace(/\/$/, "")}/api/graphql/`;
const makeClient = () => { const makeClient = () => {
return createClient({ return createClient({
url: process.env.GRAPHQL_ENDPOINT ?? "", url: graphqlEndpoint,
exchanges: [cacheExchange, fetchExchange], exchanges: [cacheExchange, fetchExchange],
// requestPolicy: "network-only", // requestPolicy: "network-only",
fetchOptions: { next: { revalidate: 0 } }, fetchOptions: { next: { revalidate: 0 } },
Binary file not shown.

Before

Width:  |  Height:  |  Size: 314 KiB

After

Width:  |  Height:  |  Size: 13 KiB

+10 -49
View File
@@ -1,25 +1,13 @@
import { Metadata, ResolvingMetadata } from "next"; import { Metadata, ResolvingMetadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getClient } from "@/app/client"; import { getClient } from "@/app/client";
import { AssociationHeader } from "@/components/associations/AssociationHeader"; import {
import { PageContent } from "@/components/general/PageContent"; AssociationPageView,
loadAssociationPageProps,
} from "@/components/associations/AssociationPageView";
import { graphql } from "@/gql"; import { graphql } from "@/gql";
import { AssociationFragment } from "@/gql/graphql";
import { getSeoMetadata } from "@/lib/seo"; import { getSeoMetadata } from "@/lib/seo";
const associationBySlugQuery = graphql(`
query associationBySlug($slug: String!) {
association: page(
contentType: "associations.AssociationPage"
slug: $slug
) {
... on AssociationPage {
...Association
}
}
}
`);
type Params = Promise<{ slug: string }>; type Params = Promise<{ slug: string }>;
export async function generateMetadata( export async function generateMetadata(
@@ -27,20 +15,9 @@ export async function generateMetadata(
parent: ResolvingMetadata parent: ResolvingMetadata
): Promise<Metadata | null> { ): Promise<Metadata | null> {
const { slug } = await params; const { slug } = await params;
const { data, error } = await getClient().query(associationBySlugQuery, { const props = await loadAssociationPageProps({ slug });
slug, if (!props) return null;
}); return getSeoMetadata(props.association, parent);
if (error) {
throw new Error(error.message);
}
if (!data?.association) {
return null;
}
const association = data.association as AssociationFragment;
const metadata = await getSeoMetadata(association, parent);
return metadata;
} }
export async function generateStaticParams() { export async function generateStaticParams() {
@@ -70,23 +47,7 @@ export async function generateStaticParams() {
export default async function Page({ params }: { params: Params }) { export default async function Page({ params }: { params: Params }) {
const { slug } = await params; const { slug } = await params;
const { data, error } = await getClient().query(associationBySlugQuery, { const props = await loadAssociationPageProps({ slug });
slug, if (!props) return notFound();
}); return <AssociationPageView {...props} />;
if (error) {
throw new Error(error.message);
}
if (!data?.association) {
return notFound();
}
const association = data.association as AssociationFragment;
return (
<main className="site-main" id="main">
<AssociationHeader association={association} />
<PageContent blocks={association.body} />
</main>
);
} }
+9 -96
View File
@@ -1,106 +1,19 @@
import { Metadata, ResolvingMetadata } from "next"; import { Metadata, ResolvingMetadata } from "next";
import { graphql } from "@/gql"; import {
import { AssociationFragment, AssociationIndexFragment } from "@/gql/graphql"; AssociationIndexView,
import { getClient } from "@/app/client"; loadAssociationIndexProps,
import { AssociationList } from "@/components/associations/AssociationList"; } from "@/components/associations/AssociationIndexView";
import { PageHeader } from "@/components/general/PageHeader";
import { PageContent } from "@/components/general/PageContent";
import { getSeoMetadata } from "@/lib/seo"; import { getSeoMetadata } from "@/lib/seo";
const allAssociationsQuery = graphql(`
query allAssociations {
index: associationIndex {
... on AssociationIndex {
...AssociationIndex
}
}
associations: pages(
contentType: "associations.AssociationPage"
limit: 1000
) {
... on AssociationPage {
...Association
}
}
}
`);
export async function generateMetadata( export async function generateMetadata(
{ params }: { params: Promise<{}> }, _: unknown,
parent: ResolvingMetadata parent: ResolvingMetadata
): Promise<Metadata | null> { ): Promise<Metadata | null> {
const { data, error } = await getClient().query(allAssociationsQuery, {}); const { index } = await loadAssociationIndexProps();
return getSeoMetadata(index, parent);
if (error) {
throw new Error(error.message);
}
if (!data?.index) {
return null;
}
const index = data.index as AssociationIndexFragment;
const metadata = await getSeoMetadata(index, parent);
return metadata;
} }
const AssociationIndexDefinition = graphql(`
fragment AssociationIndex on AssociationIndex {
... on AssociationIndex {
title
seoTitle
searchDescription
lead
body {
...Blocks
}
}
}
`);
const AssociationFragmentDefinition = graphql(`
fragment Association on AssociationPage {
__typename
id
slug
title
seoTitle
searchDescription
excerpt
lead
body {
...Blocks
}
logo {
url
width
height
}
associationType
websiteUrl
}
`);
export default async function Page() { export default async function Page() {
const { data, error } = await getClient().query(allAssociationsQuery, {}); const props = await loadAssociationIndexProps();
return <AssociationIndexView {...props} />;
if (error) {
throw new Error(error.message);
}
if (!data?.associations || !data.index) {
throw new Error("Failed to render /foreninger");
}
const associations = data.associations as AssociationFragment[];
const index = data.index as AssociationIndexFragment;
return (
<main className="site-main" id="main">
<PageHeader heading={index.title} lead={index.lead} />
{index.body && <PageContent blocks={index.body} />}
<AssociationList
associations={associations}
heading="Foreninger og utvalg"
/>
</main>
);
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+9 -62
View File
@@ -1,72 +1,19 @@
import { Metadata, ResolvingMetadata } from "next"; import { Metadata, ResolvingMetadata } from "next";
import { notFound } from "next/navigation"; import {
import { graphql } from "@/gql"; ContactIndexView,
import { ContactIndexFragment } from "@/gql/graphql"; loadContactIndexProps,
import { getClient } from "@/app/client"; } from "@/components/contact/ContactIndexView";
import { PageHeader } from "@/components/general/PageHeader";
import { PageContent } from "@/components/general/PageContent";
import { GeneralContactBlock } from "@/components/blocks/GeneralContactBlock";
import { getSeoMetadata } from "@/lib/seo"; import { getSeoMetadata } from "@/lib/seo";
const contactQuery = graphql(`
query contacts {
index: contactIndex {
... on ContactIndex {
...ContactIndex
}
}
}
`);
const ContactIndexDefinition = graphql(`
fragment ContactIndex on ContactIndex {
... on ContactIndex {
title
seoTitle
searchDescription
lead
body {
...Blocks
}
}
}
`);
export async function generateMetadata( export async function generateMetadata(
{ params }: { params: Promise<{}> }, _: unknown,
parent: ResolvingMetadata parent: ResolvingMetadata
): Promise<Metadata | null> { ): Promise<Metadata | null> {
const { data, error } = await getClient().query(contactQuery, {}); const { index } = await loadContactIndexProps();
return getSeoMetadata(index, parent);
if (error) {
throw new Error(error.message);
}
if (!data?.index) {
return null;
}
const index = data.index as ContactIndexFragment;
const metadata = await getSeoMetadata(index, parent);
return metadata;
} }
export default async function Page() { export default async function Page() {
const { data, error } = await getClient().query(contactQuery, {}); const props = await loadContactIndexProps();
return <ContactIndexView {...props} />;
if (error) {
throw new Error(error.message);
}
if (!data?.index) {
return notFound();
}
const index = data.index as ContactIndexFragment;
return (
<main className="site-main" id="main">
<PageHeader heading={index.title} lead={index.lead} />
<GeneralContactBlock />
{index.body && <PageContent blocks={index.body} />}
</main>
);
} }
+1
View File
@@ -34,6 +34,7 @@ export default function RootLayout({
return ( return (
<html lang="no"> <html lang="no">
<head> <head>
<link rel="preconnect" href="https://use.typekit.net" crossOrigin="anonymous" />
<link rel="stylesheet" href="https://use.typekit.net/spa5smt.css" /> <link rel="stylesheet" href="https://use.typekit.net/spa5smt.css" />
{process.env.UMAMI_SCRIPT_URL && process.env.UMAMI_WEBSITE_ID && ( {process.env.UMAMI_SCRIPT_URL && process.env.UMAMI_WEBSITE_ID && (
<script <script
+10 -58
View File
@@ -1,25 +1,13 @@
import { Metadata, ResolvingMetadata } from "next"; import { Metadata, ResolvingMetadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getClient } from "@/app/client"; import { getClient } from "@/app/client";
import { ImageSliderBlock } from "@/components/blocks/ImageSliderBlock"; import {
import { Breadcrumb } from "@/components/general/Breadcrumb"; VenuePageView,
import { PageContent } from "@/components/general/PageContent"; loadVenuePageProps,
import { NeufMap } from "@/components/venues/NeufMap"; } from "@/components/venues/VenuePageView";
import { VenueInfo } from "@/components/venues/VenueInfo";
import { graphql } from "@/gql"; import { graphql } from "@/gql";
import { VenueFragment } from "@/gql/graphql";
import { getSeoMetadata } from "@/lib/seo"; import { getSeoMetadata } from "@/lib/seo";
const venueBySlugQuery = graphql(`
query venueBySlug($slug: String!) {
venue: page(contentType: "venues.VenuePage", slug: $slug) {
... on VenuePage {
...Venue
}
}
}
`);
type Params = Promise<{ slug: string }>; type Params = Promise<{ slug: string }>;
export async function generateMetadata( export async function generateMetadata(
@@ -27,20 +15,9 @@ export async function generateMetadata(
parent: ResolvingMetadata parent: ResolvingMetadata
): Promise<Metadata | null> { ): Promise<Metadata | null> {
const { slug } = await params; const { slug } = await params;
const { data, error } = await getClient().query(venueBySlugQuery, { const props = await loadVenuePageProps({ slug });
slug, if (!props) return null;
}); return getSeoMetadata(props.venue, parent);
if (error) {
throw new Error(error.message);
}
if (!data?.venue) {
return null;
}
const venue = data.venue as VenueFragment;
const metadata = await getSeoMetadata(venue, parent);
return metadata;
} }
export async function generateStaticParams() { export async function generateStaticParams() {
@@ -70,32 +47,7 @@ export async function generateStaticParams() {
export default async function Page({ params }: { params: Params }) { export default async function Page({ params }: { params: Params }) {
const { slug } = await params; const { slug } = await params;
const { data, error } = await getClient().query(venueBySlugQuery, { const props = await loadVenuePageProps({ slug });
slug, if (!props) return notFound();
}); return <VenuePageView {...props} />;
if (error) {
throw new Error(error.message);
}
if (!data?.venue) {
return notFound();
}
const venue = data.venue as VenueFragment;
const featuredImage: any = venue.featuredImage;
return (
<main className="site-main" id="main">
{venue.images && venue.images.length !== 0 && (
<ImageSliderBlock block={venue.images[0]} hero />
)}
<div className="page-header-small">
<Breadcrumb link="/utleie" text="Lokale" />
<h1 className="page-title">{venue.title}</h1>
</div>
<PageContent blocks={venue.body} />
<VenueInfo venue={venue} />
<NeufMap venueSlug={venue.slug} />
</main>
);
} }
+9 -101
View File
@@ -1,111 +1,19 @@
import { Metadata, ResolvingMetadata } from "next"; import { Metadata, ResolvingMetadata } from "next";
import { graphql } from "@/gql"; import {
import { VenueFragment, VenueIndexFragment } from "@/gql/graphql"; VenueIndexView,
import { getClient } from "@/app/client"; loadVenueIndexProps,
import { VenueList } from "@/components/venues/VenueList"; } from "@/components/venues/VenueIndexView";
import { PageHeader } from "@/components/general/PageHeader";
import { PageContent } from "@/components/general/PageContent";
import { getSeoMetadata } from "@/lib/seo"; import { getSeoMetadata } from "@/lib/seo";
const venueIndexQuery = graphql(`
query venueIndex {
index: venueIndex {
... on VenueIndex {
...VenueIndex
}
}
venues: pages(contentType: "venues.VenuePage", limit: 100) {
... on VenuePage {
...Venue
}
}
}
`);
const VenueIndexDefinition = graphql(`
fragment VenueIndex on VenueIndex {
... on VenueIndex {
title
seoTitle
searchDescription
lead
body {
...Blocks
}
}
}
`);
const VenueFragmentDefinition = graphql(`
fragment Venue on VenuePage {
__typename
id
slug
title
seoTitle
searchDescription
images {
...Blocks
}
body {
...Blocks
}
featuredImage {
...Image
}
showAsBookable
showInOverview
floor
preposition
usedFor
techSpecsUrl
capabilityAudio
capabilityAudioVideo
capabilityBar
capabilityLighting
capacityLegal
capacityStanding
capacitySitting
}
`);
export async function generateMetadata( export async function generateMetadata(
{ params }: { params: Promise<{}> }, _: unknown,
parent: ResolvingMetadata parent: ResolvingMetadata
): Promise<Metadata | null> { ): Promise<Metadata | null> {
const { data, error } = await getClient().query(venueIndexQuery, {}); const { index } = await loadVenueIndexProps();
return getSeoMetadata(index, parent);
if (error) {
throw new Error(error.message);
}
if (!data?.index) {
return null;
}
const index = (data?.index ?? []) as VenueIndexFragment;
const metadata = await getSeoMetadata(index, parent);
return metadata;
} }
export default async function Page() { export default async function Page() {
const { data, error } = await getClient().query(venueIndexQuery, {}); const props = await loadVenueIndexProps();
return <VenueIndexView {...props} />;
if (error) {
throw new Error(error.message);
}
if (!data?.index || !data?.venues) {
throw new Error("Failed to render /lokaler");
}
const index = data.index as VenueIndexFragment;
const venues = (data?.venues ?? []) as VenueFragment[];
const visibleVenues = venues.filter((x) => x.showInOverview);
return (
<main className="site-main" id="main">
<PageHeader heading={index.title} lead={index.lead} />
<PageContent blocks={index.body} />
<VenueList venues={visibleVenues} />
</main>
);
} }
+6 -94
View File
@@ -1,97 +1,9 @@
import { graphql } from "@/gql"; import {
import { EventFragment } from "@/lib/event"; HomePageView,
import { NewsFragment } from "@/lib/news"; loadHomePageProps,
import { HomeFragment } from "@/gql/graphql"; } from "@/components/home/HomePageView";
import { getClient } from "@/app/client";
import { FeaturedEvents } from "@/components/events/FeaturedEvents";
import { NewsList } from "@/components/news/NewsList";
import { Newsletter } from "@/components/general/Newsletter";
import { UpcomingEvents } from "@/components/events/UpcomingEvents";
import { Pig } from "@/components/general/Pig";
import Link from "next/link";
import { Icon } from "@/components/general/Icon";
import { SectionHeader } from "@/components/general/SectionHeader";
import { SectionFooter } from "@/components/general/SectionFooter";
const HomeFragmentDefinition = graphql(`
fragment Home on HomePage {
... on HomePage {
featuredEvents {
id
}
}
}
`);
export default async function Home() { export default async function Home() {
const homeQuery = graphql(` const props = await loadHomePageProps();
query home { return <HomePageView {...props} />;
events: eventIndex {
... on EventIndex {
futureEvents {
... on EventPage {
...Event
}
}
}
}
home: page(contentType: "home.HomePage", urlPath: "/home/") {
... on HomePage {
...Home
}
}
news: pages(contentType: "news.newsPage", order: "-first_published_at", limit: 4) {
... on NewsPage {
...News
}
}
}
`);
const { data, error } = await getClient().query(homeQuery, {});
const home = (data?.home ?? []) as HomeFragment;
const events = (data?.events?.futureEvents ?? []) as EventFragment[];
const news = (data?.news ?? []) as NewsFragment[];
const featuredEventIds = home.featuredEvents.map((x) => x.id);
const featuredEvents = [
...events.filter((x) => featuredEventIds.includes(x.id)),
...events.filter((x) => !featuredEventIds.includes(x.id)),
];
return (
<>
<main className="site-main index" id="main">
<FeaturedEvents events={featuredEvents} />
<UpcomingEvents events={events} />
<div className="infoBlock">
<SectionHeader heading="Besøk oss" link="/praktisk" linkText="Praktisk info" />
<div>
<h2 className="title">Skal du besøke Chateau Neuf?</h2>
<p>
Vi hjelper deg med å finne frem, og sørger for at du har en fin
opplevelse.
</p>
<Link href="/praktisk#adkomst" className="button">
<span>Adresse og adkomst</span>
<Icon type="arrowRight" />
</Link>
<Link href="/praktisk#billetter" className="button">
<span>Billetter</span>
<Icon type="arrowRight" />
</Link>
<Link href="/praktisk#apningstider" className="button">
<span>Åpningstider</span>
<Icon type="arrowRight" />
</Link>
</div>
<div className="pig">
<Pig type="point" />
</div>
<SectionFooter link="/praktisk" linkText="Praktisk info" />
</div>
<NewsList heading="Siste nytt" featured news={news} />
</main>
<Newsletter />
</>
);
} }
+256
View File
@@ -0,0 +1,256 @@
import { getClient } from "@/app/client";
import { PreviewBanner } from "@/components/general/PreviewBanner";
import {
AssociationIndexView,
loadAssociationIndexProps,
} from "@/components/associations/AssociationIndexView";
import {
AssociationPageView,
loadAssociationPageProps,
} from "@/components/associations/AssociationPageView";
import {
ContactIndexView,
loadContactIndexProps,
} from "@/components/contact/ContactIndexView";
import {
EventIndexView,
loadEventIndexProps,
} from "@/components/events/EventIndexView";
import {
EventPageView,
loadEventPageProps,
} from "@/components/events/EventPageView";
import {
GenericPageView,
loadGenericPageProps,
} from "@/components/general/GenericPageView";
import {
HomePageView,
loadHomePageProps,
} from "@/components/home/HomePageView";
import {
NewsIndexView,
loadNewsIndexProps,
} from "@/components/news/NewsIndexView";
import {
NewsPageView,
loadNewsPageProps,
} from "@/components/news/NewsPageView";
import {
SponsorsPageView,
loadSponsorsPageProps,
} from "@/components/sponsor/SponsorsPageView";
import {
StudioPageView,
loadStudioPageProps,
} from "@/components/studio/StudioPageView";
import {
VenueIndexView,
loadVenueIndexProps,
} from "@/components/venues/VenueIndexView";
import {
VenuePageView,
loadVenuePageProps,
} from "@/components/venues/VenuePageView";
import {
VenueRentalIndexView,
loadVenueRentalIndexProps,
} from "@/components/venues/VenueRentalIndexView";
import { graphql } from "@/gql";
import {
AssociationFragment,
AssociationIndexFragment,
ContactIndexFragment,
EventFragment,
GenericFragment,
HomeFragment,
NewsFragment,
NewsIndexFragment,
SponsorsPageFragment,
StudioFragment,
VenueFragment,
VenueIndexFragment,
VenueRentalIndexFragment,
} from "@/gql/graphql";
import { cookies } from "next/headers";
export const dynamic = "force-dynamic";
export const revalidate = 0;
const previewPageQuery = graphql(`
query previewPage($token: String!) {
page: page(token: $token) {
__typename
... on GenericPage {
...Generic
}
... on StudioPage {
...Studio
}
... on SponsorsPage {
...SponsorsPage
}
... on HomePage {
...Home
}
... on EventPage {
...Event
}
... on NewsPage {
...News
}
... on AssociationPage {
...Association
}
... on VenuePage {
...Venue
}
... on NewsIndex {
...NewsIndex
}
... on AssociationIndex {
...AssociationIndex
}
... on VenueIndex {
...VenueIndex
}
... on VenueRentalIndex {
...VenueRentalIndex
}
... on ContactIndex {
...ContactIndex
}
}
}
`);
function ExpiredPreview() {
return (
<main className="site-main" id="main">
<h1>Preview session expired</h1>
<p>Click Preview again in the Wagtail admin to start a new session.</p>
</main>
);
}
function UnsupportedType({ typename }: { typename: string }) {
return (
<main className="site-main" id="main">
<h1>Preview not available</h1>
<p>
Type <code>{typename}</code> cannot be previewed.
</p>
</main>
);
}
export default async function PreviewRender() {
const token = (await cookies()).get("preview-token")?.value;
if (!token) {
return <ExpiredPreview />;
}
const { data, error } = await getClient().query(previewPageQuery, { token });
if (error) {
throw new Error(error.message);
}
if (!data?.page) {
return <ExpiredPreview />;
}
const page = data.page;
const view = await (async () => {
switch (page.__typename) {
case "GenericPage": {
const props = await loadGenericPageProps({
pageOverride: page as GenericFragment,
});
return <GenericPageView {...props!} />;
}
case "StudioPage": {
const props = await loadStudioPageProps({
pageOverride: page as StudioFragment,
});
return <StudioPageView {...props} />;
}
case "SponsorsPage": {
const props = await loadSponsorsPageProps({
pageOverride: page as SponsorsPageFragment,
});
return <SponsorsPageView {...props} />;
}
case "EventPage": {
const props = await loadEventPageProps({
eventOverride: page as EventFragment,
});
return <EventPageView {...props!} />;
}
case "NewsPage": {
const props = await loadNewsPageProps({
newsOverride: page as NewsFragment,
});
return <NewsPageView {...props!} />;
}
case "AssociationPage": {
const props = await loadAssociationPageProps({
associationOverride: page as AssociationFragment,
});
return <AssociationPageView {...props!} />;
}
case "VenuePage": {
const props = await loadVenuePageProps({
venueOverride: page as VenueFragment,
});
return <VenuePageView {...props!} />;
}
case "HomePage": {
const props = await loadHomePageProps({
homeOverride: page as HomeFragment,
});
return <HomePageView {...props} />;
}
case "EventIndex": {
const props = await loadEventIndexProps();
return <EventIndexView {...props} />;
}
case "NewsIndex": {
const props = await loadNewsIndexProps({
indexOverride: page as NewsIndexFragment,
});
return <NewsIndexView {...props} />;
}
case "AssociationIndex": {
const props = await loadAssociationIndexProps({
indexOverride: page as AssociationIndexFragment,
});
return <AssociationIndexView {...props} />;
}
case "VenueIndex": {
const props = await loadVenueIndexProps({
indexOverride: page as VenueIndexFragment,
});
return <VenueIndexView {...props} />;
}
case "VenueRentalIndex": {
const props = await loadVenueRentalIndexProps({
indexOverride: page as VenueRentalIndexFragment,
});
return <VenueRentalIndexView {...props} />;
}
case "ContactIndex": {
const props = await loadContactIndexProps({
indexOverride: page as ContactIndexFragment,
});
return <ContactIndexView {...props} />;
}
default:
return <UnsupportedType typename={page.__typename ?? "unknown"} />;
}
})();
return (
<>
<PreviewBanner />
{view}
</>
);
}
+9 -72
View File
@@ -1,82 +1,19 @@
import { Metadata, ResolvingMetadata } from "next"; import { Metadata, ResolvingMetadata } from "next";
import { graphql } from "@/gql"; import {
import { SponsorsPage, SponsorBlock } from "@/gql/graphql"; SponsorsPageView,
import { getClient } from "@/app/client"; loadSponsorsPageProps,
import { PageHeader } from "@/components/general/PageHeader"; } from "@/components/sponsor/SponsorsPageView";
import { PageContent } from "@/components/general/PageContent";
import { getSeoMetadata } from "@/lib/seo"; import { getSeoMetadata } from "@/lib/seo";
import { SponsorList } from "@/components/sponsor/SponsorList";
const sponsorsPageQuery = graphql(`
query sponsors {
page: sponsorsPage {
... on SponsorsPage {
...SponsorsPage
}
}
}
`);
export async function generateMetadata( export async function generateMetadata(
{ params }: { params: Promise<{}> }, _: unknown,
parent: ResolvingMetadata parent: ResolvingMetadata
): Promise<Metadata | null> { ): Promise<Metadata | null> {
const { data, error } = await getClient().query(sponsorsPageQuery, {}); const { page } = await loadSponsorsPageProps();
return getSeoMetadata(page, parent);
if (error) {
throw new Error(error.message);
}
if (!data?.page) {
return null;
}
const index = data.page as SponsorsPage;
const metadata = await getSeoMetadata(index, parent);
return metadata;
} }
const SponsorsPageFragmentDefinition = graphql(`
fragment SponsorsPage on SponsorsPage {
... on SponsorsPage {
title
seoTitle
searchDescription
lead
body {
...Blocks
}
sponsors {
... on SponsorBlock {
id
name
logo {
...Image
}
text
website
}
}
}
}
`);
export default async function Page() { export default async function Page() {
const { data, error } = await getClient().query(sponsorsPageQuery, {}); const props = await loadSponsorsPageProps();
return <SponsorsPageView {...props} />;
if (error) {
throw new Error(error.message);
}
if (!data?.page) {
throw new Error("Failed to render /sponsorer");
}
const page = data.page as SponsorsPage;
return (
<main className="site-main" id="main">
<PageHeader heading={page.title} lead={page.lead} />
{page.body && <PageContent blocks={page.body} />}
<SponsorList sponsors={page.sponsors as SponsorBlock[]} />
</main>
);
} }
+19
View File
@@ -0,0 +1,19 @@
import { Metadata, ResolvingMetadata } from "next";
import {
StudioPageView,
loadStudioPageProps,
} from "@/components/studio/StudioPageView";
import { getSeoMetadata } from "@/lib/seo";
export async function generateMetadata(
_: unknown,
parent: ResolvingMetadata
): Promise<Metadata | null> {
const { page } = await loadStudioPageProps();
return getSeoMetadata(page, parent);
}
export default async function Page() {
const props = await loadStudioPageProps();
return <StudioPageView {...props} />;
}
+9 -72
View File
@@ -1,82 +1,19 @@
import { Metadata, ResolvingMetadata } from "next"; import { Metadata, ResolvingMetadata } from "next";
import { graphql } from "@/gql"; import {
import { VenueFragment, VenueRentalIndexFragment } from "@/gql/graphql"; VenueRentalIndexView,
import { getClient } from "@/app/client"; loadVenueRentalIndexProps,
import { VenueList } from "@/components/venues/VenueList"; } from "@/components/venues/VenueRentalIndexView";
import { PageHeader } from "@/components/general/PageHeader";
import { BgPig } from "@/components/general/BgPig";
import { PageContent } from "@/components/general/PageContent";
import { getSeoMetadata } from "@/lib/seo"; import { getSeoMetadata } from "@/lib/seo";
const venueRentalIndexQuery = graphql(`
query venueRentalIndex {
index: venueRentalIndex {
... on VenueRentalIndex {
...VenueRentalIndex
}
}
venues: pages(contentType: "venues.VenuePage", limit: 100) {
... on VenuePage {
...Venue
}
}
}
`);
const VenueRentalIndexDefinition = graphql(`
fragment VenueRentalIndex on VenueRentalIndex {
... on VenueRentalIndex {
title
seoTitle
searchDescription
lead
body {
...Blocks
}
}
}
`);
export async function generateMetadata( export async function generateMetadata(
{ params }: { params: Promise<{}> }, _: unknown,
parent: ResolvingMetadata parent: ResolvingMetadata
): Promise<Metadata | null> { ): Promise<Metadata | null> {
const { data, error } = await getClient().query(venueRentalIndexQuery, {}); const { index } = await loadVenueRentalIndexProps();
return getSeoMetadata(index, parent);
if (error) {
throw new Error(error.message);
}
if (!data?.index) {
return null;
}
const index = data.index as VenueRentalIndexFragment;
const metadata = await getSeoMetadata(index, parent);
return metadata;
} }
export default async function Page() { export default async function Page() {
const { data, error } = await getClient().query(venueRentalIndexQuery, {}); const props = await loadVenueRentalIndexProps();
return <VenueRentalIndexView {...props} />;
if (error) {
throw new Error(error.message);
}
if (!data?.index || !data?.venues) {
throw new Error("Failed to render /utleie");
}
const index = data.index as VenueRentalIndexFragment;
const venues = (data?.venues ?? []) as VenueFragment[];
const bookableVenues = venues.filter((venue) => venue.showAsBookable);
return (
<>
<main className="site-main" id="main">
<PageHeader heading={index.title} lead={index.lead} />
{index.body && <PageContent blocks={index.body} />}
<VenueList venues={bookableVenues} heading="Våre lokaler" />
</main>
<BgPig type="key" />
</>
);
} }

Some files were not shown because too many files have changed in this diff Show More