diff --git a/Makefile b/Makefile index ac333506..4d8bbe5b 100644 --- a/Makefile +++ b/Makefile @@ -84,6 +84,7 @@ bootstrap: \ build \ run \ migrate \ + demo \ back-i18n-compile \ mails-install \ mails-build \ diff --git a/src/backend/demo/management/commands/create_demo.py b/src/backend/demo/management/commands/create_demo.py new file mode 100644 index 00000000..ab7c558e --- /dev/null +++ b/src/backend/demo/management/commands/create_demo.py @@ -0,0 +1,242 @@ +# ruff: noqa: S311, S106 +"""create_demo management command""" + +import logging +import random +import time +from collections import defaultdict + +from django import db +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError + +from faker import Faker + +from core import models + +fake = Faker() + +logger = logging.getLogger("impress.commands.demo.create_demo") + + +def random_true_with_probability(probability): + """return True with the requested probability, False otherwise.""" + return random.random() < probability + + +class BulkQueue: + """A utility class to create Django model instances in bulk by just pushing to a queue.""" + + BATCH_SIZE = 20000 + + def __init__(self, stdout, *args, **kwargs): + """Define the queue as a dict of lists.""" + self.queue = defaultdict(list) + self.stdout = stdout + + def _bulk_create(self, objects): + """Actually create instances in bulk in the database.""" + if not objects: + return + + objects[0]._meta.model.objects.bulk_create(objects, ignore_conflicts=False) # noqa: SLF001 + # In debug mode, Django keeps query cache which creates a memory leak in this case + db.reset_queries() + self.queue[objects[0]._meta.model.__name__] = [] # noqa: SLF001 + + def push(self, obj): + """Add a model instance to queue to that it gets created in bulk.""" + objects = self.queue[obj._meta.model.__name__] # noqa: SLF001 + objects.append(obj) + if len(objects) > self.BATCH_SIZE: + self._bulk_create(objects) + self.stdout.write(".", ending="") + + def flush(self): + """Flush the queue after creating the remaining model instances.""" + for objects in self.queue.values(): + self._bulk_create(objects) + + +class Timeit: + """A utility context manager/method decorator to time execution.""" + + total_time = 0 + + def __init__(self, stdout, sentence=None): + """Set the sentence to be displayed for timing information.""" + self.sentence = sentence + self.start = None + self.stdout = stdout + + def __call__(self, func): + """Behavior on call for use as a method decorator.""" + + def timeit_wrapper(*args, **kwargs): + """wrapper to trigger/stop the timer before/after function call.""" + self.__enter__() + result = func(*args, **kwargs) + self.__exit__(None, None, None) + return result + + return timeit_wrapper + + def __enter__(self): + """Start timer upon entering context manager.""" + self.start = time.perf_counter() + if self.sentence: + self.stdout.write(self.sentence, ending=".") + + def __exit__(self, exc_type, exc_value, exc_tb): + """Stop timer and display result upon leaving context manager.""" + if exc_type is not None: + raise exc_type(exc_value) + end = time.perf_counter() + elapsed_time = end - self.start + if self.sentence: + self.stdout.write(f" Took {elapsed_time:g} seconds") + + self.__class__.total_time += elapsed_time + return elapsed_time + + +def create_demo(stdout): + """ + Create a database with demo data for developers to work in a realistic environment. + The code is engineered to create a huge number of objects fast. + """ + + queue = BulkQueue(stdout) + + with Timeit(stdout, "Creating Template"): + queue.push( + models.Template( + id="472d0633-20b8-4cb1-998a-1134ade092ba", + title="Demo Template", + description="This is the demo template", + code=""" + +
+ +

Direction
Interministérielle
du numérique

+
+
+
+
La directrice
+

Réf: 1200001

+
+
Paris, le 28/09/2023
+
+
+

Note

+
à Monsieur le Premier Ministre
+
+ +
+
Objet: Generated PDF
+
{{ body }}
+
+
+""", + css=""" +body { + background: white; + font-family: arial +} + +img { + width: 5cm; + margin-left: -0.4cm; +} + +.header { + display: flex; + justify-content: space-between; +} + +.header-title { + text-align: right; + margin-top: 3rem; + font-size: 1.2rem; +} + +.second-row { + display: flex; + justify-content: space-between; + margin-top: 1.2cm; +} + +.ref { + margin-top: 0; +} + +.who { + font-weight: medium; +} + +.date, .ref { + font-size: 12px; +} + +.title, .subtitle { + margin: 0; +} + +.subtitle { + font-weight: normal; +} + +.object { + font-weight: bold; + margin-bottom: 1.2cm; + margin-top: 3rem +} +.body{ + margin-top: 1.5rem +} + +h1 { + font-size: 18px; +} + +h2 { + font-size: 14px; +} + +p { + text-align: justify; + ligne-height: 0.8; +} +""", + is_public=True, + ) + ) + queue.flush() + + +class Command(BaseCommand): + """A management command to create a demo database.""" + + help = __doc__ + + def add_arguments(self, parser): + """Add argument to require forcing execution when not in debug mode.""" + parser.add_argument( + "-f", + "--force", + action="store_true", + default=False, + help="Force command execution despite DEBUG is set to False", + ) + + def handle(self, *args, **options): + """Handling of the management command.""" + if not settings.DEBUG and not options["force"]: + raise CommandError( + ( + "This command is not meant to be used in production environment " + "except you know what you are doing, if so use --force parameter" + ) + ) + + create_demo(self.stdout) diff --git a/src/backend/demo/tests/__init__.py b/src/backend/demo/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/demo/tests/test_commands_create_demo.py b/src/backend/demo/tests/test_commands_create_demo.py new file mode 100644 index 00000000..7eb31ea3 --- /dev/null +++ b/src/backend/demo/tests/test_commands_create_demo.py @@ -0,0 +1,22 @@ +"""Test the `create_demo` management command""" + +from unittest import mock + +from django.core.management import call_command +from django.test import override_settings + +import pytest + +from core import models + + + +pytestmark = pytest.mark.django_db + + +@override_settings(DEBUG=True) +def test_commands_create_demo(): + """The create_demo management command should create objects as expected.""" + call_command("create_demo") + + assert models.Template.objects.count() == 1