.. _create_testdata: .. index:: single: Testdaten single: Management-Commando single: manage.py single: Faker single: Factory-Boy single: argparse Testdaten generieren ************************************* Es macht Sinn, schon in der Entwicklungsphase mit größeren Datenmengen zu arbeiten. Wir wollen mit 10 verschiedenen Usern arbeiten, die Events in Kategorien anlegen. Später Sollen diese Testuser für bestehende Events auch noch Reviews schreiben können. Damit wir diese Testuser nicht alle händisch eintragen können, schreiben wir uns ein eigenes Management-Command, dass uns Testuser erstellt. Verzeichnis für ein Managment-Commando anlegen ------------------------------------------------ Ein Management-Commando in Django wird so aufgerufen: .. code-block:: bash python manage.py create_user Dieses Kommando müssen wir allerdings erstmal entwickeln. Dazu erstellen wir uns im Verzeichnis ``user`` folgende Ordner- und Dateistruktur. Die Datei ``create_user.py`` ist erstmal völlig leer. .. code-block:: bash ├───management │ └───commands │ │ create_user.py ein erstes Commando -------------------- Jedes eigene Kommando erbt von BaseCommand. Die Methode ``handler`` wird überschrieben und mit eigenem Code angereichert. Wir schreiben nun folgenden Code in die Datei ``event_manager/user/management/commands/create_user.py``. .. code-block:: python from django.core.management.base import BaseCommand from django.db import transaction class Command(BaseCommand): def handle(self, *args, **options): print("Hello World!") Jetzt können wir unser Kommando mal testweise ausführen. Dazu starten wir manage.py mit dem Namen der Datei, also ``create_user``, allerdings ohne Dateisuffix ``.py`` .. code-block:: bash python manage.py create_user Das war doch einfach! Jetzt können wir loslegen, um Testuser im System anzulegen. Dazu müssen wir erstmal ``factory-boy`` installieren. Factory-Boy hilft uns dabei, möglichst realitätsnahe Zufallsdaten zu erstellen, die wir dann als Testdaten nutzen können. .. admonition:: Management Commands für Skripte und Tools Django's Management Commands sind der ideale Ort, um kleine Helferprogramme zu starten wie Imports, starten von asynchronen Jobmanagern und vielem mehr. Da ``BaseCommand`` auch das äußerst beliebte argparse [#argparse]_ aus der Python Standard-Bibliothek implementiert, kann man die Commands auch mit Argumenten aufrufen, wie wir unten gleich sehen werden. Das Projekt kann dann nahezu vollständig über ``manage.py`` gesteuert werden. Die User-Fabrik -------------------- .. admonition:: Factory-Boy Wir werden ``Factory Boy`` verwenden, um Dummy-Daten für unsere App zu generieren. Bei Factory Boy handelt es sich um eine Bibliothek, die für automatisierte Tests entwickelt wurde, aber auch für diesen Anwendungsfall gut funktioniert. Factory Boy kann leicht konfiguriert werden, um zufällige, aber realistische Daten wie Namen, E-Mails und Absätze zu generieren, da intern die ``Faker-Bibliothek`` verwendet wird. Wir erstellen sogenannte ``Factories``, die wir als Django-Model-Klasse anlegen. So kann man sich zum Beispiel eine ``UserFactory`` erstellen, die Testdaten für einen User erstellt. Diese ``UserFactory`` werden wir später auch zum Erstellen von Testusern bei den Integrationstests mit dem Unittest-Client verwenden. Eine Übersicht aller Standard-Faker findet sich hier: ``_ ``Faker`` bzw. dessen Provider bieten die Erstellung von allen möglichen Testdaten. Sogar die Lokalisierung auf eine Sprache ist möglich, wenn man zum Beispiel nur deutsche Usernamen generieren möchte. Wir nutzen den default-Case, und der ist englisch. Installieren wir nun ``factory-boy`` in unser Environment. Wir fügen in unsere ``requirements-dev.in`` Datei den Eintrag ``factory-boy`` hinzu, kompilieren die Datei und erstellen damit unsere ``requirements-dev.txt``. In einem weiteren Schritt installieren wir die nötigen Pakete mit ``pip-compile``. In die ``requirements-dev.in`` fügen wir folgenden Eintrag ein: .. code-block:: shell -c requirements.txt factory-boy Um aus den in-Dateien die endgültigen Requierements.txt-Dateien zu machen, müssen diese wieder compiliert werden. .. code-block:: shell (eventenv) pip-compile requirements-dev.in Danach wirden die neuen Einträge installiert (darauf achten, auch die requirements.txt zu syncen, da sonst Pakete fehlen würden). .. code-block:: shell (eventenv) pip-sync requirements.txt requirements-dev.txt Um Dummy-Daten zu erstellen, müssen wir eine sogenannte ``Fabrik`` erstellen, die uns Dummy-Daten generiert. Dazu legen wir die Datei ``event_manager/user/factories.py`` an und füllen Sie mit folgendem Inhalt: .. code-block:: python import factory from django.contrib.auth import get_user_model from django.contrib.auth.hashers import make_password class UserFactory(factory.django.DjangoModelFactory): """Eine Fabrik-Klasse um User zu generieren.""" class Meta: model = get_user_model() username = factory.Faker("user_name", locale="de_DE") email = factory.Faker("email", locale="de_DE") password = factory.LazyFunction(lambda: make_password("abc") Ein User hat drei Attribute, die zwingend erforderlich sind, um ihn später auch nutzen zu können: **email-Adresse, Passwort und einen Usernamen**. Die User-Namen sollen einen deutschen Touch haben, deshalb nutzen wir hier ``locale="de_DE"``. Gleiches gilt für die email-Adressen. Factory-Boy implementiert einige Hilfsmodelle für verschiedene ORMs, neben ``SQL-Alchemy`` auch für Django. Um einen reibungslosen Ablauf mit Django zu gewährleisten, muss unsere User-Fabrik also von ``factory.django.DjangoModelFactory`` erben. In der ``Meta-Klasse`` der ``UserFactory`` beziehen wir uns auf das aktuelle in den Settings gewählte User-Model ``get_user_model()``. Die Meta-Klasse kennen wir schon von dem ``Event-Model`` und werden sie später auch bei der Formularverarbeitung wiedersehen. Wir wollen nun email-Daten ``faken``, d.h. randomisiert erstellen, sowie jedem Testuser zu Testzwecken das (gut zu merkende) Passwort **abc** zuweisen (damit wir uns später auch über diesen User einloggen können, um Events zu verfassen oder Reviews zu schreiben). Da das Passwort nur gehasht in die Datenbank darf, führen wir noch ``make_password`` aus ``django.contrib.auth.hashers`` aus. Mehr dazu in der Doku von Factory-Boy: ``_ Das Commando ausbauen ---------------------- Wir ersetzen jetzt den Inhalt der Datei ``event_manager/user/management/commands/create_user.py`` mit folgenden Inhalt: .. code-block:: python from django.core.management.base import BaseCommand from django.db import transaction from django.contrib.auth import get_user_model from user.factories import UserFactory User = get_user_model() class Command(BaseCommand): def add_arguments(self, parser) -> None: """Die Argumente für die Anzahl der User festlegen.""" parser.add_argument("-n", "--number", type=int, help="Amount of users to be generated", required=True) parser.epilog = "Usage: python manage.py create_user -n 10" @transaction.atomic def handle(self, *args, **options): # delete all users except admin user User.objects.exclude(username="admin").delete() # das geparste Kommandozeilenargument -n number = options.get("number") if number and number > 0: for _ in range(number): p = UserFactory() print(f"=> {p}") print(f"{number} User erfolgreich angelegt!") Zuerst importieren wir die nötigen Module: unsere eben erstellte **Fabrikklasse, das BaseCommand sowie die transaction und eine Helferfunktion zum Referenzieren auf das aktuelle UserModel**. Unsere Klasse ``Command`` startet mit der Methode ``add_arguments``, die uns erlaubt, Argumente für einen Kommandozeilenparser zu spezifizieren. Wir können das Management-Kommando später mit ``python manage.py create_user -n 20`` starten und damit 20 User erstellen. Django implementiert für diesen Zweck das brilliante ``argparse``-Modul aus der Python Standard-Bibliothek. In der Methode ``handle`` löschen wir alle bestehenden User-Objekte bis auf das Objekt mit dem Namen ``admin``, also dem Adminuser. Danach legen wir die mit der übergebenen Anzahl festgelegten User an. Für die User in ``usernames`` werden jetzt die entsprechenden Testuser angelegt. Zuvor werden alle User bis auf den Adminuser gelöscht. Die Befehlsfolge implementieren wir hier als ``Transaktion``. .. admonition:: Was sind Transaktionen? In der Datenbankentwicklung bezeichnet eine ``Transaktion`` eine Gruppe von Teilaktionen, die in einer oder mehreren Datenbanktabellen erfolgreich ausgeführt werden müssen, bevor sie endgültig festgeschrieben ``(Commit)`` werden können. Falls eine der Teiltransaktionen fehl schlägt, werden die anderen Teilaktionen entweder nicht mehr durchgeführt, oder rückgängig gemacht ``(Rollback).`` Somit wird vermieden, dass Inkonsistenzen in der Datenbank entstehen. Django bietet mit dem ``@transaction.atomic``-Dekorator eine einfache Möglichkeit, genau solche Transaktionen zu erstellen. Vorsicht ist geboten, wenn es sich um Views mit hohem Traffic handelt: atomische Tranksaktionen sind nicht kostenlos und kosten unter Umständen Performance. Ihr Einsatz sei gut abgewägt. Mehr zu Transaktionen in Django in der Doku: ``_ Kommando auf der Konsole ausführen ------------------------------------- Jetzt können wir unser Kommando nochmal ausführen. .. code-block:: bash python manage.py create_user -n 10 Das Programm startet jetzt und erstellt uns 10 Testuser, die Namen variieren natürlich: .. code-block:: bash $ python manage.py create_user -n 10 => randolfklapp => lilianbiggen => yplath => qortmann => oscarscholtz => niklassoelzer => mechtildwinkler => wkoch-ii => adelgunde30 => eleonoreeckbauer 10 User erfolgreich angelegt! Wir sollten jetzt 10 Testuser in der Datenbank haben. Das prüfen wir entweder in der Admin oder auf der shell .. code-block:: python >>> from django.contrib.auth import get_user_model >>> get_user_model().objects.all() , , , ...]> ``get_user_model`` liefert uns das aktuelle User-Model. Auf dem führen wir via dem Manager ``objects`` die Methode ``all`` aus. Grundsätzlich sollte man immer ``get_user_model`` nutzen, wenn man Zugriff auf die User-Klasse benötigt, da diese Funktion immer das aktuell festgelegte User-Model nimmt. Factories für Events und Categories ------------------------------------ Nun wollen wir natürlich auch noch ein Kommando erstellen, um Kategorien und Events anzulegen. Dazu legen wir im Verzeichnis ``events`` diese Dateistruktur an: .. code-block:: bash ├───management │ └───commands │ │ create_events.py und füllen ``event_manager/events/management/commands/create_events.py`` mit folgendem Inhalt: .. rli:: https://raw.githubusercontent.com/realcaptainsolaris/event_manager_code/main/commands/1_create_events.py :language: python Ok, hier ist eine Menge passiert. Sehen wir uns das mal an. Zuerst einmal importieren wir alle nötigen Pakete und Module in unser Programm. Dazu gehört auch das ``random``-Modul, da wir Events zufällig Kategorien zurordnen wollen. Die Fabriken fehlen allerdings noch. .. code-block:: python import random import factory from django.contrib.auth import get_user_model from django.core.management.base import BaseCommand from django.db import transaction from events.factories import CategoryFactory from events.factories import EventFactory from events.models import Category from events.models import Event Das Kommando soll später mit Argumenten aufgerufen werden können, zum Beispiel ``python manage.py create_events events=100 categories=4``. Allerdings wollen wir das optional lassen und dem User Defaultwerte bieten. .. code-block:: python CATEGORIES = 4 EVENTS = 20 Die Klasse ``Command`` hat nun eine neue Methode: ``add_arguments``. Dieser Methodenname ist generisch und in dieser Methode geben wir Argumente an, die an das Programm übergeben werden können. Mit ``parser.description`` geben wir wie schon zuvor dem Argumentparser erstmal eine Beschreibung, die aufgerufen wird, wenn zum Beispiel ``python manage.py create_events --help`` ausgeführt wird. Dann erstellen wir zwei optionale Arugmente, die als ``int`` geparste und die via der ``help``-Eigenschaft ebenfalls in der Hilfe angzeigt werden. Diese beiden Argumente bezeichnen die Anzahl der Events sowie der Kategorien. .. code-block:: python class Command(BaseCommand): def add_arguments(self, parser): parser.description = "Generate Random Events and Categories" parser.add_argument( '-e', '--events', type=int, help='Number of events to be generated', ) parser.add_argument( '-c', '--categories', type=int, help='Number of categories to be generated, max is 10', ) parser.epilog = ( "Usage example: python manage.py create_events events=100 " "categories=10" ) Wenn wir help für dieses Subkommando ausführen ... .. code-block:: bash python manage.py create_events --help erhalten wir diese Ausgabe (gekürzt), wie das Subkommando zu nutzen ist: .. code-block:: bash usage: manage.py create_events [-h] [-e EVENTS] [-c CATEGORIES] [--version] [--no-color] [--force-color] [--skip-checks] Generate Random Events and Categories options: -h, --help show this help message and exit -e EVENTS, --events EVENTS Number of events to be generated -c CATEGORIES, --categories CATEGORIES Number of categories to be generated, max is 10 Usage example: python manage.py create_events events=100 categories=10 In der ``handle``-Methode fragen wir erstmal die Argumente ab und prüfen, ob sie in dem erlaubten Bereich liegen. Das heisst, die Zahl darf nicht negativ sein und die Anzahl an Kategorien darf 10 nicht übersteigen. Die Kategorien wollen wir später nämlich als Wortliste anlegen und nicht völlig zufällig erzeugen. Hier kommt übrigens mal der neue ``Walrus-Operator :=`` sinnvoll zum Einsatz. .. code-block:: bash num_events = n if (n := options.get("events")) else EVENTS num_categories = n if ( n := options.get("categories")) else CATEGORIES if any([num_events < 0, num_categories < 0, num_categories > 10] ): raise ValueError(("Negative Werte nicht erlaubt. Nur maximal 10" " Kategorien")) Im nächsten Schritt selektieren wir die aktuellen User, die wir ja vorher schon erstellt hatten. Befinden sich aktuell keine User im System, beendet das Programm mit einem ``SystemExit``. Die ``user_list`` benötigen wir für das Erstellen eines Events, um aus ihr einen zufälligen User zu ziehen. .. code-block:: bash user_list = get_user_model().objects.all() if not user_list: print("Es existieren keine User im System.") print("Bitte führe erst python manage.py create_user aus") raise SystemExit(1) Nun löschen wir alle bisherigen Events und Kategorien ... .. code-block:: bash print("Lösche Model Data...") for m in [Category, Event]: m.objects.all().delete() Übrigens hätten wir die Events an dieser Stelle gar nicht löschen müssen, da die Events via einem ForeignKey mit den Kategorien in Beziehung stehen. Und da wir beim Erstellen dieser Verbindung ``models.CASCADE`` gewählt hatten, also das kaskadierende Löschen, würden alle Events ebenfalls beim Löschen der Kategorien mitgelöscht. Aber sicher ist sicher, man weiß ja nie, was später noch passiert. Danach erstellen wir Testdaten mit der (noch nicht existenten Category- sowie Event-Factory). Die erstellten Kategorien sammeln wir dann in der Liste ``categories``, aus der wir später beim Erstellen eines Events zufällig einen Eintrag ziehen. Dazu hatten wir die Methode ``create_batch`` genutzt und ihr als Argument die Anzahl der Kategorien eingegeben, die der User erstellen will. Beim Erstellen der Events ziehen wir zufällig eine Kategorie und einen User und übergeben diese Werte dem ``EventFactory`` - Konstruktor. .. code-block:: bash print("Erstelle Kategorien...") categories = CategoryFactory.create_batch(num_categories) print("Erstelle Events...") for _ in range(num_events): event = EventFactory( category=random.choice(categories), author=random.choice(user_list), ) Nun fehlen uns natürlich noch die Factories. Dazu legen wir die Datei ``event_manager/events/factories.py`` an und füllen sie mit folgendem Inhalt: .. rli:: https://raw.githubusercontent.com/realcaptainsolaris/event_manager_code/main/commands/1_event_factory.py :language: python Das Erstellen der Company- und Event-Objekte ist im Grunde relativ straight forward. Die ``CategoryFactory`` zieht über den ``Iterator`` einen Eintrag aus der ``categories``-Liste, die wir vorweg angegeben hatten. Wir wollen in unserem Testsystem nämlich möglichst realistische Kategorienamen haben und deshalb haben wir diese hier selbständig vergeben. Die ``EventFactory`` ist ein klein wenig komplizierter. Das ``min-group``-Attribut wird aus dem Event-Model per ``random.choice`` zufällig gezogen. Das ``date``-Attribut liegt zwischen einem Datum von heute und 60 Tage in der Zukunft. Jetzt können wir Testdaten generieren und prüfen, ob sie in der Datenbank vorhanden sind. .. code-block:: bash python manage.py create_events --events 20 --categories 5 Wenn wir unsere Datenbank öffnen und die Tabelle ``events_events`` ansteuern, sehen wir eine ganze Menge Einträge: .. image:: /images/events_created_db.png Zugegeben, die Namen und Beschreibungen machen keinen Sinn, aber immerhin haben wir valide Testdaten, um unser System besser zu testen. Optional: Ein Makefile erstellen ---------------------------------- Ein kleiner Hinweis an Linux / Unix-Nutzer: wem das Kompilieren und Syncen mit pip-tools auf die Dauer zu lästig wird, kann sich für diese Aufgabe auch ein ``Makefile`` anlegen. Dazu unter ``event_manager/Makefile`` das File anlegen und folgenden Code eintragen: .. code-block:: bash install: @pip install -r requirements.txt -r requirements-dev.txt compile: @rm -f requirements*.txt @pip-compile --resolver=backtracking requirements.in @pip-compile --resolver=backtracking requirements-dev.in sync: @pip-sync requirements*.txt Wir haben im Makefile nun drei Kommandos ``install``, ``compile`` und ``sync``. Um nun zu Kompilieren uns anschliessend zu Syncen muss nur folgendes ausgeführt werden: .. code-block:: bash make compile make sync Optional: Das Projekt in der Version v0.1 kopieren ----------------------------------------------------- Wer bisher mitgecoded hat, sollte das Projekt eigentlich zum Laufen gebracht haben. Falls es doch irgendwo einen Fehler gibt, den man nicht behoben bekommt, oder einfach nur so, kann sich das Projekt im aktuellen Zustand auch klonen oder runterladen. Version v0.1 via github clonen ...................................... .. code-block:: bash git clone -b v0.1 git@github.com:realcaptainsolaris/event_project.git und dann nicht vergessen, ein virtuelles Environment anzulegen und zu migrieren: .. code-block:: bash python -m venv .envs/eventenv pip install pip-tools pip-sync requirements.txt requirements-dev.txt python manage.py migrate python manage.py runserver Version v0.1 als zip-Datei runterladen ........................................... ``_ Version v0.1 als tar-Datei runterladen ............................................ ``_ Nützliche Links ------------------------------------ * ``_ * ``_ .. [#argparse] https://docs.python.org/3/library/argparse.html