Allow changing raid responses character/roles through JavaScript dropdown menu.

This commit is contained in:
Casper V. Kristensen 2020-10-01 20:14:03 +02:00
parent 41eff3bec2
commit 15896cb0b6
Signed by: caspervk
GPG key ID: 289CA03790535054
13 changed files with 230 additions and 84 deletions

View file

@ -3,3 +3,7 @@ from django.apps import AppConfig
class BaseConfig(AppConfig): class BaseConfig(AppConfig):
name = "drakul.base" name = "drakul.base"
def ready(self):
# noinspection PyUnresolvedReferences
from . import signals

17
drakul/base/exceptions.py Normal file
View file

@ -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)

View file

@ -22,6 +22,7 @@ INSTALLED_APPS = (
"drakul.users.apps.UsersConfig", "drakul.users.apps.UsersConfig",
# Third party apps # Third party apps
"rest_framework",
"crispy_forms", "crispy_forms",
# Django apps # Django apps
@ -154,6 +155,17 @@ STATIC_ROOT = os.path.join(BASE_DIR, "static/")
SITE_ID = 1 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 Forms
CRISPY_TEMPLATE_PACK = "bootstrap4" CRISPY_TEMPLATE_PACK = "bootstrap4"

19
drakul/base/signals.py Normal file
View file

@ -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

View file

@ -30,71 +30,120 @@
.bg-warrior, .bg-class-9 {background-color: #c79c6e; color: #fff;} .bg-warrior, .bg-class-9 {background-color: #c79c6e; color: #fff;}
/* WoW class-coloured alerts */ /* WoW class-coloured buttons */
.alert-druid, .alert-class-1 { .btn-druid, .btn-class-1 {
color: #fff; color: #fff;
background-color: #ff7d0a; background-color: #ff7d0a;
border-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; color: #212529;
background-color: #abd473; background-color: #abd473;
border-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; color: #fff;
background-color: #69ccf0; background-color: #69ccf0;
border-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; color: #fff;
background-color: #f58cba; background-color: #f58cba;
border-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; color: #212529;
background-color: #ffffff; background-color: #ffffff;
border-color: #ced4da; 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; color: #212529;
background-color: #fff569; background-color: #fff569;
border-color: #f5eb63; 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; color: #fff;
background-color: #0070de; background-color: #0070de;
border-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; color: #fff;
background-color: #9482c9; background-color: #9482c9;
border-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; color: #fff;
background-color: #c79c6e; background-color: #c79c6e;
border-color: #c79c6e; border-color: #c79c6e;
} }
.btn-warrior:hover, .btn-class-9:hover {
color: #fff;
background-color: #b3895c;
border-color: #b3895c;
}
.response-character-dropdown {
.response-character-alert {
width: 16ch;
padding: .375rem .75rem; /* from bootstrap's .btn */
text-align: center;
touch-action: none; /* required by interact.js */ touch-action: none; /* required by interact.js */
user-select: none; /* required by interact.js */ user-select: none; /* required by interact.js */
-moz-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 bg and text colors from https://getbootstrap.com/docs/4.3/utilities/colors/ */
.response-status-no-response-bg, .response-status-0-bg { /* secondary */ .response-status-no-response-bg, .response-status-0-bg { /* secondary */

9
drakul/base/viewsets.py Normal file
View file

@ -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

View file

@ -64,9 +64,6 @@ class RaidResponseForm(ModelForm):
self.helper.form_show_labels = False self.helper.form_show_labels = False
self.helper.form_tag = 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 GuestRaidResponseForm(ModelForm):
class Meta: class Meta:
@ -104,15 +101,6 @@ class GuestRaidResponseForm(ModelForm):
self.fields["status"].initial = RaidResponse.Status.SIGNED_UP 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 RaidForm(ModelForm):
class Meta: class Meta:
model = Raid model = Raid

View file

@ -134,7 +134,7 @@ class RaidResponse(models.Model):
self._original_status = self.status self._original_status = self.status
def clean(self): 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: if self.character is None:
errors = {} errors = {}
if self.guest_name is None: 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 # 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: if self.status <= RaidResponse.Status.SIGNED_OFF:
self.role = None
if not self.is_guest: if not self.is_guest:
self.character = self.character.user.main self.character = self.character.user.main
self.role = None
elif self.role is 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 # Set attendance to one of the default values if status was changed
if self.status != self._original_status: if self.status != self._original_status:

View file

@ -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"]

View file

@ -93,14 +93,42 @@
</div> </div>
--> -->
{% for response in rank_responses %} {% for response in rank_responses %}
<div class="alert response-character-alert mb-1 alert-class-{{ response.character_klass }} text-truncate" role="alert" data-response-id="{{ response.id }}" <div class="dropdown response-character-dropdown" data-response-id="{{ response.id }}">
{% if response.role is not None %} data-response-role="{{ response.role }}"{% endif %}> <button id="response-character-btn-{{ response.id }}"
{% if response.is_guest %} class="btn btn-class-{{ response.character_klass }} response-character-btn text-truncate mb-1"
<span class="float-left">&#8652;</span>{{ response.character_name }} type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"
{% elif response.character != response.character.user.main %} {% if not perms.raids.change_raid %}disabled{% endif %}
<abbr title="{{ response.character.user.main }}">{{ response.character_name }}</abbr> >
{% else %} {% if response.is_guest %}
{{ response.character_name }} <span class="float-left">&#8652;</span>{{ response.character_name }}
{% elif response.character != response.character.user.main %}
<abbr title="{{ response.character.user.main }}">{{ response.character_name }}</abbr>
{% else %}
{{ response.character_name }}
{% endif %}
</button>
{% if perms.raids.change_raid %}
<div class="dropdown-menu" aria-labelledby="response-character-btn-{{ response.id }}">
{% if not response.is_guest %}
{% for character in response.character.user.characters.all %}
<button class="dropdown-item response-dropdown-btn" type="button"
data-response-character="{{ character.id }}"
data-response-role=""
{% if character == response.character %}disabled{% endif %}
>
{{ character }}
</button>
{% endfor %}
<div class="dropdown-divider"></div>
{% endif %}
{% for role_id, role_name in character_roles %}
<button class="dropdown-item response-dropdown-btn" type="button" data-response-role="{{ role_id }}"
{% if role_id == response.role %}disabled{% endif %}
>
{{ role_name }}
</button>
{% endfor %}
</div>
{% endif %} {% endif %}
</div> </div>
{% endfor %} {% endfor %}
@ -156,17 +184,31 @@
// Export button // Export button
const charactersTextinput = document.querySelector("#characters-textinput"); const charactersTextinput = document.querySelector("#characters-textinput");
$("#exportModal").on("show.bs.modal", function (event) { $("#exportModal").on("show.bs.modal", function (event) {
charactersTextinput.textContent = $(event.relatedTarget).closest(".card").find(".response-character-alert").map(function () { charactersTextinput.textContent = $(event.relatedTarget).closest(".card").find(".response-character-btn").map(function () {
return this.innerText.trim(); return this.innerText.trim();
}).get().join("\n"); }).get().join("\n");
}); });
{% if perms.raids.change_raid %} {% if perms.raids.change_raid %}
function updateResponse(id, data) {
document.body.style.cursor = "wait";
fetch(`/api/raids/responses/${id}/`, {
method: "PATCH",
mode: "same-origin",
headers: {
"X-CSRFToken": getCookie("csrftoken"),
"Content-Type": "application/json"
},
body: JSON.stringify(data),
}).then(function (response) {
location.reload();
});
}
// Raid response dragging // Raid response dragging
const csrftoken = getCookie("csrftoken");
let original; let original;
const position = {}; const position = {};
interact(".response-character-alert").draggable({ interact(".response-character-dropdown").draggable({
manualStart: true, manualStart: true,
listeners: { listeners: {
start(event) { start(event) {
@ -174,6 +216,7 @@
let boundingClientRect = original.getBoundingClientRect(); let boundingClientRect = original.getBoundingClientRect();
event.target.style.position = "fixed"; event.target.style.position = "fixed";
event.target.querySelector(".response-character-btn").style.cursor = "move";
event.target.style.top = boundingClientRect.top + "px"; event.target.style.top = boundingClientRect.top + "px";
event.target.style.left = boundingClientRect.left + "px"; event.target.style.left = boundingClientRect.left + "px";
position.x = 0; position.x = 0;
@ -202,37 +245,30 @@
document.body.appendChild(clone); document.body.appendChild(clone);
event.interaction.start({name: "drag"}, event.interactable, clone); event.interaction.start({name: "drag"}, event.interactable, clone);
}); });
document.querySelectorAll(".response-character-alert").forEach(function (element) {
element.style.cursor = "move";
});
interact(".response-container").dropzone({ interact(".response-container").dropzone({
accept: ".response-character-alert", accept: ".response-character-dropdown",
ondrop(event) { ondrop(event) {
// ignore if dropped into its original dropzone // ignore if dropped into its original dropzone
if (event.target.closest(".response-container") === original.closest(".response-container")) { if (event.target.closest(".response-container") === original.closest(".response-container")) {
return; return;
} }
let form = new FormData(); updateResponse(event.relatedTarget.dataset.responseId, {
if (event.relatedTarget.dataset.responseRole) { status: event.target.dataset.responseContainerStatus,
form.append("role", event.relatedTarget.dataset.responseRole); group: event.target.dataset.responseContainerGroup,
}
form.append("status", event.target.dataset.responseContainerStatus);
form.append("group", event.target.dataset.responseContainerGroup);
fetch(`/raids/responses/${event.relatedTarget.dataset.responseId}/change/`, {
method: "POST",
headers: {
"X-CSRFToken": csrftoken,
},
mode: "same-origin",
body: form,
}).then(function (response) {
document.body.style.cursor = "wait";
location.reload();
}); });
} }
}); });
// Raid response dropdown
$(".response-dropdown-btn").on("click", function () {
let data = {
role: this.dataset.responseRole || null,
};
if (this.dataset.responseCharacter) {
data["character"] = this.dataset.responseCharacter;
}
updateResponse(this.parentElement.parentElement.dataset.responseId, data);
});
{% endif %} {% endif %}
</script> </script>
{% endblock scripts %} {% endblock scripts %}

View file

@ -1,13 +1,18 @@
from django.urls import path from django.urls import path, include
from rest_framework import routers
from . import views from . import views
router = routers.DefaultRouter()
router.register(r"raids/responses", views.RaidResponseUpdateViewSet)
urlpatterns = [ urlpatterns = [
path("api/", include(router.urls)),
path("calendar/", views.RaidCalendar.as_view(), name="raid_calendar"), path("calendar/", views.RaidCalendar.as_view(), name="raid_calendar"),
path("calendar/<int:year>/<int:month>/", views.RaidCalendar.as_view(), name="raid_calendar"), path("calendar/<int:year>/<int:month>/", views.RaidCalendar.as_view(), name="raid_calendar"),
path("raids/create/", views.RaidCreateView.as_view(), name="raid_create"), path("raids/create/", views.RaidCreateView.as_view(), name="raid_create"),
path("raids/<int:pk>/", views.RaidDetailView.as_view(), name="raid_detail"), path("raids/<int:pk>/", views.RaidDetailView.as_view(), name="raid_detail"),
path("raids/<int:pk>/change/", views.RaidChangeView.as_view(), name="raid_change"), path("raids/<int:pk>/change/", views.RaidChangeView.as_view(), name="raid_change"),
path("raids/<int:pk>/delete/", views.RaidDeleteView.as_view(), name="raid_delete"), path("raids/<int:pk>/delete/", views.RaidDeleteView.as_view(), name="raid_delete"),
path("raids/responses/<int:pk>/change/", views.RaidResponseChangeView.as_view(), name="raid_response_change"),
] ]

View file

@ -5,17 +5,19 @@ from datetime import timedelta
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import Q, Max from django.db.models import Q, Max
from django.http import Http404, HttpResponse from django.http import Http404
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils import timezone from django.utils import timezone
from django.views.generic import DeleteView, CreateView, MonthArchiveView from django.views.generic import DeleteView, CreateView, MonthArchiveView
from django.views.generic.detail import SingleObjectMixin from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import BaseUpdateView
from drakul.base.views import MultiModelFormView from drakul.base.views import MultiModelFormView
from .forms import RaidResponseForm, RaidCommentForm, RaidForm, RaidResponseFormSetHelper, RaidResponseFormSet, \ from .forms import RaidResponseForm, RaidCommentForm, RaidForm, RaidResponseFormSetHelper, RaidResponseFormSet, \
RaidResponseChangeForm, GuestRaidResponseForm GuestRaidResponseForm
from .models import Raid, RaidResponse, InstanceReset from .models import Raid, RaidResponse, InstanceReset
from .serializers import RaidResponseSerializer
from ..base.viewsets import UpdateViewSet
from ..users.models import Character
User = get_user_model() User = get_user_model()
@ -94,6 +96,7 @@ class RaidDetailView(SingleObjectMixin, MultiModelFormView):
return Raid.objects.prefetch_related( return Raid.objects.prefetch_related(
"responses__character__user__rank", "responses__character__user__rank",
"responses__character__user__main", "responses__character__user__main",
"responses__character__user__characters",
"comments__user__main" "comments__user__main"
).all() ).all()
@ -106,6 +109,7 @@ class RaidDetailView(SingleObjectMixin, MultiModelFormView):
for status, responses in itertools.groupby(context["raid"].responses.all(), key=lambda r: r.status): for status, responses in itertools.groupby(context["raid"].responses.all(), key=lambda r: r.status):
raid_responses[status] = list(responses) raid_responses[status] = list(responses)
context["raid_responses"] = raid_responses context["raid_responses"] = raid_responses
context["character_roles"] = Character.Role.choices
return context return context
def get_form_classes(self): def get_form_classes(self):
@ -166,22 +170,6 @@ class RaidDetailView(SingleObjectMixin, MultiModelFormView):
return super().post(request, *args, **kwargs) return super().post(request, *args, **kwargs)
class RaidResponseChangeView(PermissionRequiredMixin, BaseUpdateView):
"""
For RaidResponse drag 'n dropping.
"""
permission_required = "raids.change_raid"
model = RaidResponse
form_class = RaidResponseChangeForm
def form_valid(self, form):
form.save()
return HttpResponse(status=204)
def form_invalid(self, form):
return HttpResponse(form, status=400)
class RaidCreateView(PermissionRequiredMixin, CreateView): class RaidCreateView(PermissionRequiredMixin, CreateView):
permission_required = "raids.add_raid" permission_required = "raids.add_raid"
model = Raid model = Raid
@ -229,3 +217,8 @@ class RaidDeleteView(PermissionRequiredMixin, DeleteView):
permission_required = "raids.delete_raid" permission_required = "raids.delete_raid"
queryset = Raid.objects.all() queryset = Raid.objects.all()
success_url = reverse_lazy("raid_calendar") success_url = reverse_lazy("raid_calendar")
class RaidResponseUpdateViewSet(UpdateViewSet):
queryset = RaidResponse.objects.all()
serializer_class = RaidResponseSerializer

View file

@ -1,3 +1,4 @@
Django==3.1.1 Django==3.1.1
psycopg2-binary>=2.5.4 # required by Django for PostgreSQL support: https://docs.djangoproject.com/en/2.2/ref/databases/#postgresql-notes psycopg2-binary>=2.5.4 # required by Django for PostgreSQL support: https://docs.djangoproject.com/en/2.2/ref/databases/#postgresql-notes
djangorestframework==3.12.1
django-crispy-forms==1.9.2 django-crispy-forms==1.9.2