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:

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.

├───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.

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

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.

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 1 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

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: https://faker.readthedocs.io/en/master/providers.html

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:

-c requirements.txt
factory-boy

Um aus den in-Dateien die endgültigen Requierements.txt-Dateien zu machen, müssen diese wieder compiliert werden.

(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).

(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:

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: https://factoryboy.readthedocs.io/en/stable/orms.html#django

Das Commando ausbauen

Wir ersetzen jetzt den Inhalt der Datei event_manager/user/management/commands/create_user.py mit folgenden Inhalt:

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.

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: https://docs.djangoproject.com/en/stable/topics/db/transactions/

Kommando auf der Konsole ausführen

Jetzt können wir unser Kommando nochmal ausführen.

python manage.py create_user -n 10

Das Programm startet jetzt und erstellt uns 10 Testuser, die Namen variieren natürlich:

$ 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

>>> from django.contrib.auth import get_user_model
>>> get_user_model().objects.all()

<QuerySet [<User: admin>, <User: Bob>, <User: Alice>, ...]>

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:

├───management
│   └───commands
│       │   create_events.py

und füllen event_manager/events/management/commands/create_events.py mit folgendem Inhalt:

"""
Generating Event Data.

This module provides a management command to generate random
event data built with factory boy and the faker libary.
"""

import random
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


CATEGORIES = 4
EVENTS = 20


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"
        )

    @transaction.atomic
    def handle(self, *args, **options):
        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"))

        print(
            f"Generating {num_events=} {num_categories=} "
        )
        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 set_testusers aus")
            raise SystemExit(1)

        print("Lösche Model Data...")
        for m in [Event, Category]:
            m.objects.all().delete()

        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),
            )

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.

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.

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.

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 …

python manage.py create_events --help

erhalten wir diese Ausgabe (gekürzt), wie das Subkommando zu nutzen ist:

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.

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.

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 …

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.

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:

import random
from datetime import timedelta

import factory
import faker
from user.factories import UserFactory

from django.contrib.auth import get_user_model
from django.utils import timezone

from . import models

User = get_user_model()
categories = [
    "Sports",
    "Talk",
    "Cooking",
    "Freetime",
    "Hiking",
    "Movies",
    "Travelling",
    "Science",
    "Arts",
    "Pets",
    "Music",
    "Wellness",
    "Religion",
]

class CategoryFactory(factory.django.DjangoModelFactory):
    """Erstellt eine Kategorie aus einer vorgegebenen Liste."""

    class Meta:
        model = models.Category

    name = factory.Iterator(categories)
    sub_title = factory.Faker("sentence", locale="de_DE")
    description = factory.Faker("paragraph", nb_sentences=20, locale="de_DE")


class EventFactory(factory.django.DjangoModelFactory):
    """Event Fabrik zum Erstellen eines neuen Events."""

    class Meta:
        model = models.Event

    # Author und Category werden nur erzeugt, wenn sie beim Erstellen der
    # Factory nicht überschrieben werden.
    author = factory.SubFactory(UserFactory)
    category = factory.SubFactory(CategoryFactory)

    name = factory.Faker("sentence")
    sub_title = factory.Faker("sentence", locale="de_DE")
    description = factory.Faker("paragraph", nb_sentences=20, locale="de_DE")
    min_group = factory.LazyAttribute(lambda _: random.choice(list(models.Event.Group)))
    is_active = factory.Faker("boolean", chance_of_getting_true=50)

    date = factory.Faker(
        "date_time_between",
        end_date=timezone.now() + timedelta(days=60),
        start_date=timezone.now() + timedelta(days=1),
        tzinfo=timezone.get_current_timezone(),
    )

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.

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:

../_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:

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:

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

git clone -b v0.1 git@github.com:realcaptainsolaris/event_project.git

und dann nicht vergessen, ein virtuelles Environment anzulegen und zu migrieren:

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

https://github.com/realcaptainsolaris/event_project/archive/refs/tags/v0.1.zip

Version v0.1 als tar-Datei runterladen

https://github.com/realcaptainsolaris/event_project/archive/refs/tags/v0.1.tar.gz