Compare commits

Author SHA1 Message Date
leander 2b8197e8a2 generate migrations for photosphere support 2026-04-15 23:44:18 +02:00
leander 3dd2c056b8 add 360 photosphere element support 2026-04-15 23:43:47 +02:00
255 changed files with 14758 additions and 17191 deletions
-1
View File
@@ -1,4 +1,3 @@
.vscode .vscode
.DS_Store .DS_Store
*.swp *.swp
scratch/
-52
View File
@@ -1,52 +0,0 @@
# 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
npm test # vitest (npm run test:watch for watch mode)
```
## Pre-commit hooks
[prek](https://github.com/j178/prek) runs ruff lint + format on `dnscms/**/*.py`, vitest on `web/` changes, 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
```
## Caching and revalidation
The frontend caches all pages and GraphQL responses (ISR) under a single `cms` cache tag.
Wagtail purges it via a webhook on any publish/unpublish/move/delete (see `dnscms/dnscms/revalidation.py`).
Date-sensitive queries (`futureEvents`, opening hours) additionally expire at Oslo midnight.
`REVALIDATE_WEBHOOK_SECRET` must be set to the same value on both sides
(`web/.env.local` and `dnscms/dnscms/settings/local.py` or env); unset disables the webhook.
```bash
# manual purge
curl -X POST -H "X-Revalidate-Secret: $SECRET" https://<frontend>/api/revalidate
```
Note: ISR is off under `npm run dev`; use `npm run build && npm run start` to test caching.
-1
View File
@@ -2,7 +2,6 @@
.DS_Store .DS_Store
*.swp *.swp
.vscode/ .vscode/
.coverage
/venv/ /venv/
/.venv/ /.venv/
/static/ /static/
-57
View File
@@ -1,57 +0,0 @@
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, PageViewSet
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 AssociationListingMixin:
"""Shared model + columns for the standalone listing and the page explorer."""
model = AssociationPage
icon = "group"
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%"),
]
class AssociationSidebarViewSet(AssociationListingMixin, PageListingViewSet):
"""Standalone 'Associations' sidebar entry, reached independently of the page tree."""
choose_parent_view_class = AssociationChooseParentView
menu_label = _("Associations")
menu_order = 2
add_to_admin_menu = True
ordering = "title"
class AssociationExplorerViewSet(AssociationListingMixin, PageViewSet):
"""Applies the same columns when navigating into AssociationIndex via the page explorer."""
association_sidebar_viewset = AssociationSidebarViewSet("associations")
association_explorer_viewset = AssociationExplorerViewSet()
@@ -1,26 +0,0 @@
# 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),
),
]
@@ -0,0 +1,492 @@
# Generated by Django 6.0.3 on 2026-04-15 20:21
import wagtail.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("associations", "0025_associationpage_lead"),
]
operations = [
migrations.AlterField(
model_name="associationindex",
name="body",
field=wagtail.fields.StreamField(
[
("paragraph", 0),
("image", 4),
("image_slider", 8),
("horizontal_rule", 10),
("featured", 18),
("page_section_navigation", 19),
("accordion", 23),
("fact_box", 26),
("photo_sphere", 29),
("embed", 30),
("raw_html", 31),
],
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.images.blocks.ImageChooserBlock",
(),
{"label": "360°-bilde"},
),
28: (
"wagtail.blocks.CharBlock",
(),
{
"help_text": "Beskrivende tittel på bildet (vises også til skjermlesere)",
"label": "Tittel",
"max_length": 256,
},
),
29: (
"wagtail.blocks.StructBlock",
[[("image", 27), ("title", 28)]],
{},
),
30: ("wagtail.embeds.blocks.EmbedBlock", (), {}),
31: ("wagtail.blocks.RawHTMLBlock", (), {}),
},
default=[("paragraph", "")],
),
),
migrations.AlterField(
model_name="associationpage",
name="body",
field=wagtail.fields.StreamField(
[
("paragraph", 0),
("image", 4),
("image_slider", 8),
("horizontal_rule", 10),
("featured", 18),
("page_section_navigation", 19),
("accordion", 23),
("fact_box", 26),
("photo_sphere", 29),
("embed", 30),
("raw_html", 31),
],
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.images.blocks.ImageChooserBlock",
(),
{"label": "360°-bilde"},
),
28: (
"wagtail.blocks.CharBlock",
(),
{
"help_text": "Beskrivende tittel på bildet (vises også til skjermlesere)",
"label": "Tittel",
"max_length": 256,
},
),
29: (
"wagtail.blocks.StructBlock",
[[("image", 27), ("title", 28)]],
{},
),
30: ("wagtail.embeds.blocks.EmbedBlock", (), {}),
31: ("wagtail.blocks.RawHTMLBlock", (), {}),
},
default=[("paragraph", "")],
),
),
]
@@ -1,24 +0,0 @@
# Generated by Django 6.0.5 on 2026-07-02 14:53
import wagtail.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('associations', '0026_alter_association_options'),
]
operations = [
migrations.AlterField(
model_name='associationindex',
name='body',
field=wagtail.fields.StreamField([('paragraph', 0), ('image', 4), ('image_slider', 8), ('horizontal_rule', 10), ('featured', 18), ('page_section_navigation', 19), ('accordion', 23), ('fact_box', 26), ('schedule', 33), ('embed', 34), ('raw_html', 35)], 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.blocks.CharBlock', (), {'help_text': 'Overskrift for timeplanen (valgfritt)', 'label': 'Tittel', 'max_length': 64, 'required': False}), 28: ('wagtail.blocks.CharBlock', (), {'help_text': 'Fritekst, f.eks. "13:00", "20:00 23:00" eller "23.06"', 'label': 'Tidspunkt/dato', 'max_length': 64, 'required': True}), 29: ('wagtail.blocks.RichTextBlock', (), {'features': ['bold', 'italic', 'link'], 'help_text': 'Kort tittel på én linje. Du kan legge inn lenker.', 'label': 'Tittel', 'required': True}), 30: ('wagtail.blocks.RichTextBlock', (), {'features': ['bold', 'italic', 'link'], 'help_text': 'Valgfri tilleggsinformasjon.', 'label': 'Beskrivelse', 'required': False}), 31: ('wagtail.blocks.StructBlock', [[('time', 28), ('title', 29), ('description', 30)]], {}), 32: ('wagtail.blocks.ListBlock', (31,), {'label': 'Punkter', 'min_num': 1}), 33: ('wagtail.blocks.StructBlock', [[('title', 27), ('items', 32)]], {}), 34: ('wagtail.embeds.blocks.EmbedBlock', (), {}), 35: ('wagtail.blocks.RawHTMLBlock', (), {})}, default=[('paragraph', '')]),
),
migrations.AlterField(
model_name='associationpage',
name='body',
field=wagtail.fields.StreamField([('paragraph', 0), ('image', 4), ('image_slider', 8), ('horizontal_rule', 10), ('featured', 18), ('page_section_navigation', 19), ('accordion', 23), ('fact_box', 26), ('schedule', 33), ('embed', 34), ('raw_html', 35)], 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.blocks.CharBlock', (), {'help_text': 'Overskrift for timeplanen (valgfritt)', 'label': 'Tittel', 'max_length': 64, 'required': False}), 28: ('wagtail.blocks.CharBlock', (), {'help_text': 'Fritekst, f.eks. "13:00", "20:00 23:00" eller "23.06"', 'label': 'Tidspunkt/dato', 'max_length': 64, 'required': True}), 29: ('wagtail.blocks.RichTextBlock', (), {'features': ['bold', 'italic', 'link'], 'help_text': 'Kort tittel på én linje. Du kan legge inn lenker.', 'label': 'Tittel', 'required': True}), 30: ('wagtail.blocks.RichTextBlock', (), {'features': ['bold', 'italic', 'link'], 'help_text': 'Valgfri tilleggsinformasjon.', 'label': 'Beskrivelse', 'required': False}), 31: ('wagtail.blocks.StructBlock', [[('time', 28), ('title', 29), ('description', 30)]], {}), 32: ('wagtail.blocks.ListBlock', (31,), {'label': 'Punkter', 'min_num': 1}), 33: ('wagtail.blocks.StructBlock', [[('title', 27), ('items', 32)]], {}), 34: ('wagtail.embeds.blocks.EmbedBlock', (), {}), 35: ('wagtail.blocks.RawHTMLBlock', (), {})}, default=[('paragraph', '')]),
),
]
File diff suppressed because one or more lines are too long
@@ -1,24 +0,0 @@
# Generated by Django 6.0.5 on 2026-07-04 22:29
import wagtail.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('associations', '0028_alter_associationindex_body_and_more'),
]
operations = [
migrations.AlterField(
model_name='associationindex',
name='body',
field=wagtail.fields.StreamField([('paragraph', 0), ('image', 4), ('image_slider', 8), ('horizontal_rule', 10), ('featured', 18), ('page_section_navigation', 19), ('accordion', 23), ('fact_box', 26), ('schedule', 33), ('embed', 34), ('raw_html', 35)], 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.blocks.CharBlock', (), {'help_text': 'Overskrift for timeplanen (valgfritt)', 'label': 'Tittel', 'max_length': 64, 'required': False}), 28: ('wagtail.blocks.CharBlock', (), {'help_text': 'Fritekst, f.eks. "13:00", "20:00 23:00" eller "23.06"', 'label': 'Tidspunkt/dato', 'max_length': 64, 'required': True}), 29: ('wagtail.blocks.RichTextBlock', (), {'features': ['bold', 'italic', 'link'], 'help_text': 'Kort tittel på én linje. Du kan legge inn lenker.', 'label': 'Tittel', 'required': True}), 30: ('wagtail.blocks.RichTextBlock', (), {'features': ['bold', 'italic', 'link'], 'help_text': 'Valgfri tilleggsinformasjon.', 'label': 'Beskrivelse', 'required': False}), 31: ('wagtail.blocks.StructBlock', [[('time', 28), ('title', 29), ('description', 30)]], {}), 32: ('wagtail.blocks.ListBlock', (31,), {'label': 'Punkter', 'min_num': 1}), 33: ('wagtail.blocks.StructBlock', [[('title', 27), ('items', 32)]], {}), 34: ('wagtail.embeds.blocks.EmbedBlock', (), {}), 35: ('wagtail.blocks.RawHTMLBlock', (), {})}, default=[('paragraph', '')]),
),
migrations.AlterField(
model_name='associationpage',
name='body',
field=wagtail.fields.StreamField([('paragraph', 0), ('image', 4), ('image_slider', 8), ('horizontal_rule', 10), ('featured', 18), ('page_section_navigation', 19), ('accordion', 23), ('fact_box', 26), ('schedule', 33), ('embed', 34), ('raw_html', 35)], 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.blocks.CharBlock', (), {'help_text': 'Overskrift for timeplanen (valgfritt)', 'label': 'Tittel', 'max_length': 64, 'required': False}), 28: ('wagtail.blocks.CharBlock', (), {'help_text': 'Fritekst, f.eks. "13:00", "20:00 23:00" eller "23.06"', 'label': 'Tidspunkt/dato', 'max_length': 64, 'required': True}), 29: ('wagtail.blocks.RichTextBlock', (), {'features': ['bold', 'italic', 'link'], 'help_text': 'Kort tittel på én linje. Du kan legge inn lenker.', 'label': 'Tittel', 'required': True}), 30: ('wagtail.blocks.RichTextBlock', (), {'features': ['bold', 'italic', 'link'], 'help_text': 'Valgfri tilleggsinformasjon.', 'label': 'Beskrivelse', 'required': False}), 31: ('wagtail.blocks.StructBlock', [[('time', 28), ('title', 29), ('description', 30)]], {}), 32: ('wagtail.blocks.ListBlock', (31,), {'label': 'Punkter', 'min_num': 1}), 33: ('wagtail.blocks.StructBlock', [[('title', 27), ('items', 32)]], {}), 34: ('wagtail.embeds.blocks.EmbedBlock', (), {}), 35: ('wagtail.blocks.RawHTMLBlock', (), {})}, default=[('paragraph', '')]),
),
]
+43 -29
View File
@@ -1,5 +1,4 @@
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,
@@ -9,20 +8,15 @@ 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, PageManager from wagtail.models import Page
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 DeferWPFieldsManagerMixin, WPImportedPageMixin from dnscms.wordpress.models import WPImportedPageMixin
class AssociationPageManager(DeferWPFieldsManagerMixin, PageManager):
pass
@register_singular_query_field("associationIndex") @register_singular_query_field("associationIndex")
class AssociationIndex(HeadlessMixin, Page): class AssociationIndex(Page):
max_count = 1 max_count = 1
subpage_types = ["associations.AssociationPage"] subpage_types = ["associations.AssociationPage"]
@@ -31,8 +25,8 @@ class AssociationIndex(HeadlessMixin, Page):
body = CommonStreamField body = CommonStreamField
content_panels = Page.content_panels + [ content_panels = Page.content_panels + [
FieldPanel("lead", heading=_("Lead")), FieldPanel("lead", heading="Ingress"),
FieldPanel("body", heading=_("Content")), FieldPanel("body", heading="Innhold"),
] ]
graphql_fields = [ graphql_fields = [
@@ -42,21 +36,15 @@ class AssociationIndex(HeadlessMixin, 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", _("Association") FORENING = "forening", "Forening"
UTVALG = "utvalg", _("Committee") UTVALG = "utvalg", "Utvalg"
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)
@@ -76,14 +64,14 @@ class AssociationPage(HeadlessMixin, WPImportedPageMixin, Page):
content_panels = Page.content_panels + [ content_panels = Page.content_panels + [
FieldPanel( FieldPanel(
"excerpt", "excerpt",
heading=_("Excerpt"), heading="Utdrag",
help_text=_("A very short summary of the content below. Used in listing views."), help_text="En veldig kort oppsummering av innholdet nedenfor. Brukes i listevisninger.",
), ),
FieldPanel("lead", heading=_("Lead")), FieldPanel("lead", heading="Ingress"),
FieldPanel("body", heading=_("Content")), FieldPanel("body", heading="Innhold"),
FieldPanel("logo"), FieldPanel("logo"),
FieldPanel("association_type", heading=_("Type")), FieldPanel("association_type", heading="Type"),
FieldPanel("website_url", heading=_("Website")), FieldPanel("website_url", heading="Nettside"),
] ]
graphql_fields = [ graphql_fields = [
@@ -100,6 +88,32 @@ class AssociationPage(HeadlessMixin, WPImportedPageMixin, Page):
index.SearchField("body"), index.SearchField("body"),
] ]
class Meta: def import_wordpress_data(self, data):
verbose_name = _("association") import html
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
@@ -0,0 +1,2 @@
# Create your tests here.
+3 -4
View File
@@ -1,13 +1,12 @@
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"]
-11
View File
@@ -1,19 +1,8 @@
from wagtail import hooks from wagtail import hooks
from .admin import association_sidebar_viewset, association_explorer_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_sidebar_viewset():
return association_sidebar_viewset
@hooks.register("register_admin_viewset")
def register_association_explorer_viewset():
return association_explorer_viewset
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -2
View File
@@ -11,7 +11,6 @@ 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
@@ -23,7 +22,7 @@ PHONE_REGEX_VALIDATOR = RegexValidator(
@register_singular_query_field("contactIndex") @register_singular_query_field("contactIndex")
class ContactIndex(HeadlessMixin, Page): class ContactIndex(Page):
max_count = 1 max_count = 1
subpage_types = [] subpage_types = []
-27
View File
@@ -1,27 +0,0 @@
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))
-11
View File
@@ -1,11 +0,0 @@
from django.apps import AppConfig
class DnsCmsConfig(AppConfig):
name = "dnscms"
def ready(self):
from dnscms import signals # noqa: F401
from dnscms.revalidation import register_signal_handlers
register_signal_handlers()
+20 -62
View File
@@ -203,6 +203,25 @@ class AccordionBlock(blocks.StructBlock):
label = "Trekkspill" label = "Trekkspill"
@register_streamfield_block
class PhotoSphereBlock(blocks.StructBlock):
image = ImageChooserBlock(label="360°-bilde")
title = blocks.CharBlock(
max_length=512,
label="Bildetekst",
required=False,
)
graphql_fields = [
GraphQLImage("image", required=True),
GraphQLString("title", required=False),
]
class Meta:
icon = "globe"
label = "360°-bilde"
@register_streamfield_block @register_streamfield_block
class FactBoxBlock(blocks.StructBlock): class FactBoxBlock(blocks.StructBlock):
background_color = blocks.ChoiceBlock( background_color = blocks.ChoiceBlock(
@@ -225,62 +244,6 @@ class FactBoxBlock(blocks.StructBlock):
label = "Faktaboks" label = "Faktaboks"
@register_streamfield_block
class ScheduleItemBlock(blocks.StructBlock):
time = blocks.CharBlock(
label="Tidspunkt/dato",
required=True,
max_length=64,
help_text='Fritekst, f.eks. "13:00", "20:00 23:00" eller "23.06"',
)
title = blocks.RichTextBlock(
features=["bold", "italic", "link"],
label="Tittel",
required=True,
help_text="Kort tittel på én linje. Du kan legge inn lenker.",
)
description = blocks.RichTextBlock(
features=["bold", "italic", "link"],
label="Beskrivelse",
required=False,
help_text="Valgfri tilleggsinformasjon.",
)
graphql_fields = [
GraphQLString("time", required=True),
GraphQLRichText("title", required=True),
GraphQLRichText("description"),
]
class Meta:
icon = "time"
label = "Punkt"
@register_streamfield_block
class ScheduleBlock(blocks.StructBlock):
title = blocks.CharBlock(
max_length=64,
required=False,
label="Tittel",
help_text="Overskrift for timeplanen (valgfritt)",
)
items = blocks.ListBlock(
ScheduleItemBlock(),
min_num=1,
label="Punkter",
)
graphql_fields = [
GraphQLString("title"),
GraphQLStreamfield("items", required=True),
]
class Meta:
icon = "time"
label = "Timeplan"
BASE_BLOCKS = [ BASE_BLOCKS = [
("paragraph", blocks.RichTextBlock(label="Rik tekst")), ("paragraph", blocks.RichTextBlock(label="Rik tekst")),
("image", ImageWithTextBlock()), ("image", ImageWithTextBlock()),
@@ -290,7 +253,7 @@ BASE_BLOCKS = [
("page_section_navigation", PageSectionNavigationBlock()), ("page_section_navigation", PageSectionNavigationBlock()),
("accordion", AccordionBlock()), ("accordion", AccordionBlock()),
("fact_box", FactBoxBlock()), ("fact_box", FactBoxBlock()),
("schedule", ScheduleBlock()), ("photo_sphere", PhotoSphereBlock()),
("embed", EmbedBlock()), ("embed", EmbedBlock()),
("raw_html", blocks.RawHTMLBlock()), ("raw_html", blocks.RawHTMLBlock()),
] ]
@@ -310,11 +273,6 @@ class PageSectionBlock(blocks.StructBlock):
("clock", "Klokke"), ("clock", "Klokke"),
("parking", "Parkering"), ("parking", "Parkering"),
("coins", "Mynter"), ("coins", "Mynter"),
("studioNote", "Note (STUDiO)"),
("studioCalendar", "Kalender (STUDiO)"),
("studioGuitar", "Gitar (STUDiO)"),
("studioDisco", "Discokule (STUDiO)"),
("studioMole", "Muldvarpen (STUDiO)"),
) )
title = blocks.CharBlock(max_length=64, required=True, label="Tittel") title = blocks.CharBlock(max_length=64, required=True, label="Tittel")
-97
View File
@@ -1,97 +0,0 @@
"""Purge the frontend cache when content changes.
The frontend caches all GraphQL responses under a single "cms" cache tag;
any content change POSTs to its /api/revalidate endpoint for a global purge.
"""
import logging
import threading
import urllib.request
from django.conf import settings
from django.db import transaction
from django.db.models.signals import post_delete, post_save
logger = logging.getLogger(__name__)
WEBHOOK_TIMEOUT_SECONDS = 5
# Non-page models exposed over GraphQL; pages are covered by wagtail signals
REVALIDATING_MODELS = [
"contacts.ContactEntity",
"events.EventCategory",
"events.EventOrganizer",
"images.CustomImage",
"openinghours.OpeningHoursSet",
]
_warned_unconfigured = False
def _send_webhook(url, secret, reason):
request = urllib.request.Request(
url, data=b"", method="POST", headers={"X-Revalidate-Secret": secret}
)
try:
with urllib.request.urlopen(request, timeout=WEBHOOK_TIMEOUT_SECONDS) as response:
logger.info("frontend revalidation ok (%s): HTTP %s", reason, response.status)
except Exception:
logger.exception("frontend revalidation failed (%s)", reason)
def trigger_frontend_revalidation(reason):
global _warned_unconfigured
secret = settings.REVALIDATE_WEBHOOK_SECRET
if not secret:
if not _warned_unconfigured:
logger.warning("REVALIDATE_WEBHOOK_SECRET is not set, skipping frontend revalidation")
_warned_unconfigured = True
return
url = f"{settings.FRONTEND_BASE_URL}/api/revalidate"
# After commit so the frontend can't re-render from pre-commit data,
# in a thread so publishing never blocks on the frontend
transaction.on_commit(
lambda: threading.Thread(
target=_send_webhook, args=(url, secret, reason), daemon=True
).start()
)
def _on_page_published(sender, instance, **kwargs):
trigger_frontend_revalidation(f"page_published: {instance.slug}")
def _on_page_unpublished(sender, instance, **kwargs):
trigger_frontend_revalidation(f"page_unpublished: {instance.slug}")
def _on_page_moved(sender, instance, **kwargs):
trigger_frontend_revalidation(f"post_page_move: {instance.slug}")
def _on_page_deleted(sender, instance, **kwargs):
trigger_frontend_revalidation(f"page_deleted: {instance.slug}")
def _on_model_changed(sender, instance, **kwargs):
trigger_frontend_revalidation(f"{sender._meta.label}: {instance}")
def register_signal_handlers():
from wagtail.models import Page
from wagtail.signals import page_published, page_unpublished, post_page_move
page_published.connect(_on_page_published, dispatch_uid="revalidation.page_published")
page_unpublished.connect(_on_page_unpublished, dispatch_uid="revalidation.page_unpublished")
post_page_move.connect(_on_page_moved, dispatch_uid="revalidation.post_page_move")
# Wagtail sends no signal on page deletion; MTI deletes of any page type
# also emit post_delete for the base Page row
post_delete.connect(_on_page_deleted, sender=Page, dispatch_uid="revalidation.page_deleted")
for label in REVALIDATING_MODELS:
post_save.connect(
_on_model_changed, sender=label, dispatch_uid=f"revalidation.save.{label}"
)
post_delete.connect(
_on_model_changed, sender=label, dispatch_uid=f"revalidation.delete.{label}"
)
-92
View File
@@ -1,92 +0,0 @@
"""Inline "button" link for Draftail rich text fields.
Stored as `<a linktype="button" data-href="...">label</a>`. The linktype (not a
plain href) avoids colliding with the built-in link feature, and lets
expand_db_html rewrite it to `<a class="button" href="...">` for the front end.
Wired up in wagtail_hooks.py.
"""
from django.urls import reverse_lazy
from django.utils.html import escape
from draftjs_exporter.dom import DOM
from wagtail.admin.rich_text.converters.html_to_contentstate import (
InlineEntityElementHandler,
)
from wagtail.admin.rich_text.editors.draftail import features as draftail_features
from wagtail.rich_text import LinkHandler
FEATURE_NAME = "button-link"
ENTITY_TYPE = "BUTTON"
def button_link_entity_decorator(props):
"""ContentState to DB HTML (keeps just the chosen URL)."""
return DOM.create_element(
"a",
{"linktype": "button", "data-href": props.get("url", "")},
props["children"],
)
class ButtonLinkEntityElementHandler(InlineEntityElementHandler):
"""DB HTML to ContentState."""
mutability = "MUTABLE"
def get_attribute_data(self, attrs):
return {"url": attrs.get("data-href", "")}
class ButtonLinkRichTextHandler(LinkHandler):
"""DB HTML to front-end HTML (expand_db_html)."""
identifier = "button"
@classmethod
def expand_db_attributes(cls, attrs):
href = escape(attrs.get("data-href", ""))
return f'<a class="button" href="{href}">'
def register_button_link_feature(features):
features.register_editor_plugin(
"draftail",
FEATURE_NAME,
draftail_features.EntityFeature(
{
"type": ENTITY_TYPE,
"icon": "arrow-right-full",
"description": "Knapp",
# Reuse the link chooser modal + payload, so buttons work like links.
"attributes": ["url", "id", "parentId"],
"allowlist": {"href": "^(http:|https:|mailto:|tel:|#|/|undefined$)"},
"chooserUrls": {
"pageChooser": reverse_lazy("wagtailadmin_choose_page"),
"externalLinkChooser": reverse_lazy("wagtailadmin_choose_page_external_link"),
"emailLinkChooser": reverse_lazy("wagtailadmin_choose_page_email_link"),
"phoneLinkChooser": reverse_lazy("wagtailadmin_choose_page_phone_link"),
"anchorLinkChooser": reverse_lazy("wagtailadmin_choose_page_anchor_link"),
},
},
js=[
# Gives LinkModalWorkflowSource its PAGE_CHOOSER_MODAL_ONLOAD_HANDLERS.
"wagtailadmin/js/page-chooser-modal.js",
"js/draftail-button-link.js",
],
css={"all": ["css/draftail-button-link.css"]},
),
)
features.register_converter_rule(
"contentstate",
FEATURE_NAME,
{
"from_database_format": {
'a[linktype="button"]': ButtonLinkEntityElementHandler(ENTITY_TYPE),
},
"to_database_format": {
"entity_decorators": {ENTITY_TYPE: button_link_entity_decorator},
},
},
)
features.register_link_type(ButtonLinkRichTextHandler)
features.default_features.append(FEATURE_NAME)
+77 -29
View File
@@ -12,6 +12,7 @@ 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)
@@ -36,11 +37,9 @@ 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",
@@ -143,8 +142,6 @@ 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/
@@ -173,13 +170,9 @@ MEDIA_URL = "/media/"
# Wagtail settings # Wagtail settings
WAGTAIL_SITE_NAME = "dnscms" WAGTAIL_SITE_NAME = "dnscms"
WAGTAIL_ALLOW_UNICODE_SLUGS = True WAGTAIL_ALLOW_UNICODE_SLUGS = False
# Headless: the Next.js frontend uses trailing-slash-free URLs, so strip
# trailing slashes from links generated by Wagtail (e.g. the GraphQL `url` field).
WAGTAIL_APPEND_SLASH = 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
@@ -190,26 +183,11 @@ 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
# Also used by wagtail-grapple to make image URLs absolute. WAGTAILADMIN_BASE_URL = "http://example.com"
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
# Public URL of the Next.js frontend. Used to direct preview iframes and to # Required by wagtail-grapple to make image URLs absolute
# redirect "View Live" clicks on the CMS host over to the headless frontend. BASE_URL = "http://example.com"
FRONTEND_BASE_URL = os.environ.get("FRONTEND_BASE_URL", "http://localhost:3000").rstrip("/")
# Shared secret for the frontend cache purge webhook (see dnscms/revalidation.py).
# Unset means no webhooks are sent.
REVALIDATE_WEBHOOK_SECRET = os.environ.get("REVALIDATE_WEBHOOK_SECRET", "")
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 # https://docs.wagtail.org/en/latest/releases/6.4.html#data-upload-max-number-fields-update
DATA_UPLOAD_MAX_NUMBER_FIELDS = 10_000 DATA_UPLOAD_MAX_NUMBER_FIELDS = 10_000
@@ -228,9 +206,79 @@ GRAPPLE = {
"news", "news",
"openinghours", "openinghours",
"sponsors", "sponsors",
"studio",
], ],
"EXPOSE_GRAPHIQL": True, "EXPOSE_GRAPHIQL": True,
"PAGE_SIZE": 100, "PAGE_SIZE": 100,
"MAX_PAGE_SIZE": 5000, "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",
},
]
+9 -2
View File
@@ -1,4 +1,4 @@
from .base import * # noqa: F403 from .base import *
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True DEBUG = True
@@ -11,7 +11,14 @@ 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 * # noqa: F403 from .local import *
except ImportError: except ImportError:
pass pass
+2 -2
View File
@@ -1,8 +1,8 @@
from .base import * # noqa: F403 from .base import *
DEBUG = False DEBUG = False
try: try:
from .local import * # noqa: F403 from .local import *
except ImportError: except ImportError:
pass pass
-23
View File
@@ -1,23 +0,0 @@
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"]
-15
View File
@@ -1,15 +0,0 @@
from django.db.models.signals import pre_save
from django.dispatch import receiver
from wagtail.models import Page
from dnscms.utils import slugify
SLUGGED_SNIPPETS = {"events.EventOrganizer", "events.EventCategory"}
@receiver(pre_save)
def normalize_slug(sender, instance, **kwargs):
label = f"{sender._meta.app_label}.{sender.__name__}"
if isinstance(instance, Page) or label in SLUGGED_SNIPPETS:
if getattr(instance, "slug", None):
instance.slug = slugify(instance.slug)
@@ -1,17 +0,0 @@
/* Styles the button-link entity as a button in the editor. Front end uses the global .button class. */
.button-link-entity .TooltipEntity {
display: inline-flex;
align-items: center;
gap: 0.25em;
padding: 0.1em 0.7em;
border: 1.5px solid currentColor;
border-radius: 999px;
font-weight: 600;
text-decoration: none;
line-height: 1.4;
}
.button-link-entity .TooltipEntity__icon {
width: 0.85em;
height: 0.85em;
}
@@ -1,40 +0,0 @@
/*
* Draftail plugin for the "button-link" feature (see dnscms/rich_text.py).
* Reuses Wagtail's link chooser (source) and edit/remove tooltip (decorator),
* so buttons insert and edit like normal links. No build step: uses Wagtail's
* globals and React.createElement.
*/
(function () {
"use strict";
var draftail = window.draftail;
var React = window.React;
var Icon = window.wagtail.components.Icon;
var TooltipEntity = draftail.TooltipEntity;
// Wrap TooltipEntity so CSS can style it as a button
function ButtonLink(props) {
var url = props.contentState.getEntity(props.entityKey).getData().url || null;
return React.createElement(
"span",
{ className: "button-link-entity" },
React.createElement(
TooltipEntity,
Object.assign({}, props, {
icon: React.createElement(Icon, { name: "arrow-right-full" }),
label: url || "",
url: url,
})
)
);
}
draftail.registerPlugin(
{
type: "BUTTON",
source: draftail.LinkModalWorkflowSource,
decorator: ButtonLink,
},
"entityTypes"
);
})();
-70
View File
@@ -16,76 +16,6 @@ function registerPricingPanelToggler() {
} }
} }
// Adds a "Duplicate" button to each event occurrence ("Forekomst") row, copying
// its date, time and venue. Wagtail's InlinePanel has no native duplicate.
function registerOccurrenceCloning() {
const FORMSET_PREFIX = "occurrences";
const container = document.getElementById(`id_${FORMSET_PREFIX}-FORMS`);
if (!container) return; // not an event page
const FIELDS = ["start", "end", "venue", "venue_custom"];
const duplicateIcon =
'<svg class="icon icon-copy" aria-hidden="true"><use href="#icon-copy"></use></svg>';
function duplicateRow(sourceChild) {
const addButton = document.getElementById(`id_${FORMSET_PREFIX}-ADD`);
const totalForms = document.getElementById(
`id_${FORMSET_PREFIX}-TOTAL_FORMS`
);
if (!addButton || !totalForms) return;
// `inline_child_occurrences-0` -> form prefix `occurrences-0`, used by field ids.
const sourcePrefix = sourceChild.id.replace(/^inline_child_/, "");
const values = {};
FIELDS.forEach((name) => {
const field = document.getElementById(`id_${sourcePrefix}-${name}`);
values[name] = field ? field.value : "";
});
// The new form takes the current TOTAL_FORMS index. Click Wagtail's real
// "Add" so it appends and initialises the row (e.g. date/time pickers).
const newIndex = parseInt(totalForms.value, 10);
addButton.click();
const newPrefix = `${FORMSET_PREFIX}-${newIndex}`;
FIELDS.forEach((name) => {
const field = document.getElementById(`id_${newPrefix}-${name}`);
if (!field) return;
field.value = values[name];
field.dispatchEvent(new Event("input", { bubbles: true }));
field.dispatchEvent(new Event("change", { bubbles: true }));
});
}
function addDuplicateButton(child) {
const controls = child.querySelector("[data-panel-controls]");
if (!controls || controls.querySelector("[data-duplicate-occurrence]")) return;
const button = document.createElement("button");
button.type = "button";
button.className = "button button--icon text-replace white";
button.title = "Duplicate";
button.setAttribute("data-duplicate-occurrence", "");
button.innerHTML = duplicateIcon;
button.addEventListener("click", () => duplicateRow(child));
// Sit just before the delete button, matching the native control order.
const deleteButton = controls.querySelector('[id$="-DELETE-button"]');
controls.insertBefore(button, deleteButton);
}
function refresh() {
container
.querySelectorAll("[data-inline-panel-child]")
.forEach((child) => addDuplicateButton(child));
}
// Cover existing rows plus any added later (via "Add" or "Duplicate").
refresh();
new MutationObserver(() => refresh()).observe(container, { childList: true });
}
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
registerPricingPanelToggler(); registerPricingPanelToggler();
registerOccurrenceCloning();
}); });
-7
View File
@@ -1,7 +0,0 @@
from django.utils.text import slugify as django_slugify
NORWEGIAN_TRANSLITERATIONS = str.maketrans({"æ": "ae", "ø": "o", "å": "a"})
def slugify(value: str) -> str:
return django_slugify(value.lower().translate(NORWEGIAN_TRANSLITERATIONS))
+27 -37
View File
@@ -1,11 +1,13 @@
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 grapple.registry import registry as grapple_registry
from wagtail import hooks from wagtail import hooks
from wagtail.models import Page from wagtail.admin.menu import MenuItem
from wagtail.search.backends import get_search_backend
from dnscms.rich_text import register_button_link_feature 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")
@@ -13,43 +15,31 @@ def enable_additional_rich_text_features(features):
features.default_features.extend(["h5", "h6", "blockquote"]) features.default_features.extend(["h5", "h6", "blockquote"])
@hooks.register("register_rich_text_features") @hooks.register("register_admin_menu_item")
def register_button_link(features): def register_events_menu_item():
register_button_link_feature(features) 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_schema_query") @hooks.register("register_admin_menu_item")
def override_search_resolver(query_mixins): def register_associations_menu_item():
""" page = AssociationIndex.objects.first()
Override Grapple's `search` resolver. Two fixes vs. the upstream version: associations_url = "#"
1. Restrict pages to live + public so drafts and access-restricted pages if page:
don't leak via the public API. associations_url = reverse("wagtailadmin_explore", args=(quote(page.pk),))
2. Run a single search across all `Page` subclasses (instead of iterating return MenuItem("Foreninger", associations_url, icon_name="group", order=2)
per-model) so results are ranked by relevance across types rather than
grouped by content type. Specific instances are fetched in a second
bulk query and reordered to match the search ranking.
Documents and images are intentionally not searched. The upstream resolver
includes them, but the frontend search page only renders Page types and
discards everything else, so iterating those indexes is wasted work.
"""
if not grapple_registry.class_models:
return
class SearchOverrideMixin: @hooks.register("register_admin_menu_item")
def resolve_search(self, info, **kwargs): def register_associations_menu_item():
query = kwargs.get("query") page = NewsIndex.objects.first()
if not query: news_url = "#"
return None if page:
s = get_search_backend() news_url = reverse("wagtailadmin_explore", args=(quote(page.pk),))
ranked = list(s.search(query, Page.objects.live().public())) return MenuItem("Nyheter", news_url, icon_name="info-circle", order=3)
if not ranked:
return []
ids = [p.id for p in ranked]
specific_map = {p.id: p for p in Page.objects.filter(id__in=ids).specific()}
return [specific_map[i] for i in ids if i in specific_map]
query_mixins.insert(0, SearchOverrideMixin)
@hooks.register("construct_page_action_menu") @hooks.register("construct_page_action_menu")
+24
View File
@@ -0,0 +1,24 @@
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
@@ -0,0 +1,51 @@
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
+6 -73
View File
@@ -1,84 +1,17 @@
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) # noqa: DJ001 wp_post_type = models.CharField(max_length=255, blank=True, null=True)
wp_link = models.TextField(blank=True, null=True) # noqa: DJ001 wp_link = models.TextField(blank=True, null=True)
wp_raw_content = models.TextField(blank=True, null=True) # noqa: DJ001 wp_raw_content = models.TextField(blank=True, null=True)
wp_processed_content = models.TextField(blank=True, null=True) # noqa: DJ001 wp_processed_content = models.TextField(blank=True, null=True)
wp_block_json = models.TextField(blank=True, null=True) # noqa: DJ001 wp_block_json = models.TextField(blank=True, null=True)
wp_normalized_styles = models.TextField(blank=True, null=True) # noqa: DJ001 wp_normalized_styles = models.TextField(blank=True, null=True)
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)
-95
View File
@@ -1,95 +0,0 @@
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 ExplorableIndexView, IndexView
from wagtail.admin.viewsets.pages import PageListingViewSet, PageViewSet
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 EventPagePrefetchMixin:
"""Prefetch the relations the event columns read, so the listing avoids N+1."""
def annotate_queryset(self, pages):
pages = super().annotate_queryset(pages)
return pages.prefetch_related(
"occurrences",
"organizer_links__organizer",
)
class EventPageIndexView(EventPagePrefetchMixin, IndexView):
pass
class EventPageExplorableIndexView(EventPagePrefetchMixin, ExplorableIndexView):
pass
class EventChooseParentView(ListingRedirectChooseParentView):
listing_url_name = "events:index"
class EventListingMixin:
"""Shared model + columns for the standalone listing and the page explorer."""
model = EventPage
icon = "date"
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%"),
]
class EventSidebarViewSet(EventListingMixin, PageListingViewSet):
"""Standalone 'Events' sidebar entry, reached independently of the page tree."""
index_view_class = EventPageIndexView
choose_parent_view_class = EventChooseParentView
menu_label = _("Events")
menu_order = 1
add_to_admin_menu = True
ordering = "-latest_revision_created_at"
class EventExplorerViewSet(EventListingMixin, PageViewSet):
"""Applies the same columns when navigating into EventIndex via the page explorer."""
index_view_class = EventPageExplorableIndexView
event_sidebar_viewset = EventSidebarViewSet("events")
event_explorer_viewset = EventExplorerViewSet()
-221
View File
@@ -1,221 +0,0 @@
from django import forms
from django.contrib import messages
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import transaction
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.http import urlencode
from django.utils.translation import gettext_lazy as _
from django.views.generic import FormView
from wagtail.admin.views.generic.base import WagtailAdminTemplateMixin
from wagtail.models import ReferenceIndex, Revision
from dnscms.utils import slugify
from events.models import EventOrganizer, EventOrganizerLink, EventPage
from events.views import event_organizer_chooser_viewset
def _check_relations_are_handled():
"""Refuse to merge if EventOrganizer has gained a relation this module doesn't handle."""
for relation in EventOrganizer._meta.related_objects:
if relation.many_to_many and relation.through is EventOrganizerLink:
continue
if relation.related_model is EventOrganizerLink and relation.field.name == "organizer":
continue
raise NotImplementedError(
f"merge_event_organizers does not handle the {relation.name!r} relation"
)
def _repoint_links(survivor, loser):
loser_links = EventOrganizerLink.objects.filter(organizer=loser)
affected_event_ids = set(loser_links.values_list("event_id", flat=True))
# drop the loser's link where the event links both, to respect the unique constraint
doubly_linked = EventOrganizerLink.objects.filter(
organizer=survivor, event_id__in=affected_event_ids
).values_list("event_id", flat=True)
loser_links.filter(event_id__in=doubly_linked).delete()
loser_links.update(organizer=survivor)
return affected_event_ids
def _rewrite_revisions(survivor, loser):
"""Rewrite organizer ids in revision JSON, which would otherwise dangle after the delete."""
affected_event_ids = set()
event_content_type = ContentType.objects.get_for_model(EventPage)
for revision in Revision.objects.filter(content_type=event_content_type).iterator():
links = revision.content.get("organizer_links") or []
if not any(link.get("organizer") == loser.pk for link in links):
continue
seen_organizer_ids = set()
rewritten = []
for link in links:
if link.get("organizer") == loser.pk:
link = {**link, "organizer": survivor.pk}
if link.get("organizer") in seen_organizer_ids:
continue
seen_organizer_ids.add(link.get("organizer"))
rewritten.append(link)
revision.content["organizer_links"] = rewritten
revision.save(update_fields=["content"])
affected_event_ids.add(int(revision.object_id))
return affected_event_ids
@transaction.atomic
def merge_event_organizers(organizer_a, organizer_b, *, name=None, slug=None):
"""
Merge two organizers, keeping the one with the lowest primary key. Event
links and event page revisions are repointed before the other organizer is
deleted. ``name``/``slug`` override the survivor's fields; a blank
``association``/``external_url`` is filled in from the deleted organizer.
"""
if organizer_a.pk == organizer_b.pk:
raise ValueError("Cannot merge an organizer with itself.")
_check_relations_are_handled()
survivor, loser = sorted([organizer_a, organizer_b], key=lambda organizer: organizer.pk)
affected_event_ids = _repoint_links(survivor, loser)
affected_event_ids |= _rewrite_revisions(survivor, loser)
if name:
survivor.name = name
if slug:
survivor.slug = slug
if not survivor.association_id:
survivor.association_id = loser.association_id
if not survivor.external_url:
survivor.external_url = loser.external_url
survivor.save()
loser.delete()
# the bulk link update bypasses the save-time reference index maintenance
for event in EventPage.objects.filter(pk__in=affected_event_ids):
ReferenceIndex.create_or_update_for_object(event)
return survivor
CUSTOM_VALUE = "__custom__"
class MergeOrganizersSelectForm(forms.Form):
organizer_a = forms.ModelChoiceField(
label=_("First organizer"),
queryset=EventOrganizer.objects.all(),
widget=event_organizer_chooser_viewset.widget_class(),
)
organizer_b = forms.ModelChoiceField(
label=_("Second organizer"),
queryset=EventOrganizer.objects.all(),
widget=event_organizer_chooser_viewset.widget_class(),
)
def clean(self):
cleaned = super().clean()
organizer_a = cleaned.get("organizer_a")
organizer_b = cleaned.get("organizer_b")
if organizer_a and organizer_b and organizer_a.pk == organizer_b.pk:
raise ValidationError(_("Pick two different organizers."))
return cleaned
class MergeOrganizersConfirmForm(forms.Form):
name = forms.ChoiceField(label=_("Name to keep"), widget=forms.RadioSelect)
name_custom = forms.CharField(label=_("Custom name"), required=False, max_length=100)
slug = forms.ChoiceField(label=_("Slug to keep"), widget=forms.RadioSelect)
slug_custom = forms.CharField(label=_("Custom slug"), required=False, max_length=255)
def __init__(self, *args, survivor, loser, **kwargs):
super().__init__(*args, **kwargs)
self.fields["name"].choices = self._choices(survivor.name, loser.name)
self.fields["name"].initial = survivor.name
self.fields["slug"].choices = self._choices(survivor.slug, loser.slug)
self.fields["slug"].initial = survivor.slug
@staticmethod
def _choices(*values):
unique_values = dict.fromkeys(values)
return [(value, value) for value in unique_values] + [
(CUSTOM_VALUE, _("Something else (enter below)"))
]
def _resolve(self, cleaned, field):
choice = cleaned.get(field)
custom = (cleaned.get(f"{field}_custom") or "").strip()
if choice == CUSTOM_VALUE:
if not custom:
self.add_error(
f"{field}_custom", _("Enter a value, or pick an existing one above.")
)
return custom
return choice
def clean(self):
cleaned = super().clean()
cleaned["final_name"] = self._resolve(cleaned, "name")
cleaned["final_slug"] = slugify(self._resolve(cleaned, "slug") or "")
return cleaned
class MergeOrganizersSelectView(WagtailAdminTemplateMixin, FormView):
template_name = "events/merge_organizers_select.html"
form_class = MergeOrganizersSelectForm
page_title = _("Merge organizers")
header_icon = "group"
def form_valid(self, form):
params = urlencode(
{
"organizer_a": form.cleaned_data["organizer_a"].pk,
"organizer_b": form.cleaned_data["organizer_b"].pk,
}
)
return redirect(f"{reverse('events_merge_organizers_confirm')}?{params}")
class MergeOrganizersConfirmView(WagtailAdminTemplateMixin, FormView):
template_name = "events/merge_organizers_confirm.html"
form_class = MergeOrganizersConfirmForm
page_title = _("Merge organizers")
header_icon = "group"
def dispatch(self, request, *args, **kwargs):
try:
pks = {int(request.GET.get(param, "")) for param in ("organizer_a", "organizer_b")}
except ValueError:
pks = set()
organizers = list(EventOrganizer.objects.filter(pk__in=pks).order_by("pk"))
if len(organizers) != 2:
messages.error(request, _("Pick two different organizers to merge."))
return redirect("events_merge_organizers")
self.survivor, self.loser = organizers
return super().dispatch(request, *args, **kwargs)
def get_form_kwargs(self):
return {**super().get_form_kwargs(), "survivor": self.survivor, "loser": self.loser}
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["survivor"] = self.survivor
context["loser"] = self.loser
context["survivor_event_count"] = self.survivor.organized_events.count()
context["loser_event_count"] = self.loser.organized_events.count()
return context
def form_valid(self, form):
survivor = merge_event_organizers(
self.survivor,
self.loser,
name=form.cleaned_data["final_name"],
slug=form.cleaned_data["final_slug"],
)
messages.success(
self.request,
_('Merged "%(loser)s" into "%(survivor)s".')
% {"loser": self.loser.name, "survivor": survivor.name},
)
return redirect(reverse("wagtailsnippets_events_eventorganizer:list"))
@@ -0,0 +1,253 @@
# Generated by Django 6.0.3 on 2026-04-15 20:21
import wagtail.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("events", "0053_eventpage_lead"),
]
operations = [
migrations.AlterField(
model_name="eventpage",
name="body",
field=wagtail.fields.StreamField(
[
("paragraph", 0),
("image", 4),
("image_slider", 8),
("horizontal_rule", 10),
("featured", 18),
("page_section_navigation", 19),
("accordion", 23),
("fact_box", 26),
("photo_sphere", 29),
("embed", 30),
("raw_html", 31),
],
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.images.blocks.ImageChooserBlock",
(),
{"label": "360°-bilde"},
),
28: (
"wagtail.blocks.CharBlock",
(),
{
"help_text": "Beskrivende tittel på bildet (vises også til skjermlesere)",
"label": "Tittel",
"max_length": 256,
},
),
29: (
"wagtail.blocks.StructBlock",
[[("image", 27), ("title", 28)]],
{},
),
30: ("wagtail.embeds.blocks.EmbedBlock", (), {}),
31: ("wagtail.blocks.RawHTMLBlock", (), {}),
},
default=[("paragraph", "")],
),
),
]
@@ -1,17 +0,0 @@
# 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'},
),
]
@@ -1,82 +0,0 @@
# 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),
),
]
@@ -1,19 +0,0 @@
# Generated by Django 6.0.5 on 2026-07-02 14:53
import wagtail.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('events', '0055_alter_eventcategory_options_and_more'),
]
operations = [
migrations.AlterField(
model_name='eventpage',
name='body',
field=wagtail.fields.StreamField([('paragraph', 0), ('image', 4), ('image_slider', 8), ('horizontal_rule', 10), ('featured', 18), ('page_section_navigation', 19), ('accordion', 23), ('fact_box', 26), ('schedule', 33), ('embed', 34), ('raw_html', 35)], 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.blocks.CharBlock', (), {'help_text': 'Overskrift for timeplanen (valgfritt)', 'label': 'Tittel', 'max_length': 64, 'required': False}), 28: ('wagtail.blocks.CharBlock', (), {'help_text': 'Fritekst, f.eks. "13:00", "20:00 23:00" eller "23.06"', 'label': 'Tidspunkt/dato', 'max_length': 64, 'required': True}), 29: ('wagtail.blocks.RichTextBlock', (), {'features': ['bold', 'italic', 'link'], 'help_text': 'Kort tittel på én linje. Du kan legge inn lenker.', 'label': 'Tittel', 'required': True}), 30: ('wagtail.blocks.RichTextBlock', (), {'features': ['bold', 'italic', 'link'], 'help_text': 'Valgfri tilleggsinformasjon.', 'label': 'Beskrivelse', 'required': False}), 31: ('wagtail.blocks.StructBlock', [[('time', 28), ('title', 29), ('description', 30)]], {}), 32: ('wagtail.blocks.ListBlock', (31,), {'label': 'Punkter', 'min_num': 1}), 33: ('wagtail.blocks.StructBlock', [[('title', 27), ('items', 32)]], {}), 34: ('wagtail.embeds.blocks.EmbedBlock', (), {}), 35: ('wagtail.blocks.RawHTMLBlock', (), {})}, default=[('paragraph', '')]),
),
]
File diff suppressed because one or more lines are too long
@@ -1,19 +0,0 @@
# Generated by Django 6.0.5 on 2026-07-04 22:29
import wagtail.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('events', '0057_alter_eventpage_body'),
]
operations = [
migrations.AlterField(
model_name='eventpage',
name='body',
field=wagtail.fields.StreamField([('paragraph', 0), ('image', 4), ('image_slider', 8), ('horizontal_rule', 10), ('featured', 18), ('page_section_navigation', 19), ('accordion', 23), ('fact_box', 26), ('schedule', 33), ('embed', 34), ('raw_html', 35)], 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.blocks.CharBlock', (), {'help_text': 'Overskrift for timeplanen (valgfritt)', 'label': 'Tittel', 'max_length': 64, 'required': False}), 28: ('wagtail.blocks.CharBlock', (), {'help_text': 'Fritekst, f.eks. "13:00", "20:00 23:00" eller "23.06"', 'label': 'Tidspunkt/dato', 'max_length': 64, 'required': True}), 29: ('wagtail.blocks.RichTextBlock', (), {'features': ['bold', 'italic', 'link'], 'help_text': 'Kort tittel på én linje. Du kan legge inn lenker.', 'label': 'Tittel', 'required': True}), 30: ('wagtail.blocks.RichTextBlock', (), {'features': ['bold', 'italic', 'link'], 'help_text': 'Valgfri tilleggsinformasjon.', 'label': 'Beskrivelse', 'required': False}), 31: ('wagtail.blocks.StructBlock', [[('time', 28), ('title', 29), ('description', 30)]], {}), 32: ('wagtail.blocks.ListBlock', (31,), {'label': 'Punkter', 'min_num': 1}), 33: ('wagtail.blocks.StructBlock', [[('title', 27), ('items', 32)]], {}), 34: ('wagtail.embeds.blocks.EmbedBlock', (), {}), 35: ('wagtail.blocks.RawHTMLBlock', (), {})}, default=[('paragraph', '')]),
),
]
+318 -132
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, Prefetch, Q, UniqueConstraint from django.db.models import Min, 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,42 +30,21 @@ 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 ( from dnscms.wordpress.models import WPImportedPageMixin
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(HeadlessMixin, Page): class EventIndex(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 ( return EventPage.objects.live().future().order_by("next_occurrence")
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(
@@ -95,12 +74,11 @@ 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, default=False, help_text="Skal denne kategorien være mulig å filtrere på i programmet?"
help_text=_("Should this category be available as a filter in the event programme?"),
) )
PIG_CHOICES = [ PIG_CHOICES = [
("", _("None")), ("", "Ingen"),
] + ALL_PIGS ] + ALL_PIGS
pig = models.CharField( pig = models.CharField(
@@ -108,14 +86,14 @@ class EventCategory(models.Model):
choices=PIG_CHOICES, choices=PIG_CHOICES,
default="", default="",
blank=True, blank=True,
help_text=_("Default pig for events of this kind."), help_text="Standardgris for arrangementer av denne typen.",
) )
panels = [ panels = [
TitleFieldPanel("name"), TitleFieldPanel("name"),
FieldPanel("slug"), FieldPanel("slug"),
FieldPanel("show_in_filters"), FieldPanel("show_in_filters"),
FieldPanel("pig", heading=_("Pig")), FieldPanel("pig", heading="Gris"),
] ]
graphql_fields = [ graphql_fields = [
@@ -126,8 +104,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):
@@ -155,8 +133,8 @@ class EventOrganizerLink(Orderable):
return f"{self.organizer.name}" return f"{self.organizer.name}"
class Meta: class Meta:
verbose_name = _("organizer") verbose_name = "Arrangør"
verbose_name_plural = _("organizers") verbose_name_plural = "Arrangører"
constraints = [ constraints = [
UniqueConstraint( UniqueConstraint(
"event", "organizer", name="event_organizer_link_event_organizer_unique" "event", "organizer", name="event_organizer_link_event_organizer_unique"
@@ -164,11 +142,9 @@ class EventOrganizerLink(Orderable):
] ]
# registered as a snippet via EventOrganizerSnippetViewSet in wagtail_hooks.py @register_snippet
@register_query_field("eventOrganizer", "eventOrganizers") @register_query_field("eventOrganizer", "eventOrganizers")
class EventOrganizer(index.Indexed, ClusterableModel): class EventOrganizer(ClusterableModel):
objects = WPAwareManager()
name = models.CharField( name = models.CharField(
max_length=100, max_length=100,
null=False, null=False,
@@ -186,13 +162,13 @@ class EventOrganizer(index.Indexed, ClusterableModel):
blank=True, blank=True,
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name="organizers", related_name="organizers",
help_text=_("If a DNS association or committee is behind it, choose it here."), help_text="Om en samfundsforening eller et utvalg står bak, velg det her.",
) )
external_url = models.URLField( external_url = models.URLField(
blank=True, blank=True,
max_length=512, max_length=512,
help_text=_("Link to the external organizer's website"), help_text="Lenke til nettstedet til ekstern arrangør",
) )
panels = [ panels = [
@@ -201,15 +177,15 @@ class EventOrganizer(index.Indexed, ClusterableModel):
FieldPanel( FieldPanel(
"association", "association",
widget=AssociationChooserWidget(linked_fields={"association": "#id_association"}), widget=AssociationChooserWidget(linked_fields={"association": "#id_association"}),
heading=_("Internal organizer"), heading="Intern arrangør",
), ),
MultiFieldPanel( MultiFieldPanel(
heading=_("External organizer"), heading="Ekstern arrangør",
children=[ children=[
FieldPanel( FieldPanel(
"external_url", "external_url",
heading=_("Website"), heading="Nettsted",
help_text=_("Leave this empty if the organizer exists in the list above."), help_text="La denne stå tom om arrangøren finnes i lista over.",
), ),
], ],
), ),
@@ -222,14 +198,9 @@ class EventOrganizer(index.Indexed, ClusterableModel):
GraphQLString("external_url"), GraphQLString("external_url"),
] ]
search_fields = [
index.SearchField("name"),
index.AutocompleteField("name"),
]
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):
@@ -238,22 +209,15 @@ class EventOrganizer(index.Indexed, ClusterableModel):
class EventPageQuerySet(PageQuerySet): class EventPageQuerySet(PageQuerySet):
def future(self): def future(self):
now = timezone.now() today = timezone.localtime(timezone.now()).date()
today_start = timezone.localtime(now).replace(hour=0, minute=0, second=0, microsecond=0) next_occurrence = Min("occurrences__start", filter=Q(occurrences__start__gte=today))
next_occurrence = Min("occurrences__start", filter=Q(occurrences__start__gte=today_start)) return self.filter(occurrences__start__gte=today).annotate(next_occurrence=next_occurrence)
return self.filter(occurrences__start__gte=today_start).annotate(
next_occurrence=next_occurrence
)
class EventPageManager( EventPageManager = PageManager.from_queryset(EventPageQuerySet)
DeferWPFieldsManagerMixin,
PageManager.from_queryset(EventPageQuerySet),
):
pass
class EventPage(HeadlessMixin, WPImportedPageMixin, Page): class EventPage(WPImportedPageMixin, Page):
subpage_types = [] subpage_types = []
parent_page_types = ["events.EventIndex"] parent_page_types = ["events.EventIndex"]
show_in_menus = False show_in_menus = False
@@ -266,19 +230,19 @@ class EventPage(HeadlessMixin, WPImportedPageMixin, Page):
blank=True, blank=True,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
related_name="+", related_name="+",
help_text=_( help_text=(
"Choose an image for use in the programme and other surfaces. " "Velg et bilde til bruk i programmet og andre visningsflater. "
"Should be a photo or an illustration without too much text " "Bør være et bilde eller en illustrasjon uten tekst "
" don't reuse a Facebook cover uncritically!" " ikke gjenbruk et Facebook-cover ukritisk!"
), ),
) )
subtitle = models.CharField( subtitle = models.CharField(
blank=True, blank=True,
max_length=128, max_length=128,
help_text=_( help_text=(
"A short text that appears right below the title. " "En kort tekst som kommer rett under under tittelen. "
"Feel free to leave it empty if you fit most of it in the main title." "La denne gjerne stå tom om du fikk plass til det meste i tittelen."
), ),
) )
lead = RichTextField(features=["italic", "link"], blank=True) lead = RichTextField(features=["italic", "link"], blank=True)
@@ -294,8 +258,8 @@ class EventPage(HeadlessMixin, WPImportedPageMixin, Page):
) )
PIG_CHOICES = [ PIG_CHOICES = [
("", _("None")), ("", "Ingen"),
("automatic", _("Automatic")), ("automatic", "Automatisk"),
] + ALL_PIGS ] + ALL_PIGS
pig = models.CharField( pig = models.CharField(
@@ -303,21 +267,21 @@ class EventPage(HeadlessMixin, WPImportedPageMixin, Page):
choices=PIG_CHOICES, choices=PIG_CHOICES,
default="automatic", default="automatic",
blank=True, blank=True,
help_text=_( help_text=(
"The pig that hangs out on the event page. " "Grisen som henger på arrangementssiden. "
"Automatic causes one to be chosen based on the event's category." "Automatisk fører til at en velges basert på arrangementets kategori."
), ),
) )
ticket_url = models.URLField( ticket_url = models.URLField(
blank=True, blank=True,
max_length=1024, max_length=1024,
help_text=_("Direct link to ticket purchase, e.g. TicketCo, Billetto or Ticketmaster"), help_text="Lenke direkte til billettkjøp, f.eks. TicketCo eller Ticketmaster",
) )
facebook_url = models.URLField( facebook_url = models.URLField(
blank=True, blank=True,
max_length=1024, max_length=1024,
help_text=_("Direct link to the event on Facebook"), help_text="Lenke direkte til arrangementet på Facebook",
) )
free = models.BooleanField(null=False, default=False) free = models.BooleanField(null=False, default=False)
@@ -326,65 +290,62 @@ class EventPage(HeadlessMixin, 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=_("Free"), help_text=_("Is this event free for everyone?")), FieldPanel("free", heading="Gratis", help_text="Er dette arrangementet gratis for alle?"),
MultiFieldPanel( MultiFieldPanel(
children=[ children=[
FieldRowPanel( FieldRowPanel(
children=[ children=[
FieldPanel("price_regular", heading=_("Regular price")), FieldPanel("price_regular", heading="Ordinær pris"),
FieldPanel("price_student", heading=_("Price for students")), FieldPanel("price_student", heading="Pris for studenter"),
FieldPanel("price_member", heading=_("Price for DNS members")), FieldPanel("price_member", heading="Pris for medlemmer av DNS"),
], ],
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=_("Ticket purchase link")), FieldPanel("ticket_url", heading="Billettkjøpslenke"),
] ]
content_panels = Page.content_panels + [ content_panels = Page.content_panels + [
FieldPanel("subtitle", heading=_("Subtitle")), FieldPanel("subtitle", heading="Undertittel"),
FieldPanel("featured_image"), FieldPanel("featured_image"),
FieldPanel("lead", heading=_("Lead")), FieldPanel("lead", heading="Ingress"),
FieldPanel("body"), FieldPanel("body"),
FieldPanel("categories", widget=forms.CheckboxSelectMultiple), FieldPanel("categories", widget=forms.CheckboxSelectMultiple),
MultiFieldPanel( MultiFieldPanel(
heading=_("Organizers"), heading="Arrangører",
children=[ children=[
HelpPanel( HelpPanel(
content=_("Who is behind the event?"), content=("Hvem står bak arrangementet?"),
), ),
MultipleChooserPanel( MultipleChooserPanel(
"organizer_links", chooser_field_name="organizer", label=_("Organizer") "organizer_links", chooser_field_name="organizer", label="Arrangør"
), ),
], ],
), ),
FieldPanel("pig", heading=_("Pig")), FieldPanel("pig", heading="Gris"),
FieldPanel( FieldPanel(
"facebook_url", "facebook_url",
heading=_("Facebook link"), heading="Facebook-lenke",
help_text=_("Direct link to the event on Facebook."), help_text="Lenke direkte til arrangementet på Facebook.",
), ),
MultiFieldPanel(heading=_("Pricing and tickets"), children=ticket_panels), MultiFieldPanel(heading="Priser og billettkjøp", children=ticket_panels),
MultiFieldPanel( MultiFieldPanel(
heading=_("Date, time and venue"), heading="Dato, tid og lokale",
children=[ children=[
HelpPanel( HelpPanel(
content=_( content=(
"If the event spans several days, add each day as a separate occurrence." "Om arrangementet går over flere dager, "
"legg inn hver dag som en egen forekomst."
), ),
), ),
InlinePanel("occurrences", min_num=1, label=_("Occurrence")), InlinePanel("occurrences", min_num=1, label="Forekomst"),
], ],
), ),
] ]
@@ -394,7 +355,7 @@ class EventPage(HeadlessMixin, WPImportedPageMixin, Page):
GraphQLImage("featured_image"), GraphQLImage("featured_image"),
GraphQLRichText("lead"), GraphQLRichText("lead"),
GraphQLStreamfield("body"), GraphQLStreamfield("body"),
GraphQLString("pig", required=True), GraphQLString("pig"),
GraphQLString("ticket_url"), GraphQLString("ticket_url"),
GraphQLString("facebook_url"), GraphQLString("facebook_url"),
GraphQLBoolean("free"), GraphQLBoolean("free"),
@@ -426,10 +387,6 @@ class EventPage(HeadlessMixin, 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()
@@ -449,10 +406,130 @@ class EventPage(HeadlessMixin, 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)
@@ -467,24 +544,22 @@ 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. "
"Use this <em>if none of the venues that can be selected on the left</em> fit. " "F.eks. <em>Frederikkeplassen</em> eller <em>Sirkusteltet</em>."
"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=_("End")), FieldPanel("end", heading="Slutt"),
], ],
), ),
FieldRowPanel( FieldRowPanel(
children=[ children=[
FieldPanel("venue", heading=_("Venue"), widget=forms.Select), FieldPanel("venue", heading="Lokale"),
FieldPanel("venue_custom", heading=_("Venue as free text")), FieldPanel("venue_custom", heading="Lokale som fritekst"),
], ],
), ),
] ]
@@ -497,29 +572,140 @@ class EventOccurrence(Orderable):
] ]
def clean(self): def clean(self):
if self.venue_custom:
trimmed = self.venue_custom.strip()
self.venue_custom = trimmed
if trimmed:
match = VenuePage.objects.filter(title=trimmed).first()
if match:
self.venue = match
self.venue_custom = ""
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": _("Venue is required.")}) raise ValidationError({"venue": "Lokale er påkrevd."})
def __str__(self): def __str__(self):
return f"{self.start}--{self.end}" return f"{self.start}--{self.end}"
class Meta: class Meta:
verbose_name = _("occurrence") verbose_name = "Forekomst"
verbose_name_plural = _("occurrences") verbose_name_plural = "Forekomster"
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
@@ -1,119 +0,0 @@
{
"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
}
]
}
}
@@ -1,55 +0,0 @@
{% extends "wagtailadmin/generic/base.html" %}
{% load i18n wagtailadmin_tags %}
{% block main_content %}
<table class="listing">
<thead>
<tr>
<th>{% trans "Outcome" %}</th>
<th>{% trans "ID" %}</th>
<th>{% trans "Name" %}</th>
<th>{% trans "Slug" %}</th>
<th>{% trans "Association" %}</th>
<th>{% trans "External URL" %}</th>
<th>{% trans "Events" %}</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>{% trans "Survives" %}</strong></td>
<td>{{ survivor.pk }}</td>
<td>{{ survivor.name }}</td>
<td>{{ survivor.slug }}</td>
<td>{{ survivor.association|default:"—" }}</td>
<td>{{ survivor.external_url|default:"—" }}</td>
<td>{{ survivor_event_count }}</td>
</tr>
<tr>
<td><strong>{% trans "Is deleted" %}</strong></td>
<td>{{ loser.pk }}</td>
<td>{{ loser.name }}</td>
<td>{{ loser.slug }}</td>
<td>{{ loser.association|default:"—" }}</td>
<td>{{ loser.external_url|default:"—" }}</td>
<td>{{ loser_event_count }}</td>
</tr>
</tbody>
</table>
<p class="help-block help-warning">
{% blocktrans with loser_name=loser.name survivor_name=survivor.name trimmed %}
Events organized by “{{ loser_name }}” will be moved to “{{ survivor_name }}”,
and “{{ loser_name }}” will be deleted. This cannot be undone.
{% endblocktrans %}
</p>
<form method="post" novalidate>
{% csrf_token %}
<ul class="fields">
{% for field in form.visible_fields %}
<li>{% formattedfield field %}</li>
{% endfor %}
</ul>
<button type="submit" class="button serious">{% trans "Merge organizers" %}</button>
</form>
{% endblock %}
@@ -1,30 +0,0 @@
{% extends "wagtailadmin/generic/base.html" %}
{% load i18n wagtailadmin_tags %}
{% block main_content %}
<p>
{% trans "Pick the two organizers to merge. The one with the lowest ID survives; the other one is deleted after its events have been moved over." %}
</p>
<form method="post" novalidate>
{% csrf_token %}
{% if form.non_field_errors %}
<div class="help-block help-critical">{{ form.non_field_errors }}</div>
{% endif %}
<ul class="fields">
{% for field in form.visible_fields %}
<li>{% formattedfield field %}</li>
{% endfor %}
</ul>
<button type="submit" class="button">{% trans "Continue" %}</button>
</form>
{% endblock %}
{% block extra_js %}
{{ block.super }}
{{ form.media.js }}
{% endblock %}
{% block extra_css %}
{{ block.super }}
{{ form.media.css }}
{% endblock %}
+2
View File
@@ -0,0 +1,2 @@
# Create your tests here.
+5 -70
View File
@@ -1,80 +1,15 @@
from django import forms
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from wagtail.admin.forms import WagtailAdminModelForm
from wagtail.admin.forms.choosers import BaseFilterForm
from wagtail.admin.ui.tables import UpdatedAtColumn
from wagtail.admin.views.generic.chooser import ChooseResultsView, ChooseView
from wagtail.admin.viewsets.chooser import ChooserViewSet from wagtail.admin.viewsets.chooser import ChooserViewSet
from wagtail.snippets.views.snippets import SnippetViewSet
from dnscms.utils import slugify
from events.models import EventOrganizer
class EventOrganizerSearchForm(BaseFilterForm):
"""Substring search on name/slug"""
q = forms.CharField(
label=_("Search term"),
widget=forms.TextInput(attrs={"placeholder": _("Search")}),
required=False,
)
def filter(self, objects):
objects = super().filter(objects)
search_query = self.cleaned_data.get("q")
if search_query:
objects = objects.filter(
Q(name__icontains=search_query) | Q(slug__icontains=search_query)
)
self.is_searching = True
self.search_query = search_query
return objects
class EventOrganizerChooseView(ChooseView):
filter_form_class = EventOrganizerSearchForm
class EventOrganizerChooseResultsView(ChooseResultsView):
filter_form_class = EventOrganizerSearchForm
class EventOrganizerSnippetViewSet(SnippetViewSet):
model = EventOrganizer
icon = "group"
list_display = ["name", "slug", UpdatedAtColumn()]
# unset the search backend so the listing search filters on icontains instead
search_backend_name = None
search_fields = ["name", "slug"]
class EventOrganizerCreationForm(WagtailAdminModelForm):
class Meta:
model = EventOrganizer
fields = ["name", "association", "external_url"]
def save(self, commit=True):
instance = super().save(commit=False)
if not instance.slug:
instance.slug = slugify(instance.name)
if commit:
instance.save()
return instance
class EventOrganizerChooserViewSet(ChooserViewSet): 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"
creation_form_class = EventOrganizerCreationForm form_fields = ["name", "association", "external_url"]
choose_view_class = EventOrganizerChooseView
choose_results_view_class = EventOrganizerChooseResultsView
event_organizer_chooser_viewset = EventOrganizerChooserViewSet("event_organizer_chooser") event_organizer_chooser_viewset = EventOrganizerChooserViewSet("event_organizer_chooser")
+1 -53
View File
@@ -1,60 +1,8 @@
from django.urls import path, reverse_lazy
from django.utils.translation import gettext_lazy as _
from wagtail import hooks from wagtail import hooks
from wagtail.admin.auth import user_passes_test
from wagtail.admin.menu import MenuItem
from wagtail.snippets.models import register_snippet
from .admin import event_sidebar_viewset, event_explorer_viewset from .views import event_organizer_chooser_viewset
from .merge_organizers import MergeOrganizersConfirmView, MergeOrganizersSelectView
from .views import EventOrganizerSnippetViewSet, event_organizer_chooser_viewset
register_snippet(EventOrganizerSnippetViewSet)
superuser_only = user_passes_test(lambda user: user.is_superuser)
class SuperuserMenuItem(MenuItem):
def is_shown(self, request):
return request.user.is_superuser
@hooks.register("register_admin_urls")
def register_merge_organizers_urls():
return [
path(
"merge-organizers/",
superuser_only(MergeOrganizersSelectView.as_view()),
name="events_merge_organizers",
),
path(
"merge-organizers/confirm/",
superuser_only(MergeOrganizersConfirmView.as_view()),
name="events_merge_organizers_confirm",
),
]
@hooks.register("register_settings_menu_item")
def register_merge_organizers_menu_item():
return SuperuserMenuItem(
_("Merge organizers"),
reverse_lazy("events_merge_organizers"),
icon_name="group",
order=1100,
)
@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_sidebar_viewset():
return event_sidebar_viewset
@hooks.register("register_admin_viewset")
def register_event_explorer_viewset():
return event_explorer_viewset
@@ -1,4 +1,4 @@
# Generated by Django 6.0.5 on 2026-07-02 14:30 # Generated by Django 6.0.3 on 2026-04-15 20:21
import wagtail.fields import wagtail.fields
from django.db import migrations from django.db import migrations
@@ -7,13 +7,302 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('generic', '0026_alter_genericpage_body'), ("generic", "0026_alter_genericpage_body"),
] ]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name='genericpage', model_name="genericpage",
name='body', name="body",
field=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'), ('studioNote', 'Note (STUDiO)'), ('studioCalendar', 'Kalender (STUDiO)'), ('studioGuitar', 'Gitar (STUDiO)'), ('studioDisco', 'Discokule (STUDiO)'), ('studioMole', 'Muldvarpen (STUDiO)')], '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)]], {})}), field=wagtail.fields.StreamField(
[
("paragraph", 0),
("image", 4),
("image_slider", 8),
("horizontal_rule", 10),
("featured", 18),
("page_section_navigation", 19),
("accordion", 23),
("fact_box", 26),
("photo_sphere", 29),
("embed", 30),
("raw_html", 31),
("page_section", 36),
],
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.images.blocks.ImageChooserBlock",
(),
{"label": "360°-bilde"},
),
28: (
"wagtail.blocks.CharBlock",
(),
{
"help_text": "Beskrivende tittel på bildet (vises også til skjermlesere)",
"label": "Tittel",
"max_length": 256,
},
),
29: (
"wagtail.blocks.StructBlock",
[[("image", 27), ("title", 28)]],
{},
),
30: ("wagtail.embeds.blocks.EmbedBlock", (), {}),
31: ("wagtail.blocks.RawHTMLBlock", (), {}),
32: (
"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,
},
),
33: ("dnscms.blocks.NeufAddressSectionBlock", (), {}),
34: ("dnscms.blocks.OpeningHoursSectionBlock", (), {}),
35: (
"wagtail.blocks.StreamBlock",
[
[
("paragraph", 0),
("image", 4),
("image_slider", 8),
("horizontal_rule", 10),
("featured", 18),
("accordion", 23),
("fact_box", 26),
("photo_sphere", 29),
("embed", 30),
("raw_html", 31),
("neuf_address", 33),
("opening_hours", 34),
]
],
{},
),
36: (
"wagtail.blocks.StructBlock",
[
[
("title", 11),
("background_color", 24),
("icon", 32),
("body", 35),
]
],
{},
),
},
),
), ),
] ]
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -2
View File
@@ -4,14 +4,13 @@ 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(HeadlessMixin, Page): class GenericPage(Page):
subpage_types = ["generic.GenericPage"] subpage_types = ["generic.GenericPage"]
show_in_menus = True show_in_menus = True
+1 -2
View File
@@ -10,10 +10,9 @@ 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(HeadlessMixin, Page): class HomePage(Page):
max_count = 1 max_count = 1
content_panels = Page.content_panels + [ content_panels = Page.content_panels + [
+21
View File
@@ -0,0 +1,21 @@
{% 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 %}
@@ -0,0 +1,52 @@
{% 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>
Binary file not shown.
-359
View File
@@ -1,359 +0,0 @@
# 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-26 01:39+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"
msgid "Title"
msgstr "Tittel"
msgid "Type"
msgstr "Type"
msgid "Updated"
msgstr "Oppdatert"
msgid "Status"
msgstr "Status"
msgid "Associations"
msgstr "Foreninger"
msgid "Lead"
msgstr "Ingress"
msgid "Content"
msgstr "Innhold"
msgid "association index"
msgstr "foreningsoversikt"
msgid "association indexes"
msgstr "foreningsoversikter"
msgid "Association"
msgstr "Forening"
msgid "Committee"
msgstr "Utvalg"
msgid "Excerpt"
msgstr "Utdrag"
msgid "A very short summary of the content below. Used in listing views."
msgstr ""
"En veldig kort oppsummering av innholdet nedenfor. Brukes i listevisninger."
msgid "Website"
msgstr "Nettsted"
msgid "association"
msgstr "forening"
msgid "associations"
msgstr "foreninger"
msgid "Choose an association"
msgstr "Velg en forening"
msgid "Choose another association"
msgstr "Velg en annen forening"
msgid "Edit this association"
msgstr "Rediger denne foreningen"
msgid "%Y-%m-%d at %H:%M"
msgstr "%Y-%m-%d kl %H:%M"
#, python-format
msgid "%(count)d occurrence"
msgid_plural "%(count)d occurrences"
msgstr[0] "%(count)d forekomst"
msgstr[1] "%(count)d forekomster"
msgid "Date"
msgstr "Dato"
msgid "Organizers"
msgstr "Arrangører"
msgid "Events"
msgstr "Arrangementer"
msgid "slug"
msgstr "permalenke"
msgid "The name of the category as it will appear in URLs."
msgstr "Navnet på kategorien slik det vil vises i URL-er."
msgid "Should this category be available as a filter in the event programme?"
msgstr "Skal denne kategorien være mulig å filtrere på i programmet?"
msgid "None"
msgstr "Ingen"
msgid "Default pig for events of this kind."
msgstr "Standardgris for arrangementer av denne typen."
msgid "Pig"
msgstr "Gris"
msgid "event category"
msgstr "arrangementskategori"
msgid "event categories"
msgstr "arrangementskategorier"
msgid "organizer"
msgstr "arrangør"
msgid "organizers"
msgstr "arrangører"
msgid "The name of the organizer as it will appear in URLs."
msgstr "Navnet på arrangøren slik det vil vises i URL-er."
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."
msgid "Link to the external organizer's website"
msgstr "Lenke til nettstedet til ekstern arrangør"
msgid "Internal organizer"
msgstr "Intern arrangør"
msgid "External organizer"
msgstr "Ekstern arrangør"
msgid "Leave this empty if the organizer exists in the list above."
msgstr "La denne stå tom om arrangøren finnes i lista over."
msgid "event organizer"
msgstr "arrangør"
msgid "event organizers"
msgstr "arrangører"
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!"
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."
msgid "Automatic"
msgstr "Automatisk"
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."
msgid "Direct link to ticket purchase, e.g. TicketCo, Billetto or Ticketmaster"
msgstr ""
"Lenke direkte til billettkjøp, f.eks. TicketCo, Billetto eller Ticketmaster"
msgid "Direct link to the event on Facebook"
msgstr "Lenke direkte til arrangementet på Facebook"
msgid "Free"
msgstr "Gratis"
msgid "Is this event free for everyone?"
msgstr "Er dette arrangementet gratis for alle?"
msgid "Regular price"
msgstr "Ordinær pris"
msgid "Price for students"
msgstr "Pris for studenter"
msgid "Price for DNS members"
msgstr "Pris for medlemmer av DNS"
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."
msgid "Ticket purchase link"
msgstr "Billettkjøpslenke"
msgid "Subtitle"
msgstr "Undertittel"
msgid "Who is behind the event?"
msgstr "Hvem står bak arrangementet?"
msgid "Organizer"
msgstr "Arrangør"
msgid "Facebook link"
msgstr "Facebook-lenke"
msgid "Direct link to the event on Facebook."
msgstr "Lenke direkte til arrangementet på Facebook."
msgid "Pricing and tickets"
msgstr "Priser og billettkjøp"
msgid "Date, time and venue"
msgstr "Dato, tid og lokale"
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."
msgid "Occurrence"
msgstr "Forekomst"
msgid "event"
msgstr "arrangement"
msgid "events"
msgstr "arrangementer"
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>."
msgid "Start"
msgstr "Start"
msgid "End"
msgstr "Slutt"
msgid "Venue"
msgstr "Lokale"
msgid "Venue as free text"
msgstr "Lokale som fritekst"
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."
msgid "Venue is required."
msgstr "Lokale er påkrevd."
msgid "occurrence"
msgstr "forekomst"
msgid "occurrences"
msgstr "forekomster"
msgid "Choose organizers"
msgstr "Velg arrangører"
msgid "Choose an organizer"
msgstr "Velg en arrangør"
msgid "Choose another organizer"
msgstr "Velg en annen arrangør"
msgid "Edit this organizer"
msgstr "Rediger denne arrangøren"
msgid "image"
msgstr "bilde"
msgid "images"
msgstr "bilder"
msgid "First published"
msgstr "Først publisert"
msgid "News"
msgstr "Nyheter"
msgid "news index"
msgstr "nyhetsoversikt"
msgid "news indexes"
msgstr "nyhetsoversikter"
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."
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."
msgid ""
"A brief, introductory paragraph that summarizes the main content of the "
"article."
msgstr ""
"Et kortfattet, innledende avsnitt som oppsummerer hovedinnholdet i "
"artikkelen."
msgid "news article"
msgstr "nyhetsartikkel"
msgid "news articles"
msgstr "nyhetsartikler"
msgid "Rentals page"
msgstr "Utleieside"
msgid "Venue overview"
msgstr "Lokaleoversikt"
msgid "Venues"
msgstr "Lokaler"
msgid "venue index"
msgstr "lokaleoversikt"
msgid "venue indexes"
msgstr "lokaleoversikter"
msgid "rentals page"
msgstr "utleieside"
msgid "rentals pages"
msgstr "utleiesider"
msgid "venue"
msgstr "lokale"
msgid "venues"
msgstr "lokaler"
#~ msgid "Bookable"
#~ msgstr "Til utleie"
#~ msgid "Listed"
#~ msgstr "I oversikt"
+3 -8
View File
@@ -1,8 +1,3 @@
# Translation tasks [tools]
[tasks.compilemessages] python = "3.14"
description = "Compile translation files for Norwegian Bokmal" uv = "latest"
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"
-52
View File
@@ -1,52 +0,0 @@
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, PageViewSet
from dnscms.admin import ListingRedirectChooseParentView
from news.models import NewsPage
class NewsChooseParentView(ListingRedirectChooseParentView):
listing_url_name = "news:index"
class NewsListingMixin:
"""Shared model + columns for the standalone listing and the page explorer."""
model = NewsPage
icon = "info-circle"
columns = [
PageTitleColumn("title", label=_("Title"), sort_key="title", classname="title"),
DateColumn(
"first_published_at",
label=_("First published"),
sort_key="first_published_at",
width="10%",
),
DateColumn(
"latest_revision_created_at",
label=_("Updated"),
sort_key="latest_revision_created_at",
width="10%",
),
PageStatusColumn("status", label=_("Status"), sort_key="live", width="10%"),
]
class NewsSidebarViewSet(NewsListingMixin, PageListingViewSet):
"""Standalone 'News' sidebar entry, reached independently of the page tree."""
choose_parent_view_class = NewsChooseParentView
menu_label = _("News")
menu_order = 3
add_to_admin_menu = True
ordering = "-latest_revision_created_at"
class NewsExplorerViewSet(NewsListingMixin, PageViewSet):
"""Applies the same columns when navigating into NewsIndex via the page explorer."""
news_sidebar_viewset = NewsSidebarViewSet("news")
news_explorer_viewset = NewsExplorerViewSet()
@@ -1,28 +0,0 @@
# 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'),
),
]
@@ -0,0 +1,253 @@
# Generated by Django 6.0.3 on 2026-04-15 20:21
import wagtail.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("news", "0018_newspage_wp_block_json_newspage_wp_link_and_more"),
]
operations = [
migrations.AlterField(
model_name="newspage",
name="body",
field=wagtail.fields.StreamField(
[
("paragraph", 0),
("image", 4),
("image_slider", 8),
("horizontal_rule", 10),
("featured", 18),
("page_section_navigation", 19),
("accordion", 23),
("fact_box", 26),
("photo_sphere", 29),
("embed", 30),
("raw_html", 31),
],
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.images.blocks.ImageChooserBlock",
(),
{"label": "360°-bilde"},
),
28: (
"wagtail.blocks.CharBlock",
(),
{
"help_text": "Beskrivende tittel på bildet (vises også til skjermlesere)",
"label": "Tittel",
"max_length": 256,
},
),
29: (
"wagtail.blocks.StructBlock",
[[("image", 27), ("title", 28)]],
{},
),
30: ("wagtail.embeds.blocks.EmbedBlock", (), {}),
31: ("wagtail.blocks.RawHTMLBlock", (), {}),
},
default=[("paragraph", "")],
),
),
]
@@ -1,19 +0,0 @@
# Generated by Django 6.0.5 on 2026-07-02 14:53
import wagtail.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('news', '0019_alter_news_options'),
]
operations = [
migrations.AlterField(
model_name='newspage',
name='body',
field=wagtail.fields.StreamField([('paragraph', 0), ('image', 4), ('image_slider', 8), ('horizontal_rule', 10), ('featured', 18), ('page_section_navigation', 19), ('accordion', 23), ('fact_box', 26), ('schedule', 33), ('embed', 34), ('raw_html', 35)], 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.blocks.CharBlock', (), {'help_text': 'Overskrift for timeplanen (valgfritt)', 'label': 'Tittel', 'max_length': 64, 'required': False}), 28: ('wagtail.blocks.CharBlock', (), {'help_text': 'Fritekst, f.eks. "13:00", "20:00 23:00" eller "23.06"', 'label': 'Tidspunkt/dato', 'max_length': 64, 'required': True}), 29: ('wagtail.blocks.RichTextBlock', (), {'features': ['bold', 'italic', 'link'], 'help_text': 'Kort tittel på én linje. Du kan legge inn lenker.', 'label': 'Tittel', 'required': True}), 30: ('wagtail.blocks.RichTextBlock', (), {'features': ['bold', 'italic', 'link'], 'help_text': 'Valgfri tilleggsinformasjon.', 'label': 'Beskrivelse', 'required': False}), 31: ('wagtail.blocks.StructBlock', [[('time', 28), ('title', 29), ('description', 30)]], {}), 32: ('wagtail.blocks.ListBlock', (31,), {'label': 'Punkter', 'min_num': 1}), 33: ('wagtail.blocks.StructBlock', [[('title', 27), ('items', 32)]], {}), 34: ('wagtail.embeds.blocks.EmbedBlock', (), {}), 35: ('wagtail.blocks.RawHTMLBlock', (), {})}, default=[('paragraph', '')]),
),
]
File diff suppressed because one or more lines are too long
@@ -1,19 +0,0 @@
# Generated by Django 6.0.5 on 2026-07-04 22:29
import wagtail.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('news', '0021_alter_newspage_body'),
]
operations = [
migrations.AlterField(
model_name='newspage',
name='body',
field=wagtail.fields.StreamField([('paragraph', 0), ('image', 4), ('image_slider', 8), ('horizontal_rule', 10), ('featured', 18), ('page_section_navigation', 19), ('accordion', 23), ('fact_box', 26), ('schedule', 33), ('embed', 34), ('raw_html', 35)], 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.blocks.CharBlock', (), {'help_text': 'Overskrift for timeplanen (valgfritt)', 'label': 'Tittel', 'max_length': 64, 'required': False}), 28: ('wagtail.blocks.CharBlock', (), {'help_text': 'Fritekst, f.eks. "13:00", "20:00 23:00" eller "23.06"', 'label': 'Tidspunkt/dato', 'max_length': 64, 'required': True}), 29: ('wagtail.blocks.RichTextBlock', (), {'features': ['bold', 'italic', 'link'], 'help_text': 'Kort tittel på én linje. Du kan legge inn lenker.', 'label': 'Tittel', 'required': True}), 30: ('wagtail.blocks.RichTextBlock', (), {'features': ['bold', 'italic', 'link'], 'help_text': 'Valgfri tilleggsinformasjon.', 'label': 'Beskrivelse', 'required': False}), 31: ('wagtail.blocks.StructBlock', [[('time', 28), ('title', 29), ('description', 30)]], {}), 32: ('wagtail.blocks.ListBlock', (31,), {'label': 'Punkter', 'min_num': 1}), 33: ('wagtail.blocks.StructBlock', [[('title', 27), ('items', 32)]], {}), 34: ('wagtail.embeds.blocks.EmbedBlock', (), {}), 35: ('wagtail.blocks.RawHTMLBlock', (), {})}, default=[('paragraph', '')]),
),
]
+60 -32
View File
@@ -1,30 +1,24 @@
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, PageManager from wagtail.models import Page
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 DeferWPFieldsManagerMixin, WPImportedPageMixin from dnscms.wordpress.models import WPImportedPageMixin
class NewsPageManager(DeferWPFieldsManagerMixin, PageManager):
pass
@register_singular_query_field("newsIndex") @register_singular_query_field("newsIndex")
class NewsIndex(HeadlessMixin, Page): class NewsIndex(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=_("Lead")), FieldPanel("lead", heading="Ingress"),
] ]
graphql_fields = [ graphql_fields = [
@@ -33,18 +27,12 @@ class NewsIndex(HeadlessMixin, 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
@@ -54,28 +42,23 @@ class NewsPage(HeadlessMixin, WPImportedPageMixin, Page):
blank=True, blank=True,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
related_name="+", related_name="+",
help_text=_( help_text=(
"Choose an image for use on the front page and other surfaces. " "Velg et bilde til bruk i på forsiden og andre visningsflater. "
"Should be a photo or an illustration without too much text." "Bør være et bilde eller en illustrasjon uten tekst."
), ),
) )
content_panels = Page.content_panels + [ content_panels = Page.content_panels + [
FieldPanel( FieldPanel(
"excerpt", "excerpt",
heading=_("Excerpt"), heading="Utdrag",
help_text=_( help_text="En veldig kort oppsummering av innholdet i artikkelen. Brukes på forsiden og i artikkeloversikten.",
"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=_("Lead"), heading="Ingress",
help_text=_( help_text="Et kortfattet, innledende avsnitt som oppsummerer hovedinnholdet i artikkelen.",
"A brief, introductory paragraph that summarizes the main content of the article."
),
), ),
FieldPanel("body"), FieldPanel("body"),
] ]
@@ -93,6 +76,51 @@ class NewsPage(HeadlessMixin, WPImportedPageMixin, Page):
index.SearchField("body"), index.SearchField("body"),
] ]
class Meta: def import_wordpress_data(self, data):
verbose_name = _("news article") import html
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 "[...]"
-13
View File
@@ -1,13 +0,0 @@
from wagtail import hooks
from .admin import news_sidebar_viewset, news_explorer_viewset
@hooks.register("register_admin_viewset")
def register_news_sidebar_viewset():
return news_sidebar_viewset
@hooks.register("register_admin_viewset")
def register_news_explorer_viewset():
return news_explorer_viewset
+2
View File
@@ -13,6 +13,8 @@ from wagtail.snippets.models import register_snippet
@register_streamfield_block @register_streamfield_block
class OpeningHoursRangeBlock(blocks.StructBlock): class OpeningHoursRangeBlock(blocks.StructBlock):
# closed = blocks.BooleanBlock(required=False, help_text="Kryss av her om lokalet er stengt.")
# blocks.RegexBlock(regex=r'^\d\d:\d\d$', error_messages={'invalid': 'Må være på formatet HH:MM'}, help_text="Tidspunkt på formatet HH:MM")
time_from = blocks.TimeBlock(required=False, label="Åpner") time_from = blocks.TimeBlock(required=False, label="Åpner")
time_to = blocks.TimeBlock(required=False, label="Stenger") time_to = blocks.TimeBlock(required=False, label="Stenger")
custom = blocks.CharBlock( custom = blocks.CharBlock(
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+2
View File
@@ -1 +1,3 @@
from django.shortcuts import render
# Create your views here. # Create your views here.
+9 -29
View File
@@ -4,24 +4,20 @@ version = "0.1.0"
description = "" description = ""
authors = [{ name = "EDB", email = "edb@neuf.no" }] authors = [{ name = "EDB", email = "edb@neuf.no" }]
requires-python = ">=3.14, <3.15" requires-python = ">=3.14, <3.15"
readme = "README.md"
dependencies = [ dependencies = [
"wagtail>=7.4.2,<8", "wagtail>=7.3.1",
"wagtail-grapple>=0.31.0,<0.32", "wagtail-grapple>=0.29.0",
"wagtail-headless-preview>=0.9,<0.10", "django>=6.0.3",
"django>=6.0.6,<7", "django-extensions>=4.1",
"django-extensions>=4.1,<5", "psycopg2-binary>=2.9.11,<3",
"psycopg2-binary>=2.9.12,<3", "gunicorn>=25.1.0",
"gunicorn>=26.0.0,<27", "whitenoise>=6.12.0",
"whitenoise>=6.12.0,<7",
] ]
[dependency-groups] [dependency-groups]
dev = [ dev = [
"ruff>=0.16.1,<0.17", "ruff>=0.15.1",
"pytest>=9.1.1,<10",
"pytest-cov>=7.0.0,<8",
"pytest-django>=4.12.0,<5",
"wagtail-factories>=4.5.0,<5",
] ]
[tool.uv] [tool.uv]
@@ -38,19 +34,3 @@ 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 -2
View File
@@ -2,5 +2,5 @@ from django.apps import AppConfig
class SponsorsConfig(AppConfig): class SponsorsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField" default_auto_field = 'django.db.models.BigAutoField'
name = "sponsors" name = 'sponsors'
@@ -1,4 +1,4 @@
# Generated by Django 6.0.5 on 2026-07-02 14:53 # Generated by Django 6.0.3 on 2026-04-15 20:21
import wagtail.fields import wagtail.fields
from django.db import migrations from django.db import migrations
@@ -7,13 +7,246 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('sponsors', '0001_initial'), ("sponsors", "0001_initial"),
] ]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name='sponsorspage', model_name="sponsorspage",
name='body', name="body",
field=wagtail.fields.StreamField([('paragraph', 0), ('image', 4), ('image_slider', 8), ('horizontal_rule', 10), ('featured', 18), ('page_section_navigation', 19), ('accordion', 23), ('fact_box', 26), ('schedule', 33), ('embed', 34), ('raw_html', 35)], 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.blocks.CharBlock', (), {'help_text': 'Overskrift for timeplanen (valgfritt)', 'label': 'Tittel', 'max_length': 64, 'required': False}), 28: ('wagtail.blocks.CharBlock', (), {'help_text': 'Fritekst, f.eks. "13:00", "20:00 23:00" eller "23.06"', 'label': 'Tidspunkt/dato', 'max_length': 64, 'required': True}), 29: ('wagtail.blocks.RichTextBlock', (), {'features': ['bold', 'italic', 'link'], 'help_text': 'Kort tittel på én linje. Du kan legge inn lenker.', 'label': 'Tittel', 'required': True}), 30: ('wagtail.blocks.RichTextBlock', (), {'features': ['bold', 'italic', 'link'], 'help_text': 'Valgfri tilleggsinformasjon.', 'label': 'Beskrivelse', 'required': False}), 31: ('wagtail.blocks.StructBlock', [[('time', 28), ('title', 29), ('description', 30)]], {}), 32: ('wagtail.blocks.ListBlock', (31,), {'label': 'Punkter', 'min_num': 1}), 33: ('wagtail.blocks.StructBlock', [[('title', 27), ('items', 32)]], {}), 34: ('wagtail.embeds.blocks.EmbedBlock', (), {}), 35: ('wagtail.blocks.RawHTMLBlock', (), {})}), field=wagtail.fields.StreamField(
[
("paragraph", 0),
("image", 4),
("image_slider", 8),
("horizontal_rule", 10),
("featured", 18),
("page_section_navigation", 19),
("accordion", 23),
("fact_box", 26),
("photo_sphere", 29),
("embed", 30),
("raw_html", 31),
],
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.images.blocks.ImageChooserBlock",
(),
{"label": "360°-bilde"},
),
28: (
"wagtail.blocks.CharBlock",
(),
{
"help_text": "Beskrivende tittel på bildet (vises også til skjermlesere)",
"label": "Tittel",
"max_length": 256,
},
),
29: (
"wagtail.blocks.StructBlock",
[[("image", 27), ("title", 28)]],
{},
),
30: ("wagtail.embeds.blocks.EmbedBlock", (), {}),
31: ("wagtail.blocks.RawHTMLBlock", (), {}),
},
),
), ),
] ]
File diff suppressed because one or more lines are too long
@@ -1,19 +0,0 @@
# Generated by Django 6.0.5 on 2026-07-04 22:29
import wagtail.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('sponsors', '0003_alter_sponsorspage_body'),
]
operations = [
migrations.AlterField(
model_name='sponsorspage',
name='body',
field=wagtail.fields.StreamField([('paragraph', 0), ('image', 4), ('image_slider', 8), ('horizontal_rule', 10), ('featured', 18), ('page_section_navigation', 19), ('accordion', 23), ('fact_box', 26), ('schedule', 33), ('embed', 34), ('raw_html', 35)], 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.blocks.CharBlock', (), {'help_text': 'Overskrift for timeplanen (valgfritt)', 'label': 'Tittel', 'max_length': 64, 'required': False}), 28: ('wagtail.blocks.CharBlock', (), {'help_text': 'Fritekst, f.eks. "13:00", "20:00 23:00" eller "23.06"', 'label': 'Tidspunkt/dato', 'max_length': 64, 'required': True}), 29: ('wagtail.blocks.RichTextBlock', (), {'features': ['bold', 'italic', 'link'], 'help_text': 'Kort tittel på én linje. Du kan legge inn lenker.', 'label': 'Tittel', 'required': True}), 30: ('wagtail.blocks.RichTextBlock', (), {'features': ['bold', 'italic', 'link'], 'help_text': 'Valgfri tilleggsinformasjon.', 'label': 'Beskrivelse', 'required': False}), 31: ('wagtail.blocks.StructBlock', [[('time', 28), ('title', 29), ('description', 30)]], {}), 32: ('wagtail.blocks.ListBlock', (31,), {'label': 'Punkter', 'min_num': 1}), 33: ('wagtail.blocks.StructBlock', [[('title', 27), ('items', 32)]], {}), 34: ('wagtail.embeds.blocks.EmbedBlock', (), {}), 35: ('wagtail.blocks.RawHTMLBlock', (), {})}),
),
]
+1 -2
View File
@@ -8,7 +8,6 @@ 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
@@ -35,7 +34,7 @@ class SponsorBlock(blocks.StructBlock):
@register_singular_query_field("sponsorsPage") @register_singular_query_field("sponsorsPage")
class SponsorsPage(HeadlessMixin, Page): class SponsorsPage(Page):
max_count = 1 max_count = 1
subpage_types = [] subpage_types = []
-6
View File
@@ -1,6 +0,0 @@
from django.apps import AppConfig
class StudioConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "studio"
-32
View File
@@ -1,32 +0,0 @@
# 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',),
),
]
@@ -1,19 +0,0 @@
# Generated by Django 6.0.5 on 2026-07-02 14:30
import wagtail.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('studio', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='studiopage',
name='body',
field=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'), ('studioNote', 'Note (STUDiO)'), ('studioCalendar', 'Kalender (STUDiO)'), ('studioGuitar', 'Gitar (STUDiO)'), ('studioDisco', 'Discokule (STUDiO)'), ('studioMole', 'Muldvarpen (STUDiO)')], '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)]], {})}),
),
]
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-64
View File
@@ -1,64 +0,0 @@
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
@@ -1,144 +0,0 @@
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
@@ -1,53 +0,0 @@
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
-603
View File
@@ -1,603 +0,0 @@
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.urls import reverse
from django.utils import timezone
from events.admin import EventDateColumn, OrganizersColumn
from events.models import (
EventCategory,
EventOccurrence,
EventOrganizer,
EventOrganizerLink,
EventPage,
)
from events.views import EventOrganizerCreationForm
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_promotes_matching_custom_text_to_venue(event_index, venue):
event = EventPageFactory(parent=event_index)
occurrence = EventOccurrence(
event=event,
start=timezone.now(),
venue_custom=f" {venue.title} ",
)
occurrence.clean()
assert occurrence.venue_id == venue.pk
assert occurrence.venue_custom == ""
def test_event_organizer_creation_form_auto_slugifies_name(db):
form = EventOrganizerCreationForm(data={"name": "Forening for ÆØÅ", "external_url": ""})
assert form.is_valid(), form.errors
organizer = form.save()
assert organizer.pk is not None
assert organizer.name == "Forening for ÆØÅ"
assert organizer.slug == "forening-for-aeoa"
def test_event_organizer_creation_form_keeps_explicit_slug(db):
organizer = EventOrganizer(name="Forening", slug="custom-slug")
form = EventOrganizerCreationForm(
data={"name": "Forening", "external_url": ""}, instance=organizer
)
assert form.is_valid(), form.errors
organizer = form.save()
assert organizer.slug == "custom-slug"
def test_eventoccurrence_clean_keeps_custom_text_when_no_venue_matches(event_index):
event = EventPageFactory(parent=event_index)
occurrence = EventOccurrence(
event=event,
start=timezone.now(),
venue_custom=" Frederikkeplassen ",
)
occurrence.clean()
assert occurrence.venue is None
assert occurrence.venue_custom == "Frederikkeplassen"
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
@pytest.fixture
def searchable_organizers(db):
return [
EventOrganizer.objects.create(name="Arrangementsutvalget", slug="arrangementsutvalget"),
EventOrganizer.objects.create(name="Cinema Neuf", slug="kino"),
]
@pytest.mark.parametrize(
"url_name",
["event_organizer_chooser:choose", "wagtailsnippets_events_eventorganizer:list"],
)
def test_organizer_search_matches_substrings_of_name_and_slug(
admin_client, searchable_organizers, url_name
):
url = reverse(url_name)
# mid-word match inside a compound name
content = admin_client.get(url, {"q": "utvalget"}).content.decode()
assert "Arrangementsutvalget" in content
assert "Cinema Neuf" not in content
# slug-only match
content = admin_client.get(url, {"q": "kino"}).content.decode()
assert "Cinema Neuf" in content
assert "Arrangementsutvalget" not in content
-71
View File
@@ -1,71 +0,0 @@
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
@@ -1,6 +0,0 @@
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"
-275
View File
@@ -1,275 +0,0 @@
import pytest
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from wagtail.models import ReferenceIndex
from events.merge_organizers import CUSTOM_VALUE, merge_event_organizers
from events.models import EventOrganizer, EventOrganizerLink, EventPage
from tests.conftest import AssociationPageFactory, EventPageFactory
pytestmark = pytest.mark.django_db
@pytest.fixture
def organizers(db):
keeper = EventOrganizer.objects.create(name="Kulturutvalget", slug="kulturutvalget")
goner = EventOrganizer.objects.create(name="KU", slug="ku")
return keeper, goner
@pytest.fixture
def editor_client(client, django_user_model):
user = django_user_model.objects.create_user(username="editor", password="pw")
user.user_permissions.add(Permission.objects.get(codename="access_admin"))
client.force_login(user)
return client
def test_merge_repoints_event_links_and_deletes_loser(event_index, organizers):
keeper, goner = organizers
event = EventPageFactory(parent=event_index)
EventOrganizerLink.objects.create(event=event, organizer=goner)
merged = merge_event_organizers(keeper, goner)
assert merged.pk == keeper.pk
assert not EventOrganizer.objects.filter(pk=goner.pk).exists()
assert list(event.organizer_links.values_list("organizer_id", flat=True)) == [keeper.pk]
def test_lowest_pk_survives_regardless_of_argument_order(organizers):
keeper, goner = organizers
merged = merge_event_organizers(goner, keeper)
assert merged.pk == keeper.pk
assert not EventOrganizer.objects.filter(pk=goner.pk).exists()
def test_merge_applies_chosen_name_and_slug(organizers):
keeper, goner = organizers
merged = merge_event_organizers(keeper, goner, name="KU", slug="ku")
assert merged.name == "KU"
assert merged.slug == "ku"
def test_merge_keeps_survivor_name_and_slug_by_default(organizers):
keeper, goner = organizers
merged = merge_event_organizers(keeper, goner)
assert merged.name == "Kulturutvalget"
assert merged.slug == "kulturutvalget"
def test_merge_collapses_duplicate_links_on_same_event(event_index, organizers):
keeper, goner = organizers
event = EventPageFactory(parent=event_index)
EventOrganizerLink.objects.create(event=event, organizer=keeper)
EventOrganizerLink.objects.create(event=event, organizer=goner)
merge_event_organizers(keeper, goner)
assert list(event.organizer_links.values_list("organizer_id", flat=True)) == [keeper.pk]
def test_merge_fills_blank_fields_from_loser(association_index, organizers):
keeper, goner = organizers
association = AssociationPageFactory(parent=association_index)
goner.association = association
goner.external_url = "https://example.com"
goner.save()
merged = merge_event_organizers(keeper, goner)
assert merged.association_id == association.pk
assert merged.external_url == "https://example.com"
def test_merge_keeps_survivor_fields_when_set(organizers):
keeper, goner = organizers
keeper.external_url = "https://keeper.example.com"
keeper.save()
goner.external_url = "https://goner.example.com"
goner.save()
merged = merge_event_organizers(keeper, goner)
assert merged.external_url == "https://keeper.example.com"
def test_merge_rewrites_event_page_revisions(event_index, organizers):
keeper, goner = organizers
event = EventPageFactory(parent=event_index)
EventOrganizerLink.objects.create(event=event, organizer=goner)
revision = EventPage.objects.get(pk=event.pk).save_revision()
assert [link["organizer"] for link in revision.content["organizer_links"]] == [goner.pk]
merge_event_organizers(keeper, goner)
revision.refresh_from_db()
assert [link["organizer"] for link in revision.content["organizer_links"]] == [keeper.pk]
def test_merge_dedupes_organizers_within_revisions(event_index, organizers):
keeper, goner = organizers
event = EventPageFactory(parent=event_index)
EventOrganizerLink.objects.create(event=event, organizer=keeper)
EventOrganizerLink.objects.create(event=event, organizer=goner)
revision = EventPage.objects.get(pk=event.pk).save_revision()
merge_event_organizers(keeper, goner)
revision.refresh_from_db()
assert [link["organizer"] for link in revision.content["organizer_links"]] == [keeper.pk]
def test_merge_updates_reference_index(event_index, organizers):
keeper, goner = organizers
event = EventPageFactory(parent=event_index)
EventOrganizerLink.objects.create(event=event, organizer=goner)
ReferenceIndex.create_or_update_for_object(EventPage.objects.get(pk=event.pk))
organizer_ct = ContentType.objects.get_for_model(EventOrganizer)
assert ReferenceIndex.objects.filter(
to_content_type=organizer_ct, to_object_id=str(goner.pk)
).exists()
merge_event_organizers(keeper, goner)
assert not ReferenceIndex.objects.filter(
to_content_type=organizer_ct, to_object_id=str(goner.pk)
).exists()
assert ReferenceIndex.objects.filter(
to_content_type=organizer_ct, to_object_id=str(keeper.pk)
).exists()
def test_merge_refuses_same_organizer(organizers):
keeper, _ = organizers
with pytest.raises(ValueError):
merge_event_organizers(keeper, keeper)
def test_merge_views_require_superuser(editor_client, organizers):
keeper, goner = organizers
confirm_url = reverse("events_merge_organizers_confirm")
urls = [
reverse("events_merge_organizers"),
f"{confirm_url}?organizer_a={keeper.pk}&organizer_b={goner.pk}",
]
for url in urls:
response = editor_client.get(url)
assert response.status_code == 302
assert response.url == reverse("wagtailadmin_home")
assert EventOrganizer.objects.filter(pk=goner.pk).exists()
def test_select_view_redirects_to_confirm(admin_client, organizers):
keeper, goner = organizers
response = admin_client.post(
reverse("events_merge_organizers"),
{"organizer_a": keeper.pk, "organizer_b": goner.pk},
)
assert response.status_code == 302
confirm_url = reverse("events_merge_organizers_confirm")
assert response.url == f"{confirm_url}?organizer_a={keeper.pk}&organizer_b={goner.pk}"
def test_select_view_rejects_same_organizer_twice(admin_client, organizers):
keeper, _ = organizers
response = admin_client.post(
reverse("events_merge_organizers"),
{"organizer_a": keeper.pk, "organizer_b": keeper.pk},
)
assert response.status_code == 200
assert "Pick two different organizers." in response.content.decode()
def test_confirm_view_shows_both_organizers(admin_client, organizers):
keeper, goner = organizers
confirm_url = reverse("events_merge_organizers_confirm")
response = admin_client.get(f"{confirm_url}?organizer_a={goner.pk}&organizer_b={keeper.pk}")
content = response.content.decode()
assert response.status_code == 200
assert "Kulturutvalget" in content
assert "KU" in content
def test_confirm_view_rejects_missing_or_equal_organizers(admin_client, organizers):
keeper, _ = organizers
confirm_url = reverse("events_merge_organizers_confirm")
queries = [
"",
f"?organizer_a={keeper.pk}&organizer_b={keeper.pk}",
f"?organizer_a=abc&organizer_b={keeper.pk}",
f"?organizer_a={keeper.pk}&organizer_b=999999",
]
for query in queries:
response = admin_client.get(confirm_url + query)
assert response.status_code == 302
assert response.url == reverse("events_merge_organizers")
def test_confirm_view_merges_with_picked_values(admin_client, event_index, organizers):
keeper, goner = organizers
event = EventPageFactory(parent=event_index)
EventOrganizerLink.objects.create(event=event, organizer=goner)
confirm_url = reverse("events_merge_organizers_confirm")
response = admin_client.post(
f"{confirm_url}?organizer_a={keeper.pk}&organizer_b={goner.pk}",
{"name": goner.name, "name_custom": "", "slug": goner.slug, "slug_custom": ""},
)
assert response.status_code == 302
assert response.url == reverse("wagtailsnippets_events_eventorganizer:list")
keeper.refresh_from_db()
assert keeper.name == "KU"
assert keeper.slug == "ku"
assert not EventOrganizer.objects.filter(pk=goner.pk).exists()
assert list(event.organizer_links.values_list("organizer_id", flat=True)) == [keeper.pk]
def test_confirm_view_merges_with_custom_values(admin_client, organizers):
keeper, goner = organizers
confirm_url = reverse("events_merge_organizers_confirm")
response = admin_client.post(
f"{confirm_url}?organizer_a={keeper.pk}&organizer_b={goner.pk}",
{
"name": CUSTOM_VALUE,
"name_custom": "Kulturutvalget (KU)",
"slug": CUSTOM_VALUE,
"slug_custom": "Kulturutvalget KU",
},
)
assert response.status_code == 302
keeper.refresh_from_db()
assert keeper.name == "Kulturutvalget (KU)"
assert keeper.slug == "kulturutvalget-ku"
def test_confirm_view_requires_custom_value_when_picked(admin_client, organizers):
keeper, goner = organizers
confirm_url = reverse("events_merge_organizers_confirm")
response = admin_client.post(
f"{confirm_url}?organizer_a={keeper.pk}&organizer_b={goner.pk}",
{"name": CUSTOM_VALUE, "name_custom": "", "slug": keeper.slug, "slug_custom": ""},
)
assert response.status_code == 200
assert EventOrganizer.objects.filter(pk=goner.pk).exists()
-32
View File
@@ -1,32 +0,0 @@
from news.admin import NewsSidebarViewSet
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_sidebar_viewset_wired_to_newspage():
assert NewsSidebarViewSet.model is NewsPage
assert NewsSidebarViewSet.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
@@ -1,91 +0,0 @@
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
@@ -1,37 +0,0 @@
"""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"
-127
View File
@@ -1,127 +0,0 @@
import logging
import urllib.error
import pytest
from dnscms import revalidation
from events.models import EventCategory
from tests.conftest import GenericPageFactory
WEBHOOK_URL = "https://frontend.example.com/api/revalidate"
class InlineThread:
def __init__(self, target=None, args=(), kwargs=None, daemon=None):
self._target = target
self._args = args
self._kwargs = kwargs or {}
def start(self):
self._target(*self._args, **self._kwargs)
@pytest.fixture
def webhook_calls(monkeypatch, settings):
settings.REVALIDATE_WEBHOOK_SECRET = "s3cret"
settings.FRONTEND_BASE_URL = "https://frontend.example.com"
calls = []
monkeypatch.setattr(revalidation.threading, "Thread", InlineThread)
monkeypatch.setattr(
revalidation, "_send_webhook", lambda url, secret, reason: calls.append((url, secret))
)
return calls
def test_page_publish_triggers_webhook(
home_page, webhook_calls, django_capture_on_commit_callbacks
):
page = GenericPageFactory(parent=home_page)
with django_capture_on_commit_callbacks(execute=True):
page.save_revision().publish()
assert webhook_calls == [(WEBHOOK_URL, "s3cret")]
def test_page_unpublish_triggers_webhook(
home_page, webhook_calls, django_capture_on_commit_callbacks
):
page = GenericPageFactory(parent=home_page)
page.save_revision().publish()
webhook_calls.clear()
with django_capture_on_commit_callbacks(execute=True):
page.unpublish()
assert webhook_calls == [(WEBHOOK_URL, "s3cret")]
def test_page_delete_triggers_webhook(
home_page, webhook_calls, django_capture_on_commit_callbacks
):
# live=False isolates the post_delete path: deleting a live page would
# also fire page_unpublished (a harmless duplicate purge in production)
page = GenericPageFactory(parent=home_page, live=False)
webhook_calls.clear()
with django_capture_on_commit_callbacks(execute=True):
page.delete()
assert webhook_calls == [(WEBHOOK_URL, "s3cret")]
@pytest.mark.django_db
def test_snippet_save_triggers_webhook(webhook_calls, django_capture_on_commit_callbacks):
with django_capture_on_commit_callbacks(execute=True):
EventCategory.objects.create(name="Konsert", slug="konsert")
assert webhook_calls == [(WEBHOOK_URL, "s3cret")]
def test_unset_secret_skips_and_warns_once(settings, monkeypatch, caplog):
settings.REVALIDATE_WEBHOOK_SECRET = ""
monkeypatch.setattr(revalidation, "_warned_unconfigured", False)
with caplog.at_level(logging.WARNING, logger="dnscms.revalidation"):
revalidation.trigger_frontend_revalidation("test")
revalidation.trigger_frontend_revalidation("test")
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
assert len(warnings) == 1
def test_send_webhook_failure_is_swallowed(monkeypatch, caplog):
def raise_urlerror(request, timeout):
raise urllib.error.URLError("connection refused")
monkeypatch.setattr(revalidation.urllib.request, "urlopen", raise_urlerror)
with caplog.at_level(logging.ERROR, logger="dnscms.revalidation"):
revalidation._send_webhook(WEBHOOK_URL, "s3cret", "test")
assert any("frontend revalidation failed" in r.message for r in caplog.records)
def test_send_webhook_request_shape(monkeypatch):
seen = {}
class FakeResponse:
status = 200
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def fake_urlopen(request, timeout):
seen["request"] = request
seen["timeout"] = timeout
return FakeResponse()
monkeypatch.setattr(revalidation.urllib.request, "urlopen", fake_urlopen)
revalidation._send_webhook(WEBHOOK_URL, "s3cret", "test")
request = seen["request"]
assert request.get_method() == "POST"
assert request.get_header("X-revalidate-secret") == "s3cret"
assert seen["timeout"] == revalidation.WEBHOOK_TIMEOUT_SECONDS
-131
View File
@@ -1,131 +0,0 @@
from wagtail.search.backends import get_search_backend
from tests.conftest import EventPageFactory, GenericPageFactory
SEARCH_QUERY = """
query Search($query: String) {
results: search(query: $query) {
__typename
... on PageInterface {
title
}
}
}
"""
def _index(page):
# Wagtail's post_save signal enqueues indexing via django-tasks, which isn't
# drained synchronously in tests. Call the backend directly so the page is
# findable through the live search code path.
get_search_backend().add(page)
def _titles_for(body, typename):
return [r["title"] for r in body["data"]["results"] if r["__typename"] == typename]
def test_search_returns_live_generic_page(home_page, graphql_post):
page = GenericPageFactory(
parent=home_page,
title="PublishedGenericSearchToken",
slug="published-generic-search",
)
_index(page)
response, body = graphql_post(SEARCH_QUERY, {"query": "PublishedGenericSearchToken"})
assert response.status_code == 200
assert "errors" not in body, body
assert "PublishedGenericSearchToken" in _titles_for(body, "GenericPage")
def test_search_excludes_draft_generic_page(home_page, graphql_post):
page = GenericPageFactory(
parent=home_page,
title="DraftGenericSearchToken",
slug="draft-generic-search",
live=False,
)
_index(page)
response, body = graphql_post(SEARCH_QUERY, {"query": "DraftGenericSearchToken"})
assert response.status_code == 200
assert "errors" not in body, body
assert "DraftGenericSearchToken" not in _titles_for(body, "GenericPage")
def test_search_returns_live_event_page(home_page, event_index, graphql_post):
page = EventPageFactory(
parent=event_index,
title="PublishedEventSearchToken",
slug="published-event-search",
)
_index(page)
response, body = graphql_post(SEARCH_QUERY, {"query": "PublishedEventSearchToken"})
assert response.status_code == 200
assert "errors" not in body, body
assert "PublishedEventSearchToken" in _titles_for(body, "EventPage")
def test_search_excludes_draft_event_page(home_page, event_index, graphql_post):
page = EventPageFactory(
parent=event_index,
title="DraftEventSearchToken",
slug="draft-event-search",
live=False,
)
_index(page)
response, body = graphql_post(SEARCH_QUERY, {"query": "DraftEventSearchToken"})
assert response.status_code == 200
assert "errors" not in body, body
assert "DraftEventSearchToken" not in _titles_for(body, "EventPage")
def test_search_results_not_grouped_by_type(home_page, event_index, graphql_post):
# Two pages of different types matching the query equally, plus a third
# page of one of those types that should rank highest. Under the
# per-model-iteration resolver, all Generic results come before all Event
# results (or vice versa) — type-grouped — so the highest-relevance Event
# ends up after a less-relevant Generic. Cross-type relevance ordering
# should put the strongest match first regardless of type.
weak_generic = GenericPageFactory(
parent=home_page,
title="Klatremus klatremus klatremus",
slug="weak-generic",
)
weak_event = EventPageFactory(
parent=event_index,
title="Klatremus klatremus klatremus",
slug="weak-event",
)
strong_event = EventPageFactory(
parent=event_index,
title="Klatremus klatremus klatremus klatremus klatremus klatremus",
slug="strong-event",
)
_index(weak_generic)
_index(weak_event)
_index(strong_event)
response, body = graphql_post(SEARCH_QUERY, {"query": "klatremus"})
assert response.status_code == 200
assert "errors" not in body, body
order = [
(r["__typename"], r["title"])
for r in body["data"]["results"]
if r["__typename"] in ("GenericPage", "EventPage")
]
assert len(order) == 3, order
# Per-type grouping would put all results of one type consecutively
# before the other type. Cross-type relevance ordering should interleave.
types_seen = [t for t, _ in order]
assert types_seen != ["GenericPage", "EventPage", "EventPage"], order
assert types_seen != ["EventPage", "EventPage", "GenericPage"], order
-41
View File
@@ -1,41 +0,0 @@
import pytest
from dnscms.utils import slugify
from events.models import EventCategory, EventOrganizer
from tests.conftest import GenericPageFactory
def test_slugify_transliterates_norwegian_letters():
assert slugify("Bjørn") == "bjorn"
assert slugify("Møterom") == "moterom"
assert slugify("Forening for ÆØÅ") == "forening-for-aeoa"
def test_slugify_is_idempotent_on_ascii():
assert slugify("already-clean-slug") == "already-clean-slug"
def test_page_save_transliterates_unicode_in_slug(home_page):
page = GenericPageFactory(parent=home_page, title="Møterom", slug="møterom")
assert page.slug == "moterom"
def test_page_save_leaves_clean_slug_untouched(home_page):
page = GenericPageFactory(parent=home_page, title="Om oss", slug="om-oss")
assert page.slug == "om-oss"
@pytest.mark.django_db
def test_event_organizer_save_transliterates_unicode_in_slug():
organizer = EventOrganizer.objects.create(name="Bjørn", slug="bjørn")
assert organizer.slug == "bjorn"
@pytest.mark.django_db
def test_event_category_save_transliterates_unicode_in_slug():
category = EventCategory.objects.create(name="Mørkerom", slug="mørkerom")
assert category.slug == "morkerom"

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