Ein neues Model: Reviews

Das Review-Model

Bevor wir mit der Entwicklung der API beginnen, wollen wir noch ein weiteres Model bauen und dafür per FactoryBoy erneut Testdaten generieren lassen.

Unsere Events sollen von angemeldeten Nutzern bewertet werden. Dazu benötigen wir ein Review-Model. Auf Views und URLs für das Review-Model verzichten wir in diesem Buch, das sei dem Leser überlassen, das zu implementieren. Uns geht es nur um das Model bzw. die Testdaten.

../_images/uml_class_4.png

In dem Diagramm sehen wir eine Klasse Tag, die wir noch gar nicht implementiert haben. Später im Buch, wenn wir Many-to-Many - Beziehungen angucken, werde ich genauer auf diese Klasse eingehen. Für uns ist sie aktuell an dieser Stelle erstmal uninteressant.

Wir öffnen die Datei event_manager/events/models.py und fügen das Review-Model ein:

Die Klasse Review beschreibt eine Bewertung eines Users für einen Event. Die Ratings wurden als enum-ähnliche Klasse angelegt, die von models.IntegerChoices erbt.

Auf den User gibt es eine 1:n Beziehung ebenso wie auf das Event. Ein Review kann also einem Event zugeordnet werden, und ein Event hat viele Reviews. Ebenso kann ein User viele Reviews haben, aber ein Review gehört nur einem Autor. Wird der User gelöscht, werden auch die Reviews gelöscht (on_delete=models.CASCADE), gleiches geschieht, wenn das Event gelöscht wird.

Die gesamte event_manager/events/models.py sieht nun so aus:

from django.db import models
from django.urls import reverse
from django.utils.text import slugify
from django.utils import timezone
from django.contrib.auth import get_user_model
from event_manager.utils import slugify_instance_name


User = get_user_model()


class DateMixin(models.Model):
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        abstract = True


class Category(DateMixin):
    """Eine Kategorie für einen Event."""

    name = models.CharField(max_length=100, unique=True)
    sub_title = models.CharField(max_length=200, null=True, blank=True)
    slug = models.SlugField(unique=True)
    description = models.TextField(null=True, blank=True)

    class Meta:
        ordering = ["name"]
        verbose_name = "Kategorie"
        verbose_name_plural = "Kategorien"

    def __str__(self):
        return self.name

    def save(self, *args, **kwargs):
        if not self.slug:
            self.slug = slugify(self.name)
        super().save(*args, **kwargs)

    def num_of_events(self):
        """Die Anzahl der Events einer Kategorie."""
        return self.events.count()


class Event(DateMixin):
    """Der Event, der auf einen bestimmten Zeitpunkt terminiert ist."""

    class Group(models.IntegerChoices):
        SMALL = 2
        MEDIUM = 5
        BIG = 10
        LARGE = 20
        UNLIMITED = 0

    name = models.CharField(max_length=100, unique=True)
    description = models.TextField(null=True, blank=True)
    date = models.DateTimeField()
    slug = models.SlugField(unique=True)
    sub_title = models.CharField(max_length=200, null=True, blank=True)
    is_active = models.BooleanField(default=True)
    min_group = models.IntegerField(choices=Group.choices)
    category = models.ForeignKey(
        Category, on_delete=models.CASCADE, related_name="events"
    )
    author = models.ForeignKey(User, on_delete=models.CASCADE, related_name="events")

    class Meta:
        ordering = ["name"]

    def __str__(self):
        return self.name

    @property
    def related_events(self):
        """
        Ähnliche Events aus der gleichen Kategorie und der selben
        min-group.
        """
        number = 5
        related_events = Event.objects.filter(
            min_group__exact=self.min_group, category=self.category
        )
        return related_events.exclude(pk=self.id)[:number]

    @property
    def has_finished(self) -> bool:
        """Wenn das Event in der Vergangenheit liegt, return True."""
        now = timezone.now()
        return self.date <= now


class Review(DateMixin):
    """Ein Review für einen Event."""

    class Ratings(models.IntegerChoices):
        BAD = 1
        OK = 2
        COOL = 3
        GREAT = 4
        WONDERFUL = 5
        AWESOME = 6

    author = models.ForeignKey(User, on_delete=models.CASCADE, related_name="reviews")
    review = models.TextField(blank=True, null=True)
    rating = models.PositiveIntegerField(
        choices=Ratings.choices,
    )
    event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="reviews")

    def __str__(self):
        return f"{self.event.name}: {self.rating},  {self.author}"


@receiver(pre_save, sender=Event)
def create_slug(sender, instance, *args, **kwargs):
    if not instance.slug:
        instance.slug = slugify_instance_name(instance, new_slug=None)

Wir können das Model jetzt migrieren

python manage.py makemigrations events
python manage.py migrate events

Das Review-Model in der Administration verfügbar machen

Wir wollen unser Model natürlich auch in der Administrationsoberfläche sehen. Dazu passen wir die Datei event_manager/events/admin.py an und fügen die Klasse ReviewAdmin ein:

@admin.register(Review)
class ReviewAdmin(admin.ModelAdmin):
    list_display = "author", "rating", "event_name"
    search_fields = ["event__name", "review"]

    @admin.display(description="Event Name")
    def event_name(self, obj):
        """The name of the event of this review."""
        return obj.event.name

In der Liste der darzustellenden Spalten auf der Review-Übersichtsseite (list_display) setzen wir eine Referenz auf ein Feld event_name, welches im Review-Model nicht exisitert.

Weiter unten implementieren wir eine Funktion gleichen Namens, die über das Review-Objekt obj auf den Event-Namen zugreift. Damit können wir nun auch den Event-Namen in der Übersicht angeben. Der @admin.display- Dekorator dient dazu, der Spalte einen schöneren Namen zu geben.

Interessant auch die Angabe des Suchfeldes search_fields: Es werden bei der Suche in den Reviews jetzt nur die Event-Namen berücksichtigt, und zwar über den klassischen Field-Lookup event__name.

../_images/review_admin.png

Inline Review anlegen

Wir sind mit der Admin noch nicht ganz fertig. Es wäre schön, wenn wir auf der Detailseite eines Events auch sehen könnten, ob und welche Reviews angelegt wurden.

Das geht mit der Klasse admin.tabularInline. Wir entwickeln eine ReviewInlineAdmin-Klasse, die von admin.tabularInline erbt und nur dazu da ist, als Inline-Ansicht in einer anderen Admin-Klasse angezeigt zu werden.

Wir fügen in event_manager/events/admin.py ganz oben (vor der EventAdmin) folgenden Eintrag hinzu:

class ReviewInlineAdmin(admin.TabularInline):
    model = Review

und inkludieren die Klasse via inlines in der EventAdmin:

@admin.register(Event)
class EventAdmin(admin.ModelAdmin):

    inlines = [ReviewInlineAdmin]
    prepopulated_fields = {"slug": ("name",)}
    list_display = (
        "date",
        "slug",
        "author",
        "name",
        "category",
        "category_slug",
    )
    [..]

nun können wir auf der Event-Detailseite sehen, ob und welche Reviews für diesen Event eingetragen wurden. Mehr zur InlineModelAdmin objects in der Django Doku: https://docs.djangoproject.com/en/stable/ref/contrib/admin/#inlinemodeladmin-objects

Die vollständige event_manager/events/admin.py sieht jetzt so aus:

from django.contrib import admin
from .models import Category, Event, Review


class ReviewInlineAdmin(admin.TabularInline):
    model = Review


@admin.register(Event)
class EventAdmin(admin.ModelAdmin):
    inlines = [ReviewInlineAdmin]
    prepopulated_fields = {"slug": ("name",)}
    list_display = (
        "date",
        "slug",
        "author",
        "name",
        "category",
        "category_slug",
        "is_active",
    )
    list_filter = ("category",)
    search_fields = ["name"]
    actions = ["make_active", "make_inactive"]
    readonly_fields = ("author",)
    list_display_links = ("name", "slug")
    radio_fields = {
        "category": admin.HORIZONTAL,
        "min_group": admin.VERTICAL,
    }
    autocomplete_fields = ['category']

    @admin.action(description="Setze Events active")
    def make_active(self, request, queryset):
        """Set all Entries to active."""
        queryset.update(is_active=True)

    @admin.action(description="Setze Events inactive")
    def make_inactive(self, request, queryset):
        """Set all Entries to inactive."""
        queryset.update(is_active=False)

    @admin.display(description="Kategorie Slug")
    def category_slug(self, obj):
        return obj.category.slug


@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
    prepopulated_fields = {"slug": ("name",)}
    search_fields = ["name"]
    list_display = "id", "name", "sub_title", "slug"
    list_display_links = ("name", "sub_title")
    list_filter = ("events", "sub_title")
    search_fields = ["name"]


@admin.register(Review)
class ReviewAdmin(admin.ModelAdmin):
    list_display = "author", "rating", "event_name"
    search_fields = ["event__name", "review"]

    @admin.display(description="Event Name")
    def event_name(self, obj):
        return obj.event.name

Testdaten anlegen

Nun benötigen wir Testdaten. Dazu nutzen wir wieder FactoryBoy und öffnen die datei event_manager/events/factories.py

class ReviewFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = models.Review

    event = factory.SubFactory(EventFactory)
    review = factory.Faker("paragraph")

und erstellen in event_manager/events/management/commands/create_events.py die Funktion für das Erstelle von Reviews.

Die gesamte Datei event_manager/events/management/commands/create_events.py sieht nun so aus:

"""
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.factories import ReviewFactory
from events.models import Category
from events.models import Event
from events.models import Review


CATEGORIES = 4
EVENTS = 20
MAX_REVIEWS = 7
MIN_RATING = 1
MAX_RATING = 6


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

            # each event can have up to MAX_REVIEWS reviews
            for _ in range(random.randint(0, MAX_REVIEWS)):
                ReviewFactory(event=event,
                              author=random.choice(user_list),
                              rating=random.randint(MIN_RATING,
                                                    MAX_RATING),
                              ),

Wir können in der Admin prüfen, ob die Daten vorhanden sind.