Compare commits

...

12 Commits
0.5.0 ... 0.6.0

Author SHA1 Message Date
Herculino Trotta
b18273a562 Merge pull request #51
feat(app): allow changing date and datetime format as a user setting
2025-01-20 19:36:13 -03:00
Herculino Trotta
60fe4c9681 feat(app): allow changing date and datetime format as a user setting 2025-01-20 19:35:22 -03:00
Herculino Trotta
f68e954bc0 Merge remote-tracking branch 'origin/main' 2025-01-18 00:00:19 -03:00
Herculino Trotta
404036bafa feat(readme): add guide to build from source 2025-01-17 23:59:55 -03:00
Herculino Trotta
5e8074ea01 fix(readme): wrong comment about running app 2025-01-17 23:59:25 -03:00
Herculino Trotta
c9cc942a10 Merge pull request #46
feat: add a duplicate/clone action to each transaction
2025-01-17 23:54:04 -03:00
Herculino Trotta
315f4e1269 feat: add a duplicate/clone action to each transaction 2025-01-17 23:53:39 -03:00
Herculino Trotta
b025ab7d24 docs: add more screenshots 2025-01-16 10:17:13 -03:00
Herculino Trotta
e2134e98a5 docs: Add information about running locally 2025-01-16 10:06:18 -03:00
Herculino Trotta
3f250338a3 Merge pull request #44 from eitchtee/new_datepicker
docker: remove YAML anchor and merge directives from docker-compose.prod.yml
2025-01-16 09:24:06 -03:00
Herculino Trotta
97c6b13d57 security: actually use SECRET_KEY env variable. You will get logged out. 2025-01-16 09:23:18 -03:00
Herculino Trotta
3dcee4dbf2 docker: remove YAML anchor and merge directives from docker-compose.prod.yml
Fixes #42
2025-01-16 09:22:08 -03:00
30 changed files with 541 additions and 136 deletions

BIN
.github/img/all_transactions.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

BIN
.github/img/calendar.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
.github/img/monthly_view.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

BIN
.github/img/networth.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

BIN
.github/img/yearly.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -19,6 +19,8 @@
**WYGIWYH** (_What You Get Is What You Have_) is a powerful, principles-first finance tracker designed for people who prefer a no-budget, straightforward approach to managing their money. With features like multi-currency support, customizable transactions, and a built-in dollar-cost averaging tracker, WYGIWYH helps you take control of your finances with simplicity and flexibility.
<img src=".github/img/monthly_view.png" width="18%"></img> <img src=".github/img/yearly.png" width="18%"></img> <img src=".github/img/networth.png" width="18%"></img> <img src=".github/img/calendar.png" width="18%"></img> <img src=".github/img/all_transactions.png" width="18%"></img>
# Why WYGIWYH?
Managing money can feel unnecessarily complex, but it doesnt have to be. WYGIWYH (pronounced "wiggy-wih") is based on a simple principle:
@@ -55,10 +57,10 @@ To run this application, you'll need [Docker](https://docs.docker.com/engine/ins
From your command line:
```bash
# Clone this repository
# Create a folder for WYGIWYH (optional)
$ mkdir WYGIWYH
# Go into the repository
# Go into the folder
$ cd WYGIWYH
$ touch docker-compose.yml
@@ -77,6 +79,48 @@ $ docker compose up -d
$ docker compose exec -it web python manage.py createsuperuser
```
## Running locally
If you want to run WYGIWYH locally, on your env file:
1. Remove `URL`
2. Set `HTTPS_ENABLED` to `false`
3. Leave the default `DJANGO_ALLOWED_HOSTS` (localhost 127.0.0.1 [::1])
You can now access localhost:OUTBOUND_PORT
> [!NOTE]
> If you're planning on running this behind Tailscale or other similar service also add your machine given IP to `DJANGO_ALLOWED_HOSTS`
> [!NOTE]
> If you're going to use another IP that isn't localhost, add it to `DJANGO_ALLOWED_HOSTS`, without `http://`
## Building from source
Features are only added to `main` when ready, if you want to run the latest version, you must build from source.
```bash
# Create a folder for WYGIWYH (optional)
$ mkdir WYGIWYH
# Go into the folder
$ cd WYGIWYH
# Clone this repository
$ git clone https://github.com/eitchtee/WYGIWYH.git .
$ cp docker-compose.prod.yml docker-compose.yml
$ cp .env.example .env
# Now edit both files as you see fit
# Run the app
$ docker compose up -d --build
# Create the first admin account
$ docker compose exec -it web python manage.py createsuperuser
```
# How it works
## Models

View File

@@ -26,7 +26,7 @@ ROOT_DIR = Path(__file__).resolve().parent.parent.parent
# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = "django-insecure-##6^&g49xwn7s67xc&33vf&=*4ibqfzn#xa*p-1sy8ag+zjjb9"
SECRET_KEY = os.getenv("SECRET_KEY", "")
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = os.getenv("DEBUG", "false").lower() == "true"

View File

@@ -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"
)

View File

@@ -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

View File

@@ -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)}

View File

@@ -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(

View File

@@ -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,

View File

@@ -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)

View File

@@ -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,

View File

@@ -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(

View File

@@ -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)

View File

@@ -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(

View File

@@ -41,6 +41,11 @@ urlpatterns = [
views.transaction_edit,
name="transaction_edit",
),
path(
"transaction/<int:transaction_id>/clone",
views.transaction_clone,
name="transaction_clone",
),
path(
"transaction/add",
views.transaction_add,

View File

@@ -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,

View File

@@ -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,

View File

@@ -1,4 +1,5 @@
import datetime
from copy import deepcopy
from django.contrib import messages
from django.contrib.auth.decorators import login_required
@@ -12,6 +13,7 @@ from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx
from apps.common.utils.dicts import remove_falsey_entries
from apps.rules.signals import transaction_created
from apps.transactions.filters import TransactionsFilter
from apps.transactions.forms import TransactionForm, TransferForm
from apps.transactions.models import Transaction
@@ -39,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"))
@@ -50,10 +52,11 @@ def transaction_add(request):
)
else:
form = TransactionForm(
user=request.user,
initial={
"date": expected_date,
"type": transaction_type,
}
},
)
return render(
@@ -70,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"))
@@ -80,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,
@@ -89,6 +92,55 @@ def transaction_edit(request, transaction_id, **kwargs):
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def transaction_clone(request, transaction_id, **kwargs):
transaction = get_object_or_404(Transaction, id=transaction_id)
new_transaction = deepcopy(transaction)
new_transaction.pk = None
new_transaction.installment_plan = None
new_transaction.installment_id = None
new_transaction.recurring_transaction = None
new_transaction.save()
new_transaction.tags.add(*transaction.tags.all())
new_transaction.entities.add(*transaction.entities.all())
messages.success(request, _("Transaction duplicated successfully"))
transaction_created.send(sender=transaction)
# THIS HAS BEEN DISABLE DUE TO HTMX INCOMPATIBILITY
# SEE https://github.com/bigskysoftware/htmx/issues/3115 and https://github.com/bigskysoftware/htmx/issues/2706
# if request.GET.get("edit") == "true":
# return HttpResponse(
# status=200,
# headers={
# "HX-Trigger": "updated",
# "HX-Push-Url": "false",
# "HX-Location": json.dumps(
# {
# "path": reverse(
# "transaction_edit",
# kwargs={"transaction_id": new_transaction.id},
# ),
# "target": "#generic-offcanvas",
# "swap": "innerHTML",
# }
# ),
# },
# )
# else:
# transaction_created.send(sender=transaction)
return HttpResponse(
status=204,
headers={"HX-Trigger": "updated"},
)
@only_htmx
@login_required
@csrf_exempt
@@ -121,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"))
@@ -134,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})
@@ -163,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})
@@ -185,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)
@@ -215,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)

View File

@@ -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(

View File

@@ -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),
),
]

View File

@@ -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,

View File

@@ -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 %}
<div hx-get="{% url 'calendar_transactions_list' day=date.day month=date.month year=date.year %}" hx-trigger="updated from:window" hx-vals='{"disable_selection": true}' hx-target="closest .offcanvas" class="show-loading">

View File

@@ -1,3 +1,4 @@
{% load date %}
{% load i18n %}
<div class="transaction d-flex my-1 {% if transaction.type == "EX" %}expense{% else %}income{% endif %}">
{% if not disable_selection %}
@@ -26,7 +27,7 @@
{# Date#}
<div class="row mb-2 mb-lg-1 tw-text-gray-400">
<div class="col-auto pe-1"><i class="fa-solid fa-calendar fa-fw me-1 fa-xs"></i></div>
<div class="col ps-0">{{ transaction.date|date:"SHORT_DATE_FORMAT" }} • {{ transaction.reference_date|date:"b/Y" }}</div>
<div class="col ps-0">{{ transaction.date|custom_date:request.user }} • {{ transaction.reference_date|date:"b/Y" }}</div>
</div>
{# Description#}
<div class="mb-2 mb-lg-1 text-white tw-text-base">
@@ -110,6 +111,14 @@
hx-get="{% url 'transaction_edit' transaction_id=transaction.id %}"
hx-target="#generic-offcanvas" hx-swap="innerHTML">
<i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="btn btn-secondary btn-sm transaction-action"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Duplicate" %}"
hx-get="{% url 'transaction_clone' transaction_id=transaction.id %}"
_="on click if event.ctrlKey set @hx-get to `{% url 'transaction_clone' transaction_id=transaction.id %}?edit=true` then call htmx.process(me) end then trigger ready"
hx-trigger="ready" >
<i class="fa-solid fa-clone fa-fw"></i></a>
<a class="btn btn-secondary btn-sm transaction-action"
role="button"
data-bs-toggle="tooltip"

View File

@@ -1,3 +1,4 @@
{% load date %}
{% load currency_display %}
{% load i18n %}
<div class="container-fluid px-md-3 py-3 column-gap-5">
@@ -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 }}
</c-amount.display>
{% else %}
<div class="tw-text-red-400">{% trans "No exchange rate available" %}</div>
@@ -83,7 +84,7 @@
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a>
</div>
</td>
<td>{{ entry.date|date:"SHORT_DATE_FORMAT" }}</td>
<td>{{ entry.date|custom_date:request.user }}</td>
<td>
<c-amount.display
:amount="entry.amount_received"
@@ -221,7 +222,7 @@
new Chart(perfomancectx, {
type: 'line',
data: {
labels: [{% for entry in entries_data %}'{{ entry.entry.date|date:"SHORT_DATE_FORMAT" }}'{% if not forloop.last %}, {% endif %}{% endfor %}],
labels: [{% for entry in entries_data %}'{{ entry.entry.date|custom_date:request.user }}'{% if not forloop.last %}, {% endif %}{% endfor %}],
datasets: [{
label: '{% trans "P/L %" %}',
data: [{% for entry in entries_data %}{{ entry.profit_loss_percentage|floatformat:"-40u" }}{% if not forloop.last %}, {% endif %}{% endfor %}],

View File

@@ -1,3 +1,4 @@
{% load date %}
{% load currency_display %}
{% load i18n %}
<div class="card-body show-loading" hx-get="{% url 'exchange_rates_list_pair' %}" hx-trigger="updated from:window" hx-swap="outerHTML" hx-vals='{"page": "{{ page_obj.number }}", "from": "{{ from_currency|default_if_none:"" }}", "to": "{{ to_currency|default_if_none:"" }}"}'>
@@ -39,7 +40,7 @@
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a>
</div>
</td>
<td class="col-3">{{ exchange_rate.date|date:"SHORT_DATETIME_FORMAT" }}</td>
<td class="col-3">{{ exchange_rate.date|custom_date:request.user }}</td>
<td class="col-3"><span class="badge rounded-pill text-bg-secondary">{{ exchange_rate.from_currency.code }}</span> x <span class="badge rounded-pill text-bg-secondary">{{ exchange_rate.to_currency.code }}</span></td>
<td class="col-3">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%}</td>
</tr>

View File

@@ -1,5 +1,5 @@
services:
web: &django
web:
image: eitchtee/wygiwyh:latest
container_name: ${SERVER_NAME}
command: /start
@@ -23,10 +23,11 @@ services:
- POSTGRES_DB=${SQL_DATABASE}
procrastinate:
<<: *django
image: eitchtee/wygiwyh:latest
container_name: ${PROCRASTINATE_NAME}
depends_on:
- db
ports: [ ]
env_file:
- .env
command: /start-procrastinate
restart: unless-stopped

View File

@@ -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'],