Skip to content

Commit d85da61

Browse files
refactor(product): extract Product/Product_Line/Product_API_Scan_Configuration into dojo/product/
Phase 1 of module reorg per AGENTS.md. Move Product, Product_Line, Product_API_Scan_Configuration + admin registrations into dojo/product/{models,admin}.py. Cross-module FKs use string refs to avoid circular imports. Product_Type re-export now pure backward-compat (F401). No migration change.
1 parent 39d1437 commit d85da61

4 files changed

Lines changed: 331 additions & 289 deletions

File tree

dojo/models.py

Lines changed: 6 additions & 289 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
import warnings
88
from contextlib import suppress
99
from datetime import datetime, timedelta
10-
from decimal import Decimal
1110
from pathlib import Path
1211
from typing import TYPE_CHECKING
1312
from urllib.parse import urlparse
@@ -746,7 +745,12 @@ def clean(self):
746745
raise ValidationError(msg)
747746

748747

749-
from dojo.product_type.models import Product_Type # noqa: E402 -- re-export; mid-file as Product FK uses it below
748+
from dojo.product.models import ( # noqa: E402 -- re-export; class-body FKs below reference these
749+
Product,
750+
Product_API_Scan_Configuration, # noqa: F401 -- re-export
751+
Product_Line, # noqa: F401 -- re-export
752+
)
753+
from dojo.product_type.models import Product_Type # noqa: E402, F401 -- re-export
750754
from dojo.test.models import ( # noqa: E402 -- re-export; class-body FKs below reference these
751755
IMPORT_ACTIONS, # noqa: F401 -- re-export
752756
IMPORT_CLOSED_FINDING, # noqa: F401 -- re-export
@@ -760,14 +764,6 @@ def clean(self):
760764
)
761765

762766

763-
class Product_Line(models.Model):
764-
name = models.CharField(max_length=300)
765-
description = models.CharField(max_length=2000)
766-
767-
def __str__(self):
768-
return self.name
769-
770-
771767
class Report_Type(models.Model):
772768
name = models.CharField(max_length=255)
773769

@@ -957,257 +953,6 @@ def get_summary(self):
957953
return f"{self.name} - Critical: {self.critical}, High: {self.high}, Medium: {self.medium}, Low: {self.low}"
958954

959955

960-
class Product(BaseModel):
961-
WEB_PLATFORM = "web"
962-
IOT = "iot"
963-
DESKTOP_PLATFORM = "desktop"
964-
MOBILE_PLATFORM = "mobile"
965-
WEB_SERVICE_PLATFORM = "web service"
966-
PLATFORM_CHOICES = (
967-
(WEB_SERVICE_PLATFORM, _("API")),
968-
(DESKTOP_PLATFORM, _("Desktop")),
969-
(IOT, _("Internet of Things")),
970-
(MOBILE_PLATFORM, _("Mobile")),
971-
(WEB_PLATFORM, _("Web")),
972-
)
973-
974-
CONSTRUCTION = "construction"
975-
PRODUCTION = "production"
976-
RETIREMENT = "retirement"
977-
LIFECYCLE_CHOICES = (
978-
(CONSTRUCTION, _("Construction")),
979-
(PRODUCTION, _("Production")),
980-
(RETIREMENT, _("Retirement")),
981-
)
982-
983-
THIRD_PARTY_LIBRARY_ORIGIN = "third party library"
984-
PURCHASED_ORIGIN = "purchased"
985-
CONTRACTOR_ORIGIN = "contractor"
986-
INTERNALLY_DEVELOPED_ORIGIN = "internal"
987-
OPEN_SOURCE_ORIGIN = "open source"
988-
OUTSOURCED_ORIGIN = "outsourced"
989-
ORIGIN_CHOICES = (
990-
(THIRD_PARTY_LIBRARY_ORIGIN, _("Third Party Library")),
991-
(PURCHASED_ORIGIN, _("Purchased")),
992-
(CONTRACTOR_ORIGIN, _("Contractor Developed")),
993-
(INTERNALLY_DEVELOPED_ORIGIN, _("Internally Developed")),
994-
(OPEN_SOURCE_ORIGIN, _("Open Source")),
995-
(OUTSOURCED_ORIGIN, _("Outsourced")),
996-
)
997-
998-
VERY_HIGH_CRITICALITY = "very high"
999-
HIGH_CRITICALITY = "high"
1000-
MEDIUM_CRITICALITY = "medium"
1001-
LOW_CRITICALITY = "low"
1002-
VERY_LOW_CRITICALITY = "very low"
1003-
NONE_CRITICALITY = "none"
1004-
BUSINESS_CRITICALITY_CHOICES = (
1005-
(VERY_HIGH_CRITICALITY, _("Very High")),
1006-
(HIGH_CRITICALITY, _("High")),
1007-
(MEDIUM_CRITICALITY, _("Medium")),
1008-
(LOW_CRITICALITY, _("Low")),
1009-
(VERY_LOW_CRITICALITY, _("Very Low")),
1010-
(NONE_CRITICALITY, _("None")),
1011-
)
1012-
1013-
name = models.CharField(max_length=255, unique=True)
1014-
description = models.CharField(max_length=4000)
1015-
1016-
product_manager = models.ForeignKey(Dojo_User, null=True, blank=True,
1017-
related_name="product_manager", on_delete=models.RESTRICT)
1018-
technical_contact = models.ForeignKey(Dojo_User, null=True, blank=True,
1019-
related_name="technical_contact", on_delete=models.RESTRICT)
1020-
team_manager = models.ForeignKey(Dojo_User, null=True, blank=True,
1021-
related_name="team_manager", on_delete=models.RESTRICT)
1022-
1023-
prod_type = models.ForeignKey(Product_Type, related_name="prod_type",
1024-
null=False, blank=False, on_delete=models.CASCADE)
1025-
sla_configuration = models.ForeignKey(SLA_Configuration,
1026-
related_name="sla_config",
1027-
null=False,
1028-
blank=False,
1029-
default=1,
1030-
on_delete=models.RESTRICT)
1031-
tid = models.IntegerField(default=0, editable=False)
1032-
authorized_users = models.ManyToManyField(Dojo_User, related_name="authorized_products", blank=True)
1033-
prod_numeric_grade = models.IntegerField(null=True, blank=True)
1034-
1035-
# Metadata
1036-
business_criticality = models.CharField(max_length=9, choices=BUSINESS_CRITICALITY_CHOICES, blank=True, null=True)
1037-
platform = models.CharField(max_length=11, choices=PLATFORM_CHOICES, blank=True, null=True)
1038-
lifecycle = models.CharField(max_length=12, choices=LIFECYCLE_CHOICES, blank=True, null=True)
1039-
origin = models.CharField(max_length=19, choices=ORIGIN_CHOICES, blank=True, null=True)
1040-
user_records = models.PositiveIntegerField(blank=True, null=True, help_text=_("Estimate the number of user records within the application."))
1041-
revenue = models.DecimalField(max_digits=15, decimal_places=2, blank=True, null=True, validators=[MinValueValidator(Decimal("0.00"))], help_text=_("Estimate the application's revenue."))
1042-
external_audience = models.BooleanField(default=False, help_text=_("Specify if the application is used by people outside the organization."))
1043-
internet_accessible = models.BooleanField(default=False, help_text=_("Specify if the application is accessible from the public internet."))
1044-
regulations = models.ManyToManyField(Regulation, blank=True)
1045-
1046-
tags = TagField(blank=True, force_lowercase=True, help_text=_("Add tags that help describe this product. Choose from the list or add new tags. Press Enter key to add."))
1047-
enable_product_tag_inheritance = models.BooleanField(
1048-
default=False,
1049-
blank=False,
1050-
verbose_name=_("Enable Product Tag Inheritance"),
1051-
help_text=_("Enables product tag inheritance. Any tags added on a product will automatically be added to all Engagements, Tests, and Findings"))
1052-
enable_simple_risk_acceptance = models.BooleanField(default=False, help_text=_("Allows simple risk acceptance by checking/unchecking a checkbox."))
1053-
enable_full_risk_acceptance = models.BooleanField(default=True, help_text=_("Allows full risk acceptance using a risk acceptance form, expiration date, uploaded proof, etc."))
1054-
1055-
disable_sla_breach_notifications = models.BooleanField(
1056-
default=False,
1057-
blank=False,
1058-
verbose_name=_("Disable SLA breach notifications"),
1059-
help_text=_("Disable SLA breach notifications if configured in the global settings"))
1060-
async_updating = models.BooleanField(default=False,
1061-
help_text=_("Findings under this Product or SLA configuration are asynchronously being updated"))
1062-
1063-
class Meta:
1064-
ordering = ("name",)
1065-
1066-
def __str__(self):
1067-
return self.name
1068-
1069-
def save(self, *args, **kwargs):
1070-
# get the product's sla config before saving (if this is an existing product)
1071-
initial_sla_config = None
1072-
if self.pk is not None:
1073-
initial_sla_config = getattr(Product.objects.get(pk=self.pk), "sla_configuration", None)
1074-
# if initial sla config exists and async finding update is already running, revert sla config before saving
1075-
if initial_sla_config and self.async_updating:
1076-
self.sla_configuration = initial_sla_config
1077-
1078-
super().save(*args, **kwargs)
1079-
1080-
# if the initial sla config exists and async finding update is not running
1081-
if initial_sla_config is not None and not self.async_updating:
1082-
# get the new sla config from the saved product
1083-
new_sla_config = getattr(self, "sla_configuration", None)
1084-
# if the sla config has changed, update finding sla expiration dates within this product
1085-
if new_sla_config and (initial_sla_config != new_sla_config):
1086-
# set the async updating flag to true for this product
1087-
self.async_updating = True
1088-
super().save(*args, **kwargs)
1089-
# set the async updating flag to true for the sla config assigned to this product
1090-
sla_config = getattr(self, "sla_configuration", None)
1091-
if sla_config:
1092-
sla_config.async_updating = True
1093-
super(SLA_Configuration, sla_config).save()
1094-
# launch the async task to update all finding sla expiration dates
1095-
from dojo.sla_config.helpers import async_update_sla_expiration_dates_sla_config_sync # noqa: I001, PLC0415 circular import
1096-
from dojo.celery_dispatch import dojo_dispatch_task # noqa: PLC0415 circular import
1097-
1098-
dojo_dispatch_task(
1099-
async_update_sla_expiration_dates_sla_config_sync,
1100-
sla_config.id,
1101-
[self.id],
1102-
)
1103-
# The async task refetches and resets async_updating on its own copies.
1104-
# Mirror that on this in-memory product and the in-memory sla_config so a
1105-
# subsequent save() on either does not trigger their lock-revert paths.
1106-
self.async_updating = False
1107-
if sla_config:
1108-
sla_config.async_updating = False
1109-
1110-
def get_absolute_url(self):
1111-
return reverse("view_product", args=[str(self.id)])
1112-
1113-
@cached_property
1114-
def findings_count(self):
1115-
try:
1116-
# if prefetched, it's already there
1117-
return self.active_finding_count
1118-
except AttributeError:
1119-
# ideally it's always prefetched and we can remove this code in the future
1120-
self.active_finding_count = Finding.objects.filter(active=True,
1121-
test__engagement__product=self).count()
1122-
return self.active_finding_count
1123-
1124-
@cached_property
1125-
def findings_active_verified_count(self):
1126-
try:
1127-
# if prefetched, it's already there
1128-
return self.active_verified_finding_count
1129-
except AttributeError:
1130-
# ideally it's always prefetched and we can remove this code in the future
1131-
self.active_verified_finding_count = Finding.objects.filter(active=True,
1132-
verified=True,
1133-
test__engagement__product=self).count()
1134-
return self.active_verified_finding_count
1135-
1136-
# TODO: Delete this after the move to Locations
1137-
@cached_property
1138-
def endpoint_host_count(self):
1139-
# active_endpoints is (should be) prefetched
1140-
endpoints = getattr(self, "active_endpoints", None)
1141-
1142-
hosts = []
1143-
for e in endpoints:
1144-
if e.host in hosts:
1145-
continue
1146-
hosts.append(e.host)
1147-
1148-
return len(hosts)
1149-
1150-
# TODO: Delete this after the move to Locations
1151-
@cached_property
1152-
def endpoint_count(self):
1153-
# active_endpoints is (should be) prefetched
1154-
endpoints = getattr(self, "active_endpoints", None)
1155-
if endpoints:
1156-
return len(self.active_endpoints)
1157-
return 0
1158-
1159-
def open_findings(self, start_date=None, end_date=None):
1160-
if start_date is None or end_date is None:
1161-
return {}
1162-
1163-
from dojo.utils import get_system_setting # noqa: PLC0415 circular import
1164-
findings = Finding.objects.filter(test__engagement__product=self,
1165-
mitigated__isnull=True,
1166-
false_p=False,
1167-
duplicate=False,
1168-
out_of_scope=False,
1169-
date__range=[start_date,
1170-
end_date])
1171-
1172-
if get_system_setting("enforce_verified_status", True) or get_system_setting("enforce_verified_status_metrics", True):
1173-
findings = findings.filter(verified=True)
1174-
1175-
critical = findings.filter(severity="Critical").count()
1176-
high = findings.filter(severity="High").count()
1177-
medium = findings.filter(severity="Medium").count()
1178-
low = findings.filter(severity="Low").count()
1179-
1180-
return {"Critical": critical,
1181-
"High": high,
1182-
"Medium": medium,
1183-
"Low": low,
1184-
"Total": (critical + high + medium + low)}
1185-
1186-
def get_breadcrumbs(self):
1187-
return [{"title": str(self),
1188-
"url": reverse("view_product", args=(self.id,))}]
1189-
1190-
@property
1191-
def get_product_type(self):
1192-
return self.prod_type if self.prod_type is not None else "unknown"
1193-
1194-
# only used in APIv2 serializers.py, should be deprecated or at least prefetched
1195-
def open_findings_list(self):
1196-
findings = Finding.objects.filter(test__engagement__product=self, active=True).values_list("id", flat=True)
1197-
return list(findings)
1198-
1199-
@property
1200-
def has_jira_configured(self):
1201-
from dojo.jira import services as jira_services # noqa: PLC0415 circular import
1202-
return jira_services.has_configured(self)
1203-
1204-
def violates_sla(self):
1205-
findings = Finding.objects.filter(test__engagement__product=self,
1206-
active=True,
1207-
sla_expiration_date__lt=timezone.now().date())
1208-
return findings.count() > 0
1209-
1210-
1211956
class Tool_Type(models.Model):
1212957
name = models.CharField(max_length=200)
1213958
description = models.CharField(max_length=2000, null=True, blank=True)
@@ -1248,31 +993,6 @@ def __str__(self):
1248993
return self.name
1249994

1250995

1251-
class Product_API_Scan_Configuration(models.Model):
1252-
product = models.ForeignKey(Product, null=False, blank=False, on_delete=models.CASCADE)
1253-
tool_configuration = models.ForeignKey(Tool_Configuration, null=False, blank=False, on_delete=models.CASCADE)
1254-
service_key_1 = models.CharField(max_length=200, null=True, blank=True)
1255-
service_key_2 = models.CharField(max_length=200, null=True, blank=True)
1256-
service_key_3 = models.CharField(max_length=200, null=True, blank=True)
1257-
1258-
def __str__(self):
1259-
name = self.tool_configuration.name
1260-
if self.service_key_1 or self.service_key_2 or self.service_key_3:
1261-
name += f" ({self.details})"
1262-
return name
1263-
1264-
@property
1265-
def details(self):
1266-
details = ""
1267-
if self.service_key_1:
1268-
details += f"{self.service_key_1}"
1269-
if self.service_key_2:
1270-
details += f" | {self.service_key_2}"
1271-
if self.service_key_3:
1272-
details += f" | {self.service_key_3}"
1273-
return details
1274-
1275-
1276996
# declare form here as we can't import forms.py due to circular imports not even locally
1277997
class ToolConfigForm_Admin(forms.ModelForm):
1278998
password = forms.CharField(widget=forms.PasswordInput, required=False)
@@ -3948,7 +3668,6 @@ def __str__(self):
39483668
admin.site.register(Endpoint_Params)
39493669
admin.site.register(Endpoint_Status)
39503670
admin.site.register(Endpoint)
3951-
admin.site.register(Product)
39523671
admin.site.register(UserContactInfo)
39533672
admin.site.register(Notes)
39543673
admin.site.register(Note_Type)
@@ -3986,10 +3705,8 @@ def __str__(self):
39863705

39873706
admin.site.register(Contact)
39883707
admin.site.register(NoteHistory)
3989-
admin.site.register(Product_Line)
39903708
admin.site.register(Report_Type)
39913709
admin.site.register(DojoMeta)
3992-
admin.site.register(Product_API_Scan_Configuration)
39933710
admin.site.register(Development_Environment)
39943711
admin.site.register(Finding_Template)
39953712
admin.site.register(Vulnerability_Id)

dojo/product/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import dojo.product.admin # noqa: F401

dojo/product/admin.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from django.contrib import admin
2+
3+
from dojo.product.models import Product, Product_API_Scan_Configuration, Product_Line
4+
5+
6+
@admin.register(Product_Line)
7+
class ProductLineAdmin(admin.ModelAdmin):
8+
9+
"""Admin support for the Product_Line model."""
10+
11+
12+
@admin.register(Product)
13+
class ProductAdmin(admin.ModelAdmin):
14+
15+
"""Admin support for the Product model."""
16+
17+
18+
@admin.register(Product_API_Scan_Configuration)
19+
class ProductAPIScanConfigurationAdmin(admin.ModelAdmin):
20+
21+
"""Admin support for the Product_API_Scan_Configuration model."""

0 commit comments

Comments
 (0)