Drupal-Voting-API: Einzelsummen für Sternebewertungen

Die Tage musste ich mal wieder etwas basteln, um im Umfeld einer Sternebewertungs-Funktion bei Drupal etwas zu erreichen, was ich als Feature einfach erwartet hätte. Da es sicher nicht nur mir so geht wollte ich das mal kurz aufschreiben. Das Problem: Wie bekommt man die Anzahl an Bewertungen pro abgegebenem Stern?

Das Szenario

Das Projekt läuft mit Drupal 10.2.7, aber die eingesetzten Module sind schon länger im gleichen Zustand. Insofern würde diese Lösung vermutlich auch mit anderen Versionen von Drupal 9 und 10 funktionieren, genau wie mit der neuen Version Drupal 11.

Es geht um Bewertungsmöglichkeiten für Inhalte, also so etwas wie „Daumen hoch“ oder eben eine Bewertung eines Inhalts auf einer Skala von 1 bis 5. Die Bewertungsfunktionen in dem Projekt sind mittels zweier Module umgesetzt: drupal/votingapi stellt eine grundlegende API für Bewertungen bereit und drupal/rate ergänzt konkrete Widgets zum Bewerten. Speziell sollte ich nun eine Fünf-Sterne-Bewertung ergänzen. Das ist mit VotingAPI und Rate tatsächlich nicht schwierig, diese Bewertung wird in der Dokumentation des Rate-Moduls als eines der unterstützten Beispiele genannt. Daneben gibt es auch andere Module, wie etwas „Fivestar“, die sich nur auf diesen einen Anwendungsfall einer Sternebewertung konzentrieren. Bevor dazu jemand eine Frage stellt: Nein, das folgende funktioniert nur mit der Kombination aus VotingAPI und Rate-Modul.

Die Sternebewertung war schnell ergänzt. Dazu habe ich ein neues Rate-Widget namens „fivestar“ angelegt, den Werttyp auf „Punkte“ gestellt und jeder der Optionen (=Sterne) 1 bis 5 Punkte zugewiesen. Besucher können nun Inhalte, an denen die Bewertungsfunktion konfiguriert ist, mit 1 bis 5 Sternen bewerten. An den Teasern wird der Mittelwert und die Anzahl der Bewertungen ausgegeben.

Das Problem

Bleibt noch der Export. Das Projekt hat für alle Inhalte einen CSV-Export. Für eine Bewertungsfunktion wie „Daumen hoch“ gebe ich dort einfach die Anzahl der Bewertungen aus. Für die Sternebewertung werden aber mehr Informationen benötigt. Mindestens der Durchschnittswert und die Anzahl der Bewertungen. 3 von 5 Sternen bei einer Bewertung ist nicht das gleiche wie bei 100 Bewertungen.

Aber genau genommen ist es für die Auswertung auch nicht egal, ob 100 Leute 3 Sterne verteilt haben oder 50 Leute 1 Stern und 50 Leute 5 Sterne. Wir hätten also gerne eine Spalte mit der Anzahl der abgegebenen Bewertungen pro Stern. Und damit fing das Problem an, denn im Gegensatz zu allen anderen bisher erwähnten Informationen, stellt das Rate-Modul von Haus aus die Einzelsummen nicht zur Verfügung.

An dieser Stelle kurz ein Wort zur Architektur. Das VotingAPI-Modul stellt zwei Tabellen zur Verfügung: „votingapi_vote“ enthält die einzelnen Bewertungen. Hier sind alle Informationen enthalten, aber natürlich kann diese Tabelle auf einer gut besuchten Seite schnell sehr groß werden. Man möchte eher nicht ständig größere Mengen an Datensätzen in dieser Tabelle durchzählen. Dafür gibt es die zweite Tabelle „votingapi_result“. Bei jeder abgegebenen Bewertung schreibt das Modul hier aktuelle Summen und andere aggregierte Werte hinein. Der Gedanke dabei ist, dass die Bewertungen sehr viel öfter ausgelesen als geschrieben werden. Wenn die Arbeit also gemacht werden muss, dann nur einmal beim Abgeben einer neuen Bewertung. Danach kann man aus dieser Tabelle Werte wie die Anzahl oder den Mittelwert für einen Node auslesen, ohne teure Berechnungen erneut anstellen zu müssen.

In dieser Tabelle hätte ich nun auch die Anzahl pro Stern erwartet. Bei der Bewertungsfunktion „Daumen hoch/runter“ gibt es z.B. die Anzahl der abgegebenen Bewertungen und die Anzahl der abgegebenen positiven Bewertungen („Daumen hoch“). Die Anzahl für „Daumen runter“ kann man sich damit ausrechnen. Für meine Sterne fand sich aber nur die Gesamtanzahl der Bewertungen, der Mittelwert und die nicht wirklich hilfreiche Summe der abgegebenen Sterne.

Die Lösung

Ich habe eine Weile in der unübersichtlichen „votingapi_result“-Tabelle herumgesucht. Das Rate-Modul schreibt hier sehr viele Werte hinein, nicht alle wirklich auf sinnvolle Art und Weise. Ich habe auch probiert, an der Konfiguration der Bewertungs-Optionen, also der einzelnen Sterne, die „vote_count“-Funktion zu konfigurieren. Man kann das tun, aber es ist nicht ganz klar, was der Anwendungsfall dafür wäre. Die gespeicherte Anzahl war jedenfalls immer die Gesamtzahl. Schließlich habe ich in die Klasse geschaut, welche die „vote_count“-Funktion umsetzt. Dort wird nirgends auf den Wert der gewählten Option geprüft, also ob 1, 3 oder 5 Sterne angeklickt wurden. Insofern ist es auch kein Wunder, dass diese Funktion immer alles zählt.

Letztlich war klar, dass ich mir diese Funktion würde selber schreiben müssen, mit „CountUp“ als Vorbild. Das klang erst mal kompliziert, war dann aber eigentlich relativ einfach umzusetzen.

Ganz grundlegend brauchen wir dafür zwei Dinge: Eine VoteResult-Funktion, welche die Sternebewertungen zählt. Und eine Deriver-Klasse, welche aus der einen Funktion fünf Funktionen macht, eine pro wählbarem Stern. Natürlich könnte man sich auch eine CountOneStar-Funktion schreiben und eine CountTwoStars-Funktion etc. Die wären dann aber bis auf wenige Zeichen komplett gleich. Insofern ist die Lösung über die Deriver-Klasse besser.

Als erstes legen wir die VoteResult-Funktion ab, in einem eigenen Modul im Ordner „src/Plugin/VoteResultFunction“.

<?php

namespace Drupal\jf_voting\Plugin\VoteResultFunction;

use Drupal\votingapi\VoteResultFunctionBase;

/**
 * The total number of votes for a specific star voting option.
 *
 * There will be one value saved per star option. See JfVotingVoteResultFunction
 * which adds the individual, derivative functions.
 *
 * @VoteResultFunction(
 *   id = "rate_count_star",
 *   label = @Translation("Number of votes for a star option"),
 *   description = @Translation("The number of votes cast for a specific star option."),
 *   deriver = "Drupal\jf_voting\Plugin\Derivative\JfVotingVoteResultFunction",
 * )
 */
class CountStar extends VoteResultFunctionBase {

  public function calculateResult($votes) {
    // Find out which star option this function is supposed to count.
    // The derivative ID looks like 'dialog_rating_fivestar.1star'.
    $pluginId = explode('.', $this->getDerivativeId());
    $starOption = substr($pluginId[1], 0, 1);

    // Count all votes which have the given amount of points as value.
    $count = 0;
    foreach ($votes as $vote) {
      if ($vote->getValue() == $starOption) {
        $count++;
      }
    }

    return $count;
  }

}

Diese Klasse zählt nicht alle Bewertungen, sondern nur diejenigen, bei denen die Anzahl der abgegebenen Punkte mit der ID der Derivative-Funktion übereinstimmt (herausgeparst aus z.B. „3star“, siehe Hinweis weiter unten).

Dazu brauchen wir dann noch den Deriver. Die Klasse kommt in den Nachbarordner „src/Plugin/Derivative“.

<?php

namespace Drupal\jf_voting\Plugin\Derivative;

use Drupal\Component\Plugin\Derivative\DeriverBase;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Deriver class to create a star count per star option.
 *
 * This only works with the "dialog_rating_fivestar" rating widget.
 */
class JfVotingVoteResultFunction extends DeriverBase implements ContainerDeriverInterface {

  /**
   * Constructs a JfVotingVoteResultFunction instance.
   *
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
   *   The entity type manager.
   */
  public function __construct(protected EntityTypeManagerInterface $entityTypeManager) {
  }

  public static function create(ContainerInterface $container, $base_plugin_id): self {
    return new static(
      $container->get('entity_type.manager')
    );
  }

  public function getDerivativeDefinitions($base_plugin_definition): array {
    $this->derivatives = [];
    $widget = $this->entityTypeManager->getStorage('rate_widget')->load('dialog_rating_fivestar');
    if ($widget) {
      $options = $widget->getOptions();
      if ($options) {
        foreach ($options as $option) {
          $pluginId = $widget->id() . '.' . $option['value'] . 'star';
          $this->derivatives[$pluginId] = $base_plugin_definition;
        }
      }
    }

    return $this->derivatives;
  }

}

Das Vermehren der VoteResult-Funktion passiert in der „getDerivativeDefinitions“-Methode. Hier wird das zuvor angelegte „dialog_rating_fivestar“-Rate-Widget geladen. In den Optionen des Widgets steckt die Definition der Sterne. Wir fügen nun pro definierter Stern-Option einen Eintrag im derivatives-Array hinzu. Auf diese Weise könnten wir das Rating-Widget auch auf drei oder sieben Sterne umkonfigurieren und müssten an diesen beiden Klassen nichts ändern.

Spannend ist vielleicht noch die gewählte Plugin-ID für das Derivatives-Array. Aus der Definition

$widget->id() . '.' . $option['value'] . 'star'

wird im Ergebnis „dialog_rating_fivestar.1star“. Der vordere Teil ist der technische Name des Rate-Widgets. Der hintere kennzeichnet die Anzahl der Sterne, für welche gezählt wird. In der VoteResult-Funktion müssen wir diese ID zerlegen, um mit der abgegebenen Anzahl an Punkten zu vergleichen. So wie es dort jetzt steht, klappt es tatsächlich nur für eine einstellige Anzahl Sterne. Besser wäre es eventuell, eine ID wie „dialog_rating_fivestar.star.1″ zu verwenden und dann in der VoteResult-Funktion

$starOption = substr($pluginId[2]);

zu verwenden. Aber für meinen Anwendungsfall war das gut genug.

Das Ergebnis sieht dann in der „votingapi_result“-Tabelle so aus, hier am Beispiel einer einzigen abgegebenen 4-Sterne-Bewertung:

Diese Informationen stehen nun zur Verfügung und können genauso wie Gesamtanzahl oder Mittelwert ausgelesen werden. Man kann damit z.B. wie man es von Amazon kennt eine Übersicht der möglichen Sterne und wie oft diese vergeben wurden darstellen. Oder in meinem Fall die Werte in einem CSV-Export ausgeben.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.

Bitte beachte die Kommentarregeln: 1) Kein Spam, und bitte höflich bleiben. 2) Ins Namensfeld gehört ein Name. Gerne ein Pseudonym, aber bitte keine Keywords. 3) Keine kommerziellen Links, außer es hat Bezug zum Beitrag. mehr Details...

So, noch mal kurz drüber schauen und dann nichts wie ab damit. Vielen Dank fürs Kommentieren! :-)