Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@ on:
branches: [main]

jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install ruff
run: pip install "$(grep -E '^ruff' requirements-dev.txt)"

- name: Ruff lint
run: ruff check --output-format=github .

test:
name: Tests
runs-on: ubuntu-latest
Expand Down
3 changes: 3 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Development-only tooling. Install with: pip install -r requirements-dev.txt
-r requirements.txt
ruff>=0.6,<1.0
19 changes: 19 additions & 0 deletions ruff.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Ruff config for Time Tracker. See https://docs.astral.sh/ruff/
target-version = "py312"
line-length = 99
indent-width = 4

# Generated migrations don't need to follow all rules.
extend-exclude = ["*/migrations/*", ".venv", "static"]

[lint]
# E/W: pycodestyle, F: pyflakes, I: isort, B: bugbear, UP: pyupgrade, DJ: flake8-django
select = ["E", "W", "F", "I", "B", "UP", "DJ"]

[lint.per-file-ignores]
# Django settings legitimately use star imports and long lines.
"config/settings.py" = ["E501", "F403", "F405"]

[format]
quote-style = "double"
indent-style = "space"
24 changes: 12 additions & 12 deletions tracker/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
from django.db import models
from django.utils import timezone


HEX_COLOR_VALIDATOR = RegexValidator(
regex=r"^#(?:[0-9a-fA-F]{3}){1,2}$",
message="Color must be a hex value like #0d6efd or #abc.",
Expand Down Expand Up @@ -75,10 +74,11 @@ def total_hours(self, since=None, until=None) -> Decimal:
def total_cost(self, since=None, until=None) -> Decimal | None:
if self.hourly_rate is None:
return None
return (self.total_hours(since=since, until=until) * self.hourly_rate).quantize(Decimal("0.01"))
total = self.total_hours(since=since, until=until) * self.hourly_rate
return total.quantize(Decimal("0.01"))

@property
def active_timer(self) -> "TimeEntry | None":
def active_timer(self) -> TimeEntry | None:
"""Return the currently-running timer for this project, if any."""
return self.entries.filter(end_time__isnull=True).order_by("-start_time").first()

Expand All @@ -104,6 +104,15 @@ class Meta:
def __str__(self) -> str:
return f"{self.project.name} · {self.date} · {self.duration_display}"

def save(self, *args, **kwargs):
# Auto-compute duration when both timestamps are set
if self.start_time and self.end_time:
delta: timedelta = self.end_time - self.start_time
self.duration_minutes = max(0, int(delta.total_seconds() // 60))
if not self.date:
self.date = timezone.localdate(self.start_time)
super().save(*args, **kwargs)

@property
def is_running(self) -> bool:
return self.start_time is not None and self.end_time is None
Expand All @@ -124,12 +133,3 @@ def current_duration_minutes(self) -> int:
def clean(self):
if self.start_time and self.end_time and self.end_time < self.start_time:
raise ValidationError("End time must be after start time.")

def save(self, *args, **kwargs):
# Auto-compute duration when both timestamps are set
if self.start_time and self.end_time:
delta: timedelta = self.end_time - self.start_time
self.duration_minutes = max(0, int(delta.total_seconds() // 60))
if not self.date:
self.date = timezone.localdate(self.start_time)
super().save(*args, **kwargs)
4 changes: 3 additions & 1 deletion tracker/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ def test_totals_with_no_entries(self):
def test_totals_aggregate_completed_entries(self):
p = Project.objects.create(name="Sum", hourly_rate=Decimal("60.00"))
now = timezone.now()
TimeEntry.objects.create(project=p, start_time=now - timedelta(hours=2), end_time=now, date=now.date())
TimeEntry.objects.create(
project=p, start_time=now - timedelta(hours=2), end_time=now, date=now.date()
)
TimeEntry.objects.create(
project=p,
start_time=now - timedelta(minutes=30),
Expand Down
18 changes: 14 additions & 4 deletions tracker/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render, resolve_url
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils import timezone
from django.utils.http import url_has_allowed_host_and_scheme
from django.views.decorators.http import require_POST
Expand All @@ -39,7 +38,6 @@
from .forms import ProjectForm, ReportFilterForm, TimeEntryForm
from .models import Project, TimeEntry


# ---------- Helpers ----------

def _safe_next(request, fallback: str = "tracker:dashboard") -> str:
Expand Down Expand Up @@ -98,7 +96,9 @@ def dashboard(request):
@login_required
def project_list(request):
show_archived = request.GET.get("archived") == "1"
projects = Project.objects.all() if show_archived else Project.objects.filter(is_archived=False)
projects = (
Project.objects.all() if show_archived else Project.objects.filter(is_archived=False)
)
projects = projects.annotate(total_minutes_db=Sum("entries__duration_minutes"))
return render(request, "tracker/project_list.html", {
"projects": projects,
Expand Down Expand Up @@ -342,7 +342,17 @@ def report_csv(request):
filename = f"timetracker-report-{timezone.localdate().isoformat()}.csv"
response["Content-Disposition"] = f'attachment; filename="{filename}"'
writer = csv.writer(response)
writer.writerow(["Project", "Date", "Start", "End", "Duration (minutes)", "Duration (hours)", "Description"])
writer.writerow(
[
"Project",
"Date",
"Start",
"End",
"Duration (minutes)",
"Duration (hours)",
"Description",
]
)
for block in context["project_blocks"]:
for e in block["entries"]:
hours = (Decimal(e.duration_minutes) / Decimal(60)).quantize(Decimal("0.01"))
Expand Down
Loading