Allow changing raid responses character/roles through JavaScript dropdown menu.
This commit is contained in:
parent
41eff3bec2
commit
15896cb0b6
13 changed files with 230 additions and 84 deletions
|
@ -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
17
drakul/base/exceptions.py
Normal 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)
|
|
@ -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
19
drakul/base/signals.py
Normal 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
|
|
@ -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
9
drakul/base/viewsets.py
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
10
drakul/raids/serializers.py
Normal file
10
drakul/raids/serializers.py
Normal 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"]
|
|
@ -93,14 +93,42 @@
|
|||
</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 %}>
|
||||
{% if response.is_guest %}
|
||||
<span class="float-left">⇌</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 }}
|
||||
<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">⇌</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 %}
|
||||
</div>
|
||||
{% 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 %}
|
||||
|
|
|
@ -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"),
|
||||
]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue