143 lines
7.8 KiB
Python
143 lines
7.8 KiB
Python
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
|