Add guest raid responses.

This commit is contained in:
Casper V. Kristensen 2020-09-23 15:12:49 +02:00
parent 5381795edc
commit 12acc50d5a
Signed by: caspervk
GPG key ID: 289CA03790535054
7 changed files with 230 additions and 80 deletions

View file

@ -1,6 +1,6 @@
from crispy_forms.bootstrap import StrictButton from crispy_forms.bootstrap import StrictButton
from crispy_forms.helper import FormHelper from crispy_forms.helper import FormHelper
from crispy_forms.layout import Submit, Layout, Column, Row, Field from crispy_forms.layout import Submit, Layout, Column, Row, Field, HTML
from django.forms import ModelForm, inlineformset_factory from django.forms import ModelForm, inlineformset_factory
from .models import RaidResponse, RaidComment, Raid from .models import RaidResponse, RaidComment, Raid
@ -68,6 +68,40 @@ class RaidResponseForm(ModelForm):
return self.cleaned_data["role"] or self.cleaned_data["character"].role return self.cleaned_data["role"] or self.cleaned_data["character"].role
class GuestRaidResponseForm(ModelForm):
class Meta:
model = RaidResponse
fields = ["guest_name", "guest_klass", "role", "status"]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["status"].initial = RaidResponse.Status.SIGNED_UP
self.helper = FormHelper()
self.helper.layout = Layout(
Row(
Column("guest_name", css_class="col-12 col-md-3"),
Column("guest_klass", css_class="col-12 col-md-2"),
Column("role", css_class="col-12 col-md-2"),
Column("status", css_class="col-12 col-md-3"),
Column(
HTML("<label>&nbsp;</label>"), # offsets the button consistent with the other input elements
StrictButton(
"Add",
type="submit",
css_class=f"btn-block btn-primary"
),
css_class="form-group col-md-2"
),
css_class="form-row"
)
)
self.helper.render_hidden_fields = True # Include MultiFormMixin's hidden form_id_field_prefix
self.helper.form_show_labels = True
self.helper.form_tag = False
class RaidResponseChangeForm(ModelForm): class RaidResponseChangeForm(ModelForm):
class Meta: class Meta:
model = RaidResponse model = RaidResponse
@ -120,7 +154,7 @@ class RaidCommentForm(ModelForm):
RaidResponseFormSet = inlineformset_factory( RaidResponseFormSet = inlineformset_factory(
Raid, # parent model Raid, # parent model
RaidResponse, RaidResponse,
fields=["character", "role", "status", "group", "note", "attendance"], fields=["character", "guest_name", "guest_klass", "role", "status", "group", "note", "attendance"],
can_delete=False, can_delete=False,
extra=1 extra=1
) )
@ -133,10 +167,12 @@ class RaidResponseFormSetHelper(FormHelper):
super().__init__(form) super().__init__(form)
self.layout = Layout( self.layout = Layout(
Field("character", css_class="character-select"), Field("character", css_class="character-select"),
Field("guest_name", css_class="guest-name"),
Field("guest_klass", css_class="guest-klass"),
Field("role", css_class="role-select"), Field("role", css_class="role-select"),
Field("status", css_class="status-select"), Field("status", css_class="status-select"),
Field("group", css_class="group-select"), Field("group", css_class="group-select"),
Field("note"), Field("note"),
Field("attendance", css_class="attendance-input"), Field("attendance", css_class="attendance-input", style="width: 10ch"),
) )
self.form_tag = False self.form_tag = False

View file

@ -0,0 +1,31 @@
# Generated by Django 3.1.1 on 2020-09-23 14:50
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('users', '0005_auto_20191121_1646'),
('raids', '0009_auto_20200528_0410'),
]
operations = [
migrations.AddField(
model_name='raidresponse',
name='guest_klass',
field=models.PositiveSmallIntegerField(blank=True, choices=[(1, 'Druid'), (2, 'Hunter'), (3, 'Mage'), (4, 'Paladin'), (5, 'Priest'), (6, 'Rogue'), (7, 'Shaman'), (8, 'Warlock'), (9, 'Warrior')], null=True, verbose_name='Guest class'),
),
migrations.AddField(
model_name='raidresponse',
name='guest_name',
field=models.CharField(blank=True, max_length=12, null=True, validators=[django.core.validators.MinLengthValidator(2)], verbose_name='Guest name'),
),
migrations.AlterField(
model_name='raidresponse',
name='character',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='raid_responses', to='users.character'),
),
]

View file

@ -2,6 +2,7 @@ from datetime import timedelta
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MinLengthValidator
from django.db import models from django.db import models
from drakul.users.models import Character from drakul.users.models import Character
@ -53,9 +54,26 @@ class RaidResponse(models.Model):
Character, Character,
related_name="raid_responses", related_name="raid_responses",
on_delete=models.CASCADE, on_delete=models.CASCADE,
blank=True,
null=True,
db_index=True db_index=True
) )
guest_name = models.CharField(
"Guest name",
max_length=12, # Blizzard limits character names to 2-12 characters
validators=[MinLengthValidator(2)],
blank=True,
null=True
)
guest_klass = models.PositiveSmallIntegerField(
"Guest class",
choices=Character.Klass.choices,
blank=True,
null=True
)
role = models.PositiveSmallIntegerField( role = models.PositiveSmallIntegerField(
choices=Character.Role.choices, choices=Character.Role.choices,
blank=True, blank=True,
@ -116,9 +134,23 @@ 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
if self.character is None:
errors = {}
if self.guest_name is None:
errors["guest_name"] = "This field is required for guests."
if self.guest_klass is None:
errors["guest_klass"] = "This field is required for guests."
if errors:
raise ValidationError(errors)
else:
self.guest_name = None
self.guest_klass = None
# 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.character = self.character.user.main if not self.is_guest:
self.character = self.character.user.main
self.role = None self.role = None
elif self.role is None: elif self.role is None:
raise ValidationError({"role": "This field is required."}) raise ValidationError({"role": "This field is required."})
@ -128,9 +160,30 @@ class RaidResponse(models.Model):
self.attendance = RaidResponse.Status(self.status).default_attendance self.attendance = RaidResponse.Status(self.status).default_attendance
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# Delete guest "No Response"s
if self.is_guest and self.status == RaidResponse.Status.NO_RESPONSE:
self.delete()
return
super().save(*args, **kwargs) super().save(*args, **kwargs)
self._original_status = self.status self._original_status = self.status
@property
def is_guest(self):
return self.character is None
@property
def character_name(self):
if self.is_guest:
return self.guest_name
return self.character.name
@property
def character_klass(self):
if self.is_guest:
return self.guest_klass
return self.character.klass
def __str__(self): def __str__(self):
return f"{self.character} {self.get_status_display()} for '{self.raid}'" return f"{self.character} {self.get_status_display()} for '{self.raid}'"

View file

@ -124,7 +124,8 @@
function trIsExtra(tr) { function trIsExtra(tr) {
let characterSelect = tr.querySelector(".character-select"); let characterSelect = tr.querySelector(".character-select");
return characterSelect == null || characterSelect.value === ""; // the "--------" character has value="" let guestName = tr.querySelector(".guest-name");
return (characterSelect == null || characterSelect.value === "") && (guestName == null || guestName.value === ""); // the "--------" character has value=""
} }
function updateSelectTotals() { function updateSelectTotals() {
@ -163,8 +164,8 @@
} }
updateSelectTotals(); updateSelectTotals();
responseForm.querySelectorAll("select").forEach((select) => { responseForm.querySelectorAll("select, input").forEach((element) => {
select.addEventListener("change", updateSelectTotals); element.addEventListener("change", updateSelectTotals);
}); });
function changeSignupStatus(from, to) { function changeSignupStatus(from, to) {

View file

@ -6,31 +6,42 @@
{% block content %} {% block content %}
<div class="card mb-1"> <div class="container">
<div class="card mb-1">
<h4 class="card-header d-flex"> <h4 class="card-header d-flex">
<span class="mr-auto">{{ raid.title }} <small class="text-muted">{{ raid.date }}</small></span> <span class="mr-auto">{{ raid.title }} <small class="text-muted">{{ raid.date }}</small></span>
{% if perms.raids.change_raid %}<a class="btn btn-outline-primary btn-sm ml-2" role="button" href="{% url 'raid_change' raid.id %}">Edit</a>{% endif %} {% if perms.raids.change_raid %}<a class="btn btn-outline-primary btn-sm ml-2" role="button" href="{% url 'raid_change' raid.id %}">Edit</a>{% endif %}
{% if perms.raids.delete_raid %}<a class="btn text-danger btn-link btn-sm ml-2" role="button" href="{% url 'raid_delete' raid.id %}">Delete</a>{% endif %} {% if perms.raids.delete_raid %}<a class="btn text-danger btn-link btn-sm ml-2" role="button" href="{% url 'raid_delete' raid.id %}">Delete</a>{% endif %}
</h4> </h4>
<div class="card-body"> <div class="card-body">
<p class="card-text">{{ raid.description | urlize | linebreaksbr | default:"<em>No description</em>" }}</p> <p class="card-text">{{ raid.description | urlize | linebreaksbr | default:"<em>No description</em>" }}</p>
{% if raid.is_optional %} {% if raid.is_optional %}
<p class="card-text mb-0"><small class="text-muted">This raid is optional.</small></p> <p class="card-text mb-0"><small class="text-muted">This raid is optional.</small></p>
{% endif %} {% endif %}
<p class="card-text"><small class="text-muted">Response deadline: {{ raid.response_deadline }}.</small></p> <p class="card-text"><small class="text-muted">Response deadline: {{ raid.response_deadline }}.</small></p>
</div>
</div>
{% if response_form %}
<div class="card">
<div class="card-body pb-0">
<form class="raid-response-form" method="post" action="{% url 'raid_detail' raid.id %}">
{% crispy response_form %}
</form>
</div> </div>
</div> </div>
{% endif %} {% if response_form %}
<div class="mb-4"></div> <div class="card">
<div class="card-body pb-0">
<form class="raid-response-form" method="post" action="{% url 'raid_detail' raid.id %}">
{% crispy response_form %}
</form>
</div>
</div>
{% endif %}
{% if guest_response_form %}
<div class="mb-2"></div>
<div class="card">
<h6 class="card-header">Add Guest</h6>
<div class="card-body pt-2 pb-0">
<form class="guest-raid-response-form" method="post" action="{% url 'raid_detail' raid.id %}">
{% crispy guest_response_form %}
</form>
</div>
</div>
{% endif %}
<div class="mb-4"></div>
<div class="modal fade" id="exportModal" tabindex="-1" role="dialog" aria-labelledby="exportModalLabel" aria-hidden="true"> <div class="modal fade" id="exportModal" tabindex="-1" role="dialog" aria-labelledby="exportModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document"> <div class="modal-dialog" role="document">
@ -52,62 +63,64 @@
</div> </div>
</div> </div>
{% for status, status_responses in raid_responses.items %} {% for status, status_responses in raid_responses.items %}
{% regroup status_responses by group as group_responses_list %} {% regroup status_responses by group as group_responses_list %}
{% for group, group_responses in group_responses_list %} {% for group, group_responses in group_responses_list %}
<div class="card mb-2 response-container" data-response-container-status="{{ status }}" data-response-container-group="{{ group }}"> <div class="card mb-2 response-container" data-response-container-status="{{ status }}" data-response-container-group="{{ group }}">
<h6 class="card-header d-flex response-status-{{ status }}-bg"> <h6 class="card-header d-flex response-status-{{ status }}-bg">
<span class="mr-auto"> <span class="mr-auto">
{{ status.label }}{% if group_responses_list|length > 1 %}: {{ group_responses.0.get_group_display }}{% endif %} ({{ group_responses | length }}) {{ status.label }}{% if group_responses_list|length > 1 %}: {{ group_responses.0.get_group_display }}{% endif %} ({{ group_responses | length }})
</span> </span>
<span type="button" class="badge badge-dark" data-toggle="modal" data-target="#exportModal">Export</span> <span type="button" class="badge badge-dark" data-toggle="modal" data-target="#exportModal">Export</span>
</h6> </h6>
<div class="card-body mb-n4"> <div class="card-body mb-n4">
{% regroup group_responses by get_role_display as role_responses_list %} {% regroup group_responses by get_role_display as role_responses_list %}
{% for role, role_responses in role_responses_list %} {% for role, role_responses in role_responses_list %}
{% if role is not None %} {% if role is not None %}
<h6 class="card-title border-bottom pb-2">{{ role }} ({{ role_responses | length }})</h6> <h6 class="card-title border-bottom pb-2">{{ role }} ({{ role_responses | length }})</h6>
{% endif %} {% endif %}
<div class="d-flex flex-wrap"> <div class="d-flex flex-wrap">
{% regroup role_responses by character.klass as class_responses_list %} {% regroup role_responses by character.klass as class_responses_list %}
{% for class, class_responses in class_responses_list %} {% for class, class_responses in class_responses_list %}
<div class="d-flex flex-column mr-2 mb-4"> <div class="d-flex flex-column mr-2 mb-4">
{% regroup class_responses by character.user.rank as rank_responses_list %} {% regroup class_responses by character.user.rank as rank_responses_list %}
{% for rank, rank_responses in rank_responses_list %} {% for rank, rank_responses in rank_responses_list %}
<!-- TODO: Uncomment to show user ranks in raid signup <!-- TODO: Uncomment to show user ranks in raid signup
<div class="d-flex justify-content-center align-items-center"> <div class="d-flex justify-content-center align-items-center">
<hr class="flex-grow-1 my-0"> <hr class="flex-grow-1 my-0">
<div class="mx-2 my-0 font-weight-light text-muted small">{{ rank }}</div> <div class="mx-2 my-0 font-weight-light text-muted small">{{ rank }}</div>
<hr class="flex-grow-1 my-0"> <hr class="flex-grow-1 my-0">
</div>
-->
{% for response in rank_responses %}
<div class="alert response-character-alert mb-1 alert-class-{{ response.character.klass }}" role="alert" data-response-id="{{ response.id }}"
{% if response.role is not None %} data-response-role="{{ response.role }}"{% endif %}>
{% if response.character != response.character.user.main %}
<abbr title="{{ response.character.user.main }}">{{ response.character.name }}</abbr>
{% else %}
{{ response.character.name }}
{% endif %}
</div> </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">&#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 %}
</div>
{% endfor %}
{% endfor %} {% endfor %}
</div>
{% endfor %} {% endfor %}
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% endfor %}
</div> </div>
</div> {% empty %}
{% empty %} {% if perms.raids.change_raid %}
{% if perms.raids.change_raid %} <div class="card mb-2 response-container" style="opacity: 0.4" data-response-container-status="{{ status }}" data-response-container-group="1">
<div class="card mb-2 response-container" style="opacity: 0.4" data-response-container-status="{{ status }}" data-response-container-group="1"> <h6 class="card-header d-flex response-status-{{ status }}-bg">
<h6 class="card-header d-flex response-status-{{ status }}-bg"> <span class="mr-auto">{{ status.label }}</span>
<span class="mr-auto">{{ status.label }}</span> </h6>
</h6> </div>
</div> {% endif %}
{% endif %} {% endfor %}
{% endfor %} {% endfor %}
{% endfor %}
<div id="comments" class="card mt-4 mb-4"> <div id="comments" class="card mt-4 mb-4">
<h6 class="card-header">Comments</h6> <h6 class="card-header">Comments</h6>

View file

@ -14,7 +14,7 @@ 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 RaidResponseChangeForm, GuestRaidResponseForm
from .models import Raid, RaidResponse, InstanceReset from .models import Raid, RaidResponse, InstanceReset
User = get_user_model() User = get_user_model()
@ -117,6 +117,8 @@ class RaidDetailView(SingleObjectMixin, MultiModelFormView):
} }
if self.object.response_deadline > timezone.now(): if self.object.response_deadline > timezone.now():
classes["response_form"] = RaidResponseForm classes["response_form"] = RaidResponseForm
if self.request.user.has_perm("raids.change_raid"):
classes["guest_response_form"] = GuestRaidResponseForm
return classes return classes
def get_response_form_instance(self): def get_response_form_instance(self):
@ -129,6 +131,10 @@ class RaidDetailView(SingleObjectMixin, MultiModelFormView):
form.instance.raid = self.object form.instance.raid = self.object
form.save() form.save()
def guest_response_form_valid(self, form):
form.instance.raid = self.object
form.save()
def comment_form_valid(self, form): def comment_form_valid(self, form):
form.instance.raid = self.object form.instance.raid = self.object
form.instance.user = self.request.user form.instance.user = self.request.user
@ -138,12 +144,19 @@ class RaidDetailView(SingleObjectMixin, MultiModelFormView):
kwargs["user"] = self.request.user kwargs["user"] = self.request.user
return kwargs return kwargs
def get_guest_response_form_kwargs(self, kwargs):
kwargs["prefix"] = "guest"
return kwargs
def get_comment_form_success_url(self): def get_comment_form_success_url(self):
return reverse("raid_detail", kwargs={"pk": self.object.pk}) + "#comments" return reverse("raid_detail", kwargs={"pk": self.object.pk}) + "#comments"
def get_response_form_success_url(self): def get_response_form_success_url(self):
return reverse("raid_detail", kwargs={"pk": self.object.pk}) return reverse("raid_detail", kwargs={"pk": self.object.pk})
def get_guest_response_form_success_url(self):
return reverse("raid_detail", kwargs={"pk": self.object.pk})
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
self.object = self.get_object() self.object = self.get_object()
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)

View file

@ -38,7 +38,10 @@ class AttendanceView(TemplateView):
context["raid_list"] = list(reversed(raids)) context["raid_list"] = list(reversed(raids))
context["attendance_matrix"] = {user: {raid: None for raid in reversed(raids)} for user in users} context["attendance_matrix"] = {user: {raid: None for raid in reversed(raids)} for user in users}
for response in RaidResponse.objects.filter(raid__in=raids).select_related("raid", "character__user"): for response in RaidResponse.objects.filter(
character__isnull=False,
raid__in=raids
).select_related("raid", "character__user"):
with suppress(KeyError): # KeyError means user was in previous raid but is now inactive with suppress(KeyError): # KeyError means user was in previous raid but is now inactive
context["attendance_matrix"][response.character.user][response.raid] = response context["attendance_matrix"][response.character.user][response.raid] = response
return context return context