From 60fe4c96818a5f48777501508bbb65e30ef117f9 Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Mon, 20 Jan 2025 19:35:22 -0300 Subject: [PATCH] feat(app): allow changing date and datetime format as a user setting --- app/apps/common/templatetags/date.py | 32 +++ app/apps/common/utils/django.py | 129 ++++++++++++ app/apps/common/widgets/datepicker.py | 190 +++++++++++------- app/apps/currencies/forms.py | 8 +- app/apps/currencies/views/exchange_rates.py | 8 +- app/apps/dca/forms.py | 4 +- app/apps/dca/views.py | 8 +- app/apps/monthly_overview/views.py | 4 +- app/apps/transactions/filters.py | 6 +- app/apps/transactions/forms.py | 29 +-- .../transactions/views/installment_plans.py | 10 +- .../views/recurring_transactions.py | 12 +- app/apps/transactions/views/transactions.py | 20 +- app/apps/users/forms.py | 52 ++++- .../0013_usersettings_date_format_and_more.py | 23 +++ app/apps/users/models.py | 3 + .../fragments/list_transactions.html | 3 +- app/templates/cotton/transaction/item.html | 3 +- .../dca/fragments/strategy/details.html | 7 +- .../exchange_rates/fragments/table.html | 3 +- frontend/src/application/datepicker.js | 2 + 21 files changed, 426 insertions(+), 130 deletions(-) create mode 100644 app/apps/common/templatetags/date.py create mode 100644 app/apps/users/migrations/0013_usersettings_date_format_and_more.py diff --git a/app/apps/common/templatetags/date.py b/app/apps/common/templatetags/date.py new file mode 100644 index 0000000..f57d685 --- /dev/null +++ b/app/apps/common/templatetags/date.py @@ -0,0 +1,32 @@ +from django import template +from django.template.defaultfilters import date as date_filter +from django.utils import formats, timezone + +register = template.Library() + + +@register.filter +def custom_date(value, user=None): + if not value: + return "" + + # Determine if the value is a datetime or just a date + is_datetime = hasattr(value, "hour") + + # Convert to current timezone if it's a datetime + if is_datetime and timezone.is_aware(value): + value = timezone.localtime(value) + + if user and user.is_authenticated: + user_settings = user.settings + + if is_datetime: + format_setting = user_settings.datetime_format + else: + format_setting = user_settings.date_format + + return formats.date_format(value, format_setting, use_l10n=True) + + return date_filter( + value, "SHORT_DATE_FORMAT" if not is_datetime else "SHORT_DATETIME_FORMAT" + ) diff --git a/app/apps/common/utils/django.py b/app/apps/common/utils/django.py index c1a6342..b8f2faf 100644 --- a/app/apps/common/utils/django.py +++ b/app/apps/common/utils/django.py @@ -30,3 +30,132 @@ def django_to_python_datetime(django_format): python_format = python_format.replace(django_code, python_code) return python_format + + +def django_to_airdatepicker_datetime(django_format): + format_map = { + # Time + "h": "h", # Hour (12-hour) + "H": "H", # Hour (24-hour) + "i": "m", # Minutes + "A": "AA", # AM/PM uppercase + "a": "aa", # am/pm lowercase + "P": "h:mm AA", # Localized time format (e.g., "2:30 PM") + # Date + "D": "E", # Short weekday name + "l": "EEEE", # Full weekday name + "j": "d", # Day of month without leading zero + "d": "dd", # Day of month with leading zero + "n": "M", # Month without leading zero + "m": "MM", # Month with leading zero + "M": "MMM", # Short month name + "F": "MMMM", # Full month name + "y": "yy", # Year, 2 digits + "Y": "yyyy", # Year, 4 digits + } + + result = "" + i = 0 + while i < len(django_format): + char = django_format[i] + if char == "\\": # Handle escaped characters + if i + 1 < len(django_format): + result += django_format[i + 1] + i += 2 + continue + + if char in format_map: + result += format_map[char] + else: + result += char + i += 1 + + return result + + +def django_to_airdatepicker_datetime_separated(django_format): + format_map = { + # Time formats + "h": "h", # Hour (12-hour) + "H": "H", # Hour (24-hour) + "i": "m", # Minutes + "A": "AA", # AM/PM uppercase + "a": "aa", # am/pm lowercase + "P": "h:mm AA", # Localized time format + # Date formats + "D": "E", # Short weekday name + "l": "EEEE", # Full weekday name + "j": "d", # Day of month without leading zero + "d": "dd", # Day of month with leading zero + "n": "M", # Month without leading zero + "m": "MM", # Month with leading zero + "M": "MMM", # Short month name + "F": "MMMM", # Full month name + "y": "yy", # Year, 2 digits + "Y": "yyyy", # Year, 4 digits + } + + # Define which characters belong to time format + time_chars = {"h", "H", "i", "A", "a", "P"} + date_chars = {"D", "l", "j", "d", "n", "m", "M", "F", "y", "Y"} + + date_parts = [] + time_parts = [] + current_part = [] + is_time = False + + i = 0 + while i < len(django_format): + char = django_format[i] + + if char == "\\": # Handle escaped characters + if i + 1 < len(django_format): + current_part.append(django_format[i + 1]) + i += 2 + continue + + if char in format_map: + if char in time_chars: + # If we were building a date part, save it and start a time part + if current_part and not is_time: + date_parts.append("".join(current_part)) + current_part = [] + is_time = True + current_part.append(format_map[char]) + elif char in date_chars: + # If we were building a time part, save it and start a date part + if current_part and is_time: + time_parts.append("".join(current_part)) + current_part = [] + is_time = False + current_part.append(format_map[char]) + else: + # Handle separators + if char in "/:.-": + current_part.append(char) + elif char == " ": + if current_part: + if is_time: + time_parts.append("".join(current_part)) + else: + date_parts.append("".join(current_part)) + current_part = [] + current_part.append(char) + + i += 1 + + # Don't forget the last part + if current_part: + if is_time: + time_parts.append("".join(current_part)) + else: + date_parts.append("".join(current_part)) + + date_format = "".join(date_parts) + time_format = "".join(time_parts) + + # Clean up multiple spaces while preserving necessary ones + date_format = " ".join(filter(None, date_format.split())) + time_format = " ".join(filter(None, time_format.split())) + + return date_format, time_format diff --git a/app/apps/common/widgets/datepicker.py b/app/apps/common/widgets/datepicker.py index 9ef45df..2dba9b3 100644 --- a/app/apps/common/widgets/datepicker.py +++ b/app/apps/common/widgets/datepicker.py @@ -4,7 +4,11 @@ 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 +from apps.common.utils.django import ( + django_to_python_datetime, + django_to_airdatepicker_datetime, + django_to_airdatepicker_datetime_separated, +) class AirDatePickerInput(widgets.DateInput): @@ -14,104 +18,56 @@ class AirDatePickerInput(widgets.DateInput): format=None, clear_button=True, auto_close=True, + user=None, *args, **kwargs, ): attrs = attrs or {} - + self.user = user super().__init__(attrs=attrs, format=format, *args, **kwargs) - self.clear_button = clear_button self.auto_close = auto_close - def _get_current_language(self): + @staticmethod + def _get_current_language(): """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 _get_format(self): + """Get the format string based on user settings or default""" + if self.format: + return self.format + + if self.user and hasattr(self.user, "settings"): + user_format = self.user.settings.date_format + print(user_format) + if user_format == "SHORT_DATE_FORMAT": + return get_format("SHORT_DATE_FORMAT", use_l10n=True) + return user_format + + return get_format("SHORT_DATE_FORMAT", use_l10n=True) + 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 - ) + attrs["data-date-format"] = django_to_airdatepicker_datetime(self._get_format()) 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 - ) + self.attrs["data-value"] = value 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 formats.date_format(value, format=self._get_format(), use_l10n=True) return str(value) @@ -123,8 +79,95 @@ class AirDateTimePickerInput(widgets.DateTimeInput): # 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 + value.strip(), + django_to_python_datetime(self._get_format()) + or django_to_python_datetime(get_format("SHORT_DATETIME_FORMAT")), + ).strftime("%Y-%m-%d") + except (ValueError, TypeError) as e: + return value + return None + + +class AirDateTimePickerInput(widgets.DateTimeInput): + def __init__( + self, + attrs=None, + format=None, + timepicker=True, + clear_button=True, + auto_close=True, + user=None, + *args, + **kwargs, + ): + attrs = attrs or {} + self.user = user + super().__init__(attrs=attrs, format=format, *args, **kwargs) + self.timepicker = timepicker + self.clear_button = clear_button + self.auto_close = auto_close + + @staticmethod + def _get_current_language(): + """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 _get_format(self): + """Get the format string based on user settings or default""" + if self.format: + return self.format + + if self.user and hasattr(self.user, "settings"): + user_format = self.user.settings.datetime_format + if user_format == "SHORT_DATETIME_FORMAT": + return get_format("SHORT_DATETIME_FORMAT", use_l10n=True) + return user_format + + return get_format("SHORT_DATETIME_FORMAT", use_l10n=True) + + def build_attrs(self, base_attrs, extra_attrs=None): + attrs = super().build_attrs(base_attrs, extra_attrs) + + date_format, time_format = django_to_airdatepicker_datetime_separated( + self._get_format() + ) + + # 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"] = date_format + attrs["data-time-format"] = time_format + + return attrs + + def format_value(self, value): + """Format the value for display in the widget.""" + if value: + self.attrs["data-value"] = datetime.datetime.strftime( + value, "%Y-%m-%d %H:%M:00" + ) + + if value is None: + return "" + if isinstance(value, (datetime.date, datetime.datetime)): + return formats.date_format(value, format=self._get_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.strip(), + django_to_python_datetime(self._get_format()) or django_to_python_datetime(get_format("SHORT_DATETIME_FORMAT")), ).strftime("%Y-%m-%d %H:%M:%S") except (ValueError, TypeError) as e: @@ -140,7 +183,8 @@ class AirMonthYearPickerInput(AirDatePickerInput): # Store the Python format for internal use self.python_format = "%B %Y" - def _get_month_names(self): + @staticmethod + def _get_month_names(): """Get month names using Django's date translation""" return {dates.MONTHS[i]: i for i in range(1, 13)} diff --git a/app/apps/currencies/forms.py b/app/apps/currencies/forms.py index b6b36c4..fb5e745 100644 --- a/app/apps/currencies/forms.py +++ b/app/apps/currencies/forms.py @@ -65,9 +65,6 @@ class CurrencyForm(forms.ModelForm): class ExchangeRateForm(forms.ModelForm): date = forms.DateTimeField( - widget=AirDateTimePickerInput( - clear_button=False, - ), label=_("Date"), ) @@ -75,7 +72,7 @@ class ExchangeRateForm(forms.ModelForm): model = ExchangeRate fields = ["from_currency", "to_currency", "rate", "date"] - def __init__(self, *args, **kwargs): + def __init__(self, *args, user=None, **kwargs): super().__init__(*args, **kwargs) self.helper = FormHelper() @@ -84,6 +81,9 @@ class ExchangeRateForm(forms.ModelForm): self.helper.layout = Layout("date", "from_currency", "to_currency", "rate") self.fields["rate"].widget = ArbitraryDecimalDisplayNumberInput() + self.fields["date"].widget = AirDateTimePickerInput( + clear_button=False, user=user + ) if self.instance and self.instance.pk: self.helper.layout.append( diff --git a/app/apps/currencies/views/exchange_rates.py b/app/apps/currencies/views/exchange_rates.py index 7312672..46ef4a2 100644 --- a/app/apps/currencies/views/exchange_rates.py +++ b/app/apps/currencies/views/exchange_rates.py @@ -84,7 +84,7 @@ def exchange_rates_list_pair(request): @require_http_methods(["GET", "POST"]) def exchange_rate_add(request): if request.method == "POST": - form = ExchangeRateForm(request.POST) + form = ExchangeRateForm(request.POST, user=request.user) if form.is_valid(): form.save() messages.success(request, _("Exchange rate added successfully")) @@ -96,7 +96,7 @@ def exchange_rate_add(request): }, ) else: - form = ExchangeRateForm() + form = ExchangeRateForm(user=request.user) return render( request, @@ -112,7 +112,7 @@ def exchange_rate_edit(request, pk): exchange_rate = get_object_or_404(ExchangeRate, id=pk) if request.method == "POST": - form = ExchangeRateForm(request.POST, instance=exchange_rate) + form = ExchangeRateForm(request.POST, instance=exchange_rate, user=request.user) if form.is_valid(): form.save() messages.success(request, _("Exchange rate updated successfully")) @@ -124,7 +124,7 @@ def exchange_rate_edit(request, pk): }, ) else: - form = ExchangeRateForm(instance=exchange_rate) + form = ExchangeRateForm(instance=exchange_rate, user=request.user) return render( request, diff --git a/app/apps/dca/forms.py b/app/apps/dca/forms.py index 2b61163..2158704 100644 --- a/app/apps/dca/forms.py +++ b/app/apps/dca/forms.py @@ -62,11 +62,10 @@ class DCAEntryForm(forms.ModelForm): "notes", ] widgets = { - "date": AirDatePickerInput(clear_button=False), "notes": forms.Textarea(attrs={"rows": 3}), } - def __init__(self, *args, **kwargs): + def __init__(self, *args, user=None, **kwargs): super().__init__(*args, **kwargs) self.helper = FormHelper() self.helper.form_tag = False @@ -107,3 +106,4 @@ class DCAEntryForm(forms.ModelForm): self.fields["amount_paid"].widget = ArbitraryDecimalDisplayNumberInput() self.fields["amount_received"].widget = ArbitraryDecimalDisplayNumberInput() + self.fields["date"].widget = AirDatePickerInput(clear_button=False, user=user) diff --git a/app/apps/dca/views.py b/app/apps/dca/views.py index cfdfa97..045167f 100644 --- a/app/apps/dca/views.py +++ b/app/apps/dca/views.py @@ -157,7 +157,7 @@ def strategy_detail(request, strategy_id): def strategy_entry_add(request, strategy_id): strategy = get_object_or_404(DCAStrategy, id=strategy_id) if request.method == "POST": - form = DCAEntryForm(request.POST) + form = DCAEntryForm(request.POST, user=request.user) if form.is_valid(): entry = form.save(commit=False) entry.strategy = strategy @@ -171,7 +171,7 @@ def strategy_entry_add(request, strategy_id): }, ) else: - form = DCAEntryForm() + form = DCAEntryForm(user=request.user) return render( request, @@ -186,7 +186,7 @@ def strategy_entry_edit(request, strategy_id, entry_id): dca_entry = get_object_or_404(DCAEntry, id=entry_id, strategy__id=strategy_id) if request.method == "POST": - form = DCAEntryForm(request.POST, instance=dca_entry) + form = DCAEntryForm(request.POST, instance=dca_entry, user=request.user) if form.is_valid(): form.save() messages.success(request, _("Entry updated successfully")) @@ -198,7 +198,7 @@ def strategy_entry_edit(request, strategy_id, entry_id): }, ) else: - form = DCAEntryForm(instance=dca_entry) + form = DCAEntryForm(instance=dca_entry, user=request.user) return render( request, diff --git a/app/apps/monthly_overview/views.py b/app/apps/monthly_overview/views.py index 0289a4d..814bce5 100644 --- a/app/apps/monthly_overview/views.py +++ b/app/apps/monthly_overview/views.py @@ -41,7 +41,7 @@ def monthly_overview(request, month: int, year: int): previous_month = 12 if month == 1 else month - 1 previous_year = year - 1 if previous_month == 12 and month == 1 else year - f = TransactionsFilter(request.GET) + f = TransactionsFilter(request.GET, user=request.user) return render( request, @@ -64,7 +64,7 @@ def monthly_overview(request, month: int, year: int): def transactions_list(request, month: int, year: int): order = request.GET.get("order") - f = TransactionsFilter(request.GET) + f = TransactionsFilter(request.GET, user=request.user) transactions_filtered = ( f.qs.filter() .filter( diff --git a/app/apps/transactions/filters.py b/app/apps/transactions/filters.py index 9f4b516..81d9ef5 100644 --- a/app/apps/transactions/filters.py +++ b/app/apps/transactions/filters.py @@ -88,13 +88,11 @@ class TransactionsFilter(django_filters.FilterSet): date_start = django_filters.DateFilter( field_name="date", lookup_expr="gte", - widget=AirDatePickerInput(), label=_("Date from"), ) date_end = django_filters.DateFilter( field_name="date", lookup_expr="lte", - widget=AirDatePickerInput(), label=_("Until"), ) reference_date_start = MonthYearFilter( @@ -135,7 +133,7 @@ class TransactionsFilter(django_filters.FilterSet): "to_amount", ] - def __init__(self, data=None, *args, **kwargs): + def __init__(self, data=None, user=None, *args, **kwargs): # if filterset is bound, use initial values as defaults if data is not None: # get a mutable copy of the QueryDict @@ -184,3 +182,5 @@ class TransactionsFilter(django_filters.FilterSet): self.form.fields["to_amount"].widget = ArbitraryDecimalDisplayNumberInput() self.form.fields["from_amount"].widget = ArbitraryDecimalDisplayNumberInput() + self.form.fields["date_start"].widget = AirDatePickerInput(user=user) + self.form.fields["date_end"].widget = AirDatePickerInput(user=user) diff --git a/app/apps/transactions/forms.py b/app/apps/transactions/forms.py index 190ddf5..83de81d 100644 --- a/app/apps/transactions/forms.py +++ b/app/apps/transactions/forms.py @@ -60,9 +60,7 @@ class TransactionForm(forms.ModelForm): widget=TomSelect(clear_button=False, group_by="group"), ) - date = forms.DateField( - widget=AirDatePickerInput(clear_button=False), label=_("Date") - ) + date = forms.DateField(label=_("Date")) reference_date = forms.DateField( widget=AirMonthYearPickerInput(), label=_("Reference Date"), required=False @@ -88,7 +86,7 @@ class TransactionForm(forms.ModelForm): "account": TomSelect(clear_button=False, group_by="group"), } - def __init__(self, *args, **kwargs): + def __init__(self, *args, user=None, **kwargs): super().__init__(*args, **kwargs) # if editing a transaction display non-archived items and it's own item even if it's archived @@ -139,6 +137,7 @@ class TransactionForm(forms.ModelForm): ) self.fields["reference_date"].required = False + self.fields["date"].widget = AirDatePickerInput(clear_button=False, user=user) if self.instance and self.instance.pk: decimal_places = self.instance.account.currency.decimal_places @@ -240,9 +239,7 @@ class TransferForm(forms.Form): queryset=TransactionTag.objects.filter(active=True), ) - date = forms.DateField( - widget=AirDatePickerInput(clear_button=False), label=_("Date") - ) + date = forms.DateField(label=_("Date")) reference_date = forms.DateField( widget=AirMonthYearPickerInput(), label=_("Reference Date"), required=False @@ -259,7 +256,7 @@ class TransferForm(forms.Form): label=_("Notes"), ) - def __init__(self, *args, **kwargs): + def __init__(self, *args, user=None, **kwargs): super().__init__(*args, **kwargs) self.helper = FormHelper() @@ -327,8 +324,8 @@ class TransferForm(forms.Form): ) self.fields["from_amount"].widget = ArbitraryDecimalDisplayNumberInput() - self.fields["to_amount"].widget = ArbitraryDecimalDisplayNumberInput() + self.fields["date"].widget = AirDatePickerInput(clear_button=False, user=user) def clean(self): cleaned_data = super().clean() @@ -439,10 +436,9 @@ class InstallmentPlanForm(forms.ModelForm): "account": TomSelect(), "recurrence": TomSelect(clear_button=False), "notes": forms.Textarea(attrs={"rows": 3}), - "start_date": AirDatePickerInput(clear_button=False), } - def __init__(self, *args, **kwargs): + def __init__(self, *args, user=None, **kwargs): super().__init__(*args, **kwargs) # if editing display non-archived items and it's own item even if it's archived @@ -499,6 +495,9 @@ class InstallmentPlanForm(forms.ModelForm): ) self.fields["installment_amount"].widget = ArbitraryDecimalDisplayNumberInput() + self.fields["start_date"].widget = AirDatePickerInput( + clear_button=False, user=user + ) if self.instance and self.instance.pk: self.helper.layout.append( @@ -677,8 +676,6 @@ class RecurringTransactionForm(forms.ModelForm): "entities", ] widgets = { - "start_date": AirDatePickerInput(clear_button=False), - "end_date": AirDatePickerInput(), "reference_date": AirMonthYearPickerInput(), "recurrence_type": TomSelect(clear_button=False), "notes": forms.Textarea( @@ -688,7 +685,7 @@ class RecurringTransactionForm(forms.ModelForm): ), } - def __init__(self, *args, **kwargs): + def __init__(self, *args, user=None, **kwargs): super().__init__(*args, **kwargs) # if editing display non-archived items and it's own item even if it's archived @@ -745,6 +742,10 @@ class RecurringTransactionForm(forms.ModelForm): ) self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput() + self.fields["start_date"].widget = AirDatePickerInput( + clear_button=False, user=user + ) + self.fields["end_date"].widget = AirDatePickerInput(user=user) if self.instance and self.instance.pk: self.helper.layout.append( diff --git a/app/apps/transactions/views/installment_plans.py b/app/apps/transactions/views/installment_plans.py index a9a868d..6c330ec 100644 --- a/app/apps/transactions/views/installment_plans.py +++ b/app/apps/transactions/views/installment_plans.py @@ -82,7 +82,7 @@ def installment_plan_transactions(request, installment_plan_id): @require_http_methods(["GET", "POST"]) def installment_plan_add(request): if request.method == "POST": - form = InstallmentPlanForm(request.POST) + form = InstallmentPlanForm(request.POST, user=request.user) if form.is_valid(): form.save() messages.success(request, _("Installment Plan added successfully")) @@ -94,7 +94,7 @@ def installment_plan_add(request): }, ) else: - form = InstallmentPlanForm() + form = InstallmentPlanForm(user=request.user) return render( request, @@ -110,7 +110,9 @@ def installment_plan_edit(request, installment_plan_id): installment_plan = get_object_or_404(InstallmentPlan, id=installment_plan_id) if request.method == "POST": - form = InstallmentPlanForm(request.POST, instance=installment_plan) + form = InstallmentPlanForm( + request.POST, instance=installment_plan, user=request.user + ) if form.is_valid(): form.save() messages.success(request, _("Installment Plan updated successfully")) @@ -122,7 +124,7 @@ def installment_plan_edit(request, installment_plan_id): }, ) else: - form = InstallmentPlanForm(instance=installment_plan) + form = InstallmentPlanForm(instance=installment_plan, user=request.user) return render( request, diff --git a/app/apps/transactions/views/recurring_transactions.py b/app/apps/transactions/views/recurring_transactions.py index ba163b8..6b7f9e9 100644 --- a/app/apps/transactions/views/recurring_transactions.py +++ b/app/apps/transactions/views/recurring_transactions.py @@ -108,7 +108,7 @@ def recurring_transaction_transactions(request, recurring_transaction_id): @require_http_methods(["GET", "POST"]) def recurring_transaction_add(request): if request.method == "POST": - form = RecurringTransactionForm(request.POST) + form = RecurringTransactionForm(request.POST, user=request.user) if form.is_valid(): form.save() messages.success(request, _("Recurring Transaction added successfully")) @@ -120,7 +120,7 @@ def recurring_transaction_add(request): }, ) else: - form = RecurringTransactionForm() + form = RecurringTransactionForm(user=request.user) return render( request, @@ -138,7 +138,9 @@ def recurring_transaction_edit(request, recurring_transaction_id): ) if request.method == "POST": - form = RecurringTransactionForm(request.POST, instance=recurring_transaction) + form = RecurringTransactionForm( + request.POST, instance=recurring_transaction, user=request.user + ) if form.is_valid(): form.save() messages.success(request, _("Recurring Transaction updated successfully")) @@ -150,7 +152,9 @@ def recurring_transaction_edit(request, recurring_transaction_id): }, ) else: - form = RecurringTransactionForm(instance=recurring_transaction) + form = RecurringTransactionForm( + instance=recurring_transaction, user=request.user + ) return render( request, diff --git a/app/apps/transactions/views/transactions.py b/app/apps/transactions/views/transactions.py index 3561fa4..1869064 100644 --- a/app/apps/transactions/views/transactions.py +++ b/app/apps/transactions/views/transactions.py @@ -41,7 +41,7 @@ def transaction_add(request): ).date() if request.method == "POST": - form = TransactionForm(request.POST) + form = TransactionForm(request.POST, user=request.user) if form.is_valid(): form.save() messages.success(request, _("Transaction added successfully")) @@ -52,10 +52,11 @@ def transaction_add(request): ) else: form = TransactionForm( + user=request.user, initial={ "date": expected_date, "type": transaction_type, - } + }, ) return render( @@ -72,7 +73,7 @@ def transaction_edit(request, transaction_id, **kwargs): transaction = get_object_or_404(Transaction, id=transaction_id) if request.method == "POST": - form = TransactionForm(request.POST, instance=transaction) + form = TransactionForm(request.POST, user=request.user, instance=transaction) if form.is_valid(): form.save() messages.success(request, _("Transaction updated successfully")) @@ -82,7 +83,7 @@ def transaction_edit(request, transaction_id, **kwargs): headers={"HX-Trigger": "updated, hide_offcanvas"}, ) else: - form = TransactionForm(instance=transaction) + form = TransactionForm(instance=transaction, user=request.user) return render( request, @@ -172,7 +173,7 @@ def transactions_transfer(request): ).date() if request.method == "POST": - form = TransferForm(request.POST) + form = TransferForm(request.POST, user=request.user) if form.is_valid(): form.save() messages.success(request, _("Transfer added successfully")) @@ -185,7 +186,8 @@ def transactions_transfer(request): initial={ "reference_date": expected_date, "date": expected_date, - } + }, + user=request.user, ) return render(request, "transactions/fragments/transfer.html", {"form": form}) @@ -214,7 +216,7 @@ def transaction_pay(request, transaction_id): @login_required @require_http_methods(["GET"]) def transaction_all_index(request): - f = TransactionsFilter(request.GET) + f = TransactionsFilter(request.GET, user=request.user) return render(request, "transactions/pages/transactions.html", {"filter": f}) @@ -236,7 +238,7 @@ def transaction_all_list(request): transactions = default_order(transactions, order=order) - f = TransactionsFilter(request.GET, queryset=transactions) + f = TransactionsFilter(request.GET, user=request.user, queryset=transactions) page_number = request.GET.get("page", 1) paginator = Paginator(f.qs, 100) @@ -266,7 +268,7 @@ def transaction_all_summary(request): "installment_plan", ).all() - f = TransactionsFilter(request.GET, queryset=transactions) + f = TransactionsFilter(request.GET, user=request.user, queryset=transactions) currency_data = calculate_currency_totals(f.qs.all(), ignore_empty=True) currency_percentages = calculate_percentage_distribution(currency_data) diff --git a/app/apps/users/forms.py b/app/apps/users/forms.py index ebf4a06..ff778ee 100644 --- a/app/apps/users/forms.py +++ b/app/apps/users/forms.py @@ -46,9 +46,57 @@ class LoginForm(AuthenticationForm): class UserSettingsForm(forms.ModelForm): + DATE_FORMAT_CHOICES = [ + ("SHORT_DATE_FORMAT", _("Default")), + ("d-m-Y", "20-01-2025"), + ("m-d-Y", "01-20-2025"), + ("Y-m-d", "2025-01-20"), + ("d/m/Y", "20/01/2025"), + ("m/d/Y", "01/20/2025"), + ("Y/m/d", "2025/01/20"), + ("d.m.Y", "20.01.2025"), + ("m.d.Y", "01.20.2025"), + ("Y.m.d", "2025.01.20"), + ] + + DATETIME_FORMAT_CHOICES = [ + ("SHORT_DATETIME_FORMAT", _("Default")), + ("d-m-Y H:i", "20-01-2025 15:30"), + ("m-d-Y H:i", "01-20-2025 15:30"), + ("Y-m-d H:i", "2025-01-20 15:30"), + ("d-m-Y h:i A", "20-01-2025 03:30 PM"), + ("m-d-Y h:i A", "01-20-2025 03:30 PM"), + ("Y-m-d h:i A", "2025-01-20 03:30 PM"), + ("d/m/Y H:i", "20/01/2025 15:30"), + ("m/d/Y H:i", "01/20/2025 15:30"), + ("Y/m/d H:i", "2025/01/20 15:30"), + ("d/m/Y h:i A", "20/01/2025 03:30 PM"), + ("m/d/Y h:i A", "01/20/2025 03:30 PM"), + ("Y/m/d h:i A", "2025/01/20 03:30 PM"), + ("d.m.Y H:i", "20.01.2025 15:30"), + ("m.d.Y H:i", "01.20.2025 15:30"), + ("Y.m.d H:i", "2025.01.20 15:30"), + ("d.m.Y h:i A", "20.01.2025 03:30 PM"), + ("m.d.Y h:i A", "01.20.2025 03:30 PM"), + ("Y.m.d h:i A", "2025.01.20 03:30 PM"), + ] + + date_format = forms.ChoiceField( + choices=DATE_FORMAT_CHOICES, initial="SHORT_DATE_FORMAT" + ) + datetime_format = forms.ChoiceField( + choices=DATETIME_FORMAT_CHOICES, initial="SHORT_DATETIME_FORMAT" + ) + class Meta: model = UserSettings - fields = ["language", "timezone", "start_page"] + fields = [ + "language", + "timezone", + "start_page", + "date_format", + "datetime_format", + ] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -59,6 +107,8 @@ class UserSettingsForm(forms.ModelForm): self.helper.layout = Layout( "language", "timezone", + "date_format", + "datetime_format", "start_page", FormActions( NoClassSubmit( diff --git a/app/apps/users/migrations/0013_usersettings_date_format_and_more.py b/app/apps/users/migrations/0013_usersettings_date_format_and_more.py new file mode 100644 index 0000000..8d5d40a --- /dev/null +++ b/app/apps/users/migrations/0013_usersettings_date_format_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.5 on 2025-01-20 17:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0012_alter_usersettings_start_page'), + ] + + operations = [ + migrations.AddField( + model_name='usersettings', + name='date_format', + field=models.CharField(default='SHORT_DATE_FORMAT', max_length=100), + ), + migrations.AddField( + model_name='usersettings', + name='datetime_format', + field=models.CharField(default='SHORT_DATETIME_FORMAT', max_length=100), + ), + ] diff --git a/app/apps/users/models.py b/app/apps/users/models.py index 784a95d..5ecc5cb 100644 --- a/app/apps/users/models.py +++ b/app/apps/users/models.py @@ -36,6 +36,9 @@ class UserSettings(models.Model): hide_amounts = models.BooleanField(default=False) mute_sounds = models.BooleanField(default=False) + date_format = models.CharField(max_length=100, default="SHORT_DATE_FORMAT") + datetime_format = models.CharField(max_length=100, default="SHORT_DATETIME_FORMAT") + language = models.CharField( max_length=10, choices=(("auto", _("Auto")),) + settings.LANGUAGES, diff --git a/app/templates/calendar_view/fragments/list_transactions.html b/app/templates/calendar_view/fragments/list_transactions.html index ba1bd65..669e3d6 100644 --- a/app/templates/calendar_view/fragments/list_transactions.html +++ b/app/templates/calendar_view/fragments/list_transactions.html @@ -1,8 +1,9 @@ {% extends 'extends/offcanvas.html' %} +{% load date %} {% load i18n %} {% load crispy_forms_tags %} -{% block title %}{% translate 'Transactions on' %} {{ date|date:"SHORT_DATE_FORMAT" }}{% endblock %} +{% block title %}{% translate 'Transactions on' %} {{ date|custom_date:request.user }}{% endblock %} {% block body %}
diff --git a/app/templates/cotton/transaction/item.html b/app/templates/cotton/transaction/item.html index 02ba143..db12eb5 100644 --- a/app/templates/cotton/transaction/item.html +++ b/app/templates/cotton/transaction/item.html @@ -1,3 +1,4 @@ +{% load date %} {% load i18n %}
{% if not disable_selection %} @@ -26,7 +27,7 @@ {# Date#}
-
{{ transaction.date|date:"SHORT_DATE_FORMAT" }} • {{ transaction.reference_date|date:"b/Y" }}
+
{{ transaction.date|custom_date:request.user }} • {{ transaction.reference_date|date:"b/Y" }}
{# Description#}
diff --git a/app/templates/dca/fragments/strategy/details.html b/app/templates/dca/fragments/strategy/details.html index 0db0e7c..3e2a6dd 100644 --- a/app/templates/dca/fragments/strategy/details.html +++ b/app/templates/dca/fragments/strategy/details.html @@ -1,3 +1,4 @@ +{% load date %} {% load currency_display %} {% load i18n %}
@@ -16,7 +17,7 @@ :prefix="strategy.payment_currency.prefix" :suffix="strategy.payment_currency.suffix" :decimal_places="strategy.payment_currency.decimal_places"> - • {{ strategy.current_price.1|date:"SHORT_DATETIME_FORMAT" }} + • {{ strategy.current_price.1|custom_date:request.user }} {% else %}
{% trans "No exchange rate available" %}
@@ -83,7 +84,7 @@ _="install prompt_swal">
- {{ entry.date|date:"SHORT_DATE_FORMAT" }} + {{ entry.date|custom_date:request.user }} @@ -39,7 +40,7 @@ _="install prompt_swal">
- {{ exchange_rate.date|date:"SHORT_DATETIME_FORMAT" }} + {{ exchange_rate.date|custom_date:request.user }} {{ exchange_rate.from_currency.code }} x {{ exchange_rate.to_currency.code }} 1 {{ exchange_rate.from_currency.code }} ≅ {% currency_display amount=exchange_rate.rate prefix=exchange_rate.to_currency.prefix suffix=exchange_rate.to_currency.suffix decimal_places=exchange_rate.to_currency.decimal_places%} diff --git a/frontend/src/application/datepicker.js b/frontend/src/application/datepicker.js index 66ecfb2..2d2c848 100644 --- a/frontend/src/application/datepicker.js +++ b/frontend/src/application/datepicker.js @@ -26,6 +26,8 @@ window.DatePicker = function createDynamicDatePicker(element) { let baseOpts = { isMobile: isOnMobile, + dateFormat: element.dataset.dateFormat, + timeFormat: element.dataset.timeFormat, timepicker: element.dataset.timepicker === 'true', autoClose: element.dataset.autoClose === 'true', buttons: element.dataset.clearButton === 'true' ? ['clear', 'today'] : ['today'],