diff --git a/ai/__pycache__/__init__.cpython-311.pyc b/ai/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..b9450fa Binary files /dev/null and b/ai/__pycache__/__init__.cpython-311.pyc differ diff --git a/ai/__pycache__/local_ai_api.cpython-311.pyc b/ai/__pycache__/local_ai_api.cpython-311.pyc new file mode 100644 index 0000000..e906db1 Binary files /dev/null and b/ai/__pycache__/local_ai_api.cpython-311.pyc differ diff --git a/config/__pycache__/settings.cpython-311.pyc b/config/__pycache__/settings.cpython-311.pyc index d79d6a7..1d243eb 100644 Binary files a/config/__pycache__/settings.cpython-311.pyc and b/config/__pycache__/settings.cpython-311.pyc differ diff --git a/config/settings.py b/config/settings.py index 291d043..3fa0a10 100644 --- a/config/settings.py +++ b/config/settings.py @@ -150,9 +150,11 @@ STATIC_URL = 'static/' STATIC_ROOT = BASE_DIR / 'staticfiles' STATICFILES_DIRS = [ - BASE_DIR / 'static', - BASE_DIR / 'assets', - BASE_DIR / 'node_modules', + path for path in [ + BASE_DIR / 'static', + BASE_DIR / 'assets', + BASE_DIR / 'node_modules', + ] if path.exists() ] # Email diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc index 5e8987a..b3a467b 100644 Binary files a/core/__pycache__/admin.cpython-311.pyc and b/core/__pycache__/admin.cpython-311.pyc differ diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc new file mode 100644 index 0000000..04c46f8 Binary files /dev/null and b/core/__pycache__/forms.cpython-311.pyc differ diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc index a251b5f..ada034d 100644 Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ diff --git a/core/__pycache__/tests.cpython-311.pyc b/core/__pycache__/tests.cpython-311.pyc new file mode 100644 index 0000000..7159898 Binary files /dev/null and b/core/__pycache__/tests.cpython-311.pyc differ diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index f705988..10c39b1 100644 Binary files a/core/__pycache__/urls.cpython-311.pyc and b/core/__pycache__/urls.cpython-311.pyc differ diff --git a/core/__pycache__/utils.cpython-311.pyc b/core/__pycache__/utils.cpython-311.pyc new file mode 100644 index 0000000..c9d0f02 Binary files /dev/null and b/core/__pycache__/utils.cpython-311.pyc differ diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index 2f0989c..bdd96cb 100644 Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ diff --git a/core/admin.py b/core/admin.py index 8c38f3f..9e75f94 100644 --- a/core/admin.py +++ b/core/admin.py @@ -1,3 +1,11 @@ from django.contrib import admin -# Register your models here. +from .models import Trip + + +@admin.register(Trip) +class TripAdmin(admin.ModelAdmin): + list_display = ("date", "trip_type", "start_location", "end_location", "distance_miles", "distance_source", "updated_at") + list_filter = ("trip_type", "distance_source", "date") + search_fields = ("start_location", "end_location", "business_purpose", "notes") + ordering = ("-date", "-start_time", "-created_at") diff --git a/core/forms.py b/core/forms.py new file mode 100644 index 0000000..6c1978c --- /dev/null +++ b/core/forms.py @@ -0,0 +1,142 @@ +from decimal import Decimal + +from django import forms +from django.utils import timezone + +from .models import Trip + + +DATETIME_LOCAL_FORMAT = "%Y-%m-%dT%H:%M" + + +class TripForm(forms.ModelForm): + created_at_override = forms.DateTimeField( + required=False, + label="Custom created timestamp", + input_formats=[DATETIME_LOCAL_FORMAT], + widget=forms.DateTimeInput(attrs={"type": "datetime-local", "class": "form-control", "placeholder": "Leave blank to use now"}, format=DATETIME_LOCAL_FORMAT), + help_text="Optional for retroactive entries. Leave blank to use the current timestamp.", + ) + updated_at_override = forms.DateTimeField( + required=False, + label="Custom updated timestamp", + input_formats=[DATETIME_LOCAL_FORMAT], + widget=forms.DateTimeInput(attrs={"type": "datetime-local", "class": "form-control", "placeholder": "Leave blank to auto-update"}, format=DATETIME_LOCAL_FORMAT), + help_text="Optional. If empty while editing, the app will stamp the current time.", + ) + update_end_odometer_from_map = forms.BooleanField( + required=False, + label="Use Google Maps miles to prefill ending odometer", + help_text="If a starting odometer and route miles are available, this suggests an ending odometer.", + ) + + class Meta: + model = Trip + fields = ["date", "start_time", "end_time", "start_location", "end_location", "business_purpose", "trip_type", "start_odometer", "end_odometer", "distance_miles", "notes"] + widgets = { + "date": forms.DateInput(attrs={"type": "date", "class": "form-control"}), + "start_time": forms.TimeInput(attrs={"type": "time", "class": "form-control"}), + "end_time": forms.TimeInput(attrs={"type": "time", "class": "form-control"}), + "start_location": forms.TextInput(attrs={"class": "form-control", "placeholder": "123 Market St, San Francisco"}), + "end_location": forms.TextInput(attrs={"class": "form-control", "placeholder": "Client office, warehouse, property, etc."}), + "business_purpose": forms.Textarea(attrs={"class": "form-control", "rows": 3, "placeholder": "Meeting with Client A at Property X"}), + "trip_type": forms.Select(attrs={"class": "form-select"}), + "start_odometer": forms.NumberInput(attrs={"class": "form-control", "step": "0.1", "min": "0", "placeholder": "Optional"}), + "end_odometer": forms.NumberInput(attrs={"class": "form-control", "step": "0.1", "min": "0", "placeholder": "Optional"}), + "distance_miles": forms.NumberInput(attrs={"class": "form-control", "step": "0.1", "min": "0", "placeholder": "Auto-filled from Google Maps or manual"}), + "notes": forms.Textarea(attrs={"class": "form-control", "rows": 3, "placeholder": "Client, project, repair details, or extra notes"}), + } + help_texts = { + "business_purpose": "Be specific so the trip is audit-friendly.", + "distance_miles": "Preferred source is odometer when both readings are present; otherwise Google Maps mileage is used.", + } + + def __init__(self, *args, latest_end_odometer=None, **kwargs): + super().__init__(*args, **kwargs) + if not self.instance.pk and latest_end_odometer is not None and self.initial.get("start_odometer") in (None, ""): + self.initial["start_odometer"] = latest_end_odometer + + + def clean_business_purpose(self): + purpose = (self.cleaned_data.get("business_purpose") or "").strip() + if len(purpose) < 8: + raise forms.ValidationError("Please add a more specific business purpose for this trip.") + return purpose + + def clean(self): + cleaned_data = super().clean() + start_odometer = cleaned_data.get("start_odometer") + end_odometer = cleaned_data.get("end_odometer") + distance_miles = cleaned_data.get("distance_miles") + should_prefill_end = cleaned_data.get("update_end_odometer_from_map") + + if start_odometer is not None and end_odometer is not None and end_odometer < start_odometer: + self.add_error("end_odometer", "Ending odometer must be greater than or equal to starting odometer.") + + if should_prefill_end and start_odometer is not None and distance_miles is not None and not end_odometer: + cleaned_data["end_odometer"] = Decimal(start_odometer) + Decimal(distance_miles) + self.cleaned_data["end_odometer"] = cleaned_data["end_odometer"] + + if start_odometer is None and end_odometer is not None: + self.add_error("start_odometer", "Add a starting odometer before storing an ending odometer.") + + if start_odometer is None and end_odometer is None and distance_miles is None: + self.add_error("distance_miles", "Calculate Google Maps mileage or enter odometer readings.") + + return cleaned_data + + def save(self, commit=True): + trip = super().save(commit=False) + now = timezone.now() + created_override = self.cleaned_data.get("created_at_override") + updated_override = self.cleaned_data.get("updated_at_override") + + if trip.pk: + trip.created_at = created_override or trip.created_at + trip.updated_at = updated_override or now + else: + trip.created_at = created_override or now + trip.updated_at = updated_override or trip.created_at + + if commit: + trip.save() + return trip + + +class TripFilterForm(forms.Form): + start_date = forms.DateField(required=False, widget=forms.DateInput(attrs={"type": "date", "class": "form-control"})) + end_date = forms.DateField(required=False, widget=forms.DateInput(attrs={"type": "date", "class": "form-control"})) + month = forms.IntegerField(required=False, min_value=1, max_value=12, widget=forms.NumberInput(attrs={"class": "form-control", "placeholder": "Month"})) + year = forms.IntegerField(required=False, min_value=2000, max_value=2100, widget=forms.NumberInput(attrs={"class": "form-control", "placeholder": "Year"})) + trip_type = forms.ChoiceField(required=False, choices=[("", "All trip types"), *Trip.TripType.choices], widget=forms.Select(attrs={"class": "form-select"})) + + +class ReportFilterForm(forms.Form): + REPORT_CHOICES = [("month", "Monthly report"), ("range", "Custom date range"), ("year", "Annual summary")] + + report_type = forms.ChoiceField(choices=REPORT_CHOICES, required=False, initial="month", widget=forms.Select(attrs={"class": "form-select"})) + month = forms.IntegerField(required=False, min_value=1, max_value=12, widget=forms.NumberInput(attrs={"class": "form-control"})) + year = forms.IntegerField(required=False, min_value=2000, max_value=2100, widget=forms.NumberInput(attrs={"class": "form-control"})) + start_date = forms.DateField(required=False, widget=forms.DateInput(attrs={"type": "date", "class": "form-control"})) + end_date = forms.DateField(required=False, widget=forms.DateInput(attrs={"type": "date", "class": "form-control"})) + + def clean(self): + cleaned = super().clean() + report_type = cleaned.get("report_type") or "month" + year = cleaned.get("year") + month = cleaned.get("month") + start_date = cleaned.get("start_date") + end_date = cleaned.get("end_date") + + if report_type == "month": + if not month or not year: + raise forms.ValidationError("Choose both month and year for the monthly report.") + elif report_type == "range": + if not start_date or not end_date: + raise forms.ValidationError("Choose both start and end dates for a custom range.") + if end_date < start_date: + raise forms.ValidationError("End date must be on or after the start date.") + elif report_type == "year": + if not year: + raise forms.ValidationError("Choose a year for the annual summary.") + return cleaned diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py new file mode 100644 index 0000000..b73a998 --- /dev/null +++ b/core/migrations/0001_initial.py @@ -0,0 +1,38 @@ +# Generated by Django 5.2.7 on 2026-03-24 11:31 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Trip', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField()), + ('start_time', models.TimeField()), + ('end_time', models.TimeField()), + ('start_location', models.CharField(max_length=255)), + ('end_location', models.CharField(max_length=255)), + ('business_purpose', models.TextField()), + ('trip_type', models.CharField(choices=[('business', 'Business'), ('personal', 'Personal'), ('commuting', 'Commuting'), ('repair', 'Repair / Maintenance')], max_length=20)), + ('start_odometer', models.DecimalField(blank=True, decimal_places=1, max_digits=10, null=True)), + ('end_odometer', models.DecimalField(blank=True, decimal_places=1, max_digits=10, null=True)), + ('distance_miles', models.DecimalField(blank=True, decimal_places=1, max_digits=8, null=True)), + ('distance_source', models.CharField(choices=[('odometer', 'Odometer'), ('map', 'Google Maps')], default='map', max_length=20)), + ('notes', models.TextField(blank=True)), + ('created_at', models.DateTimeField(default=django.utils.timezone.now)), + ('updated_at', models.DateTimeField(default=django.utils.timezone.now)), + ], + options={ + 'ordering': ['-date', '-start_time', '-created_at'], + }, + ), + ] diff --git a/core/migrations/__pycache__/0001_initial.cpython-311.pyc b/core/migrations/__pycache__/0001_initial.cpython-311.pyc new file mode 100644 index 0000000..bb347da Binary files /dev/null and b/core/migrations/__pycache__/0001_initial.cpython-311.pyc differ diff --git a/core/models.py b/core/models.py index 71a8362..04d561d 100644 --- a/core/models.py +++ b/core/models.py @@ -1,3 +1,72 @@ -from django.db import models +from decimal import Decimal -# Create your models here. +from django.core.exceptions import ValidationError +from django.db import models +from django.urls import reverse +from django.utils import timezone + + +class Trip(models.Model): + class TripType(models.TextChoices): + BUSINESS = "business", "Business" + PERSONAL = "personal", "Personal" + COMMUTING = "commuting", "Commuting" + REPAIR = "repair", "Repair / Maintenance" + + class DistanceSource(models.TextChoices): + ODOMETER = "odometer", "Odometer" + MAP = "map", "Google Maps" + + date = models.DateField() + start_time = models.TimeField() + end_time = models.TimeField() + start_location = models.CharField(max_length=255) + end_location = models.CharField(max_length=255) + business_purpose = models.TextField() + trip_type = models.CharField(max_length=20, choices=TripType.choices) + start_odometer = models.DecimalField(max_digits=10, decimal_places=1, null=True, blank=True) + end_odometer = models.DecimalField(max_digits=10, decimal_places=1, null=True, blank=True) + distance_miles = models.DecimalField(max_digits=8, decimal_places=1, null=True, blank=True) + distance_source = models.CharField(max_length=20, choices=DistanceSource.choices, default=DistanceSource.MAP) + notes = models.TextField(blank=True) + created_at = models.DateTimeField(default=timezone.now) + updated_at = models.DateTimeField(default=timezone.now) + + class Meta: + ordering = ["-date", "-start_time", "-created_at"] + + def __str__(self): + return f"{self.date:%Y-%m-%d} · {self.start_location} → {self.end_location}" + + def get_absolute_url(self): + return reverse("trip_detail", args=[self.pk]) + + @property + def miles_display(self): + return self.distance_miles or Decimal("0.0") + + def clean(self): + errors = {} + if self.end_time and self.start_time and self.end_time < self.start_time: + errors["end_time"] = "End time must be after the start time." + + if self.start_odometer is not None and self.end_odometer is not None: + if self.end_odometer < self.start_odometer: + errors["end_odometer"] = "Ending odometer must be greater than or equal to starting odometer." + else: + self.distance_miles = self.end_odometer - self.start_odometer + self.distance_source = self.DistanceSource.ODOMETER + elif self.distance_miles is not None: + if self.distance_miles < 0: + errors["distance_miles"] = "Distance must be zero or greater." + else: + self.distance_source = self.DistanceSource.MAP + else: + errors["distance_miles"] = "Add odometer readings or calculate mileage from Google Maps." + + if errors: + raise ValidationError(errors) + + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) diff --git a/core/templates/base.html b/core/templates/base.html index 1e7e5fb..22a2804 100644 --- a/core/templates/base.html +++ b/core/templates/base.html @@ -1,25 +1,66 @@ +{% load static %} -
-