Entwicklung

Nachdem der Entwurf im Großen und Ganzen fertig war, konnte ich mich an die Programmierung der Webseite machen.

Ich habe mich dazu entschieden, mit Django zu arbeiten, da mir vor allem die Django-Administrationsoberfläche sehr gut gefällt und ich dieses Framework gerne einmal ausprobieren möchte.

Als Entwicklungsumgebung habe ich hauptsächlich PyCharm Professional verwendet. Ich verwende die JetBrains-IDEs schon seit Jahren und habe damit nur gute Erfahrungen gemacht. Die Dokumentation habe ich mit VSCode geschrieben, da PyCharm keine Preview-Funktion für Sphinx besitzt.

Django hat fast alle Funktionen, die für eine Webanwendung benötigt werden, bereits eingebaut. Die einzigen Bibliotheken, ich zusätlich benötigt habe, sind requests, django-hashid-field und django-import-export. Requests wird für den Zugriff auf die Unsplash-API benötigt. Django-Hashid-Field erlaubt es, IDs von Datenbankfeldern als 7stelligen Code auszugeben, was für Spiel- und Spieler-IDs benötigt wird. Django-Import-Export ermöglicht es, Daten über die Adminoberfläche zum importieren und exportieren, was beim Einfügen von Fragen sehr hilfreich ist.

Spiellogik

Ich habe mich dazu entschieden, die Anwendungslogik von der Darstellung zu trennen. Deswegen habe ich das Verzeichnis Controller angelegt, in dem sich sämtliche Klassen der Anwendungslogik befinden.

Die Klasse GameLogic kann mit dem Spiel- und Spielercode instanziiert werden und beinhaltet Methoden zur Ausführung sämtlicher Spieleraktionen (Auswahl des zu entfernenden Bildes, Abstimmung) sowie zum Abrufen der Spielinformationen.

Die Klasse Images beinhaltet die Methoden für den Zugriff auf die Unsplash-API und das Abrufen von neuen Bildern.

Diese Methoden können dann in den Views aufgerufen werden.

Die genaue Dokumentation dieser Klassen findet sich ebenso wie eine Dokumentation des Datenmodells im Abschnitt Referenz.

Views

Scio besteht aus insgesamt 5 Views, davon 3 Webseiten und 2 API-Endpoints.

class scio.views.home(request)

Die Homepage von Scio. Hier befinden sich 2 Formulare, eines zum Erstellen von neuen Spielen und eines, um einem existierenden Spiel beizutreten.

class scio.views.game(request, game)

Die Spielseite. Auf diese Seite wird man nach Erstellung eines neuen Spiels weitergeleitet. Die Spielseite zeigt Informationen zum Spiel an, erlaubt aber keine Interaktionen wie die Spielerseite.

Parameter

game – Spielcode

class scio.views.play(request, game, player)

Die Spielerseite. Auf diese Seite wird man nach Beitritt zu einem Spiel weitergeleitet. Die Spielerseite zeigt Informationen zum Spiel an und erlaubt Interaktionen wie das Abstimmen.

Parameter
  • game – Spielcode

  • player – Spielercode

class scio.views.api_gameinfo(request, game)

Die Spiel-API gibt Informationen zum angegebenen Spiel aus.

Parameter

game – Spielcode

class scio.views.api_playerinfo(request, game, player)

Die Spieler-API gibt bei einer GET-Request Informationen zum angegebenen Spiel aus. Mit POST-Requests lassen sich Aktionen ausführen:

  • POST(action=selection, image=<ID>) Auswahl eines zu entfernenden Bildes

  • POST(action=guess, guess=<ID1>,<ID2>,<ID3>,<ID4>) Abgabe eines Tipps (4 durch Kommas getrennte Bild-IDs)

  • POST(action=proceed) Fortfahren

  • POST(action=remove_player, player=<ID>) Spieler entfernen

Parameter

game – Spielcode

Design

Das Design habe ich auf codepen.io erstellt und getestet. Ich konnte das vorher entworfene Design mit minimalen Änderungen umsetzen.

_images/design1.png

Bilder anzeigen, auswählen und entfernen

_images/design2.png

Testformular

_images/design3.png

Wertungstabelle, Punktzahlen der einzelnen Spieler

Frontend

Das Frontend für das Spiel wurde mit Vue.js implementiert. Die Spielseite ruft alle 2 Sekunden über ein Javascript die Spieldaten von der Webseite ab. Wenn sich die Spielinhalte durch die Aktion eines anderen Spielers (z.B. Abstimmung) ändern, wird die Ansicht so automatisch aktualisiert.

Diese Implementation ist nicht optimal, es wäre auch möglich, den Datenaustausch zwischen Javascript und Server mittels Websockets durchzuführen. Dies ist aber mit Django nicht ohne weiteres möglich.

Da ich hier keine zeitkritische Anwendung habe und die Abfragen nur für einen relativ kurzen Zeitraum und nicht permanent im Hintergrund ausgeführt werden müssen, ist Polling dennoch eine adäquate Lösung.

Probleme

Das Django-Framework ist sehr gut dokumentiert und ich konnte fast alle Informationen auf deren Webseite finden. Insbesondere die Handhabung des Datenmodells brauchte etwas Einarbeitungszeit, da sich die Verwendung des Django-ORMs doch sehr von einfachen SQL-Abfragen unterscheidet, mit denen ich bisher meist gearbeitet hatte.

Es gab ein Problem bei der Modellierung von Beziehungen, bei dem ich etwas länger suchen musste, um es zu lösen.

Wenn man in Django mit models.ForeignKey eine Beziehung erstellt, ist diese standardmäßig bidirektional. Erstellt man also in Klasse A 2 Beziehungen auf Klasse B, so sind diese Beziehungen zwar in A->B - Richtung eindeutig, jedoch nicht in die umgekehrte Richtung.

class Round(models.Model):
    question1 = models.ForeignKey(Question, on_delete=models.SET_NULL)
    question2 = models.ForeignKey(Question, on_delete=models.SET_NULL)

In diesem Beispiel könnte man mit einem Filter-Statement wie Round.objects.filter(question1=q) zwar eindeutig alle Runden herausfiltern, die eine bestimmte Frage1 beinhalten. Umgekehrt gäbe es aber ein Problem, da Django standardmäßig den Namen der Klasse A als Schlüssel für den Beziehungspartner auf der Seite von Klasse B verwendet.

Sollte also der Befehl Question.objects.filter(round=r) Frage 1 oder Frage 2 der Runde r zurückgeben? Diese Situation wäre uneindeutig, weswegen Django bei Erstellung des Modells einen Fehler ausgibt:

ERRORS:
<class 'scio.admin.RoundAdmin'>: (admin.E108) The value of 'list_display[4]' refers to 'question', which is not a callable, an attribute of 'RoundAdmin', or an attribute or method on 'scio.Round'.
scio.Round.question1: (fields.E304) Reverse accessor for 'Round.question1' clashes with reverse accessor for 'Round.question2'.
        HINT: Add or change a related_name argument to the definition for 'Round.question1' or 'Round.question2'.
scio.Round.question2: (fields.E304) Reverse accessor for 'Round.question2' clashes with reverse accessor for 'Round.question1'.
        HINT: Add or change a related_name argument to the definition for 'Round.question2' or 'Round.question1'.

Die Lösung für dieses Problem ist es, den Schlüssel für den Beziehungspartner mit dem Feld related_name zu überschreiben.

class Round(models.Model):
    question1 = models.ForeignKey(Question, related_name='round_q1' on_delete=models.SET_NULL)
    question2 = models.ForeignKey(Question, related_name='round_q2', on_delete=models.SET_NULL)

Man kann related_name auch auf einen leeren String setzen. In diesem Fall kann von Klasse B nicht mehr auf den Beziehungspartner A zugegriffen werden, man erhält also eine unidirektionale Beziehung.