From 98547f5e0af1432f2433531f9282f3265994d929 Mon Sep 17 00:00:00 2001 From: Floris272 Date: Tue, 26 Nov 2024 14:17:25 +0100 Subject: [PATCH 1/8] Add producttypen app --- pyproject.toml | 2 +- requirements/base.in | 1 - src/open_producten/conf/base.py | 2 +- src/open_producten/producttypen/__init__.py | 0 .../producttypen/admin/__init__.py | 17 + .../producttypen/admin/bestand.py | 17 + src/open_producten/producttypen/admin/link.py | 17 + .../producttypen/admin/onderwerp.py | 79 +++ .../producttypen/admin/prijs.py | 35 ++ .../producttypen/admin/producttype.py | 61 +++ src/open_producten/producttypen/admin/upn.py | 19 + .../producttypen/admin/vraag.py | 34 ++ src/open_producten/producttypen/apps.py | 6 + .../producttypen/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../management/commands/load_upl.py | 68 +++ .../producttypen/management/parsers.py | 29 ++ .../producttypen/migrations/0001_initial.py | 468 ++++++++++++++++++ .../producttypen/migrations/__init__.py | 0 .../producttypen/models/__init__.py | 19 + .../producttypen/models/bestand.py | 24 + .../producttypen/models/link.py | 27 + .../producttypen/models/onderwerp.py | 67 +++ .../producttypen/models/prijs.py | 63 +++ .../producttypen/models/producttype.py | 120 +++++ src/open_producten/producttypen/models/upn.py | 31 ++ .../producttypen/models/vraag.py | 51 ++ .../producttypen/tests/__init__.py | 0 .../producttypen/tests/data/upl.csv | 2 + .../producttypen/tests/data/wrong-upl.csv | 2 + .../producttypen/tests/factories.py | 86 ++++ .../producttypen/tests/test_load_upn.py | 101 ++++ .../tests/test_onderwerp_admin.py | 88 ++++ .../producttypen/tests/test_prijs.py | 42 ++ .../producttypen/tests/test_producttype.py | 46 ++ .../tests/test_producttype_admin.py | 30 ++ .../producttypen/tests/test_vraag.py | 25 + src/open_producten/utils/models.py | 6 +- 38 files changed, 1679 insertions(+), 6 deletions(-) create mode 100644 src/open_producten/producttypen/__init__.py create mode 100644 src/open_producten/producttypen/admin/__init__.py create mode 100644 src/open_producten/producttypen/admin/bestand.py create mode 100644 src/open_producten/producttypen/admin/link.py create mode 100644 src/open_producten/producttypen/admin/onderwerp.py create mode 100644 src/open_producten/producttypen/admin/prijs.py create mode 100644 src/open_producten/producttypen/admin/producttype.py create mode 100644 src/open_producten/producttypen/admin/upn.py create mode 100644 src/open_producten/producttypen/admin/vraag.py create mode 100644 src/open_producten/producttypen/apps.py create mode 100644 src/open_producten/producttypen/management/__init__.py create mode 100644 src/open_producten/producttypen/management/commands/__init__.py create mode 100644 src/open_producten/producttypen/management/commands/load_upl.py create mode 100644 src/open_producten/producttypen/management/parsers.py create mode 100644 src/open_producten/producttypen/migrations/0001_initial.py create mode 100644 src/open_producten/producttypen/migrations/__init__.py create mode 100644 src/open_producten/producttypen/models/__init__.py create mode 100644 src/open_producten/producttypen/models/bestand.py create mode 100644 src/open_producten/producttypen/models/link.py create mode 100644 src/open_producten/producttypen/models/onderwerp.py create mode 100644 src/open_producten/producttypen/models/prijs.py create mode 100644 src/open_producten/producttypen/models/producttype.py create mode 100644 src/open_producten/producttypen/models/upn.py create mode 100644 src/open_producten/producttypen/models/vraag.py create mode 100644 src/open_producten/producttypen/tests/__init__.py create mode 100644 src/open_producten/producttypen/tests/data/upl.csv create mode 100644 src/open_producten/producttypen/tests/data/wrong-upl.csv create mode 100644 src/open_producten/producttypen/tests/factories.py create mode 100644 src/open_producten/producttypen/tests/test_load_upn.py create mode 100644 src/open_producten/producttypen/tests/test_onderwerp_admin.py create mode 100644 src/open_producten/producttypen/tests/test_prijs.py create mode 100644 src/open_producten/producttypen/tests/test_producttype.py create mode 100644 src/open_producten/producttypen/tests/test_producttype_admin.py create mode 100644 src/open_producten/producttypen/tests/test_vraag.py diff --git a/pyproject.toml b/pyproject.toml index 37bd05b..c38f7f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [project] -requires-python = "3.11" +requires-python = '>= 3.11' [tool.uv.pip] emit-index-url = false diff --git a/requirements/base.in b/requirements/base.in index c1da701..bd40142 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -10,7 +10,6 @@ django-treebeard # django-extra-fields django-tinymce geopy -django-open-forms-client django-localflavor # waiting for > 2.0.1 diff --git a/src/open_producten/conf/base.py b/src/open_producten/conf/base.py index 183bf5b..dcf2625 100644 --- a/src/open_producten/conf/base.py +++ b/src/open_producten/conf/base.py @@ -24,7 +24,7 @@ "django.contrib.gis", "open_producten.accounts", "open_producten.utils", - # "open_producten.producttypen", + "open_producten.producttypen", # "open_producten.products", # "open_producten.locations", ] diff --git a/src/open_producten/producttypen/__init__.py b/src/open_producten/producttypen/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/open_producten/producttypen/admin/__init__.py b/src/open_producten/producttypen/admin/__init__.py new file mode 100644 index 0000000..25b3513 --- /dev/null +++ b/src/open_producten/producttypen/admin/__init__.py @@ -0,0 +1,17 @@ +from .bestand import BestandAdmin +from .link import LinkAdmin +from .onderwerp import OnderwerpAdmin +from .prijs import PrijsAdmin +from .producttype import ProductTypeAdmin +from .upn import UniformeProductNaamAdmin +from .vraag import VraagAdmin + +__all__ = [ + "ProductTypeAdmin", + "BestandAdmin", + "LinkAdmin", + "VraagAdmin", + "UniformeProductNaamAdmin", + "PrijsAdmin", + "OnderwerpAdmin", +] diff --git a/src/open_producten/producttypen/admin/bestand.py b/src/open_producten/producttypen/admin/bestand.py new file mode 100644 index 0000000..96ad76b --- /dev/null +++ b/src/open_producten/producttypen/admin/bestand.py @@ -0,0 +1,17 @@ +from django.contrib import admin + +from ..models import Bestand + + +class BestandInline(admin.TabularInline): + model = Bestand + extra = 1 + + +@admin.register(Bestand) +class BestandAdmin(admin.ModelAdmin): + list_display = ("product_type", "bestand") + list_filter = ("product_type",) + + def get_queryset(self, request): + return super().get_queryset(request).select_related("product_type") diff --git a/src/open_producten/producttypen/admin/link.py b/src/open_producten/producttypen/admin/link.py new file mode 100644 index 0000000..e56b566 --- /dev/null +++ b/src/open_producten/producttypen/admin/link.py @@ -0,0 +1,17 @@ +from django.contrib import admin + +from ..models import Link + + +class LinkInline(admin.TabularInline): + model = Link + extra = 1 + ordering = ("pk",) + + +@admin.register(Link) +class LinkAdmin(admin.ModelAdmin): + list_display = ("product_type", "naam", "url") + + def get_queryset(self, request): + return super().get_queryset(request).select_related("product_type") diff --git a/src/open_producten/producttypen/admin/onderwerp.py b/src/open_producten/producttypen/admin/onderwerp.py new file mode 100644 index 0000000..7881ed0 --- /dev/null +++ b/src/open_producten/producttypen/admin/onderwerp.py @@ -0,0 +1,79 @@ +from django import forms +from django.contrib import admin +from django.forms import BaseModelFormSet +from django.utils.translation import gettext as _ + +from treebeard.admin import TreeAdmin +from treebeard.forms import movenodeform_factory + +from ..models import Onderwerp, OnderwerpProductType +from .vraag import VraagInline + + +class ProductTypeInline(admin.TabularInline): + model = OnderwerpProductType + fields = ("product_type",) + extra = 1 + + +class OnderwerpAdminForm(movenodeform_factory(Onderwerp)): + class Meta: + model = Onderwerp + fields = "__all__" + + +class OnderwerpAdminFormSet(BaseModelFormSet): + def clean(self): + super().clean() + + data = { + form.cleaned_data["id"]: form.cleaned_data["gepubliceerd"] + for form in self.forms + } + + for onderwerp, gepubliceerd in data.items(): + if children := onderwerp.get_children(): + if not gepubliceerd and any([data[child] for child in children]): + raise forms.ValidationError( + _( + "Hoofd onderwerpen moeten gepubliceerd zijn met gepubliceerde sub onderwerpen." + ) + ) + + +@admin.register(Onderwerp) +class OnderwerpAdmin(TreeAdmin): + form = OnderwerpAdminForm + inlines = ( + ProductTypeInline, + VraagInline, + ) + search_fields = ("naam",) + list_display = ( + "naam", + "gepubliceerd", + ) + list_editable = ("gepubliceerd",) + exclude = ("path", "depth", "numchild") + fieldsets = ( + ( + None, + { + "fields": ( + "naam", + "beschrijving", + "gepubliceerd", + "_position", + "_ref_node_id", + ), + }, + ), + ) + + list_filter = [ + "gepubliceerd", + ] + + def get_changelist_formset(self, request, **kwargs): + kwargs["formset"] = OnderwerpAdminFormSet + return super().get_changelist_formset(request, **kwargs) diff --git a/src/open_producten/producttypen/admin/prijs.py b/src/open_producten/producttypen/admin/prijs.py new file mode 100644 index 0000000..f66204c --- /dev/null +++ b/src/open_producten/producttypen/admin/prijs.py @@ -0,0 +1,35 @@ +from django.contrib import admin +from django.core.exceptions import ValidationError +from django.forms import BaseInlineFormSet + +from ..models import Prijs, PrijsOptie + + +class PrijsOptieInlineFormSet(BaseInlineFormSet): + + def clean(self): + """Check that at least one option has been added.""" + super().clean() + if any(self.errors): + return + if not any( + cleaned_data and not cleaned_data.get("DELETE", False) + for cleaned_data in self.cleaned_data + ): + raise ValidationError("Er is minimaal één optie vereist.") + + +class PrijsOptieInline(admin.TabularInline): + model = PrijsOptie + extra = 1 + ordering = ("beschrijving",) + formset = PrijsOptieInlineFormSet + + +@admin.register(Prijs) +class PrijsAdmin(admin.ModelAdmin): + model = Prijs + inlines = [PrijsOptieInline] + + def get_queryset(self, request): + return super().get_queryset(request).select_related("product_type") diff --git a/src/open_producten/producttypen/admin/producttype.py b/src/open_producten/producttypen/admin/producttype.py new file mode 100644 index 0000000..ffd0683 --- /dev/null +++ b/src/open_producten/producttypen/admin/producttype.py @@ -0,0 +1,61 @@ +from django import forms +from django.contrib import admin +from django.contrib.admin.widgets import FilteredSelectMultiple +from django.utils.translation import gettext as _ + +from ..models import Onderwerp, ProductType +from .bestand import BestandInline +from .link import LinkInline +from .vraag import VraagInline + + +class ProductTypeAdminForm(forms.ModelForm): + class Meta: + model = ProductType + fields = "__all__" + + onderwerpen = forms.ModelMultipleChoiceField( + label=_("onderwerpen"), + queryset=Onderwerp.objects.all(), + required=False, + widget=FilteredSelectMultiple(verbose_name=_("Onderwerp"), is_stacked=False), + ) + + def clean(self): + cleaned_data = super().clean() + if len(cleaned_data["onderwerpen"]) == 0: + self.add_error("onderwerpen", _("Er is minimaal één onderwerp vereist")) + return cleaned_data + + +@admin.register(ProductType) +class ProductTypeAdmin(admin.ModelAdmin): + list_display = ("naam", "aanmaak_datum", "display_onderwerpen", "gepubliceerd") + list_filter = ("gepubliceerd", "onderwerp") + list_editable = ("gepubliceerd",) + date_hierarchy = "aanmaak_datum" + autocomplete_fields = ( + "gerelateerde_product_typen", + # "organisations", + # "contacts", + # "locations", + ) + search_fields = ("naam",) + ordering = ("naam",) + save_on_top = True + form = ProductTypeAdminForm + inlines = (BestandInline, LinkInline, VraagInline) + + def get_queryset(self, request): + return ( + super() + .get_queryset(request) + .prefetch_related( + "onderwerpen", + # "contacts", "locations", "organisations" + ) + ) + + @admin.display(description="onderwerpen") + def display_onderwerpen(self, obj): + return ", ".join(p.naam for p in obj.onderwerp.all()) diff --git a/src/open_producten/producttypen/admin/upn.py b/src/open_producten/producttypen/admin/upn.py new file mode 100644 index 0000000..805b008 --- /dev/null +++ b/src/open_producten/producttypen/admin/upn.py @@ -0,0 +1,19 @@ +from django.contrib import admin + +from ..models import UniformeProductNaam + + +@admin.register(UniformeProductNaam) +class UniformeProductNaamAdmin(admin.ModelAdmin): + list_display = ("naam", "uri", "is_verwijderd") + list_filter = ("is_verwijderd",) + search_fields = ("naam", "uri") + + def has_change_permission(self, request, obj=None): + return False + + def has_add_permission(self, request): + return False + + def has_delete_permission(self, request, obj=None): + return False diff --git a/src/open_producten/producttypen/admin/vraag.py b/src/open_producten/producttypen/admin/vraag.py new file mode 100644 index 0000000..b100214 --- /dev/null +++ b/src/open_producten/producttypen/admin/vraag.py @@ -0,0 +1,34 @@ +from django.contrib import admin + +from ordered_model.admin import OrderedModelAdmin + +from ..models import Vraag + + +@admin.register(Vraag) +class VraagAdmin(OrderedModelAdmin): + list_filter = ("onderwerp",) + list_display = ( + "vraag", + "onderwerp", + "product_type", + ) + search_fields = ( + "vraag", + "antwoord", + "onderwerp__naam", + "product_type__naam", + ) + + def get_queryset(self, request): + return super().get_queryset(request).select_related("onderwerp", "product_type") + + +class VraagInline(admin.TabularInline): + model = Vraag + extra = 1 + + fields = [ + "vraag", + "antwoord", + ] diff --git a/src/open_producten/producttypen/apps.py b/src/open_producten/producttypen/apps.py new file mode 100644 index 0000000..e5d3c29 --- /dev/null +++ b/src/open_producten/producttypen/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ProducttypesConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "open_producten.producttypen" diff --git a/src/open_producten/producttypen/management/__init__.py b/src/open_producten/producttypen/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/open_producten/producttypen/management/commands/__init__.py b/src/open_producten/producttypen/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/open_producten/producttypen/management/commands/load_upl.py b/src/open_producten/producttypen/management/commands/load_upl.py new file mode 100644 index 0000000..0bb4098 --- /dev/null +++ b/src/open_producten/producttypen/management/commands/load_upl.py @@ -0,0 +1,68 @@ +from dataclasses import dataclass + +from django.core.management.base import BaseCommand, CommandError + +from open_producten.producttypen.models import ( + UniformeProductNaam as UniformProductNaamModel, +) + +from ..parsers import CsvParser + + +@dataclass +class UniformProductName: + name: str + uri: str + + +class Command(BaseCommand): + + def __init__(self): + self.help = "Load upn to the database from a given XML/CSV file." + self.parser = CsvParser() + super().__init__() + + def add_arguments(self, parser): + parser.add_argument( + "filename", + help="The name of the file to be imported.", + ) + + def handle(self, **options): + filename = options.pop("filename") + + self.stdout.write(f"Importing upn from {filename}...") + + try: + data = self.parser.parse(filename) + upn_objects = [ + UniformProductName(name=entry["UniformeProductnaam"], uri=entry["URI"]) + for entry in data + ] + created_count = self.load_upl(upn_objects) + + except KeyError as e: + raise CommandError(f"{str(e)} does not exist in csv.") + except Exception as e: + raise CommandError(str(e)) + + self.stdout.write(f"Done ({created_count} objects).") + + def load_upl(self, data: list[UniformProductName]) -> int: + count = 0 + upn_updated_list = [] + + for obj in data: + upn, created = UniformProductNaamModel.objects.update_or_create( + uri=obj.uri, + defaults={"naam": obj.name, "is_verwijderd": False}, + ) + upn_updated_list.append(upn.id) + + if created: + count += 1 + + UniformProductNaamModel.objects.exclude(id__in=upn_updated_list).update( + is_verwijderd=True, + ) + return count diff --git a/src/open_producten/producttypen/management/parsers.py b/src/open_producten/producttypen/management/parsers.py new file mode 100644 index 0000000..d95eed2 --- /dev/null +++ b/src/open_producten/producttypen/management/parsers.py @@ -0,0 +1,29 @@ +import csv +import os +from tempfile import NamedTemporaryFile +from typing import List + +import requests + + +class CsvParser: + + def parse(self, filename: str): + _, extension = os.path.splitext(filename) + file_format = extension[1:] + + if file_format != "csv": + raise Exception("File format is not csv") + + if not filename.startswith("http"): + return self.process_csv(filename) + + with NamedTemporaryFile() as f: + f.write(requests.get(filename).content) + f.seek(0) + return self.process_csv(f.name) + + def process_csv(self, filename: str) -> List[dict]: + with open(filename, encoding="utf-8-sig") as f: + data = csv.DictReader(f) + return list(data) diff --git a/src/open_producten/producttypen/migrations/0001_initial.py b/src/open_producten/producttypen/migrations/0001_initial.py new file mode 100644 index 0000000..899ef00 --- /dev/null +++ b/src/open_producten/producttypen/migrations/0001_initial.py @@ -0,0 +1,468 @@ +# Generated by Django 4.2.16 on 2024-11-26 12:59 + +import datetime +from decimal import Decimal +import django.contrib.postgres.fields +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import tinymce.models +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Onderwerp", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "gepubliceerd", + models.BooleanField( + default=False, + help_text="Whether the object is accessible through the API.", + verbose_name="Published", + ), + ), + ( + "aanmaak_datum", + models.DateTimeField( + auto_now_add=True, + help_text="The datetime at which the object was created.", + verbose_name="Created on", + ), + ), + ( + "update_datum", + models.DateTimeField( + auto_now=True, + help_text="The datetime at which the object was last changed.", + verbose_name="Updated on", + ), + ), + ("path", models.CharField(max_length=255, unique=True)), + ("depth", models.PositiveIntegerField()), + ("numchild", models.PositiveIntegerField(default=0)), + ( + "naam", + models.CharField( + help_text="Naam van het onderwerp.", + max_length=100, + verbose_name="naam", + ), + ), + ( + "beschrijving", + tinymce.models.HTMLField( + blank=True, + default="", + help_text="Beschrijving van het onderwerp.", + verbose_name="beschrijving", + ), + ), + ], + options={ + "verbose_name": "Onderwerp", + "verbose_name_plural": "Onderwerpen", + "ordering": ("path",), + }, + ), + migrations.CreateModel( + name="OnderwerpProductType", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "onderwerp", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="producttypen.onderwerp", + ), + ), + ], + ), + migrations.CreateModel( + name="Prijs", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "actief_vanaf", + models.DateField( + help_text="De datum vanaf wanneer de prijs actief is.", + unique=True, + validators=[ + django.core.validators.MinValueValidator( + datetime.date.today + ) + ], + verbose_name="start datum", + ), + ), + ], + options={ + "verbose_name": "Prijs", + "verbose_name_plural": "Prijzen", + }, + ), + migrations.CreateModel( + name="ProductType", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "gepubliceerd", + models.BooleanField( + default=False, + help_text="Whether the object is accessible through the API.", + verbose_name="Published", + ), + ), + ( + "aanmaak_datum", + models.DateTimeField( + auto_now_add=True, + help_text="The datetime at which the object was created.", + verbose_name="Created on", + ), + ), + ( + "update_datum", + models.DateTimeField( + auto_now=True, + help_text="The datetime at which the object was last changed.", + verbose_name="Updated on", + ), + ), + ( + "naam", + models.CharField( + help_text="naam van het product type.", + max_length=100, + verbose_name="naam", + ), + ), + ( + "samenvatting", + models.TextField( + default="", + help_text="Korte beschrijving van het product type, maximaal 300 karakters.", + max_length=300, + verbose_name="samenvatting", + ), + ), + ( + "beschrijving", + tinymce.models.HTMLField( + help_text="Product type beschrijving met WYSIWYG editor.", + verbose_name="beschrijving", + ), + ), + ( + "keywords", + django.contrib.postgres.fields.ArrayField( + base_field=models.CharField(blank=True, max_length=100), + blank=True, + default=list, + help_text="Lijst van keywords waarop kan worden gezocht.", + size=None, + verbose_name="Keywords", + ), + ), + ( + "gerelateerde_product_typen", + models.ManyToManyField( + blank=True, + help_text="gerelateerde product typen naar dit product type.", + to="producttypen.producttype", + verbose_name="gerelateerde product typen", + ), + ), + ( + "onderwerp", + models.ManyToManyField( + blank=True, + help_text="onderwerpen waaraan het product type is gelinkt.", + related_name="product_typen", + through="producttypen.OnderwerpProductType", + to="producttypen.onderwerp", + verbose_name="onderwerp", + ), + ), + ], + options={ + "verbose_name": "Product type", + "verbose_name_plural": "Product types", + }, + ), + migrations.CreateModel( + name="UniformeProductNaam", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "naam", + models.CharField( + help_text="Uniforme product naam", + max_length=100, + unique=True, + verbose_name="naam", + ), + ), + ( + "uri", + models.URLField( + help_text="Uri naar de UPN definitie.", + unique=True, + verbose_name="Uri", + ), + ), + ( + "is_verwijderd", + models.BooleanField( + default=False, + help_text="Geeft aan of de UPN is verwijderd.", + verbose_name="is verwijderd", + ), + ), + ], + options={ + "verbose_name": "Uniforme product naam", + "verbose_name_plural": "Uniforme product namen", + }, + ), + migrations.CreateModel( + name="Vraag", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("vraag", models.CharField(max_length=250, verbose_name="vraag")), + ("antwoord", tinymce.models.HTMLField(verbose_name="antwoord")), + ( + "onderwerp", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="vragen", + to="producttypen.onderwerp", + verbose_name="onderwerp", + ), + ), + ( + "product_type", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="vragen", + to="producttypen.producttype", + verbose_name="Product type", + ), + ), + ], + options={ + "verbose_name": "Vraag", + "verbose_name_plural": "Vragen", + }, + ), + migrations.AddField( + model_name="producttype", + name="uniforme_product_naam", + field=models.ForeignKey( + help_text="Uniforme product naam gedefinieerd door de overheid.", + on_delete=django.db.models.deletion.CASCADE, + related_name="product_typen", + to="producttypen.uniformeproductnaam", + verbose_name="Uniforme Product naam", + ), + ), + migrations.CreateModel( + name="PrijsOptie", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "bedrag", + models.DecimalField( + decimal_places=2, + help_text="Het bedrag van de prijs optie.", + max_digits=8, + validators=[ + django.core.validators.MinValueValidator(Decimal("0.01")) + ], + verbose_name="bedrag", + ), + ), + ( + "beschrijving", + models.CharField( + help_text="Korte beschrijving van de optie.", + max_length=100, + verbose_name="beschrijving", + ), + ), + ( + "prijs", + models.ForeignKey( + help_text="De prijs waarbij deze optie hoort.", + on_delete=django.db.models.deletion.CASCADE, + related_name="prijsopties", + to="producttypen.prijs", + verbose_name="prijs", + ), + ), + ], + options={ + "verbose_name": "Prijs optie", + "verbose_name_plural": "Prijs opties", + }, + ), + migrations.AddField( + model_name="prijs", + name="product_type", + field=models.ForeignKey( + help_text="Het product type waarbij deze prijs hoort.", + on_delete=django.db.models.deletion.CASCADE, + related_name="prijzen", + to="producttypen.producttype", + verbose_name="product type", + ), + ), + migrations.AddField( + model_name="onderwerpproducttype", + name="product_type", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="producttypen.producttype", + ), + ), + migrations.CreateModel( + name="Link", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "naam", + models.CharField( + help_text="Naam van de link.", + max_length=100, + verbose_name="naam", + ), + ), + ( + "url", + models.URLField(help_text="Url van de link.", verbose_name="Url"), + ), + ( + "product_type", + models.ForeignKey( + help_text="Het product type waarbij deze link hoort.", + on_delete=django.db.models.deletion.CASCADE, + related_name="links", + to="producttypen.producttype", + verbose_name="Product type", + ), + ), + ], + options={ + "verbose_name": "Product type link", + "verbose_name_plural": "Product type links", + }, + ), + migrations.CreateModel( + name="Bestand", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("bestand", models.FileField(upload_to="", verbose_name="bestand")), + ( + "product_type", + models.ForeignKey( + help_text="Het product type waarbij dit bestand hoort.", + on_delete=django.db.models.deletion.CASCADE, + related_name="bestanden", + to="producttypen.producttype", + verbose_name="product type", + ), + ), + ], + options={ + "verbose_name": "Product type bestand", + "verbose_name_plural": "Product type bestanden", + }, + ), + migrations.AlterUniqueTogether( + name="prijs", + unique_together={("product_type", "actief_vanaf")}, + ), + ] diff --git a/src/open_producten/producttypen/migrations/__init__.py b/src/open_producten/producttypen/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/open_producten/producttypen/models/__init__.py b/src/open_producten/producttypen/models/__init__.py new file mode 100644 index 0000000..4ec7996 --- /dev/null +++ b/src/open_producten/producttypen/models/__init__.py @@ -0,0 +1,19 @@ +from .bestand import Bestand +from .link import Link +from .onderwerp import Onderwerp +from .prijs import Prijs, PrijsOptie +from .producttype import OnderwerpProductType, ProductType +from .upn import UniformeProductNaam +from .vraag import Vraag + +__all__ = [ + "UniformeProductNaam", + "Vraag", + "Onderwerp", + "Link", + "Prijs", + "PrijsOptie", + "ProductType", + "Bestand", + "OnderwerpProductType", +] diff --git a/src/open_producten/producttypen/models/bestand.py b/src/open_producten/producttypen/models/bestand.py new file mode 100644 index 0000000..f598107 --- /dev/null +++ b/src/open_producten/producttypen/models/bestand.py @@ -0,0 +1,24 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from open_producten.utils.models import BaseModel + +from .producttype import ProductType + + +class Bestand(BaseModel): + product_type = models.ForeignKey( + ProductType, + verbose_name=_("product type"), + related_name="bestanden", + on_delete=models.CASCADE, + help_text=_("Het product type waarbij dit bestand hoort."), + ) + bestand = models.FileField(verbose_name=_("bestand")) + + class Meta: + verbose_name = _("Product type bestand") + verbose_name_plural = _("Product type bestanden") + + def __str__(self): + return f"{self.product_type}: {self.bestand.name}" diff --git a/src/open_producten/producttypen/models/link.py b/src/open_producten/producttypen/models/link.py new file mode 100644 index 0000000..3ec1fb7 --- /dev/null +++ b/src/open_producten/producttypen/models/link.py @@ -0,0 +1,27 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from open_producten.utils.models import BaseModel + +from .producttype import ProductType + + +class Link(BaseModel): + product_type = models.ForeignKey( + ProductType, + verbose_name=_("Product type"), + related_name="links", + on_delete=models.CASCADE, + help_text=_("Het product type waarbij deze link hoort."), + ) + naam = models.CharField( + verbose_name=_("naam"), max_length=100, help_text=_("Naam van de link.") + ) + url = models.URLField(verbose_name=_("Url"), help_text=_("Url van de link.")) + + class Meta: + verbose_name = _("Product type link") + verbose_name_plural = _("Product type links") + + def __str__(self): + return f"{self.product_type}: {self.naam}" diff --git a/src/open_producten/producttypen/models/onderwerp.py b/src/open_producten/producttypen/models/onderwerp.py new file mode 100644 index 0000000..760fbb2 --- /dev/null +++ b/src/open_producten/producttypen/models/onderwerp.py @@ -0,0 +1,67 @@ +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from tinymce import models as tinymce_models +from treebeard.exceptions import InvalidMoveToDescendant +from treebeard.mp_tree import MP_MoveHandler, MP_Node + +from open_producten.utils.models import BasePublishableModel + + +class PublishedMoveHandler(MP_MoveHandler): + def process(self): + if self.node.gepubliceerd and not self.target.gepubliceerd: + raise InvalidMoveToDescendant( + _( + "Gepubliceerde onderwerpen kunnen kunnen geen ongepubliceerd hoofd-onderwerp hebben." + ) + ) + return super().process() + + +class Onderwerp(MP_Node, BasePublishableModel): + naam = models.CharField( + verbose_name=_("naam"), max_length=100, help_text=_("Naam van het onderwerp.") + ) + + beschrijving = tinymce_models.HTMLField( + verbose_name=_("beschrijving"), + blank=True, + default="", + help_text=_("Beschrijving van het onderwerp."), + ) + + class Meta: + verbose_name = _("Onderwerp") + verbose_name_plural = _("Onderwerpen") + ordering = ("path",) + + def __str__(self): + return self.naam + + @property + def parent_onderwerp(self): + return self.get_parent() + + def move(self, target, pos=None): + return PublishedMoveHandler(self, target, pos).process() + + def clean(self): + if self.gepubliceerd and self.parent_onderwerp: + if not self.parent_onderwerp.gepubliceerd: + raise ValidationError( + _( + "Hoofd-onderwerpen moeten gepubliceerd zijn voordat sub-onderwerpen kunnen worden gepubliceerd." + ) + ) + + if ( + not self.gepubliceerd + and self.get_children().filter(gepubliceerd=True).exists() + ): + raise ValidationError( + _( + "Hoofd-onderwerpen kunnen niet ongepubliceerd worden als ze gepubliceerde sub-onderwerpen hebben." + ) + ) diff --git a/src/open_producten/producttypen/models/prijs.py b/src/open_producten/producttypen/models/prijs.py new file mode 100644 index 0000000..7f7dd40 --- /dev/null +++ b/src/open_producten/producttypen/models/prijs.py @@ -0,0 +1,63 @@ +import datetime +from decimal import Decimal + +from django.core.validators import MinValueValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from open_producten.utils.models import BaseModel + +from .producttype import ProductType + + +class Prijs(BaseModel): + product_type = models.ForeignKey( + ProductType, + verbose_name=_("product type"), + on_delete=models.CASCADE, + related_name="prijzen", + help_text=_("Het product type waarbij deze prijs hoort."), + ) + actief_vanaf = models.DateField( + verbose_name=_("start datum"), + validators=[MinValueValidator(datetime.date.today)], + unique=True, + help_text=_("De datum vanaf wanneer de prijs actief is."), + ) + + class Meta: + verbose_name = _("Prijs") + verbose_name_plural = _("Prijzen") + unique_together = ("product_type", "actief_vanaf") + + def __str__(self): + return f"{self.product_type.naam} {self.actief_vanaf}" + + +class PrijsOptie(BaseModel): + prijs = models.ForeignKey( + Prijs, + verbose_name=_("prijs"), + on_delete=models.CASCADE, + related_name="prijsopties", + help_text=_("De prijs waarbij deze optie hoort."), + ) + bedrag = models.DecimalField( + verbose_name=_("bedrag"), + decimal_places=2, + max_digits=8, + validators=[MinValueValidator(Decimal("0.01"))], + help_text=_("Het bedrag van de prijs optie."), + ) + beschrijving = models.CharField( + verbose_name=_("beschrijving"), + max_length=100, + help_text=_("Korte beschrijving van de optie."), + ) + + class Meta: + verbose_name = _("Prijs optie") + verbose_name_plural = _("Prijs opties") + + def __str__(self): + return f"{self.beschrijving} {self.bedrag}" diff --git a/src/open_producten/producttypen/models/producttype.py b/src/open_producten/producttypen/models/producttype.py new file mode 100644 index 0000000..dcc9486 --- /dev/null +++ b/src/open_producten/producttypen/models/producttype.py @@ -0,0 +1,120 @@ +from datetime import date + +from django.contrib.postgres.fields import ArrayField +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from tinymce import models as tinymce_models + +# from open_producten.locations.models import Contact, Location, Organisation +from open_producten.utils.models import BasePublishableModel + +from .onderwerp import Onderwerp +from .upn import UniformeProductNaam + + +class OnderwerpProductType(models.Model): + """ + Through-model for Category-ProductType m2m-relations. + """ + + onderwerp = models.ForeignKey(Onderwerp, on_delete=models.CASCADE) + product_type = models.ForeignKey("ProductType", on_delete=models.CASCADE) + + +class ProductType(BasePublishableModel): + naam = models.CharField( + verbose_name=_("naam"), + max_length=100, + help_text=_("naam van het product type."), + ) + + samenvatting = models.TextField( + verbose_name=_("samenvatting"), + default="", + max_length=300, + help_text=_("Korte beschrijving van het product type, maximaal 300 karakters."), + ) + + beschrijving = tinymce_models.HTMLField( + verbose_name=_("beschrijving"), + help_text=_("Product type beschrijving met WYSIWYG editor."), + ) + + gerelateerde_product_typen = models.ManyToManyField( + "ProductType", + verbose_name=_("gerelateerde product typen"), + blank=True, + help_text=_("gerelateerde product typen naar dit product type."), + ) + + keywords = ArrayField( + models.CharField(max_length=100, blank=True), + verbose_name=_("Keywords"), + default=list, + blank=True, + help_text=_("Lijst van keywords waarop kan worden gezocht."), + ) + + uniforme_product_naam = models.ForeignKey( + UniformeProductNaam, + verbose_name=_("Uniforme Product naam"), + on_delete=models.CASCADE, + help_text=_("Uniforme product naam gedefinieerd door de overheid."), + related_name="product_typen", + ) + + onderwerp = models.ManyToManyField( + Onderwerp, + verbose_name=_("onderwerp"), + blank=True, + related_name="product_typen", + help_text=_("onderwerpen waaraan het product type is gelinkt."), + through=OnderwerpProductType, + ) + + # organisations = models.ManyToManyField( + # Organisation, + # verbose_name=_("Organisations"), + # blank=True, + # related_name="products", + # help_text=_("Organisations which provides this product"), + # ) + # + # contacts = models.ManyToManyField( + # Contact, + # verbose_name=_("Contacts"), + # related_name="products", + # blank=True, + # help_text=_("The contacts responsible for the product"), + # ) + # + # locations = models.ManyToManyField( + # Location, + # verbose_name=_("Locations"), + # related_name="products", + # blank=True, + # help_text=_("Locations where the product is available at."), + # ) + + class Meta: + verbose_name = _("Product type") + verbose_name_plural = _("Product types") + + def __str__(self): + return self.naam + + # def clean(self): + # for contact in self.contacts.all(): + # if ( + # contact.organisation_id is not None + # and not self.organisations.filter(id=contact.organisation_id).exists() + # ): + # self.organisations.add(contact.organisation) + + @property + def current_price(self): + now = date.today() + return ( + self.prijzen.filter(actief_vanaf__lte=now).order_by("actief_vanaf").last() + ) diff --git a/src/open_producten/producttypen/models/upn.py b/src/open_producten/producttypen/models/upn.py new file mode 100644 index 0000000..fd5509c --- /dev/null +++ b/src/open_producten/producttypen/models/upn.py @@ -0,0 +1,31 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from open_producten.utils.models import BaseModel + + +class UniformeProductNaam(BaseModel): + naam = models.CharField( + verbose_name=_("naam"), + max_length=100, + help_text=_("Uniforme product naam"), + unique=True, + ) + + uri = models.URLField( + verbose_name=_("Uri"), + help_text=_("Uri naar de UPN definitie."), + unique=True, + ) + is_verwijderd = models.BooleanField( + _("is verwijderd"), + help_text=_("Geeft aan of de UPN is verwijderd."), + default=False, + ) + + class Meta: + verbose_name = _("Uniforme product naam") + verbose_name_plural = _("Uniforme product namen") + + def __str__(self): + return self.naam diff --git a/src/open_producten/producttypen/models/vraag.py b/src/open_producten/producttypen/models/vraag.py new file mode 100644 index 0000000..aef759f --- /dev/null +++ b/src/open_producten/producttypen/models/vraag.py @@ -0,0 +1,51 @@ +from django.core.validators import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from tinymce import models as tinymce_models + +from open_producten.utils.models import BaseModel + +from .onderwerp import Onderwerp +from .producttype import ProductType + + +class Vraag(BaseModel): + onderwerp = models.ForeignKey( + Onderwerp, + verbose_name=_("onderwerp"), + on_delete=models.CASCADE, + blank=True, + null=True, + related_name="vragen", + ) + product_type = models.ForeignKey( + ProductType, + verbose_name=_("Product type"), + on_delete=models.CASCADE, + blank=True, + null=True, + related_name="vragen", + ) + vraag = models.CharField(verbose_name=_("vraag"), max_length=250) + antwoord = tinymce_models.HTMLField(verbose_name=_("antwoord")) + + class Meta: + verbose_name = _("Vraag") + verbose_name_plural = _("Vragen") + + def clean(self): + if self.onderwerp and self.product_type: + raise ValidationError( + _( + "Een vraag kan niet gelink zijn aan een onderwerp en een product type." + ) + ) + + if not self.onderwerp and not self.product_type: + raise ValidationError( + _("Een vraag moet gelinkt zijn aan een onderwerp of een product type.") + ) + + def __str__(self): + return self.vraag diff --git a/src/open_producten/producttypen/tests/__init__.py b/src/open_producten/producttypen/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/open_producten/producttypen/tests/data/upl.csv b/src/open_producten/producttypen/tests/data/upl.csv new file mode 100644 index 0000000..a7d16b8 --- /dev/null +++ b/src/open_producten/producttypen/tests/data/upl.csv @@ -0,0 +1,2 @@ +UniformeProductnaam,URI,Rijk,Provincie,Waterschap,Gemeente,Burger,Bedrijf,Dienstenwet,SDG,Autonomie,Medebewind,Aanvraag,Subsidie,Melding,Verplichting,Grondslag,Grondslaglabel,Grondslaglink +aangifte vertrek buitenland,http://standaarden.overheid.nl/owms/terms/AangifteVertrekBuitenland,X,,,X,X,,,D1,,X,,,,X,http://standaarden.overheid.nl/owms/terms/Wet_BRP_art_2_43,Artikel 2.43 Wet basisregistratie personen,https://wetten.overheid.nl/jci1.3:c:BWBR0033715&hoofdstuk=2&afdeling=1¶graaf=5&artikel=2.43 diff --git a/src/open_producten/producttypen/tests/data/wrong-upl.csv b/src/open_producten/producttypen/tests/data/wrong-upl.csv new file mode 100644 index 0000000..b2321fc --- /dev/null +++ b/src/open_producten/producttypen/tests/data/wrong-upl.csv @@ -0,0 +1,2 @@ +UniformeProductnaam,url,Rijk,Provincie,Waterschap,Gemeente,Burger,Bedrijf,Dienstenwet,SDG,Autonomie,Medebewind,Aanvraag,Subsidie,Melding,Verplichting,Grondslag,Grondslaglabel,Grondslaglink +aangifte vertrek buitenland,http://standaarden.overheid.nl/owms/terms/AangifteVertrekBuitenland,X,,,X,X,,,D1,,X,,,,X,http://standaarden.overheid.nl/owms/terms/Wet_BRP_art_2_43,Artikel 2.43 Wet basisregistratie personen,https://wetten.overheid.nl/jci1.3:c:BWBR0033715&hoofdstuk=2&afdeling=1¶graaf=5&artikel=2.43 diff --git a/src/open_producten/producttypen/tests/factories.py b/src/open_producten/producttypen/tests/factories.py new file mode 100644 index 0000000..159d703 --- /dev/null +++ b/src/open_producten/producttypen/tests/factories.py @@ -0,0 +1,86 @@ +import factory.fuzzy + +from ..models import ( + Bestand, + Link, + Onderwerp, + Prijs, + PrijsOptie, + ProductType, + UniformeProductNaam, + Vraag, +) + + +class UniformeProductNaamFactory(factory.django.DjangoModelFactory): + naam = factory.Sequence(lambda n: f"upn {n}") + uri = factory.Faker("url") + + class Meta: + model = UniformeProductNaam + + +class ProductTypeFactory(factory.django.DjangoModelFactory): + naam = factory.Sequence(lambda n: f"product type {n}") + samenvatting = factory.Faker("sentence") + beschrijving = factory.Faker("paragraph") + gepubliceerd = True + uniforme_product_naam = factory.SubFactory(UniformeProductNaamFactory) + + class Meta: + model = ProductType + + +class OnderwerpFactory(factory.django.DjangoModelFactory): + naam = factory.Sequence(lambda n: f"subject {n}") + beschrijving = factory.Faker("sentence") + gepubliceerd = True + + class Meta: + model = Onderwerp + + @classmethod + def _create(cls, model_class, *args, **kwargs): + """For now factory creates only root onderwerpen""" + return Onderwerp.add_root(**kwargs) + + +class VraagFactory(factory.django.DjangoModelFactory): + vraag = factory.Faker("sentence") + antwoord = factory.Faker("text") + + class Meta: + model = Vraag + + +class PrijsFactory(factory.django.DjangoModelFactory): + actief_vanaf = factory.Faker("date") + product_type = factory.SubFactory(ProductTypeFactory) + + class Meta: + model = Prijs + + +class PrijsOptieFactory(factory.django.DjangoModelFactory): + beschrijving = factory.Faker("sentence") + bedrag = factory.fuzzy.FuzzyDecimal(1, 10) + + class Meta: + model = PrijsOptie + + +class BestandFactory(factory.django.DjangoModelFactory): + product_type = factory.SubFactory(ProductTypeFactory) + bestand = factory.django.FileField(filename="test_file.txt") + + class Meta: + model = Bestand + + +class LinkFactory(factory.django.DjangoModelFactory): + product_type = factory.SubFactory(ProductTypeFactory) + naam = factory.Sequence(lambda n: f"link {n}") + url = factory.Faker("url") + + class Meta: + model = Link diff --git a/src/open_producten/producttypen/tests/test_load_upn.py b/src/open_producten/producttypen/tests/test_load_upn.py new file mode 100644 index 0000000..dd4d487 --- /dev/null +++ b/src/open_producten/producttypen/tests/test_load_upn.py @@ -0,0 +1,101 @@ +import os +from io import StringIO +from unittest.mock import patch + +from django.core.management import CommandError, call_command +from django.test import SimpleTestCase, TestCase + +from open_producten.producttypen.management.parsers import CsvParser +from open_producten.producttypen.models import UniformeProductNaam + + +class TestLoadUPNCommand(TestCase): + + def call_command(self, *args, **kwargs): + out = StringIO() + call_command( + "load_upl", + *args, + stdout=out, + stderr=StringIO(), + **kwargs, + ) + return out.getvalue() + + def test_load_csv_with_correct_columns(self): + file_name = "data/upl.csv" + path = os.path.join(os.path.dirname(__file__), file_name) + result = self.call_command(path) + self.assertEqual(result, f"Importing upn from {path}...\nDone (1 objects).\n") + self.assertEqual(UniformeProductNaam.objects.count(), 1) + self.assertEqual( + UniformeProductNaam.objects.first().naam, "aangifte vertrek buitenland" + ) + self.assertEqual( + UniformeProductNaam.objects.first().uri, + "http://standaarden.overheid.nl/owms/terms/AangifteVertrekBuitenland", + ) + + def test_load_csv_with_incorrect_columns(self): + file_name = "data/wrong-upl.csv" + path = os.path.join(os.path.dirname(__file__), file_name) + + with self.assertRaisesMessage(CommandError, "'URI' does not exist in csv"): + self.call_command(path) + + +class TestCSVParser(SimpleTestCase): + + def setUp(self): + self.parser = CsvParser() + + def test_parser_returns_error_when_format_is_not_csv(self): + with self.assertRaisesMessage(Exception, "File format is not csv"): + self.parser.parse("abc.txt") + + @patch("open_producten.producttypen.management.parsers.NamedTemporaryFile") + @patch("open_producten.producttypen.management.parsers.CsvParser.process_csv") + def test_parser_with_url(self, mock_process_csv, mock_NamedTemporaryFile): + self.parser.parse("https://www.abc.com/abc.csv") + + self.assertEqual(mock_NamedTemporaryFile.call_count, 1) + self.assertEqual(mock_process_csv.call_count, 1) + + @patch("tempfile.NamedTemporaryFile") + @patch("open_producten.producttypen.management.parsers.CsvParser.process_csv") + def test_parser_with_file(self, mock_process_csv, mock_NamedTemporaryFile): + self.parser.parse("abc.csv") + + self.assertEqual(mock_NamedTemporaryFile.call_count, 0) + self.assertEqual(mock_process_csv.call_count, 1) + + def test_process_csv(self): + file_name = "data/upl.csv" + path = os.path.join(os.path.dirname(__file__), file_name) + result = self.parser.process_csv(path) + self.assertEqual( + result, + [ + { + "Aanvraag": "", + "Autonomie": "", + "Bedrijf": "", + "Burger": "X", + "Dienstenwet": "", + "Gemeente": "X", + "Grondslag": "http://standaarden.overheid.nl/owms/terms/Wet_BRP_art_2_43", + "Grondslaglabel": "Artikel 2.43 Wet basisregistratie personen", + "Grondslaglink": "https://wetten.overheid.nl/jci1.3:c:BWBR0033715&hoofdstuk=2&afdeling=1¶graaf=5&artikel=2.43", + "Medebewind": "X", + "Melding": "", + "Provincie": "", + "Rijk": "X", + "SDG": "D1", + "Subsidie": "", + "URI": "http://standaarden.overheid.nl/owms/terms/AangifteVertrekBuitenland", + "UniformeProductnaam": "aangifte vertrek buitenland", + "Verplichting": "X", + "Waterschap": "", + } + ], + ) diff --git a/src/open_producten/producttypen/tests/test_onderwerp_admin.py b/src/open_producten/producttypen/tests/test_onderwerp_admin.py new file mode 100644 index 0000000..8cd46e0 --- /dev/null +++ b/src/open_producten/producttypen/tests/test_onderwerp_admin.py @@ -0,0 +1,88 @@ +from django.core.exceptions import ValidationError +from django.forms import modelformset_factory +from django.test import TestCase + +from open_producten.utils.tests.helpers import build_formset_data + +from ..admin.onderwerp import OnderwerpAdmin, OnderwerpAdminForm, OnderwerpAdminFormSet +from ..models import Onderwerp +from .factories import OnderwerpFactory + + +def create_form(data, instance=None): + return OnderwerpAdminForm( + instance=instance, + data=data, + ) + + +class TestOnderwerpAdminForm(TestCase): + + def setUp(self): + self.data = { + "naam": "test", + "_position": "first-child", + "path": "00010001", + "numchild": 1, + "depth": 1, + } + + def test_parent_nodes_must_be_published_when_publishing_child(self): + parent = OnderwerpFactory.create(gepubliceerd=False) + data = self.data | {"gepubliceerd": True, "_ref_node_id": parent.id} + + form = create_form(data) + + self.assertEquals( + form.non_field_errors(), + [ + "Hoofd-onderwerpen moeten gepubliceerd zijn voordat sub-onderwerpen kunnen worden gepubliceerd." + ], + ) + + parent.gepubliceerd = True + parent.save() + + form = create_form(data) + self.assertEquals(form.errors, {}) + + def test_parent_nodes_cannot_be_published_with_published_children(self): + parent = OnderwerpFactory.create(gepubliceerd=False) + parent.add_child(**{"naam": "child", "gepubliceerd": True}) + data = {"gepubliceerd": False, "_ref_node_id": None} + + form = create_form(data, parent) + + self.assertEquals( + form.non_field_errors(), + [ + "Hoofd-onderwerpen kunnen niet ongepubliceerd worden als ze gepubliceerde sub-onderwerpen hebben." + ], + ) + + +class TestCategoryAdminFormSet(TestCase): + + def setUp(self): + self.formset = modelformset_factory( + model=Onderwerp, + formset=OnderwerpAdminFormSet, + fields=OnderwerpAdmin.list_editable, + ) + + self.parent = Onderwerp.add_root(**{"naam": "parent", "gepubliceerd": False}) + self.child = self.parent.add_child(**{"naam": "child", "gepubliceerd": True}) + + def test_parent_nodes_cannot_be_unpublished_with_published_children(self): + data = build_formset_data( + "form", + { + "id": self.parent.id, # gepubliceerd off + }, + {"id": self.child.id, "gepubliceerd": "on"}, + ) + + object_formset = self.formset(data) + + with self.assertRaises(ValidationError): + object_formset.clean() diff --git a/src/open_producten/producttypen/tests/test_prijs.py b/src/open_producten/producttypen/tests/test_prijs.py new file mode 100644 index 0000000..80efaeb --- /dev/null +++ b/src/open_producten/producttypen/tests/test_prijs.py @@ -0,0 +1,42 @@ +from datetime import date, timedelta +from decimal import Decimal + +from django.core.exceptions import ValidationError +from django.test import TestCase + +from .factories import PrijsFactory, PrijsOptieFactory, ProductTypeFactory + + +class TestPrijs(TestCase): + + def test_unique_validation(self): + product_type = ProductTypeFactory.create() + PrijsFactory.create( + product_type=product_type, actief_vanaf=date.today() + timedelta(days=1) + ) + + with self.assertRaises(ValidationError): + duplicate = PrijsFactory.build( + product_type=product_type, actief_vanaf=date.today() + timedelta(days=1) + ) + duplicate.full_clean() + + def test_min_date_validation(self): + with self.assertRaises(ValidationError): + prijs = PrijsFactory.build(actief_vanaf=date(2020, 1, 1)) + prijs.full_clean() + + +class TestPrijsOption(TestCase): + def setUp(self): + self.prijs = PrijsFactory.create() + + def test_min_amount_validation(self): + with self.assertRaises(ValidationError): + option = PrijsOptieFactory.build(prijs=self.prijs, bedrag=Decimal("-1")) + option.full_clean() + + def test_decimal_place_validation(self): + with self.assertRaises(ValidationError): + option = PrijsOptieFactory.build(prijs=self.prijs, bedrag=Decimal("0.001")) + option.full_clean() diff --git a/src/open_producten/producttypen/tests/test_producttype.py b/src/open_producten/producttypen/tests/test_producttype.py new file mode 100644 index 0000000..e996c5e --- /dev/null +++ b/src/open_producten/producttypen/tests/test_producttype.py @@ -0,0 +1,46 @@ +from datetime import date + +from django.test import TestCase + +from freezegun import freeze_time + +# from ...locations.tests.factories import ContactFactory +from .factories import PrijsFactory, ProductTypeFactory + + +class TestProductType(TestCase): + def setUp(self): + self.product_type = ProductTypeFactory.create() + self.past_price = PrijsFactory.create( + product_type=self.product_type, actief_vanaf=date(2020, 1, 1) + ) + self.current_price = PrijsFactory.create( + product_type=self.product_type, actief_vanaf=date(2024, 1, 1) + ) + self.future_price = PrijsFactory.create( + product_type=self.product_type, actief_vanaf=date(2025, 1, 1) + ) + + @freeze_time("2024-02-02") + def test_current_price_when_set(self): + price = self.product_type.current_price + self.assertEqual(price, self.current_price) + + def test_current_price_without_prices(self): + self.product_type = ProductTypeFactory.create() + self.assertIsNone(self.product_type.current_price) + + # def test_clean_with_contact_that_has_no_org(self): + # contact = ContactFactory(organisation_id=None) + # product_type = ProductTypeFactory.create() + # product_type.contacts.add(contact) + # product_type.clean() + # + # def test_clean_with_contact_that_has_org(self): + # contact = ContactFactory() + # product_type = ProductTypeFactory.create() + # product_type.contacts.add(contact) + # product_type.clean() + # + # self.assertEqual(product_type.organisations.count(), 1) + # self.assertEqual(product_type.organisations.first().id, contact.organisation.id) diff --git a/src/open_producten/producttypen/tests/test_producttype_admin.py b/src/open_producten/producttypen/tests/test_producttype_admin.py new file mode 100644 index 0000000..e7895e3 --- /dev/null +++ b/src/open_producten/producttypen/tests/test_producttype_admin.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from ..admin.producttype import ProductTypeAdminForm +from ..models import Onderwerp +from .factories import OnderwerpFactory, UniformeProductNaamFactory + + +class TestProductTypeAdminForm(TestCase): + def setUp(self): + upn = UniformeProductNaamFactory.create() + self.data = { + "naam": "test", + "uniforme_product_naam": upn, + "beschrijving": "content", + "samenvatting": "summary", + } + + def test_at_least_one_onderwerp_is_required(self): + form = ProductTypeAdminForm(data=self.data) + + self.assertEquals( + form.errors, {"onderwerpen": ["Er is minimaal één onderwerp vereist"]} + ) + + OnderwerpFactory.create() + form = ProductTypeAdminForm( + data=self.data | {"onderwerpen": Onderwerp.objects.all()} + ) + + self.assertEquals(form.errors, {}) diff --git a/src/open_producten/producttypen/tests/test_vraag.py b/src/open_producten/producttypen/tests/test_vraag.py new file mode 100644 index 0000000..14ff137 --- /dev/null +++ b/src/open_producten/producttypen/tests/test_vraag.py @@ -0,0 +1,25 @@ +from django.core.exceptions import ValidationError +from django.test import TestCase + +from .factories import OnderwerpFactory, ProductTypeFactory, VraagFactory + + +class TestVraag(TestCase): + + def setUp(self): + self.productType = ProductTypeFactory.create() + self.onderwerp = OnderwerpFactory.create() + + def test_error_when_linked_to_type_and_onderwerp(self): + vraag = VraagFactory.build( + product_type=self.productType, onderwerp=self.onderwerp + ) + + with self.assertRaises(ValidationError): + vraag.clean() + + def test_error_when_not_linked_to_type_or_onderwerp(self): + vraag = VraagFactory.build() + + with self.assertRaises(ValidationError): + vraag.clean() diff --git a/src/open_producten/utils/models.py b/src/open_producten/utils/models.py index 7874777..fbc103f 100644 --- a/src/open_producten/utils/models.py +++ b/src/open_producten/utils/models.py @@ -12,17 +12,17 @@ class Meta: class BasePublishableModel(BaseModel): - published = models.BooleanField( + gepubliceerd = models.BooleanField( verbose_name=_("Published"), default=False, help_text=_("Whether the object is accessible through the API."), ) - created_on = models.DateTimeField( + aanmaak_datum = models.DateTimeField( verbose_name=_("Created on"), auto_now_add=True, help_text=_("The datetime at which the object was created."), ) - updated_on = models.DateTimeField( + update_datum = models.DateTimeField( verbose_name=_("Updated on"), auto_now=True, help_text=_("The datetime at which the object was last changed."), From ed94c532a64c9f558510a5a3094edf9eae5e23c2 Mon Sep 17 00:00:00 2001 From: Floris272 Date: Tue, 26 Nov 2024 16:56:58 +0100 Subject: [PATCH 2/8] Replace tinymce with markdownx --- requirements/base.in | 2 +- requirements/base.txt | 19 ++++---- requirements/ci.txt | 17 +++---- requirements/dev.txt | 17 +++---- src/open_producten/conf/base.py | 24 +++------- .../producttypen/admin/vraag.py | 3 +- .../producttypen/migrations/0001_initial.py | 10 ++--- .../producttypen/models/onderwerp.py | 4 +- .../producttypen/models/producttype.py | 4 +- .../producttypen/models/vraag.py | 4 +- src/open_producten/static/initTinymce.js | 44 ------------------- .../templates/markdownx/widget.html | 14 ++++++ src/open_producten/urls.py | 3 +- 13 files changed, 57 insertions(+), 108 deletions(-) delete mode 100644 src/open_producten/static/initTinymce.js create mode 100644 src/open_producten/templates/markdownx/widget.html diff --git a/requirements/base.in b/requirements/base.in index bd40142..20fbe61 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -8,7 +8,7 @@ django-treebeard # API libraries # django-extra-fields -django-tinymce +django-markdownx geopy django-localflavor diff --git a/requirements/base.txt b/requirements/base.txt index 00ce93b..e249969 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -84,8 +84,8 @@ django==4.2.16 # django-jsonform # django-localflavor # django-log-outgoing-requests + # django-markdownx # django-markup - # django-open-forms-client # django-otp # django-phonenumber-field # django-privates @@ -97,7 +97,6 @@ django==4.2.16 # django-setup-configuration # django-simple-certmanager # django-solo - # django-tinymce # django-treebeard # django-two-factor-auth # djangorestframework @@ -137,10 +136,10 @@ django-localflavor==4.0 # via -r requirements/base.in django-log-outgoing-requests==0.6.1 # via open-api-framework +django-markdownx==4.0.7 + # via -r requirements/base.in django-markup==1.9.1 # via open-api-framework -django-open-forms-client==0.4.0 - # via -r requirements/base.in django-ordered-model==3.7.4 # via django-admin-index django-otp==1.5.4 @@ -167,12 +166,9 @@ django-solo==2.4.0 # via # commonground-api-common # django-log-outgoing-requests - # django-open-forms-client # mozilla-django-oidc-db # notifications-api-common # zgw-consumers -django-tinymce==4.1.0 - # via -r requirements/base.in django-treebeard==4.7.1 # via -r requirements/base.in django-two-factor-auth==1.17.0 @@ -250,7 +246,9 @@ jsonschema-specifications==2024.10.1 kombu==5.4.2 # via celery markdown==3.7 - # via django-markup + # via + # django-markdownx + # django-markup markupsafe==3.0.2 # via jinja2 maykin-2fa==1.0.1 @@ -274,7 +272,9 @@ packaging==24.2 phonenumberslite==8.13.50 # via django-two-factor-auth pillow==11.0.0 - # via -r requirements/base.in + # via + # -r requirements/base.in + # django-markdownx prometheus-client==0.21.0 # via flower prompt-toolkit==3.0.48 @@ -334,7 +334,6 @@ requests==2.32.3 # commonground-api-common # coreapi # django-log-outgoing-requests - # django-open-forms-client # gemma-zds-client # mozilla-django-oidc # open-api-framework diff --git a/requirements/ci.txt b/requirements/ci.txt index f8e894b..0025eeb 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -151,8 +151,8 @@ django==4.2.16 # django-jsonform # django-localflavor # django-log-outgoing-requests + # django-markdownx # django-markup - # django-open-forms-client # django-otp # django-phonenumber-field # django-privates @@ -164,7 +164,6 @@ django==4.2.16 # django-setup-configuration # django-simple-certmanager # django-solo - # django-tinymce # django-treebeard # django-two-factor-auth # djangorestframework @@ -231,15 +230,15 @@ django-log-outgoing-requests==0.6.1 # -c requirements/base.txt # -r requirements/base.txt # open-api-framework -django-markup==1.9.1 +django-markdownx==4.0.7 # via # -c requirements/base.txt # -r requirements/base.txt - # open-api-framework -django-open-forms-client==0.4.0 +django-markup==1.9.1 # via # -c requirements/base.txt # -r requirements/base.txt + # open-api-framework django-ordered-model==3.7.4 # via # -c requirements/base.txt @@ -301,14 +300,9 @@ django-solo==2.4.0 # -r requirements/base.txt # commonground-api-common # django-log-outgoing-requests - # django-open-forms-client # mozilla-django-oidc-db # notifications-api-common # zgw-consumers -django-tinymce==4.1.0 - # via - # -c requirements/base.txt - # -r requirements/base.txt django-treebeard==4.7.1 # via # -c requirements/base.txt @@ -492,6 +486,7 @@ markdown==3.7 # via # -c requirements/base.txt # -r requirements/base.txt + # django-markdownx # django-markup markupsafe==3.0.2 # via @@ -560,6 +555,7 @@ pillow==11.0.0 # via # -c requirements/base.txt # -r requirements/base.txt + # django-markdownx platformdirs==4.3.6 # via # black @@ -686,7 +682,6 @@ requests==2.32.3 # commonground-api-common # coreapi # django-log-outgoing-requests - # django-open-forms-client # gemma-zds-client # mozilla-django-oidc # open-api-framework diff --git a/requirements/dev.txt b/requirements/dev.txt index cee5f3a..2692f6f 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -178,8 +178,8 @@ django==4.2.16 # django-jsonform # django-localflavor # django-log-outgoing-requests + # django-markdownx # django-markup - # django-open-forms-client # django-otp # django-phonenumber-field # django-privates @@ -191,7 +191,6 @@ django==4.2.16 # django-setup-configuration # django-simple-certmanager # django-solo - # django-tinymce # django-treebeard # django-two-factor-auth # djangorestframework @@ -262,15 +261,15 @@ django-log-outgoing-requests==0.6.1 # -c requirements/ci.txt # -r requirements/ci.txt # open-api-framework -django-markup==1.9.1 +django-markdownx==4.0.7 # via # -c requirements/ci.txt # -r requirements/ci.txt - # open-api-framework -django-open-forms-client==0.4.0 +django-markup==1.9.1 # via # -c requirements/ci.txt # -r requirements/ci.txt + # open-api-framework django-ordered-model==3.7.4 # via # -c requirements/ci.txt @@ -332,14 +331,9 @@ django-solo==2.4.0 # -r requirements/ci.txt # commonground-api-common # django-log-outgoing-requests - # django-open-forms-client # mozilla-django-oidc-db # notifications-api-common # zgw-consumers -django-tinymce==4.1.0 - # via - # -c requirements/ci.txt - # -r requirements/ci.txt django-treebeard==4.7.1 # via # -c requirements/ci.txt @@ -549,6 +543,7 @@ markdown==3.7 # via # -c requirements/ci.txt # -r requirements/ci.txt + # django-markdownx # django-markup markupsafe==3.0.2 # via @@ -626,6 +621,7 @@ pillow==11.0.0 # via # -c requirements/ci.txt # -r requirements/ci.txt + # django-markdownx platformdirs==4.3.6 # via # -c requirements/ci.txt @@ -765,7 +761,6 @@ requests==2.32.3 # commonground-api-common # coreapi # django-log-outgoing-requests - # django-open-forms-client # gemma-zds-client # mozilla-django-oidc # open-api-framework diff --git a/src/open_producten/conf/base.py b/src/open_producten/conf/base.py index dcf2625..00ad7f4 100644 --- a/src/open_producten/conf/base.py +++ b/src/open_producten/conf/base.py @@ -19,8 +19,7 @@ "rest_framework.authtoken", "localflavor", "treebeard", - "tinymce", - "openformsclient", + "markdownx", "django.contrib.gis", "open_producten.accounts", "open_producten.utils", @@ -71,6 +70,11 @@ ADMIN_INDEX_SHOW_REMAINING_APPS = False +# +# markdownx +# +MARKDOWNX_EDITOR_RESIZABLE = False + # # Django rest framework # @@ -108,22 +112,6 @@ "VERSION": API_VERSION, } -TINYMCE_DEFAULT_CONFIG = { # TODO: light/dark mode based on browser settings - "height": 200, - "menubar": False, - "plugins": "advlist,autolink,lists,link,image,charmap,print,preview,anchor," - "searchreplace,visualblocks,code,fullscreen,insertdatetime,media,table,paste," - "code,wordcount", - "toolbar": "undo redo | formatselect | " - "bold italic backcolor | alignleft aligncenter " - "alignright alignjustify | bullist numlist outdent indent | " - "removeformat", - "skin": "oxide-dark", - "content_css": "dark", -} - -TINYMCE_EXTRA_MEDIA = {"js": ["initTinymce.js"]} - # # geopy # diff --git a/src/open_producten/producttypen/admin/vraag.py b/src/open_producten/producttypen/admin/vraag.py index b100214..93019a2 100644 --- a/src/open_producten/producttypen/admin/vraag.py +++ b/src/open_producten/producttypen/admin/vraag.py @@ -1,12 +1,13 @@ from django.contrib import admin +from markdownx.admin import MarkdownxModelAdmin from ordered_model.admin import OrderedModelAdmin from ..models import Vraag @admin.register(Vraag) -class VraagAdmin(OrderedModelAdmin): +class VraagAdmin(OrderedModelAdmin, MarkdownxModelAdmin): list_filter = ("onderwerp",) list_display = ( "vraag", diff --git a/src/open_producten/producttypen/migrations/0001_initial.py b/src/open_producten/producttypen/migrations/0001_initial.py index 899ef00..1e90440 100644 --- a/src/open_producten/producttypen/migrations/0001_initial.py +++ b/src/open_producten/producttypen/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.16 on 2024-11-26 12:59 +# Generated by Django 4.2.16 on 2024-11-26 14:40 import datetime from decimal import Decimal @@ -6,7 +6,7 @@ import django.core.validators from django.db import migrations, models import django.db.models.deletion -import tinymce.models +import markdownx.models import uuid @@ -66,7 +66,7 @@ class Migration(migrations.Migration): ), ( "beschrijving", - tinymce.models.HTMLField( + markdownx.models.MarkdownxField( blank=True, default="", help_text="Beschrijving van het onderwerp.", @@ -187,7 +187,7 @@ class Migration(migrations.Migration): ), ( "beschrijving", - tinymce.models.HTMLField( + markdownx.models.MarkdownxField( help_text="Product type beschrijving met WYSIWYG editor.", verbose_name="beschrijving", ), @@ -285,7 +285,7 @@ class Migration(migrations.Migration): ), ), ("vraag", models.CharField(max_length=250, verbose_name="vraag")), - ("antwoord", tinymce.models.HTMLField(verbose_name="antwoord")), + ("antwoord", markdownx.models.MarkdownxField(verbose_name="antwoord")), ( "onderwerp", models.ForeignKey( diff --git a/src/open_producten/producttypen/models/onderwerp.py b/src/open_producten/producttypen/models/onderwerp.py index 760fbb2..0c22ac0 100644 --- a/src/open_producten/producttypen/models/onderwerp.py +++ b/src/open_producten/producttypen/models/onderwerp.py @@ -2,7 +2,7 @@ from django.db import models from django.utils.translation import gettext_lazy as _ -from tinymce import models as tinymce_models +from markdownx.models import MarkdownxField from treebeard.exceptions import InvalidMoveToDescendant from treebeard.mp_tree import MP_MoveHandler, MP_Node @@ -25,7 +25,7 @@ class Onderwerp(MP_Node, BasePublishableModel): verbose_name=_("naam"), max_length=100, help_text=_("Naam van het onderwerp.") ) - beschrijving = tinymce_models.HTMLField( + beschrijving = MarkdownxField( verbose_name=_("beschrijving"), blank=True, default="", diff --git a/src/open_producten/producttypen/models/producttype.py b/src/open_producten/producttypen/models/producttype.py index dcc9486..ad6adb0 100644 --- a/src/open_producten/producttypen/models/producttype.py +++ b/src/open_producten/producttypen/models/producttype.py @@ -4,7 +4,7 @@ from django.db import models from django.utils.translation import gettext_lazy as _ -from tinymce import models as tinymce_models +from markdownx.models import MarkdownxField # from open_producten.locations.models import Contact, Location, Organisation from open_producten.utils.models import BasePublishableModel @@ -36,7 +36,7 @@ class ProductType(BasePublishableModel): help_text=_("Korte beschrijving van het product type, maximaal 300 karakters."), ) - beschrijving = tinymce_models.HTMLField( + beschrijving = MarkdownxField( verbose_name=_("beschrijving"), help_text=_("Product type beschrijving met WYSIWYG editor."), ) diff --git a/src/open_producten/producttypen/models/vraag.py b/src/open_producten/producttypen/models/vraag.py index aef759f..d83b282 100644 --- a/src/open_producten/producttypen/models/vraag.py +++ b/src/open_producten/producttypen/models/vraag.py @@ -2,7 +2,7 @@ from django.db import models from django.utils.translation import gettext_lazy as _ -from tinymce import models as tinymce_models +from markdownx.models import MarkdownxField from open_producten.utils.models import BaseModel @@ -28,7 +28,7 @@ class Vraag(BaseModel): related_name="vragen", ) vraag = models.CharField(verbose_name=_("vraag"), max_length=250) - antwoord = tinymce_models.HTMLField(verbose_name=_("antwoord")) + antwoord = MarkdownxField(verbose_name=_("antwoord")) class Meta: verbose_name = _("Vraag") diff --git a/src/open_producten/static/initTinymce.js b/src/open_producten/static/initTinymce.js deleted file mode 100644 index 281fc01..0000000 --- a/src/open_producten/static/initTinymce.js +++ /dev/null @@ -1,44 +0,0 @@ -// Sets the dar/light mode based on the browser. -// Copied minimal needed stuff from open-forms - -'use strict'; -{ - function initTinyMCE(el) { - if (el.closest('.empty-form') === null) { - // Don't do empty inlines - var mce_conf = JSON.parse(el.dataset.mceConf); - const useDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches; - - mce_conf = { - ...mce_conf, - ...{ - skin: useDarkMode ? 'oxide-dark' : 'oxide', - content_css: useDarkMode ? 'dark' : 'default', - }, - }; - - const id = el.id; - - if (!tinyMCE.get(id)) { - tinyMCE.init(mce_conf); - } - } - } - - function ready(fn) { - if (document.readyState !== 'loading') { - fn(); - } else { - document.addEventListener('DOMContentLoaded', fn); - } - } - - function initializeTinyMCE(element, formsetName) { - Array.from(element.querySelectorAll('.tinymce')).forEach(area => initTinyMCE(area)); - } - - ready(function () { - // initialize the TinyMCE editors on load - initializeTinyMCE(document); - }); -} diff --git a/src/open_producten/templates/markdownx/widget.html b/src/open_producten/templates/markdownx/widget.html new file mode 100644 index 0000000..f9b9158 --- /dev/null +++ b/src/open_producten/templates/markdownx/widget.html @@ -0,0 +1,14 @@ +
+ {% include 'django/forms/widgets/textarea.html' %} + +
+
diff --git a/src/open_producten/urls.py b/src/open_producten/urls.py index 80692d8..eace535 100644 --- a/src/open_producten/urls.py +++ b/src/open_producten/urls.py @@ -90,7 +90,7 @@ ] ), ), - path("tinymce/", include("tinymce.urls")), + path("markdownx/", include("markdownx.urls")), ] # NOTE: The staticfiles_urlpatterns also discovers static files (ie. no need to run collectstatic). Both the static @@ -104,4 +104,5 @@ urlpatterns = [ path("__debug__/", include(debug_toolbar.urls)), + path("favicon.ico", RedirectView.as_view(url="/static/ico/favicon.png")), ] + urlpatterns From 67ab7dc9561ab37becdc147f7bea2d3137df871b Mon Sep 17 00:00:00 2001 From: Floris272 Date: Fri, 29 Nov 2024 11:45:18 +0100 Subject: [PATCH 3/8] remove gerelateerde_product_typen and rename fields --- bin/generate_api_schema.sh | 2 +- src/open_producten/conf/base.py | 3 ++ .../producttypen/admin/prijs.py | 5 +-- .../producttypen/admin/producttype.py | 5 ++- src/open_producten/producttypen/apps.py | 2 +- .../producttypen/migrations/0001_initial.py | 31 ++++++---------- .../producttypen/models/onderwerp.py | 6 ++-- .../producttypen/models/producttype.py | 15 +++----- .../producttypen/tests/factories.py | 4 +-- .../tests/test_onderwerp_admin.py | 36 +++++++++++-------- .../producttypen/tests/test_prijs.py | 10 +++--- .../producttypen/tests/test_producttype.py | 16 ++++----- .../tests/test_producttype_admin.py | 6 ++-- .../producttypen/tests/test_vraag.py | 4 +-- src/open_producten/utils/models.py | 9 ++--- 15 files changed, 74 insertions(+), 80 deletions(-) diff --git a/bin/generate_api_schema.sh b/bin/generate_api_schema.sh index 17aa820..96fb3fd 100755 --- a/bin/generate_api_schema.sh +++ b/bin/generate_api_schema.sh @@ -17,5 +17,5 @@ OUTFILE=${1:-src/openapi.yaml} src/manage.py spectacular \ --validate \ --fail-on-warn \ - --lang=en \ + --lang=nl \ --file "$OUTFILE" diff --git a/src/open_producten/conf/base.py b/src/open_producten/conf/base.py index 00ad7f4..d431f12 100644 --- a/src/open_producten/conf/base.py +++ b/src/open_producten/conf/base.py @@ -110,6 +110,9 @@ "DESCRIPTION": _DESCRIPTION, "TOS": None, "VERSION": API_VERSION, + "SWAGGER_UI_DIST": "SIDECAR", + "SWAGGER_UI_FAVICON_HREF": "SIDECAR", + "REDOC_DIST": "SIDECAR", } # diff --git a/src/open_producten/producttypen/admin/prijs.py b/src/open_producten/producttypen/admin/prijs.py index f66204c..2cb0310 100644 --- a/src/open_producten/producttypen/admin/prijs.py +++ b/src/open_producten/producttypen/admin/prijs.py @@ -1,6 +1,7 @@ from django.contrib import admin from django.core.exceptions import ValidationError from django.forms import BaseInlineFormSet +from django.utils.translation import gettext_lazy as _ from ..models import Prijs, PrijsOptie @@ -8,7 +9,7 @@ class PrijsOptieInlineFormSet(BaseInlineFormSet): def clean(self): - """Check that at least one option has been added.""" + """Check that at least one optie has been added.""" super().clean() if any(self.errors): return @@ -16,7 +17,7 @@ def clean(self): cleaned_data and not cleaned_data.get("DELETE", False) for cleaned_data in self.cleaned_data ): - raise ValidationError("Er is minimaal één optie vereist.") + raise ValidationError(_("Er is minimaal één optie vereist.")) class PrijsOptieInline(admin.TabularInline): diff --git a/src/open_producten/producttypen/admin/producttype.py b/src/open_producten/producttypen/admin/producttype.py index ffd0683..913daea 100644 --- a/src/open_producten/producttypen/admin/producttype.py +++ b/src/open_producten/producttypen/admin/producttype.py @@ -24,18 +24,17 @@ class Meta: def clean(self): cleaned_data = super().clean() if len(cleaned_data["onderwerpen"]) == 0: - self.add_error("onderwerpen", _("Er is minimaal één onderwerp vereist")) + self.add_error("onderwerpen", _("Er is minimaal één onderwerp vereist.")) return cleaned_data @admin.register(ProductType) class ProductTypeAdmin(admin.ModelAdmin): list_display = ("naam", "aanmaak_datum", "display_onderwerpen", "gepubliceerd") - list_filter = ("gepubliceerd", "onderwerp") + list_filter = ("gepubliceerd", "onderwerpen") list_editable = ("gepubliceerd",) date_hierarchy = "aanmaak_datum" autocomplete_fields = ( - "gerelateerde_product_typen", # "organisations", # "contacts", # "locations", diff --git a/src/open_producten/producttypen/apps.py b/src/open_producten/producttypen/apps.py index e5d3c29..2b4a9af 100644 --- a/src/open_producten/producttypen/apps.py +++ b/src/open_producten/producttypen/apps.py @@ -1,6 +1,6 @@ from django.apps import AppConfig -class ProducttypesConfig(AppConfig): +class ProducttypenConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "open_producten.producttypen" diff --git a/src/open_producten/producttypen/migrations/0001_initial.py b/src/open_producten/producttypen/migrations/0001_initial.py index 1e90440..c912ee7 100644 --- a/src/open_producten/producttypen/migrations/0001_initial.py +++ b/src/open_producten/producttypen/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.16 on 2024-11-26 14:40 +# Generated by Django 4.2.16 on 2024-11-29 13:18 import datetime from decimal import Decimal @@ -33,15 +33,15 @@ class Migration(migrations.Migration): "gepubliceerd", models.BooleanField( default=False, - help_text="Whether the object is accessible through the API.", - verbose_name="Published", + help_text="Geeft aan of het object getoond kan worden.", + verbose_name="gepubliceerd", ), ), ( "aanmaak_datum", models.DateTimeField( auto_now_add=True, - help_text="The datetime at which the object was created.", + help_text="De datum waarop het object is aangemaakt.", verbose_name="Created on", ), ), @@ -49,7 +49,7 @@ class Migration(migrations.Migration): "update_datum", models.DateTimeField( auto_now=True, - help_text="The datetime at which the object was last changed.", + help_text="De datum waarop het object voor het laatst is gewijzigd.", verbose_name="Updated on", ), ), @@ -148,15 +148,15 @@ class Migration(migrations.Migration): "gepubliceerd", models.BooleanField( default=False, - help_text="Whether the object is accessible through the API.", - verbose_name="Published", + help_text="Geeft aan of het object getoond kan worden.", + verbose_name="gepubliceerd", ), ), ( "aanmaak_datum", models.DateTimeField( auto_now_add=True, - help_text="The datetime at which the object was created.", + help_text="De datum waarop het object is aangemaakt.", verbose_name="Created on", ), ), @@ -164,7 +164,7 @@ class Migration(migrations.Migration): "update_datum", models.DateTimeField( auto_now=True, - help_text="The datetime at which the object was last changed.", + help_text="De datum waarop het object voor het laatst is gewijzigd.", verbose_name="Updated on", ), ), @@ -204,16 +204,7 @@ class Migration(migrations.Migration): ), ), ( - "gerelateerde_product_typen", - models.ManyToManyField( - blank=True, - help_text="gerelateerde product typen naar dit product type.", - to="producttypen.producttype", - verbose_name="gerelateerde product typen", - ), - ), - ( - "onderwerp", + "onderwerpen", models.ManyToManyField( blank=True, help_text="onderwerpen waaraan het product type is gelinkt.", @@ -226,7 +217,7 @@ class Migration(migrations.Migration): ], options={ "verbose_name": "Product type", - "verbose_name_plural": "Product types", + "verbose_name_plural": "Product typen", }, ), migrations.CreateModel( diff --git a/src/open_producten/producttypen/models/onderwerp.py b/src/open_producten/producttypen/models/onderwerp.py index 0c22ac0..80d67ee 100644 --- a/src/open_producten/producttypen/models/onderwerp.py +++ b/src/open_producten/producttypen/models/onderwerp.py @@ -41,15 +41,15 @@ def __str__(self): return self.naam @property - def parent_onderwerp(self): + def hoofd_onderwerp(self): return self.get_parent() def move(self, target, pos=None): return PublishedMoveHandler(self, target, pos).process() def clean(self): - if self.gepubliceerd and self.parent_onderwerp: - if not self.parent_onderwerp.gepubliceerd: + if self.gepubliceerd and self.hoofd_onderwerp: + if not self.hoofd_onderwerp.gepubliceerd: raise ValidationError( _( "Hoofd-onderwerpen moeten gepubliceerd zijn voordat sub-onderwerpen kunnen worden gepubliceerd." diff --git a/src/open_producten/producttypen/models/producttype.py b/src/open_producten/producttypen/models/producttype.py index ad6adb0..4129315 100644 --- a/src/open_producten/producttypen/models/producttype.py +++ b/src/open_producten/producttypen/models/producttype.py @@ -15,7 +15,7 @@ class OnderwerpProductType(models.Model): """ - Through-model for Category-ProductType m2m-relations. + Through-model for Onderwerp-ProductType m2m-relations. """ onderwerp = models.ForeignKey(Onderwerp, on_delete=models.CASCADE) @@ -41,13 +41,6 @@ class ProductType(BasePublishableModel): help_text=_("Product type beschrijving met WYSIWYG editor."), ) - gerelateerde_product_typen = models.ManyToManyField( - "ProductType", - verbose_name=_("gerelateerde product typen"), - blank=True, - help_text=_("gerelateerde product typen naar dit product type."), - ) - keywords = ArrayField( models.CharField(max_length=100, blank=True), verbose_name=_("Keywords"), @@ -64,7 +57,7 @@ class ProductType(BasePublishableModel): related_name="product_typen", ) - onderwerp = models.ManyToManyField( + onderwerpen = models.ManyToManyField( Onderwerp, verbose_name=_("onderwerp"), blank=True, @@ -99,7 +92,7 @@ class ProductType(BasePublishableModel): class Meta: verbose_name = _("Product type") - verbose_name_plural = _("Product types") + verbose_name_plural = _("Product typen") def __str__(self): return self.naam @@ -113,7 +106,7 @@ def __str__(self): # self.organisations.add(contact.organisation) @property - def current_price(self): + def actuele_prijs(self): now = date.today() return ( self.prijzen.filter(actief_vanaf__lte=now).order_by("actief_vanaf").last() diff --git a/src/open_producten/producttypen/tests/factories.py b/src/open_producten/producttypen/tests/factories.py index 159d703..6299b64 100644 --- a/src/open_producten/producttypen/tests/factories.py +++ b/src/open_producten/producttypen/tests/factories.py @@ -32,7 +32,7 @@ class Meta: class OnderwerpFactory(factory.django.DjangoModelFactory): - naam = factory.Sequence(lambda n: f"subject {n}") + naam = factory.Sequence(lambda n: f"onderwerp {n}") beschrijving = factory.Faker("sentence") gepubliceerd = True @@ -71,7 +71,7 @@ class Meta: class BestandFactory(factory.django.DjangoModelFactory): product_type = factory.SubFactory(ProductTypeFactory) - bestand = factory.django.FileField(filename="test_file.txt") + bestand = factory.django.FileField(filename="test_bestand.txt") class Meta: model = Bestand diff --git a/src/open_producten/producttypen/tests/test_onderwerp_admin.py b/src/open_producten/producttypen/tests/test_onderwerp_admin.py index 8cd46e0..7d54078 100644 --- a/src/open_producten/producttypen/tests/test_onderwerp_admin.py +++ b/src/open_producten/producttypen/tests/test_onderwerp_admin.py @@ -27,9 +27,9 @@ def setUp(self): "depth": 1, } - def test_parent_nodes_must_be_published_when_publishing_child(self): - parent = OnderwerpFactory.create(gepubliceerd=False) - data = self.data | {"gepubliceerd": True, "_ref_node_id": parent.id} + def test_hoofd_onderwerpen_must_be_published_when_publishing_sub_onderwerp(self): + hoofd_onderwerp = OnderwerpFactory.create(gepubliceerd=False) + data = self.data | {"gepubliceerd": True, "_ref_node_id": hoofd_onderwerp.id} form = create_form(data) @@ -40,18 +40,18 @@ def test_parent_nodes_must_be_published_when_publishing_child(self): ], ) - parent.gepubliceerd = True - parent.save() + hoofd_onderwerp.gepubliceerd = True + hoofd_onderwerp.save() form = create_form(data) self.assertEquals(form.errors, {}) - def test_parent_nodes_cannot_be_published_with_published_children(self): - parent = OnderwerpFactory.create(gepubliceerd=False) - parent.add_child(**{"naam": "child", "gepubliceerd": True}) + def test_hoofd_onderwerpen_cannot_be_published_with_published_sub_onderwerpen(self): + hoofd_onderwerp = OnderwerpFactory.create(gepubliceerd=False) + hoofd_onderwerp.add_child(**{"naam": "sub onderwerp", "gepubliceerd": True}) data = {"gepubliceerd": False, "_ref_node_id": None} - form = create_form(data, parent) + form = create_form(data, hoofd_onderwerp) self.assertEquals( form.non_field_errors(), @@ -61,7 +61,7 @@ def test_parent_nodes_cannot_be_published_with_published_children(self): ) -class TestCategoryAdminFormSet(TestCase): +class TestOnderwerpAdminFormSet(TestCase): def setUp(self): self.formset = modelformset_factory( @@ -70,16 +70,22 @@ def setUp(self): fields=OnderwerpAdmin.list_editable, ) - self.parent = Onderwerp.add_root(**{"naam": "parent", "gepubliceerd": False}) - self.child = self.parent.add_child(**{"naam": "child", "gepubliceerd": True}) + self.hoofd_onderwerp = Onderwerp.add_root( + **{"naam": "hoofd onderwerp", "gepubliceerd": False} + ) + self.sub_onderwerp = self.hoofd_onderwerp.add_child( + **{"naam": "sub onderwerp", "gepubliceerd": True} + ) - def test_parent_nodes_cannot_be_unpublished_with_published_children(self): + def test_hoofd_onderwerpen_cannot_be_unpublished_with_published_sub_onderwerpen( + self, + ): data = build_formset_data( "form", { - "id": self.parent.id, # gepubliceerd off + "id": self.hoofd_onderwerp.id, # gepubliceerd false }, - {"id": self.child.id, "gepubliceerd": "on"}, + {"id": self.sub_onderwerp.id, "gepubliceerd": "on"}, ) object_formset = self.formset(data) diff --git a/src/open_producten/producttypen/tests/test_prijs.py b/src/open_producten/producttypen/tests/test_prijs.py index 80efaeb..51a506c 100644 --- a/src/open_producten/producttypen/tests/test_prijs.py +++ b/src/open_producten/producttypen/tests/test_prijs.py @@ -27,16 +27,16 @@ def test_min_date_validation(self): prijs.full_clean() -class TestPrijsOption(TestCase): +class TestPrijsOptie(TestCase): def setUp(self): self.prijs = PrijsFactory.create() def test_min_amount_validation(self): with self.assertRaises(ValidationError): - option = PrijsOptieFactory.build(prijs=self.prijs, bedrag=Decimal("-1")) - option.full_clean() + optie = PrijsOptieFactory.build(prijs=self.prijs, bedrag=Decimal("-1")) + optie.full_clean() def test_decimal_place_validation(self): with self.assertRaises(ValidationError): - option = PrijsOptieFactory.build(prijs=self.prijs, bedrag=Decimal("0.001")) - option.full_clean() + optie = PrijsOptieFactory.build(prijs=self.prijs, bedrag=Decimal("0.001")) + optie.full_clean() diff --git a/src/open_producten/producttypen/tests/test_producttype.py b/src/open_producten/producttypen/tests/test_producttype.py index e996c5e..483bb73 100644 --- a/src/open_producten/producttypen/tests/test_producttype.py +++ b/src/open_producten/producttypen/tests/test_producttype.py @@ -11,24 +11,24 @@ class TestProductType(TestCase): def setUp(self): self.product_type = ProductTypeFactory.create() - self.past_price = PrijsFactory.create( + self.past_prijs = PrijsFactory.create( product_type=self.product_type, actief_vanaf=date(2020, 1, 1) ) - self.current_price = PrijsFactory.create( + self.current_prijs = PrijsFactory.create( product_type=self.product_type, actief_vanaf=date(2024, 1, 1) ) - self.future_price = PrijsFactory.create( + self.future_prijs = PrijsFactory.create( product_type=self.product_type, actief_vanaf=date(2025, 1, 1) ) @freeze_time("2024-02-02") - def test_current_price_when_set(self): - price = self.product_type.current_price - self.assertEqual(price, self.current_price) + def test_actuele_prijs_when_set(self): + prijs = self.product_type.actuele_prijs + self.assertEqual(prijs, self.current_prijs) - def test_current_price_without_prices(self): + def test_actuele_prijs_without_prijzen(self): self.product_type = ProductTypeFactory.create() - self.assertIsNone(self.product_type.current_price) + self.assertIsNone(self.product_type.actuele_prijs) # def test_clean_with_contact_that_has_no_org(self): # contact = ContactFactory(organisation_id=None) diff --git a/src/open_producten/producttypen/tests/test_producttype_admin.py b/src/open_producten/producttypen/tests/test_producttype_admin.py index e7895e3..42a76bc 100644 --- a/src/open_producten/producttypen/tests/test_producttype_admin.py +++ b/src/open_producten/producttypen/tests/test_producttype_admin.py @@ -11,15 +11,15 @@ def setUp(self): self.data = { "naam": "test", "uniforme_product_naam": upn, - "beschrijving": "content", - "samenvatting": "summary", + "beschrijving": "beschrijving", + "samenvatting": "samenvatting", } def test_at_least_one_onderwerp_is_required(self): form = ProductTypeAdminForm(data=self.data) self.assertEquals( - form.errors, {"onderwerpen": ["Er is minimaal één onderwerp vereist"]} + form.errors, {"onderwerpen": ["Er is minimaal één onderwerp vereist."]} ) OnderwerpFactory.create() diff --git a/src/open_producten/producttypen/tests/test_vraag.py b/src/open_producten/producttypen/tests/test_vraag.py index 14ff137..1ec57e9 100644 --- a/src/open_producten/producttypen/tests/test_vraag.py +++ b/src/open_producten/producttypen/tests/test_vraag.py @@ -10,7 +10,7 @@ def setUp(self): self.productType = ProductTypeFactory.create() self.onderwerp = OnderwerpFactory.create() - def test_error_when_linked_to_type_and_onderwerp(self): + def test_error_when_linked_to_product_type_and_onderwerp(self): vraag = VraagFactory.build( product_type=self.productType, onderwerp=self.onderwerp ) @@ -18,7 +18,7 @@ def test_error_when_linked_to_type_and_onderwerp(self): with self.assertRaises(ValidationError): vraag.clean() - def test_error_when_not_linked_to_type_or_onderwerp(self): + def test_error_when_not_linked_to_product_type_or_onderwerp(self): vraag = VraagFactory.build() with self.assertRaises(ValidationError): diff --git a/src/open_producten/utils/models.py b/src/open_producten/utils/models.py index fbc103f..fa5c28c 100644 --- a/src/open_producten/utils/models.py +++ b/src/open_producten/utils/models.py @@ -13,19 +13,20 @@ class Meta: class BasePublishableModel(BaseModel): gepubliceerd = models.BooleanField( - verbose_name=_("Published"), + verbose_name=_("gepubliceerd"), default=False, - help_text=_("Whether the object is accessible through the API."), + help_text=_("Geeft aan of het object getoond kan worden."), + # TODO unpublished objects are currently not filtered out of api. ) aanmaak_datum = models.DateTimeField( verbose_name=_("Created on"), auto_now_add=True, - help_text=_("The datetime at which the object was created."), + help_text=_("De datum waarop het object is aangemaakt."), ) update_datum = models.DateTimeField( verbose_name=_("Updated on"), auto_now=True, - help_text=_("The datetime at which the object was last changed."), + help_text=_("De datum waarop het object voor het laatst is gewijzigd."), ) class Meta: From a9ebe0d1b96dfcfb6c3d7f25396a1adba875cc16 Mon Sep 17 00:00:00 2001 From: Floris272 Date: Mon, 9 Dec 2024 16:40:16 +0100 Subject: [PATCH 4/8] Add pr feedback --- .../conf/locale/en/LC_MESSAGES/django.po | 358 +++++++++++++---- .../conf/locale/nl/LC_MESSAGES/django.po | 377 +++++++++++++----- .../producttypen/admin/bestand.py | 1 + src/open_producten/producttypen/admin/link.py | 2 + .../producttypen/admin/onderwerp.py | 6 +- .../producttypen/admin/prijs.py | 2 + .../producttypen/admin/producttype.py | 13 +- .../management/commands/load_upl.py | 123 ++++-- .../producttypen/management/parsers.py | 29 -- ...2_alter_onderwerp_beschrijving_and_more.py | 98 +++++ .../producttypen/models/onderwerp.py | 19 +- .../producttypen/models/prijs.py | 1 - .../producttypen/models/producttype.py | 4 +- .../producttypen/models/vraag.py | 13 +- .../tests/data/upl-empty-data.csv | 2 + ...{wrong-upl.csv => upl-missing-columns.csv} | 0 .../producttypen/tests/test_load_upn.py | 175 +++++--- .../producttypen/tests/test_onderwerp.py | 36 ++ .../tests/test_onderwerp_admin.py | 55 +-- 19 files changed, 940 insertions(+), 374 deletions(-) delete mode 100644 src/open_producten/producttypen/management/parsers.py create mode 100644 src/open_producten/producttypen/migrations/0002_alter_onderwerp_beschrijving_and_more.py create mode 100644 src/open_producten/producttypen/tests/data/upl-empty-data.csv rename src/open_producten/producttypen/tests/data/{wrong-upl.csv => upl-missing-columns.csv} (100%) create mode 100644 src/open_producten/producttypen/tests/test_onderwerp.py diff --git a/src/open_producten/conf/locale/en/LC_MESSAGES/django.po b/src/open_producten/conf/locale/en/LC_MESSAGES/django.po index 69a8337..16479c7 100644 --- a/src/open_producten/conf/locale/en/LC_MESSAGES/django.po +++ b/src/open_producten/conf/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-11-25 16:08+0100\n" +"POT-Creation-Date: 2024-12-09 16:39+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -16,199 +16,387 @@ msgstr "" "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" -#: open_producten/accounts/models.py:18 + +#: accounts/models.py:19 msgid "username" msgstr "" -#: open_producten/accounts/models.py:22 +#: accounts/models.py:23 msgid "Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only." msgstr "" -#: open_producten/accounts/models.py:26 +#: accounts/models.py:27 msgid "A user with that username already exists." msgstr "" -#: open_producten/accounts/models.py:29 +#: accounts/models.py:30 msgid "first name" msgstr "" -#: open_producten/accounts/models.py:30 +#: accounts/models.py:31 msgid "last name" msgstr "" -#: open_producten/accounts/models.py:31 +#: accounts/models.py:32 msgid "email address" msgstr "" -#: open_producten/accounts/models.py:33 +#: accounts/models.py:34 msgid "staff status" msgstr "" -#: open_producten/accounts/models.py:35 +#: accounts/models.py:36 msgid "Designates whether the user can log into this admin site." msgstr "" -#: open_producten/accounts/models.py:38 +#: accounts/models.py:39 msgid "active" msgstr "" -#: open_producten/accounts/models.py:41 +#: accounts/models.py:42 msgid "" "Designates whether this user should be treated as active. Unselect this " "instead of deleting accounts." msgstr "" -#: open_producten/accounts/models.py:45 +#: accounts/models.py:46 msgid "date joined" msgstr "" -#: open_producten/accounts/models.py:53 +#: accounts/models.py:54 msgid "user" msgstr "" -#: open_producten/accounts/models.py:54 +#: accounts/models.py:55 msgid "users" msgstr "" -#: open_producten/accounts/utils.py:18 +#: accounts/utils.py:18 msgid "You need to be superuser to create other superusers." msgstr "" -#: open_producten/accounts/utils.py:44 +#: accounts/utils.py:44 msgid "You cannot create or update a user with more permissions than yourself." msgstr "" -#: open_producten/producttypen/models/tag.py:9 -#: open_producten/producttypen/models/tag.py:25 -msgid "name" +#: producttypen/admin/onderwerp.py:39 +msgid "Onderwerpen moeten gepubliceerd zijn met gepubliceerde sub onderwerpen." msgstr "" -#: open_producten/producttypen/models/tag.py:11 -msgid "Name of the tag type" +#: producttypen/admin/prijs.py:20 +msgid "Er is minimaal één optie vereist." msgstr "" -#: open_producten/producttypen/models/tag.py:16 -msgid "tag type" +#: producttypen/admin/producttype.py:18 +msgid "onderwerpen" msgstr "" -#: open_producten/producttypen/models/tag.py:17 -msgid "tag types" +#: producttypen/admin/producttype.py:21 producttypen/models/onderwerp.py:36 +msgid "Onderwerp" msgstr "" -#: open_producten/producttypen/models/tag.py:25 -msgid "Name of the tag" +#: producttypen/admin/producttype.py:27 +msgid "Er is minimaal één onderwerp vereist." msgstr "" -#: open_producten/producttypen/models/tag.py:28 -msgid "icon" +#: producttypen/models/bestand.py:12 producttypen/models/prijs.py:16 +msgid "product type" msgstr "" -#: open_producten/producttypen/models/tag.py:31 -msgid "Icon of the tag" +#: producttypen/models/bestand.py:15 +msgid "Het product type waarbij dit bestand hoort." msgstr "" -#: open_producten/producttypen/models/tag.py:35 -msgid "type" +#: producttypen/models/bestand.py:17 +msgid "bestand" msgstr "" -#: open_producten/producttypen/models/tag.py:38 -msgid "The related tag type" +#: producttypen/models/bestand.py:20 +msgid "Product type bestand" msgstr "" -#: open_producten/producttypen/models/tag.py:42 -msgid "tag" +#: producttypen/models/bestand.py:21 +msgid "Product type bestanden" msgstr "" -#: open_producten/producttypen/models/tag.py:43 -msgid "tags" +#: producttypen/models/link.py:12 producttypen/models/producttype.py:94 +#: producttypen/models/vraag.py:25 +msgid "Product type" msgstr "" -#: open_producten/templates/admin/base_site.html:5 -#: open_producten/templates/admin/base_site.html:21 -msgid "Administration" +#: producttypen/models/link.py:15 +msgid "Het product type waarbij deze link hoort." msgstr "" -#: open_producten/templates/admin/base_site.html:32 -msgid "View site" +#: producttypen/models/link.py:18 producttypen/models/onderwerp.py:25 +#: producttypen/models/upn.py:9 +msgid "naam" msgstr "" -#: open_producten/templates/admin/base_site.html:38 -msgid "Account security" +#: producttypen/models/link.py:18 +msgid "Naam van de link." msgstr "" -#: open_producten/templates/admin/base_site.html:41 -msgid "Change password" +#: producttypen/models/link.py:20 +msgid "Url" msgstr "" -#: open_producten/templates/admin/base_site.html:43 -msgid "Log out" +#: producttypen/models/link.py:20 +msgid "Url van de link." msgstr "" -#: open_producten/templates/maykin_2fa/login.html:18 -msgid "Contact support" +#: producttypen/models/link.py:23 +msgid "Product type link" msgstr "" -#: open_producten/templates/samples/pager.html:8 -#: open_producten/templates/samples/pager.html:10 -msgid "Previous" +#: producttypen/models/link.py:24 +msgid "Product type links" msgstr "" -#: open_producten/templates/samples/pager.html:16 -#: open_producten/templates/samples/pager.html:18 -msgid "Next" +#: producttypen/models/onderwerp.py:17 +msgid "" +"Gepubliceerde onderwerpen kunnen kunnen geen ongepubliceerd hoofd-onderwerp " +"hebben." msgstr "" -#: open_producten/utils/management/commands/clear_cache.py:10 -msgid "Clear given cache only" +#: producttypen/models/onderwerp.py:25 +msgid "Naam van het onderwerp." msgstr "" -#: open_producten/utils/models.py:16 -msgid "Published" +#: producttypen/models/onderwerp.py:29 producttypen/models/prijs.py:52 +#: producttypen/models/producttype.py:40 +msgid "beschrijving" msgstr "" -#: open_producten/utils/models.py:18 -msgid "Whether the object is accessible through the API." +#: producttypen/models/onderwerp.py:32 +msgid "Beschrijving van het onderwerp, ondersteund markdown format." msgstr "" -#: open_producten/utils/models.py:21 -msgid "Created on" +#: producttypen/models/onderwerp.py:37 +msgid "Onderwerpen" msgstr "" -#: open_producten/utils/models.py:23 -msgid "The datetime at which the object was created." +#: producttypen/models/onderwerp.py:58 +msgid "" +"Onderwerpen moeten gepubliceerd zijn voordat sub-onderwerpen kunnen worden " +"gepubliceerd." msgstr "" -#: open_producten/utils/models.py:26 -msgid "Updated on" +#: producttypen/models/onderwerp.py:68 +msgid "" +"Onderwerpen kunnen niet ongepubliceerd worden als ze gepubliceerde sub-" +"onderwerpen hebben." msgstr "" -#: open_producten/utils/models.py:28 -msgid "The datetime at which the object was last changed." +#: producttypen/models/prijs.py:19 +msgid "Het product type waarbij deze prijs hoort." msgstr "" -#: open_producten/utils/templates/utils/includes/version_info.html:4 -#, python-format -msgid "version %(RELEASE)s" +#: producttypen/models/prijs.py:22 +msgid "start datum" msgstr "" -#: open_producten/utils/templates/utils/includes/version_info.html:8 -#, python-format -msgid "GIT SHA: %(GIT_SHA)s" +#: producttypen/models/prijs.py:24 +msgid "De datum vanaf wanneer de prijs actief is." +msgstr "" + +#: producttypen/models/prijs.py:28 +msgid "Prijs" +msgstr "" + +#: producttypen/models/prijs.py:29 +msgid "Prijzen" +msgstr "" + +#: producttypen/models/prijs.py:39 +msgid "prijs" +msgstr "" + +#: producttypen/models/prijs.py:42 +msgid "De prijs waarbij deze optie hoort." +msgstr "" + +#: producttypen/models/prijs.py:45 +msgid "bedrag" +msgstr "" + +#: producttypen/models/prijs.py:49 +msgid "Het bedrag van de prijs optie." +msgstr "" + +#: producttypen/models/prijs.py:54 +msgid "Korte beschrijving van de optie." +msgstr "" + +#: producttypen/models/prijs.py:58 +msgid "Prijs optie" +msgstr "" + +#: producttypen/models/prijs.py:59 +msgid "Prijs opties" +msgstr "" + +#: producttypen/models/producttype.py:27 +msgid "product type naam" +msgstr "" + +#: producttypen/models/producttype.py:29 +msgid "naam van het product type." +msgstr "" + +#: producttypen/models/producttype.py:33 +msgid "samenvatting" +msgstr "" + +#: producttypen/models/producttype.py:36 +msgid "Korte beschrijving van het product type, maximaal 300 karakters." +msgstr "" + +#: producttypen/models/producttype.py:41 +msgid "Product type beschrijving, ondersteund markdown format." +msgstr "" + +#: producttypen/models/producttype.py:46 +msgid "Keywords" +msgstr "" + +#: producttypen/models/producttype.py:49 +msgid "Lijst van keywords waarop kan worden gezocht." +msgstr "" + +#: producttypen/models/producttype.py:54 +msgid "Uniforme Product naam" +msgstr "" + +#: producttypen/models/producttype.py:56 +msgid "Uniforme product naam gedefinieerd door de overheid." +msgstr "" + +#: producttypen/models/producttype.py:62 producttypen/models/vraag.py:16 +msgid "onderwerp" +msgstr "" + +#: producttypen/models/producttype.py:65 +msgid "onderwerpen waaraan het product type is gelinkt." +msgstr "" + +#: producttypen/models/producttype.py:95 +msgid "Product typen" +msgstr "" + +#: producttypen/models/upn.py:11 producttypen/models/upn.py:27 +msgid "Uniforme product naam" +msgstr "" + +#: producttypen/models/upn.py:16 +msgid "Uri" +msgstr "" + +#: producttypen/models/upn.py:17 +msgid "Uri naar de UPN definitie." +msgstr "" + +#: producttypen/models/upn.py:21 +msgid "is verwijderd" +msgstr "" + +#: producttypen/models/upn.py:22 +msgid "Geeft aan of de UPN is verwijderd." +msgstr "" + +#: producttypen/models/upn.py:28 +msgid "Uniforme product namen" +msgstr "" + +#: producttypen/models/vraag.py:21 +msgid "Het onderwerp waarbij deze vraag hoort." +msgstr "" + +#: producttypen/models/vraag.py:30 +msgid "Het product type waarbij deze vraag hoort." +msgstr "" + +#: producttypen/models/vraag.py:33 +msgid "vraag" +msgstr "" + +#: producttypen/models/vraag.py:35 +msgid "De vraag die wordt beantwoord." +msgstr "" + +#: producttypen/models/vraag.py:38 +msgid "antwoord" +msgstr "" + +#: producttypen/models/vraag.py:39 +msgid "Het antwoord op de vraag, ondersteund markdown format." +msgstr "" + +#: producttypen/models/vraag.py:43 +msgid "Vraag" +msgstr "" + +#: producttypen/models/vraag.py:44 +msgid "Vragen" +msgstr "" + +#: producttypen/models/vraag.py:50 +msgid "Een vraag kan niet gelink zijn aan een onderwerp en een product type." +msgstr "" + +#: producttypen/models/vraag.py:56 +msgid "Een vraag moet gelinkt zijn aan een onderwerp of een product type." +msgstr "" + +#: templates/maykin_2fa/login.html:13 +msgid "or" +msgstr "" + +#: templates/maykin_2fa/login.html:16 +msgid "Login with organization account" +msgstr "" + +#: templates/maykin_2fa/login.html:25 +msgid "Contact support" +msgstr "" + +#: utils/management/commands/clear_cache.py:10 +msgid "Clear given cache only" +msgstr "" + +#: utils/models.py:16 +msgid "gepubliceerd" +msgstr "" + +#: utils/models.py:18 +msgid "Geeft aan of het object getoond kan worden." +msgstr "" + +#: utils/models.py:22 +msgid "Created on" +msgstr "" + +#: utils/models.py:24 +msgid "De datum waarop het object is aangemaakt." +msgstr "" + +#: utils/models.py:27 +msgid "Updated on" +msgstr "" + +#: utils/models.py:29 +msgid "De datum waarop het object voor het laatst is gewijzigd." msgstr "" -#: open_producten/utils/tests/test_validators.py:24 -#: open_producten/utils/validators.py:23 +#: utils/tests/test_validators.py:24 utils/validators.py:23 #, python-format msgid "The provided value contains an invalid character: %s" msgstr "" -#: open_producten/utils/tests/test_validators.py:61 -#: open_producten/utils/validators.py:52 +#: utils/tests/test_validators.py:61 utils/validators.py:52 msgid "Invalid postal code." msgstr "" -#: open_producten/utils/tests/test_validators.py:85 -#: open_producten/utils/validators.py:32 +#: utils/tests/test_validators.py:85 utils/validators.py:32 msgid "Invalid mobile phonenumber." msgstr "" diff --git a/src/open_producten/conf/locale/nl/LC_MESSAGES/django.po b/src/open_producten/conf/locale/nl/LC_MESSAGES/django.po index bf68e01..16479c7 100644 --- a/src/open_producten/conf/locale/nl/LC_MESSAGES/django.po +++ b/src/open_producten/conf/locale/nl/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-11-25 14:52+0100\n" +"POT-Creation-Date: 2024-12-09 16:39+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -16,206 +16,387 @@ msgstr "" "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" -#: open_producten/accounts/models.py:18 +#: accounts/models.py:19 msgid "username" msgstr "" -#: open_producten/accounts/models.py:22 +#: accounts/models.py:23 msgid "Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only." msgstr "" -#: open_producten/accounts/models.py:26 +#: accounts/models.py:27 msgid "A user with that username already exists." msgstr "" -#: open_producten/accounts/models.py:29 +#: accounts/models.py:30 msgid "first name" msgstr "" -#: open_producten/accounts/models.py:30 +#: accounts/models.py:31 msgid "last name" msgstr "" -#: open_producten/accounts/models.py:31 +#: accounts/models.py:32 msgid "email address" msgstr "" -#: open_producten/accounts/models.py:33 +#: accounts/models.py:34 msgid "staff status" msgstr "" -#: open_producten/accounts/models.py:35 +#: accounts/models.py:36 msgid "Designates whether the user can log into this admin site." msgstr "" -#: open_producten/accounts/models.py:38 +#: accounts/models.py:39 msgid "active" msgstr "" -#: open_producten/accounts/models.py:41 +#: accounts/models.py:42 msgid "" "Designates whether this user should be treated as active. Unselect this " "instead of deleting accounts." msgstr "" -#: open_producten/accounts/models.py:45 +#: accounts/models.py:46 msgid "date joined" msgstr "" -#: open_producten/accounts/models.py:53 +#: accounts/models.py:54 msgid "user" msgstr "" -#: open_producten/accounts/models.py:54 +#: accounts/models.py:55 msgid "users" msgstr "" -#: open_producten/accounts/utils.py:18 +#: accounts/utils.py:18 msgid "You need to be superuser to create other superusers." msgstr "" -#: open_producten/accounts/utils.py:44 +#: accounts/utils.py:44 msgid "You cannot create or update a user with more permissions than yourself." msgstr "" -#: open_producten/producttypen/models/tag.py:9 -#: open_producten/producttypen/models/tag.py:25 -msgid "name" -msgstr "naam" +#: producttypen/admin/onderwerp.py:39 +msgid "Onderwerpen moeten gepubliceerd zijn met gepubliceerde sub onderwerpen." +msgstr "" -#: open_producten/producttypen/models/tag.py:11 -msgid "Name of the tag type" -msgstr "Naam van het tag type" +#: producttypen/admin/prijs.py:20 +msgid "Er is minimaal één optie vereist." +msgstr "" -#: open_producten/producttypen/models/tag.py:16 -#, fuzzy -#| msgid "Tag types" -msgid "tag type" +#: producttypen/admin/producttype.py:18 +msgid "onderwerpen" msgstr "" -#: open_producten/producttypen/models/tag.py:17 -#, fuzzy -#| msgid "Tag types" -msgid "tag types" -msgstr "tag typen" +#: producttypen/admin/producttype.py:21 producttypen/models/onderwerp.py:36 +msgid "Onderwerp" +msgstr "" -#: open_producten/producttypen/models/tag.py:25 -msgid "Name of the tag" -msgstr "Naam van de tag" +#: producttypen/admin/producttype.py:27 +msgid "Er is minimaal één onderwerp vereist." +msgstr "" -#: open_producten/producttypen/models/tag.py:28 -msgid "icon" -msgstr "icoon" +#: producttypen/models/bestand.py:12 producttypen/models/prijs.py:16 +msgid "product type" +msgstr "" -#: open_producten/producttypen/models/tag.py:31 -msgid "Icon of the tag" -msgstr "Icoon van de tag" +#: producttypen/models/bestand.py:15 +msgid "Het product type waarbij dit bestand hoort." +msgstr "" -#: open_producten/producttypen/models/tag.py:35 -#, fuzzy -#| msgid "Tag types" -msgid "type" +#: producttypen/models/bestand.py:17 +msgid "bestand" msgstr "" -#: open_producten/producttypen/models/tag.py:38 -msgid "The related tag type" -msgstr "Het gerelateerde tag type" +#: producttypen/models/bestand.py:20 +msgid "Product type bestand" +msgstr "" -#: open_producten/producttypen/models/tag.py:42 -msgid "tag" +#: producttypen/models/bestand.py:21 +msgid "Product type bestanden" msgstr "" -#: open_producten/producttypen/models/tag.py:43 -msgid "tags" +#: producttypen/models/link.py:12 producttypen/models/producttype.py:94 +#: producttypen/models/vraag.py:25 +msgid "Product type" msgstr "" -#: open_producten/templates/admin/base_site.html:5 -#: open_producten/templates/admin/base_site.html:21 -msgid "Administration" +#: producttypen/models/link.py:15 +msgid "Het product type waarbij deze link hoort." msgstr "" -#: open_producten/templates/admin/base_site.html:32 -msgid "View site" +#: producttypen/models/link.py:18 producttypen/models/onderwerp.py:25 +#: producttypen/models/upn.py:9 +msgid "naam" msgstr "" -#: open_producten/templates/admin/base_site.html:38 -msgid "Account security" +#: producttypen/models/link.py:18 +msgid "Naam van de link." msgstr "" -#: open_producten/templates/admin/base_site.html:41 -msgid "Change password" +#: producttypen/models/link.py:20 +msgid "Url" msgstr "" -#: open_producten/templates/admin/base_site.html:43 -msgid "Log out" +#: producttypen/models/link.py:20 +msgid "Url van de link." msgstr "" -#: open_producten/templates/maykin_2fa/login.html:18 -msgid "Contact support" +#: producttypen/models/link.py:23 +msgid "Product type link" msgstr "" -#: open_producten/templates/samples/pager.html:8 -#: open_producten/templates/samples/pager.html:10 -msgid "Previous" +#: producttypen/models/link.py:24 +msgid "Product type links" msgstr "" -#: open_producten/templates/samples/pager.html:16 -#: open_producten/templates/samples/pager.html:18 -msgid "Next" +#: producttypen/models/onderwerp.py:17 +msgid "" +"Gepubliceerde onderwerpen kunnen kunnen geen ongepubliceerd hoofd-onderwerp " +"hebben." msgstr "" -#: open_producten/utils/management/commands/clear_cache.py:10 -msgid "Clear given cache only" +#: producttypen/models/onderwerp.py:25 +msgid "Naam van het onderwerp." msgstr "" -#: open_producten/utils/models.py:16 -msgid "Published" +#: producttypen/models/onderwerp.py:29 producttypen/models/prijs.py:52 +#: producttypen/models/producttype.py:40 +msgid "beschrijving" msgstr "" -#: open_producten/utils/models.py:18 -msgid "Whether the object is accessible through the API." +#: producttypen/models/onderwerp.py:32 +msgid "Beschrijving van het onderwerp, ondersteund markdown format." msgstr "" -#: open_producten/utils/models.py:21 -msgid "Created on" +#: producttypen/models/onderwerp.py:37 +msgid "Onderwerpen" +msgstr "" + +#: producttypen/models/onderwerp.py:58 +msgid "" +"Onderwerpen moeten gepubliceerd zijn voordat sub-onderwerpen kunnen worden " +"gepubliceerd." msgstr "" -#: open_producten/utils/models.py:23 -msgid "The datetime at which the object was created." +#: producttypen/models/onderwerp.py:68 +msgid "" +"Onderwerpen kunnen niet ongepubliceerd worden als ze gepubliceerde sub-" +"onderwerpen hebben." msgstr "" -#: open_producten/utils/models.py:26 -msgid "Updated on" +#: producttypen/models/prijs.py:19 +msgid "Het product type waarbij deze prijs hoort." msgstr "" -#: open_producten/utils/models.py:28 -msgid "The datetime at which the object was last changed." +#: producttypen/models/prijs.py:22 +msgid "start datum" msgstr "" -#: open_producten/utils/templates/utils/includes/version_info.html:4 -#, python-format -msgid "version %(RELEASE)s" +#: producttypen/models/prijs.py:24 +msgid "De datum vanaf wanneer de prijs actief is." msgstr "" -#: open_producten/utils/templates/utils/includes/version_info.html:8 -#, python-format -msgid "GIT SHA: %(GIT_SHA)s" +#: producttypen/models/prijs.py:28 +msgid "Prijs" +msgstr "" + +#: producttypen/models/prijs.py:29 +msgid "Prijzen" +msgstr "" + +#: producttypen/models/prijs.py:39 +msgid "prijs" +msgstr "" + +#: producttypen/models/prijs.py:42 +msgid "De prijs waarbij deze optie hoort." +msgstr "" + +#: producttypen/models/prijs.py:45 +msgid "bedrag" +msgstr "" + +#: producttypen/models/prijs.py:49 +msgid "Het bedrag van de prijs optie." +msgstr "" + +#: producttypen/models/prijs.py:54 +msgid "Korte beschrijving van de optie." +msgstr "" + +#: producttypen/models/prijs.py:58 +msgid "Prijs optie" +msgstr "" + +#: producttypen/models/prijs.py:59 +msgid "Prijs opties" +msgstr "" + +#: producttypen/models/producttype.py:27 +msgid "product type naam" +msgstr "" + +#: producttypen/models/producttype.py:29 +msgid "naam van het product type." +msgstr "" + +#: producttypen/models/producttype.py:33 +msgid "samenvatting" +msgstr "" + +#: producttypen/models/producttype.py:36 +msgid "Korte beschrijving van het product type, maximaal 300 karakters." +msgstr "" + +#: producttypen/models/producttype.py:41 +msgid "Product type beschrijving, ondersteund markdown format." +msgstr "" + +#: producttypen/models/producttype.py:46 +msgid "Keywords" +msgstr "" + +#: producttypen/models/producttype.py:49 +msgid "Lijst van keywords waarop kan worden gezocht." +msgstr "" + +#: producttypen/models/producttype.py:54 +msgid "Uniforme Product naam" +msgstr "" + +#: producttypen/models/producttype.py:56 +msgid "Uniforme product naam gedefinieerd door de overheid." +msgstr "" + +#: producttypen/models/producttype.py:62 producttypen/models/vraag.py:16 +msgid "onderwerp" +msgstr "" + +#: producttypen/models/producttype.py:65 +msgid "onderwerpen waaraan het product type is gelinkt." +msgstr "" + +#: producttypen/models/producttype.py:95 +msgid "Product typen" +msgstr "" + +#: producttypen/models/upn.py:11 producttypen/models/upn.py:27 +msgid "Uniforme product naam" +msgstr "" + +#: producttypen/models/upn.py:16 +msgid "Uri" +msgstr "" + +#: producttypen/models/upn.py:17 +msgid "Uri naar de UPN definitie." +msgstr "" + +#: producttypen/models/upn.py:21 +msgid "is verwijderd" +msgstr "" + +#: producttypen/models/upn.py:22 +msgid "Geeft aan of de UPN is verwijderd." +msgstr "" + +#: producttypen/models/upn.py:28 +msgid "Uniforme product namen" +msgstr "" + +#: producttypen/models/vraag.py:21 +msgid "Het onderwerp waarbij deze vraag hoort." +msgstr "" + +#: producttypen/models/vraag.py:30 +msgid "Het product type waarbij deze vraag hoort." +msgstr "" + +#: producttypen/models/vraag.py:33 +msgid "vraag" +msgstr "" + +#: producttypen/models/vraag.py:35 +msgid "De vraag die wordt beantwoord." +msgstr "" + +#: producttypen/models/vraag.py:38 +msgid "antwoord" +msgstr "" + +#: producttypen/models/vraag.py:39 +msgid "Het antwoord op de vraag, ondersteund markdown format." +msgstr "" + +#: producttypen/models/vraag.py:43 +msgid "Vraag" +msgstr "" + +#: producttypen/models/vraag.py:44 +msgid "Vragen" +msgstr "" + +#: producttypen/models/vraag.py:50 +msgid "Een vraag kan niet gelink zijn aan een onderwerp en een product type." +msgstr "" + +#: producttypen/models/vraag.py:56 +msgid "Een vraag moet gelinkt zijn aan een onderwerp of een product type." +msgstr "" + +#: templates/maykin_2fa/login.html:13 +msgid "or" +msgstr "" + +#: templates/maykin_2fa/login.html:16 +msgid "Login with organization account" +msgstr "" + +#: templates/maykin_2fa/login.html:25 +msgid "Contact support" +msgstr "" + +#: utils/management/commands/clear_cache.py:10 +msgid "Clear given cache only" +msgstr "" + +#: utils/models.py:16 +msgid "gepubliceerd" +msgstr "" + +#: utils/models.py:18 +msgid "Geeft aan of het object getoond kan worden." +msgstr "" + +#: utils/models.py:22 +msgid "Created on" +msgstr "" + +#: utils/models.py:24 +msgid "De datum waarop het object is aangemaakt." +msgstr "" + +#: utils/models.py:27 +msgid "Updated on" +msgstr "" + +#: utils/models.py:29 +msgid "De datum waarop het object voor het laatst is gewijzigd." msgstr "" -#: open_producten/utils/tests/test_validators.py:24 -#: open_producten/utils/validators.py:23 +#: utils/tests/test_validators.py:24 utils/validators.py:23 #, python-format msgid "The provided value contains an invalid character: %s" msgstr "" -#: open_producten/utils/tests/test_validators.py:61 -#: open_producten/utils/validators.py:52 +#: utils/tests/test_validators.py:61 utils/validators.py:52 msgid "Invalid postal code." msgstr "" -#: open_producten/utils/tests/test_validators.py:85 -#: open_producten/utils/validators.py:32 +#: utils/tests/test_validators.py:85 utils/validators.py:32 msgid "Invalid mobile phonenumber." msgstr "" diff --git a/src/open_producten/producttypen/admin/bestand.py b/src/open_producten/producttypen/admin/bestand.py index 96ad76b..c606291 100644 --- a/src/open_producten/producttypen/admin/bestand.py +++ b/src/open_producten/producttypen/admin/bestand.py @@ -12,6 +12,7 @@ class BestandInline(admin.TabularInline): class BestandAdmin(admin.ModelAdmin): list_display = ("product_type", "bestand") list_filter = ("product_type",) + search_fields = ("bestand",) def get_queryset(self, request): return super().get_queryset(request).select_related("product_type") diff --git a/src/open_producten/producttypen/admin/link.py b/src/open_producten/producttypen/admin/link.py index e56b566..6472510 100644 --- a/src/open_producten/producttypen/admin/link.py +++ b/src/open_producten/producttypen/admin/link.py @@ -12,6 +12,8 @@ class LinkInline(admin.TabularInline): @admin.register(Link) class LinkAdmin(admin.ModelAdmin): list_display = ("product_type", "naam", "url") + list_filter = ("product_type__naam",) + search_fields = ("naam", "product_type__naam") def get_queryset(self, request): return super().get_queryset(request).select_related("product_type") diff --git a/src/open_producten/producttypen/admin/onderwerp.py b/src/open_producten/producttypen/admin/onderwerp.py index 7881ed0..78092d2 100644 --- a/src/open_producten/producttypen/admin/onderwerp.py +++ b/src/open_producten/producttypen/admin/onderwerp.py @@ -36,7 +36,7 @@ def clean(self): if not gepubliceerd and any([data[child] for child in children]): raise forms.ValidationError( _( - "Hoofd onderwerpen moeten gepubliceerd zijn met gepubliceerde sub onderwerpen." + "Onderwerpen moeten gepubliceerd zijn met gepubliceerde sub onderwerpen." ) ) @@ -70,9 +70,7 @@ class OnderwerpAdmin(TreeAdmin): ), ) - list_filter = [ - "gepubliceerd", - ] + list_filter = ["gepubliceerd", "product_typen"] def get_changelist_formset(self, request, **kwargs): kwargs["formset"] = OnderwerpAdminFormSet diff --git a/src/open_producten/producttypen/admin/prijs.py b/src/open_producten/producttypen/admin/prijs.py index 2cb0310..d31bdca 100644 --- a/src/open_producten/producttypen/admin/prijs.py +++ b/src/open_producten/producttypen/admin/prijs.py @@ -31,6 +31,8 @@ class PrijsOptieInline(admin.TabularInline): class PrijsAdmin(admin.ModelAdmin): model = Prijs inlines = [PrijsOptieInline] + list_display = ("__str__", "actief_vanaf") + list_filter = ("product_type__naam",) def get_queryset(self, request): return super().get_queryset(request).select_related("product_type") diff --git a/src/open_producten/producttypen/admin/producttype.py b/src/open_producten/producttypen/admin/producttype.py index 913daea..c988abf 100644 --- a/src/open_producten/producttypen/admin/producttype.py +++ b/src/open_producten/producttypen/admin/producttype.py @@ -30,7 +30,14 @@ def clean(self): @admin.register(ProductType) class ProductTypeAdmin(admin.ModelAdmin): - list_display = ("naam", "aanmaak_datum", "display_onderwerpen", "gepubliceerd") + list_display = ( + "naam", + "uniforme_product_naam", + "aanmaak_datum", + "display_onderwerpen", + "gepubliceerd", + "keywords", + ) list_filter = ("gepubliceerd", "onderwerpen") list_editable = ("gepubliceerd",) date_hierarchy = "aanmaak_datum" @@ -39,7 +46,7 @@ class ProductTypeAdmin(admin.ModelAdmin): # "contacts", # "locations", ) - search_fields = ("naam",) + search_fields = ("naam", "uniforme_product_naam__naam", "keywords") ordering = ("naam",) save_on_top = True form = ProductTypeAdminForm @@ -57,4 +64,4 @@ def get_queryset(self, request): @admin.display(description="onderwerpen") def display_onderwerpen(self, obj): - return ", ".join(p.naam for p in obj.onderwerp.all()) + return ", ".join(p.naam for p in obj.onderwerpen.all()) diff --git a/src/open_producten/producttypen/management/commands/load_upl.py b/src/open_producten/producttypen/management/commands/load_upl.py index 0bb4098..2b3f786 100644 --- a/src/open_producten/producttypen/management/commands/load_upl.py +++ b/src/open_producten/producttypen/management/commands/load_upl.py @@ -1,13 +1,17 @@ +import csv +import os from dataclasses import dataclass +from io import StringIO from django.core.management.base import BaseCommand, CommandError +from django.db import transaction + +import requests from open_producten.producttypen.models import ( UniformeProductNaam as UniformProductNaamModel, ) -from ..parsers import CsvParser - @dataclass class UniformProductName: @@ -15,54 +19,119 @@ class UniformProductName: uri: str +def _check_if_csv_extension(path: str): + _, extension = os.path.splitext(path) + file_format = extension[1:] + + if file_format != "csv": + raise CommandError("File format is not csv.") + + class Command(BaseCommand): def __init__(self): - self.help = "Load upn to the database from a given XML/CSV file." - self.parser = CsvParser() + self.help = ( + "Load upn to the database from a given local csv file or csv file url." + ) super().__init__() def add_arguments(self, parser): parser.add_argument( - "filename", - help="The name of the file to be imported.", + "--file", + help="The path to the csv file to be imported.", + ) + parser.add_argument( + "--url", + help="The url to the csv file to be imported.", ) def handle(self, **options): - filename = options.pop("filename") + file = options.pop("file") + url = options.pop("url") + + if file and url: + raise CommandError("Only one of --file or --url can be specified.") - self.stdout.write(f"Importing upn from {filename}...") + if not file and not url: + raise CommandError("Either --file or --url must be specified.") + + self.stdout.write(f"Importing upn from {file if file else url}...") try: - data = self.parser.parse(filename) - upn_objects = [ - UniformProductName(name=entry["UniformeProductnaam"], uri=entry["URI"]) - for entry in data - ] - created_count = self.load_upl(upn_objects) - - except KeyError as e: - raise CommandError(f"{str(e)} does not exist in csv.") + if file: + created_count, update_count, removed_count = self._parse_csv_file(file) + else: + created_count, update_count, removed_count = self._parse_csv_url(url) + except CommandError: + raise except Exception as e: - raise CommandError(str(e)) + raise CommandError(f"Something went wrong: {str(e)}") + self.stdout.write( + "Done\n" + f"Created {created_count} product names.\n" + f"Updated {update_count} product names.\n" + f"{removed_count} product names did not exist in the csv." + ) + + def _parse_csv_file(self, file: str): + _check_if_csv_extension(file) + + with open(file, encoding="utf-8-sig") as f: + data = csv.DictReader(f) + return self._load_upl(data) + + def _parse_csv_url(self, url: str): + _check_if_csv_extension(url) - self.stdout.write(f"Done ({created_count} objects).") + response = requests.get(url) + if response.status_code != 200: + raise CommandError(f"Url returned status code: {response.status_code}.") - def load_upl(self, data: list[UniformProductName]) -> int: - count = 0 + content = StringIO(response.text) + data = csv.DictReader(content) + return self._load_upl(data) + + @transaction.atomic + def _load_upl(self, data: csv.DictReader) -> tuple[int, int, int]: + created_count = 0 + updated_count = 0 upn_updated_list = [] + columns = { + "uri": "URI", + "name": "UniformeProductnaam", + } + + if missing_columns := [ + f"'{key}'" for key in columns.values() if key not in data.fieldnames + ]: + raise CommandError( + f"Column(s) {', '.join(missing_columns)} do not exist in the CSV." + ) + + for i, row in enumerate(data): + uri = row[columns["uri"]] + name = row[columns["name"]] + + if not name or not uri: + self.stdout.write( + f"Skipping index {i} because of missing column(s) {' or '.join(columns.values())}." + ) + continue - for obj in data: upn, created = UniformProductNaamModel.objects.update_or_create( - uri=obj.uri, - defaults={"naam": obj.name, "is_verwijderd": False}, + uri=uri, + defaults={"naam": name, "is_verwijderd": False}, ) upn_updated_list.append(upn.id) if created: - count += 1 + created_count += 1 + else: + updated_count += 1 - UniformProductNaamModel.objects.exclude(id__in=upn_updated_list).update( + removed_count = UniformProductNaamModel.objects.exclude( + id__in=upn_updated_list + ).update( is_verwijderd=True, ) - return count + return created_count, updated_count, removed_count diff --git a/src/open_producten/producttypen/management/parsers.py b/src/open_producten/producttypen/management/parsers.py deleted file mode 100644 index d95eed2..0000000 --- a/src/open_producten/producttypen/management/parsers.py +++ /dev/null @@ -1,29 +0,0 @@ -import csv -import os -from tempfile import NamedTemporaryFile -from typing import List - -import requests - - -class CsvParser: - - def parse(self, filename: str): - _, extension = os.path.splitext(filename) - file_format = extension[1:] - - if file_format != "csv": - raise Exception("File format is not csv") - - if not filename.startswith("http"): - return self.process_csv(filename) - - with NamedTemporaryFile() as f: - f.write(requests.get(filename).content) - f.seek(0) - return self.process_csv(f.name) - - def process_csv(self, filename: str) -> List[dict]: - with open(filename, encoding="utf-8-sig") as f: - data = csv.DictReader(f) - return list(data) diff --git a/src/open_producten/producttypen/migrations/0002_alter_onderwerp_beschrijving_and_more.py b/src/open_producten/producttypen/migrations/0002_alter_onderwerp_beschrijving_and_more.py new file mode 100644 index 0000000..33efde5 --- /dev/null +++ b/src/open_producten/producttypen/migrations/0002_alter_onderwerp_beschrijving_and_more.py @@ -0,0 +1,98 @@ +# Generated by Django 4.2.16 on 2024-12-09 15:38 + +import datetime +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import markdownx.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("producttypen", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="onderwerp", + name="beschrijving", + field=markdownx.models.MarkdownxField( + blank=True, + default="", + help_text="Beschrijving van het onderwerp, ondersteund markdown format.", + verbose_name="beschrijving", + ), + ), + migrations.AlterField( + model_name="prijs", + name="actief_vanaf", + field=models.DateField( + help_text="De datum vanaf wanneer de prijs actief is.", + validators=[ + django.core.validators.MinValueValidator(datetime.date.today) + ], + verbose_name="start datum", + ), + ), + migrations.AlterField( + model_name="producttype", + name="beschrijving", + field=markdownx.models.MarkdownxField( + help_text="Product type beschrijving, ondersteund markdown format.", + verbose_name="beschrijving", + ), + ), + migrations.AlterField( + model_name="producttype", + name="naam", + field=models.CharField( + help_text="naam van het product type.", + max_length=100, + verbose_name="product type naam", + ), + ), + migrations.AlterField( + model_name="vraag", + name="antwoord", + field=markdownx.models.MarkdownxField( + help_text="Het antwoord op de vraag, ondersteund markdown format.", + verbose_name="antwoord", + ), + ), + migrations.AlterField( + model_name="vraag", + name="onderwerp", + field=models.ForeignKey( + blank=True, + help_text="Het onderwerp waarbij deze vraag hoort.", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="vragen", + to="producttypen.onderwerp", + verbose_name="onderwerp", + ), + ), + migrations.AlterField( + model_name="vraag", + name="product_type", + field=models.ForeignKey( + blank=True, + help_text="Het product type waarbij deze vraag hoort.", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="vragen", + to="producttypen.producttype", + verbose_name="Product type", + ), + ), + migrations.AlterField( + model_name="vraag", + name="vraag", + field=models.CharField( + help_text="De vraag die wordt beantwoord.", + max_length=250, + verbose_name="vraag", + ), + ), + ] diff --git a/src/open_producten/producttypen/models/onderwerp.py b/src/open_producten/producttypen/models/onderwerp.py index 80d67ee..42e504a 100644 --- a/src/open_producten/producttypen/models/onderwerp.py +++ b/src/open_producten/producttypen/models/onderwerp.py @@ -29,7 +29,7 @@ class Onderwerp(MP_Node, BasePublishableModel): verbose_name=_("beschrijving"), blank=True, default="", - help_text=_("Beschrijving van het onderwerp."), + help_text=_("Beschrijving van het onderwerp, ondersteund markdown format."), ) class Meta: @@ -48,13 +48,16 @@ def move(self, target, pos=None): return PublishedMoveHandler(self, target, pos).process() def clean(self): - if self.gepubliceerd and self.hoofd_onderwerp: - if not self.hoofd_onderwerp.gepubliceerd: - raise ValidationError( - _( - "Hoofd-onderwerpen moeten gepubliceerd zijn voordat sub-onderwerpen kunnen worden gepubliceerd." - ) + if ( + self.gepubliceerd + and self.hoofd_onderwerp + and not self.hoofd_onderwerp.gepubliceerd + ): + raise ValidationError( + _( + "Onderwerpen moeten gepubliceerd zijn voordat sub-onderwerpen kunnen worden gepubliceerd." ) + ) if ( not self.gepubliceerd @@ -62,6 +65,6 @@ def clean(self): ): raise ValidationError( _( - "Hoofd-onderwerpen kunnen niet ongepubliceerd worden als ze gepubliceerde sub-onderwerpen hebben." + "Onderwerpen kunnen niet ongepubliceerd worden als ze gepubliceerde sub-onderwerpen hebben." ) ) diff --git a/src/open_producten/producttypen/models/prijs.py b/src/open_producten/producttypen/models/prijs.py index 7f7dd40..39a03c2 100644 --- a/src/open_producten/producttypen/models/prijs.py +++ b/src/open_producten/producttypen/models/prijs.py @@ -21,7 +21,6 @@ class Prijs(BaseModel): actief_vanaf = models.DateField( verbose_name=_("start datum"), validators=[MinValueValidator(datetime.date.today)], - unique=True, help_text=_("De datum vanaf wanneer de prijs actief is."), ) diff --git a/src/open_producten/producttypen/models/producttype.py b/src/open_producten/producttypen/models/producttype.py index 4129315..f27bcc4 100644 --- a/src/open_producten/producttypen/models/producttype.py +++ b/src/open_producten/producttypen/models/producttype.py @@ -24,7 +24,7 @@ class OnderwerpProductType(models.Model): class ProductType(BasePublishableModel): naam = models.CharField( - verbose_name=_("naam"), + verbose_name=_("product type naam"), max_length=100, help_text=_("naam van het product type."), ) @@ -38,7 +38,7 @@ class ProductType(BasePublishableModel): beschrijving = MarkdownxField( verbose_name=_("beschrijving"), - help_text=_("Product type beschrijving met WYSIWYG editor."), + help_text=_("Product type beschrijving, ondersteund markdown format."), ) keywords = ArrayField( diff --git a/src/open_producten/producttypen/models/vraag.py b/src/open_producten/producttypen/models/vraag.py index d83b282..07480da 100644 --- a/src/open_producten/producttypen/models/vraag.py +++ b/src/open_producten/producttypen/models/vraag.py @@ -18,6 +18,7 @@ class Vraag(BaseModel): blank=True, null=True, related_name="vragen", + help_text=_("Het onderwerp waarbij deze vraag hoort."), ) product_type = models.ForeignKey( ProductType, @@ -26,9 +27,17 @@ class Vraag(BaseModel): blank=True, null=True, related_name="vragen", + help_text=_("Het product type waarbij deze vraag hoort."), + ) + vraag = models.CharField( + verbose_name=_("vraag"), + max_length=250, + help_text=_("De vraag die wordt beantwoord."), + ) + antwoord = MarkdownxField( + verbose_name=_("antwoord"), + help_text=_("Het antwoord op de vraag, ondersteund markdown format."), ) - vraag = models.CharField(verbose_name=_("vraag"), max_length=250) - antwoord = MarkdownxField(verbose_name=_("antwoord")) class Meta: verbose_name = _("Vraag") diff --git a/src/open_producten/producttypen/tests/data/upl-empty-data.csv b/src/open_producten/producttypen/tests/data/upl-empty-data.csv new file mode 100644 index 0000000..68cbc63 --- /dev/null +++ b/src/open_producten/producttypen/tests/data/upl-empty-data.csv @@ -0,0 +1,2 @@ +UniformeProductnaam,URI,Rijk,Provincie,Waterschap,Gemeente,Burger,Bedrijf,Dienstenwet,SDG,Autonomie,Medebewind,Aanvraag,Subsidie,Melding,Verplichting,Grondslag,Grondslaglabel,Grondslaglink +aangifte vertrek buitenland,,X,,,X,X,,,D1,,X,,,,X,http://standaarden.overheid.nl/owms/terms/Wet_BRP_art_2_43,Artikel 2.43 Wet basisregistratie personen,https://wetten.overheid.nl/jci1.3:c:BWBR0033715&hoofdstuk=2&afdeling=1¶graaf=5&artikel=2.43 diff --git a/src/open_producten/producttypen/tests/data/wrong-upl.csv b/src/open_producten/producttypen/tests/data/upl-missing-columns.csv similarity index 100% rename from src/open_producten/producttypen/tests/data/wrong-upl.csv rename to src/open_producten/producttypen/tests/data/upl-missing-columns.csv diff --git a/src/open_producten/producttypen/tests/test_load_upn.py b/src/open_producten/producttypen/tests/test_load_upn.py index dd4d487..65caea1 100644 --- a/src/open_producten/producttypen/tests/test_load_upn.py +++ b/src/open_producten/producttypen/tests/test_load_upn.py @@ -1,15 +1,24 @@ import os from io import StringIO -from unittest.mock import patch from django.core.management import CommandError, call_command -from django.test import SimpleTestCase, TestCase +from django.test import TestCase + +import requests_mock -from open_producten.producttypen.management.parsers import CsvParser from open_producten.producttypen.models import UniformeProductNaam +from open_producten.producttypen.tests.factories import UniformeProductNaamFactory + +TESTS_DIR = os.path.dirname(os.path.abspath(__file__)) + +class TestLoadUPLCommand(TestCase): -class TestLoadUPNCommand(TestCase): + def setUp(self): + self.path = os.path.join(TESTS_DIR, "data/upl.csv") + self.requests_mock = requests_mock.Mocker() + self.requests_mock.start() + self.addCleanup(self.requests_mock.stop) def call_command(self, *args, **kwargs): out = StringIO() @@ -22,80 +31,124 @@ def call_command(self, *args, **kwargs): ) return out.getvalue() - def test_load_csv_with_correct_columns(self): - file_name = "data/upl.csv" - path = os.path.join(os.path.dirname(__file__), file_name) - result = self.call_command(path) - self.assertEqual(result, f"Importing upn from {path}...\nDone (1 objects).\n") + def test_call_command_without_file_and_url(self): + with self.assertRaisesMessage( + Exception, "Either --file or --url must be specified." + ): + self.call_command() + + def test_call_command_wit_file_and_url(self): + with self.assertRaisesMessage( + Exception, "Only one of --file or --url can be specified." + ): + self.call_command("--file", self.path, "--url", "https://example.com") + + def test_with_other_file_extension(self): + path = os.path.join(TESTS_DIR, "data/upl.txt") + with self.assertRaisesMessage(Exception, "File format is not csv."): + self.call_command("--file", path) + + def test_with_csv_file(self): + result = self.call_command("--file", self.path) + self.assertEqual( + result, + f"Importing upn from {self.path}...\nDone\nCreated 1 product names.\nUpdated 0 product names.\n0 product names did not exist in the csv.\n", + ) + self.assertEqual(UniformeProductNaam.objects.count(), 1) self.assertEqual( - UniformeProductNaam.objects.first().naam, "aangifte vertrek buitenland" + UniformeProductNaam.objects.get().naam, "aangifte vertrek buitenland" ) self.assertEqual( - UniformeProductNaam.objects.first().uri, + UniformeProductNaam.objects.get().uri, "http://standaarden.overheid.nl/owms/terms/AangifteVertrekBuitenland", ) - def test_load_csv_with_incorrect_columns(self): - file_name = "data/wrong-upl.csv" - path = os.path.join(os.path.dirname(__file__), file_name) + def test_with_csv_url_404(self): + self.requests_mock.get( + status_code=404, + url="https://test/upl.csv", + ) - with self.assertRaisesMessage(CommandError, "'URI' does not exist in csv"): - self.call_command(path) + with self.assertRaisesMessage(Exception, "Url returned status code: 404"): + self.call_command("--url", "https://test/upl.csv") + def test_parse_csv_url(self): + with open(self.path) as f: -class TestCSVParser(SimpleTestCase): + self.requests_mock.get( + status_code=200, + text=f.read(), + url="https://test/upl.csv", + ) - def setUp(self): - self.parser = CsvParser() + result = self.call_command("--url", "https://test/upl.csv") + + self.assertEqual( + result, + "Importing upn from https://test/upl.csv...\nDone\nCreated 1 product names.\nUpdated 0 product names.\n0 product names did not exist in the csv.\n", + ) + + self.assertEqual(UniformeProductNaam.objects.count(), 1) + self.assertEqual( + UniformeProductNaam.objects.get().naam, "aangifte vertrek buitenland" + ) + self.assertEqual( + UniformeProductNaam.objects.get().uri, + "http://standaarden.overheid.nl/owms/terms/AangifteVertrekBuitenland", + ) - def test_parser_returns_error_when_format_is_not_csv(self): - with self.assertRaisesMessage(Exception, "File format is not csv"): - self.parser.parse("abc.txt") + def test_load_upl_with_empty_data_at_column(self): + path = os.path.join(TESTS_DIR, "data/upl-empty-data.csv") - @patch("open_producten.producttypen.management.parsers.NamedTemporaryFile") - @patch("open_producten.producttypen.management.parsers.CsvParser.process_csv") - def test_parser_with_url(self, mock_process_csv, mock_NamedTemporaryFile): - self.parser.parse("https://www.abc.com/abc.csv") + result = self.call_command("--file", path) + self.assertEqual( + result, + f"Importing upn from {path}...\nSkipping index 0 because of missing column(s) URI or UniformeProductnaam.\nDone\nCreated 0 product names.\nUpdated 0 product names.\n0 product names did not exist in the csv.\n", + ) - self.assertEqual(mock_NamedTemporaryFile.call_count, 1) - self.assertEqual(mock_process_csv.call_count, 1) + self.assertEqual(UniformeProductNaam.objects.count(), 0) - @patch("tempfile.NamedTemporaryFile") - @patch("open_producten.producttypen.management.parsers.CsvParser.process_csv") - def test_parser_with_file(self, mock_process_csv, mock_NamedTemporaryFile): - self.parser.parse("abc.csv") + def test_load_csv_with_missing_columns(self): + path = os.path.join(TESTS_DIR, "data/upl-missing-columns.csv") - self.assertEqual(mock_NamedTemporaryFile.call_count, 0) - self.assertEqual(mock_process_csv.call_count, 1) + with self.assertRaisesMessage( + CommandError, "Column(s) 'URI' do not exist in the CSV." + ): + self.call_command("--file", path) - def test_process_csv(self): - file_name = "data/upl.csv" - path = os.path.join(os.path.dirname(__file__), file_name) - result = self.parser.process_csv(path) + def test_upn_is_updated(self): + UniformeProductNaamFactory.create( + uri="http://standaarden.overheid.nl/owms/terms/AangifteVertrekBuitenland" + ) + + result = self.call_command("--file", self.path) self.assertEqual( result, - [ - { - "Aanvraag": "", - "Autonomie": "", - "Bedrijf": "", - "Burger": "X", - "Dienstenwet": "", - "Gemeente": "X", - "Grondslag": "http://standaarden.overheid.nl/owms/terms/Wet_BRP_art_2_43", - "Grondslaglabel": "Artikel 2.43 Wet basisregistratie personen", - "Grondslaglink": "https://wetten.overheid.nl/jci1.3:c:BWBR0033715&hoofdstuk=2&afdeling=1¶graaf=5&artikel=2.43", - "Medebewind": "X", - "Melding": "", - "Provincie": "", - "Rijk": "X", - "SDG": "D1", - "Subsidie": "", - "URI": "http://standaarden.overheid.nl/owms/terms/AangifteVertrekBuitenland", - "UniformeProductnaam": "aangifte vertrek buitenland", - "Verplichting": "X", - "Waterschap": "", - } - ], + f"Importing upn from {self.path}...\nDone\nCreated 0 product names.\nUpdated 1 product names.\n0 product names did not exist in the csv.\n", + ) + + self.assertEqual(UniformeProductNaam.objects.count(), 1) + self.assertEqual( + UniformeProductNaam.objects.get().naam, "aangifte vertrek buitenland" + ) + self.assertEqual( + UniformeProductNaam.objects.get().uri, + "http://standaarden.overheid.nl/owms/terms/AangifteVertrekBuitenland", + ) + + def test_upn_not_in_csv_is_set_to_deleted(self): + upn = UniformeProductNaamFactory.create( + uri="http://standaarden.overheid.nl/owms/terms/BlaBla" + ) + + result = self.call_command("--file", self.path) + self.assertEqual( + result, + f"Importing upn from {self.path}...\nDone\nCreated 1 product names.\nUpdated 0 product names.\n1 product names did not exist in the csv.\n", + ) + + self.assertEqual(UniformeProductNaam.objects.count(), 2) + self.assertEqual( + UniformeProductNaam.objects.filter(is_verwijderd=True).get().naam, upn.naam ) diff --git a/src/open_producten/producttypen/tests/test_onderwerp.py b/src/open_producten/producttypen/tests/test_onderwerp.py new file mode 100644 index 0000000..fc9fbe9 --- /dev/null +++ b/src/open_producten/producttypen/tests/test_onderwerp.py @@ -0,0 +1,36 @@ +from django.core.exceptions import ValidationError +from django.test import TestCase + +from .factories import OnderwerpFactory + + +class TestVraag(TestCase): + + def test_hoofd_onderwerpen_must_be_published_when_publishing_sub_onderwerp(self): + hoofd_onderwerp = OnderwerpFactory.create(gepubliceerd=False) + sub_onderwerp = hoofd_onderwerp.add_child( + **{"naam": "sub onderwerp", "gepubliceerd": True} + ) + + with self.assertRaisesMessage( + ValidationError, + "Onderwerpen moeten gepubliceerd zijn voordat sub-onderwerpen kunnen worden gepubliceerd.", + ): + sub_onderwerp.clean() + + hoofd_onderwerp.gepubliceerd = True + hoofd_onderwerp.save() + + sub_onderwerp.clean() + + def test_hoofd_onderwerpen_cannot_be_published_with_published_sub_onderwerpen(self): + hoofd_onderwerp = OnderwerpFactory.create(gepubliceerd=False) + hoofd_onderwerp.add_child(**{"naam": "sub onderwerp", "gepubliceerd": True}) + + hoofd_onderwerp.gepubliceerd = False + + with self.assertRaisesMessage( + ValidationError, + "Onderwerpen kunnen niet ongepubliceerd worden als ze gepubliceerde sub-onderwerpen hebben.", + ): + hoofd_onderwerp.clean() diff --git a/src/open_producten/producttypen/tests/test_onderwerp_admin.py b/src/open_producten/producttypen/tests/test_onderwerp_admin.py index 7d54078..826994a 100644 --- a/src/open_producten/producttypen/tests/test_onderwerp_admin.py +++ b/src/open_producten/producttypen/tests/test_onderwerp_admin.py @@ -4,61 +4,8 @@ from open_producten.utils.tests.helpers import build_formset_data -from ..admin.onderwerp import OnderwerpAdmin, OnderwerpAdminForm, OnderwerpAdminFormSet +from ..admin.onderwerp import OnderwerpAdmin, OnderwerpAdminFormSet from ..models import Onderwerp -from .factories import OnderwerpFactory - - -def create_form(data, instance=None): - return OnderwerpAdminForm( - instance=instance, - data=data, - ) - - -class TestOnderwerpAdminForm(TestCase): - - def setUp(self): - self.data = { - "naam": "test", - "_position": "first-child", - "path": "00010001", - "numchild": 1, - "depth": 1, - } - - def test_hoofd_onderwerpen_must_be_published_when_publishing_sub_onderwerp(self): - hoofd_onderwerp = OnderwerpFactory.create(gepubliceerd=False) - data = self.data | {"gepubliceerd": True, "_ref_node_id": hoofd_onderwerp.id} - - form = create_form(data) - - self.assertEquals( - form.non_field_errors(), - [ - "Hoofd-onderwerpen moeten gepubliceerd zijn voordat sub-onderwerpen kunnen worden gepubliceerd." - ], - ) - - hoofd_onderwerp.gepubliceerd = True - hoofd_onderwerp.save() - - form = create_form(data) - self.assertEquals(form.errors, {}) - - def test_hoofd_onderwerpen_cannot_be_published_with_published_sub_onderwerpen(self): - hoofd_onderwerp = OnderwerpFactory.create(gepubliceerd=False) - hoofd_onderwerp.add_child(**{"naam": "sub onderwerp", "gepubliceerd": True}) - data = {"gepubliceerd": False, "_ref_node_id": None} - - form = create_form(data, hoofd_onderwerp) - - self.assertEquals( - form.non_field_errors(), - [ - "Hoofd-onderwerpen kunnen niet ongepubliceerd worden als ze gepubliceerde sub-onderwerpen hebben." - ], - ) class TestOnderwerpAdminFormSet(TestCase): From 46fba8063449f89c723413d3ad5fc5e1f7f4e239 Mon Sep 17 00:00:00 2001 From: Floris272 Date: Mon, 9 Dec 2024 17:11:15 +0100 Subject: [PATCH 5/8] Add prijs change permission condition --- .../producttypen/admin/prijs.py | 6 ++++ .../producttypen/tests/test_prijs.py | 34 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/open_producten/producttypen/admin/prijs.py b/src/open_producten/producttypen/admin/prijs.py index d31bdca..89ec4ec 100644 --- a/src/open_producten/producttypen/admin/prijs.py +++ b/src/open_producten/producttypen/admin/prijs.py @@ -1,6 +1,7 @@ from django.contrib import admin from django.core.exceptions import ValidationError from django.forms import BaseInlineFormSet +from django.utils.datetime_safe import datetime from django.utils.translation import gettext_lazy as _ from ..models import Prijs, PrijsOptie @@ -36,3 +37,8 @@ class PrijsAdmin(admin.ModelAdmin): def get_queryset(self, request): return super().get_queryset(request).select_related("product_type") + + def has_change_permission(self, request, obj=None): + if obj and obj.actief_vanaf < datetime.today().date(): + return False + return super().has_change_permission(request, obj) diff --git a/src/open_producten/producttypen/tests/test_prijs.py b/src/open_producten/producttypen/tests/test_prijs.py index 51a506c..d079644 100644 --- a/src/open_producten/producttypen/tests/test_prijs.py +++ b/src/open_producten/producttypen/tests/test_prijs.py @@ -1,9 +1,16 @@ from datetime import date, timedelta from decimal import Decimal +from django.contrib.admin.sites import AdminSite from django.core.exceptions import ValidationError from django.test import TestCase +from freezegun import freeze_time + +from open_producten.producttypen.admin import PrijsAdmin +from open_producten.producttypen.models import Prijs + +from ...accounts.tests.factories import UserFactory from .factories import PrijsFactory, PrijsOptieFactory, ProductTypeFactory @@ -26,6 +33,33 @@ def test_min_date_validation(self): prijs = PrijsFactory.build(actief_vanaf=date(2020, 1, 1)) prijs.full_clean() + @freeze_time("2024-01-02") + def test_change_permission_on_old_price(self): + instance = PrijsFactory.create(actief_vanaf=date(2024, 1, 1)) + admin = PrijsAdmin(Prijs, AdminSite()) + request = self.client.request() + request.user = UserFactory(superuser=True) + + self.assertFalse(admin.has_change_permission(request, instance)) + + @freeze_time("2024-01-02") + def test_change_permission_on_future_price(self): + instance = PrijsFactory.create(actief_vanaf=date(2024, 3, 1)) + admin = PrijsAdmin(Prijs, AdminSite()) + request = self.client.request() + request.user = UserFactory(superuser=True) + + self.assertTrue(admin.has_change_permission(request, instance)) + + @freeze_time("2024-01-02") + def test_change_permission_on_current_price(self): + instance = PrijsFactory.create(actief_vanaf=date(2024, 1, 2)) + admin = PrijsAdmin(Prijs, AdminSite()) + request = self.client.request() + request.user = UserFactory(superuser=True) + + self.assertTrue(admin.has_change_permission(request, instance)) + class TestPrijsOptie(TestCase): def setUp(self): From 4a2760f7ff3b4e76dfe7fee5948eff1b70da4c35 Mon Sep 17 00:00:00 2001 From: Floris272 Date: Tue, 10 Dec 2024 10:12:55 +0100 Subject: [PATCH 6/8] Add actief vanaf filter to prijs admin --- src/open_producten/producttypen/admin/prijs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/open_producten/producttypen/admin/prijs.py b/src/open_producten/producttypen/admin/prijs.py index 89ec4ec..c70e903 100644 --- a/src/open_producten/producttypen/admin/prijs.py +++ b/src/open_producten/producttypen/admin/prijs.py @@ -33,7 +33,7 @@ class PrijsAdmin(admin.ModelAdmin): model = Prijs inlines = [PrijsOptieInline] list_display = ("__str__", "actief_vanaf") - list_filter = ("product_type__naam",) + list_filter = ("product_type__naam", "actief_vanaf") def get_queryset(self, request): return super().get_queryset(request).select_related("product_type") From 09a8f6b60d12441f2f072a71f121d14bd49fd944 Mon Sep 17 00:00:00 2001 From: Floris272 Date: Tue, 10 Dec 2024 17:34:59 +0100 Subject: [PATCH 7/8] Change onderwerp tree order to name (alphabetically) --- src/open_producten/producttypen/models/onderwerp.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/open_producten/producttypen/models/onderwerp.py b/src/open_producten/producttypen/models/onderwerp.py index 42e504a..c0e9438 100644 --- a/src/open_producten/producttypen/models/onderwerp.py +++ b/src/open_producten/producttypen/models/onderwerp.py @@ -21,6 +21,8 @@ def process(self): class Onderwerp(MP_Node, BasePublishableModel): + node_order_by = ["naam"] + naam = models.CharField( verbose_name=_("naam"), max_length=100, help_text=_("Naam van het onderwerp.") ) From 47a1afdc7a09d9890153b4e11c29c5e1d6f4114a Mon Sep 17 00:00:00 2001 From: Floris272 Date: Wed, 11 Dec 2024 12:03:18 +0100 Subject: [PATCH 8/8] fix load_upl command request error handling --- .../management/commands/load_upl.py | 10 +++++++--- .../{test_load_upn.py => test_load_upl.py} | 19 +++++++++++++++---- 2 files changed, 22 insertions(+), 7 deletions(-) rename src/open_producten/producttypen/tests/{test_load_upn.py => test_load_upl.py} (88%) diff --git a/src/open_producten/producttypen/management/commands/load_upl.py b/src/open_producten/producttypen/management/commands/load_upl.py index 2b3f786..bf8bc51 100644 --- a/src/open_producten/producttypen/management/commands/load_upl.py +++ b/src/open_producten/producttypen/management/commands/load_upl.py @@ -83,9 +83,13 @@ def _parse_csv_file(self, file: str): def _parse_csv_url(self, url: str): _check_if_csv_extension(url) - response = requests.get(url) - if response.status_code != 200: - raise CommandError(f"Url returned status code: {response.status_code}.") + try: + response = requests.get(url) + response.raise_for_status() + except requests.exceptions.ConnectionError: + raise CommandError(f"Could not connect to {url}") + except requests.exceptions.RequestException as e: + raise CommandError(e) content = StringIO(response.text) data = csv.DictReader(content) diff --git a/src/open_producten/producttypen/tests/test_load_upn.py b/src/open_producten/producttypen/tests/test_load_upl.py similarity index 88% rename from src/open_producten/producttypen/tests/test_load_upn.py rename to src/open_producten/producttypen/tests/test_load_upl.py index 65caea1..009be7d 100644 --- a/src/open_producten/producttypen/tests/test_load_upn.py +++ b/src/open_producten/producttypen/tests/test_load_upl.py @@ -5,6 +5,7 @@ from django.test import TestCase import requests_mock +from requests.exceptions import ConnectionError from open_producten.producttypen.models import UniformeProductNaam from open_producten.producttypen.tests.factories import UniformeProductNaamFactory @@ -33,19 +34,19 @@ def call_command(self, *args, **kwargs): def test_call_command_without_file_and_url(self): with self.assertRaisesMessage( - Exception, "Either --file or --url must be specified." + CommandError, "Either --file or --url must be specified." ): self.call_command() def test_call_command_wit_file_and_url(self): with self.assertRaisesMessage( - Exception, "Only one of --file or --url can be specified." + CommandError, "Only one of --file or --url can be specified." ): self.call_command("--file", self.path, "--url", "https://example.com") def test_with_other_file_extension(self): path = os.path.join(TESTS_DIR, "data/upl.txt") - with self.assertRaisesMessage(Exception, "File format is not csv."): + with self.assertRaisesMessage(CommandError, "File format is not csv."): self.call_command("--file", path) def test_with_csv_file(self): @@ -70,7 +71,17 @@ def test_with_csv_url_404(self): url="https://test/upl.csv", ) - with self.assertRaisesMessage(Exception, "Url returned status code: 404"): + with self.assertRaisesMessage( + CommandError, "404 Client Error: None for url: https://test/upl.csv" + ): + self.call_command("--url", "https://test/upl.csv") + + def test_wih_csv_url_connection_error(self): + self.requests_mock.get(exc=ConnectionError, url="https://test/upl.csv") + + with self.assertRaisesMessage( + CommandError, "Could not connect to https://test/upl.csv" + ): self.call_command("--url", "https://test/upl.csv") def test_parse_csv_url(self):