Compare commits

..

3 Commits

Author SHA1 Message Date
ponas 509a50c321 dnscms: add studio page 2026-05-19 06:09:54 +02:00
ponas 843062bb13 dnscms: add more tests 2026-05-19 04:42:27 +02:00
ponas f7e0200a0a dnscms: add pytest-cov 2026-05-19 04:11:39 +02:00
16 changed files with 822 additions and 50 deletions
+1
View File
@@ -2,6 +2,7 @@
.DS_Store
*.swp
.vscode/
.coverage
/venv/
/.venv/
/static/
+2
View File
@@ -37,6 +37,7 @@ INSTALLED_APPS = [
"news",
"openinghours",
"sponsors",
"studio",
# end cms apps
"grapple",
"graphene_django",
@@ -206,6 +207,7 @@ GRAPPLE = {
"news",
"openinghours",
"sponsors",
"studio",
],
"EXPOSE_GRAPHIQL": True,
"PAGE_SIZE": 100,
+12
View File
@@ -18,6 +18,7 @@ dependencies = [
dev = [
"ruff>=0.15.13,<0.16",
"pytest>=9.0.3,<10",
"pytest-cov>=7.0.0,<8",
"pytest-django>=4.12.0,<5",
"wagtail-factories>=4.4.0,<5",
]
@@ -41,3 +42,14 @@ exclude = ["**/migrations/*.py"]
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",
]
View File
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class StudioConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "studio"
+32
View File
@@ -0,0 +1,32 @@
# Generated by Django 6.0.5 on 2026-05-19 02:59
import django.db.models.deletion
import wagtail.fields
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('images', '0005_customimage_description'),
('wagtailcore', '0097_baselogentry_uuid_action_timestamp_indexes'),
]
operations = [
migrations.CreateModel(
name='StudioPage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
('lead', wagtail.fields.RichTextField(blank=True)),
('body', wagtail.fields.StreamField([('paragraph', 0), ('image', 4), ('image_slider', 8), ('horizontal_rule', 10), ('featured', 18), ('page_section_navigation', 19), ('accordion', 23), ('fact_box', 26), ('embed', 27), ('raw_html', 28), ('page_section', 33)], block_lookup={0: ('wagtail.blocks.RichTextBlock', (), {'label': 'Rik tekst'}), 1: ('wagtail.images.blocks.ImageChooserBlock', (), {'label': 'Bilde'}), 2: ('wagtail.blocks.ChoiceBlock', [], {'choices': [('fullwidth', 'Fullbredde'), ('bleed', 'Utfallende'), ('original', 'Uendret størrelse')], 'icon': 'cup', 'label': 'Bildeformat'}), 3: ('wagtail.blocks.CharBlock', (), {'label': 'Bildetekst', 'max_length': 512, 'required': False}), 4: ('wagtail.blocks.StructBlock', [[('image', 1), ('image_format', 2), ('text', 3)]], {}), 5: ('wagtail.blocks.CharBlock', (), {'label': 'Tekst', 'max_length': 512, 'required': False}), 6: ('wagtail.blocks.StructBlock', [[('image', 1), ('text', 5)]], {}), 7: ('wagtail.blocks.ListBlock', (6,), {'label': 'Bilder', 'min_num': 1}), 8: ('wagtail.blocks.StructBlock', [[('images', 7)]], {}), 9: ('wagtail.blocks.ChoiceBlock', [], {'choices': [('deepBrick', 'Dyp tegl'), ('neufPink', 'Griserosa'), ('goldenOrange', 'Gyllen oransje'), ('goldenBeige', 'Gyllen beige'), ('chateauBlue', 'Slottsblå')], 'label': 'Farge', 'required': False}), 10: ('wagtail.blocks.StructBlock', [[('color', 9)]], {}), 11: ('wagtail.blocks.CharBlock', (), {'label': 'Tittel', 'max_length': 64, 'required': True}), 12: ('wagtail.blocks.RichTextBlock', (), {'features': ['bold', 'italic', 'link'], 'label': 'Tekst', 'required': True}), 13: ('wagtail.blocks.PageChooserBlock', (), {'header': 'Fremhevet side', 'required': True}), 14: ('wagtail.blocks.CharBlock', (), {'default': 'Les mer', 'help_text': 'Lenketeksten som tar deg videre til siden. Tips: Ikke start med "Trykk her"', 'label': 'Lenketekst', 'max_length': 64, 'required': True}), 15: ('wagtail.blocks.ChoiceBlock', [], {'choices': [('betongGray', 'Betonggrå'), ('deepBrick', 'Dyp tegl'), ('neufPink', 'Griserosa'), ('goldenOrange', 'Gyllen oransje'), ('goldenBeige', 'Gyllen beige'), ('chateauBlue', 'Slottsblå')], 'label': 'Bakgrunnsfarge'}), 16: ('wagtail.blocks.ChoiceBlock', [], {'choices': [('left', 'Venstre'), ('right', 'Høyre')], 'label': 'Bildeplassering'}), 17: ('wagtail.images.blocks.ImageChooserBlock', (), {'header': 'Overstyr bilde', 'help_text': 'Bildet som er tilknyttet undersiden du vil fremheve, vil automatisk brukes. Om det mangler eller du vil overstyre hvilket bilde som et brukes, kan du velge et her.', 'required': False}), 18: ('wagtail.blocks.StructBlock', [[('title', 11), ('text', 12), ('featured_page', 13), ('link_text', 14), ('background_color', 15), ('image_position', 16), ('featured_image_override', 17)]], {}), 19: ('dnscms.blocks.PageSectionNavigationBlock', (), {}), 20: ('wagtail.blocks.CharBlock', (), {'label': 'Overskrift', 'max_length': 64, 'required': True}), 21: ('wagtail.blocks.StructBlock', [[('image', 1), ('image_format', 2), ('text', 3)]], {'label': 'Bilde'}), 22: ('wagtail.blocks.StreamBlock', [[('paragraph', 0), ('image', 21)]], {}), 23: ('wagtail.blocks.StructBlock', [[('heading', 20), ('body', 22)]], {}), 24: ('wagtail.blocks.ChoiceBlock', [], {'choices': [('betongGray', 'Betonggrå'), ('deepBrick', 'Dyp tegl'), ('neufPink', 'Griserosa'), ('goldenOrange', 'Gyllen oransje'), ('goldenBeige', 'Gyllen beige'), ('chateauBlue', 'Slottsblå')], 'label': 'Bakgrunnsfarge', 'required': False}), 25: ('wagtail.blocks.RichTextBlock', (), {'features': ['bold', 'italic', 'link', 'ol', 'ul', 'h2', 'h3'], 'label': 'Innhold'}), 26: ('wagtail.blocks.StructBlock', [[('background_color', 24), ('body', 25)]], {}), 27: ('wagtail.embeds.blocks.EmbedBlock', (), {}), 28: ('wagtail.blocks.RawHTMLBlock', (), {}), 29: ('wagtail.blocks.ChoiceBlock', [], {'choices': [('pigHeadLogo', 'Grisehodelogo'), ('key', 'Nøkkel'), ('ticket', 'Billett'), ('shield', 'Skjold'), ('bottle', 'Flaske'), ('lostProperty', 'Hittegods'), ('pigsty', 'Grisebinge'), ('wheelchair', 'Rullestol'), ('clock', 'Klokke'), ('parking', 'Parkering'), ('coins', 'Mynter')], 'label': 'Ikon', 'required': False}), 30: ('dnscms.blocks.NeufAddressSectionBlock', (), {}), 31: ('dnscms.blocks.OpeningHoursSectionBlock', (), {}), 32: ('wagtail.blocks.StreamBlock', [[('paragraph', 0), ('image', 4), ('image_slider', 8), ('horizontal_rule', 10), ('featured', 18), ('accordion', 23), ('fact_box', 26), ('embed', 27), ('raw_html', 28), ('neuf_address', 30), ('opening_hours', 31)]], {}), 33: ('wagtail.blocks.StructBlock', [[('title', 11), ('background_color', 24), ('icon', 29), ('body', 32)]], {})})),
('pig', models.CharField(blank=True, choices=[('', 'Ingen'), ('logo', 'Logogrisen'), ('music', 'Musikergrisen'), ('drink', 'Drikkegrisen'), ('dance', 'Dansegrisen'), ('point', 'Pekegrisen'), ('student', 'Studentgrisen'), ('listen', 'Lyttegrisen'), ('guard', 'Vaktgrisen'), ('key', 'Nøkkelgrisen'), ('chill', 'Liggegrisen'), ('peek', 'Tittegrisen')], default='', help_text='Grisen nedi hjørnet.', max_length=32)),
('logo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='images.customimage')),
],
options={
'abstract': False,
},
bases=('wagtailcore.page',),
),
]
+63
View File
@@ -0,0 +1,63 @@
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 dnscms.blocks import BASE_BLOCKS, PageSectionBlock
from dnscms.options import ALL_PIGS
@register_singular_query_field("studioPage")
class StudioPage(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"),
]
+66
View File
@@ -5,10 +5,36 @@ 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}")
@@ -23,6 +49,36 @@ class EventPageFactory(wagtail_factories.PageFactory):
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}")
@@ -56,6 +112,16 @@ 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)
+319 -18
View File
@@ -1,16 +1,21 @@
from datetime import timedelta
from datetime import datetime, timedelta
import pytest
from django.core.exceptions import ValidationError
from django.utils import timezone
from events.models import (
EventCategory,
EventOccurrence,
EventOrganizer,
EventOrganizerLink,
EventPage,
)
from tests.conftest import EventPageFactory
from tests.conftest import (
AssociationPageFactory,
CustomImageFactory,
EventPageFactory,
)
def test_eventpage_clean_unsets_specific_pricing_when_free():
@@ -30,6 +35,58 @@ def test_eventpage_clean_unsets_specific_pricing_when_free():
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(
@@ -57,14 +114,10 @@ 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"
)
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"
)
EventOccurrence.objects.create(event=future, start=now + timedelta(days=7), venue_custom="New")
results = list(EventPage.objects.live().future().order_by("next_occurrence"))
@@ -72,17 +125,265 @@ def test_eventpage_manager_future_filters_past_and_annotates(event_index):
assert results[0].next_occurrence is not None
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")
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)
EventOrganizerLink.objects.create(event=event, organizer=org_a)
EventOrganizerLink.objects.create(event=event, organizer=org_b)
event = EventPageFactory(parent=event_index, title="Late today")
EventOccurrence.objects.create(event=event, start=late_today, venue_custom="X")
event = EventPage.objects.get(pk=event.pk)
assert event.organizer_links.count() == 2
assert event.pk in EventPage.objects.future().values_list("pk", flat=True)
event.clean()
assert event.organizer_links.count() == 1
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_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")
@pytest.fixture
def comprehensive_event(event_index, venue, association_index):
"""A fully-populated paid EventPage exercising every field exposed via GraphQL."""
image = CustomImageFactory(
title="Cover",
alt="Et fotografi av en gris med solbriller",
attribution="Foto: Test",
)
konsert = EventCategory.objects.create(
name="Konsert", slug="konsert", show_in_filters=True, pig="pigHeadLogo"
)
klubb = EventCategory.objects.create(name="Klubb", slug="klubb")
association = AssociationPageFactory(
parent=association_index,
title="Internal",
association_type="forening",
)
internal_org = EventOrganizer.objects.create(
name="Internal", slug="internal", association=association
)
external_org = EventOrganizer.objects.create(
name="External",
slug="external",
external_url="https://external.example.com",
)
event = EventPageFactory(
parent=event_index,
title="Et arrangement",
slug="et-arrangement",
subtitle="En undertekst",
lead="<p>Ingress.</p>",
body=[("paragraph", "<p>Body content.</p>")],
pig="automatic",
free=False,
price_regular="150",
price_student="100",
price_member="75",
ticket_url="https://example.com/tickets",
facebook_url="https://facebook.com/example",
featured_image=image,
)
event.categories.add(konsert, klubb)
EventOrganizerLink.objects.create(event=event, organizer=internal_org)
EventOrganizerLink.objects.create(event=event, organizer=external_org)
now = timezone.now()
EventOccurrence.objects.create(
event=event,
start=now + timedelta(days=5),
end=now + timedelta(days=5, hours=3),
venue=venue,
)
EventOccurrence.objects.create(
event=event,
start=now + timedelta(days=12),
end=now + timedelta(days=12, hours=2),
venue_custom="Frederikkeplassen",
)
event.save()
return event
def test_graphql_event_index_returns_all_fields_for_comprehensive_event(
comprehensive_event, graphql_post
):
response, body = graphql_post(
"""
query {
eventIndex {
futureEvents {
title
slug
subtitle
lead
body {
blockType
field
... on RichTextBlock {
value
}
}
pig
free
priceRegular
priceStudent
priceMember
ticketUrl
facebookUrl
featuredImage {
alt
attribution
}
categories {
name
slug
showInFilters
pig
}
organizers {
name
slug
externalUrl
association {
title
}
}
occurrences {
start
end
venueCustom
venue {
title
}
}
}
}
}
"""
)
assert response.status_code == 200
assert "errors" not in body, body
events = body["data"]["eventIndex"]["futureEvents"]
event = next(e for e in events if e["title"] == "Et arrangement")
assert event["slug"] == "et-arrangement"
assert event["subtitle"] == "En undertekst"
assert "Ingress." in event["lead"]
assert event["pig"] == "automatic"
assert event["free"] is False
assert event["priceRegular"] == "150"
assert event["priceStudent"] == "100"
assert event["priceMember"] == "75"
assert event["ticketUrl"] == "https://example.com/tickets"
assert event["facebookUrl"] == "https://facebook.com/example"
assert event["featuredImage"]["alt"] == "Et fotografi av en gris med solbriller"
assert event["featuredImage"]["attribution"] == "Foto: Test"
assert event["body"][0]["blockType"] == "RichTextBlock"
assert "Body content." in event["body"][0]["value"]
categories_by_name = {c["name"]: c for c in event["categories"]}
assert set(categories_by_name) == {"Konsert", "Klubb"}
assert categories_by_name["Konsert"]["slug"] == "konsert"
assert categories_by_name["Konsert"]["showInFilters"] is True
assert categories_by_name["Konsert"]["pig"] == "pigHeadLogo"
assert categories_by_name["Klubb"]["showInFilters"] is False
organizers_by_name = {o["name"]: o for o in event["organizers"]}
assert set(organizers_by_name) == {"Internal", "External"}
assert organizers_by_name["Internal"]["association"]["title"] == "Internal"
assert organizers_by_name["Internal"]["externalUrl"] == ""
assert organizers_by_name["External"]["association"] is None
assert organizers_by_name["External"]["externalUrl"] == "https://external.example.com"
assert len(event["occurrences"]) == 2
venue_occ = next(o for o in event["occurrences"] if o["venue"] is not None)
custom_occ = next(o for o in event["occurrences"] if o["venueCustom"])
assert venue_occ["venueCustom"] == ""
assert venue_occ["venue"]["title"]
assert custom_occ["venue"] is None
assert custom_occ["venueCustom"] == "Frederikkeplassen"
venue_occ_db = comprehensive_event.occurrences.exclude(venue=None).get()
custom_occ_db = comprehensive_event.occurrences.exclude(venue_custom="").get()
assert datetime.fromisoformat(venue_occ["start"]) == venue_occ_db.start
assert datetime.fromisoformat(venue_occ["end"]) == venue_occ_db.end
assert datetime.fromisoformat(custom_occ["start"]) == custom_occ_db.start
assert datetime.fromisoformat(custom_occ["end"]) == custom_occ_db.end
+71
View File
@@ -0,0 +1,71 @@
from generic.models import GenericPage
from tests.conftest import GenericPageFactory
def test_generic_page_persists_via_factory(home_page):
page = GenericPageFactory(
parent=home_page,
title="Om oss",
slug="om-oss",
lead="<p>Ingress.</p>",
body=[("paragraph", "<p>Body content.</p>")],
pig="drink",
)
reloaded = GenericPage.objects.get(pk=page.pk)
assert reloaded.title == "Om oss"
assert reloaded.slug == "om-oss"
assert "Ingress." in reloaded.lead
assert reloaded.pig == "drink"
assert reloaded.body[0].block_type == "paragraph"
def test_generic_page_allows_recursive_children(home_page):
parent = GenericPageFactory(parent=home_page, title="Parent", slug="parent")
child = GenericPageFactory(parent=parent, title="Child", slug="child")
assert child.get_parent().specific == parent
assert list(parent.get_children().specific()) == [child]
def test_graphql_generic_page_query(home_page, graphql_post):
GenericPageFactory(
parent=home_page,
title="Om oss",
slug="om-oss",
lead="<p>Ingress text.</p>",
body=[("paragraph", "<p>Body content.</p>")],
pig="drink",
)
response, body = graphql_post(
"""
query {
page(slug: "om-oss", contentType: "generic.GenericPage") {
title
slug
... on GenericPage {
lead
pig
body {
blockType
field
... on RichTextBlock {
value
}
}
}
}
}
"""
)
assert response.status_code == 200
assert "errors" not in body, body
data = body["data"]["page"]
assert data["title"] == "Om oss"
assert data["slug"] == "om-oss"
assert "Ingress text." in data["lead"]
assert data["pig"] == "drink"
assert data["body"][0]["blockType"] == "RichTextBlock"
assert "Body content." in data["body"][0]["value"]
-32
View File
@@ -1,38 +1,6 @@
from datetime import timedelta
from django.utils import timezone
from events.models import EventOccurrence
from tests.conftest import EventPageFactory
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"
def test_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
+26
View File
@@ -0,0 +1,26 @@
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_graphql_news_index_query(news_index, graphql_post):
response, body = graphql_post(
"""
query {
newsIndex {
title
}
}
"""
)
assert response.status_code == 200
assert "errors" not in body, body
assert body["data"]["newsIndex"]["title"] == news_index.title
+91
View File
@@ -0,0 +1,91 @@
import datetime
import pytest
from openinghours.models import OpeningHoursItem, OpeningHoursSet
@pytest.fixture
def opening_hours_set(db):
return OpeningHoursSet.objects.create(
name="Vanlige åpningstider",
effective_from=datetime.date(2025, 1, 1),
)
def test_opening_hours_set_str_with_end_date():
ohs = OpeningHoursSet(
name="Sommer",
effective_from=datetime.date(2025, 6, 1),
effective_to=datetime.date(2025, 8, 31),
)
assert str(ohs) == "Sommer (2025-06-01 - 2025-08-31)"
def test_opening_hours_set_str_uses_infinity_when_open_ended():
ohs = OpeningHoursSet(
name="Forever",
effective_from=datetime.date(2025, 1, 1),
effective_to=None,
)
assert str(ohs) == "Forever (2025-01-01 - ∞)"
def test_opening_hours_streamfield_week_roundtrip(opening_hours_set):
OpeningHoursItem.objects.create(
opening_hours_set=opening_hours_set,
function="glassbaren",
week=[
(
"week",
{
"monday": {
"time_from": datetime.time(15, 0),
"time_to": datetime.time(23, 0),
"custom": "",
},
"tuesday": {"time_from": None, "time_to": None, "custom": "Stengt"},
"wednesday": {"time_from": None, "time_to": None, "custom": ""},
"thursday": {"time_from": None, "time_to": None, "custom": ""},
"friday": {"time_from": None, "time_to": None, "custom": ""},
"saturday": {"time_from": None, "time_to": None, "custom": ""},
"sunday": {"time_from": None, "time_to": None, "custom": ""},
},
),
],
)
reloaded = OpeningHoursSet.objects.get(pk=opening_hours_set.pk)
item = reloaded.items.get()
assert item.function == "glassbaren"
week_block = item.week[0]
assert week_block.block_type == "week"
assert week_block.value["monday"]["time_from"] == datetime.time(15, 0)
assert week_block.value["monday"]["time_to"] == datetime.time(23, 0)
assert week_block.value["tuesday"]["custom"] == "Stengt"
def test_graphql_opening_hours_sets_query(db, graphql_post):
OpeningHoursSet.objects.create(
name="Sommer 2025",
effective_from=datetime.date(2025, 6, 1),
effective_to=datetime.date(2025, 8, 31),
)
response, body = graphql_post(
"""
query {
openingHoursSets {
name
effectiveFrom
effectiveTo
}
}
"""
)
assert response.status_code == 200
assert "errors" not in body, body
names = [s["name"] for s in body["data"]["openingHoursSets"]]
assert "Sommer 2025" in names
+78
View File
@@ -0,0 +1,78 @@
from studio.models import StudioPage
from tests.conftest import CustomImageFactory, StudioPageFactory
def test_studio_page_persists_via_factory(home_page):
logo = CustomImageFactory()
page = StudioPageFactory(
parent=home_page,
title="STUDiO",
slug="studio",
lead="<p>Ingress.</p>",
body=[("paragraph", "<p>Body content.</p>")],
pig="drink",
logo=logo,
)
reloaded = StudioPage.objects.get(pk=page.pk)
assert reloaded.title == "STUDiO"
assert reloaded.slug == "studio"
assert "Ingress." in reloaded.lead
assert reloaded.pig == "drink"
assert reloaded.body[0].block_type == "paragraph"
assert reloaded.logo == logo
def test_studio_page_is_singleton(home_page):
StudioPageFactory(parent=home_page, slug="studio")
assert StudioPage.can_create_at(home_page) is False
def test_graphql_studio_page_query(home_page, graphql_post):
logo = CustomImageFactory(alt="STUDiO-logo")
StudioPageFactory(
parent=home_page,
title="STUDiO",
slug="studio",
lead="<p>Ingress text.</p>",
body=[("paragraph", "<p>Body content.</p>")],
pig="drink",
logo=logo,
)
response, body = graphql_post(
"""
query {
page: studioPage {
... on StudioPage {
title
slug
lead
pig
logo {
alt
}
body {
blockType
field
... on RichTextBlock {
value
}
}
}
}
}
"""
)
assert response.status_code == 200
assert "errors" not in body, body
data = body["data"]["page"]
assert data["title"] == "STUDiO"
assert data["slug"] == "studio"
assert "Ingress text." in data["lead"]
assert data["pig"] == "drink"
assert data["logo"]["alt"] == "STUDiO-logo"
assert data["body"][0]["blockType"] == "RichTextBlock"
assert "Body content." in data["body"][0]["value"]
+55
View File
@@ -71,6 +71,45 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "coverage"
version = "7.14.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1c/18/b9a6586d73992807c26f9a5f274131be3d76b56b18a82b9392e2a25d2e45/coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d", size = 220036, upload-time = "2026-05-10T18:01:33.057Z" },
{ url = "https://files.pythonhosted.org/packages/f3/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63", size = 220368, upload-time = "2026-05-10T18:01:34.705Z" },
{ url = "https://files.pythonhosted.org/packages/69/aa/c12e52a5ba148d9995229d557e3be6e554fe469addc0e9241b2f0956d8ea/coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212", size = 251417, upload-time = "2026-05-10T18:01:36.949Z" },
{ url = "https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3", size = 253924, upload-time = "2026-05-10T18:01:38.985Z" },
{ url = "https://files.pythonhosted.org/packages/33/c4/59c3de0bd1b538824173fd518fed51c1ce740ca5ed68e74545983f4053a9/coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97", size = 255269, upload-time = "2026-05-10T18:01:40.957Z" },
{ url = "https://files.pythonhosted.org/packages/7b/a9/36dfa153a62040296f6e7febfdb20a5720622f6ef5a81a41e8237b9a5344/coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8", size = 257583, upload-time = "2026-05-10T18:01:42.607Z" },
{ url = "https://files.pythonhosted.org/packages/26/7b/cc2c048d4114d9ab1c2409e9ee365e5ae10736df6dffcfc9444effa6c708/coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb", size = 251434, upload-time = "2026-05-10T18:01:44.537Z" },
{ url = "https://files.pythonhosted.org/packages/ee/df/6770eaa576e604575e9a78055313250faef5faa84bd6f71a39fece519c43/coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe", size = 253280, upload-time = "2026-05-10T18:01:46.175Z" },
{ url = "https://files.pythonhosted.org/packages/ad/9e/1c0264514a3f98259a6d64765a397b2c8373e3ba59ee722a4802d3ec0c61/coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa", size = 251241, upload-time = "2026-05-10T18:01:48.732Z" },
{ url = "https://files.pythonhosted.org/packages/64/16/4efdf3e3c4079cdbf0ece56a2fea872df9e8a3e15a13a0af4400e1075944/coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5", size = 255516, upload-time = "2026-05-10T18:01:50.819Z" },
{ url = "https://files.pythonhosted.org/packages/93/69/b1de96346603881b3d1bc8d6447c83200e1c9700ffbaff926ba01ff5724c/coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c", size = 251059, upload-time = "2026-05-10T18:01:52.773Z" },
{ url = "https://files.pythonhosted.org/packages/a4/66/2881853e0363a5e0a724d1103e53650795367471b6afb234f8b49e713bc6/coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca", size = 252716, upload-time = "2026-05-10T18:01:54.506Z" },
{ url = "https://files.pythonhosted.org/packages/55/5c/0d3305d002c41dcde873dbe456491e663dc55152ca526b630b5c47efd62f/coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828", size = 222788, upload-time = "2026-05-10T18:01:56.487Z" },
{ url = "https://files.pythonhosted.org/packages/f9/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d", size = 223600, upload-time = "2026-05-10T18:01:58.497Z" },
{ url = "https://files.pythonhosted.org/packages/00/70/a18c408e674bc26281cadaedc7351f929bd2094e191e4b15271c30b084cc/coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9", size = 222168, upload-time = "2026-05-10T18:02:00.411Z" },
{ url = "https://files.pythonhosted.org/packages/3d/89/2681f071d238b62aff8dfc2ab44fc24cfdb38d1c01f391a80522ff5d3a16/coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1", size = 220766, upload-time = "2026-05-10T18:02:02.313Z" },
{ url = "https://files.pythonhosted.org/packages/bd/c7/c987babafd9207ffa1995e1ef1f9b26762cf4963aa768a66b6f0501e4616/coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c", size = 221035, upload-time = "2026-05-10T18:02:04.017Z" },
{ url = "https://files.pythonhosted.org/packages/5a/e9/d6a5ac3b333088143d6fc877d398a9a674dc03124a2f776e131f03864823/coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84", size = 262405, upload-time = "2026-05-10T18:02:05.915Z" },
{ url = "https://files.pythonhosted.org/packages/38/b1/e70838d29a7c08e22d44398a46db90815bbcbf28de06992bd9210d1a8d8e/coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436", size = 264530, upload-time = "2026-05-10T18:02:07.582Z" },
{ url = "https://files.pythonhosted.org/packages/6b/73/5c31ef97763288d03d9995152b96d5475b527c63d91c84b01caea894b83a/coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a", size = 266932, upload-time = "2026-05-10T18:02:09.401Z" },
{ url = "https://files.pythonhosted.org/packages/e1/76/dd56d80f29c5f05b4d76f7e7c6d47cafacae017189c75c5759d24f9ff0cc/coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f", size = 268062, upload-time = "2026-05-10T18:02:11.399Z" },
{ url = "https://files.pythonhosted.org/packages/6e/c7/27ba85cd5b95614f159ff93ebff1901584a8d192e2e5e24c4943a7453f59/coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb", size = 261504, upload-time = "2026-05-10T18:02:13.257Z" },
{ url = "https://files.pythonhosted.org/packages/13/2e/e8149f60ab5d5684c6eee881bdf34b127115cddbb958b196768dd9d63473/coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490", size = 264398, upload-time = "2026-05-10T18:02:15.063Z" },
{ url = "https://files.pythonhosted.org/packages/d9/7f/1261b025285323225f4b4abffa5a643649dfd67e25ddca7ebcbdea3b7cb3/coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9", size = 262000, upload-time = "2026-05-10T18:02:16.756Z" },
{ url = "https://files.pythonhosted.org/packages/d3/dc/829c54f60b9d08389439c00f813c752781c496fc5788c78d8006db4b4f2b/coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020", size = 265732, upload-time = "2026-05-10T18:02:18.817Z" },
{ url = "https://files.pythonhosted.org/packages/ed/b0/70bd1419941652fa062689cba9c3eeafb8f5e6fbb890bce41c3bdda5dbd6/coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6", size = 260847, upload-time = "2026-05-10T18:02:20.528Z" },
{ url = "https://files.pythonhosted.org/packages/f2/73/be40b2390656c654d35ea0015ea7ba3d945769cf80790ad5e0bb2d56d2ba/coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db", size = 263166, upload-time = "2026-05-10T18:02:22.337Z" },
{ url = "https://files.pythonhosted.org/packages/29/55/4a643f712fcf7cf2881f8ec1e0ccb7b164aff3108f69b51801246c8799f2/coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2", size = 223573, upload-time = "2026-05-10T18:02:24.11Z" },
{ url = "https://files.pythonhosted.org/packages/27/96/3acae5da0953be042c0b4dea6d6789d2f080701c77b88e44d5bd41b9219b/coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644", size = 224680, upload-time = "2026-05-10T18:02:25.896Z" },
{ url = "https://files.pythonhosted.org/packages/93/3d/6ab5d2dd8325d838737c6f8d83d62eb6230e0d70b87b51b57bbfd08fa767/coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b", size = 222703, upload-time = "2026-05-10T18:02:27.822Z" },
{ url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" },
]
[[package]]
name = "defusedxml"
version = "0.7.1"
@@ -222,6 +261,7 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "pytest-django" },
{ name = "ruff" },
{ name = "wagtail-factories" },
@@ -241,6 +281,7 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=9.0.3,<10" },
{ name = "pytest-cov", specifier = ">=7.0.0,<8" },
{ name = "pytest-django", specifier = ">=4.12.0,<5" },
{ name = "ruff", specifier = ">=0.15.13,<0.16" },
{ name = "wagtail-factories", specifier = ">=4.4.0,<5" },
@@ -536,6 +577,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "pytest-cov"
version = "7.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
]
[[package]]
name = "pytest-django"
version = "4.12.0"