diff --git a/app/apps/common/fields/forms/dynamic_select.py b/app/apps/common/fields/forms/dynamic_select.py index 2d2ab01..6e0a8c6 100644 --- a/app/apps/common/fields/forms/dynamic_select.py +++ b/app/apps/common/fields/forms/dynamic_select.py @@ -61,7 +61,6 @@ class DynamicModelChoiceField(forms.ModelChoiceField): self._created_instance = instance return instance except Exception as e: - print(e) raise ValidationError( self.error_messages["invalid_choice"], code="invalid_choice" ) diff --git a/app/apps/common/fields/month_year.py b/app/apps/common/fields/month_year.py index 70eb33c..d4481fe 100644 --- a/app/apps/common/fields/month_year.py +++ b/app/apps/common/fields/month_year.py @@ -1,9 +1,11 @@ import datetime from django import forms -from django.db import models from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ +from apps.common.widgets.datepicker import AirMonthYearPickerInput from apps.common.widgets.month_year import MonthYearWidget @@ -27,7 +29,7 @@ class MonthYearModelField(models.DateField): class MonthYearFormField(forms.DateField): - widget = MonthYearWidget + widget = AirMonthYearPickerInput def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -42,7 +44,11 @@ class MonthYearFormField(forms.DateField): date = datetime.datetime.strptime(value, "%Y-%m") return date.replace(day=1).date() except ValueError: - raise ValidationError(_("Invalid date format. Use YYYY-MM.")) + try: + date = datetime.datetime.strptime(value, "%Y-%m-%d") + return date.replace(day=1).date() + except ValueError: + raise ValidationError(_("Invalid date format. Use YYYY-MM.")) def prepare_value(self, value): if isinstance(value, datetime.date): diff --git a/app/apps/common/utils/django.py b/app/apps/common/utils/django.py new file mode 100644 index 0000000..c1a6342 --- /dev/null +++ b/app/apps/common/utils/django.py @@ -0,0 +1,32 @@ +def django_to_python_datetime(django_format): + mapping = { + # Day + "j": "%d", # Day of the month without leading zeros + "d": "%d", # Day of the month with leading zeros + "D": "%a", # Day of the week, short version + "l": "%A", # Day of the week, full version + # Month + "n": "%m", # Month without leading zeros + "m": "%m", # Month with leading zeros + "M": "%b", # Month, short version + "F": "%B", # Month, full version + # Year + "y": "%y", # Year, 2 digits + "Y": "%Y", # Year, 4 digits + # Time + "g": "%I", # Hour (12-hour), without leading zeros + "G": "%H", # Hour (24-hour), without leading zeros + "h": "%I", # Hour (12-hour), with leading zeros + "H": "%H", # Hour (24-hour), with leading zeros + "i": "%M", # Minutes + "s": "%S", # Seconds + "a": "%p", # am/pm + "A": "%p", # AM/PM + "P": "%I:%M %p", + } + + python_format = django_format + for django_code, python_code in mapping.items(): + python_format = python_format.replace(django_code, python_code) + + return python_format diff --git a/app/apps/common/widgets/datepicker.py b/app/apps/common/widgets/datepicker.py new file mode 100644 index 0000000..9ef45df --- /dev/null +++ b/app/apps/common/widgets/datepicker.py @@ -0,0 +1,185 @@ +import datetime + +from django.forms import widgets +from django.utils import formats, translation, dates +from django.utils.formats import get_format + +from apps.common.utils.django import django_to_python_datetime + + +class AirDatePickerInput(widgets.DateInput): + def __init__( + self, + attrs=None, + format=None, + clear_button=True, + auto_close=True, + *args, + **kwargs, + ): + attrs = attrs or {} + + super().__init__(attrs=attrs, format=format, *args, **kwargs) + + self.clear_button = clear_button + self.auto_close = auto_close + + def _get_current_language(self): + """Get current language code in format compatible with AirDatepicker""" + lang_code = translation.get_language() + # AirDatepicker uses simple language codes + return lang_code.split("-")[0] + + def build_attrs(self, base_attrs, extra_attrs=None): + attrs = super().build_attrs(base_attrs, extra_attrs) + + # Add data attributes for AirDatepicker configuration + attrs["data-auto-close"] = str(self.auto_close).lower() + attrs["data-clear-button"] = str(self.clear_button).lower() + attrs["data-language"] = self._get_current_language() + attrs["data-date-format"] = self.format or get_format( + "SHORT_DATE_FORMAT", use_l10n=True + ) + + return attrs + + def format_value(self, value): + """Format the value for display in the widget.""" + if value: + self.attrs["data-value"] = ( + value # We use this to dynamically select the initial date on AirDatePicker + ) + + if value is None: + return "" + if isinstance(value, (datetime.date, datetime.datetime)): + return formats.date_format( + value, format=self.format or "SHORT_DATE_FORMAT", use_l10n=True + ) + + return str(value) + + +class AirDateTimePickerInput(widgets.DateTimeInput): + def __init__( + self, + attrs=None, + format=None, + timepicker=True, + clear_button=True, + auto_close=True, + *args, + **kwargs, + ): + attrs = attrs or {} + + super().__init__(attrs=attrs, format=format, *args, **kwargs) + + self.timepicker = timepicker + self.clear_button = clear_button + self.auto_close = auto_close + + def _get_current_language(self): + """Get current language code in format compatible with AirDatepicker""" + lang_code = translation.get_language() + # AirDatepicker uses simple language codes + return lang_code.split("-")[0] + + def build_attrs(self, base_attrs, extra_attrs=None): + attrs = super().build_attrs(base_attrs, extra_attrs) + + # Add data attributes for AirDatepicker configuration + attrs["data-timepicker"] = str(self.timepicker).lower() + attrs["data-auto-close"] = str(self.auto_close).lower() + attrs["data-clear-button"] = str(self.clear_button).lower() + attrs["data-language"] = self._get_current_language() + attrs["data-date-format"] = self.format or get_format( + "SHORT_DATETIME_FORMAT", use_l10n=True + ) + + return attrs + + def format_value(self, value): + """Format the value for display in the widget.""" + if value: + self.attrs["data-value"] = ( + value # We use this to dynamically select the initial date on AirDatePicker + ) + + if value is None: + return "" + if isinstance(value, (datetime.date, datetime.datetime)): + return formats.date_format( + value, format=self.format or "SHORT_DATETIME_FORMAT", use_l10n=True + ) + + return str(value) + + def value_from_datadict(self, data, files, name): + """Parse the datetime string from the form data.""" + value = super().value_from_datadict(data, files, name) + if value: + try: + # This does it's best to convert Django's PHP-Style date format to a datetime format and reformat the + # value to be read by Django. Probably could be improved + return datetime.datetime.strptime( + value, + self.format + or django_to_python_datetime(get_format("SHORT_DATETIME_FORMAT")), + ).strftime("%Y-%m-%d %H:%M:%S") + except (ValueError, TypeError) as e: + return value + return None + + +class AirMonthYearPickerInput(AirDatePickerInput): + def __init__(self, attrs=None, format=None, *args, **kwargs): + super().__init__(attrs=attrs, format=format, *args, **kwargs) + # Store the display format for AirDatepicker + self.display_format = "MMMM yyyy" + # Store the Python format for internal use + self.python_format = "%B %Y" + + def _get_month_names(self): + """Get month names using Django's date translation""" + return {dates.MONTHS[i]: i for i in range(1, 13)} + + def format_value(self, value): + """Format the value for display in the widget.""" + if value: + self.attrs["data-value"] = ( + value # We use this to dynamically select the initial date on AirDatePicker + ) + + if value is None: + return "" + if isinstance(value, str): + try: + value = datetime.datetime.strptime(value, "%Y-%m-%d").date() + except ValueError: + return value + if isinstance(value, (datetime.datetime, datetime.date)): + # Use Django's date translation + month_name = dates.MONTHS[value.month] + return f"{month_name} {value.year}" + return value + + def value_from_datadict(self, data, files, name): + """Convert the value from the widget format back to a format Django can handle.""" + value = super().value_from_datadict(data, files, name) + if value: + try: + # Split the value into month name and year + month_str, year_str = value.rsplit(" ", 1) + year = int(year_str) + + # Get month number from translated month name + month_names = self._get_month_names() + month = month_names.get(month_str) + + if month and year: + # Return the first day of the month in Django's expected format + return datetime.date(year, month, 1).strftime("%Y-%m-%d") + except (ValueError, KeyError): + return None + return None diff --git a/app/apps/currencies/forms.py b/app/apps/currencies/forms.py index 59f0658..b6b36c4 100644 --- a/app/apps/currencies/forms.py +++ b/app/apps/currencies/forms.py @@ -6,9 +6,10 @@ from django.forms import CharField from django.utils.translation import gettext_lazy as _ from apps.common.widgets.crispy.submit import NoClassSubmit +from apps.common.widgets.datepicker import AirDateTimePickerInput from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput -from apps.currencies.models import Currency, ExchangeRate from apps.common.widgets.tom_select import TomSelect +from apps.currencies.models import Currency, ExchangeRate class CurrencyForm(forms.ModelForm): @@ -64,9 +65,10 @@ class CurrencyForm(forms.ModelForm): class ExchangeRateForm(forms.ModelForm): date = forms.DateTimeField( - widget=forms.DateTimeInput( - attrs={"type": "datetime-local"}, format="%Y-%m-%dT%H:%M" - ) + widget=AirDateTimePickerInput( + clear_button=False, + ), + label=_("Date"), ) class Meta: diff --git a/app/apps/dca/forms.py b/app/apps/dca/forms.py index 7e916e7..2b61163 100644 --- a/app/apps/dca/forms.py +++ b/app/apps/dca/forms.py @@ -1,13 +1,14 @@ from crispy_forms.bootstrap import FormActions -from django import forms from crispy_forms.helper import FormHelper from crispy_forms.layout import Layout, Row, Column +from django import forms from django.utils.translation import gettext_lazy as _ +from apps.common.widgets.crispy.submit import NoClassSubmit +from apps.common.widgets.datepicker import AirDatePickerInput +from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput from apps.common.widgets.tom_select import TomSelect from apps.dca.models import DCAStrategy, DCAEntry -from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput -from apps.common.widgets.crispy.submit import NoClassSubmit class DCAStrategyForm(forms.ModelForm): @@ -61,7 +62,7 @@ class DCAEntryForm(forms.ModelForm): "notes", ] widgets = { - "date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"), + "date": AirDatePickerInput(clear_button=False), "notes": forms.Textarea(attrs={"rows": 3}), } diff --git a/app/apps/transactions/filters.py b/app/apps/transactions/filters.py index 93032c6..9f4b516 100644 --- a/app/apps/transactions/filters.py +++ b/app/apps/transactions/filters.py @@ -8,6 +8,7 @@ from django_filters import Filter from apps.accounts.models import Account from apps.common.fields.month_year import MonthYearFormField +from apps.common.widgets.datepicker import AirDatePickerInput from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput from apps.common.widgets.tom_select import TomSelectMultiple from apps.currencies.models import Currency @@ -87,13 +88,13 @@ class TransactionsFilter(django_filters.FilterSet): date_start = django_filters.DateFilter( field_name="date", lookup_expr="gte", - widget=forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"), + widget=AirDatePickerInput(), label=_("Date from"), ) date_end = django_filters.DateFilter( field_name="date", lookup_expr="lte", - widget=forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"), + widget=AirDatePickerInput(), label=_("Until"), ) reference_date_start = MonthYearFilter( diff --git a/app/apps/transactions/forms.py b/app/apps/transactions/forms.py index 9045986..190ddf5 100644 --- a/app/apps/transactions/forms.py +++ b/app/apps/transactions/forms.py @@ -16,10 +16,11 @@ from apps.common.fields.forms.dynamic_select import ( DynamicModelChoiceField, DynamicModelMultipleChoiceField, ) -from apps.common.fields.month_year import MonthYearFormField from apps.common.widgets.crispy.submit import NoClassSubmit +from apps.common.widgets.datepicker import AirDatePickerInput, AirMonthYearPickerInput from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput from apps.common.widgets.tom_select import TomSelect +from apps.rules.signals import transaction_created, transaction_updated from apps.transactions.models import ( Transaction, TransactionCategory, @@ -28,7 +29,6 @@ from apps.transactions.models import ( RecurringTransaction, TransactionEntity, ) -from apps.rules.signals import transaction_created, transaction_updated class TransactionForm(forms.ModelForm): @@ -59,7 +59,14 @@ class TransactionForm(forms.ModelForm): label=_("Account"), widget=TomSelect(clear_button=False, group_by="group"), ) - reference_date = MonthYearFormField(label=_("Reference Date"), required=False) + + date = forms.DateField( + widget=AirDatePickerInput(clear_button=False), label=_("Date") + ) + + reference_date = forms.DateField( + widget=AirMonthYearPickerInput(), label=_("Reference Date"), required=False + ) class Meta: model = Transaction @@ -77,7 +84,6 @@ class TransactionForm(forms.ModelForm): "entities", ] widgets = { - "date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"), "notes": forms.Textarea(attrs={"rows": 3}), "account": TomSelect(clear_button=False, group_by="group"), } @@ -118,8 +124,8 @@ class TransactionForm(forms.ModelForm): css_class="form-row", ), Row( - Column("date", css_class="form-group col-md-6 mb-0"), - Column("reference_date", css_class="form-group col-md-6 mb-0"), + Column(Field("date"), css_class="form-group col-md-6 mb-0"), + Column(Field("reference_date"), css_class="form-group col-md-6 mb-0"), css_class="form-row", ), "description", @@ -235,10 +241,13 @@ class TransferForm(forms.Form): ) date = forms.DateField( - label=_("Date"), - widget=forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"), + widget=AirDatePickerInput(clear_button=False), label=_("Date") ) - reference_date = MonthYearFormField(label=_("Reference Date"), required=False) + + reference_date = forms.DateField( + widget=AirMonthYearPickerInput(), label=_("Reference Date"), required=False + ) + description = forms.CharField(max_length=500, label=_("Description")) notes = forms.CharField( required=False, @@ -404,7 +413,10 @@ class InstallmentPlanForm(forms.ModelForm): queryset=TransactionEntity.objects.filter(active=True), ) type = forms.ChoiceField(choices=Transaction.Type.choices) - reference_date = MonthYearFormField(label=_("Reference Date"), required=False) + + reference_date = forms.DateField( + widget=AirMonthYearPickerInput(), label=_("Reference Date"), required=False + ) class Meta: model = InstallmentPlan @@ -424,10 +436,10 @@ class InstallmentPlanForm(forms.ModelForm): "entities", ] widgets = { - "start_date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"), "account": TomSelect(), "recurrence": TomSelect(clear_button=False), "notes": forms.Textarea(attrs={"rows": 3}), + "start_date": AirDatePickerInput(clear_button=False), } def __init__(self, *args, **kwargs): @@ -646,7 +658,6 @@ class RecurringTransactionForm(forms.ModelForm): queryset=TransactionEntity.objects.filter(active=True), ) type = forms.ChoiceField(choices=Transaction.Type.choices) - reference_date = MonthYearFormField(label=_("Reference Date"), required=False) class Meta: model = RecurringTransaction @@ -666,8 +677,9 @@ class RecurringTransactionForm(forms.ModelForm): "entities", ] widgets = { - "start_date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"), - "end_date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"), + "start_date": AirDatePickerInput(clear_button=False), + "end_date": AirDatePickerInput(), + "reference_date": AirMonthYearPickerInput(), "recurrence_type": TomSelect(clear_button=False), "notes": forms.Textarea( attrs={ diff --git a/app/templates/extends/offcanvas.html b/app/templates/extends/offcanvas.html index c2d7f5b..6f5cef9 100644 --- a/app/templates/extends/offcanvas.html +++ b/app/templates/extends/offcanvas.html @@ -5,6 +5,7 @@