From 15896cb0b6a37c09b81de2baad5881b285c2f33a Mon Sep 17 00:00:00 2001 From: "Casper V. Kristensen" Date: Thu, 1 Oct 2020 20:14:03 +0200 Subject: [PATCH] Allow changing raid responses character/roles through JavaScript dropdown menu. --- drakul/base/apps.py | 4 + drakul/base/exceptions.py | 17 +++ drakul/base/settings/common.py | 12 +++ drakul/base/signals.py | 19 ++++ drakul/base/static/css/main.css | 79 +++++++++++--- drakul/base/viewsets.py | 9 ++ drakul/raids/forms.py | 12 --- drakul/raids/models.py | 9 +- drakul/raids/serializers.py | 10 ++ drakul/raids/templates/raids/raid_detail.html | 102 ++++++++++++------ drakul/raids/urls.py | 9 +- drakul/raids/views.py | 31 +++--- requirements.txt | 1 + 13 files changed, 230 insertions(+), 84 deletions(-) create mode 100644 drakul/base/exceptions.py create mode 100644 drakul/base/signals.py create mode 100644 drakul/base/viewsets.py create mode 100644 drakul/raids/serializers.py diff --git a/drakul/base/apps.py b/drakul/base/apps.py index e99c639..f4b32e3 100755 --- a/drakul/base/apps.py +++ b/drakul/base/apps.py @@ -3,3 +3,7 @@ from django.apps import AppConfig class BaseConfig(AppConfig): name = "drakul.base" + + def ready(self): + # noinspection PyUnresolvedReferences + from . import signals diff --git a/drakul/base/exceptions.py b/drakul/base/exceptions.py new file mode 100644 index 0000000..2a9a80e --- /dev/null +++ b/drakul/base/exceptions.py @@ -0,0 +1,17 @@ +from django.core.exceptions import ValidationError as DjangoValidationError, ImproperlyConfigured +from rest_framework.exceptions import ValidationError as DRFValidationError + + +def drf_validation_error(exception: DjangoValidationError): + """ + Transform Django model validation errors into an equivalent DRF ValidationError. + """ + if hasattr(exception, "message_dict"): + detail = exception.message_dict + elif hasattr(exception, "message"): + detail = exception.message + elif hasattr(exception, "messages"): + detail = exception.messages + else: + raise ImproperlyConfigured + return DRFValidationError(detail=detail) diff --git a/drakul/base/settings/common.py b/drakul/base/settings/common.py index 78b38e3..ee8fac0 100755 --- a/drakul/base/settings/common.py +++ b/drakul/base/settings/common.py @@ -22,6 +22,7 @@ INSTALLED_APPS = ( "drakul.users.apps.UsersConfig", # Third party apps + "rest_framework", "crispy_forms", # Django apps @@ -154,6 +155,17 @@ STATIC_ROOT = os.path.join(BASE_DIR, "static/") SITE_ID = 1 +# Django REST Framework +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework.authentication.SessionAuthentication", + ], + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.DjangoModelPermissions", + ] +} + + # Crispy Forms CRISPY_TEMPLATE_PACK = "bootstrap4" diff --git a/drakul/base/signals.py b/drakul/base/signals.py new file mode 100644 index 0000000..0df45c2 --- /dev/null +++ b/drakul/base/signals.py @@ -0,0 +1,19 @@ +from django.contrib.sessions.models import Session +from django.core.exceptions import ValidationError as DjangoValidationError +from django.db.models.signals import pre_save +from django.dispatch import receiver + +from drakul.base.exceptions import drf_validation_error + + +@receiver(pre_save) +def pre_save_full_clean_handler(sender, instance, *args, **kwargs): + """ + Force all models to call full_clean() before save(), to keep validation logic DRY between DRF and forms. + """ + if sender == Session: + return + try: + instance.full_clean() + except DjangoValidationError as e: + raise drf_validation_error(e) # we must be in a DRF view, otherwise Django would already have caught the error diff --git a/drakul/base/static/css/main.css b/drakul/base/static/css/main.css index e4f47f5..2a1e3cf 100644 --- a/drakul/base/static/css/main.css +++ b/drakul/base/static/css/main.css @@ -30,71 +30,120 @@ .bg-warrior, .bg-class-9 {background-color: #c79c6e; color: #fff;} -/* WoW class-coloured alerts */ -.alert-druid, .alert-class-1 { +/* WoW class-coloured buttons */ +.btn-druid, .btn-class-1 { color: #fff; background-color: #ff7d0a; border-color: #ff7d0a; } +.btn-druid:hover, .btn-class-1:hover { + color: #fff; + background-color: #eb7c0a; + border-color: #eb7c0a; +} -.alert-hunter, .alert-class-2 { +.btn-hunter, .btn-class-2 { color: #212529; background-color: #abd473; border-color: #abd473; } +.btn-hunter:hover, .btn-class-2:hover { + color: #212529; + background-color: #98c062; + border-color: #98c062; +} -.alert-mage, .alert-class-3 { +.btn-mage, .btn-class-3 { color: #fff; background-color: #69ccf0; border-color: #69ccf0; } +.btn-mage:hover, .btn-class-3:hover { + color: #fff; + background-color: #67b8dc; + border-color: #67b8dc; +} -.alert-paladin, .alert-class-4 { +.btn-paladin, .btn-class-4 { color: #fff; background-color: #f58cba; border-color: #f58cba; } +.btn-paladin:hover, .btn-class-4:hover { + color: #fff; + background-color: #e17aa7; + border-color: #e17aa7; +} -.alert-priest, .alert-class-5 { +.btn-priest, .btn-class-5 { color: #212529; background-color: #ffffff; border-color: #ced4da; } +.btn-priest:hover, .btn-class-5:hover { + color: #212529; + background-color: #ebebeb; + border-color: #ced2d6; +} -.alert-rogue, .alert-class-6 { +.btn-rogue, .btn-class-6 { color: #212529; background-color: #fff569; border-color: #f5eb63; } +.btn-rogue:hover, .btn-class-6:hover { + color: #212529; + background-color: #ebe168; + border-color: #e1d761; +} -.alert-shaman, .alert-class-7 { +.btn-shaman, .btn-class-7 { color: #fff; background-color: #0070de; border-color: #0070de; } +.btn-shaman:hover, .btn-class-7:hover { + color: #fff; + background-color: #0065ca; + border-color: #0065ca; +} -.alert-warlock, .alert-class-8 { +.btn-warlock, .btn-class-8 { color: #fff; background-color: #9482c9; border-color: #9482c9; } +.btn-warlock:hover, .btn-class-8:hover { + color: #fff; + background-color: #816fb5; + border-color: #816fb5; +} -.alert-warrior, .alert-class-9 { +.btn-warrior, .btn-class-9 { color: #fff; background-color: #c79c6e; border-color: #c79c6e; } +.btn-warrior:hover, .btn-class-9:hover { + color: #fff; + background-color: #b3895c; + border-color: #b3895c; +} - -.response-character-alert { - width: 16ch; - padding: .375rem .75rem; /* from bootstrap's .btn */ - text-align: center; +.response-character-dropdown { touch-action: none; /* required by interact.js */ user-select: none; /* required by interact.js */ -moz-user-select: none; /* required by interact.js */ } +.response-character-btn { + width: 16ch; +} + +.response-character-btn:disabled { + opacity: initial; +} + /* Response bg and text colors from https://getbootstrap.com/docs/4.3/utilities/colors/ */ .response-status-no-response-bg, .response-status-0-bg { /* secondary */ diff --git a/drakul/base/viewsets.py b/drakul/base/viewsets.py new file mode 100644 index 0000000..230cae4 --- /dev/null +++ b/drakul/base/viewsets.py @@ -0,0 +1,9 @@ +from rest_framework import mixins, viewsets + + +class UpdateViewSet(mixins.UpdateModelMixin, + viewsets.GenericViewSet): + """ + A viewset that provides `update()` and `partial_update()` actions. + """ + pass diff --git a/drakul/raids/forms.py b/drakul/raids/forms.py index 9f4e7f7..25522fd 100644 --- a/drakul/raids/forms.py +++ b/drakul/raids/forms.py @@ -64,9 +64,6 @@ class RaidResponseForm(ModelForm): self.helper.form_show_labels = False self.helper.form_tag = False - def clean_role(self): - return self.cleaned_data["role"] or self.cleaned_data["character"].role - class GuestRaidResponseForm(ModelForm): class Meta: @@ -104,15 +101,6 @@ class GuestRaidResponseForm(ModelForm): self.fields["status"].initial = RaidResponse.Status.SIGNED_UP -class RaidResponseChangeForm(ModelForm): - class Meta: - model = RaidResponse - fields = ["role", "status", "group"] - - def clean_role(self): - return self.cleaned_data["role"] or self.instance.character.role - - class RaidForm(ModelForm): class Meta: model = Raid diff --git a/drakul/raids/models.py b/drakul/raids/models.py index d75f8c5..7ecbc54 100755 --- a/drakul/raids/models.py +++ b/drakul/raids/models.py @@ -134,7 +134,7 @@ class RaidResponse(models.Model): self._original_status = self.status def clean(self): - # Either Character or Guest Name/Guest Class must be set + # Either Character or Guest Name/Guest Class must be exclusively set if self.character is None: errors = {} if self.guest_name is None: @@ -149,11 +149,14 @@ class RaidResponse(models.Model): # Make sure no-responses and sign-offs are character- and role-agnostic, but all other responses are not if self.status <= RaidResponse.Status.SIGNED_OFF: + self.role = None if not self.is_guest: self.character = self.character.user.main - self.role = None elif self.role is None: - raise ValidationError({"role": "This field is required."}) + if not self.is_guest: + self.role = self.character.role + else: + raise ValidationError({"role": "This field is required."}) # Set attendance to one of the default values if status was changed if self.status != self._original_status: diff --git a/drakul/raids/serializers.py b/drakul/raids/serializers.py new file mode 100644 index 0000000..b2c2ecd --- /dev/null +++ b/drakul/raids/serializers.py @@ -0,0 +1,10 @@ +from rest_framework import serializers + +from .models import RaidResponse + + +class RaidResponseSerializer(serializers.ModelSerializer): + class Meta: + model = RaidResponse + fields = ["id", "character", "role", "status", "group"] + read_only_fields = ["id"] diff --git a/drakul/raids/templates/raids/raid_detail.html b/drakul/raids/templates/raids/raid_detail.html index 583b266..50faad7 100644 --- a/drakul/raids/templates/raids/raid_detail.html +++ b/drakul/raids/templates/raids/raid_detail.html @@ -93,14 +93,42 @@ --> {% for response in rank_responses %} -