Skip to content

Commit b2bf761

Browse files
committed
feat: management command to delete a product
1 parent 005790f commit b2bf761

2 files changed

Lines changed: 210 additions & 0 deletions

File tree

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import logging
2+
from argparse import ArgumentParser
3+
from typing import Any
4+
5+
from django.core.management.base import BaseCommand, CommandError
6+
from django.db import transaction
7+
from django.db.models import Model
8+
from django.db.models.signals import post_delete
9+
10+
from application.core.models import Observation, Product
11+
from application.core.signals import observation_post_delete
12+
from application.licenses.models import License_Component
13+
from application.licenses.signals import license_component_post_delete
14+
15+
logger = logging.getLogger("secobserve.core")
16+
17+
18+
class Command(BaseCommand):
19+
20+
help = "Delete a product with many observations in batches"
21+
22+
def add_arguments(self, parser: ArgumentParser) -> None:
23+
parser.add_argument("product_name", help="Exact name of the product")
24+
parser.add_argument("--batch-size", type=int, default=1000, help="Number of objects deleted per transaction")
25+
parser.add_argument("--no-input", action="store_true", help="Do not ask for confirmation")
26+
27+
def handle(self, *args: Any, **options: Any) -> None:
28+
product_name = options["product_name"]
29+
batch_size = options["batch_size"]
30+
if batch_size < 1:
31+
raise CommandError("--batch-size must be at least 1")
32+
33+
product = Product.objects.filter(name=product_name).first()
34+
if not product:
35+
raise CommandError(f"Product {product_name} not found")
36+
if product.is_product_group:
37+
raise CommandError(f"{product_name} is a product group, delete the products of the group first")
38+
39+
observation_count = Observation.objects.filter(product=product).count()
40+
license_component_count = License_Component.objects.filter(product=product).count()
41+
logger.info(
42+
"Product %s has %s observations and %s license components",
43+
product_name,
44+
observation_count,
45+
license_component_count,
46+
)
47+
48+
if not options["no_input"]:
49+
answer = input(f"Delete product {product_name}? [y/N] ")
50+
if answer.strip().lower() != "y":
51+
logger.info("... aborted")
52+
return
53+
54+
# The receivers are not needed when the whole product is deleted and would slow down the deletion
55+
post_delete.disconnect(observation_post_delete, sender=Observation)
56+
post_delete.disconnect(license_component_post_delete, sender=License_Component)
57+
try:
58+
self._delete_in_batches(Observation, product, batch_size, observation_count)
59+
self._delete_in_batches(License_Component, product, batch_size, license_component_count)
60+
61+
with transaction.atomic():
62+
product.delete()
63+
finally:
64+
post_delete.connect(observation_post_delete, sender=Observation)
65+
post_delete.connect(license_component_post_delete, sender=License_Component)
66+
67+
logger.info("... product %s deleted", product_name)
68+
69+
def _delete_in_batches(self, model: type[Model], product: Product, batch_size: int, total: int) -> None:
70+
name = model.__name__
71+
deleted = 0
72+
while True:
73+
ids = list(
74+
model.objects.filter(product=product) # type: ignore[attr-defined]
75+
.order_by("id")
76+
.values_list("id", flat=True)[:batch_size]
77+
)
78+
if not ids:
79+
break
80+
with transaction.atomic():
81+
model.objects.filter(id__in=ids).delete() # type: ignore[attr-defined]
82+
deleted += len(ids)
83+
logger.info("... %s / %s %s deleted", deleted, total, name)
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
from datetime import datetime, timezone
2+
from unittest.mock import patch
3+
4+
from django.core.management import call_command
5+
from django.core.management.base import CommandError
6+
from django.test import TestCase
7+
from django.utils import timezone as django_timezone
8+
9+
from application.access_control.models import User
10+
from application.core.models import (
11+
Branch,
12+
Evidence,
13+
Observation,
14+
Observation_Log,
15+
Product,
16+
)
17+
from application.import_observations.models import Parser
18+
from application.licenses.models import License_Component
19+
20+
21+
class TestDeleteProductCommand(TestCase):
22+
"""DB-backed tests for the `delete_product` management command."""
23+
24+
def setUp(self) -> None:
25+
self.user = User.objects.create(username="delete-product@example.com")
26+
self.parser = Parser.objects.create(name="parser_1")
27+
self.product = Product.objects.create(name="product_1")
28+
self.other_product = Product.objects.create(name="product_2")
29+
self.branch = Branch.objects.create(product=self.product, name="main")
30+
31+
for i in range(3):
32+
observation = self._create_observation(self.product, f"observation_{i}")
33+
Observation_Log.objects.create(observation=observation, user=self.user, comment="comment")
34+
Evidence.objects.create(observation=observation, name="evidence", evidence="evidence")
35+
self._create_license_component(self.product)
36+
37+
self.other_observation = self._create_observation(self.other_product, "other_observation")
38+
self.other_license_component = self._create_license_component(self.other_product)
39+
40+
def _create_observation(self, product: Product, title: str) -> Observation:
41+
return Observation.objects.create(
42+
title=title,
43+
product=product,
44+
parser=self.parser,
45+
numerical_severity=1,
46+
import_last_seen=django_timezone.now(),
47+
)
48+
49+
def _create_license_component(self, product: Product) -> License_Component:
50+
return License_Component.objects.create(
51+
product=product,
52+
component_name="component",
53+
numerical_evaluation_result=3,
54+
)
55+
56+
def test_product_deleted(self) -> None:
57+
call_command("delete_product", "product_1", "--no-input", "--batch-size", "2")
58+
59+
self.assertFalse(Product.objects.filter(name="product_1").exists())
60+
self.assertFalse(Observation.objects.filter(product=self.product).exists())
61+
self.assertFalse(Observation_Log.objects.filter(observation__product=self.product).exists())
62+
self.assertFalse(Evidence.objects.filter(observation__product=self.product).exists())
63+
self.assertFalse(License_Component.objects.filter(product=self.product).exists())
64+
self.assertFalse(Branch.objects.filter(product=self.product).exists())
65+
66+
self.assertTrue(Product.objects.filter(name="product_2").exists())
67+
self.assertTrue(Observation.objects.filter(pk=self.other_observation.pk).exists())
68+
self.assertTrue(License_Component.objects.filter(pk=self.other_license_component.pk).exists())
69+
70+
def test_product_not_found(self) -> None:
71+
with self.assertRaisesMessage(CommandError, "Product unknown not found"):
72+
call_command("delete_product", "unknown", "--no-input")
73+
74+
def test_product_group(self) -> None:
75+
product_group = Product.objects.create(name="product_group", is_product_group=True)
76+
self.product.product_group = product_group
77+
self.product.save()
78+
79+
with self.assertRaisesMessage(CommandError, "product_group is a product group"):
80+
call_command("delete_product", "product_group", "--no-input")
81+
82+
self.assertTrue(Product.objects.filter(name="product_group").exists())
83+
self.assertEqual(3, Observation.objects.filter(product=self.product).count())
84+
85+
def test_invalid_batch_size(self) -> None:
86+
with self.assertRaisesMessage(CommandError, "--batch-size must be at least 1"):
87+
call_command("delete_product", "product_1", "--no-input", "--batch-size", "0")
88+
89+
@patch("builtins.input", return_value="n")
90+
def test_confirmation_declined(self, mock_input) -> None:
91+
call_command("delete_product", "product_1")
92+
93+
mock_input.assert_called_once()
94+
self.assertTrue(Product.objects.filter(name="product_1").exists())
95+
self.assertEqual(3, Observation.objects.filter(product=self.product).count())
96+
97+
@patch("builtins.input", return_value="y")
98+
def test_confirmation_accepted(self, mock_input) -> None:
99+
call_command("delete_product", "product_1")
100+
101+
mock_input.assert_called_once()
102+
self.assertFalse(Product.objects.filter(name="product_1").exists())
103+
104+
@patch("application.core.signals.push_deleted_observation_to_issue_tracker")
105+
def test_no_issue_tracker_push(self, mock_push) -> None:
106+
self.product.issue_tracker_active = True
107+
self.product.save()
108+
Observation.objects.filter(product=self.product).update(issue_tracker_issue_id="123")
109+
110+
with self.captureOnCommitCallbacks(execute=True):
111+
call_command("delete_product", "product_1", "--no-input")
112+
113+
mock_push.assert_not_called()
114+
115+
@patch("application.core.signals.push_deleted_observation_to_issue_tracker")
116+
def test_receivers_reconnected(self, mock_push) -> None:
117+
call_command("delete_product", "product_1", "--no-input")
118+
119+
with self.captureOnCommitCallbacks(execute=True):
120+
self.other_observation.delete()
121+
mock_push.assert_called_once()
122+
123+
old_timestamp = datetime(2020, 1, 1, tzinfo=timezone.utc)
124+
Product.objects.filter(pk=self.other_product.pk).update(last_license_change=old_timestamp)
125+
License_Component.objects.get(pk=self.other_license_component.pk).delete()
126+
self.other_product.refresh_from_db()
127+
self.assertGreater(self.other_product.last_license_change, old_timestamp)

0 commit comments

Comments
 (0)