Merge pull request #22 from makes-hacks-hip/refactor_doc_test
Refactor doc test
This commit is contained in:
commit
60cfae3d64
20 changed files with 822 additions and 352 deletions
24
README.md
24
README.md
|
|
@ -7,13 +7,13 @@ For documentation see https://makes-hacks-hip.github.io/RepApp/.
|
|||
## Setup for development
|
||||
|
||||
- Clone repo
|
||||
- python -m venv venv
|
||||
- source venv/bin/activate
|
||||
- cd rc_hip
|
||||
- python manage.py makemigrations
|
||||
- python manage.py migrate
|
||||
- python manage.py createsuperuser
|
||||
- python manage.py runserver
|
||||
- `python -m venv venv`
|
||||
- `source venv/bin/activate`
|
||||
- `cd rc_hip`
|
||||
- `python manage.py makemigrations`
|
||||
- `python manage.py migrate`
|
||||
- `python manage.py createsuperuser`
|
||||
- `python manage.py runserver`
|
||||
|
||||
## Build Docker image
|
||||
|
||||
|
|
@ -30,3 +30,13 @@ docker run --rm -d \
|
|||
repapp`
|
||||
```
|
||||
- Open `http://127.0.0.1:8020/` in your browser
|
||||
|
||||
## Run tests
|
||||
|
||||
### Unit tests
|
||||
|
||||
Run `python manage.py test`
|
||||
|
||||
### LiveServer Tests
|
||||
|
||||
Run `python manage.py test repapp.live_tests.WorkflowTests`
|
||||
|
|
|
|||
175
docs/index.md
175
docs/index.md
|
|
@ -4,6 +4,8 @@ RepApp ist eine Web App um Reparaturen im Rahmen eines Repair-Cafés zu organisi
|
|||
|
||||
## Benutzer
|
||||
|
||||
RepApp unterscheidet zwischen den folgenden Benutzer-Typen:
|
||||
|
||||
### Gast
|
||||
|
||||
Ein Gast ist ein Gerätebesitzer der ein oder mehrere Geräte zur Reparatur anmelden möchte.
|
||||
|
|
@ -18,30 +20,38 @@ Ein Organisator ist Mitglied des Repair-Cafés das die Termine mit den Gästen o
|
|||
|
||||
## Anwendungsfälle
|
||||
|
||||
Die Anwendungsfälle sind in Meilensteine unterteilt.
|
||||
Zu Meilenstein 1 (M1) gehören alle Anwendungsfälle die notwendig sind um RepApp "produktiv" für die Anmeldung von Geräten zu verwenden.
|
||||
|
||||
### Gast
|
||||
|
||||
- [x] M1: Als Gast möchte ich einen Termin für eine Reparatur vereinbaren. (A1)
|
||||
- [x] M1: Als Gast möchte ich eine Bestätigung für eine Reparaturanfrage bekommen. (A2)
|
||||
- [ ] M1: Als Gast möchte ich eine Terminbenachrichtigung oder eine Absage bekommen. (A3)
|
||||
- [ ] Als Gast möchte ich mein angemeldetes Gerät und dessen Status einsehen können. (A4)
|
||||
- [ ] M1: Als Gast möchte ich Rückfragen zum Gerät empfangen und diese beantworten können. (A5)
|
||||
- [ ] M1: Als Gast möchte ich Fragen ans Repair-Café stellen können. (A26)
|
||||
- [ ] M1: Als Gast möchte ich ausschließlich per eMail mit dem System interagieren können. (A30)
|
||||
- [ ] M1: Als Gast möchte per eMail Fragen ans Team stellen können. (A33)
|
||||
- [ ] M1: Als Gast möchte ich per eMail auf Rückfragen antworten können. (A34)
|
||||
- [ ] M1: Als Gast möchte ich eine Termineinladung per eMail bestätigen können. (A35)
|
||||
|
||||
- [ ] Als Gast möchte ich mein angemeldetes Gerät und dessen Status einsehen können. (A4)
|
||||
- [ ] Als Gast möchte ich ein Gerät für eine Folgereparatur anmelden können. (A6)
|
||||
- [ ] Als Gast möchte ich meine Daten ansehen können. (A21)
|
||||
- [ ] Als Gast möchte ich meine Daten löschen können. (A22)
|
||||
- [ ] Als Gast möchte ich eine Geräteanmeldung löschen können. (A23)
|
||||
- [ ] M1: Als Gast möchte ausschließlich per eMail mit dem System interagieren können. (A30)
|
||||
|
||||
### Reparateur
|
||||
|
||||
- [ ] M1: Als Reparateur möchte ich die angemeldeten Geräte ansehen können. (A7)
|
||||
- [ ] M1: Als Reparateur möchte ich mir ein angemeldetes Gerät zuordnen können. (A8)
|
||||
- [ ] Als Reparateur möchte ich ein angemeldetes Gerät ablehnen können. (A9)
|
||||
- [ ] M1: Als Reparateur möchte ich Rückfragen zum Gerät stellen können. (A10)
|
||||
- [ ] M1: Als Reparateur möchte ich eine Benachrichtigung bekommen wenn eine Rückfrage beantwortet wird. (A11)
|
||||
- [ ] M1: Als Reparateur möchte ich eine Benachrichtigung bekommen wenn mir ein Gerät zugeordnet wird. (A12)
|
||||
- [ ] M1: Als Reparateur möchte ich mich für ein Repair-Café anmelden können. (A13)
|
||||
- [ ] M1: Als Reparateur möchte ich mich für ein Repair-Café abmelden können. (A14)
|
||||
|
||||
- [ ] Als Reparateur möchte ich ein angemeldetes Gerät ablehnen können. (A9)
|
||||
- [ ] Als Reparateur möchte ich benachrichtigt werden wenn ein Gast eine mir zugeordnete Geräteanmeldung löscht. (A25)
|
||||
|
||||
### Organisator
|
||||
|
|
@ -55,21 +65,27 @@ Ein Organisator ist Mitglied des Repair-Cafés das die Termine mit den Gästen o
|
|||
- [ ] M1: Als Organisator möchte ich einen Reparateur zu einem Termin zuordnen können. (A20)
|
||||
- [ ] M1: Als Organisator möchte ich ein Gerät bearbeiten können. (A28)
|
||||
- [ ] M1: Als Organisator möchte ich ein Gerät absagen können, wenn der Gast mich informiert hat dass er nicht kommt. (A29)
|
||||
|
||||
- [ ] Als Organisator möchte ich benachrichtigt werden wenn ein Gast eine Geräteanmeldung löscht. (A24)
|
||||
- [ ] Als Organisator möchte ich alle Daten exportieren können. (A31)
|
||||
- [ ] Als Organisator möchte ich Geräte auf ein späteres Repair-Café verschieben können. (A32)
|
||||
|
||||
## Daten
|
||||
|
||||
|
||||
### Benutzer
|
||||
|
||||
Ein Repapp-Benutzer erweitert den Standard-Benutzer um eine eindeutige eMail-Adresse, um diese als Identifikationsmerkmal verwenden zu können.
|
||||
|
||||
### Cafe
|
||||
|
||||
Ein Cafe ist ein Repair-Café Termin.
|
||||
|
||||
Attribute:
|
||||
|
||||
- Datum: Datum der Veranstaltung
|
||||
- Ort: Beschreibung wo das Repair-Café stattfindet
|
||||
- Adresse: Adresse an der das Repair-Café stattfindet
|
||||
- Datum: Datum der Veranstaltung
|
||||
|
||||
Annahmen:
|
||||
|
||||
|
|
@ -113,6 +129,7 @@ Attribute:
|
|||
Technische Attribute:
|
||||
|
||||
- Identifier: SHA256 Hash aus Name + Wohnort + Timestamp
|
||||
- Benutzer: Referenz zum Benutzer der für diesen Gast erstellt wurde.
|
||||
|
||||
### Gerät
|
||||
|
||||
|
|
@ -120,6 +137,7 @@ Ein Gerät ist ein defekter Gegenstand der im Rahmen eines Repair-Cafés reparie
|
|||
|
||||
Attribute:
|
||||
|
||||
- Erstellungsdatum: Datum an dem diese Gerät angemeldet wurde.
|
||||
- Gerät: Bezeichnung des Geräts
|
||||
- Hersteller: Hersteller des Geräts
|
||||
- Fehler: Beschreibung des Defekts
|
||||
|
|
@ -158,7 +176,7 @@ Attribute:
|
|||
|
||||
- Frage: Frage zum Gerät oder Defekt
|
||||
- Antwort: Antwort des Gastes
|
||||
- Datum: Datum der Erstellung der Rückfrage
|
||||
- Erstellungsdatum: Datum der Erstellung der Rückfrage
|
||||
|
||||
Technische Attribute:
|
||||
|
||||
|
|
@ -179,6 +197,52 @@ Technische Attribute:
|
|||
- Cafe: Referenz zum Cafe
|
||||
- Gerät: Referenz zum Gerät
|
||||
|
||||
### Einmal-Login
|
||||
|
||||
Ein Einmal-Login ist ein Geheimnis das es einem Gast erlaubt sich einmalig damit anzumelden.
|
||||
|
||||
- Geheimnis: Geheimnis um den Gast zu identifizieren.
|
||||
- URL: URL die nach der Anmeldung angezeigt werden soll.
|
||||
- Erstellungsdatum: Datum an dem der Einmal-Login angelegt wurde.
|
||||
- Login benutzt?: Boolesches Flag. True wenn der Einmal-Login bereits benutzt wurde.
|
||||
- Login Datum: Datum an dem der Einmal-Login benutzt wurde, oder Datum der Erstellung, da das Feld aus technischen Gründen nicht leer sein kann.
|
||||
|
||||
Technische Attribute:
|
||||
|
||||
- Benutzer: Referenz zum Benutzer
|
||||
|
||||
### Nachricht
|
||||
|
||||
Eine Nachricht ist eine Anfrage von einem Gast.
|
||||
|
||||
- Nachricht: Anfrage des Gastes.
|
||||
- Antwort: Antwort an den Gast.
|
||||
- Erstellungsdatum: Datum an dem die Anfrage erstellt wurde.
|
||||
|
||||
Technische Attribute:
|
||||
|
||||
- Gast: Referenz zum Gast
|
||||
|
||||
## Anmelde Konzept
|
||||
|
||||
RepApp unterstützt pro Benutzer-Gruppe verschiedenen Anmelde-Konzepte.
|
||||
|
||||
## Gäste
|
||||
|
||||
Für Gäste wird automatsch ein Benutzerkonto angelegt, mit einem generierten, sicheren Passwort. Mit der eMail Adresse und dem Passwort kann ein Gast sich in der RepApp anmelden um seine Daten sehen und bearbeiten zu können.
|
||||
|
||||
### Einmal-Login
|
||||
|
||||
Für Gäste gibt es Einmal-Logins als Alternative. Ein Einmal-Login ist ein Geheimnis und eine Ziel-URL, das dem Gast in From eines Anmelde-Links in einer eMail mitgeteilt wird. Mit diesem Link kann der Gast sich einmalig anmelden, da das Geheimnis danach potentiell Dritten bekannt ist, z.B. über die Browser History.
|
||||
|
||||
Nach dem erfolgreichen Login wird der Gast automatisch zu der hinterlegten URL weitergeleitet. Dieser Mechanismus erlaubt es einem Gast "per Klick" Zugriff auf geschützte Daten zu geben.
|
||||
|
||||
Wenn ein Gast versucht den Einmal-Link nochmals zu verwenden, schlägt dies Fehl, und dem Gast wird automatisch ein neuer Einmal-Link per eMail mitgeteilt. Dies wird dem Gast auch über entsprechende Nachrichten mitgeteilt.
|
||||
|
||||
## Mitarbeiter
|
||||
|
||||
Der Login für Mitarbeiter ist nur über das Single-Sign-On der Repair-Cafés erlaubt (Keycloak), das mittels OIDC angebunden ist. Alle Mitarbeiter im Repair-Café haben dort bereits einen Benutzer, was zum einen sicherstellt dass es sich um einen Mitarbeiter handelt, und zum anderen auch die Veraltung der Benutzer in RepApp vereinfacht.
|
||||
|
||||
## Ansichten
|
||||
|
||||
### Landing Page: Repair-Cafés (S1)
|
||||
|
|
@ -187,12 +251,10 @@ URL: /
|
|||
|
||||
Name: index
|
||||
|
||||
Sicherheit: Die Seite ist ohne Zugangsbeschränkung oder Anmeldung erreichbar.
|
||||
|
||||
Diese Seite zeigt eine Liste der zukünftigen Repair-Café Termine.
|
||||
Sie enthält pro Repair-Café einen Knopf um ein Gerät für dieses Repair-Café anzumelden.
|
||||
|
||||
#### Sicherheit
|
||||
|
||||
Die Seite ist ohne Zugangsbeschränkung oder Anmeldung erreichbar.
|
||||
Sie enthält pro Repair-Café einen Link um ein Gerät für dieses Repair-Café anzumelden.
|
||||
|
||||
### Gerät anmelden (S2)
|
||||
|
||||
|
|
@ -200,16 +262,14 @@ URL: cafe/int:cafe/
|
|||
|
||||
Name: register_device
|
||||
|
||||
Sicherheit: Die Seite ist ohne Zugangsbeschränkung oder Anmeldung erreichbar.
|
||||
|
||||
Diese Seite zeigt das Formular zum anmelden der Geräte.
|
||||
Das Formular hat die Felder `eMail-Adresse`, `Art des Geräts`, `Hersteller & Modell/Typ`, `Fehlerbeschreibung`, `Foto vom Gerät`, `Foto vom Typenschild`, ein Kontrollkästchen `Folgetermin`, ein Kontrollkästchen `Informationen zur Reparaturabwicklung`, ein Kontrollkästchen `Datenschutz` und einen Knopf `Absenden` zum senden des Formulars.
|
||||
|
||||
Das Repair-Café zu dem die Anmeldung gehört ist über eine ID in der URL der Seite festgelegt.
|
||||
|
||||
Die eMail-Benachrichtigung and die Organisatoren und die Bestätigung an den Gast wird beim absenden des Formulars gesendet, falls die eMail Adresse zu einem bekannten Gast gehört.
|
||||
|
||||
#### Sicherheit
|
||||
|
||||
Die Seite ist ohne Zugangsbeschränkung oder Anmeldung erreichbar.
|
||||
Falls die eMail Adresse zu einem bekannten Gast gehört, wird beim absenden des Formulars eine eMail-Benachrichtigung and die Organisatoren und Bestätigung an den Gast gesendet.
|
||||
|
||||
### Gast anmelden (S3)
|
||||
|
||||
|
|
@ -217,16 +277,14 @@ URL: cafe/int:cafe/device/str:device_identifier/mail/str:mail/
|
|||
|
||||
Name: register_guest
|
||||
|
||||
Sicherheit: Die Seite ist ohne Zugangsbeschränkung oder Anmeldung erreichbar.
|
||||
|
||||
Diese Seite zeigt das Formular zum anmelden eines neuen Gastes.
|
||||
Das Formular hat die Felder `Name`, `Telefon`, `Wohnort` und einen Knopf `Absenden` zum senden des Formulars.
|
||||
|
||||
Die eMail-Adresse und das Gerät zu dem die Gast-Kontaktdaten gehören ist über IDs in der URL der Seite festgelegt.
|
||||
|
||||
Die eMail-Benachrichtigung and die Organisatoren und die Bestätigung an den Gast wird beim absenden des Formulars gesendet.
|
||||
|
||||
#### Sicherheit
|
||||
|
||||
Die Seite ist ohne Zugangsbeschränkung oder Anmeldung erreichbar.
|
||||
Beim absenden des Formulars eine eMail-Benachrichtigung and die Organisatoren und Bestätigung an den Gast gesendet.
|
||||
|
||||
### Anmeldung bestätigt (S7)
|
||||
|
||||
|
|
@ -234,45 +292,43 @@ URL: cafe/int:cafe/device/str:device_identifier/confirm/
|
|||
|
||||
Name: register_device_final
|
||||
|
||||
Sicherheit: Die Seite ist ohne Zugangsbeschränkung oder Anmeldung erreichbar.
|
||||
|
||||
Die Seite Anmeldung bestätigt zeigt einen Hinweis dass die Anmeldung des Gerätes erfolgreich abgeschlossen wurde.
|
||||
|
||||
Die Geräte ID ist in der URL der Seite festgelegt.
|
||||
|
||||
#### Sicherheit
|
||||
|
||||
Die Seite ist ohne Zugangsbeschränkung oder Anmeldung erreichbar.
|
||||
|
||||
### Geräte Detailseite (S5)
|
||||
|
||||
URL: device/str:device_identifier/
|
||||
|
||||
Name: view_device
|
||||
|
||||
Sicherheit: Diese Seite ist für alle Mitarbeiter und den Gast der das Gerät angemeldet hat erreichbar.
|
||||
|
||||
Die Geräte Detailseite zeigt die Informationen `Art des Geräts`, `Hersteller & Modell/Typ`, `Fehlerbeschreibung`, `Foto vom Gerät`, `Foto vom Typenschild` und `Folgetermin` an.
|
||||
|
||||
Das Gerät ist über IDs in der URL der Seite festgelegt.
|
||||
|
||||
#### Sicherheit
|
||||
|
||||
Die Seite ist ohne Zugangsbeschränkung oder Anmeldung erreichbar.
|
||||
|
||||
### Gast Detailseite (S6)
|
||||
|
||||
URL: guest/profile/
|
||||
|
||||
Name: guest_profile
|
||||
|
||||
Sicherheit: Die Seite enthält persönliche Daten und ist nur für den Gast zu dem sie gehört erreichbar.
|
||||
|
||||
Die Gast Detailseite hat die Felder `Name`, `eMail`, `Telefon` und `Wohnort` die mit den Angaben des Gastes ausgefüllt sind.
|
||||
Weiter enthält die Seite einen Knopf `Aktualisieren` der das Formular absendet.
|
||||
Die Ansicht enthält auch eine Liste mit Links zu allen Geräten die der Gast angemeldet hat.
|
||||
|
||||
Der Gast ist über IDs in der URL der Seite festgelegt.
|
||||
|
||||
#### Sicherheit
|
||||
|
||||
Die Seite enthält persönliche Daten und ist nur nach Anmeldung erreichbar.
|
||||
Der Gast ist über den angemeldeten Benutzer festgelegt.
|
||||
|
||||
## Abläufe
|
||||
|
||||
### Anmeldung eines defekten Gerätes
|
||||
|
||||
#### Variante: Neuer Gast
|
||||
#### Variante: Neuer Gast (F1)
|
||||
|
||||
Als neuer Gast der ein defekten Geräte anmelden möchte,
|
||||
|
||||
|
|
@ -282,7 +338,7 @@ Als neuer Gast der ein defekten Geräte anmelden möchte,
|
|||
- Die nächste Seite (S5) bestätigt mir die Anmeldung meines Gerätes.
|
||||
- In meinem eMail Posteingang finde ich ebenfalls eine Bestätigung der Geräteanmeldung.
|
||||
|
||||
#### Variante: Bekannter Gast (existierende eMail Adresse)
|
||||
#### Variante: Bekannter Gast (existierende eMail Adresse) (F2)
|
||||
|
||||
Als bekannter Gast der ein defekten Geräte anmelden möchte,
|
||||
|
||||
|
|
@ -293,29 +349,32 @@ Als bekannter Gast der ein defekten Geräte anmelden möchte,
|
|||
|
||||
## Konfigurationsparameter
|
||||
|
||||
- DJANGO_SECRET_KEY
|
||||
- DJANGO_DEBUG
|
||||
- DJANGO_EMAIL_HOST
|
||||
- DJANGO_EMAIL_PORT
|
||||
- DJANGO_EMAIL_HOST_USER
|
||||
- DJANGO_EMAIL_HOST_PASSWORD
|
||||
- DJANGO_EMAIL_USE_TLS
|
||||
- OIDC_RP_CLIENT_SECRET
|
||||
- DJANGO_SECRET_KEY: Secret Key für das Django Framework.
|
||||
- DJANGO_DEBUG: Debug-Modus verwenden? Standard ist True.
|
||||
|
||||
- DJANGO_EMAIL_HOST: eMail Server
|
||||
- DJANGO_EMAIL_PORT: eMail Server Port
|
||||
- DJANGO_EMAIL_HOST_USER: Benutzername für den eMail Server
|
||||
- DJANGO_EMAIL_HOST_PASSWORD: Passwort für den eMail Server
|
||||
- DJANGO_EMAIL_USE_TLS: TLS verwenden? Standard ist True.
|
||||
|
||||
- OIDC_RP_CLIENT_SECRET: Secret für den OIDC Prozess. Ist auch im Keycloak konfiguriert.
|
||||
|
||||
### Feste Konfiguration
|
||||
|
||||
- ALLOWED_HOSTS
|
||||
- LANGUAGE_CODE
|
||||
- TIME_ZONE
|
||||
- CSRF_TRUSTED_ORIGINS
|
||||
- AUTH_USER_MODEL
|
||||
- AUTHENTICATION_BACKENDS
|
||||
- LOGIN_REDIRECT_URL
|
||||
- OIDC_RP_CLIENT_ID
|
||||
- OIDC_RP_SIGN_ALGO
|
||||
- OIDC_OP_JWKS_ENDPOINT
|
||||
- OIDC_OP_AUTHORIZATION_ENDPOINT
|
||||
- OIDC_OP_TOKEN_ENDPOINT
|
||||
- OIDC_OP_USER_ENDPOINT
|
||||
- LOGIN_REDIRECT_URL
|
||||
- LOGOUT_REDIRECT_URL
|
||||
- ALLOWED_HOSTS: Erlaubte Domainnamen. ("127.0.0.1", "localhost", "repapp.rc-hip.de", "anmeldung.repaircafe-hilpoltstein.de")
|
||||
- LANGUAGE_CODE: de
|
||||
- TIME_ZONE: Europe/Berlin
|
||||
- CSRF_TRUSTED_ORIGINS: Siehe ALLOWED_HOSTS.
|
||||
- AUTH_USER_MODEL: CustomUser in RepApp Models definiert.
|
||||
- AUTHENTICATION_BACKENDS: Standard + Backends aus RepApp Backends.
|
||||
|
||||
- OIDC_RP_CLIENT_ID: OIDC Client ID, im Keycloak konfiguriert.
|
||||
- OIDC_RP_SIGN_ALGO: RS256
|
||||
- OIDC_OP_JWKS_ENDPOINT: Keycloak URL
|
||||
- OIDC_OP_AUTHORIZATION_ENDPOINT: Keycloak URL
|
||||
- OIDC_OP_TOKEN_ENDPOINT: Keycloak URL
|
||||
- OIDC_OP_USER_ENDPOINT: Keycloak URL
|
||||
|
||||
- LOGIN_REDIRECT_URL: Debug: 127.0.0.1:8000, sonst https://anmeldung.repaircafe-hilpoltstein.de/
|
||||
- LOGOUT_REDIRECT_URL: Siehe LOGIN_REDIRECT_URL
|
||||
|
|
|
|||
|
|
@ -163,7 +163,6 @@ AUTH_USER_MODEL = "repapp.CustomUser"
|
|||
AUTHENTICATION_BACKENDS = [
|
||||
"repapp.backends.EmailBackend",
|
||||
"django.contrib.auth.backends.ModelBackend",
|
||||
# "mozilla_django_oidc.auth.OIDCAuthenticationBackend",
|
||||
"repapp.backends.KeycloakOIDCAB",
|
||||
"repapp.backends.OneTimeLoginBackend",
|
||||
]
|
||||
|
|
@ -184,5 +183,24 @@ if DEBUG:
|
|||
LOGIN_REDIRECT_URL = "http://127.0.0.1:8000/"
|
||||
LOGOUT_REDIRECT_URL = "http://127.0.0.1:8000/"
|
||||
else:
|
||||
LOGIN_REDIRECT_URL = "https://repapp.rc-hip.de/"
|
||||
LOGOUT_REDIRECT_URL = "https://repapp.rc-hip.de/"
|
||||
LOGIN_REDIRECT_URL = "https://anmeldung.repaircafe-hilpoltstein.de/"
|
||||
LOGOUT_REDIRECT_URL = "https://anmeldung.repaircafe-hilpoltstein.de/"
|
||||
|
||||
|
||||
LOG_LEVEL = "INFO"
|
||||
if DEBUG:
|
||||
LOG_LEVEL = "DEBUG"
|
||||
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
},
|
||||
},
|
||||
"root": {
|
||||
"handlers": ["console"],
|
||||
"level": LOG_LEVEL,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,22 @@ from django.contrib import admin
|
|||
from import_export.admin import ImportExportModelAdmin, ExportActionMixin
|
||||
from import_export import resources
|
||||
from .models import (Organisator, Cafe, Question, Device, CustomUser,
|
||||
Reparateur, Appointment, Guest, Candidate, OneTimeLogin)
|
||||
Reparateur, Appointment, Guest, Candidate, OneTimeLogin, Message)
|
||||
|
||||
|
||||
class CafeResource(resources.ModelResource):
|
||||
"""
|
||||
Resource wrapper for Cafe model.
|
||||
"""
|
||||
class Meta:
|
||||
"""
|
||||
Meta data for resource model.
|
||||
"""
|
||||
model = Cafe
|
||||
|
||||
@staticmethod
|
||||
def get_display_name():
|
||||
return "Repair-Café"
|
||||
|
||||
|
||||
class GuestResource(resources.ModelResource):
|
||||
|
|
@ -93,16 +108,20 @@ class ExportAdmin(ImportExportModelAdmin, ExportActionMixin):
|
|||
QuestionResource,
|
||||
AppointmentResource,
|
||||
CandidateResource,
|
||||
CafeResource,
|
||||
]
|
||||
|
||||
|
||||
# Register all models of RepApp for the admin interface.
|
||||
admin.site.register(Organisator)
|
||||
admin.site.register(Cafe)
|
||||
admin.site.register(Reparateur)
|
||||
admin.site.register(CustomUser)
|
||||
admin.site.register(OneTimeLogin)
|
||||
admin.site.register(Message)
|
||||
# Make the "data" models exportable.
|
||||
admin.site.register(Cafe, ExportAdmin)
|
||||
admin.site.register(Question, ExportAdmin)
|
||||
admin.site.register(Guest, ExportAdmin)
|
||||
admin.site.register(Device, ExportAdmin)
|
||||
admin.site.register(Reparateur)
|
||||
admin.site.register(Appointment, ExportAdmin)
|
||||
admin.site.register(Candidate, ExportAdmin)
|
||||
admin.site.register(CustomUser)
|
||||
admin.site.register(OneTimeLogin)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,39 @@
|
|||
"""
|
||||
Authentication backends for RepApp.
|
||||
"""
|
||||
import unicodedata
|
||||
import logging
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.backends import ModelBackend
|
||||
from mozilla_django_oidc.auth import OIDCAuthenticationBackend
|
||||
from .utils import generate_username, create_repapp_user
|
||||
from .models import OneTimeLogin
|
||||
from .models import OneTimeLogin, Organisator, Reparateur
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def generate_username(email):
|
||||
# Using Python 3 and Django 1.11+, usernames can contain alphanumeric
|
||||
# (ascii and unicode), _, @, +, . and - characters. So we normalize
|
||||
# it and slice at 150 characters.
|
||||
return unicodedata.normalize('NFKC', email)[:150]
|
||||
|
||||
|
||||
def create_repapp_user(user):
|
||||
organisator = Organisator.objects.filter(mail=user.email).first()
|
||||
if not organisator:
|
||||
reparateur = Reparateur.objects.filter(mail=user.email).first()
|
||||
if not reparateur:
|
||||
reparateur = Reparateur(
|
||||
name=user.username,
|
||||
mail=user.email,
|
||||
)
|
||||
reparateur.save()
|
||||
else:
|
||||
reparateur.name = user.username
|
||||
reparateur.save()
|
||||
else:
|
||||
organisator.name = user.username
|
||||
organisator.save()
|
||||
|
||||
|
||||
class OneTimeLoginBackend(ModelBackend):
|
||||
|
|
@ -14,7 +42,6 @@ class OneTimeLoginBackend(ModelBackend):
|
|||
"""
|
||||
|
||||
def authenticate(self, request, username=None, password=None, **kwargs):
|
||||
print(f'authenticate {username}')
|
||||
login = OneTimeLogin.objects.filter(secret=username).first()
|
||||
if login:
|
||||
return login.user
|
||||
|
|
@ -28,10 +55,10 @@ class EmailBackend(ModelBackend):
|
|||
"""
|
||||
|
||||
def authenticate(self, request, username=None, password=None, **kwargs):
|
||||
userModel = get_user_model()
|
||||
user_model = get_user_model()
|
||||
try:
|
||||
user = userModel.objects.get(email=username)
|
||||
except userModel.DoesNotExist:
|
||||
user = user_model.objects.get(email=username)
|
||||
except user_model.DoesNotExist:
|
||||
return None
|
||||
else:
|
||||
if user.check_password(password):
|
||||
|
|
@ -41,24 +68,37 @@ class EmailBackend(ModelBackend):
|
|||
|
||||
class KeycloakOIDCAB(OIDCAuthenticationBackend):
|
||||
"""
|
||||
KeycloakOIDCAB allows a login using Open ID Connect (with the Repair-Café Keycloak Single Sign On server)
|
||||
KeycloakOIDCAB allows a login using Open ID Connect
|
||||
(with the Repair-Café Keycloak Single Sign On server)
|
||||
"""
|
||||
|
||||
def create_user(self, claims):
|
||||
user = super(KeycloakOIDCAB, self).create_user(claims)
|
||||
logger.debug(f'Create user {user.email}')
|
||||
|
||||
try:
|
||||
user.username = claims.get(
|
||||
'preferred_username', generate_username(user.email))
|
||||
user.save()
|
||||
except Exception as exception:
|
||||
logger.error(exception)
|
||||
user.username = generate_username(user.email)
|
||||
user.save()
|
||||
|
||||
logger.debug(f'Updated username {user.username}')
|
||||
|
||||
create_repapp_user(user)
|
||||
|
||||
return user
|
||||
|
||||
def update_user(self, user, claims):
|
||||
logger.debug(f'Update user {user.email} ({user.username})')
|
||||
try:
|
||||
user.username = claims.get(
|
||||
'preferred_username', generate_username(user.email))
|
||||
user.save()
|
||||
except Exception as exception:
|
||||
logger.error(exception)
|
||||
|
||||
create_repapp_user(user)
|
||||
|
||||
|
|
|
|||
BIN
rc_hip/repapp/fixtures/Hummingbird.jpg
Normal file
BIN
rc_hip/repapp/fixtures/Hummingbird.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 572 KiB |
BIN
rc_hip/repapp/fixtures/Nut.jpg
Normal file
BIN
rc_hip/repapp/fixtures/Nut.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 933 KiB |
11
rc_hip/repapp/fixtures/cafe-data.json
Normal file
11
rc_hip/repapp/fixtures/cafe-data.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
[
|
||||
{
|
||||
"model": "repapp.cafe",
|
||||
"pk": 1,
|
||||
"fields": {
|
||||
"location": "Haus Einstein' (beim Gymnasium)",
|
||||
"address": "Patersholzer Weg 19, Hilpoltstein",
|
||||
"event_date": "2023-05-13"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -6,6 +6,9 @@ from django.core.exceptions import ValidationError
|
|||
|
||||
|
||||
class HoneypotField(forms.BooleanField):
|
||||
"""
|
||||
Simple honeypot field.
|
||||
"""
|
||||
default_widget = forms.CheckboxInput(
|
||||
{'style': 'display:none !important;', 'tabindex': '-1', 'autocomplete': 'off'})
|
||||
|
||||
|
|
@ -22,6 +25,9 @@ class HoneypotField(forms.BooleanField):
|
|||
|
||||
|
||||
class RegisterDevice(forms.Form):
|
||||
"""
|
||||
Form for registering a device for a repair cafe.
|
||||
"""
|
||||
mail = forms.EmailField(
|
||||
label="eMail Adresse",
|
||||
help_text="Diese eMail-Adresse wird für Aktualisierungen"
|
||||
|
|
@ -49,7 +55,8 @@ class RegisterDevice(forms.Form):
|
|||
)
|
||||
follow_up = forms.BooleanField(
|
||||
label="Folgetermin",
|
||||
help_text="Kreuzen Sie diese Kästchen an wenn sie mit diesem Gerät bereits bei einem Repair-Café Termin waren.",
|
||||
help_text="Kreuzen Sie diese Kästchen an wenn sie mit diesem Gerät "
|
||||
"bereits bei einem Repair-Café Termin waren.",
|
||||
required=False
|
||||
)
|
||||
confirm_repair = forms.BooleanField(
|
||||
|
|
@ -72,6 +79,9 @@ class RegisterDevice(forms.Form):
|
|||
|
||||
|
||||
class RegisterGuest(forms.Form):
|
||||
"""
|
||||
Form for registering new guests.
|
||||
"""
|
||||
name = forms.CharField(
|
||||
label="Name",
|
||||
max_length=200
|
||||
|
|
|
|||
112
rc_hip/repapp/live_tests.py
Normal file
112
rc_hip/repapp/live_tests.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import time
|
||||
import os
|
||||
from pathlib import Path
|
||||
from django.contrib.staticfiles.testing import StaticLiveServerTestCase
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.firefox.webdriver import WebDriver
|
||||
|
||||
|
||||
class WorkflowTests(StaticLiveServerTestCase):
|
||||
fixtures = ["cafe-data.json"]
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.selenium = WebDriver()
|
||||
cls.selenium.implicitly_wait(10)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.selenium.quit()
|
||||
super().tearDownClass()
|
||||
|
||||
def test_register_device(self):
|
||||
# Open landing page
|
||||
self.selenium.get(f"{self.live_server_url}/")
|
||||
# Click on a register link
|
||||
self.selenium.find_element(By.CLASS_NAME, "register_link").click()
|
||||
time.sleep(1)
|
||||
# Enter new device
|
||||
# Enter guest mail address
|
||||
mail = self.selenium.find_element(By.NAME, "mail")
|
||||
mail.send_keys("guest@example.com")
|
||||
# Enter device
|
||||
device = self.selenium.find_element(By.NAME, "device")
|
||||
device.send_keys("A Test Device")
|
||||
# Enter manufacturer
|
||||
manufacturer = self.selenium.find_element(By.NAME, "manufacturer")
|
||||
manufacturer.send_keys("A Test Manufacturer")
|
||||
# Enter a error description
|
||||
error = self.selenium.find_element(By.NAME, "error")
|
||||
error.send_keys("A description of the issue\nof the device.")
|
||||
# Skip device picture and device type plate
|
||||
# No follow up repair
|
||||
# Accept repair conditions
|
||||
self.selenium.find_element(By.NAME, "confirm_repair").click()
|
||||
# Accept data protection notice
|
||||
self.selenium.find_element(By.NAME, "confirm_data").click()
|
||||
time.sleep(1)
|
||||
# Send form
|
||||
self.selenium.find_element(By.CLASS_NAME, 'submit_button').click()
|
||||
time.sleep(1)
|
||||
# Register new guest
|
||||
# Enter guest name
|
||||
name = self.selenium.find_element(By.NAME, "name")
|
||||
name.send_keys("A Guest")
|
||||
# Enter guest phone
|
||||
phone = self.selenium.find_element(By.NAME, "phone")
|
||||
phone.send_keys("0911 123 456")
|
||||
# Enter guest residence
|
||||
residence = self.selenium.find_element(By.NAME, "residence")
|
||||
residence.send_keys("Musterstadt")
|
||||
time.sleep(1)
|
||||
# Send form
|
||||
self.selenium.find_element(By.CLASS_NAME, 'submit_button').click()
|
||||
time.sleep(1)
|
||||
self.assertTrue("confirm" in self.selenium.current_url)
|
||||
self.assertTrue("Anmeldung erfolgreich!" in self.selenium.page_source)
|
||||
# Register second device
|
||||
# Open landing page again
|
||||
self.selenium.get(f"{self.live_server_url}/")
|
||||
# Click on a register link
|
||||
self.selenium.find_element(By.CLASS_NAME, "register_link").click()
|
||||
time.sleep(1)
|
||||
# Enter new device
|
||||
# Enter guest mail address
|
||||
mail = self.selenium.find_element(By.NAME, "mail")
|
||||
mail.send_keys("guest@example.com")
|
||||
# Enter device
|
||||
device = self.selenium.find_element(By.NAME, "device")
|
||||
device.send_keys("Another Test Device")
|
||||
# Enter manufacturer
|
||||
manufacturer = self.selenium.find_element(By.NAME, "manufacturer")
|
||||
manufacturer.send_keys("Another Test Manufacturer")
|
||||
# Enter a error description
|
||||
error = self.selenium.find_element(By.NAME, "error")
|
||||
error.send_keys("Another issue description.")
|
||||
# Add device picture
|
||||
test_script_folder = Path(__file__).resolve().parent
|
||||
fixtures_folder = os.path.join(test_script_folder, "fixtures")
|
||||
device_picture_file = os.path.join(fixtures_folder, "Hummingbird.jpg")
|
||||
device_picture = self.selenium.find_element(By.NAME, "device_picture")
|
||||
device_picture.send_keys(device_picture_file)
|
||||
print(f"Using device picture {device_picture_file}")
|
||||
# Add type plate picture
|
||||
type_plate_picture_file = os.path.join(fixtures_folder, "Nut.jpg")
|
||||
type_plate_picture = self.selenium.find_element(
|
||||
By.NAME, "type_plate_picture")
|
||||
type_plate_picture.send_keys(type_plate_picture_file)
|
||||
print(f"Using type plate picture {type_plate_picture_file}")
|
||||
# Select as follow up repair
|
||||
self.selenium.find_element(By.NAME, "follow_up").click()
|
||||
# Accept repair conditions
|
||||
self.selenium.find_element(By.NAME, "confirm_repair").click()
|
||||
# Accept data protection notice
|
||||
self.selenium.find_element(By.NAME, "confirm_data").click()
|
||||
time.sleep(1)
|
||||
# Send form
|
||||
self.selenium.find_element(By.CLASS_NAME, 'submit_button').click()
|
||||
time.sleep(1)
|
||||
# Guest is known, no guest registration needed.
|
||||
self.assertTrue("confirm" in self.selenium.current_url)
|
||||
self.assertTrue("Anmeldung erfolgreich!" in self.selenium.page_source)
|
||||
20
rc_hip/repapp/migrations/0003_guest_user.py
Normal file
20
rc_hip/repapp/migrations/0003_guest_user.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# Generated by Django 4.2 on 2023-04-25 07:05
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('repapp', '0002_message'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='guest',
|
||||
name='user',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Benutzer'),
|
||||
),
|
||||
]
|
||||
18
rc_hip/repapp/migrations/0004_alter_onetimelogin_secret.py
Normal file
18
rc_hip/repapp/migrations/0004_alter_onetimelogin_secret.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 4.2 on 2023-04-25 12:08
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('repapp', '0003_guest_user'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='onetimelogin',
|
||||
name='secret',
|
||||
field=models.CharField(max_length=200, unique=True, verbose_name='Geheimnis'),
|
||||
),
|
||||
]
|
||||
|
|
@ -7,6 +7,27 @@ from django.utils.translation import gettext_lazy as _
|
|||
from django.contrib.auth.models import AbstractUser
|
||||
|
||||
|
||||
def device_directory_path(instance, filename):
|
||||
"""
|
||||
device_directory_path generates a device-specific storage path for file uploads.
|
||||
"""
|
||||
return f'device_{instance.identifier}/{filename}'
|
||||
|
||||
|
||||
class CustomUser(AbstractUser):
|
||||
"""
|
||||
Custom user object with unique email.
|
||||
"""
|
||||
email = models.EmailField(unique=True, verbose_name=_("eMail Adresse"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Benutzer')
|
||||
verbose_name_plural = _('Benutzer')
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.email}'
|
||||
|
||||
|
||||
class Cafe(models.Model):
|
||||
"""
|
||||
A Cafe is a "Repair-Café event.
|
||||
|
|
@ -64,6 +85,8 @@ class Guest(models.Model):
|
|||
phone = models.CharField(max_length=200, verbose_name=_("Telefonnummer"))
|
||||
residence = models.CharField(max_length=200, verbose_name=_("Wohnort"))
|
||||
mail = models.CharField(max_length=200, verbose_name=_("eMail"))
|
||||
user = models.ForeignKey(
|
||||
CustomUser, on_delete=models.CASCADE, null=True, verbose_name=_("Benutzer"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Gast')
|
||||
|
|
@ -73,18 +96,13 @@ class Guest(models.Model):
|
|||
return f'Gast {self.name} (eMail: {self.mail})'
|
||||
|
||||
|
||||
def device_directory_path(instance, filename):
|
||||
"""
|
||||
device_directory_path generates a device-specific storage path for file uploads.
|
||||
"""
|
||||
return f'device_{instance.identifier}/{filename}'
|
||||
|
||||
|
||||
class Device(models.Model):
|
||||
"""
|
||||
A Device is a broken device owned by a guest which shall be repaired during a Repair-Café.
|
||||
"""
|
||||
identifier = models.CharField(max_length=200, verbose_name=_("ID"))
|
||||
date = models.DateField(verbose_name=_(
|
||||
"Erstellungsdatum"), default=django.utils.timezone.now)
|
||||
device = models.CharField(max_length=200, verbose_name=_("Art des Geräts"))
|
||||
manufacturer = models.CharField(
|
||||
max_length=200, verbose_name=_("Hersteller & Modell/Typ"))
|
||||
|
|
@ -94,13 +112,11 @@ class Device(models.Model):
|
|||
upload_to=device_directory_path, null=True, verbose_name=_("Foto vom Gerät"))
|
||||
type_plate_picture = models.FileField(
|
||||
upload_to=device_directory_path, null=True, verbose_name=_("Foto vom Typenschild"))
|
||||
confirmed = models.BooleanField(verbose_name=_("Bestätigung gesendet?"))
|
||||
guest = models.ForeignKey(
|
||||
Guest, on_delete=models.CASCADE, null=True, verbose_name=_("Gast"))
|
||||
cafe = models.ForeignKey(
|
||||
Cafe, on_delete=models.CASCADE, null=False, verbose_name=_("Repair-Café"))
|
||||
confirmed = models.BooleanField(verbose_name=_("Bestätigung gesendet?"))
|
||||
date = models.DateField(verbose_name=_(
|
||||
"Erstellungsdatum"), default=django.utils.timezone.now)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Gerät')
|
||||
|
|
@ -174,25 +190,12 @@ class Candidate(models.Model):
|
|||
return f'Kandidat {self.cafe.event_date} für Gerät {self.device.device}'
|
||||
|
||||
|
||||
class CustomUser(AbstractUser):
|
||||
email = models.EmailField(unique=True, verbose_name=_("eMail Adresse"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Benutzer')
|
||||
verbose_name_plural = _('Benutzer')
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.email}'
|
||||
|
||||
|
||||
class OneTimeLogin(models.Model):
|
||||
"""
|
||||
A OneTimeLogin is a secret which can be used once to login a user.
|
||||
"""
|
||||
secret = models.CharField(max_length=200, verbose_name=_(
|
||||
"secret"), unique=True, null=False)
|
||||
user = models.ForeignKey(
|
||||
CustomUser, on_delete=models.CASCADE, null=False, verbose_name=_("Benutzer"))
|
||||
"Geheimnis"), unique=True, null=False)
|
||||
url = models.CharField(max_length=200, verbose_name=_("URL"))
|
||||
created = models.DateField(verbose_name=_(
|
||||
"Erstellungsdatum"), default=django.utils.timezone.now)
|
||||
|
|
@ -200,6 +203,8 @@ class OneTimeLogin(models.Model):
|
|||
verbose_name=_("Login benutzt?"), default=False)
|
||||
login_date = models.DateField(verbose_name=_(
|
||||
"Login Datum"), null=True)
|
||||
user = models.ForeignKey(
|
||||
CustomUser, on_delete=models.CASCADE, null=False, verbose_name=_("Benutzer"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Einmal-Login')
|
||||
|
|
|
|||
|
|
@ -308,7 +308,7 @@
|
|||
<p>
|
||||
<strong>
|
||||
Nur per Voranmeldung.
|
||||
<a href="{% url 'register_device' cafe.id %}">Zum Anmeldeformular</a>
|
||||
<a class="register_link" href="{% url 'register_device' cafe.id %}">Zum Anmeldeformular</a>
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,6 @@
|
|||
enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<button type="submit" class="btn btn-primary">Absenden</button>
|
||||
<button type="submit" class="btn btn-primary submit_button">Absenden</button>
|
||||
</form>
|
||||
{% endblock content %}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
{% extends "repapp/base.html" %}
|
||||
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block title %}- Gast anmelden{% endblock title %}
|
||||
|
||||
{% block title %}
|
||||
- Gast anmelden
|
||||
{% endblock title %}
|
||||
{% block content %}
|
||||
<h1 class="mt-2">Ihre Daten</h1>
|
||||
<hr class="mt-0 mb-4">
|
||||
<form action="{% url 'register_guest' cafe.id device.identifier mail %}" method="post">
|
||||
<h1 class="mt-2">Ihre Daten</h1>
|
||||
<hr class="mt-0 mb-4">
|
||||
<form action="{% url 'register_guest' cafe.id device.identifier mail %}"
|
||||
method="post">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<button type="submit" class="btn btn-primary">Absenden</button>
|
||||
</form>
|
||||
<button type="submit" class="btn btn-primary submit_button">Absenden</button>
|
||||
</form>
|
||||
{% endblock content %}
|
||||
|
|
@ -1 +1,139 @@
|
|||
# Create your tests here.
|
||||
"""
|
||||
Tests for RepApp.
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
import django.utils.timezone
|
||||
|
||||
from django.test import TestCase
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
|
||||
from .backends import OneTimeLoginBackend, EmailBackend
|
||||
from .models import OneTimeLogin, CustomUser, Cafe
|
||||
|
||||
|
||||
class OneTimeLoginTest(TestCase):
|
||||
"""
|
||||
Tests for the one time login feature.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
user = CustomUser(
|
||||
email="user@example.com",
|
||||
)
|
||||
user.save()
|
||||
self.user = user
|
||||
|
||||
otl = OneTimeLogin(
|
||||
secret="LetMeIn",
|
||||
user=user,
|
||||
url="/test/url",
|
||||
)
|
||||
otl.save()
|
||||
self.otl = otl
|
||||
|
||||
def test_allowed_one_time_login(self):
|
||||
"""
|
||||
Test that one time login is allowed.
|
||||
"""
|
||||
backend = OneTimeLoginBackend()
|
||||
result = backend.authenticate(None, self.otl.secret, None)
|
||||
|
||||
self.assertEqual(result, self.user)
|
||||
|
||||
def test_unknown_one_time_login(self):
|
||||
"""
|
||||
Test that one time login is not allowed for unknown secret.
|
||||
"""
|
||||
|
||||
backend = OneTimeLoginBackend()
|
||||
result = backend.authenticate(None, "other phrase", None)
|
||||
|
||||
self.assertEqual(result, None)
|
||||
|
||||
|
||||
class EmailLoginTest(TestCase):
|
||||
"""
|
||||
Tests for the one time login feature.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
user = CustomUser(
|
||||
email="user@example.com"
|
||||
)
|
||||
user.set_password("aPassword")
|
||||
user.save()
|
||||
self.user = user
|
||||
|
||||
def test_allowed_email_login(self):
|
||||
"""
|
||||
Test that email login is allowed.
|
||||
"""
|
||||
backend = EmailBackend()
|
||||
result = backend.authenticate(None, self.user.email, "aPassword")
|
||||
|
||||
self.assertEqual(result, self.user)
|
||||
|
||||
def test_unknown_email_login(self):
|
||||
"""
|
||||
Test that email login is not allowed for unknown user.
|
||||
"""
|
||||
|
||||
backend = EmailBackend()
|
||||
result = backend.authenticate(
|
||||
None, "other@example.com", "somePassword")
|
||||
|
||||
self.assertEqual(result, None)
|
||||
|
||||
def test_wrong_password_login(self):
|
||||
"""
|
||||
Test that email login is not allowed for wrong password.
|
||||
"""
|
||||
|
||||
backend = EmailBackend()
|
||||
result = backend.authenticate(
|
||||
None, self.user.email, "wrongPassword")
|
||||
|
||||
self.assertEqual(result, None)
|
||||
|
||||
|
||||
class UtilsTest(TestCase):
|
||||
"""
|
||||
Test for utility functions.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class ViewsTest(TestCase):
|
||||
"""
|
||||
Test for RepApp views.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
cafe = Cafe(location="neuer Ort", address="neue Adresse",
|
||||
event_date=django.utils.timezone.now())
|
||||
cafe.save()
|
||||
self.cafe = cafe
|
||||
|
||||
date = datetime.now() - timedelta(days=7)
|
||||
old_cafe = Cafe(location="alter Ort", address="alte Adresse",
|
||||
event_date=date)
|
||||
old_cafe.save()
|
||||
self.old_cafe = old_cafe
|
||||
|
||||
def test_index(self):
|
||||
"""
|
||||
Test index page.
|
||||
"""
|
||||
client = Client(enforce_csrf_checks=True)
|
||||
response = client.get(reverse('index'))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# ensure future cafe is displayed
|
||||
self.assertContains(response, self.cafe.location)
|
||||
self.assertContains(response, self.cafe.address)
|
||||
|
||||
# ensure old cafe is not displayed
|
||||
self.assertNotContains(response, self.old_cafe.location)
|
||||
self.assertNotContains(response, self.old_cafe.address)
|
||||
|
|
|
|||
|
|
@ -1,171 +0,0 @@
|
|||
import os
|
||||
import unicodedata
|
||||
import datetime
|
||||
import random
|
||||
from hashlib import sha256
|
||||
from django.template.loader import render_to_string
|
||||
from django.urls import reverse
|
||||
from django.core.mail import send_mail
|
||||
from django.contrib import messages
|
||||
from repapp.models import Organisator, Reparateur, OneTimeLogin
|
||||
|
||||
|
||||
def create_one_time_login(user, url):
|
||||
"""
|
||||
create_one_time_login create a one time login object for user logins.
|
||||
"""
|
||||
secret = sha256(
|
||||
f'{user.email}{url}{datetime.datetime.now()}{random.randint(0,9999999)}'.encode(
|
||||
'utf-8')
|
||||
).hexdigest()
|
||||
otl = OneTimeLogin(
|
||||
secret=secret,
|
||||
user=user,
|
||||
url=url,
|
||||
)
|
||||
otl.save()
|
||||
return otl
|
||||
|
||||
|
||||
def send_one_time_login_mail(one_time_login, request):
|
||||
url = request.build_absolute_uri(
|
||||
f'/onetimelogin/{one_time_login.secret}/')
|
||||
subject = render_to_string(
|
||||
'repapp/mail/mail_one_time_login_subject.html').replace('\n', '')
|
||||
text = render_to_string('repapp/mail/mail_one_time_login_text.html', {
|
||||
'link': url,
|
||||
})
|
||||
html = render_to_string('repapp/mail/mail_one_time_login_html.html', {
|
||||
'link': url,
|
||||
})
|
||||
|
||||
ok = send_mail(
|
||||
subject=subject,
|
||||
message=text,
|
||||
from_email=os.getenv("DJANGO_SENDER_ADDRESS", ""),
|
||||
recipient_list=[f"{one_time_login.user.email}"],
|
||||
fail_silently=True,
|
||||
html_message=html
|
||||
)
|
||||
|
||||
if ok > 0:
|
||||
messages.add_message(request, messages.INFO,
|
||||
'Sie haben einen neuen Login per eMail erhalten.')
|
||||
|
||||
|
||||
def create_repapp_user(user):
|
||||
organisator = Organisator.objects.filter(mail=user.email).first()
|
||||
if not organisator:
|
||||
reparateur = Reparateur.objects.filter(mail=user.email).first()
|
||||
if not reparateur:
|
||||
reparateur = Reparateur(
|
||||
name=user.username,
|
||||
mail=user.email,
|
||||
)
|
||||
reparateur.save()
|
||||
else:
|
||||
reparateur.name = user.username
|
||||
reparateur.save()
|
||||
else:
|
||||
organisator.name = user.username
|
||||
organisator.save()
|
||||
|
||||
|
||||
def generate_username(email):
|
||||
# Using Python 3 and Django 1.11+, usernames can contain alphanumeric
|
||||
# (ascii and unicode), _, @, +, . and - characters. So we normalize
|
||||
# it and slice at 150 characters.
|
||||
return unicodedata.normalize('NFKC', email)[:150]
|
||||
|
||||
|
||||
def send_confirmation_mails(device, guest, cafe, request):
|
||||
organizers = []
|
||||
for organizer in Organisator.objects.all():
|
||||
organizers.append(organizer.mail)
|
||||
|
||||
text = render_to_string('repapp/mail/notice_new_device.html', {
|
||||
'guest': device.guest,
|
||||
'device': device,
|
||||
'cafe': device.cafe,
|
||||
})
|
||||
|
||||
send_mail(
|
||||
subject=f"Neues Gerät { device.device }",
|
||||
message=text,
|
||||
from_email=os.getenv("DJANGO_SENDER_ADDRESS", ""),
|
||||
recipient_list=organizers,
|
||||
fail_silently=True
|
||||
)
|
||||
|
||||
path = reverse('view_device', kwargs={
|
||||
'device_identifier': device.identifier})
|
||||
url = request.build_absolute_uri(path)
|
||||
otl = create_one_time_login(guest.user, url)
|
||||
login_url = request.build_absolute_uri(f'/onetimelogin/{otl.secret}/')
|
||||
|
||||
subject = render_to_string('repapp/mail/mail_register_device_subject.html', {
|
||||
'cafe': cafe,
|
||||
}).replace('\n', '')
|
||||
text = render_to_string('repapp/mail/mail_register_device_text.html', {
|
||||
'guest': guest,
|
||||
'device': device,
|
||||
'cafe': cafe,
|
||||
'login_url': login_url,
|
||||
})
|
||||
html = render_to_string('repapp/mail/mail_register_device_html.html', {
|
||||
'guest': guest,
|
||||
'device': device,
|
||||
'cafe': cafe,
|
||||
'login_url': login_url,
|
||||
})
|
||||
|
||||
mail_count = send_mail(
|
||||
subject=subject,
|
||||
message=text,
|
||||
from_email=os.getenv("DJANGO_SENDER_ADDRESS", ""),
|
||||
recipient_list=[f"{guest.mail}"],
|
||||
fail_silently=True,
|
||||
html_message=html
|
||||
)
|
||||
|
||||
if mail_count > 0:
|
||||
device.confirmed = True
|
||||
device.save()
|
||||
else:
|
||||
messages.add_message(request, messages.ERROR,
|
||||
'Fehler beim senden der Bestätigungs-eMail!')
|
||||
|
||||
|
||||
def send_guest_account_mail(guest, password, request):
|
||||
url = request.build_absolute_uri('/guest/profile/')
|
||||
subject = render_to_string(
|
||||
'repapp/mail/mail_new_guest_subject.html').replace('\n', '')
|
||||
text = render_to_string('repapp/mail/mail_new_guest_text.html', {
|
||||
'link': url,
|
||||
'username': guest.user.email,
|
||||
'password': password,
|
||||
})
|
||||
html = render_to_string('repapp/mail/mail_new_guest_html.html', {
|
||||
'link': url,
|
||||
'username': guest.user.email,
|
||||
'password': password,
|
||||
})
|
||||
|
||||
ok = send_mail(
|
||||
subject=subject,
|
||||
message=text,
|
||||
from_email=os.getenv("DJANGO_SENDER_ADDRESS", ""),
|
||||
recipient_list=[f"{guest.user.email}"],
|
||||
fail_silently=True,
|
||||
html_message=html
|
||||
)
|
||||
|
||||
if ok > 0:
|
||||
messages.add_message(request, messages.INFO,
|
||||
'Sie haben ihre Benutzerdaten per eMail erhalten.')
|
||||
|
||||
|
||||
def is_member(user):
|
||||
organisator = Organisator.objects.filter(mail=user.email).first()
|
||||
reparateur = Reparateur.objects.filter(mail=user.email).first()
|
||||
return organisator or reparateur
|
||||
|
|
@ -5,7 +5,12 @@ import datetime
|
|||
import random
|
||||
import string
|
||||
import time
|
||||
import os
|
||||
from hashlib import sha256
|
||||
from django.template.loader import render_to_string
|
||||
from django.urls import reverse
|
||||
from django.core.mail import send_mail
|
||||
from django.contrib import messages
|
||||
from django.views import generic
|
||||
from django.urls import reverse_lazy
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
|
|
@ -15,11 +20,146 @@ from django.contrib import messages
|
|||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth import authenticate, login
|
||||
from django.utils.timezone import now
|
||||
from repapp.models import CustomUser
|
||||
from repapp.models import CustomUser, Organisator, Reparateur
|
||||
from .models import Cafe, Device, Guest, OneTimeLogin
|
||||
from .forms import RegisterDevice, RegisterGuest
|
||||
from .utils import (send_confirmation_mails, create_one_time_login,
|
||||
send_one_time_login_mail, send_guest_account_mail, is_member)
|
||||
|
||||
|
||||
def send_one_time_login_mail(secret, mail, request):
|
||||
url = request.build_absolute_uri(
|
||||
f'/onetimelogin/{secret}/')
|
||||
subject = render_to_string(
|
||||
'repapp/mail/mail_one_time_login_subject.html').replace('\n', '')
|
||||
text = render_to_string('repapp/mail/mail_one_time_login_text.html', {
|
||||
'link': url,
|
||||
})
|
||||
html = render_to_string('repapp/mail/mail_one_time_login_html.html', {
|
||||
'link': url,
|
||||
})
|
||||
|
||||
ok = send_mail(
|
||||
subject=subject,
|
||||
message=text,
|
||||
from_email=os.getenv("DJANGO_SENDER_ADDRESS", ""),
|
||||
recipient_list=[mail],
|
||||
fail_silently=True,
|
||||
html_message=html
|
||||
)
|
||||
|
||||
if ok > 0:
|
||||
messages.add_message(request, messages.INFO,
|
||||
'Sie haben einen neuen Login per eMail erhalten.')
|
||||
|
||||
|
||||
def send_confirmation_mails(device, guest, cafe, request):
|
||||
organizers = []
|
||||
for organizer in Organisator.objects.all():
|
||||
organizers.append(organizer.mail)
|
||||
|
||||
text = render_to_string('repapp/mail/notice_new_device.html', {
|
||||
'guest': device.guest,
|
||||
'device': device,
|
||||
'cafe': device.cafe,
|
||||
})
|
||||
|
||||
send_mail(
|
||||
subject=f"Neues Gerät { device.device }",
|
||||
message=text,
|
||||
from_email=os.getenv("DJANGO_SENDER_ADDRESS", ""),
|
||||
recipient_list=organizers,
|
||||
fail_silently=True
|
||||
)
|
||||
|
||||
path = reverse('view_device', kwargs={
|
||||
'device_identifier': device.identifier})
|
||||
url = request.build_absolute_uri(path)
|
||||
secret = create_one_time_login(guest.user, url)
|
||||
login_url = request.build_absolute_uri(f'/onetimelogin/{secret}/')
|
||||
|
||||
subject = render_to_string('repapp/mail/mail_register_device_subject.html', {
|
||||
'cafe': cafe,
|
||||
}).replace('\n', '')
|
||||
text = render_to_string('repapp/mail/mail_register_device_text.html', {
|
||||
'guest': guest,
|
||||
'device': device,
|
||||
'cafe': cafe,
|
||||
'login_url': login_url,
|
||||
})
|
||||
html = render_to_string('repapp/mail/mail_register_device_html.html', {
|
||||
'guest': guest,
|
||||
'device': device,
|
||||
'cafe': cafe,
|
||||
'login_url': login_url,
|
||||
})
|
||||
|
||||
mail_count = send_mail(
|
||||
subject=subject,
|
||||
message=text,
|
||||
from_email=os.getenv("DJANGO_SENDER_ADDRESS", ""),
|
||||
recipient_list=[f"{guest.mail}"],
|
||||
fail_silently=True,
|
||||
html_message=html
|
||||
)
|
||||
|
||||
if mail_count > 0:
|
||||
device.confirmed = True
|
||||
device.save()
|
||||
else:
|
||||
messages.add_message(request, messages.ERROR,
|
||||
'Fehler beim senden der Bestätigungs-eMail!')
|
||||
|
||||
|
||||
def send_guest_account_mail(guest, password, request):
|
||||
url = request.build_absolute_uri('/guest/profile/')
|
||||
subject = render_to_string(
|
||||
'repapp/mail/mail_new_guest_subject.html').replace('\n', '')
|
||||
text = render_to_string('repapp/mail/mail_new_guest_text.html', {
|
||||
'link': url,
|
||||
'username': guest.user.email,
|
||||
'password': password,
|
||||
})
|
||||
html = render_to_string('repapp/mail/mail_new_guest_html.html', {
|
||||
'link': url,
|
||||
'username': guest.user.email,
|
||||
'password': password,
|
||||
})
|
||||
|
||||
ok = send_mail(
|
||||
subject=subject,
|
||||
message=text,
|
||||
from_email=os.getenv("DJANGO_SENDER_ADDRESS", ""),
|
||||
recipient_list=[f"{guest.user.email}"],
|
||||
fail_silently=True,
|
||||
html_message=html
|
||||
)
|
||||
|
||||
if ok > 0:
|
||||
messages.add_message(request, messages.INFO,
|
||||
'Sie haben ihre Benutzerdaten per eMail erhalten.')
|
||||
|
||||
|
||||
def is_member(user):
|
||||
organisator = Organisator.objects.filter(mail=user.email).first()
|
||||
reparateur = Reparateur.objects.filter(mail=user.email).first()
|
||||
return organisator or reparateur
|
||||
|
||||
|
||||
def create_one_time_login(user, url) -> str:
|
||||
"""
|
||||
create_one_time_login create a one time login object for user logins.
|
||||
"""
|
||||
secret = sha256(
|
||||
f'{user.email}{url}{datetime.datetime.now()}{random.randint(0,9999999)}'.encode(
|
||||
'utf-8')
|
||||
).hexdigest()
|
||||
hash = sha256(secret.encode('utf-8')).hexdigest()
|
||||
otl = OneTimeLogin(
|
||||
secret=hash,
|
||||
user=user,
|
||||
url=url,
|
||||
)
|
||||
otl.save()
|
||||
return secret
|
||||
|
||||
|
||||
class IndexView(generic.ListView):
|
||||
|
|
@ -65,23 +205,17 @@ class RegisterDeviceFormView(generic.edit.FormView):
|
|||
|
||||
guest = Guest.objects.filter(mail=mail).first()
|
||||
|
||||
print(f'Register: Dev: {device}, Guest: {guest}')
|
||||
|
||||
if guest:
|
||||
print('Guest already known')
|
||||
device.guest = guest
|
||||
device.save()
|
||||
|
||||
send_confirmation_mails(device, guest, cafe, self.request)
|
||||
|
||||
print('Mails sent')
|
||||
|
||||
resp = HttpResponseRedirect(
|
||||
reverse_lazy('register_device_final', kwargs={
|
||||
'cafe': cafe.pk,
|
||||
'device_identifier': device.identifier})
|
||||
)
|
||||
print(resp)
|
||||
return resp
|
||||
else:
|
||||
return HttpResponseRedirect(
|
||||
|
|
@ -103,6 +237,9 @@ class RegisterDeviceFormView(generic.edit.FormView):
|
|||
|
||||
|
||||
class RegisterGuestFormView(generic.edit.FormView):
|
||||
"""
|
||||
View for registering a new guest.
|
||||
"""
|
||||
template_name = "repapp/register_guest.html"
|
||||
form_class = RegisterGuest
|
||||
|
||||
|
|
@ -134,12 +271,23 @@ class RegisterGuestFormView(generic.edit.FormView):
|
|||
if not CustomUser.objects.filter(email=mail).exists():
|
||||
password = ''.join(random.choice(string.ascii_letters)
|
||||
for i in range(10))
|
||||
|
||||
username = name
|
||||
i = 1
|
||||
while CustomUser.objects.filter(username=username).first():
|
||||
username = f'{name}{i}'
|
||||
i = i + 1
|
||||
|
||||
user = CustomUser.objects.create_user(
|
||||
username=name,
|
||||
username=username,
|
||||
email=mail,
|
||||
password=password)
|
||||
)
|
||||
user.set_password(password)
|
||||
user.save()
|
||||
|
||||
guest.user = user
|
||||
guest.save()
|
||||
|
||||
send_guest_account_mail(guest, password, self.request)
|
||||
|
||||
send_confirmation_mails(device, guest, cafe, self.request)
|
||||
|
|
@ -157,17 +305,29 @@ class RegisterGuestFormView(generic.edit.FormView):
|
|||
device_identifier = self.kwargs['device_identifier']
|
||||
device = get_object_or_404(Device, identifier=device_identifier)
|
||||
|
||||
mail = self.kwargs['mail']
|
||||
guest = Guest.objects.filter(mail=mail).first()
|
||||
if guest:
|
||||
return HttpResponseRedirect(
|
||||
reverse_lazy('register_device_final', kwargs={
|
||||
'cafe': cafe.pk,
|
||||
'device_identifier': device.identifier})
|
||||
)
|
||||
|
||||
context = super(RegisterGuestFormView, self).get_context_data(
|
||||
**kwargs
|
||||
)
|
||||
context["cafe"] = cafe
|
||||
context["device"] = device
|
||||
context["mail"] = self.kwargs['mail']
|
||||
context["mail"] = mail
|
||||
|
||||
return context
|
||||
|
||||
|
||||
def register_device_final(request, cafe, device_identifier):
|
||||
"""
|
||||
View to confirm device registration.
|
||||
"""
|
||||
cafe = get_object_or_404(Cafe, pk=cafe)
|
||||
device = get_object_or_404(Device, identifier=device_identifier)
|
||||
|
||||
|
|
@ -180,6 +340,9 @@ def register_device_final(request, cafe, device_identifier):
|
|||
|
||||
@login_required
|
||||
def device_view(request, device_identifier):
|
||||
"""
|
||||
View for showing device details.
|
||||
"""
|
||||
user = request.user
|
||||
if not user:
|
||||
raise PermissionDenied()
|
||||
|
|
@ -200,6 +363,9 @@ def device_view(request, device_identifier):
|
|||
|
||||
@login_required
|
||||
def profile(request):
|
||||
"""
|
||||
View for showing guest details.
|
||||
"""
|
||||
user = request.user
|
||||
if not user:
|
||||
raise PermissionDenied()
|
||||
|
|
@ -216,6 +382,9 @@ def profile(request):
|
|||
|
||||
|
||||
def member_login(request):
|
||||
"""
|
||||
Login page for repair cafe members, using OIDC.
|
||||
"""
|
||||
return render(
|
||||
request,
|
||||
"repapp/member_login.html"
|
||||
|
|
@ -223,23 +392,34 @@ def member_login(request):
|
|||
|
||||
|
||||
def cron(request):
|
||||
"""
|
||||
View to trigger automated regular tasks.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
def process_mails(request):
|
||||
"""
|
||||
View to trigger processing of email in inbox.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
def one_time_login(request, secret):
|
||||
def one_time_login(request, secret: str):
|
||||
"""
|
||||
View for one time login.
|
||||
"""
|
||||
# waste a little time as brute force protection
|
||||
time.sleep(1)
|
||||
|
||||
otl = get_object_or_404(OneTimeLogin, secret=secret)
|
||||
hash = sha256(secret.encode('utf-8')).hexdigest()
|
||||
otl = get_object_or_404(OneTimeLogin, secret=hash)
|
||||
|
||||
if otl.login_used:
|
||||
messages.add_message(request, messages.ERROR,
|
||||
'Der Einmal-Login wurde schon verwendet und ist nichtmehr gültig.')
|
||||
new_otl = create_one_time_login(otl.user, otl.url)
|
||||
send_one_time_login_mail(new_otl, request)
|
||||
secret = create_one_time_login(otl.user, otl.url)
|
||||
send_one_time_login_mail(secret, otl.user.email, request)
|
||||
return HttpResponseRedirect(reverse_lazy('index'))
|
||||
else:
|
||||
otl.login_used = True
|
||||
|
|
|
|||
|
|
@ -9,3 +9,4 @@ crispy-bootstrap4==2022.1
|
|||
mozilla-django-oidc==3.0.0
|
||||
djlint==1.23.3
|
||||
django-import-export==3.2.0
|
||||
selenium==4.9.0
|
||||
|
|
|
|||
Reference in a new issue