Skip to content

Commit 971a746

Browse files
committed
feat: calculate observation and license count from metrics
1 parent f6c0d42 commit 971a746

18 files changed

Lines changed: 423 additions & 31 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 6.0.3 on 2026-03-29 07:24
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("commons", "0020_settings_observation_title_notification_email_to_and_more"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="settings",
15+
name="observation_count_from_metrics",
16+
field=models.BooleanField(default=False),
17+
),
18+
]
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Generated by Django 5.2.9 on 2026-03-13
2+
3+
from django.db import migrations
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("commons", "0021_settings_observation_count_from_metrics"),
10+
("commons", "0022_merge_20260313"),
11+
]
12+
13+
operations = []

backend/application/commons/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,8 @@ class Settings(Model, DirtyFieldsMixin):
231231
help_text="Time margin in seconds for checks of issued at, not before and expiration of OIDC tokens",
232232
)
233233

234+
observation_count_from_metrics = BooleanField(default=False)
235+
234236
def save(self, *args: Any, **kwargs: Any) -> None:
235237
"""
236238
Save object to the database. Removes all other entries if there

backend/application/core/api/views_product.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from application.access_control.queries.api_token import get_api_token_by_id
2929
from application.authorization.services.authorization import user_has_permission_or_403
3030
from application.authorization.services.roles_permissions import Permissions
31+
from application.commons.models import Settings
3132
from application.commons.services.log_message import format_log_message
3233
from application.core.api.filters import (
3334
BranchFilter,
@@ -118,8 +119,12 @@ class ProductGroupViewSet(ModelViewSet):
118119
search_fields = ["name"]
119120

120121
def get_queryset(self) -> QuerySet[Product]:
121-
return get_products(is_product_group=True, with_annotations=True)
122-
122+
settings = Settings.load()
123+
return get_products(
124+
is_product_group=True,
125+
with_observation_annotations=not settings.observation_count_from_metrics,
126+
with_metrics_annotations=settings.observation_count_from_metrics,
127+
)
123128

124129
class ProductGroupNameViewSet(GenericViewSet, ListModelMixin, RetrieveModelMixin):
125130
serializer_class = ProductNameSerializer
@@ -142,8 +147,13 @@ class ProductViewSet(ModelViewSet):
142147
search_fields = ["name"]
143148

144149
def get_queryset(self) -> QuerySet[Product]:
150+
settings = Settings.load()
145151
return (
146-
get_products(is_product_group=False, with_annotations=True)
152+
get_products(
153+
is_product_group=False,
154+
with_observation_annotations=not settings.observation_count_from_metrics,
155+
with_metrics_annotations=settings.observation_count_from_metrics,
156+
)
147157
.select_related("product_group")
148158
.select_related("product_group__license_policy")
149159
.select_related("repository_default_branch")
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Generated by Django 6.0.3 on 2026-03-29 16:24
2+
3+
import django.utils.timezone
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
("core", "0081_product_observation_notification_min_priority_and_more"),
11+
]
12+
13+
operations = [
14+
migrations.AddField(
15+
model_name="product",
16+
name="last_license_change",
17+
field=models.DateTimeField(default=django.utils.timezone.now),
18+
),
19+
]
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Generated by Django 5.2.9 on 2026-03-13
2+
3+
from django.db import migrations
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("core", "0082_product_last_license_change"),
10+
("core", "0083_alter_observation_assessment_status_and_more"),
11+
]
12+
13+
operations = []

backend/application/core/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ class Product(Model, DirtyFieldsMixin): # pylint: disable=too-many-instance-att
105105
issue_tracker_minimum_severity = CharField(max_length=12, choices=Severity.SEVERITY_CHOICES, blank=True)
106106

107107
last_observation_change = DateTimeField(default=timezone.now)
108+
last_license_change = DateTimeField(default=timezone.now)
108109

109110
assessments_need_approval = BooleanField(default=False)
110111
new_observations_in_review = BooleanField(default=False)

backend/application/core/queries/product.py

Lines changed: 162 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
from datetime import date
12
from typing import Optional
23

3-
from django.db.models import Count, Exists, IntegerField, OuterRef, Q, Subquery
4+
from django.db.models import Count, Exists, IntegerField, OuterRef, Q, Subquery, Sum
45
from django.db.models.functions import Coalesce
56
from django.db.models.query import QuerySet
67

@@ -15,17 +16,38 @@
1516
from application.core.types import Severity, Status
1617
from application.licenses.models import License_Component
1718
from application.licenses.types import License_Policy_Evaluation_Result
19+
from application.metrics.models import Product_License_Metrics, Product_Metrics
20+
21+
SEVERITY_MAPPING = {
22+
Severity.SEVERITY_CRITICAL: "active_critical",
23+
Severity.SEVERITY_HIGH: "active_high",
24+
Severity.SEVERITY_MEDIUM: "active_medium",
25+
Severity.SEVERITY_LOW: "active_low",
26+
Severity.SEVERITY_NONE: "active_none",
27+
Severity.SEVERITY_UNKNOWN: "active_unknown",
28+
}
29+
30+
EVALUATION_RESULT_MAPPING = {
31+
License_Policy_Evaluation_Result.RESULT_ALLOWED: "allowed",
32+
License_Policy_Evaluation_Result.RESULT_FORBIDDEN: "forbidden",
33+
License_Policy_Evaluation_Result.RESULT_IGNORED: "ignored",
34+
License_Policy_Evaluation_Result.RESULT_REVIEW_REQUIRED: "review_required",
35+
License_Policy_Evaluation_Result.RESULT_UNKNOWN: "unknown",
36+
}
1837

1938

2039
def get_product_by_id(
21-
product_id: int, is_product_group: bool = None, with_annotations: bool = False
40+
product_id: int,
41+
is_product_group: bool = None,
42+
with_observation_annotations: bool = False,
43+
with_metrics_annotations: bool = False,
2244
) -> Optional[Product]:
2345
try:
2446
if is_product_group is None:
25-
return _add_annotations(Product.objects.all(), False, False).get(id=product_id)
26-
return _add_annotations(Product.objects.all(), is_product_group, with_annotations).get(
27-
id=product_id, is_product_group=is_product_group
28-
)
47+
return _add_annotations(Product.objects.all(), False, False, False).get(id=product_id)
48+
return _add_annotations(
49+
Product.objects.all(), is_product_group, with_observation_annotations, with_metrics_annotations
50+
).get(id=product_id, is_product_group=is_product_group)
2951
except Product.DoesNotExist:
3052
return None
3153

@@ -39,7 +61,9 @@ def get_product_by_name(name: str, is_product_group: bool = None) -> Optional[Pr
3961
return None
4062

4163

42-
def get_products(is_product_group: bool = None, with_annotations: bool = False) -> QuerySet[Product]:
64+
def get_products(
65+
is_product_group: bool = None, with_observation_annotations: bool = False, with_metrics_annotations: bool = False
66+
) -> QuerySet[Product]:
4367
user = get_current_user()
4468

4569
if user is None:
@@ -48,7 +72,7 @@ def get_products(is_product_group: bool = None, with_annotations: bool = False)
4872
products = Product.objects.all()
4973

5074
if is_product_group is not None:
51-
products = _add_annotations(products, is_product_group, with_annotations)
75+
products = _add_annotations(products, is_product_group, with_observation_annotations, with_metrics_annotations)
5276

5377
if not user.is_superuser:
5478
product_members = Product_Member.objects.filter(product=OuterRef("pk"), user=user)
@@ -83,12 +107,18 @@ def get_products(is_product_group: bool = None, with_annotations: bool = False)
83107
return products
84108

85109

86-
def _add_annotations(queryset: QuerySet, is_product_group: bool, with_annotations: bool) -> QuerySet:
87-
if not with_annotations:
110+
def _add_annotations(
111+
queryset: QuerySet, is_product_group: bool, with_observation_annotations: bool, with_metrics_annotations: bool
112+
) -> QuerySet:
113+
if not with_observation_annotations and not with_metrics_annotations:
88114
return queryset
89115

90-
queryset = _add_observation_annotations(queryset, is_product_group)
91-
queryset = _add_license_annotations(queryset, is_product_group)
116+
if with_observation_annotations:
117+
queryset = _add_observation_annotations(queryset, is_product_group)
118+
queryset = _add_license_annotations(queryset, is_product_group)
119+
elif with_metrics_annotations:
120+
queryset = _add_observation_metrics_annotations(queryset, is_product_group)
121+
queryset = _add_license_metrics_annotations(queryset, is_product_group)
92122
return queryset
93123

94124

@@ -136,6 +166,50 @@ def _add_observation_annotations(queryset: QuerySet, is_product_group: bool) ->
136166
return queryset
137167

138168

169+
def _add_observation_metrics_annotations(queryset: QuerySet, is_product_group: bool) -> QuerySet:
170+
subquery_active_critical = (
171+
_get_product_group_metrics_subquery(Severity.SEVERITY_CRITICAL)
172+
if is_product_group
173+
else _get_product_metrics_subquery(Severity.SEVERITY_CRITICAL)
174+
)
175+
subquery_active_high = (
176+
_get_product_group_metrics_subquery(Severity.SEVERITY_HIGH)
177+
if is_product_group
178+
else _get_product_metrics_subquery(Severity.SEVERITY_HIGH)
179+
)
180+
subquery_active_medium = (
181+
_get_product_group_metrics_subquery(Severity.SEVERITY_MEDIUM)
182+
if is_product_group
183+
else _get_product_metrics_subquery(Severity.SEVERITY_MEDIUM)
184+
)
185+
subquery_active_low = (
186+
_get_product_group_metrics_subquery(Severity.SEVERITY_LOW)
187+
if is_product_group
188+
else _get_product_metrics_subquery(Severity.SEVERITY_LOW)
189+
)
190+
subquery_active_none = (
191+
_get_product_group_metrics_subquery(Severity.SEVERITY_NONE)
192+
if is_product_group
193+
else _get_product_metrics_subquery(Severity.SEVERITY_NONE)
194+
)
195+
subquery_active_unknown = (
196+
_get_product_group_metrics_subquery(Severity.SEVERITY_UNKNOWN)
197+
if is_product_group
198+
else _get_product_metrics_subquery(Severity.SEVERITY_UNKNOWN)
199+
)
200+
201+
queryset = queryset.annotate(
202+
active_critical_observation_count=Coalesce(subquery_active_critical, 0),
203+
active_high_observation_count=Coalesce(subquery_active_high, 0),
204+
active_medium_observation_count=Coalesce(subquery_active_medium, 0),
205+
active_low_observation_count=Coalesce(subquery_active_low, 0),
206+
active_none_observation_count=Coalesce(subquery_active_none, 0),
207+
active_unknown_observation_count=Coalesce(subquery_active_unknown, 0),
208+
)
209+
210+
return queryset
211+
212+
139213
def _add_license_annotations(queryset: QuerySet, is_product_group: bool) -> QuerySet:
140214
settings = Settings.load()
141215
if settings.feature_license_management:
@@ -176,6 +250,46 @@ def _add_license_annotations(queryset: QuerySet, is_product_group: bool) -> Quer
176250
return queryset
177251

178252

253+
def _add_license_metrics_annotations(queryset: QuerySet, is_product_group: bool) -> QuerySet:
254+
settings = Settings.load()
255+
if settings.feature_license_management:
256+
subquery_license_forbidden = (
257+
_get_product_group_license_metrics_subquery(License_Policy_Evaluation_Result.RESULT_FORBIDDEN)
258+
if is_product_group
259+
else _get_product_license_metrics_subquery(License_Policy_Evaluation_Result.RESULT_FORBIDDEN)
260+
)
261+
subquery_license_review_required = (
262+
_get_product_group_license_metrics_subquery(License_Policy_Evaluation_Result.RESULT_REVIEW_REQUIRED)
263+
if is_product_group
264+
else _get_product_license_metrics_subquery(License_Policy_Evaluation_Result.RESULT_REVIEW_REQUIRED)
265+
)
266+
subquery_license_unknown = (
267+
_get_product_group_license_metrics_subquery(License_Policy_Evaluation_Result.RESULT_UNKNOWN)
268+
if is_product_group
269+
else _get_product_license_metrics_subquery(License_Policy_Evaluation_Result.RESULT_UNKNOWN)
270+
)
271+
subquery_license_allowed = (
272+
_get_product_group_license_metrics_subquery(License_Policy_Evaluation_Result.RESULT_ALLOWED)
273+
if is_product_group
274+
else _get_product_license_metrics_subquery(License_Policy_Evaluation_Result.RESULT_ALLOWED)
275+
)
276+
subquery_license_ignored = (
277+
_get_product_group_license_metrics_subquery(License_Policy_Evaluation_Result.RESULT_IGNORED)
278+
if is_product_group
279+
else _get_product_license_metrics_subquery(License_Policy_Evaluation_Result.RESULT_IGNORED)
280+
)
281+
282+
queryset = queryset.annotate(
283+
forbidden_licenses_count=Coalesce(subquery_license_forbidden, 0),
284+
review_required_licenses_count=Coalesce(subquery_license_review_required, 0),
285+
unknown_licenses_count=Coalesce(subquery_license_unknown, 0),
286+
allowed_licenses_count=Coalesce(subquery_license_allowed, 0),
287+
ignored_licenses_count=Coalesce(subquery_license_ignored, 0),
288+
)
289+
290+
return queryset
291+
292+
179293
def _get_product_observation_subquery(severity: str) -> Subquery:
180294
branch_filter = Q(branch__is_default_branch=True) | (
181295
Q(branch__isnull=True) & Q(product__repository_default_branch__isnull=True)
@@ -216,6 +330,23 @@ def _get_product_group_observation_subquery(severity: str) -> Subquery:
216330
)
217331

218332

333+
def _get_product_metrics_subquery(severity: str) -> Subquery:
334+
return Subquery(
335+
Product_Metrics.objects.filter(product=OuterRef("pk"), date=date.today()).values(SEVERITY_MAPPING[severity]),
336+
output_field=IntegerField(),
337+
)
338+
339+
340+
def _get_product_group_metrics_subquery(severity: str) -> Subquery:
341+
return Subquery(
342+
Product_Metrics.objects.filter(product__product_group=OuterRef("pk"), date=date.today())
343+
.values("product__product_group")
344+
.annotate(total=Sum(SEVERITY_MAPPING[severity]))
345+
.values("total"),
346+
output_field=IntegerField(),
347+
)
348+
349+
219350
def _get_product_license_subquery(evaluation_result: str) -> Subquery:
220351
branch_filter = Q(branch__is_default_branch=True) | (
221352
Q(branch__isnull=True) & Q(product__repository_default_branch__isnull=True)
@@ -252,3 +383,22 @@ def _get_product_group_license_subquery(evaluation_result: str) -> Subquery:
252383
.values("count"),
253384
output_field=IntegerField(),
254385
)
386+
387+
388+
def _get_product_license_metrics_subquery(evaluation_result: str) -> Subquery:
389+
return Subquery(
390+
Product_License_Metrics.objects.filter(product=OuterRef("pk"), date=date.today()).values(
391+
EVALUATION_RESULT_MAPPING[evaluation_result]
392+
),
393+
output_field=IntegerField(),
394+
)
395+
396+
397+
def _get_product_group_license_metrics_subquery(evaluation_result: str) -> Subquery:
398+
return Subquery(
399+
Product_License_Metrics.objects.filter(product__product_group=OuterRef("pk"), date=date.today())
400+
.values("product__product_group")
401+
.annotate(total=Sum(EVALUATION_RESULT_MAPPING[evaluation_result]))
402+
.values("total"),
403+
output_field=IntegerField(),
404+
)

backend/application/core/services/security_gate.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,9 @@ def _calculate_active_product_security_gate(product: Product) -> bool:
9393
product.security_gate_threshold_unknown if product.security_gate_threshold_unknown else 0
9494
)
9595

96-
annotated_product = get_product_by_id(product_id=product.pk, is_product_group=False, with_annotations=True)
96+
annotated_product = get_product_by_id(
97+
product_id=product.pk, is_product_group=False, with_observation_annotations=True
98+
)
9799
if not annotated_product:
98100
raise ValueError(f"Product {product.pk} not found while calculating security gate.")
99101

@@ -123,7 +125,9 @@ def _calculate_active_product_security_gate(product: Product) -> bool:
123125
def _calculate_active_config_security_gate(product: Product) -> bool:
124126
settings = Settings.load()
125127

126-
annotated_product = get_product_by_id(product_id=product.pk, is_product_group=False, with_annotations=True)
128+
annotated_product = get_product_by_id(
129+
product_id=product.pk, is_product_group=False, with_observation_annotations=True
130+
)
127131
if not annotated_product:
128132
raise ValueError(f"Product {product.pk} not found while calculating security gate.")
129133

backend/application/import_observations/services/import_observations.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,9 @@ def process_license_components( # pylint: disable=too-many-statements disable=t
536536
vulnerability_check.last_import_licenses_updated = len(components_updated)
537537
vulnerability_check.last_import_licenses_deleted = components_deleted
538538

539+
vulnerability_check.product.last_license_change = timezone.now()
540+
vulnerability_check.product.save()
541+
539542
if scanner:
540543
vulnerability_check.scanner = scanner
541544
vulnerability_check.save()

0 commit comments

Comments
 (0)