Compare commits

...

3 commits

18 changed files with 505 additions and 326 deletions

View file

@ -6,6 +6,7 @@
{% block content %} {% block content %}
<div class="container">
<div class="d-flex justify-content-center align-items-center pt-5"> <div class="d-flex justify-content-center align-items-center pt-5">
<form class="m-auto" method="post" action="{% url 'login' %}"> <form class="m-auto" method="post" action="{% url 'login' %}">
{% csrf_token %} {% csrf_token %}
@ -15,4 +16,5 @@
<p class="text-center mt-2"><a href="{% url 'signup' %}">Sign Up</a></p> <p class="text-center mt-2"><a href="{% url 'signup' %}">Sign Up</a></p>
</form> </form>
</div> </div>
</div>
{% endblock content %} {% endblock content %}

View file

@ -6,6 +6,7 @@
{% block content %} {% block content %}
<div class="container">
<div class="d-flex justify-content-center align-items-center mt-5"> <div class="d-flex justify-content-center align-items-center mt-5">
<form class="m-auto" method="post" action="{% url 'password_change' %}"> <form class="m-auto" method="post" action="{% url 'password_change' %}">
<h2>Change Password</h2> <h2>Change Password</h2>
@ -15,4 +16,5 @@
<button type="submit" class="btn btn-primary btn-block">Change Password</button> <button type="submit" class="btn btn-primary btn-block">Change Password</button>
</form> </form>
</div> </div>
</div>
{% endblock content %} {% endblock content %}

View file

@ -6,6 +6,7 @@
{% block content %} {% block content %}
<div class="container">
<div class="d-flex justify-content-center align-items-center pt-5"> <div class="d-flex justify-content-center align-items-center pt-5">
<form class="m-auto" method="post" action="{% url 'signup' %}"> <form class="m-auto" method="post" action="{% url 'signup' %}">
<h2>Sign Up</h2> <h2>Sign Up</h2>
@ -21,4 +22,5 @@
<p class="text-center mt-2"><a href="{% url 'login' %}">Log In</a></p> <p class="text-center mt-2"><a href="{% url 'login' %}">Log In</a></p>
</form> </form>
</div> </div>
</div>
{% endblock content %} {% endblock content %}

View file

@ -4,6 +4,7 @@
{% block content %} {% block content %}
<div class="container">
<h2>Bank</h2> <h2>Bank</h2>
<div class="card"> <div class="card">
<div class="card-header d-flex"> <div class="card-header d-flex">
@ -24,4 +25,5 @@
{% block bank_footer %} {% block bank_footer %}
{% endblock bank_footer %} {% endblock bank_footer %}
</div> </div>
</div>
{% endblock content %} {% endblock content %}

View file

@ -1,3 +1,13 @@
.container {
max-width: 1140px;
}
@media (min-width:1600px) {
.container-wide {
max-width: 1500px
}
}
/* WoW class-coloured text */ /* WoW class-coloured text */
.color-druid, .color-class-1 {color: #ff7d0a;} .color-druid, .color-class-1 {color: #ff7d0a;}
.color-hunter, .color-class-2 {color: #abd473;} .color-hunter, .color-class-2 {color: #abd473;}

View file

@ -43,10 +43,8 @@
</header> </header>
<main class="flex-shrink-0" role="main"> <main class="flex-shrink-0" role="main">
<div class="container">
{% block content %} {% block content %}
{% endblock content %} {% endblock content %}
</div>
</main> </main>
<footer class="footer mt-auto"> <footer class="footer mt-auto">

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,8 +134,22 @@ 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:
if not self.is_guest:
self.character = self.character.user.main self.character = self.character.user.main
self.role = None self.role = None
elif self.role is None: elif self.role is None:
@ -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

@ -4,6 +4,7 @@
{% block content %} {% block content %}
<div class="container">
<h2>Raids</h2> <h2>Raids</h2>
<div class="d-flex justify-content-center mb-1"> <div class="d-flex justify-content-center mb-1">
<div class="btn-group" role="group" aria-label="Calendar navigation controls"> <div class="btn-group" role="group" aria-label="Calendar navigation controls">
@ -52,4 +53,5 @@
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
{% if perms.raids.add_raid %}<a class="btn btn-success" role="button" href="{% url 'raid_create' %}">Add New</a>{% endif %} {% if perms.raids.add_raid %}<a class="btn btn-success" role="button" href="{% url 'raid_create' %}">Add New</a>{% endif %}
</div> </div>
</div>
{% endblock content %} {% endblock content %}

View file

@ -6,13 +6,15 @@
{% block content %} {% block content %}
<div class="container container-wide">
<div class="card mb-3"> <div class="card mb-3">
<h5 class="card-header">Raid Details</h5> <h5 class="card-header">Raid Details</h5>
<div class="card-body"> <div class="card-body">
{% crispy raid_form %} {% crispy raid_form %}
</div> </div>
</div> </div>
</div>
<div class="container container-wide">
<div class="card mb-3"> <div class="card mb-3">
<h5 class="card-header">Raid Responses</h5> <h5 class="card-header">Raid Responses</h5>
<div class="card-body"> <div class="card-body">
@ -102,6 +104,7 @@
</form> </form>
</div> </div>
</div> </div>
</div>
{% endblock content %} {% endblock content %}
@ -121,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() {
@ -160,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,9 +6,11 @@
{% block content %} {% block content %}
<div class="container">
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
<p>Are you sure you want to delete "{{ object }}"?</p> <p>Are you sure you want to delete "{{ object }}"?</p>
<button type="submit" class="btn btn-danger">Confirm</button> <button type="submit" class="btn btn-danger">Confirm</button>
</form> </form>
</div>
{% endblock content %} {% endblock content %}

View file

@ -6,5 +6,7 @@
{% block content %} {% block content %}
<div class="container">
{% crispy form %} {% crispy form %}
</div>
{% endblock content %} {% endblock content %}

View file

@ -6,8 +6,8 @@
{% block content %} {% block content %}
<div class="container">
<div class="card mb-1"> <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 %}
@ -30,6 +30,17 @@
</div> </div>
</div> </div>
{% endif %} {% 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="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">
@ -82,12 +93,14 @@
</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 }}" role="alert" data-response-id="{{ response.id }}" <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.role is not None %} data-response-role="{{ response.role }}"{% endif %}>
{% if response.character != response.character.user.main %} {% if response.is_guest %}
<abbr title="{{ response.character.user.main }}">{{ response.character.name }}</abbr> <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 %} {% else %}
{{ response.character.name }} {{ response.character_name }}
{% endif %} {% endif %}
</div> </div>
{% endfor %} {% endfor %}
@ -134,6 +147,7 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div>
{% endblock content %} {% endblock content %}

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

@ -23,6 +23,7 @@ class Migration(migrations.Migration):
dependencies = [ dependencies = [
('users', '0002_create_default_ranks'), ('users', '0002_create_default_ranks'),
('raids', '0004_raid_is_optional'),
] ]
operations = [ operations = [

View file

@ -6,6 +6,7 @@
{% block content %} {% block content %}
<div class="container">
<h2>Attendance</h2> <h2>Attendance</h2>
<table class="table table-dark table-bordered text-center"> <table class="table table-dark table-bordered text-center">
<thead class="thead-light"> <thead class="thead-light">
@ -31,4 +32,5 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
{% endblock %} {% endblock %}

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