From 5d80a01e7627aeac0dabc49a9460800c067e4e0c Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 7 Jun 2026 16:54:04 +0200 Subject: [PATCH 1/9] refactor(product_type): extract Product_Type model into dojo/product_type/ Phase 1 of module reorg per AGENTS.md. Move Product_Type class + admin registration into dojo/product_type/{models,admin}.py with backward-compat re-export in dojo/models.py. No migration change (app_label unchanged). --- dojo/models.py | 71 +------------------------------ dojo/product_type/__init__.py | 1 + dojo/product_type/admin.py | 9 ++++ dojo/product_type/models.py | 80 +++++++++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 70 deletions(-) create mode 100644 dojo/product_type/admin.py create mode 100644 dojo/product_type/models.py diff --git a/dojo/models.py b/dojo/models.py index a41f5640889..eaf21960650 100644 --- a/dojo/models.py +++ b/dojo/models.py @@ -758,75 +758,7 @@ def clean(self): raise ValidationError(msg) -class Product_Type(BaseModel): - - """ - Product types represent the top level model, these can be business unit divisions, different offices or locations, development teams, or any other logical way of distinguishing "types" of products. - ` - Examples: - * IAM Team - * Internal / 3rd Party - * Main company / Acquisition - * San Francisco / New York offices - """ - - name = models.CharField(max_length=255, unique=True) - description = models.CharField(max_length=4000, null=True, blank=True) - critical_product = models.BooleanField(default=False) - key_product = models.BooleanField(default=False) - authorized_users = models.ManyToManyField(Dojo_User, related_name="authorized_product_types", blank=True) - - class Meta: - ordering = ("name",) - - def __str__(self): - return self.name - - def get_absolute_url(self): - return reverse("product_type", args=[str(self.id)]) - - def get_breadcrumbs(self): - return [{"title": str(self), - "url": reverse("edit_product_type", args=(self.id,))}] - - @cached_property - def critical_present(self): - c_findings = Finding.objects.filter( - test__engagement__product__prod_type=self, severity="Critical") - if c_findings.count() > 0: - return True - return None - - @cached_property - def high_present(self): - c_findings = Finding.objects.filter( - test__engagement__product__prod_type=self, severity="High") - if c_findings.count() > 0: - return True - return None - - @cached_property - def calc_health(self): - h_findings = Finding.objects.filter( - test__engagement__product__prod_type=self, severity="High") - c_findings = Finding.objects.filter( - test__engagement__product__prod_type=self, severity="Critical") - health = 100 - if c_findings.count() > 0: - health = 40 - health -= ((c_findings.count() - 1) * 5) - if h_findings.count() > 0: - if health == 100: - health = 60 - health -= ((h_findings.count() - 1) * 2) - if health < 5: - return 5 - return health - - # only used by bulk risk acceptance api - @property - def unaccepted_open_findings(self): - return Finding.objects.filter(risk_accepted=False, active=True, duplicate=False, test__engagement__product__prod_type=self) +from dojo.product_type.models import Product_Type # noqa: E402 -- re-export; mid-file as Product FK uses it below class Product_Line(models.Model): @@ -4432,7 +4364,6 @@ def __str__(self): admin.site.register(Endpoint_Status) admin.site.register(Endpoint) admin.site.register(Product) -admin.site.register(Product_Type) admin.site.register(UserContactInfo) admin.site.register(Notes) admin.site.register(Note_Type) diff --git a/dojo/product_type/__init__.py b/dojo/product_type/__init__.py index e69de29bb2d..83aa70f8a17 100644 --- a/dojo/product_type/__init__.py +++ b/dojo/product_type/__init__.py @@ -0,0 +1 @@ +import dojo.product_type.admin # noqa: F401 diff --git a/dojo/product_type/admin.py b/dojo/product_type/admin.py new file mode 100644 index 00000000000..86cf1fd6bc3 --- /dev/null +++ b/dojo/product_type/admin.py @@ -0,0 +1,9 @@ +from django.contrib import admin + +from dojo.product_type.models import Product_Type + + +@admin.register(Product_Type) +class Product_TypeAdmin(admin.ModelAdmin): + + """Admin support for the Product_Type model.""" diff --git a/dojo/product_type/models.py b/dojo/product_type/models.py new file mode 100644 index 00000000000..50bfcc722b4 --- /dev/null +++ b/dojo/product_type/models.py @@ -0,0 +1,80 @@ +from django.db import models +from django.urls import reverse +from django.utils.functional import cached_property + +from dojo.base_models.base import BaseModel + + +class Product_Type(BaseModel): + + """ + Product types represent the top level model, these can be business unit divisions, different offices or locations, development teams, or any other logical way of distinguishing "types" of products. + ` + Examples: + * IAM Team + * Internal / 3rd Party + * Main company / Acquisition + * San Francisco / New York offices + """ + + name = models.CharField(max_length=255, unique=True) + description = models.CharField(max_length=4000, null=True, blank=True) + critical_product = models.BooleanField(default=False) + key_product = models.BooleanField(default=False) + authorized_users = models.ManyToManyField("dojo.Dojo_User", related_name="authorized_product_types", blank=True) + + class Meta: + ordering = ("name",) + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse("product_type", args=[str(self.id)]) + + def get_breadcrumbs(self): + return [{"title": str(self), + "url": reverse("edit_product_type", args=(self.id,))}] + + @cached_property + def critical_present(self): + from dojo.models import Finding # noqa: PLC0415 -- lazy import, avoids circular dependency + c_findings = Finding.objects.filter( + test__engagement__product__prod_type=self, severity="Critical") + if c_findings.count() > 0: + return True + return None + + @cached_property + def high_present(self): + from dojo.models import Finding # noqa: PLC0415 -- lazy import, avoids circular dependency + c_findings = Finding.objects.filter( + test__engagement__product__prod_type=self, severity="High") + if c_findings.count() > 0: + return True + return None + + @cached_property + def calc_health(self): + from dojo.models import Finding # noqa: PLC0415 -- lazy import, avoids circular dependency + h_findings = Finding.objects.filter( + test__engagement__product__prod_type=self, severity="High") + c_findings = Finding.objects.filter( + test__engagement__product__prod_type=self, severity="Critical") + health = 100 + if c_findings.count() > 0: + health = 40 + health -= ((c_findings.count() - 1) * 5) + if h_findings.count() > 0: + if health == 100: + health = 60 + health -= ((h_findings.count() - 1) * 2) + if health < 5: + return 5 + return health + + # only used by bulk risk acceptance api + @property + def unaccepted_open_findings(self): + from dojo.models import Finding # noqa: PLC0415 -- lazy import, avoids circular dependency + return Finding.objects.filter(risk_accepted=False, active=True, duplicate=False, test__engagement__product__prod_type=self) From cbee48750878dc7a762ee4501a15f314837b0a37 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 7 Jun 2026 19:44:38 +0200 Subject: [PATCH 2/9] refactor(product_type): move forms + UI filter into dojo/product_type/ui/ [Phase 3,4] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3+4 of module reorg per AGENTS.md. Move Product_TypeForm, Delete_Product_TypeForm, Add_Product_Type_AuthorizedUsersForm into ui/forms.py (re-export from dojo/forms.py) and ProductTypeFilter into ui/filters.py. The filter keeps its DojoFilter base; its only consumer is the product_type view, so no dojo/filters.py re-export is kept (matches the url module) — avoids the extracted-filter<->dojo.filters circular import. --- dojo/filters.py | 16 ---------- dojo/forms.py | 39 +----------------------- dojo/product_type/ui/__init__.py | 0 dojo/product_type/ui/filters.py | 24 +++++++++++++++ dojo/product_type/ui/forms.py | 51 ++++++++++++++++++++++++++++++++ dojo/product_type/views.py | 3 +- 6 files changed, 78 insertions(+), 55 deletions(-) create mode 100644 dojo/product_type/ui/__init__.py create mode 100644 dojo/product_type/ui/filters.py create mode 100644 dojo/product_type/ui/forms.py diff --git a/dojo/filters.py b/dojo/filters.py index 96184d92e2d..8354e51b2f4 100644 --- a/dojo/filters.py +++ b/dojo/filters.py @@ -3780,22 +3780,6 @@ class Meta: # re-exported at the bottom of this module for backward compatibility. -class ProductTypeFilter(DojoFilter): - name = CharFilter(lookup_expr="icontains") - - o = OrderingFilter( - # tuple-mapping retains order - fields=( - ("name", "name"), - ), - ) - - class Meta: - model = Product_Type - exclude = [] - include = ("name",) - - class TestTypeFilter(DojoFilter): name = CharFilter(lookup_expr="icontains") diff --git a/dojo/forms.py b/dojo/forms.py index e33d7ef51c4..89163ffb14a 100644 --- a/dojo/forms.py +++ b/dojo/forms.py @@ -243,44 +243,7 @@ def value_from_datadict(self, data, files, name): return data.get(name, None) -class Product_TypeForm(forms.ModelForm): - description = forms.CharField(widget=forms.Textarea(attrs={}), - required=False) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["critical_product"].label = labels.ORG_CRITICAL_PRODUCT_LABEL - self.fields["key_product"].label = labels.ORG_KEY_PRODUCT_LABEL - - class Meta: - model = Product_Type - fields = ["name", "description", "critical_product", "key_product"] - - -class Delete_Product_TypeForm(forms.ModelForm): - id = forms.IntegerField(required=True, - widget=forms.widgets.HiddenInput()) - - class Meta: - model = Product_Type - fields = ["id"] - - -class Add_Product_Type_AuthorizedUsersForm(forms.Form): - users = forms.ModelMultipleChoiceField( - queryset=Dojo_User.objects.none(), required=True, label="Users", - ) - - def __init__(self, *args, product_type=None, **kwargs): - super().__init__(*args, **kwargs) - self.product_type = product_type - current = product_type.authorized_users.values_list("pk", flat=True) - self.fields["users"].queryset = ( - Dojo_User.objects.filter(is_active=True) - .exclude(is_superuser=True) - .exclude(pk__in=current) - .order_by("first_name", "last_name") - ) +from dojo.product_type.ui.forms import Add_Product_Type_AuthorizedUsersForm, Delete_Product_TypeForm, Product_TypeForm # noqa: E402, F401, I001 class Test_TypeForm(forms.ModelForm): diff --git a/dojo/product_type/ui/__init__.py b/dojo/product_type/ui/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/product_type/ui/filters.py b/dojo/product_type/ui/filters.py new file mode 100644 index 00000000000..bc4880b73bb --- /dev/null +++ b/dojo/product_type/ui/filters.py @@ -0,0 +1,24 @@ +import logging + +from django_filters import CharFilter, OrderingFilter + +from dojo.filters import DojoFilter +from dojo.product_type.models import Product_Type + +logger = logging.getLogger(__name__) + + +class ProductTypeFilter(DojoFilter): + name = CharFilter(lookup_expr="icontains") + + o = OrderingFilter( + # tuple-mapping retains order + fields=( + ("name", "name"), + ), + ) + + class Meta: + model = Product_Type + exclude = [] + include = ("name",) diff --git a/dojo/product_type/ui/forms.py b/dojo/product_type/ui/forms.py new file mode 100644 index 00000000000..68405a9ba19 --- /dev/null +++ b/dojo/product_type/ui/forms.py @@ -0,0 +1,51 @@ +import logging + +from django import forms + +from dojo.labels import get_labels +from dojo.models import Dojo_User +from dojo.product_type.models import Product_Type + +logger = logging.getLogger(__name__) + +labels = get_labels() + + +class Product_TypeForm(forms.ModelForm): + description = forms.CharField(widget=forms.Textarea(attrs={}), + required=False) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["critical_product"].label = labels.ORG_CRITICAL_PRODUCT_LABEL + self.fields["key_product"].label = labels.ORG_KEY_PRODUCT_LABEL + + class Meta: + model = Product_Type + fields = ["name", "description", "critical_product", "key_product"] + + +class Delete_Product_TypeForm(forms.ModelForm): + id = forms.IntegerField(required=True, + widget=forms.widgets.HiddenInput()) + + class Meta: + model = Product_Type + fields = ["id"] + + +class Add_Product_Type_AuthorizedUsersForm(forms.Form): + users = forms.ModelMultipleChoiceField( + queryset=Dojo_User.objects.none(), required=True, label="Users", + ) + + def __init__(self, *args, product_type=None, **kwargs): + super().__init__(*args, **kwargs) + self.product_type = product_type + current = product_type.authorized_users.values_list("pk", flat=True) + self.fields["users"].queryset = ( + Dojo_User.objects.filter(is_active=True) + .exclude(is_superuser=True) + .exclude(pk__in=current) + .order_by("first_name", "last_name") + ) diff --git a/dojo/product_type/views.py b/dojo/product_type/views.py index eebb25cf106..44b7ce6ab6f 100644 --- a/dojo/product_type/views.py +++ b/dojo/product_type/views.py @@ -15,7 +15,7 @@ from dojo.authorization.authorization import user_has_permission_or_403 from dojo.authorization.roles_permissions import Permissions -from dojo.filters import ProductFilter, ProductFilterWithoutObjectLookups, ProductTypeFilter +from dojo.filters import ProductFilter, ProductFilterWithoutObjectLookups from dojo.forms import ( Add_Product_Type_AuthorizedUsersForm, Delete_Product_TypeForm, @@ -27,6 +27,7 @@ from dojo.product_type.queries import ( get_authorized_product_types, ) +from dojo.product_type.ui.filters import ProductTypeFilter from dojo.query_utils import build_count_subquery from dojo.utils import ( add_breadcrumb, From 1620c602609c5afa395496b7f9abfcef60645ec4 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 7 Jun 2026 19:45:56 +0200 Subject: [PATCH 3/9] refactor(product_type): move views into dojo/product_type/ui/views.py [Phase 5] Phase 5 of module reorg per AGENTS.md. Move dojo/product_type/views.py to dojo/product_type/ui/views.py and update its two importers (dojo/organization/urls.py and the counts unit test). product_type has no urls.py (routes live in dojo/organization/urls.py), so only the views move. --- dojo/organization/urls.py | 2 +- dojo/product_type/{ => ui}/views.py | 0 unittests/test_product_type_counts.py | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename dojo/product_type/{ => ui}/views.py (100%) diff --git a/dojo/organization/urls.py b/dojo/organization/urls.py index ea9e8d00cc3..1147f927ec3 100644 --- a/dojo/organization/urls.py +++ b/dojo/organization/urls.py @@ -2,7 +2,7 @@ from django.urls import re_path from dojo.product import views as product_views -from dojo.product_type import views +from dojo.product_type.ui import views from dojo.utils import redirect_view # TODO: remove the else: branch once v3 migration is complete diff --git a/dojo/product_type/views.py b/dojo/product_type/ui/views.py similarity index 100% rename from dojo/product_type/views.py rename to dojo/product_type/ui/views.py diff --git a/unittests/test_product_type_counts.py b/unittests/test_product_type_counts.py index 5bac04f3d1c..9ea01cfdcc3 100644 --- a/unittests/test_product_type_counts.py +++ b/unittests/test_product_type_counts.py @@ -1,5 +1,5 @@ from dojo.models import Product, Product_Type -from dojo.product_type.views import prefetch_for_product_type +from dojo.product_type.ui.views import prefetch_for_product_type from unittests.dojo_test_case import DojoTestCase, versioned_fixtures From e76fc4e2c202ed3f87cfc2b3919d4f98ae2bc422 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 7 Jun 2026 19:54:21 +0200 Subject: [PATCH 4/9] refactor(product_type): extract API layer into dojo/product_type/api/ [Phase 6,8,9] Phase 6/8/9 of module reorg per AGENTS.md (Phase 7 N/A - no product_type API filter). Move ProductTypeSerializer into api/serializer.py (re-exported from api_v2/serializers.py, still used by ReportGenerateSerializer) and ProductTypeViewSet into api/views.py. Add api/urls.py with add_product_type_urls() preserving route 'product_types' + basename 'product_type'; wire it into dojo/urls.py. Viewset re-export omitted (would cycle api_v2.views<->product_type.api.views; only consumer was a test, now imports new path). --- dojo/api_v2/serializers.py | 5 +- dojo/api_v2/views.py | 83 ------------------------- dojo/product_type/api/__init__.py | 1 + dojo/product_type/api/serializer.py | 9 +++ dojo/product_type/api/urls.py | 6 ++ dojo/product_type/api/views.py | 93 +++++++++++++++++++++++++++++ dojo/urls.py | 4 +- unittests/test_rest_framework.py | 2 +- 8 files changed, 113 insertions(+), 90 deletions(-) create mode 100644 dojo/product_type/api/__init__.py create mode 100644 dojo/product_type/api/serializer.py create mode 100644 dojo/product_type/api/urls.py create mode 100644 dojo/product_type/api/views.py diff --git a/dojo/api_v2/serializers.py b/dojo/api_v2/serializers.py index dc15ac6c2dc..ea1c7a651ff 100644 --- a/dojo/api_v2/serializers.py +++ b/dojo/api_v2/serializers.py @@ -721,10 +721,7 @@ class Meta: fields = ["path"] -class ProductTypeSerializer(serializers.ModelSerializer): - class Meta: - model = Product_Type - fields = "__all__" +from dojo.product_type.api.serializer import ProductTypeSerializer # noqa: E402 class EngagementSerializer(serializers.ModelSerializer): diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index 3f8bb0cf169..bdffb613d0e 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -104,7 +104,6 @@ Notes, Product, Product_API_Scan_Configuration, - Product_Type, Regulation, Risk_Acceptance, SLA_Configuration, @@ -128,9 +127,6 @@ get_authorized_product_api_scan_configurations, get_authorized_products, ) -from dojo.product_type.queries import ( - get_authorized_product_types, -) from dojo.query_utils import build_count_subquery from dojo.reports.views import ( prefetch_related_findings_for_report, @@ -1783,85 +1779,6 @@ def generate_report(self, request, pk=None): return Response(report.data) -# Authorization: object-based -@extend_schema_view(**schema_with_prefetch()) -# Authorization: object-based -@extend_schema_view(**schema_with_prefetch()) -class ProductTypeViewSet( - PrefetchDojoModelViewSet, -): - serializer_class = serializers.ProductTypeSerializer - queryset = Product_Type.objects.none() - filter_backends = (DjangoFilterBackend,) - filterset_fields = [ - "id", - "name", - "critical_product", - "key_product", - "created", - "updated", - ] - permission_classes = ( - IsAuthenticated, - permissions.UserHasProductTypePermission, - ) - - def get_queryset(self): - return get_authorized_product_types( - "view", - ).distinct() - - def destroy(self, request, *args, **kwargs): - instance = self.get_object() - if get_setting("ASYNC_OBJECT_DELETE"): - async_del = async_delete() - async_del.delete(instance) - else: - instance.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - @extend_schema( - request=serializers.ReportGenerateOptionSerializer, - responses={status.HTTP_200_OK: serializers.ReportGenerateSerializer}, - ) - @action( - detail=True, methods=["post"], - # IsAuthenticated only: report generation requires View permission, - # enforced by the permission-filtered get_queryset(). The viewset's - # permission_classes would check Edit (POST), which is too restrictive. - permission_classes=[IsAuthenticated], - ) - def generate_report(self, request, pk=None): - product_type = self.get_object() - - options = {} - # prepare post data - report_options = serializers.ReportGenerateOptionSerializer( - data=request.data, - ) - if report_options.is_valid(): - options["include_finding_notes"] = report_options.validated_data[ - "include_finding_notes" - ] - options["include_finding_images"] = report_options.validated_data[ - "include_finding_images" - ] - options[ - "include_executive_summary" - ] = report_options.validated_data["include_executive_summary"] - options[ - "include_table_of_contents" - ] = report_options.validated_data["include_table_of_contents"] - else: - return Response( - report_options.errors, status=status.HTTP_400_BAD_REQUEST, - ) - - data = report_generate(request, product_type, options) - report = serializers.ReportGenerateSerializer(data) - return Response(report.data) - - # Authorization: authenticated, configuration class DevelopmentEnvironmentViewSet( DojoModelViewSet, diff --git a/dojo/product_type/api/__init__.py b/dojo/product_type/api/__init__.py new file mode 100644 index 00000000000..9ac0eff9870 --- /dev/null +++ b/dojo/product_type/api/__init__.py @@ -0,0 +1 @@ +path = "product_types" # noqa: RUF067 diff --git a/dojo/product_type/api/serializer.py b/dojo/product_type/api/serializer.py new file mode 100644 index 00000000000..fc09e6e7100 --- /dev/null +++ b/dojo/product_type/api/serializer.py @@ -0,0 +1,9 @@ +from rest_framework import serializers + +from dojo.product_type.models import Product_Type + + +class ProductTypeSerializer(serializers.ModelSerializer): + class Meta: + model = Product_Type + fields = "__all__" diff --git a/dojo/product_type/api/urls.py b/dojo/product_type/api/urls.py new file mode 100644 index 00000000000..419dc829307 --- /dev/null +++ b/dojo/product_type/api/urls.py @@ -0,0 +1,6 @@ +from dojo.product_type.api.views import ProductTypeViewSet + + +def add_product_type_urls(router): + router.register("product_types", ProductTypeViewSet, basename="product_type") + return router diff --git a/dojo/product_type/api/views.py b/dojo/product_type/api/views.py new file mode 100644 index 00000000000..82c614d8483 --- /dev/null +++ b/dojo/product_type/api/views.py @@ -0,0 +1,93 @@ +from django_filters.rest_framework import DjangoFilterBackend +from drf_spectacular.utils import extend_schema, extend_schema_view +from rest_framework import status +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from dojo.api_v2.serializers import ReportGenerateOptionSerializer, ReportGenerateSerializer +from dojo.api_v2.views import PrefetchDojoModelViewSet, report_generate, schema_with_prefetch +from dojo.authorization import api_permissions as permissions +from dojo.models import Product_Type +from dojo.product_type.api.serializer import ProductTypeSerializer +from dojo.product_type.queries import get_authorized_product_types +from dojo.utils import async_delete, get_setting + + +# Authorization: object-based +@extend_schema_view(**schema_with_prefetch()) +# Authorization: object-based +@extend_schema_view(**schema_with_prefetch()) +class ProductTypeViewSet( + PrefetchDojoModelViewSet, +): + serializer_class = ProductTypeSerializer + queryset = Product_Type.objects.none() + filter_backends = (DjangoFilterBackend,) + filterset_fields = [ + "id", + "name", + "critical_product", + "key_product", + "created", + "updated", + ] + permission_classes = ( + IsAuthenticated, + permissions.UserHasProductTypePermission, + ) + + def get_queryset(self): + return get_authorized_product_types( + "view", + ).distinct() + + def destroy(self, request, *args, **kwargs): + instance = self.get_object() + if get_setting("ASYNC_OBJECT_DELETE"): + async_del = async_delete() + async_del.delete(instance) + else: + instance.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + @extend_schema( + request=ReportGenerateOptionSerializer, + responses={status.HTTP_200_OK: ReportGenerateSerializer}, + ) + @action( + detail=True, methods=["post"], + # IsAuthenticated only: report generation requires View permission, + # enforced by the permission-filtered get_queryset(). The viewset's + # permission_classes would check Edit (POST), which is too restrictive. + permission_classes=[IsAuthenticated], + ) + def generate_report(self, request, pk=None): + product_type = self.get_object() + + options = {} + # prepare post data + report_options = ReportGenerateOptionSerializer( + data=request.data, + ) + if report_options.is_valid(): + options["include_finding_notes"] = report_options.validated_data[ + "include_finding_notes" + ] + options["include_finding_images"] = report_options.validated_data[ + "include_finding_images" + ] + options[ + "include_executive_summary" + ] = report_options.validated_data["include_executive_summary"] + options[ + "include_table_of_contents" + ] = report_options.validated_data["include_table_of_contents"] + else: + return Response( + report_options.errors, status=status.HTTP_400_BAD_REQUEST, + ) + + data = report_generate(request, product_type, options) + report = ReportGenerateSerializer(data) + return Response(report.data) diff --git a/dojo/urls.py b/dojo/urls.py index 9b9a8d6a399..a31c6e62bd3 100644 --- a/dojo/urls.py +++ b/dojo/urls.py @@ -37,7 +37,6 @@ NotesViewSet, NoteTypeViewSet, ProductAPIScanConfigurationViewSet, - ProductTypeViewSet, ProductViewSet, RegulationsViewSet, ReImportScanView, @@ -80,6 +79,7 @@ from dojo.object.urls import urlpatterns as object_urls from dojo.organization.api.urls import add_organization_urls from dojo.organization.urls import urlpatterns as organization_urls +from dojo.product_type.api.urls import add_product_type_urls from dojo.regulations.urls import urlpatterns as regulations from dojo.reports.urls import urlpatterns as reports_urls from dojo.search.urls import urlpatterns as search_urls @@ -136,7 +136,7 @@ v2_api.register(r"product_api_scan_configurations", ProductAPIScanConfigurationViewSet, basename="product_api_scan_configuration") # RBAC endpoints moved to Pro under legacy authorization: # product_groups, product_members → pro/product_groups, pro/product_members -v2_api.register(r"product_types", ProductTypeViewSet, basename="product_type") +v2_api = add_product_type_urls(v2_api) # RBAC endpoints moved to Pro under legacy authorization: # product_type_members, product_type_groups → pro/product_type_members, pro/product_type_groups v2_api.register(r"regulations", RegulationsViewSet, basename="regulations") diff --git a/unittests/test_rest_framework.py b/unittests/test_rest_framework.py index 4455dba1374..c6b9d747231 100644 --- a/unittests/test_rest_framework.py +++ b/unittests/test_rest_framework.py @@ -57,7 +57,6 @@ NotesViewSet, NoteTypeViewSet, ProductAPIScanConfigurationViewSet, - ProductTypeViewSet, ProductViewSet, RiskAcceptanceViewSet, SonarqubeIssueViewSet, @@ -116,6 +115,7 @@ from dojo.organization.api.views import ( OrganizationViewSet, ) +from dojo.product_type.api.views import ProductTypeViewSet from dojo.url.api.views import URLViewSet from dojo.url.models import URL From f561576b5e6be37b1b0ef5a1f1a56e0c216877ab Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 7 Jun 2026 19:12:34 +0200 Subject: [PATCH 5/9] docs(agents): fold Phase 1 reorg lessons into the playbook Update AGENTS.md with conventions learned doing the Phase 1 model extractions: string FK refs to break circular imports, ruff noqa conventions (PLC0415/E402/F401), consolidated re-export placement, single-sourced constants, load-bearing side-effect imports, and corrected docker-based verify commands (manage.py shell, run-unittest.sh). --- AGENTS.md | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 64279977619..3882c7f292f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,17 +117,29 @@ grep -rn "from dojo.models import.*{Model}" dojo/ unittests/ 5. Remove original model code (keep re-export line) **Import rules for models.py:** -- Upward FKs (e.g., Test -> Engagement): import from `dojo.models` if not yet extracted, or `dojo.{module}.models` if already extracted -- Downward references (e.g., Product_Type querying Finding): use lazy imports inside method bodies -- Shared utilities (`copy_model_util`, `_manage_inherited_tags`, `get_current_date`, etc.): import from `dojo.models` +- **Prefer string FK refs to break circular imports.** Convert EVERY ForeignKey/ManyToMany/OneToOne whose target is NOT a class being moved into a string ref `"dojo."` (e.g. `models.ForeignKey(Engagement, ...)` → `models.ForeignKey("dojo.Engagement", ...)`). This lets the extracted `models.py` carry ZERO top-level `from dojo.models import ...`, which is what actually prevents circular imports. String refs produce identical migrations (Django resolves via the app registry) — `makemigrations --check` must still say "No changes detected". +- References AMONG the classes being moved together also use string refs, for uniformity and to avoid in-file ordering issues. +- Downward/other dojo references inside METHOD bodies: lazy imports inside the method. +- Shared utilities (`copy_model_util`, `_manage_inherited_tags`, `get_current_date`, `tomorrow`, etc.): import from `dojo.models`. CAVEAT: if a utility is used as a class-body field default (e.g. `default=get_current_date`), it must be imported (not redefined locally) so its `__module__` stays `dojo.models` — otherwise migration serialization changes and `makemigrations` flags a diff. These utils are defined early in `dojo.models` (before the re-export that loads your module), so a top-level `from dojo.models import get_current_date, tomorrow, copy_model_util` resolves correctly despite the partial circular load. - Do NOT set `app_label` in Meta — all models inherit `dojo` app_label automatically -**Verify:** +**Lint conventions (the repo pre-commit ruff is strict — match exactly):** +- Method-body lazy imports need `# noqa: PLC0415 -- lazy import, avoids circular dependency`. +- Mid-file / non-top re-exports in `dojo/models.py` need `# noqa: E402`, plus `# noqa: F401` ONLY on names not referenced elsewhere in `dojo/models.py` (a name still used by a remaining class body must NOT get F401). +- Self-check before committing: `/home/valentijn/.local/bin/ruff check --config ruff.toml ` (ruff is a host binary, NOT in the uwsgi container). Never let `ruff --fix` wrap a re-export into a parenthesized multiline — shorten the comment instead. + +**Re-export placement:** use ONE consolidated re-export block per module, placed at the earliest moved class's original position. A name referenced in a class-body FK at load-time must be re-exported BEFORE that line. + +**Constants:** single-source module-level constants in the extracted module and re-export from `dojo/models.py` (done for `IMPORT_ACTIONS`, `ENGAGEMENT_STATUS_CHOICES`). Do not duplicate. + +**Watch for load-bearing imports:** some imports in `dojo/models.py` exist for side effects, not the imported name (e.g. `from dojo.utils import parse_cvss_data` transitively registers `dojo.location` models for `apps.py:ready()`). If you remove the last consumer of such an import, keep it as a re-export or `apps.py` breaks. + +**Verify** (runs in docker; model imports need `manage.py shell -c`, not bare `python -c`): ```bash -python manage.py check -python manage.py makemigrations --check -python -c "from dojo.{module}.models import {Model}" -python -c "from dojo.models import {Model}" +docker compose exec -T uwsgi python manage.py check +docker compose exec -T uwsgi python manage.py makemigrations --check --dry-run # must say "No changes detected" +docker compose exec -T uwsgi python manage.py shell -c "from dojo.{module}.models import {Model}; print('ok')" +docker compose exec -T uwsgi python manage.py shell -c "from dojo.models import {Model}; print('ok')" ``` ### Phase 2: Extract Services @@ -217,9 +229,10 @@ Update UI views and API viewsets to call the service instead of containing logic ### After Each Phase: Verify ```bash -python manage.py check -python manage.py makemigrations --check -python -m pytest unittests/ -x --timeout=120 +docker compose exec -T uwsgi python manage.py check +docker compose exec -T uwsgi python manage.py makemigrations --check --dry-run +# Tests run via the wrapper (NOT pytest/manage.py test directly); tee to capture output: +./run-unittest.sh --test-case unittests.{relevant_test_module} 2>&1 | tee /tmp/test.log ``` --- From 3a8ce06568e513b2c40ff186acf0d8e29c60858c Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 7 Jun 2026 20:19:53 +0200 Subject: [PATCH 6/9] docs(agents): add Phase 2-9 lessons from the product_type full reorg Conditional services phase; keep filter base class + drop re-export when sole consumer is the module's own view (circular-import fix); per-symbol re-export decisions by actual consumers (incl. multi-line imports); preserve DRF route+basename; run real tests after dropping a re-export. --- AGENTS.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3882c7f292f..2bcbf2467e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,6 +144,8 @@ docker compose exec -T uwsgi python manage.py shell -c "from dojo.models import ### Phase 2: Extract Services +**This phase is conditional.** If the module's views are pure CRUD (form save/delete, simple field add/remove) with none of the "belongs in services" items below, there is NO `services.py` — skip the phase (the `url`/`location` reference modules have none). Don't invent a service just to have one. + Create `dojo/{module}/services.py` with business logic extracted from UI views. **What belongs in services.py:** @@ -185,8 +187,14 @@ Update UI views and API viewsets to call the service instead of containing logic ### Phase 4: Extract UI Filters to `ui/filters.py` 1. Create `dojo/{module}/ui/filters.py` — move module-specific filters from `dojo/filters.py` -2. Shared base classes (`DojoFilter`, `DateRangeFilter`, `ReportBooleanFilter`) stay in `dojo/filters.py` -3. Add re-exports in `dojo/filters.py` +2. Shared base classes (`DojoFilter`, `DateRangeFilter`, `ReportBooleanFilter`) stay in `dojo/filters.py`. **Keep the original base class** (`class XFilter(DojoFilter)`) — do NOT switch to `FilterSet` to dodge an import. +3. **Circular-import caveat**: a re-export in `dojo/filters.py` (`from dojo.{module}.ui.filters import XFilter`) while `ui/filters.py` imports `DojoFilter` back from `dojo.filters` creates a real cycle (fails when `ui/filters.py` loads first). Resolve per the re-export rule below — usually: **drop the `dojo/filters.py` re-export** when the filter's only consumer is the module's own view, and import the filter directly from `dojo.{module}.ui.filters` in that view (matches the `url` module). + +> **Re-export decisions (Phases 3,4,6,8) — decide per symbol, by actual remaining consumers:** +> - `grep -rn` the symbol across `dojo/` and `unittests/` first. Account for multi-line `from x import (\n ...\n)` blocks — a one-line grep misses them. +> - If a symbol is still referenced by code that REMAINS in the monolith (e.g. `ProductTypeSerializer` used by `ReportGenerateSerializer` in `api_v2/serializers.py`) → **keep** the re-export (`# noqa: E402` + `F401` as needed). +> - If the ONLY consumers are code you are moving/updating anyway (the module's own views/tests) → **omit** the re-export and point those consumers at the new path. This is required when a re-export would cycle (filter↔`dojo.filters`, `api_v2.views`↔`{module}.api.views`). +> - After dropping any re-export, run the module's real unit tests (not just `manage.py check`) — `check` won't catch a broken import in a test module. ### Phase 5: Move UI Views/URLs into `ui/` @@ -226,6 +234,8 @@ Update UI views and API viewsets to call the service instead of containing logic ``` 2. Update `dojo/urls.py` — replace `v2_api.register(...)` with `add_{module}_urls(v2_api)` +**Preserve the exact route and basename** from the original `v2_api.register(...)` call. They often differ (e.g. route `product_types`, `basename="product_type"`); `path` in `api/__init__.py` should be the route string, and pass `basename=` explicitly if the original did. Changing either breaks DRF URL reversing and the API tests. Verify with `reverse('{basename}-list')`. + ### After Each Phase: Verify ```bash From 03435d108822d0a674e0114497bab4b8c68caf11 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 7 Jun 2026 21:37:24 +0200 Subject: [PATCH 7/9] docs(agents): add API serializer/viewset cycle-break + class-copy lessons from engagement reorg --- AGENTS.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2bcbf2467e3..20a3e8b5259 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -209,7 +209,9 @@ Update UI views and API viewsets to call the service instead of containing logic 1. Create `dojo/{module}/api/__init__.py` with `path = "{module}"` 2. Create `dojo/{module}/api/serializer.py` — move from `dojo/api_v2/serializers.py` -3. Add re-exports in `dojo/api_v2/serializers.py` +3. Re-export ONLY the serializers still referenced by code REMAINING in `api_v2/serializers.py` (e.g. one nested by `ReportGenerateSerializer` / used in a `RiskAcceptance` representation). Serializers consumed only by the viewset are imported by their new path in Phase 8, so omit those re-exports. + +**Cycle-break for serializers that reference api_v2 serializers** (matches `dojo/test/api/serializer.py`, `dojo/engagement/api/serializer.py`): a moved serializer cannot import `NoteSerializer`/`FileSerializer`/`TagListSerializerField` etc. from `dojo.api_v2.serializers` at module level — that cycles once `api_v2/serializers.py` re-imports your serializer. Convert class-body field assignments (`tags = TagListSerializerField(...)`, `notes = NoteSerializer(many=True)`) into a lazy `get_fields()` override that imports inside the method (`# noqa: PLC0415`); `build_relational_field` lazy-imports the same way. The extracted module then carries ZERO top-level `dojo.api_v2.serializers` import. ### Phase 7: Extract API Filters to `api/filters.py` @@ -219,7 +221,9 @@ Update UI views and API viewsets to call the service instead of containing logic ### Phase 8: Extract API ViewSets to `api/views.py` 1. Create `dojo/{module}/api/views.py` — move from `dojo/api_v2/views.py` -2. Add re-exports in `dojo/api_v2/views.py` +2. Do NOT re-export the viewset in `dojo/api_v2/views.py` — it would cycle (`api_v2.views` ↔ `{module}.api.views`, because the viewset imports its base classes back from `api_v2.views`). Update the consumers instead: the `dojo/urls.py` registration (Phase 9) and `unittests/test_rest_framework.py`, which imports viewsets by name (a dropped re-export there is an ImportError that `manage.py check` won't catch — only the test run does). + +**Viewset import pattern (matches `dojo/test/api/views.py`, `dojo/engagement/api/views.py`):** `from dojo.api_v2.views import DojoModelViewSet, PrefetchDojoModelViewSet, report_generate, schema_with_prefetch` — base classes and helpers stay in the monolith. Requalify every `serializers.X` reference that stays in `api_v2` to `api_v2_serializers.X` via `from dojo.api_v2 import serializers as api_v2_serializers`; import the MOVED serializers by name from `dojo.{module}.api.serializer`. PRESERVE active class decorators such as `@extend_schema_view(**schema_with_prefetch())` — they are easy to drop when copying a viewset and silently change the generated schema. After moving, prune the now-unused engagement-specific imports left behind in `api_v2/views.py` (filter, services, queries, models) — ruff flags them. ### Phase 9: Extract API URL Registration @@ -238,6 +242,8 @@ Update UI views and API viewsets to call the service instead of containing logic ### After Each Phase: Verify +**When copying a class/function out, capture through to the next top-level `class`/dedent.** A fixed-line-window read can silently truncate a long class (trailing fields + `Meta` + `__init__`), yielding a partial copy that still imports cleanly but drops behavior. Confirm the last line of the source class before deleting it from the monolith. + ```bash docker compose exec -T uwsgi python manage.py check docker compose exec -T uwsgi python manage.py makemigrations --check --dry-run From 48b4e7e8a59a8eedc7e4eab1a95465da4c6f9409 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 7 Jun 2026 23:28:13 +0200 Subject: [PATCH 8/9] docs(agents): note prefetcher full-reexport + extend_schema_field cycle-break (Phase 6) --- AGENTS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 20a3e8b5259..c91d5362456 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -211,8 +211,12 @@ Update UI views and API viewsets to call the service instead of containing logic 2. Create `dojo/{module}/api/serializer.py` — move from `dojo/api_v2/serializers.py` 3. Re-export ONLY the serializers still referenced by code REMAINING in `api_v2/serializers.py` (e.g. one nested by `ReportGenerateSerializer` / used in a `RiskAcceptance` representation). Serializers consumed only by the viewset are imported by their new path in Phase 8, so omit those re-exports. + **EXCEPTION — prefetcher discovery (re-export the FULL moved ModelSerializer set):** `dojo/api_v2/prefetch/prefetcher.py` builds its model→serializer map via `inspect.getmembers(sys.modules["dojo.api_v2.serializers"], ...)`. Any moved `ModelSerializer` that drops out of `api_v2/serializers.py`'s module members disappears from that map, so prefetch breaks (e.g. `test_detail_prefetch` / `test_list_prefetch` fail with `'' not found`) — and `manage.py check` does NOT catch it; only the `test_rest_framework` prefetch tests do. So re-export the ENTIRE set of moved `ModelSerializer`s (not just the ReportGenerate-nested ones), even nested/sub serializers with no other consumer. This re-export block is byte-identical module membership → zero behavior change. (Pure `serializers.Serializer` subclasses that aren't tied to a model and aren't referenced elsewhere can still be omitted.) This bit the finding module (18 serializers); revisit earlier modules if their prefetch tests ever regress. + **Cycle-break for serializers that reference api_v2 serializers** (matches `dojo/test/api/serializer.py`, `dojo/engagement/api/serializer.py`): a moved serializer cannot import `NoteSerializer`/`FileSerializer`/`TagListSerializerField` etc. from `dojo.api_v2.serializers` at module level — that cycles once `api_v2/serializers.py` re-imports your serializer. Convert class-body field assignments (`tags = TagListSerializerField(...)`, `notes = NoteSerializer(many=True)`) into a lazy `get_fields()` override that imports inside the method (`# noqa: PLC0415`); `build_relational_field` lazy-imports the same way. The extracted module then carries ZERO top-level `dojo.api_v2.serializers` import. +**`@extend_schema_field` decorators referencing api_v2 serializers also cycle** (their argument is evaluated eagerly at class-body load). A class-body `@extend_schema_field(RiskAcceptanceSerializer)` / `@extend_schema_field(BurpRawRequestResponseSerializer)` cannot stay. Drop the decorator and reapply the override at the bottom of the module via `drf_spectacular.utils.set_override(Cls.method, "field", LazyImportedSerializer)` inside a small `_apply_schema_overrides()` that lazy-imports the api_v2 serializer (`# noqa: PLC0415`). This preserves the generated schema with no top-level api_v2 reference. (Decorators whose argument is one of the MOVED serializers in the same file are fine as-is.) + ### Phase 7: Extract API Filters to `api/filters.py` 1. Create `dojo/{module}/api/filters.py` — move `Api{Model}Filter` from `dojo/filters.py` From a5ea352e26dd0ce4b9db90d9f00b86bfbb87f590 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Mon, 8 Jun 2026 22:26:13 +0200 Subject: [PATCH 9/9] docs(agents): add Phase 10 peripheral-module 10-PR stack plan Self-contained brief for finishing the reorg: 5 new draft PRs (#6-10) stacked on top of the finding PR, plus CWE+BurpRawRequestResponse folded into the existing finding module PR. Bundles, line ranges, stack/cascade mechanics, and module-specific gotchas. Marks the 5 core modules Complete. --- AGENTS.md | 78 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 73 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c91d5362456..e53dcd7cadf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,11 +54,12 @@ Modules in various stages of reorganization: |--------|-----------|-------------|-----|------|--------| | **url** | In module | N/A | Done | Done | **Complete** | | **location** | In module | N/A | N/A | Done | **Complete** | -| **product_type** | In dojo/models.py | Missing | Partial (views at root) | In dojo/api_v2/ | Needs work | -| **test** | In dojo/models.py | Missing | Partial (views at root) | In dojo/api_v2/ | Needs work | -| **engagement** | In dojo/models.py | Partial (32 lines) | Partial (views at root) | In dojo/api_v2/ | Needs work | -| **product** | In dojo/models.py | Missing | Partial (views at root) | In dojo/api_v2/ | Needs work | -| **finding** | In dojo/models.py | Missing | Partial (views at root) | In dojo/api_v2/ | Needs work | +| **product_type** | In module | N/A | Done | Done | **Complete** (#14970) | +| **test** | In module | N/A | Done | Done | **Complete** (#14971) | +| **engagement** | In module | In module | Done | Done | **Complete** (#14972) | +| **product** | In module | N/A | Done | Done | **Complete** (#14973) | +| **finding** | In module | N/A (helper.py) | Done | Done | **Complete** (#14974); CWE+Burp pending | +| **peripheral (×18)** | In dojo/models.py | — | Partial/none | Partial/none | **Phase 10** (PRs #6–10, see below) | ### Monolithic Files Being Decomposed @@ -280,3 +281,70 @@ def critical_present(self): - **Signal registration**: Handled in `dojo/apps.py` via `import dojo.{module}.signals`. Already set up for test, engagement, product, product_type. - **Watson search**: Uses `self.get_model("Product")` in `apps.py` — works via Django's model registry regardless of file location. - **Admin registration**: Currently at the bottom of `dojo/models.py` (lines 4888-4973). Must be moved to `{module}/admin.py` and removed from `dojo/models.py` to avoid `AlreadyRegistered` errors. + +--- + +## Phase 10: Peripheral Model Modules — 10-PR Stack Continuation + +> **This section is the complete, self-contained brief for a fresh agent session (auto mode) to finish the reorganization.** The 5 core hierarchy modules (`product_type`, `test`, `engagement`, `product`, `finding`) are DONE — they are the templates. What remains is moving the ~45 *peripheral* model classes still defined in `dojo/models.py` into their domain modules, each as a **full vertical slice** (all 9 phases), reusing the playbook above. + +### Goal & scope + +`dojo/models.py` is now ~2,254 lines and still **defines** these peripheral model classes. Move each into its module (most module dirs already exist with `views.py`/`urls.py`/helpers but NO `models.py`/`admin.py` — only `dojo/url/` and `dojo/location/` are complete-with-models templates). Leave backward-compat re-exports in every monolith (`dojo/models.py`, `forms.py`, `filters.py`, `api_v2/serializers.py`, `api_v2/views.py`) per the rules above. + +**Decisions already locked with the user (do NOT relitigate):** +- **Full vertical slice per module** (Phases 1–9), not models-only. Skip a phase only when the module genuinely has no code for it (e.g. no API serializer/viewset exists → no `api/` layer; no module-specific form → no `ui/forms.py`). Follow the "Phase 2 is conditional" / re-export-by-actual-consumer rules above. +- **These models STAY in `dojo/models.py`** (no module worth creating — do NOT extract): `DojoMeta`, `Network_Locations`, `Sonarqube_Issue`, `Sonarqube_Issue_Transition`, `Check_List`, `Testing_Guide_Category`, `Testing_Guide`, `Language_Type`, `Languages`, `App_Analysis`. Leave them untouched. +- **`CWE` + `BurpRawRequestResponse` fold into `finding`** (they are finding-domain), and are done FIRST on the EXISTING finding PR (#14974), not a new PR. + +### The 10-PR stack + +The 5 core PRs already exist (stacked, merge bottom-up): `dev ← #14970 product_type ← #14971 test ← #14972 engagement ← #14973 product ← #14974 finding`. **The new work CONTINUES this stack on top of #14974.** All branches and PRs follow the same conventions as the existing 5. + +| PR | Branch (head) | Base | Contents | +|----|---------------|------|----------| +| 1–5 | existing | existing | DONE: product_type, test, engagement, product, finding | +| **5 (#14974)** | `reorg/finding-models` | `reorg/product-models` | **ADD `CWE` + `BurpRawRequestResponse` to `dojo/finding/`** (full slice). Existing PR — do NOT create a new one. | +| **6** | `reorg/peripheral-user` | `reorg/finding-models` | **Bundle A**: `user` (`Dojo_User`, `UserContactInfo`, `Contact`) + `system_settings` (`System_Settings`) | +| **7** | `reorg/peripheral-tools-endpoint` | `reorg/peripheral-user` | **Bundle B**: `endpoint` (`Endpoint_Params`, `Endpoint_Status`, `Endpoint`) + `tool_type` (`Tool_Type`) + `tool_config` (`Tool_Configuration`, + admin classes `ToolConfigForm_Admin`/`Tool_Configuration_Admin`) + `tool_product` (`Tool_Product_Settings`, `Tool_Product_History`) | +| **8** | `reorg/peripheral-survey-benchmark` | `reorg/peripheral-tools-endpoint` | **Bundle C**: `survey` (`Question`, `TextQuestion`, `Choice`, `ChoiceQuestion`, `Engagement_Survey`, `Answered_Survey`, `General_Survey`, `Answer`, `TextAnswer`, `ChoiceAnswer`) + `benchmark` (`Benchmark_Type`, `Benchmark_Category`, `Benchmark_Requirement`, `Benchmark_Product`, `Benchmark_Product_Summary`) | +| **9** | `reorg/peripheral-notes-files` | `reorg/peripheral-survey-benchmark` | **Bundle D**: `notes` (`NoteHistory`, `Notes`) + `note_type` (`Note_Type`) + `file_uploads` (`UniqueUploadNameProvider`, `FileUpload`, `FileAccessToken`) + `reports` (`Report_Type`) + `risk_acceptance` (`Risk_Acceptance`) | +| **10** | `reorg/peripheral-misc` | `reorg/peripheral-notes-files` | **Bundle E**: `regulations` (`Regulation`) + `banner` (`BannerConf`) + `announcement` (`Announcement`, `UserAnnouncement`) + `development_environment` (`Development_Environment`) + `object` (`Objects_Review`, `Objects_Product`) | + +**Bundle order is by FK direction**: `user` first (`Dojo_User` is an FK target almost everywhere); everything else references already-moved or string-ref'd models. Inside a bundle, FKs between same-bundle models are real class refs; FKs to anything OUTSIDE the bundle become string refs `"dojo."` (per the string-FK rule above — this keeps the extracted `models.py` free of top-level `from dojo.models import`). + +### Stack & PR mechanics (locked with user) + +- **Branches live on the `upstream` remote** (`git@github.com:DefectDojo/django-DefectDojo.git`), exactly like the existing 5 (their head branches are on upstream, e.g. `upstream/reorg/finding-models`). Push each new branch to `upstream`, and **force-push with `--force-with-lease`** on cascade (`git push --force-with-lease upstream :`). +- **The 5 new PRs are DRAFT PRs.** Create with `gh pr create --draft --repo DefectDojo/django-DefectDojo --base --head `. +- Each new branch is created from its predecessor's tip: `git checkout -b reorg/peripheral-user reorg/finding-models`, etc. Merge bottom-up. +- **PR descriptions**: every PR in the stack (all 10) must include a stack map listing all 10 PRs in order with checkboxes and the bottom-up merge note, so reviewers see the whole picture. Summary section only — NO test-plan section (see CLAUDE.local.md / PR rules). Format PR URLs as markdown links. Read an existing body with `gh pr view --json body -q '.body'` before editing; edit via `--body-file` or the REST `gh api -X PATCH` path (inline `--body` silently fails on this repo). +- **Cascade after editing a lower branch** (e.g. this AGENTS.md commit on #14970): `git rebase --onto ` up the chain, then force-push all with `--force-with-lease`. AGENTS.md edits always land on the bottom branch (#14970) and cascade. + +### Per-module execution = the 9-phase playbook above + +For EACH module in a bundle, run **Phase 0 pre-flight first** (the grep block above) to discover its exact forms/filters/serializers/viewsets/urls/admin/signals/consumers — do NOT trust a memorized list. Then Phases 1–9. Reference complete templates: `dojo/url/`, `dojo/location/` (models), and `dojo/finding/`, `dojo/product/`, `dojo/test/`, `dojo/engagement/` (full API+UI slices). Verify gates after each phase (`manage.py check`, `makemigrations --check --dry-run`, `./run-unittest.sh --test-case unittests. 2>&1 | tee /tmp/test.log`). All gates run in docker (`docker compose exec -T uwsgi ...`); model imports need `manage.py shell -c`. + +### Model line ranges in `dojo/models.py` (snapshot — re-grep before editing; line numbers shift as you extract) + +- **CWE** 1027–1031 · **BurpRawRequestResponse** 1563–1575 → `finding` (PR #14974) +- **Dojo_User** 174–209 · **UserContactInfo** 211–234 · **Contact** 605–612 · **System_Settings** 236–595 +- **Tool_Type** 940–949 · **Tool_Configuration** 951–979 · **ToolConfigForm_Admin/Tool_Configuration_Admin** 981–1010 · **Endpoint_Params** 1033–1039 · **Endpoint_Status** 1041–1093 · **Endpoint** 1095–1470 · **Tool_Product_Settings** 1765–1777 · **Tool_Product_History** 1779–1785 +- **Benchmark_Type** 1890–1905 · **Benchmark_Category** 1907–1921 · **Benchmark_Requirement** 1923–1939 · **Benchmark_Product** 1941–1957 · **Benchmark_Product_Summary** 1959–1989 · **Question** 1992–2012 · **TextQuestion** 2014–2024 · **Choice** 2026–2039 · **ChoiceQuestion** 2041–2058 · **Engagement_Survey** 2060–2076 · **Answered_Survey** 2078–2101 · **General_Survey** 2107–2123 · **Answer** 2126–2138 · **TextAnswer** 2140–2149 · **ChoiceAnswer** 2151–2253 +- **Note_Type** 614–623 · **NoteHistory** 625–636 · **Notes** 638–669 · **UniqueUploadNameProvider** 108–135 · **FileUpload** 671–749 · **FileAccessToken** 1679–1703 · **Report_Type** 751–753 · **Risk_Acceptance** 1577–1677 +- **Regulation** 136–168 · **Announcement** 1713–1725 · **UserAnnouncement** 1727–1730 · **BannerConf** 1732–1763 · **Development_Environment** 1472–1481 · **Objects_Review** 1829–1835 · **Objects_Product** 1837–1861 + +### Module-specific gotchas (beyond the generic playbook) + +- **`Question` / `Answer` (survey)**: base classes are defined inside a `with warnings.catch_warnings(): ...` block (polymorphic-model deprecation suppression). PRESERVE that block structure when moving to `dojo/survey/models.py` — don't flatten it. +- **survey & benchmark have NO serializers/viewsets in `api_v2`** (verified). So Bundle C likely has no `api/` layer — skip Phases 6–9 for those modules (confirm with Phase 0). They DO have UI views/urls/forms/filters. +- **`Benchmark_Requirement` → M2M `CWE`**: `CWE` moves to `finding` in PR #14974 (lands lower in the stack), so by the time Bundle C runs, use string ref `"dojo.CWE"` (the `dojo.models` re-export stays valid). Same for any other `CWE` reference. +- **`Risk_Acceptance`**: M2M `accepted_findings`→Finding, FK `owner`→Dojo_User, M2M `notes`→Notes — all cross-bundle → string refs. `dojo/risk_acceptance/` already has `api.py`/`helper.py`/`queries.py`/`signals.py` but no `models.py`; reconcile `api.py` vs the playbook's `api/` dir layout. +- **`Endpoint`**: references `Dojo_User`, `Finding`, `Product`, `Endpoint_Status` — string-ref everything except same-bundle `Endpoint_Params`/`Endpoint_Status`. `dojo/endpoint/` already has `queries.py`/`utils.py`/`signals.py`. +- **`tool_config` admin**: `ToolConfigForm_Admin` (a `forms.ModelForm`) and `Tool_Configuration_Admin` (an `admin.ModelAdmin`) currently sit in `dojo/models.py` — move them to `dojo/tool_config/admin.py` (form + admin), not `models.py`. +- **`CWE` / `BurpRawRequestResponse` are heavily imported** (20+ files across `dojo/` and `unittests/`, including tool parsers for CWE and importers for Burp). Run the Phase 0 consumer grep (`grep -rn "import.*\bCWE\b" dojo/ unittests/`, same for `BurpRawRequestResponse`) and rely on the `dojo.models` re-export for external consumers — only repoint finding's own code. +- **Shared bases (the `FindingTagStringFilter` trap)**: before moving any form/filter, grep for subclasses/consumers OUTSIDE the module. If a base form/filter is also used by a model staying in `dojo/models.py` or another module, KEEP it in the monolith and import it, rather than moving + back-importing (which cycles). The prefetcher full-re-export rule (Phase 6) applies to any moved `ModelSerializer`. + +### After the stack is built + +Update the **Current State** table above (mark the newly-completed modules **Complete**), and update the monolith line counts in "Monolithic Files Being Decomposed" (they are stale — `dojo/models.py` is ~2,254 lines now, not 4,973).