Initial commit

This commit is contained in:
Felix Baumann
2026-06-27 17:07:34 +00:00
commit fb47c53d52
256 changed files with 21270 additions and 0 deletions
View File
+38
View File
@@ -0,0 +1,38 @@
from django.contrib import admin
from .models import TimeEntry
@admin.register(TimeEntry)
class TimeEntryAdmin(admin.ModelAdmin):
list_display = (
"project",
"user",
"entry_type",
"activity",
"start",
"end",
"break_minutes",
)
list_filter = (
"entry_type",
"project",
"user",
)
search_fields = (
"activity",
"project__name",
"user__username",
"user__first_name",
"user__last_name",
)
autocomplete_fields = (
"project",
"user",
)
ordering = (
"-start",
)
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class TimeTrackingConfig(AppConfig):
name = 'time_tracking'
+40
View File
@@ -0,0 +1,40 @@
# Generated by Django 6.0.6 on 2026-06-23 22:12
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('projects', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='TimeEntry',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('entry_type', models.CharField(choices=[('travel', 'Anfahrt'), ('work', 'Arbeitszeit')], default='work', max_length=20, verbose_name='Typ')),
('activity', models.CharField(blank=True, max_length=255, verbose_name='Tätigkeit')),
('start', models.DateTimeField(verbose_name='Beginn')),
('end', models.DateTimeField(blank=True, null=True)),
('break_minutes', models.PositiveIntegerField(default=0, verbose_name='Pause (Minuten)')),
('note', models.TextField(blank=True, verbose_name='Notiz')),
('invoiced', models.BooleanField(default=False, verbose_name='Abgerechnet')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='time_entries', to='projects.project', verbose_name='Projekt')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='time_entries', to=settings.AUTH_USER_MODEL, verbose_name='Mitarbeiter')),
],
options={
'verbose_name': 'Zeiterfassung',
'verbose_name_plural': 'Zeiterfassungen',
'ordering': ['-start'],
},
),
]
+105
View File
@@ -0,0 +1,105 @@
from django.db import models
from django.contrib.auth import get_user_model
User = get_user_model()
class TimeEntry(models.Model):
class EntryType(models.TextChoices):
TRAVEL = "travel", "Anfahrt"
WORK = "work", "Arbeitszeit"
project = models.ForeignKey(
"projects.Project",
on_delete=models.CASCADE,
related_name="time_entries",
verbose_name="Projekt"
)
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="time_entries",
verbose_name="Mitarbeiter"
)
entry_type = models.CharField(
max_length=20,
choices=EntryType.choices,
default=EntryType.WORK,
verbose_name="Typ"
)
activity = models.CharField(
max_length=255,
verbose_name="Tätigkeit",
blank=True,
)
start = models.DateTimeField(
verbose_name="Beginn"
)
end = models.DateTimeField(null=True, blank=True)
break_minutes = models.PositiveIntegerField(
default=0,
verbose_name="Pause (Minuten)"
)
note = models.TextField(
blank=True,
verbose_name="Notiz"
)
invoiced = models.BooleanField(default=False,verbose_name="Abgerechnet")
created_at = models.DateTimeField(
auto_now_add=True
)
updated_at = models.DateTimeField(
auto_now=True
)
class Meta:
verbose_name = "Zeiterfassung"
verbose_name_plural = "Zeiterfassungen"
ordering = ["-start"]
def __str__(self):
return f"{self.user} - {self.start:%d.%m.%Y} - {self.duration_hours} Stunden"
@property
def duration_minutes(self):
if not self.end:
return 0
duration = (self.end - self.start).total_seconds() / 60
return max(0, int(duration) - self.break_minutes)
@property
def duration_hours(self):
if not self.end:
return 0
return round(self.duration_minutes / 60, 2)
@property
def duration_minutes_total(self):
end = self.end or None
if not end:
return 0
return max(0, int((end - self.start).total_seconds() / 60) - self.break_minutes)
@property
def duration_hhmm(self):
total_minutes = self.duration_minutes_total
hours = total_minutes // 60
minutes = total_minutes % 60
return f"{hours:02d}:{minutes:02d}"
+91
View File
@@ -0,0 +1,91 @@
import django_tables2 as tables
from .models import TimeEntry
class TimeEntryTable(tables.Table):
entry_type = tables.TemplateColumn(
template_name="time_tracking/tables/entry_type.html",
verbose_name="Typ",
orderable=False,
)
activity = tables.TemplateColumn(
template_name="time_tracking/tables/activity.html",
verbose_name="Aktivität",
orderable=False,
)
start = tables.TemplateColumn(
template_name="time_tracking/tables/start.html",
verbose_name="Start",
orderable=False,
)
end = tables.TemplateColumn(
template_name="time_tracking/tables/end.html",
verbose_name="Ende",
orderable=False,
)
break_minutes = tables.TemplateColumn(
template_name="time_tracking/tables/break.html",
verbose_name="Pause (Min)",
orderable=False,
)
duration = tables.Column(
accessor="duration_hhmm",
verbose_name="Dauer",
orderable=False,
)
invoiced = tables.TemplateColumn(
template_name="time_tracking/tables/invoiced.html",
verbose_name="Abgerechnet",
orderable=False,
)
actions = tables.TemplateColumn(
template_name="time_tracking/tables/actions.html",
verbose_name="Aktionen",
orderable=False,
)
class Meta:
model = TimeEntry
template_name = "django_tables2/table.html"
attrs = {
"class": "w-full text-sm min-w-[1200px]"
}
fields = (
"entry_type",
"activity",
"start",
"end",
"break_minutes",
"duration",
"invoiced",
"actions",
)
def __init__(self, *args, types=None, project=None, **kwargs):
self.types = types
self.project = project
super().__init__(*args, **kwargs)
def before_render(self, request):
for row in self.rows:
row.record.project = self.project
row.record.types = self.types
@@ -0,0 +1,78 @@
{% extends "base.html" %}
{% block title %}
{{ project.name }} - Zeiterfassung
{% endblock %}
{% block header_title %}
Zeiterfassung
{% endblock %}
{% block header_description %}
Zeiten erfassen und verwalten
{% endblock %}
{% block content %}
{% load django_tables2 %}
<div class="mb-8">
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div>
<h2 class="text-xl font-bold">
Aktionen
</h2>
<p class="text-slate-400 text-sm mt-1">
Arbeitszeiten und Anfahrten erfassen
</p>
</div>
<div class="flex flex-col sm:flex-row gap-2">
<!-- START -->
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="start">
<button class="btn btn-action-start">
▶ Start
</button>
</form>
<!-- STOP -->
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="stop">
<button class="btn btn-action-stop">
■ Stop
</button>
</form>
</div>
</div>
</div>
</div>
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="mb-6">
<h2 class="text-xl font-bold">
Zeiterfassungen
</h2>
<p class="text-slate-400 text-sm mt-1">
Erfasste Zeiten des Projekts
</p>
</div>
<div class="overflow-x-auto rounded-xl">
{% render_table table %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,30 @@
<div class="flex gap-2">
<form method="post"
action="{% url 'time_tracking:edit' record.project_id record.id %}"
id="save-form-{{ record.id }}"
class="inline">
{% csrf_token %}
<button type="submit"
class="btn btn-save">
Speichern
</button>
</form>
<form method="post"
action="{% url 'time_tracking:delete' record.project_id record.id %}"
class="inline">
{% csrf_token %}
<button type="submit"
class="btn btn-delete">
Löschen
</button>
</form>
</div>
@@ -0,0 +1,6 @@
<input
type="text"
name="activity"
form="save-form-{{ record.id }}"
value="{{ record.activity }}"
class="border rounded px-2 py-1 w-full">
@@ -0,0 +1,6 @@
<input
type="number"
name="break_minutes"
form="save-form-{{ record.id }}"
value="{{ record.break_minutes }}"
class="border rounded px-2 py-1 w-24">
@@ -0,0 +1,6 @@
<input
type="datetime-local"
name="end"
form="save-form-{{ record.id }}"
value="{% if record.end %}{{ record.end|date:'Y-m-d' }}T{{ record.end|date:'H:i' }}{% endif %}"
class="border rounded px-2 py-1">
@@ -0,0 +1,13 @@
<select
name="entry_type"
form="save-form-{{ record.id }}"
class="border rounded px-2 py-1 w-full">
{% for value,label in types %}
<option value="{{ value }}"
{% if record.entry_type == value %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
@@ -0,0 +1,5 @@
{% if record.invoiced %}
<span class="text-slate-400 font-semibold">Ja</span>
{% else %}
<span class="text-slate-400">Nein</span>
{% endif %}
@@ -0,0 +1,6 @@
<input
type="datetime-local"
name="start"
form="save-form-{{ record.id }}"
value="{{ record.start|date:'Y-m-d' }}T{{ record.start|date:'H:i' }}"
class="border rounded px-2 py-1">
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+10
View File
@@ -0,0 +1,10 @@
from django.urls import path
from . import views
app_name = "time_tracking"
urlpatterns = [
path("<int:project_id>/time_tracking/",views.time_tracking_overview,name="overview"),
path("<int:project_id>/time_tracking/<int:entry_id>/edit/",views.time_tracking_edit,name="edit"),
path("<int:project_id>/time_tracking/<int:entry_id>/delete/",views.time_tracking_delete,name="delete"),
]
+263
View File
@@ -0,0 +1,263 @@
from django.contrib.auth.decorators import login_required
from accounts.decorators import permission_required
from django.shortcuts import render, get_object_or_404, redirect
from django.utils import timezone
from django.utils.dateparse import parse_datetime
from datetime import datetime
from projects.models import ProjectMembership
from .models import TimeEntry
from django_tables2 import RequestConfig
from .tables import TimeEntryTable
@login_required
@permission_required("projects_time_tracking_view")
def time_tracking_overview(request, project_id):
membership = get_object_or_404(
ProjectMembership,
project_id=project_id,
user=request.user
)
project = membership.project
# ==========================
# POST ACTIONS
# ==========================
if request.method == "POST":
action = request.POST.get("action")
# --------------------------
# START TIMER
# --------------------------
if action == "start":
open_entry = TimeEntry.objects.filter(
project=project,
user=request.user,
end__isnull=True
).first()
if not open_entry:
TimeEntry.objects.create(
project=project,
user=request.user,
entry_type=TimeEntry.EntryType.WORK,
activity="",
start=timezone.now(),
break_minutes=0,
)
return redirect(
"time_tracking:overview",
project.id
)
# --------------------------
# STOP TIMER
# --------------------------
if action == "stop":
open_entry = TimeEntry.objects.filter(
project=project,
user=request.user,
end__isnull=True
).order_by("-start").first()
if open_entry:
open_entry.end = timezone.now()
open_entry.save(
update_fields=[
"end"
]
)
return redirect(
"time_tracking:overview",
project.id
)
# --------------------------
# ADD ENTRY
# --------------------------
if action == "add":
start_str = request.POST.get("start", "")
end_str = request.POST.get("end", "")
start_dt = None
end_dt = None
try:
start_dt = datetime.strptime(start_str, "%Y-%m-%dT%H:%M")
start_dt = timezone.make_aware(start_dt)
except (ValueError, TypeError):
pass
if end_str:
try:
end_dt = datetime.strptime(end_str, "%Y-%m-%dT%H:%M")
end_dt = timezone.make_aware(end_dt)
except (ValueError, TypeError):
pass
TimeEntry.objects.create(
project=project,
user=request.user,
entry_type=request.POST.get("entry_type"),
activity=request.POST.get("activity"),
start=start_dt or timezone.now(),
end=end_dt,
break_minutes=request.POST.get("break_minutes") or 0,
note=request.POST.get("note", "")
)
return redirect(
"time_tracking:overview",
project.id
)
# ==========================
# TABLE
# ==========================
entries = project.time_entries.select_related(
"user"
).order_by(
"-start"
)
table = TimeEntryTable(
entries,
request=request,
)
RequestConfig(
request,
paginate={
"per_page": 25
}
).configure(table)
# ==========================
# RESPONSE
# ==========================
return render(
request,
"time_tracking/overview.html",
{
"project": project,
"table": table,
"types": TimeEntry.EntryType.choices,
}
)
@login_required
@permission_required("projects_time_tracking_edit")
def time_tracking_edit(request, project_id, entry_id):
membership = get_object_or_404(
ProjectMembership,
project_id=project_id,
user=request.user
)
project = membership.project
if request.method != "POST":
return redirect(
"time_tracking:overview",
project.id
)
entry = get_object_or_404(
TimeEntry,
id=entry_id,
project=project
)
# Parse datetime fields
start_str = request.POST.get("start", "")
end_str = request.POST.get("end", "")
try:
# Format: "Y-m-dTH:i" (e.g., "2026-06-27T13:36")
entry.start = datetime.strptime(start_str, "%Y-%m-%dT%H:%M")
entry.start = timezone.make_aware(entry.start)
except (ValueError, TypeError):
pass
if end_str:
try:
entry.end = datetime.strptime(end_str, "%Y-%m-%dT%H:%M")
entry.end = timezone.make_aware(entry.end)
except (ValueError, TypeError):
entry.end = None
else:
entry.end = None
entry.entry_type = request.POST.get("entry_type")
entry.activity = request.POST.get("activity")
entry.break_minutes = request.POST.get("break_minutes") or 0
entry.note = request.POST.get("note", "")
entry.save()
return redirect(
"time_tracking:overview",
project.id
)
@login_required
@permission_required("projects_time_tracking_delete")
def time_tracking_delete(request, project_id, entry_id):
membership = get_object_or_404(
ProjectMembership,
project_id=project_id,
user=request.user
)
project = membership.project
if request.method == "POST":
TimeEntry.objects.filter(
id=entry_id,
project=project
).delete()
return redirect(
"time_tracking:overview",
project.id
)