Compare commits

...

5 commits

17 changed files with 233 additions and 99 deletions

View file

@ -158,5 +158,16 @@ CRISPY_TEMPLATE_PACK = "bootstrap4"
# Drakul # Drakul
DEFAULT_ATTENDANCE_ATTENDING = 1.0 DEFAULT_ATTENDANCE = {
DEFAULT_ATTENDANCE_NOT_ATTENDING = 0.0 "No Response": 0.0,
"Signed Off": 0.0,
"Signed Up": 1.0,
"Stand By": 1.0,
"Confirmed": 1.0,
}
ATTENDANCE_COLORS = [
(0.8, "#28a745"),
(0.5, "#ffc107"),
(-10, "#dc3545")
]

View file

@ -122,6 +122,16 @@
/* Response bg and text colors from https://getbootstrap.com/docs/4.3/utilities/colors/ */ /* Response bg and text colors from https://getbootstrap.com/docs/4.3/utilities/colors/ */
.response-status-no-response-bg, .response-status-0-bg { /* secondary */
color: #fff;
background-color: #6c757d;
}
a.response-status-no-response-bg:focus, a.response-status-0-bg:focus,
a.response-status-no-response-bg:hover, a.response-status-0-bg:hover {
color: #fff;
background-color: #545b62;
}
.response-status-signed-off-bg, .response-status-1-bg { /* danger */ .response-status-signed-off-bg, .response-status-1-bg { /* danger */
color: #fff; color: #fff;
background-color: #dc3545; background-color: #dc3545;
@ -162,16 +172,6 @@ a.response-status-confirmed-bg:hover, a.response-status-4-bg:hover {
background-color: #1e7e34; background-color: #1e7e34;
} }
.response-status-no-response-bg, .response-status-0-bg { /* secondary */
color: #fff;
background-color: #6c757d;
}
a.response-status-no-response-bg:focus, a.response-status-0-bg:focus,
a.response-status-no-response-bg:hover, a.response-status-0-bg:hover {
color: #fff;
background-color: #545b62;
}
/* https://django-crispy-forms.readthedocs.io/en/latest/crispy_tag_forms.html#change-required-fields */ /* https://django-crispy-forms.readthedocs.io/en/latest/crispy_tag_forms.html#change-required-fields */
.asteriskField { .asteriskField {

View file

@ -23,7 +23,7 @@
<div class="collapse navbar-collapse" id="navbar-collapse"> <div class="collapse navbar-collapse" id="navbar-collapse">
<div class="navbar-nav mr-auto"> <div class="navbar-nav mr-auto">
<a class="nav-item nav-link" href="{% url 'raid_calendar' %}">Raids</a> <a class="nav-item nav-link" href="{% url 'raid_calendar' %}">Raids</a>
<a class="nav-item nav-link" href="{% url 'user_list' %}">Users</a> <a class="nav-item nav-link" href="{% url 'attendance' %}">Attendance</a>
<!--<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">DKP</a>--> <!--<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">DKP</a>-->
</div> </div>
{% if user.is_authenticated %} {% if user.is_authenticated %}

View file

@ -3,3 +3,6 @@ from django.apps import AppConfig
class RaidsConfig(AppConfig): class RaidsConfig(AppConfig):
name = "drakul.raids" name = "drakul.raids"
def ready(self):
from . import signals # noqa

View file

@ -20,7 +20,7 @@ class RaidResponseForm(ModelForm):
self.helper = FormHelper() self.helper = FormHelper()
if self.instance.pk is None or self.instance.status == RaidResponse.SIGNED_OFF: if self.instance.status <= RaidResponse.SIGNED_OFF:
signup_button = StrictButton( signup_button = StrictButton(
"Sign Up", "Sign Up",
type="submit", type="submit",
@ -59,6 +59,12 @@ class RaidResponseForm(ModelForm):
self.helper.form_show_labels = False self.helper.form_show_labels = False
self.helper.form_tag = False self.helper.form_tag = False
def clean_role(self):
role = self.cleaned_data["role"]
if role is None:
return self.cleaned_data["character"].role
return role
class RaidForm(ModelForm): class RaidForm(ModelForm):
class Meta: class Meta:

View file

@ -0,0 +1,22 @@
# Generated by Django 2.2.6 on 2020-01-06 23:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('raids', '0004_raid_is_optional'),
]
operations = [
migrations.AlterModelOptions(
name='raidresponse',
options={'ordering': ['-status', 'role', 'character__klass', 'character__user__rank', 'character__name']},
),
migrations.AlterField(
model_name='raidresponse',
name='status',
field=models.PositiveSmallIntegerField(choices=[(0, 'No Response'), (1, 'Signed Off'), (2, 'Signed Up'), (3, 'Stand By'), (4, 'Confirmed')]),
),
]

View file

@ -62,11 +62,13 @@ class RaidResponse(models.Model):
null=True null=True
) )
NO_RESPONSE = 0
SIGNED_OFF = 1 SIGNED_OFF = 1
SIGNED_UP = 2 SIGNED_UP = 2
STANDBY = 3 STANDBY = 3
CONFIRMED = 4 CONFIRMED = 4
STATUS_CHOICES = [ STATUS_CHOICES = [
(NO_RESPONSE, "No Response"),
(SIGNED_OFF, "Signed Off"), (SIGNED_OFF, "Signed Off"),
(SIGNED_UP, "Signed Up"), (SIGNED_UP, "Signed Up"),
(STANDBY, "Stand By"), (STANDBY, "Stand By"),
@ -80,17 +82,24 @@ class RaidResponse(models.Model):
choices=STATUS_CHOICES choices=STATUS_CHOICES
) )
note = models.CharField( STATUS_DEFAULT_ATTENDANCE = {
max_length=100, NO_RESPONSE: settings.DEFAULT_ATTENDANCE["No Response"],
blank=True, SIGNED_OFF: settings.DEFAULT_ATTENDANCE["Signed Off"],
null=True SIGNED_UP: settings.DEFAULT_ATTENDANCE["Signed Up"],
) STANDBY: settings.DEFAULT_ATTENDANCE["Stand By"],
CONFIRMED: settings.DEFAULT_ATTENDANCE["Confirmed"],
}
attendance = models.DecimalField( attendance = models.DecimalField(
max_digits=3, max_digits=3,
decimal_places=2, decimal_places=2,
blank=True blank=True
) )
note = models.CharField(
max_length=100,
blank=True,
null=True
)
class Meta: class Meta:
ordering = ["-status", "role", "character__klass", "character__user__rank", "character__name"] ordering = ["-status", "role", "character__klass", "character__user__rank", "character__name"]
@ -103,18 +112,15 @@ class RaidResponse(models.Model):
self._original_status = self.status self._original_status = self.status
def clean(self): def clean(self):
# Make sure sign-offs are role-agnostic, but all other responses are not # Make sure no-responses and sign-offs are role-agnostic, but all other responses are not
if self.status == RaidResponse.SIGNED_OFF: if self.status <= RaidResponse.SIGNED_OFF:
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."})
# Set attendance to one of the default values if status was changed # Set attendance to one of the default values if status was changed
if self.status != self._original_status: if self.status != self._original_status:
if self.status >= RaidResponse.SIGNED_UP: self.attendance = RaidResponse.STATUS_DEFAULT_ATTENDANCE[self.status]
self.attendance = settings.DEFAULT_ATTENDANCE_ATTENDING # 1.0 by default
else:
self.attendance = settings.DEFAULT_ATTENDANCE_NOT_ATTENDING # 0.0 by default
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
super().save(*args, **kwargs) super().save(*args, **kwargs)

47
drakul/raids/signals.py Normal file
View file

@ -0,0 +1,47 @@
from django.contrib.auth import get_user_model
from django.db.models import Q
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import Raid, RaidResponse
User = get_user_model()
@receiver(post_save, sender=Raid)
def create_raid_no_responses(instance: Raid, **kwargs):
# Delete all pre-existing no-responses, in case the deadline was changed
instance.responses.filter(status=RaidResponse.NO_RESPONSE).delete()
# Then create them (again)
users = User.objects \
.exclude(Q(date_joined__gt=instance.date) | Q(characters__raid_responses__raid=instance) | Q(is_active=False)) \
.select_related("main")
RaidResponse.objects.bulk_create(
RaidResponse(
raid=instance,
character=user.main,
status=RaidResponse.NO_RESPONSE,
attendance=RaidResponse.STATUS_DEFAULT_ATTENDANCE[RaidResponse.NO_RESPONSE]
)
for user in users
)
@receiver(post_save, sender=User)
def create_user_no_responses(instance: User, created: bool, **kwargs):
if (instance.original_main_id, instance.original_date_joined) == (instance.main_id, instance.date_joined) \
and not created:
return
# Delete all pre-existing no-responses for this user, in case date_joined or main was changed
RaidResponse.objects.filter(character__user=instance, status=RaidResponse.NO_RESPONSE).delete()
# Then create them (again)
RaidResponse.objects.bulk_create(
RaidResponse(
raid=raid,
character=instance.main,
status=RaidResponse.NO_RESPONSE,
attendance=RaidResponse.STATUS_DEFAULT_ATTENDANCE[RaidResponse.NO_RESPONSE]
)
for raid in Raid.objects.filter(response_deadline__gte=instance.date_joined)
)

View file

@ -52,9 +52,8 @@
</div> </div>
</div> </div>
{% regroup responses by get_status_display as status_responses_list %} {% regroup raid.responses.all by get_status_display as status_responses_list %}
{% for status, status_responses in status_responses_list %} {% for status, status_responses in status_responses_list %}
{% with status=status|default_if_none:"No Response" %}
<div class="card mb-2"> <div class="card mb-2">
<h6 class="card-header d-flex response-status-{{ status | slugify }}-bg"> <h6 class="card-header d-flex response-status-{{ status | slugify }}-bg">
<span class="mr-auto">{{ status }} ({{ status_responses | length }})</span> <span class="mr-auto">{{ status }} ({{ status_responses | length }})</span>
@ -93,7 +92,6 @@
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
{% endwith %}
{% endfor %} {% endfor %}
<div id="comments" class="card mt-4 mb-4"> <div id="comments" class="card mt-4 mb-4">

View file

@ -88,12 +88,8 @@ class RaidDetailView(SingleObjectMixin, MultiModelFormView):
def get_queryset(self): def get_queryset(self):
return Raid.objects.prefetch_related( return Raid.objects.prefetch_related(
"responses",
"responses__character",
"responses__character__user",
"responses__character__user__rank", "responses__character__user__rank",
"responses__character__user__main", "responses__character__user__main",
"comments",
"comments__user__main" "comments__user__main"
).all() ).all()
@ -133,22 +129,6 @@ class RaidDetailView(SingleObjectMixin, MultiModelFormView):
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_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
raid = context["raid"] = self.get_object()
# Create temporary pseudo-responses for users who haven't responded
no_response_users = User.objects \
.exclude(Q(date_joined__gt=raid.date) | Q(characters__raid_responses__raid=raid) | Q(is_active=False)) \
.select_related("main") \
.order_by("main__klass", "rank", "main__name")
pseudo_no_responses = [RaidResponse(character=user.main, status=None)
for user in no_response_users]
prefetch_related_objects(pseudo_no_responses, "character__user__rank", "character__user__main")
context["responses"] = list(raid.responses.all()) + pseudo_no_responses
return context
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)
@ -177,7 +157,7 @@ class RaidChangeView(PermissionRequiredMixin, SingleObjectMixin, MultiModelFormV
} }
def get_queryset(self): def get_queryset(self):
return Raid.objects.prefetch_related("responses", "responses__character").all() return Raid.objects.all()
def get_raid_form_instance(self): def get_raid_form_instance(self):
return self.object return self.object

View file

@ -48,6 +48,14 @@ class User(AbstractUser):
class Meta: class Meta:
ordering = ["rank", "username"] ordering = ["rank", "username"]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._set_originals()
def _set_originals(self):
self.original_main_id = self.main_id
self.original_date_joined = self.date_joined
def clean(self): def clean(self):
self.username = self.normalize_username(self.username) self.username = self.normalize_username(self.username)
if not hasattr(self, "rank"): if not hasattr(self, "rank"):
@ -75,6 +83,8 @@ class User(AbstractUser):
user = super().save(*args, **kwargs) user = super().save(*args, **kwargs)
self.main.user = self self.main.user = self
self.main.save() self.main.save()
self._set_originals()
return user return user

View file

@ -0,0 +1,34 @@
{% extends "base.html" %}
{% load users_extras %}
{% block title %}Attendance{% endblock %}
{% block content %}
<h2>Attendance</h2>
<table class="table table-dark table-bordered text-center">
<thead class="thead-light">
<tr>
{% for raid in raid_list %}
<th><a class="text-reset" href="{% url 'raid_detail' raid.id %}">{{ raid.date | date:"d/m"}}</a></th>
{% endfor %}
<th>Avg</th>
<th></th>
</tr>
</thead>
<tbody class="table-sm">
{% for user, user_responses in attendance_matrix.items %}
<tr>
{% for response in user_responses.values %}
<td class="response-status-{{ response.status | default_if_none:'no-color' }}-bg">
{% attendance_cell response %}
</td>
{% endfor %}
<td class="font-weight-bold" style="color: {% avg_attendance_color user.avg_attendance %}">{{ user.avg_attendance | floatformat:2 }}</td>
<td class="text-left color-class-{{ user.main.klass }}">{{ user.main }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View file

@ -1,34 +0,0 @@
{% extends "base.html" %}
{% block title %}Users{% endblock %}
{% block content %}
<h2>Users</h2>
<div class="card">
<div class="card-body">
<table class="table">
<thead class="table-borderless">
<tr>
<th scope="col">Character Name</th>
<th scope="col">Rank</th>
<th scope="col">Class</th>
<th scope="col">Role</th>
<th scope="col">Avg Attendance (Month, Total)</th>
</tr>
</thead>
<tbody>
{% for user in user_list %}
<tr>
<th scope="row">{{ user.main.name }}</th>
<td>{{ user.rank }}</td>
<td>{{ user.main.get_klass_display }}</td>
<td>{{ user.main.get_role_display }}</td>
<td>{{ user.avg_attendance_month | floatformat:2 }}, {{ user.avg_attendance_total | floatformat:2 }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

View file

View file

@ -0,0 +1,33 @@
from decimal import Decimal
from django import template
from django.conf import settings
from django.utils.html import format_html
from drakul.raids.models import RaidResponse
register = template.Library()
@register.simple_tag
def attendance_cell(response: RaidResponse):
if response is None:
return ""
if response.attendance == RaidResponse.STATUS_DEFAULT_ATTENDANCE[response.status]:
cell = ""
else:
cell = response.attendance
if response.note is not None:
return format_html(
'<abbr title="{}">{}</abbr>',
response.note,
cell or "?"
)
return cell
@register.simple_tag
def avg_attendance_color(avg: Decimal):
return next(color for threshold, color in settings.ATTENDANCE_COLORS if avg >= threshold)

View file

@ -3,5 +3,5 @@ from django.urls import path
from . import views from . import views
urlpatterns = [ urlpatterns = [
path("users/", views.UserListView.as_view(), name="user_list"), path("attendance/", views.AttendanceView.as_view(), name="attendance"),
] ]

View file

@ -1,25 +1,43 @@
from contextlib import suppress
from django.db.models import Avg, Q from django.db.models import Avg, Q
from django.utils import timezone from django.utils import timezone
from django.views.generic import ListView from django.views.generic import TemplateView
from .models import User from .models import User
from ..raids.models import Raid, RaidResponse
class UserListView(ListView): class AttendanceView(TemplateView):
def get_queryset(self): template_name = "users/attendance.html"
return User.objects.select_related("main").annotate(
avg_attendance_total=Avg( def get_context_data(self, **kwargs):
"characters__raid_responses__attendance", context = super().get_context_data(**kwargs)
filter=Q(characters__raid_responses__raid__date__lte=timezone.now()) # only count past raids users = User.objects \
), .filter(is_active=True) \
avg_attendance_month=Avg( .select_related("main") \
.annotate(
avg_attendance=Avg(
"characters__raid_responses__attendance", "characters__raid_responses__attendance",
filter=Q( filter=Q(
characters__raid_responses__raid__date__lte=timezone.now(), characters__raid_responses__raid__date__lte=timezone.now(), # only count past raids
characters__raid_responses__raid__date__gte=timezone.now() - timezone.timedelta(days=31) characters__raid_responses__raid__is_optional=False
) )
) )
).order_by("rank", "main__name") ).order_by("main__klass", "-avg_attendance", "main__name")
# Get the last 12 non-optional raids
raids = Raid.objects.filter(
date__lte=timezone.now(),
is_optional=False
).order_by("-date")[:12]
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"):
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
# CharacterDetailView: # CharacterDetailView: