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):
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",
# 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"

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;}
/* 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 */

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

View file

@ -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,10 +149,13 @@ 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:
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

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,8 +93,12 @@
</div>
-->
{% 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 }}"
{% if response.role is not None %} data-response-role="{{ response.role }}"{% endif %}>
<div class="dropdown response-character-dropdown" data-response-id="{{ response.id }}">
<button id="response-character-btn-{{ response.id }}"
class="btn btn-class-{{ response.character_klass }} response-character-btn text-truncate mb-1"
type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"
{% if not perms.raids.change_raid %}disabled{% endif %}
>
{% if response.is_guest %}
<span class="float-left">&#8652;</span>{{ response.character_name }}
{% elif response.character != response.character.user.main %}
@ -102,6 +106,30 @@
{% 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 %}
</div>
{% endfor %}
{% endfor %}
@ -156,17 +184,31 @@
// Export button
const charactersTextinput = document.querySelector("#characters-textinput");
$("#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();
}).get().join("\n");
});
{% 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
const csrftoken = getCookie("csrftoken");
let original;
const position = {};
interact(".response-character-alert").draggable({
interact(".response-character-dropdown").draggable({
manualStart: true,
listeners: {
start(event) {
@ -174,6 +216,7 @@
let boundingClientRect = original.getBoundingClientRect();
event.target.style.position = "fixed";
event.target.querySelector(".response-character-btn").style.cursor = "move";
event.target.style.top = boundingClientRect.top + "px";
event.target.style.left = boundingClientRect.left + "px";
position.x = 0;
@ -202,37 +245,30 @@
document.body.appendChild(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({
accept: ".response-character-alert",
accept: ".response-character-dropdown",
ondrop(event) {
// ignore if dropped into its original dropzone
if (event.target.closest(".response-container") === original.closest(".response-container")) {
return;
}
let form = new FormData();
if (event.relatedTarget.dataset.responseRole) {
form.append("role", event.relatedTarget.dataset.responseRole);
}
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();
updateResponse(event.relatedTarget.dataset.responseId, {
status: event.target.dataset.responseContainerStatus,
group: event.target.dataset.responseContainerGroup,
});
}
});
// 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 %}
</script>
{% 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
router = routers.DefaultRouter()
router.register(r"raids/responses", views.RaidResponseUpdateViewSet)
urlpatterns = [
path("api/", include(router.urls)),
path("calendar/", 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/<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>/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.mixins import PermissionRequiredMixin
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.utils import timezone
from django.views.generic import DeleteView, CreateView, MonthArchiveView
from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import BaseUpdateView
from drakul.base.views import MultiModelFormView
from .forms import RaidResponseForm, RaidCommentForm, RaidForm, RaidResponseFormSetHelper, RaidResponseFormSet, \
RaidResponseChangeForm, GuestRaidResponseForm
GuestRaidResponseForm
from .models import Raid, RaidResponse, InstanceReset
from .serializers import RaidResponseSerializer
from ..base.viewsets import UpdateViewSet
from ..users.models import Character
User = get_user_model()
@ -94,6 +96,7 @@ class RaidDetailView(SingleObjectMixin, MultiModelFormView):
return Raid.objects.prefetch_related(
"responses__character__user__rank",
"responses__character__user__main",
"responses__character__user__characters",
"comments__user__main"
).all()
@ -106,6 +109,7 @@ class RaidDetailView(SingleObjectMixin, MultiModelFormView):
for status, responses in itertools.groupby(context["raid"].responses.all(), key=lambda r: r.status):
raid_responses[status] = list(responses)
context["raid_responses"] = raid_responses
context["character_roles"] = Character.Role.choices
return context
def get_form_classes(self):
@ -166,22 +170,6 @@ class RaidDetailView(SingleObjectMixin, MultiModelFormView):
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):
permission_required = "raids.add_raid"
model = Raid
@ -229,3 +217,8 @@ class RaidDeleteView(PermissionRequiredMixin, DeleteView):
permission_required = "raids.delete_raid"
queryset = Raid.objects.all()
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
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