diff --git a/drakul/raids/forms.py b/drakul/raids/forms.py index 4fde276..1f4c054 100644 --- a/drakul/raids/forms.py +++ b/drakul/raids/forms.py @@ -1,6 +1,6 @@ from crispy_forms.bootstrap import StrictButton 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 .models import RaidResponse, RaidComment, Raid @@ -68,6 +68,40 @@ class RaidResponseForm(ModelForm): 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(""), # 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 Meta: model = RaidResponse @@ -120,7 +154,7 @@ class RaidCommentForm(ModelForm): RaidResponseFormSet = inlineformset_factory( Raid, # parent model RaidResponse, - fields=["character", "role", "status", "group", "note", "attendance"], + fields=["character", "guest_name", "guest_klass", "role", "status", "group", "note", "attendance"], can_delete=False, extra=1 ) @@ -133,10 +167,12 @@ class RaidResponseFormSetHelper(FormHelper): super().__init__(form) self.layout = Layout( 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("status", css_class="status-select"), Field("group", css_class="group-select"), Field("note"), - Field("attendance", css_class="attendance-input"), + Field("attendance", css_class="attendance-input", style="width: 10ch"), ) self.form_tag = False diff --git a/drakul/raids/migrations/0010_auto_20200923_1450.py b/drakul/raids/migrations/0010_auto_20200923_1450.py new file mode 100644 index 0000000..5e4d43e --- /dev/null +++ b/drakul/raids/migrations/0010_auto_20200923_1450.py @@ -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'), + ), + ] diff --git a/drakul/raids/models.py b/drakul/raids/models.py index 4910177..d75f8c5 100755 --- a/drakul/raids/models.py +++ b/drakul/raids/models.py @@ -2,6 +2,7 @@ from datetime import timedelta from django.conf import settings from django.core.exceptions import ValidationError +from django.core.validators import MinLengthValidator from django.db import models from drakul.users.models import Character @@ -53,9 +54,26 @@ class RaidResponse(models.Model): Character, related_name="raid_responses", on_delete=models.CASCADE, + blank=True, + null=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( choices=Character.Role.choices, blank=True, @@ -116,9 +134,23 @@ class RaidResponse(models.Model): self._original_status = self.status 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 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 elif self.role is None: raise ValidationError({"role": "This field is required."}) @@ -128,9 +160,30 @@ class RaidResponse(models.Model): self.attendance = RaidResponse.Status(self.status).default_attendance 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) 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): return f"{self.character} {self.get_status_display()} for '{self.raid}'" diff --git a/drakul/raids/templates/raids/raid_change.html b/drakul/raids/templates/raids/raid_change.html index bab8516..7a3ffb7 100644 --- a/drakul/raids/templates/raids/raid_change.html +++ b/drakul/raids/templates/raids/raid_change.html @@ -124,7 +124,8 @@ function trIsExtra(tr) { 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() { @@ -163,8 +164,8 @@ } updateSelectTotals(); - responseForm.querySelectorAll("select").forEach((select) => { - select.addEventListener("change", updateSelectTotals); + responseForm.querySelectorAll("select, input").forEach((element) => { + element.addEventListener("change", updateSelectTotals); }); function changeSignupStatus(from, to) { diff --git a/drakul/raids/templates/raids/raid_detail.html b/drakul/raids/templates/raids/raid_detail.html index 198508b..583b266 100644 --- a/drakul/raids/templates/raids/raid_detail.html +++ b/drakul/raids/templates/raids/raid_detail.html @@ -6,31 +6,42 @@ {% block content %} -
- -

- {{ raid.title }} {{ raid.date }} - {% if perms.raids.change_raid %}Edit{% endif %} - {% if perms.raids.delete_raid %}Delete{% endif %} -

-
-

{{ raid.description | urlize | linebreaksbr | default:"No description" }}

- {% if raid.is_optional %} -

This raid is optional.

- {% endif %} -

Response deadline: {{ raid.response_deadline }}.

-
-
-{% if response_form %} -
-
-
- {% crispy response_form %} -
+
+
+

+ {{ raid.title }} {{ raid.date }} + {% if perms.raids.change_raid %}Edit{% endif %} + {% if perms.raids.delete_raid %}Delete{% endif %} +

+
+

{{ raid.description | urlize | linebreaksbr | default:"No description" }}

+ {% if raid.is_optional %} +

This raid is optional.

+ {% endif %} +

Response deadline: {{ raid.response_deadline }}.

-{% endif %} -
+ {% if response_form %} +
+
+
+ {% crispy response_form %} +
+
+
+ {% endif %} + {% if guest_response_form %} +
+
+
Add Guest
+
+
+ {% crispy guest_response_form %} +
+
+
+ {% endif %} +
-{% for status, status_responses in raid_responses.items %} - {% regroup status_responses by group as group_responses_list %} - {% for group, group_responses in group_responses_list %} -
-
- - {{ status.label }}{% if group_responses_list|length > 1 %}: {{ group_responses.0.get_group_display }}{% endif %} ({{ group_responses | length }}) - - Export -
-
- {% regroup group_responses by get_role_display as role_responses_list %} - {% for role, role_responses in role_responses_list %} - {% if role is not None %} -
{{ role }} ({{ role_responses | length }})
- {% endif %} -
- {% regroup role_responses by character.klass as class_responses_list %} - {% for class, class_responses in class_responses_list %} -
- {% regroup class_responses by character.user.rank as rank_responses_list %} - {% for rank, rank_responses in rank_responses_list %} - - {% for response in rank_responses %} - - {% empty %} - {% if perms.raids.change_raid %} -
-
- {{ status.label }} -
-
- {% endif %} + {% empty %} + {% if perms.raids.change_raid %} +
+
+ {{ status.label }} +
+
+ {% endif %} + {% endfor %} {% endfor %} -{% endfor %}
Comments
diff --git a/drakul/raids/views.py b/drakul/raids/views.py index 098cc2a..51124ae 100644 --- a/drakul/raids/views.py +++ b/drakul/raids/views.py @@ -14,7 +14,7 @@ from django.views.generic.edit import BaseUpdateView from drakul.base.views import MultiModelFormView from .forms import RaidResponseForm, RaidCommentForm, RaidForm, RaidResponseFormSetHelper, RaidResponseFormSet, \ - RaidResponseChangeForm + RaidResponseChangeForm, GuestRaidResponseForm from .models import Raid, RaidResponse, InstanceReset User = get_user_model() @@ -117,6 +117,8 @@ class RaidDetailView(SingleObjectMixin, MultiModelFormView): } if self.object.response_deadline > timezone.now(): classes["response_form"] = RaidResponseForm + if self.request.user.has_perm("raids.change_raid"): + classes["guest_response_form"] = GuestRaidResponseForm return classes def get_response_form_instance(self): @@ -129,6 +131,10 @@ class RaidDetailView(SingleObjectMixin, MultiModelFormView): form.instance.raid = self.object form.save() + def guest_response_form_valid(self, form): + form.instance.raid = self.object + form.save() + def comment_form_valid(self, form): form.instance.raid = self.object form.instance.user = self.request.user @@ -138,12 +144,19 @@ class RaidDetailView(SingleObjectMixin, MultiModelFormView): kwargs["user"] = self.request.user return kwargs + def get_guest_response_form_kwargs(self, kwargs): + kwargs["prefix"] = "guest" + return kwargs + def get_comment_form_success_url(self): return reverse("raid_detail", kwargs={"pk": self.object.pk}) + "#comments" def get_response_form_success_url(self): 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): self.object = self.get_object() return super().get(request, *args, **kwargs) diff --git a/drakul/users/views.py b/drakul/users/views.py index a9c128c..39e1a70 100644 --- a/drakul/users/views.py +++ b/drakul/users/views.py @@ -38,7 +38,10 @@ class AttendanceView(TemplateView): context["raid_list"] = list(reversed(raids)) 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 context["attendance_matrix"][response.character.user][response.raid] = response return context