Skip to content

Commit 880b188

Browse files
Merge pull request #52 from MEHRSHAD-MIRSHEKARY/fix/generate-data-command
Fix/generate data command
2 parents 211eaa4 + 44a272d commit 880b188

File tree

4 files changed

+188
-35
lines changed

4 files changed

+188
-35
lines changed

data_generator/management/commands/generate_data.py

Lines changed: 135 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import logging
12
import sys
23
from random import choice
3-
from typing import Any, Dict, List, Optional, TextIO
4+
from typing import Any, Dict, List, Optional, TextIO, Tuple
45

56
from django.apps import apps
67
from django.core.management.base import BaseCommand
@@ -9,6 +10,8 @@
910
from data_generator.generators.data_generator import model_data_generator
1011
from data_generator.settings.conf import config
1112

13+
logger = logging.getLogger(__name__)
14+
1215

1316
class Command(BaseCommand):
1417
"""Management command to generate fake data for models within a Django
@@ -147,60 +150,165 @@ def _get_target_models(self) -> List[Any]:
147150
148151
"""
149152
return [
150-
model
151-
for model in apps.get_models()
152-
if f"{model._meta.app_label}.{model.__name__}"
153-
not in config.exclude_models + self.django_models
154-
and model._meta.app_label not in config.exclude_apps
153+
model for model in apps.get_models() if not self._is_model_excluded(model)
155154
]
156155

157156
def generate_data_for_model(self, model: Any) -> None:
158157
"""Generate and bulk-create data instances for a specific model.
159158
160159
Args:
161160
----
162-
model (Model): The Django model class to generate data for.
161+
model: The Django model class to generate data for.
163162
164163
"""
165-
model_name = model.__name__
164+
model_name = f"{model._meta.app_label}.{model.__name__}"
166165
if model in self.processed_models or model_name in self.django_models:
167166
return
168167

168+
self._ensure_related_models_generated(model)
169169
batch_size = max(100, self.num_records // 10)
170170

171171
self.stdout.write(f"\nGenerating data for model: {model_name}")
172+
172173
unique_values: Dict = {}
173-
self._display_progress(0, self.num_records, model_name)
174+
instances = []
175+
failed = False
176+
174177
for i in range(0, self.num_records, batch_size):
175-
instances = [
176-
model(**self._generate_model_data(model, unique_values))
177-
for _ in range(min(batch_size, self.num_records - i))
178-
]
178+
batch_instances, batch_failed = self._generate_batch_instances(
179+
model, unique_values, min(batch_size, self.num_records - i)
180+
)
181+
if batch_failed:
182+
failed = True
183+
break
184+
instances.extend(batch_instances)
185+
186+
self._display_progress(
187+
i + len(batch_instances), self.num_records, model_name
188+
)
189+
190+
if failed:
191+
# Log error after progress bar to avoid disruption
192+
logger.error(
193+
"Failed to generate data for model '%s': "
194+
"No instances found for related model(s), which are required for data generation. "
195+
"Skipping data generation for this model.\n"
196+
"Hint: Ensure at least one instance exist for related models or remove them "
197+
"from 'DATA_GENERATOR_EXCLUDE_MODELS' or 'DATA_GENERATOR_EXCLUDE_APPS' in settings.",
198+
model_name,
199+
)
200+
self.stdout.write(
201+
self.style.ERROR(
202+
f"Skipped data generation for {model_name} due to missing related data.\n"
203+
"Hint: Create at least one instance for related models or remove them from excluded settings."
204+
)
205+
)
206+
return
207+
208+
if instances:
179209
model._default_manager.bulk_create(instances, ignore_conflicts=True)
180-
self._display_progress(i + len(instances), self.num_records, model_name)
181210

211+
self._display_progress(self.num_records, self.num_records, model_name)
182212
self.stdout.write("\nDone!")
183213

184-
# Mark the model as processed
185214
self.processed_models.add(model)
186-
# Clear the related instances cache after generating data
187215
self.related_instance_cache.clear()
188216

189-
def _generate_model_data(self, model: Any, unique_values: Dict) -> Dict[str, Any]:
217+
def _generate_batch_instances(
218+
self, model: Any, unique_values: Dict, batch_size: int
219+
) -> Tuple[List[Any], bool]:
220+
"""Generate a batch of model instances.
221+
222+
Args:
223+
----
224+
model: The Django model class to generate data for.
225+
unique_values: Dictionary to track unique field values.
226+
batch_size: Number of instances to generate in this batch.
227+
228+
Returns:
229+
-------
230+
Tuple containing:
231+
- List of generated instances.
232+
- Boolean indicating if generation failed.
233+
234+
"""
235+
instances = []
236+
for _ in range(batch_size):
237+
instance_data = self._generate_model_data(model, unique_values)
238+
if instance_data is None:
239+
return [], True
240+
instances.append(model(**instance_data))
241+
return instances, False
242+
243+
def _ensure_related_models_generated(self, model: Any) -> None:
244+
"""Ensure all related models have data generated before processing the
245+
current model.
246+
247+
This method recursively checks the fields of the given model for relationships
248+
(e.g., ForeignKey, OneToOneField) and generates data for any related models that
249+
have not yet been processed. This ensures that dependent data is available before
250+
generating data for the current model, avoiding issues with missing related instances.
251+
252+
Args:
253+
----
254+
model: The Django model class to check for related models.
255+
256+
Returns:
257+
-------
258+
None
259+
260+
"""
261+
for field in model._meta.fields:
262+
if field.is_relation:
263+
related_model = field.related_model
264+
# Always generate data for OneToOneField relations, even if excluded
265+
if field.one_to_one and related_model not in self.processed_models:
266+
self.generate_data_for_model(related_model)
267+
# For other relations, only generate if not excluded and not processed
268+
elif (
269+
not self._is_model_excluded(related_model)
270+
and related_model not in self.processed_models
271+
):
272+
self.generate_data_for_model(related_model)
273+
274+
print(self._is_model_excluded(related_model))
275+
276+
def _is_model_excluded(self, model: Any) -> bool:
277+
"""Check if a model or its app is excluded from data generation.
278+
279+
Args:
280+
----
281+
model: The Django model class to check.
282+
283+
Returns:
284+
-------
285+
bool: True if the model or its app is excluded, False otherwise.
286+
287+
"""
288+
model_name = f"{model._meta.app_label}.{model.__name__}"
289+
return (
290+
model_name in config.exclude_models + self.django_models
291+
or model._meta.app_label in config.exclude_apps
292+
)
293+
294+
def _generate_model_data(
295+
self, model: Any, unique_values: Dict
296+
) -> Optional[Dict[str, Any]]:
190297
"""Generate a dictionary of field data for a model instance, handling
191298
unique and related fields.
192299
193300
Args:
194301
----
195-
model (Model): The Django model for which data is generated.
302+
model: The Django model for which data is generated.
303+
unique_values: Dictionary to track unique field values.
196304
197305
Returns:
198306
-------
199-
Dict[str, Any]: A dictionary of field values for model instantiation.
307+
Dict[str, Any]: A dictionary of field values for model instantiation, or None if data cannot be generated.
200308
201309
"""
202310
data: Dict = {}
203-
model_name = model.__name__
311+
model_name = f"{model._meta.app_label}.{model.__name__}"
204312

205313
for field in model._meta.fields:
206314
field_name = field.name
@@ -218,14 +326,16 @@ def _generate_model_data(self, model: Any, unique_values: Dict) -> Dict[str, Any
218326
generator = model_data_generator.field_generators.get(type(field).__name__)
219327
if field.is_relation:
220328
related_model = field.related_model
221-
self.generate_data_for_model(related_model)
222329
if related_model not in self.related_instance_cache:
223330
self.related_instance_cache[related_model] = list(
224331
related_model._default_manager.order_by("-id").values_list(
225332
"id", flat=True
226333
)[: self.num_records]
227334
)
228335

336+
if not self.related_instance_cache[related_model]:
337+
return None
338+
229339
rel_id_field = f"{field.name}_id"
230340
if field.one_to_one:
231341
data[rel_id_field] = self.get_unique_rel_instance(related_model)
@@ -274,11 +384,9 @@ def get_unique_rel_instance(self, model: Any) -> Optional[int]:
274384
Optional[int]: A unique instance ID or None if no instances exist.
275385
276386
"""
277-
if self.related_instance_cache[model]:
278-
instance_id = choice(self.related_instance_cache[model])
279-
self.related_instance_cache[model].remove(instance_id)
280-
return instance_id
281-
return None
387+
instance_id = choice(self.related_instance_cache[model])
388+
self.related_instance_cache[model].remove(instance_id)
389+
return instance_id
282390

283391
def _confirm_models(self, related_models: List[Any]) -> bool:
284392
"""Display the list of models for the user to review and ask for
@@ -327,7 +435,7 @@ def _confirm_proceed(self) -> bool:
327435
"""
328436
while True:
329437
user_input = (
330-
input("\nType 'y' to proceed or 'n' to cancel the operation:")
438+
input("\nType 'y' to proceed or 'n' to cancel the operation: ")
331439
.strip()
332440
.lower()
333441
)

data_generator/tests/commands/test_generate_data.py

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from unittest.mock import MagicMock, patch
44

55
import pytest
6+
from django.contrib.admin.models import LogEntry
67
from django.contrib.auth.models import User
78
from django.core.management import call_command
89
from django.db.models import CASCADE, BooleanField, ForeignKey, OneToOneField
@@ -251,10 +252,12 @@ def test_generate_data_with_mock_model(
251252
# Create a mock model-like object
252253
mock_model = MagicMock()
253254
mock_model.__name__ = "MockModel"
255+
mock_model._meta.app_label = "app"
254256

255257
# Related model mock
256258
rel_model = MagicMock()
257259
rel_model.__name__ = "MockRelModel"
260+
rel_model._meta.app_label = "app"
258261
rel_model._meta.fields = [BooleanField(default=True)]
259262
rel_model._default_manager.bulk_create = MagicMock()
260263
rel_model._default_manager.order_by.return_value.values_list.return_value = [
@@ -264,13 +267,13 @@ def test_generate_data_with_mock_model(
264267
mock_model._meta.fields = [OneToOneField(rel_model, on_delete=CASCADE)]
265268
mock_model._default_manager.bulk_create = MagicMock()
266269

267-
mock_get_models.return_value = [mock_model]
270+
mock_get_models.return_value = [mock_model, LogEntry]
268271

269272
out = StringIO()
270273
call_command("generate_data", num_records=2, stdout=out)
271274

272-
assert "Generating data for model: MockModel" in out.getvalue()
273-
assert "Generating data for model: MockRelModel" in out.getvalue()
275+
assert "Generating data for model: app.MockModel" in out.getvalue()
276+
assert "Generating data for model: app.MockRelModel" in out.getvalue()
274277
assert "Done!" in out.getvalue()
275278

276279
@patch("builtins.input", side_effect=["y"])
@@ -292,21 +295,63 @@ def test_generate_data_with_foreign_key(
292295
# Create a mock model-like object
293296
mock_model = MagicMock()
294297
mock_model.__name__ = "MockModel"
298+
mock_model._meta.app_label = "app"
295299

296300
# Related model mock
297301
rel_model = MagicMock()
298302
rel_model.__name__ = "MockRelModel"
303+
rel_model._meta.app_label = "app"
299304
rel_model._meta.fields = [BooleanField(default=True)]
300305
rel_model._default_manager.bulk_create = MagicMock()
301306

302-
mock_model._meta.fields = [ForeignKey(rel_model, on_delete=CASCADE)]
307+
mock_model._meta.fields = [
308+
ForeignKey(rel_model, on_delete=CASCADE),
309+
ForeignKey(User, on_delete=CASCADE),
310+
311+
]
312+
mock_model._default_manager.bulk_create = MagicMock()
313+
314+
mock_get_models.return_value = [mock_model, rel_model]
315+
316+
out = StringIO()
317+
call_command("generate_data", num_records=1, stdout=out)
318+
319+
assert "Generating data for model: app.MockModel" in out.getvalue()
320+
assert "Generating data for model: app.MockRelModel" in out.getvalue()
321+
assert "Done!" in out.getvalue()
322+
323+
@patch("builtins.input", side_effect=["y"])
324+
@patch("django.apps.apps.get_models")
325+
def test_generate_data_with_foreign_key_user_model(
326+
self, mock_get_models: MagicMock, mock_input: MagicMock
327+
) -> None:
328+
"""
329+
Test data generation with User model object that handles ForeignKey relation.
330+
331+
Args:
332+
----
333+
None
334+
335+
Asserts:
336+
-------
337+
The output should indicate that data generation has started and completed for both models.
338+
"""
339+
# Create a mock model-like object
340+
mock_model = MagicMock()
341+
mock_model.__name__ = "MockModel"
342+
mock_model._meta.app_label = "app"
343+
344+
mock_model._meta.fields = [
345+
ForeignKey(User, on_delete=CASCADE),
346+
347+
]
303348
mock_model._default_manager.bulk_create = MagicMock()
304349

305350
mock_get_models.return_value = [mock_model]
306351

307352
out = StringIO()
308353
call_command("generate_data", num_records=1, stdout=out)
309354

310-
assert "Generating data for model: MockModel" in out.getvalue()
311-
assert "Generating data for model: MockRelModel" in out.getvalue()
355+
assert "Generating data for model: app.MockModel" in out.getvalue()
356+
assert "Generating data for model: auth.User" in out.getvalue()
312357
assert "Done!" in out.getvalue()

data_generator/tests/settings/test_check.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
]
1414

1515

16-
class TestCheckNotificationSettings:
16+
class TestCheckDataGeneratorSettings:
1717
@patch("data_generator.settings.check.config")
1818
def test_valid_settings(self, mock_config: MagicMock) -> None:
1919
"""

data_generator/tests/setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ def configure_django_settings() -> None:
7676
},
7777
},
7878
],
79-
DATA_GENERATOR_CUSTOM_FIELD_VALUES={"User": {"first_name": "somebody"}},
79+
DATA_GENERATOR_CUSTOM_FIELD_VALUES={"auth.User": {"first_name": "somebody"}},
8080
LANGUAGE_CODE="en-us",
8181
TIME_ZONE="UTC",
8282
USE_I18N=True,

0 commit comments

Comments
 (0)