added real sagex hours + changed to DurationField

This commit is contained in:
Louis Heredero 2025-02-04 00:19:05 +01:00
parent 6777207f4e
commit eb0cab6295
Signed by: HEL
GPG Key ID: 8D83DE470F8544E7
13 changed files with 299 additions and 15 deletions

View File

@ -2,11 +2,21 @@ from datetime import datetime
class DateConverter: class DateConverter:
regex = '\d{4}-\d{1,2}-\d{1,2}' regex = r"\d{4}-\d{1,2}-\d{1,2}"
format = '%Y-%m-%d' format = r"%Y-%m-%d"
def to_python(self, value): def to_python(self, value):
return datetime.strptime(value, self.format).date() return datetime.strptime(value, self.format).date()
def to_url(self, value): def to_url(self, value):
return value.strftime(self.format) return value.strftime(self.format)
class YearMonthConverter:
regex = r"\d{4}-\d{1,2}"
format = r"%Y-%m"
def to_python(self, value):
return datetime.strptime(value, self.format).date()
def to_url(self, value):
return value.strftime(self.format)

View File

@ -17,10 +17,11 @@ Including another URLconf
from django.contrib import admin from django.contrib import admin
from django.urls import path, register_converter from django.urls import path, register_converter
from TimeDispatcher.converters import DateConverter from TimeDispatcher.converters import DateConverter, YearMonthConverter
from dispatcher import views from dispatcher import views
register_converter(DateConverter, "date") register_converter(DateConverter, "date")
register_converter(YearMonthConverter, "year_month")
urlpatterns = [ urlpatterns = [
path("", views.dashboard_view, name="dashboard"), path("", views.dashboard_view, name="dashboard"),
@ -38,5 +39,6 @@ urlpatterns = [
path("projects/<int:id>/", views.project_view, name="project"), path("projects/<int:id>/", views.project_view, name="project"),
path("stats/by-month/<int:year>/<int:month>/", views.get_stats_by_month, name="stats_by_month"), path("stats/by-month/<int:year>/<int:month>/", views.get_stats_by_month, name="stats_by_month"),
path("stats/between/<date:start_date>/<date:end_date>/", views.get_stats_between, name="stats_between"), path("stats/between/<date:start_date>/<date:end_date>/", views.get_stats_between, name="stats_between"),
path("clockings/<date:date>/", views.set_clocking, name="set_clocking") path("clockings/<date:date>/", views.set_clocking, name="set_clocking"),
path("sagex/<int:id>/<year_month:month>/", views.set_real_sagex, name="set_real_sagex"),
] ]

View File

@ -1,6 +1,15 @@
import datetime
from dispatcher.models import Task, Project from dispatcher.models import Task, Project
def convert_timedelta(td: datetime.timedelta):
total_seconds = td.total_seconds()
hours, remainder = divmod(total_seconds, 3600)
minutes, seconds = divmod(remainder, 60)
return f"{int(hours):02d}:{int(minutes):02d}:{int(seconds):02d}"
def import_tasks(csv_content: str): def import_tasks(csv_content: str):
tasks = [] tasks = []
for line in csv_content.splitlines()[1:]: for line in csv_content.splitlines()[1:]:

View File

@ -0,0 +1,66 @@
# Generated by Django 5.1.5 on 2025-02-03 17:53
import datetime
from datetime import timedelta
import django.db.models.deletion
from django.db import migrations, models
def time_to_duration(apps, schema_editor):
Clocking = apps.get_model("dispatcher", "Clocking")
for clocking in Clocking.objects.all().iterator():
if clocking.old_remote:
remote: datetime.time = clocking.old_remote
clocking.remote = timedelta(hours=remote.hour, minutes=remote.minute)
else:
clocking.remote = timedelta()
clocking.save()
def duration_to_time(apps, schema_editor):
Clocking = apps.get_model("dispatcher", "Clocking")
for clocking in Clocking.objects.all().iterator():
if clocking.remote.total_seconds() != 0:
remote: datetime.timedelta = clocking.remote
total_seconds = remote.total_seconds()
hours, remainder = divmod(total_seconds, 3600)
minutes, seconds = divmod(remainder, 60)
clocking.old_remote = datetime.time(int(hours), int(minutes), int(seconds))
else:
clocking.old_remote = None
clocking.save()
class Migration(migrations.Migration):
dependencies = [
('dispatcher', '0008_alter_clocking_in_am_alter_clocking_in_pm_and_more'),
]
operations = [
migrations.RenameField(
model_name='clocking',
old_name='remote',
new_name='old_remote'
),
migrations.AddField(
model_name='clocking',
name='remote',
field=models.DurationField(default=datetime.timedelta),
),
migrations.RunPython(time_to_duration, duration_to_time),
migrations.RemoveField(
model_name='clocking',
name='old_remote'
),
migrations.CreateModel(
name='RealSageXHours',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('hours', models.DurationField(default=datetime.timedelta)),
('parent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dispatcher.parent')),
],
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 5.1.5 on 2025-02-03 18:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcher', '0009_alter_clocking_remote_realsagexhours'),
]
operations = [
migrations.AddConstraint(
model_name='realsagexhours',
constraint=models.UniqueConstraint(fields=('parent', 'date'), name='unique_monthly_sagex'),
),
]

View File

@ -1,3 +1,5 @@
from datetime import timedelta
from django.db import models from django.db import models
class Parent(models.Model): class Parent(models.Model):
@ -39,4 +41,15 @@ class Clocking(models.Model):
out_am = models.TimeField(null=True, default=None, verbose_name="Clock out AM") out_am = models.TimeField(null=True, default=None, verbose_name="Clock out AM")
in_pm = models.TimeField(null=True, default=None, verbose_name="Clock in PM") in_pm = models.TimeField(null=True, default=None, verbose_name="Clock in PM")
out_pm = models.TimeField(null=True, default=None, verbose_name="Clock out PM") out_pm = models.TimeField(null=True, default=None, verbose_name="Clock out PM")
remote = models.TimeField(null=True, default=None) remote = models.DurationField(default=timedelta)
class RealSageXHours(models.Model):
parent = models.ForeignKey(Parent, on_delete=models.CASCADE)
date = models.DateField()
hours = models.DurationField(default=timedelta)
class Meta:
constraints = [
models.UniqueConstraint(fields=["parent", "date"], name="unique_monthly_sagex")
]

View File

@ -3,7 +3,8 @@ from datetime import datetime, timedelta, time
from rest_framework import serializers from rest_framework import serializers
from rest_framework.serializers import ModelSerializer from rest_framework.serializers import ModelSerializer
from dispatcher.models import Clocking from dispatcher.core import convert_timedelta
from dispatcher.models import Clocking, RealSageXHours
class ClockingSerializer(ModelSerializer): class ClockingSerializer(ModelSerializer):
@ -24,8 +25,19 @@ class ClockingSerializer(ModelSerializer):
out_pm = datetime.combine(obj.date, obj.out_pm) out_pm = datetime.combine(obj.date, obj.out_pm)
total += out_pm - in_pm total += out_pm - in_pm
if obj.remote is not None: if obj.remote is not None:
total += timedelta(hours=obj.remote.hour, minutes=obj.remote.minute) total += obj.remote
seconds = total.seconds seconds = total.seconds
minutes = seconds // 60 minutes = seconds // 60
return minutes return minutes
class RealSageXHoursSerializer(ModelSerializer):
hours = serializers.SerializerMethodField()
class Meta:
model = RealSageXHours
fields = "__all__"
def get_hours(self, obj: RealSageXHours):
return convert_timedelta(obj.hours)

View File

@ -57,6 +57,7 @@ function formatDate(date) {
function formatDuration(duration) { function formatDuration(duration) {
let hours = Math.floor(duration / 60) let hours = Math.floor(duration / 60)
duration -= hours * 60 duration -= hours * 60
duration = Math.round(duration)
let res = "" let res = ""
if (hours > 0) { if (hours > 0) {
res += hours.toString() + "h" res += hours.toString() + "h"

View File

@ -92,4 +92,51 @@
#projects-table th, #projects-table td { #projects-table th, #projects-table td {
border-left: solid var(--light4) 1px; border-left: solid var(--light4) 1px;
}
.by-range tr.real-sagex-hours .sagex-hours {
height: 2em;
input {
max-width: 3em;
background: none;
color: var(--light1);
&:focus-visible {
outline: solid var(--dark4) 1px;
}
&::-webkit-inner-spin-button {
display: none;
}
&.hours {
text-align: right;
}
&.minutes {
text-align: left;
max-width: 2em;
}
}
&.saved {
input {
animation-name: sagex-saved;
animation-duration: 1s;
}
}
}
@keyframes sagex-saved {
0% {
}
10%, 60% {
border-color: var(--accent);
}
100% {
}
} }

View File

@ -95,6 +95,8 @@ function updateTable(data) {
headers.querySelector(".total-clocking").innerText = formatDuration(totalWorked) headers.querySelector(".total-clocking").innerText = formatDuration(totalWorked)
headers.querySelector(".total-duration").innerText = formatDuration(totalImputed) headers.querySelector(".total-duration").innerText = formatDuration(totalImputed)
let sagexHoursTemplate = getTemplate("sagex-hours")
let totalSagex = 0 let totalSagex = 0
parents.forEach(parent => { parents.forEach(parent => {
let duration = +parent.duration let duration = +parent.duration
@ -112,11 +114,66 @@ function updateTable(data) {
let sagexDuration = duration * totalWorked / totalImputed let sagexDuration = duration * totalWorked / totalImputed
totalSagex += sagexDuration totalSagex += sagexDuration
sagexHours.insertCell(-1).innerText = formatDuration(sagexDuration) sagexHours.insertCell(-1).innerText = formatDuration(sagexDuration)
realSagexHours.insertCell(-1) let td = sagexHoursTemplate.cloneNode(true)
let [h, m, s] = parent.real_sagex_hours.split(":")
let hoursInput = td.querySelector("input.hours")
let minutesInput = td.querySelector("input.minutes")
hoursInput.value = h
minutesInput.value = m
let timeout = null
minutesInput.addEventListener("input", () => reformatTime(minutesInput))
td.addEventListener("change", () => {
if (timeout !== null) {
clearTimeout(timeout)
}
timeout = setTimeout(() => {
updateSagex(parent.id, td)
}, 1000)
})
realSagexHours.appendChild(td)
}) })
headers.querySelector(".sagex-hours-total").innerText = formatDuration(totalSagex) headers.querySelector(".sagex-hours-total").innerText = formatDuration(totalSagex)
} }
function reformatTime(input) {
let value = input.value
if (value.length === 1) {
input.value = "0" + value
} else if (value.length > 2) {
input.value = value.slice(value.length - 2)
}
}
function updateSagex(id, cell) {
let hoursInput = cell.querySelector(".hours")
let minutesInput = cell.querySelector(".minutes")
reformatTime(minutesInput)
let hours = +hoursInput.value
let minutes = +minutesInput.value
let year = curYear.toString().padStart(4, "0")
let month = (curMonth + 1).toString().padStart(2, "0")
let date = `${year}-${month}`
let fd = new FormData()
fd.set("minutes", hours * 60 + minutes)
req(`/sagex/${id}/${date}/`, {
method: "POST",
body: fd
}).then(res => {
return res.json()
}).then(res => {
if (res.status === "success") {
cell.classList.add("saved")
cell.addEventListener("animationend", () => {
cell.classList.remove("saved")
}, {once: true})
} else {
alert(res.error)
}
})
}
window.addEventListener("load", () => { window.addEventListener("load", () => {
prevMonthBtn = document.getElementById("prev-month") prevMonthBtn = document.getElementById("prev-month")
nextMonthBtn = document.getElementById("next-month") nextMonthBtn = document.getElementById("next-month")

BIN
dispatcher/static/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1,17 +1,19 @@
import datetime import datetime
from datetime import timedelta from datetime import timedelta
from django.db.models import Sum, Q, QuerySet from django.db.models import Sum, Q, QuerySet, Subquery, OuterRef, DurationField, Value, DecimalField
from django.db.models.functions import Coalesce
from django.http import JsonResponse from django.http import JsonResponse
from django.shortcuts import render, get_object_or_404, redirect from django.shortcuts import render, get_object_or_404, redirect
from django.views import generic from django.views import generic
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from rest_framework.status import HTTP_400_BAD_REQUEST
from dispatcher.core import import_tasks from dispatcher.core import import_tasks, convert_timedelta
from dispatcher.forms import ProjectForm, ImportForm, ParentForm from dispatcher.forms import ProjectForm, ImportForm, ParentForm
from dispatcher.models import Project, Parent, Clocking, Task from dispatcher.models import Project, Parent, Clocking, Task, RealSageXHours
from dispatcher.serializers import ClockingSerializer from dispatcher.serializers import ClockingSerializer, RealSageXHoursSerializer
def dashboard_view(request): def dashboard_view(request):
@ -138,6 +140,12 @@ def get_stats_by_month(request, year: int, month: int):
if month < 1 or month > 12: if month < 1 or month > 12:
return JsonResponse({"status": "error", "error": f"Invalid month {month}"}) return JsonResponse({"status": "error", "error": f"Invalid month {month}"})
sagex_subquery = RealSageXHours.objects.filter(
parent=OuterRef("pk"),
date__year=year,
date__month=month
).values("hours")[:1]
parents = Parent.objects.annotate( parents = Parent.objects.annotate(
total_duration=Sum( total_duration=Sum(
"project__task__duration", "project__task__duration",
@ -146,6 +154,11 @@ def get_stats_by_month(request, year: int, month: int):
project__task__date__month=month project__task__date__month=month
) )
) )
).annotate(
real_sagex_hours=Coalesce(
Subquery(sagex_subquery, output_field=DurationField()),
timedelta(0)
)
) )
projects = Project.objects.annotate( projects = Project.objects.annotate(
total_duration=Sum( total_duration=Sum(
@ -195,7 +208,8 @@ def format_stats(parents: QuerySet[Parent], projects: QuerySet[Project], clockin
"name": parent.name, "name": parent.name,
"project_num": parent.project_num, "project_num": parent.project_num,
"is_productive": parent.is_productive, "is_productive": parent.is_productive,
"duration": parent.total_duration "duration": parent.total_duration,
"real_sagex_hours": convert_timedelta(parent.real_sagex_hours)
} }
for parent in parents for parent in parents
], ],
@ -283,4 +297,34 @@ def set_clocking(request, date: datetime.date):
return JsonResponse({ return JsonResponse({
"status": "success", "status": "success",
"clocking": ClockingSerializer(clocking).data "clocking": ClockingSerializer(clocking).data
})
@require_POST
def set_real_sagex(request, id, month: datetime.date):
parent = get_object_or_404(Parent, id=id)
minutes = request.POST.get("minutes")
if minutes is None:
return JsonResponse({
"status": "error",
"error": "Missing minutes field"
}, status=HTTP_400_BAD_REQUEST)
try:
minutes = int(minutes)
except ValueError:
return JsonResponse({
"status": "error",
"error": "Invalid value for minutes, must be an int"
}, status=HTTP_400_BAD_REQUEST)
hours, minutes = divmod(minutes, 60)
entry, created = RealSageXHours.objects.get_or_create(parent=parent, date=month)
entry.hours = timedelta(hours=int(hours), minutes=int(minutes))
entry.save()
entry.refresh_from_db()
return JsonResponse({
"status": "success",
"sagex": RealSageXHoursSerializer(entry).data
}) })

View File

@ -7,6 +7,12 @@
{% endblock %} {% endblock %}
{% block footer %}{% endblock %} {% block footer %}{% endblock %}
{% block main %} {% block main %}
{% csrf_token %}
<table class="template">
<td class="template sagex-hours">
<input type="number" class="hours" min="0" step="1" />:<input type="number" class="minutes" min="0" step="1" />
</td>
</table>
<div class="by-range"> <div class="by-range">
<div class="controls"> <div class="controls">
<div id="month-sel" class="group"> <div id="month-sel" class="group">
@ -33,7 +39,7 @@
<th colspan="2">N° SageX</th> <th colspan="2">N° SageX</th>
</tr> </tr>
<tr class="clocking"> <tr class="clocking">
<th>Total imputed</th> <th>Total clocked</th>
<td class="total-clocking"></td> <td class="total-clocking"></td>
</tr> </tr>
<tr class="total-durations"> <tr class="total-durations">