diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2219795..4297b65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..4b5e4b7 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +# Development-only tooling. Install with: pip install -r requirements-dev.txt +-r requirements.txt +ruff>=0.6,<1.0 diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..f259ff0 --- /dev/null +++ b/ruff.toml @@ -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" diff --git a/tracker/models.py b/tracker/models.py index 7872f91..2982422 100644 --- a/tracker/models.py +++ b/tracker/models.py @@ -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.", @@ -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() @@ -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 @@ -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) diff --git a/tracker/tests.py b/tracker/tests.py index 610ce4e..0f98565 100644 --- a/tracker/tests.py +++ b/tracker/tests.py @@ -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), diff --git a/tracker/views.py b/tracker/views.py index 78436dd..5885462 100644 --- a/tracker/views.py +++ b/tracker/views.py @@ -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 @@ -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: @@ -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, @@ -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"))