Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
8e365e0
feat: show simple statistics per shift
crosspolar Aug 8, 2025
f06da84
feat: show simple statistics for whole coop
crosspolar Aug 8, 2025
d6a1bd2
forgot Shift-import
crosspolar Aug 12, 2025
0db1c0f
new text
crosspolar Aug 12, 2025
152ae4d
only show when more than one person worked in it so you don't have we…
crosspolar Aug 12, 2025
81562bd
Revert "feat: show simple statistics for whole coop"
crosspolar Sep 6, 2025
8aa4292
Revert "forgot Shift-import"
crosspolar Sep 6, 2025
087dcfc
use annotations to reduce number of queries
crosspolar Sep 6, 2025
cfc2939
move to function
crosspolar Sep 6, 2025
70e4daa
test
crosspolar Sep 6, 2025
a0d341d
fix tests
crosspolar Sep 6, 2025
ef7ac3d
refactor tests
crosspolar Sep 9, 2025
df4a345
translation
crosspolar Sep 9, 2025
372e17b
translation
crosspolar Sep 9, 2025
a2a5357
Merge branch 'master' into shift_stats
crosspolar Sep 21, 2025
20ce421
translation
crosspolar Sep 21, 2025
40f225e
Merge branch 'master' into shift_stats
crosspolar Nov 21, 2025
b706d46
Merge branch 'master' into shift_stats
crosspolar Feb 7, 2026
0eb3b08
get_past_shifts_data should accept ShiftTemplate
crosspolar Feb 8, 2026
7f824d0
test_get_past_shifts_data_changedShiftTemplateDuration_correctSum
crosspolar Feb 8, 2026
c435d6c
typing
crosspolar Feb 8, 2026
f962f3f
remove non-needed imports
crosspolar Feb 8, 2026
dc4f0d9
ensure consistent slot assignment
crosspolar Feb 8, 2026
fd44868
transl
crosspolar Feb 8, 2026
db15129
also show date of first_shift
crosspolar Feb 8, 2026
36e3674
show random text
crosspolar Feb 8, 2026
fe14c97
transl
crosspolar Feb 8, 2026
44dd717
Update tapir/shifts/tests/test_get_past_shifts_data.py
crosspolar Feb 24, 2026
dcb891d
Update tapir/shifts/tests/test_get_past_shifts_data.py
crosspolar Feb 24, 2026
c8fa274
fix mutable context
crosspolar Feb 24, 2026
2d8050c
test: use constant not expensive expression
crosspolar Feb 24, 2026
a81191e
Merge branch 'master' into shift_stats
crosspolar Feb 24, 2026
9b51401
transl
crosspolar Feb 24, 2026
daa101d
Merge branch 'refs/heads/master' into shift_stats
crosspolar Mar 13, 2026
28ea082
remove random_shift_text
crosspolar Mar 13, 2026
5d670be
remove random_shift_text
crosspolar Mar 13, 2026
1a7211e
Merge branch 'master' into shift_stats
crosspolar Mar 13, 2026
c2cfaa1
transl
crosspolar Mar 13, 2026
7c684df
Merge branch 'master' into shift_stats
crosspolar Mar 28, 2026
c19bab2
translation
crosspolar Mar 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions tapir/shifts/templates/shifts/shift_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@
<h5>
<span id="shift_card_title">{% translate 'Shift' %}: {{ shift.get_display_name }}</span>
</h5>
{% if total_valid_attendances and total_hours and no_of_past_shifts %}
<span data-bs-toggle="tooltip"
title="Total Hours: {{ total_hours }}, Number of Past Shifts: {{ no_of_past_shifts }}, First Shift Date: {{ first_shift_date|date:'d.m.y' }}"></span>
{% endif %}
<span class="d-flex justify-content-end flex-wrap gap-2">
{% if request.user|user_watching_shift:shift %}
<form style="display: inline"
Expand Down
1 change: 1 addition & 0 deletions tapir/shifts/templatetags/shifts.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import datetime
import random

import tapir.shifts.config
from django import template
Expand Down
102 changes: 102 additions & 0 deletions tapir/shifts/tests/test_get_past_shifts_data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import datetime

from django.utils import timezone

from tapir.accounts.tests.factories.factories import TapirUserFactory
from tapir.shifts.models import (
ShiftTemplate,
ShiftAttendance,
Shift,
)
from tapir.shifts.tests.factories import ShiftFactory, ShiftTemplateFactory
from tapir.shifts.views import ShiftDetailView
from tapir.utils.tests_utils import TapirFactoryTestBase


class ShiftGetPastShiftsStatisticsTests(TapirFactoryTestBase):

def test_getPastShiftsData_differentStatesOfAttendance_onlyCountDoneState(self):
user_done = TapirUserFactory.create()
user_excused = TapirUserFactory.create()

shift_template: ShiftTemplate = ShiftTemplateFactory.create(nb_slots=2)
shift: Shift = ShiftFactory.create(
Comment thread
crosspolar marked this conversation as resolved.
start_time=timezone.now() - datetime.timedelta(days=1),
nb_slots=2,
shift_template=shift_template,
)
slots = list(shift.slots.all())
ShiftAttendance.objects.create(
state=ShiftAttendance.State.DONE, user=user_done, slot=slots[0]
)
ShiftAttendance.objects.create(
state=ShiftAttendance.State.MISSED_EXCUSED,
user=user_excused,
slot=slots[1],
)
Comment thread
crosspolar marked this conversation as resolved.

context = ShiftDetailView.get_past_shifts_data(shift.shift_template)
self.assertEqual(context["no_of_past_shifts"], 1)
self.assertEqual(context["total_valid_attendances"], 1)
self.assertEqual(
context["total_hours"],
(shift.end_time - shift.start_time).total_seconds() / 3600,
)

def test_getPastShiftsData_multipleAttendandances_correctValues(self):
users = TapirUserFactory.create_batch(5)
shift_template = ShiftTemplateFactory.create(nb_slots=len(users))
shifts: list[Shift] = [
ShiftFactory.create(
start_time=timezone.now() - datetime.timedelta(days=i + 1),
nb_slots=len(users),
shift_template=shift_template,
)
for i in range(5)
]

for shift in shifts:
slots = list(shift.slots.all())
for i, user in enumerate(users):
ShiftAttendance.objects.create(
state=ShiftAttendance.State.DONE, user=user, slot=slots[i]
)
context = ShiftDetailView.get_past_shifts_data(shift_template)

self.assertEqual(context["no_of_past_shifts"], len(shifts))
self.assertEqual(context["total_valid_attendances"], len(users) * len(shifts))
self.assertEqual(
context["total_hours"],
70.0,
"The value should have been calculated by "
"len(users)* sum((shift.end_time - shift.start_time).total_seconds() / 3600 for shift in shifts)",
)

def test_getPastShiftsData_changedShiftTemplateDuration_correctSum(self):
shift_template: ShiftTemplate = ShiftTemplateFactory.create(
start_time=datetime.time(hour=10, tzinfo=datetime.timezone.utc),
end_time=datetime.time(hour=12, tzinfo=datetime.timezone.utc),
)
shift_2_hours = shift_template.create_shift_if_necessary(
timezone.now() - datetime.timedelta(days=7)
)
ShiftAttendance.objects.create(
user=TapirUserFactory.create(),
slot=shift_2_hours.slots.first(),
state=ShiftAttendance.State.DONE,
)

shift_template.end_time = datetime.time(hour=15, tzinfo=datetime.timezone.utc)
shift_template.save()

shift_5_hours = shift_template.create_shift_if_necessary(
timezone.now() - datetime.timedelta(days=14)
)
ShiftAttendance.objects.create(
user=TapirUserFactory.create(),
slot=shift_5_hours.slots.first(),
state=ShiftAttendance.State.DONE,
)

context = ShiftDetailView.get_past_shifts_data(shift_template)
self.assertAlmostEqual(context["total_hours"], 7.0)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this can just be a normal self.assertEqual? Is there a reason for using assertAlmostEqual?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Floating-point numbers, you know. The print(0.1+0.2 == 0.3) giving False.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But have you actually had that problem here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, but I would say it's good practice preventing problems

42 changes: 42 additions & 0 deletions tapir/shifts/views/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,9 @@ def get_context_data(self, **kwargs):
.prefetch_related("slot_template__attendance_template__user")
)

if shift.shift_template:
Comment thread
crosspolar marked this conversation as resolved.
self.get_past_shifts_data(shift.shift_template, context)

for slot in slots:
slot.can_self_register = slot.user_can_attend(self.request.user)
slot.can_self_unregister = slot.user_can_self_unregister(self.request.user)
Expand All @@ -262,6 +265,45 @@ def get_context_data(self, **kwargs):
Shift.NB_DAYS_FOR_SELF_LOOK_FOR_STAND_IN
)
context["SHIFT_ATTENDANCE_STATES"] = SHIFT_ATTENDANCE_STATES

return context

@staticmethod
def get_past_shifts_data(shift_template: ShiftTemplate, context: dict = None):
if context is None:
context = {}
past_shifts = (
Shift.objects.filter(
shift_template=shift_template, end_time__lt=timezone.now()
)
.annotate(
valid_attendance_count=Count(
"slots__attendances",
filter=Q(
slots__attendances__state__in=[
ShiftAttendance.State.DONE,
]
),
)
)
.order_by("start_time")
)
context["no_of_past_shifts"] = past_shifts.count()

# Sum valid attendances for related shifts
total_valid_attendances = sum(s.valid_attendance_count for s in past_shifts)

context["total_valid_attendances"] = total_valid_attendances

# Calculate total working hours
total_hours = sum(
(s.end_time - s.start_time).total_seconds()
* s.valid_attendance_count
/ 3600
for s in past_shifts
)
context["total_hours"] = round(total_hours)
context["first_shift_date"] = past_shifts.first().start_time.date()
return context


Expand Down
Loading
Loading