Compare commits
5 commits
be86354c1f
...
94d90efad5
Author | SHA1 | Date | |
---|---|---|---|
Casper V. Kristensen | 94d90efad5 | ||
Casper V. Kristensen | bb16c152c4 | ||
Casper V. Kristensen | 84f3554d98 | ||
Casper V. Kristensen | c01bb56b5d | ||
Casper V. Kristensen | dd7f45599d |
|
@ -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")
|
||||||
|
]
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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:
|
||||||
|
|
22
drakul/raids/migrations/0005_auto_20200106_2305.py
Normal file
22
drakul/raids/migrations/0005_auto_20200106_2305.py
Normal 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')]),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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
47
drakul/raids/signals.py
Normal 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)
|
||||||
|
)
|
|
@ -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">
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
34
drakul/users/templates/users/attendance.html
Normal file
34
drakul/users/templates/users/attendance.html
Normal 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 %}
|
|
@ -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 %}
|
|
0
drakul/users/templatetags/__init__.py
Normal file
0
drakul/users/templatetags/__init__.py
Normal file
33
drakul/users/templatetags/users_extras.py
Normal file
33
drakul/users/templatetags/users_extras.py
Normal 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)
|
|
@ -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"),
|
||||||
]
|
]
|
||||||
|
|
|
@ -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") \
|
||||||
"characters__raid_responses__attendance",
|
.annotate(
|
||||||
filter=Q(
|
avg_attendance=Avg(
|
||||||
characters__raid_responses__raid__date__lte=timezone.now(),
|
"characters__raid_responses__attendance",
|
||||||
characters__raid_responses__raid__date__gte=timezone.now() - timezone.timedelta(days=31)
|
filter=Q(
|
||||||
|
characters__raid_responses__raid__date__lte=timezone.now(), # only count past raids
|
||||||
|
characters__raid_responses__raid__is_optional=False
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
).order_by("main__klass", "-avg_attendance", "main__name")
|
||||||
).order_by("rank", "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:
|
||||||
|
|
Loading…
Reference in a new issue