Compare commits

...

67 Commits
0.7.0 ... 0.7.6

Author SHA1 Message Date
Herculino Trotta
13795c797f Merge pull request #85
feat: add number format user setting and improve date format handling
2025-01-27 13:31:28 -03:00
Herculino Trotta
331a7d5b18 locale: update translations 2025-01-27 13:30:06 -03:00
Herculino Trotta
81b8da30d6 feat: add number_format to user_settings form 2025-01-27 13:26:08 -03:00
Herculino Trotta
80bad240e7 refactor: remove custom_date filter 2025-01-27 13:25:47 -03:00
Herculino Trotta
187c56c96c refactor: remove user attr from datepicker
since monkey patched get_format already does what we want
2025-01-27 13:25:06 -03:00
Herculino Trotta
3796112d77 feat: monkey patch get_format to return usersettings 2025-01-27 13:22:21 -03:00
Herculino Trotta
958940089a feat: add number_format user setting 2025-01-27 13:20:12 -03:00
Herculino Trotta
a08548bb13 feat: add local access to user and request from anywhere 2025-01-27 13:19:28 -03:00
Herculino Trotta
7fe446e510 refactor: remove custom_date filter 2025-01-27 13:18:57 -03:00
Herculino Trotta
eccb0d15ee Merge pull request #83 from eitchtee/eitchtee-patch-1
Update README.md
2025-01-26 21:03:45 -03:00
Herculino Trotta
7ebd329706 Update README.md 2025-01-26 21:03:14 -03:00
Herculino Trotta
d3fcd5fe7e Merge pull request #82
fix datepicker datetime handling and action-bar
2025-01-26 20:56:53 -03:00
Herculino Trotta
b0a3acbdde fix: transactions action bar error on page change 2025-01-26 20:56:03 -03:00
Herculino Trotta
33ce38d74c feat(datepicker): improve value handling 2025-01-26 20:54:29 -03:00
Herculino Trotta
fa51a7fef9 fix(datepicker): wrong datetime format 2025-01-26 20:53:16 -03:00
Herculino Trotta
d7c072a35c fix(currencies): don't error out if from_currency or to_currency isn't set 2025-01-26 20:52:47 -03:00
Herculino Trotta
c88a6dcf3a Update README.md 2025-01-26 11:49:28 -03:00
Herculino Trotta
fcb54a0af2 Merge pull request #79 from DragonHeart69/main
Add new Dutch translations for v0.7.2
2025-01-26 11:20:35 -03:00
Herculino Trotta
eec2ced481 refactor(settings): drop SQL_ENGINE env variable as only postgres is supported 2025-01-26 11:19:38 -03:00
Herculino Trotta
58a6048857 fix(settings): respect SQL_PORT env variable, defaulting to 5432 if not available 2025-01-26 11:17:38 -03:00
Herculino Trotta
93774cca64 docker: update python image from slim-buster to slim-bookworm 2025-01-26 11:16:39 -03:00
Dimitri Decrock
679f49badc Add new Dutch translations for v0.7.2 2025-01-26 13:37:06 +01:00
Herculino Trotta
b535a12014 feat: enable Dutch (Nederlands) language choice 2025-01-25 15:55:42 -03:00
Herculino Trotta
72876bff43 Merge pull request #76 from DragonHeart69/main
1st edition of the Dutch translation
2025-01-25 15:36:38 -03:00
Dimitri Decrock
4411022027 delete merge 2025-01-25 19:36:51 +01:00
Dimitri Decrock
086210b39d Merge branch 'eitchtee-main' 2025-01-25 19:29:07 +01:00
Dimitri Decrock
73cb2d861b update 2025-01-25 19:26:37 +01:00
Dimitri Decrock
1c479ef85a Merge branch 'main' of https://github.com/eitchtee/WYGIWYH into eitchtee-main 2025-01-25 19:25:56 +01:00
Dimitri Decrock
51b2b11825 final translation Dutch 1st publication 2025-01-25 18:44:53 +01:00
Herculino Trotta
c9d1b5b5f3 Merge pull request #75
locale: update locales
2025-01-25 13:55:09 -03:00
Herculino Trotta
a22a95cb9f locale: update locales 2025-01-25 13:54:10 -03:00
Herculino Trotta
5c46a2c15e feat: pluralize toast for bulk edit 2025-01-25 13:48:32 -03:00
Herculino Trotta
4f091c601e Merge pull request #73
feat: add bulk duplicate action and toasts for existing actions
2025-01-25 13:44:55 -03:00
Herculino Trotta
0fac78d15a feat: add bulk duplicate action and toasts for existing actions 2025-01-25 13:44:39 -03:00
Herculino Trotta
aa171c0e76 Merge pull request #72
fix: clear internal_id when duplicating
2025-01-25 13:42:54 -03:00
Herculino Trotta
73ca418dc8 fix: clear internal_id when duplicating 2025-01-25 13:42:23 -03:00
Herculino Trotta
7c34f36ffb Merge pull request #71 from eitchtee/dev
feat: tidy up transactions action bar
2025-01-25 12:44:48 -03:00
Herculino Trotta
2b6be8c6ac feat: tidy up transactions action bar 2025-01-25 12:43:53 -03:00
Herculino Trotta
f643c41cf1 Merge pull request #70
feat: bulk edit selected transactions
2025-01-25 12:42:36 -03:00
Herculino Trotta
1ef7a780fb feat: bulk edit selected transactions 2025-01-25 12:41:55 -03:00
Herculino Trotta
c3a753d221 Merge pull request #69 from eitchtee/dev
feat: add new animation to transactions action bar
2025-01-25 12:39:51 -03:00
Herculino Trotta
c474b6cda9 feat: add new animation to transactions action bar 2025-01-25 12:37:30 -03:00
Herculino Trotta
aff3aa7ed2 feat: add new animation to transactions action bar 2025-01-25 12:37:24 -03:00
Dimitri Decrock
414a9bb88a 4d part Dutch translation 2025-01-25 14:23:23 +01:00
Herculino Trotta
5f202a3820 Merge pull request #68
feat(transactions): proper clear button for filters
2025-01-25 01:30:43 -03:00
Herculino Trotta
e71775292a feat(transactions): proper clear button for filters 2025-01-25 01:30:24 -03:00
Herculino Trotta
01aa8acb71 Merge pull request #67 from eitchtee/dev
refactor: add end slashes for some urls without
2025-01-24 22:56:20 -03:00
Herculino Trotta
d030f9686b refactor: add end slashes for some urls without 2025-01-24 22:55:36 -03:00
Herculino Trotta
56d7e41bc5 Merge pull request #66
feat: add new /add/ endpoint for quickly adding new transactions
2025-01-24 22:52:17 -03:00
Herculino Trotta
0857b44fc3 feat: add new /add/ endpoint for quickly adding new transactions 2025-01-24 22:50:39 -03:00
Herculino Trotta
d4b5afd8b2 Merge pull request #65
fix(transactions): unaligned type button
2025-01-24 22:49:42 -03:00
Herculino Trotta
9c4ba3a6de fix(transactions): unaligned type button 2025-01-24 22:48:24 -03:00
Herculino Trotta
ec8b0e21d8 Merge pull request #63
feat(transactions): new is_paid switch
2025-01-24 22:47:20 -03:00
Herculino Trotta
6c60c3659c feat(transactions): new is_paid switch 2025-01-24 22:47:00 -03:00
Herculino Trotta
a040b8acd2 Merge pull request #62
fix(transactions:filter): unaligned filter buttons
2025-01-24 22:42:20 -03:00
Herculino Trotta
e72d6cd1ea fix(transactions:filter): unaligned filter buttons 2025-01-24 22:42:01 -03:00
Herculino Trotta
3fb670ef00 Merge pull request #61 from eitchtee/dev
locale: update translations
2025-01-24 16:31:30 -03:00
Herculino Trotta
b9cd97f0b8 locale: update translations and remove dutch from available languages until translation is done 2025-01-24 16:30:31 -03:00
Herculino Trotta
011e0ad7c9 Merge pull request #60 from eitchtee/dev
fix: import preset not working behind nginx due to long url/csrf missing
2025-01-24 16:08:32 -03:00
Herculino Trotta
97465c07fe fix: import preset not working behind nginx due to long url/csrf missing 2025-01-24 16:06:47 -03:00
Dimitri Decrock
f6d1a42b35 Merge branch 'eitchtee:main' into main 2025-01-24 19:22:03 +01:00
Dimitri Decrock
eb25f8aeb3 3d part Dutch translation 2025-01-24 19:22:01 +01:00
Herculino Trotta
36cbe2935a Merge pull request #59
feat(pwa): better offline page and offline
2025-01-24 14:25:57 -03:00
Herculino Trotta
dbea78cd3c feat(pwa): better offline page and offline request handler 2025-01-24 14:22:30 -03:00
Herculino Trotta
d50c84f8e6 refactor: remove debug prints 2025-01-24 00:36:33 -03:00
Dimitri Decrock
2ee64a534e 2nd part Dutch translation 2025-01-23 07:13:15 +01:00
Dimitri Decrock
14073d3555 Start with Dutch translation 2025-01-22 19:36:13 +01:00
69 changed files with 2497 additions and 1262 deletions

View File

@@ -9,7 +9,6 @@ SECRET_KEY=<GENERATE A SAFE SECRET KEY AND PLACE IT HERE>
DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1]
OUTBOUND_PORT=9005
SQL_ENGINE=django.db.backends.postgresql
SQL_DATABASE=wygiwyh
SQL_USER=wygiwyh
SQL_PASSWORD=<INSERT A SAFE PASSWORD HERE>

View File

@@ -95,31 +95,16 @@ You can now access localhost:OUTBOUND_PORT
> [!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.
Features are only added to `main` when ready, if you want to run the latest version, you must build from source.
All the required Dockerfiles are [here](https://github.com/eitchtee/WYGIWYH/tree/main/docker/prod).
```bash
# Create a folder for WYGIWYH (optional)
$ mkdir WYGIWYH
## Unraid
# Go into the folder
$ cd WYGIWYH
[nwithan8](https://github.com/nwithan8) has kindly provided a Unraid template for WYGIWYH, have a look at the [unraid_templates](https://github.com/nwithan8/unraid_templates) repo.
# 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
```
WYGIWYH and WYGIWYH--Procrastinate should be available on the Unraid Store. You need both for all features.
# How it works

View File

@@ -77,6 +77,7 @@ INSTALLED_APPS = [
]
MIDDLEWARE = [
"apps.common.middleware.thread_local.ThreadLocalMiddleware",
"debug_toolbar.middleware.DebugToolbarMiddleware",
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
@@ -126,12 +127,12 @@ WSGI_APPLICATION = "WYGIWYH.wsgi.application"
DATABASES = {
"default": {
"ENGINE": os.environ.get("SQL_ENGINE", "django.db.backends.sqlite3"),
"NAME": os.environ.get("SQL_DATABASE", BASE_DIR / "db.sqlite3"),
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ.get("SQL_DATABASE"),
"USER": os.environ.get("SQL_USER", "user"),
"PASSWORD": os.environ.get("SQL_PASSWORD", "password"),
"HOST": os.environ.get("SQL_HOST", "localhost"),
"PORT": "5432",
"PORT": os.environ.get("SQL_PORT", "5432"),
}
}
@@ -363,7 +364,13 @@ PWA_APP_SPLASH_SCREEN = [
]
PWA_APP_DIR = "ltr"
PWA_APP_LANG = "en-US"
PWA_APP_SHORTCUTS = []
PWA_APP_SHORTCUTS = [
{
"name": "New Transaction",
"url": "/add/",
"description": "Add new transaction",
}
]
PWA_APP_SCREENSHOTS = [
{
"src": "/static/img/pwa/splash-750x1334.png",
@@ -377,6 +384,7 @@ PWA_APP_SCREENSHOTS = [
"type": "image/png",
},
]
PWA_SERVICE_WORKER_PATH = BASE_DIR / "templates" / "pwa" / "serviceworker.js"
ENABLE_SOFT_DELETE = os.getenv("ENABLE_SOFT_DELETE", "false").lower() == "true"
KEEP_DELETED_TRANSACTIONS_FOR = int(os.getenv("KEEP_DELETED_ENTRIES_FOR", "365"))

View File

@@ -2,9 +2,7 @@ from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from apps.accounts.forms import AccountGroupForm
@@ -89,7 +87,6 @@ def account_group_edit(request, pk):
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def account_group_delete(request, pk):
account_group = get_object_or_404(AccountGroup, id=pk)

View File

@@ -2,9 +2,7 @@ from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from apps.accounts.forms import AccountForm
@@ -89,7 +87,6 @@ def account_edit(request, pk):
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def account_delete(request, pk):
account = get_object_or_404(Account, id=pk)

View File

@@ -0,0 +1,31 @@
from apps.common.middleware.thread_local import get_current_user
from django.utils.formats import get_format as original_get_format
def get_format(format_type=None, lang=None, use_l10n=None):
user = get_current_user()
if user and user.is_authenticated and hasattr(user, "settings"):
user_settings = user.settings
if format_type == "THOUSAND_SEPARATOR":
number_format = getattr(user_settings, "number_format", None)
if number_format == "DC":
return "."
elif number_format == "CD":
return ","
elif format_type == "DECIMAL_SEPARATOR":
number_format = getattr(user_settings, "number_format", None)
if number_format == "DC":
return ","
elif number_format == "CD":
return "."
elif format_type == "SHORT_DATE_FORMAT":
date_format = getattr(user_settings, "date_format", None)
if date_format and date_format != "SHORT_DATE_FORMAT":
return date_format
elif format_type == "SHORT_DATETIME_FORMAT":
datetime_format = getattr(user_settings, "datetime_format", None)
if datetime_format and datetime_format != "SHORT_DATETIME_FORMAT":
return datetime_format
return original_get_format(format_type, lang, use_l10n)

View File

@@ -1,14 +1,17 @@
import zoneinfo
from django.utils import formats
from django.utils import timezone, translation
from django.utils.translation import activate
from django.utils.functional import lazy
from apps.common.functions.format import get_format as custom_get_format
from apps.users.models import UserSettings
class LocalizationMiddleware:
def __init__(self, get_response):
self.get_response = get_response
self.patch_get_format()
def __call__(self, request):
tz = request.COOKIES.get("mytz")
@@ -33,9 +36,14 @@ class LocalizationMiddleware:
timezone.activate(zoneinfo.ZoneInfo("UTC"))
if user_language and user_language != "auto":
activate(user_language)
translation.activate(user_language)
else:
detected_language = translation.get_language_from_request(request)
activate(detected_language)
translation.activate(detected_language)
return self.get_response(request)
@staticmethod
def patch_get_format():
formats.get_format = custom_get_format
formats.get_format_lazy = lazy(custom_get_format, str, list, tuple)

View File

@@ -0,0 +1,73 @@
"""
threadlocals middleware
~~~~~~~~~~~~~~~~~~~~~~~
make the request object everywhere available (e.g. in model instance).
based on: http://code.djangoproject.com/wiki/CookBookThreadlocalsAndUser
Put this into your settings:
--------------------------------------------------------------------------
MIDDLEWARE_CLASSES = (
...
'django_tools.middlewares.ThreadLocal.ThreadLocalMiddleware',
...
)
--------------------------------------------------------------------------
Usage:
--------------------------------------------------------------------------
from django_tools.middlewares import ThreadLocal
# Get the current request object:
request = ThreadLocal.get_current_request()
# You can get the current user directly with:
user = ThreadLocal.get_current_user()
--------------------------------------------------------------------------
:copyleft: 2009-2017 by the django-tools team, see AUTHORS for more details.
:license: GNU GPL v3 or above, see LICENSE for more details.
"""
try:
from threading import local
except ImportError:
from django.utils._threading_local import local
try:
from django.utils.deprecation import MiddlewareMixin
except ImportError:
MiddlewareMixin = object # fallback for Django < 1.10
_thread_locals = local()
def get_current_request():
"""returns the request object for this thread"""
return getattr(_thread_locals, "request", None)
def get_current_user():
"""returns the current user, if exist, otherwise returns None"""
request = get_current_request()
if request:
return getattr(request, "user", None)
class ThreadLocalMiddleware(MiddlewareMixin):
"""Simple middleware that adds the request object in thread local storage."""
def process_request(self, request):
_thread_locals.request = request
def process_response(self, request, response):
if hasattr(_thread_locals, "request"):
del _thread_locals.request
return response
def process_exception(self, request, exception):
if hasattr(_thread_locals, "request"):
del _thread_locals.request

View File

@@ -1,32 +0,0 @@
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

@@ -1,6 +1,6 @@
from django import template
from django.utils.formats import get_format
from apps.common.functions.format import get_format
register = template.Library()

View File

@@ -35,7 +35,7 @@ def django_to_python_datetime(django_format):
def django_to_airdatepicker_datetime(django_format):
format_map = {
# Time
"h": "h", # Hour (12-hour)
"h": "hh", # Hour (12-hour)
"H": "H", # Hour (24-hour)
"i": "m", # Minutes
"A": "AA", # AM/PM uppercase
@@ -76,7 +76,7 @@ def django_to_airdatepicker_datetime(django_format):
def django_to_airdatepicker_datetime_separated(django_format):
format_map = {
# Time formats
"h": "hH", # Hour (12-hour)
"h": "hh", # Hour (12-hour)
"H": "HH", # Hour (24-hour)
"i": "mm", # Minutes
"A": "AA", # AM/PM uppercase

View File

@@ -2,7 +2,6 @@ import datetime
from django.forms import widgets
from django.utils import formats, translation, dates
from django.utils.formats import get_format
from django.utils.translation import gettext_lazy as _
from apps.common.utils.django import (
@@ -10,6 +9,7 @@ from apps.common.utils.django import (
django_to_airdatepicker_datetime,
django_to_airdatepicker_datetime_separated,
)
from apps.common.functions.format import get_format
class AirDatePickerInput(widgets.DateInput):
@@ -19,12 +19,10 @@ 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
@@ -41,12 +39,6 @@ class AirDatePickerInput(widgets.DateInput):
if self.format:
return self.format
if self.user and hasattr(self.user, "settings"):
user_format = self.user.settings.date_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):
@@ -97,12 +89,10 @@ class AirDateTimePickerInput(widgets.DateTimeInput):
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
@@ -120,12 +110,6 @@ class AirDateTimePickerInput(widgets.DateTimeInput):
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):
@@ -148,9 +132,14 @@ class AirDateTimePickerInput(widgets.DateTimeInput):
def format_value(self, value):
"""Format the value for display in the widget."""
if value:
if value and isinstance(value, (datetime.date, datetime.datetime)):
self.attrs["data-value"] = datetime.datetime.strftime(
value, "%Y-%m-%d %H:%M:00"
value, "%Y-%m-%dT%H:%M:00"
)
elif value and isinstance(value, str):
value = datetime.datetime.strptime(value, "%Y-%m-%d %H:%M:00")
self.attrs["data-value"] = datetime.datetime.strftime(
value, "%Y-%m-%dT%H:%M:00"
)
if value is None:
@@ -195,6 +184,7 @@ class AirMonthYearPickerInput(AirDatePickerInput):
# Add data attributes for AirDatepicker configuration
attrs["data-now-button-txt"] = _("Today")
attrs["data-date-format"] = "MMMM yyyy"
return attrs

View File

@@ -1,7 +1,9 @@
from decimal import Decimal, InvalidOperation
from django import forms
from django.utils.formats import get_format, number_format
from django.utils.formats import number_format
from apps.common.functions.format import get_format
def convert_to_decimal(value: str):

View File

@@ -72,7 +72,7 @@ class ExchangeRateForm(forms.ModelForm):
model = ExchangeRate
fields = ["from_currency", "to_currency", "rate", "date"]
def __init__(self, *args, user=None, **kwargs):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
@@ -81,9 +81,7 @@ 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
)
self.fields["date"].widget = AirDateTimePickerInput(clear_button=False)
if self.instance and self.instance.pk:
self.helper.layout.append(

View File

@@ -72,7 +72,9 @@ class ExchangeRate(models.Model):
def clean(self):
super().clean()
if self.from_currency == self.to_currency:
raise ValidationError(
{"to_currency": _("From and To currencies cannot be the same.")}
)
# Check if the attributes exist before comparing them
if hasattr(self, "from_currency") and hasattr(self, "to_currency"):
if self.from_currency == self.to_currency:
raise ValidationError(
{"to_currency": _("From and To currencies cannot be the same.")}
)

View File

@@ -2,9 +2,7 @@ from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx
@@ -89,7 +87,6 @@ def currency_edit(request, pk):
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def currency_delete(request, pk):
currency = get_object_or_404(Currency, id=pk)

View File

@@ -1,12 +1,11 @@
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import F, CharField, Value
from django.db.models import CharField, Value
from django.db.models.functions import Concat
from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx
@@ -84,7 +83,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, user=request.user)
form = ExchangeRateForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, _("Exchange rate added successfully"))
@@ -96,7 +95,7 @@ def exchange_rate_add(request):
},
)
else:
form = ExchangeRateForm(user=request.user)
form = ExchangeRateForm()
return render(
request,
@@ -112,7 +111,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, user=request.user)
form = ExchangeRateForm(request.POST, instance=exchange_rate)
if form.is_valid():
form.save()
messages.success(request, _("Exchange rate updated successfully"))
@@ -124,7 +123,7 @@ def exchange_rate_edit(request, pk):
},
)
else:
form = ExchangeRateForm(instance=exchange_rate, user=request.user)
form = ExchangeRateForm(instance=exchange_rate)
return render(
request,
@@ -135,7 +134,6 @@ def exchange_rate_edit(request, pk):
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def exchange_rate_delete(request, pk):
exchange_rate = get_object_or_404(ExchangeRate, id=pk)

View File

@@ -65,7 +65,7 @@ class DCAEntryForm(forms.ModelForm):
"notes": forms.Textarea(attrs={"rows": 3}),
}
def __init__(self, *args, user=None, **kwargs):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
@@ -106,4 +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)
self.fields["date"].widget = AirDatePickerInput(clear_button=False)

View File

@@ -6,12 +6,11 @@ from django.db.models.functions import TruncMonth
from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx
from apps.dca.models import DCAStrategy, DCAEntry
from apps.dca.forms import DCAEntryForm, DCAStrategyForm
from apps.dca.models import DCAStrategy, DCAEntry
@login_required
@@ -82,7 +81,6 @@ def strategy_edit(request, strategy_id):
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def strategy_delete(request, strategy_id):
dca_strategy = get_object_or_404(DCAStrategy, id=strategy_id)
@@ -157,7 +155,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, user=request.user)
form = DCAEntryForm(request.POST)
if form.is_valid():
entry = form.save(commit=False)
entry.strategy = strategy
@@ -171,7 +169,7 @@ def strategy_entry_add(request, strategy_id):
},
)
else:
form = DCAEntryForm(user=request.user)
form = DCAEntryForm()
return render(
request,
@@ -186,7 +184,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, user=request.user)
form = DCAEntryForm(request.POST, instance=dca_entry)
if form.is_valid():
form.save()
messages.success(request, _("Entry updated successfully"))
@@ -198,7 +196,7 @@ def strategy_entry_edit(request, strategy_id, entry_id):
},
)
else:
form = DCAEntryForm(instance=dca_entry, user=request.user)
form = DCAEntryForm(instance=dca_entry)
return render(
request,
@@ -209,7 +207,6 @@ def strategy_entry_edit(request, strategy_id, entry_id):
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def strategy_entry_delete(request, entry_id, strategy_id):
dca_entry = get_object_or_404(DCAEntry, id=entry_id, strategy__id=strategy_id)

View File

@@ -9,7 +9,7 @@ from apps.import_app.schemas import version_1
class ImportProfile(models.Model):
class Versions(models.IntegerChoices):
VERSION_1 = 1, _("Version") + " 1"
VERSION_1 = 1, "Version 1"
name = models.CharField(max_length=100, verbose_name=_("Name"), unique=True)
yaml_config = models.TextField(verbose_name=_("YAML Configuration"))
@@ -25,6 +25,10 @@ class ImportProfile(models.Model):
class Meta:
ordering = ["name"]
def get_version_display(self):
version_number = self.Versions(self.version).name.split("_")[1]
return _("Version {number}").format(number=version_number)
def clean(self):
if self.version and self.version == self.Versions.VERSION_1:
try:

View File

@@ -38,7 +38,7 @@ class PresetService:
preset["schema_version"]
) # Check if schema version is valid
except Exception as e:
print(e)
pass
else:
presets.append(preset)

View File

@@ -201,7 +201,6 @@ class ImportService:
),
None,
)
print(category_mapping)
try:
if category_mapping:

View File

@@ -5,15 +5,14 @@ from django.contrib.auth.decorators import login_required
from django.core.files.storage import FileSystemStorage
from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx
from apps.import_app.forms import ImportRunFileUploadForm, ImportProfileForm
from apps.import_app.models import ImportRun, ImportProfile
from apps.import_app.tasks import process_import
from apps.import_app.services import PresetService
from apps.import_app.tasks import process_import
def import_view(request):
@@ -33,7 +32,6 @@ def import_view(request):
@require_http_methods(["GET"])
def import_presets_list(request):
presets = PresetService.get_all_presets()
print(presets)
return render(
request,
"import_app/fragments/profiles/list_presets.html",
@@ -67,9 +65,9 @@ def import_profile_list(request):
@login_required
@require_http_methods(["GET", "POST"])
def import_profile_add(request):
message = request.GET.get("message", None) or request.POST.get("message", None)
message = request.POST.get("message", None)
if request.method == "POST":
if request.method == "POST" and request.POST.get("submit"):
form = ImportProfileForm(request.POST)
if form.is_valid():
@@ -83,12 +81,11 @@ def import_profile_add(request):
},
)
else:
print(int(request.GET.get("version", 1)))
form = ImportProfileForm(
initial={
"name": request.GET.get("name"),
"version": int(request.GET.get("version", 1)),
"yaml_config": request.GET.get("yaml_config"),
"name": request.POST.get("name"),
"version": int(request.POST.get("version", 1)),
"yaml_config": request.POST.get("yaml_config"),
}
)
@@ -130,7 +127,6 @@ def import_profile_edit(request, profile_id):
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def import_profile_delete(request, profile_id):
profile = ImportProfile.objects.get(id=profile_id)
@@ -215,7 +211,6 @@ def import_run_add(request, profile_id):
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def import_run_delete(request, profile_id, run_id):
run = ImportRun.objects.get(profile__id=profile_id, id=run_id)

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, user=request.user)
f = TransactionsFilter(request.GET)
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, user=request.user)
f = TransactionsFilter(request.GET)
transactions_filtered = (
f.qs.filter()
.filter(

View File

@@ -52,19 +52,4 @@ urlpatterns = [
views.transaction_rule_action_delete,
name="transaction_rule_action_delete",
),
# path(
# "rules/<int:installment_plan_id>/transactions/",
# views.installment_plan_transactions,
# name="rule_view",
# ),
# path(
# "rules/<int:installment_plan_id>/edit/",
# views.installment_plan_edit,
# name="rule_edit",
# ),
# path(
# "rules/<int:installment_plan_id>/delete/",
# views.installment_plan_delete,
# name="rule_delete",
# ),
]

View File

@@ -3,7 +3,6 @@ from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404, redirect
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx
@@ -118,7 +117,6 @@ def transaction_rule_view(request, transaction_rule_id):
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def transaction_rule_delete(request, transaction_rule_id):
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
@@ -201,7 +199,6 @@ def transaction_rule_action_edit(request, transaction_rule_action_id):
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def transaction_rule_action_delete(request, transaction_rule_action_id):
transaction_rule_action = get_object_or_404(

View File

@@ -133,7 +133,7 @@ class TransactionsFilter(django_filters.FilterSet):
"to_amount",
]
def __init__(self, data=None, user=None, *args, **kwargs):
def __init__(self, data=None, *args, **kwargs):
# if filterset is bound, use initial values as defaults
if data is not None:
# get a mutable copy of the QueryDict
@@ -182,5 +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)
self.form.fields["date_start"].widget = AirDatePickerInput()
self.form.fields["date_end"].widget = AirDatePickerInput()

View File

@@ -1,5 +1,5 @@
from crispy_bootstrap5.bootstrap5 import Switch
from crispy_forms.bootstrap import FormActions
from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion
from crispy_forms.bootstrap import FormActions, AccordionGroup
from crispy_forms.helper import FormHelper
from crispy_forms.layout import (
Layout,
@@ -86,7 +86,7 @@ class TransactionForm(forms.ModelForm):
"account": TomSelect(clear_button=False, group_by="group"),
}
def __init__(self, *args, user=None, **kwargs):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# if editing a transaction display non-archived items and it's own item even if it's archived
@@ -115,7 +115,7 @@ class TransactionForm(forms.ModelForm):
"type",
template="transactions/widgets/income_expense_toggle_buttons.html",
),
Switch("is_paid"),
Field("is_paid", template="transactions/widgets/paid_toggle_button.html"),
Row(
Column("account", css_class="form-group col-md-6 mb-0"),
Column("entities", css_class="form-group col-md-6 mb-0"),
@@ -136,8 +136,48 @@ class TransactionForm(forms.ModelForm):
"notes",
)
self.helper_simple = FormHelper()
self.helper_simple.form_tag = False
self.helper_simple.form_method = "post"
self.helper_simple.layout = Layout(
Field(
"type",
template="transactions/widgets/income_expense_toggle_buttons.html",
),
Field("is_paid", template="transactions/widgets/paid_toggle_button.html"),
"account",
Row(
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",
Field("amount", inputmode="decimal"),
BS5Accordion(
AccordionGroup(
_("More"),
"entities",
Row(
Column("category", css_class="form-group col-md-6 mb-0"),
Column("tags", css_class="form-group col-md-6 mb-0"),
css_class="form-row",
),
"notes",
active=False,
),
flush=False,
always_open=False,
css_class="mb-3",
),
FormActions(
NoClassSubmit(
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
),
),
)
self.fields["reference_date"].required = False
self.fields["date"].widget = AirDatePickerInput(clear_button=False, user=user)
self.fields["date"].widget = AirDatePickerInput(clear_button=False)
if self.instance and self.instance.pk:
decimal_places = self.instance.account.currency.decimal_places
@@ -183,6 +223,43 @@ class TransactionForm(forms.ModelForm):
return instance
class BulkEditTransactionForm(TransactionForm):
is_paid = forms.NullBooleanField(required=False)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Make all fields optional
for field_name, field in self.fields.items():
field.required = False
del self.helper.layout[-1] # Remove button
del self.helper.layout[0:2] # Remove type, is_paid field
self.helper.layout.insert(
0,
Field(
"type",
template="transactions/widgets/unselectable_income_expense_toggle_buttons.html",
),
)
self.helper.layout.insert(
1,
Field(
"is_paid",
template="transactions/widgets/unselectable_paid_toggle_button.html",
),
)
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
),
),
)
class TransferForm(forms.Form):
from_account = forms.ModelChoiceField(
queryset=Account.objects.filter(is_archived=False),
@@ -256,7 +333,7 @@ class TransferForm(forms.Form):
label=_("Notes"),
)
def __init__(self, *args, user=None, **kwargs):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
@@ -325,7 +402,7 @@ 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)
self.fields["date"].widget = AirDatePickerInput(clear_button=False)
def clean(self):
cleaned_data = super().clean()
@@ -438,7 +515,7 @@ class InstallmentPlanForm(forms.ModelForm):
"notes": forms.Textarea(attrs={"rows": 3}),
}
def __init__(self, *args, user=None, **kwargs):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# if editing display non-archived items and it's own item even if it's archived
@@ -495,9 +572,7 @@ class InstallmentPlanForm(forms.ModelForm):
)
self.fields["installment_amount"].widget = ArbitraryDecimalDisplayNumberInput()
self.fields["start_date"].widget = AirDatePickerInput(
clear_button=False, user=user
)
self.fields["start_date"].widget = AirDatePickerInput(clear_button=False)
if self.instance and self.instance.pk:
self.helper.layout.append(
@@ -685,7 +760,7 @@ class RecurringTransactionForm(forms.ModelForm):
),
}
def __init__(self, *args, user=None, **kwargs):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# if editing display non-archived items and it's own item even if it's archived
@@ -742,10 +817,8 @@ 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)
self.fields["start_date"].widget = AirDatePickerInput(clear_button=False)
self.fields["end_date"].widget = AirDatePickerInput()
if self.instance and self.instance.pk:
self.helper.layout.append(

View File

@@ -12,7 +12,7 @@ urlpatterns = [
name="transactions_all_summary",
),
path(
"transactions/actions/pay",
"transactions/actions/pay/",
views.bulk_pay_transactions,
name="transactions_bulk_pay",
),
@@ -27,32 +27,47 @@ urlpatterns = [
name="transactions_bulk_delete",
),
path(
"transaction/<int:transaction_id>/pay",
"transactions/actions/duplicate/",
views.bulk_clone_transactions,
name="transactions_bulk_clone",
),
path(
"transaction/<int:transaction_id>/pay/",
views.transaction_pay,
name="transaction_pay",
),
path(
"transaction/<int:transaction_id>/delete",
"transaction/<int:transaction_id>/delete/",
views.transaction_delete,
name="transaction_delete",
),
path(
"transaction/<int:transaction_id>/edit",
"transaction/<int:transaction_id>/edit/",
views.transaction_edit,
name="transaction_edit",
),
path(
"transaction/<int:transaction_id>/clone",
"transactions/bulk-edit/",
views.transactions_bulk_edit,
name="transactions_bulk_edit",
),
path(
"transaction/<int:transaction_id>/clone/",
views.transaction_clone,
name="transaction_clone",
),
path(
"transaction/add",
"transaction/add/",
views.transaction_add,
name="transaction_add",
),
path(
"transactions/transfer",
"add/",
views.transaction_simple_add,
name="transaction_simple_add",
),
path(
"transactions/transfer/",
views.transactions_transfer,
name="transactions_transfer",
),

View File

@@ -1,5 +1,9 @@
from copy import deepcopy
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.utils.translation import gettext_lazy as _, ngettext_lazy
from apps.common.decorators.htmx import only_htmx
from apps.transactions.models import Transaction
@@ -9,7 +13,19 @@ from apps.transactions.models import Transaction
@login_required
def bulk_pay_transactions(request):
selected_transactions = request.GET.getlist("transactions", [])
Transaction.objects.filter(id__in=selected_transactions).update(is_paid=True)
transactions = Transaction.objects.filter(id__in=selected_transactions)
count = transactions.count()
transactions.update(is_paid=True)
messages.success(
request,
ngettext_lazy(
"%(count)s transaction marked as paid",
"%(count)s transactions marked as paid",
count,
)
% {"count": count},
)
return HttpResponse(
status=204,
@@ -21,7 +37,19 @@ def bulk_pay_transactions(request):
@login_required
def bulk_unpay_transactions(request):
selected_transactions = request.GET.getlist("transactions", [])
Transaction.objects.filter(id__in=selected_transactions).update(is_paid=False)
transactions = Transaction.objects.filter(id__in=selected_transactions)
count = transactions.count()
transactions.update(is_paid=False)
messages.success(
request,
ngettext_lazy(
"%(count)s transaction marked as not paid",
"%(count)s transactions marked as not paid",
count,
)
% {"count": count},
)
return HttpResponse(
status=204,
@@ -33,7 +61,54 @@ def bulk_unpay_transactions(request):
@login_required
def bulk_delete_transactions(request):
selected_transactions = request.GET.getlist("transactions", [])
Transaction.objects.filter(id__in=selected_transactions).delete()
transactions = Transaction.objects.filter(id__in=selected_transactions)
count = transactions.count()
transactions.delete()
messages.success(
request,
ngettext_lazy(
"%(count)s transaction deleted successfully",
"%(count)s transactions deleted successfully",
count,
)
% {"count": count},
)
return HttpResponse(
status=204,
headers={"HX-Trigger": "updated"},
)
@only_htmx
@login_required
def bulk_clone_transactions(request):
selected_transactions = request.GET.getlist("transactions", [])
transactions = Transaction.objects.filter(id__in=selected_transactions)
count = transactions.count()
for transaction in transactions:
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.internal_id = None
new_transaction.save()
new_transaction.tags.add(*transaction.tags.all())
new_transaction.entities.add(*transaction.entities.all())
messages.success(
request,
ngettext_lazy(
"%(count)s transaction duplicated successfully",
"%(count)s transactions duplicated successfully",
count,
)
% {"count": count},
)
return HttpResponse(
status=204,

View File

@@ -2,9 +2,7 @@ from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx
@@ -111,7 +109,6 @@ def category_edit(request, category_id):
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def category_delete(request, category_id):
category = get_object_or_404(TransactionCategory, id=category_id)

View File

@@ -3,7 +3,6 @@ from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx
@@ -110,7 +109,6 @@ def entity_edit(request, entity_id):
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def entity_delete(request, entity_id):
entity = get_object_or_404(TransactionEntity, id=entity_id)

View File

@@ -4,7 +4,6 @@ from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx
@@ -82,7 +81,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, user=request.user)
form = InstallmentPlanForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, _("Installment Plan added successfully"))
@@ -94,7 +93,7 @@ def installment_plan_add(request):
},
)
else:
form = InstallmentPlanForm(user=request.user)
form = InstallmentPlanForm()
return render(
request,
@@ -110,9 +109,7 @@ 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, user=request.user
)
form = InstallmentPlanForm(request.POST, instance=installment_plan)
if form.is_valid():
form.save()
messages.success(request, _("Installment Plan updated successfully"))
@@ -124,7 +121,7 @@ def installment_plan_edit(request, installment_plan_id):
},
)
else:
form = InstallmentPlanForm(instance=installment_plan, user=request.user)
form = InstallmentPlanForm(instance=installment_plan)
return render(
request,
@@ -152,7 +149,6 @@ def installment_plan_refresh(request, installment_plan_id):
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def installment_plan_delete(request, installment_plan_id):
installment_plan = get_object_or_404(InstallmentPlan, id=installment_plan_id)

View File

@@ -1,5 +1,4 @@
from dateutil.relativedelta import relativedelta
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.db.models import Q
@@ -7,7 +6,6 @@ from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx
@@ -108,7 +106,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, user=request.user)
form = RecurringTransactionForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, _("Recurring Transaction added successfully"))
@@ -120,7 +118,7 @@ def recurring_transaction_add(request):
},
)
else:
form = RecurringTransactionForm(user=request.user)
form = RecurringTransactionForm()
return render(
request,
@@ -138,9 +136,7 @@ def recurring_transaction_edit(request, recurring_transaction_id):
)
if request.method == "POST":
form = RecurringTransactionForm(
request.POST, instance=recurring_transaction, user=request.user
)
form = RecurringTransactionForm(request.POST, instance=recurring_transaction)
if form.is_valid():
form.save()
messages.success(request, _("Recurring Transaction updated successfully"))
@@ -152,9 +148,7 @@ def recurring_transaction_edit(request, recurring_transaction_id):
},
)
else:
form = RecurringTransactionForm(
instance=recurring_transaction, user=request.user
)
form = RecurringTransactionForm(instance=recurring_transaction)
return render(
request,
@@ -230,7 +224,6 @@ def recurring_transaction_finish(request, recurring_transaction_id):
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def recurring_transaction_delete(request, recurring_transaction_id):
recurring_transaction = get_object_or_404(

View File

@@ -3,7 +3,6 @@ from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx
@@ -110,7 +109,6 @@ def tag_edit(request, tag_id):
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def tag_delete(request, tag_id):
tag = get_object_or_404(TransactionTag, id=tag_id)

View File

@@ -7,15 +7,18 @@ from django.core.paginator import Paginator
from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.utils.translation import gettext_lazy as _, ngettext_lazy
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.rules.signals import transaction_created, transaction_updated
from apps.transactions.filters import TransactionsFilter
from apps.transactions.forms import TransactionForm, TransferForm
from apps.transactions.forms import (
TransactionForm,
TransferForm,
BulkEditTransactionForm,
)
from apps.transactions.models import Transaction
from apps.transactions.utils.calculations import (
calculate_currency_totals,
@@ -41,7 +44,7 @@ def transaction_add(request):
).date()
if request.method == "POST":
form = TransactionForm(request.POST, user=request.user)
form = TransactionForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, _("Transaction added successfully"))
@@ -52,7 +55,6 @@ def transaction_add(request):
)
else:
form = TransactionForm(
user=request.user,
initial={
"date": expected_date,
"type": transaction_type,
@@ -66,6 +68,48 @@ def transaction_add(request):
)
@login_required
@require_http_methods(["GET", "POST"])
def transaction_simple_add(request):
month = int(request.GET.get("month", timezone.localdate(timezone.now()).month))
year = int(request.GET.get("year", timezone.localdate(timezone.now()).year))
transaction_type = Transaction.Type(request.GET.get("type", "IN"))
now = timezone.localdate(timezone.now())
expected_date = datetime.datetime(
day=now.day if month == now.month and year == now.year else 1,
month=month,
year=year,
).date()
if request.method == "POST":
form = TransactionForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, _("Transaction added successfully"))
form = TransactionForm(
initial={
"date": expected_date,
"type": transaction_type,
},
)
else:
form = TransactionForm(
initial={
"date": expected_date,
"type": transaction_type,
},
)
return render(
request,
"transactions/pages/add.html",
{"form": form},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
@@ -73,7 +117,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, user=request.user, instance=transaction)
form = TransactionForm(request.POST, instance=transaction)
if form.is_valid():
form.save()
messages.success(request, _("Transaction updated successfully"))
@@ -83,7 +127,7 @@ def transaction_edit(request, transaction_id, **kwargs):
headers={"HX-Trigger": "updated, hide_offcanvas"},
)
else:
form = TransactionForm(instance=transaction, user=request.user)
form = TransactionForm(instance=transaction)
return render(
request,
@@ -92,6 +136,60 @@ def transaction_edit(request, transaction_id, **kwargs):
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def transactions_bulk_edit(request):
# Get selected transaction IDs from the URL parameter
transaction_ids = request.GET.getlist("transactions") or request.POST.getlist(
"transactions"
)
# Load the selected transactions
transactions = Transaction.objects.filter(id__in=transaction_ids)
count = transactions.count()
if request.method == "POST":
form = BulkEditTransactionForm(request.POST)
if form.is_valid():
# Apply changes from the form to all selected transactions
for transaction in transactions:
for field_name, value in form.cleaned_data.items():
if value or isinstance(
value, bool
): # Only update fields that have been filled in the form
if field_name == "tags":
transaction.tags.set(value)
elif field_name == "entities":
transaction.entities.set(value)
else:
setattr(transaction, field_name, value)
transaction.save()
transaction_updated.send(sender=transaction)
messages.success(
request,
ngettext_lazy(
"%(count)s transaction updated successfully",
"%(count)s transactions updated successfully",
count,
)
% {"count": count},
)
return HttpResponse(
status=204,
headers={"HX-Trigger": "updated, hide_offcanvas"},
)
else:
form = BulkEditTransactionForm(initial={"is_paid": None, "type": None})
context = {
"form": form,
"transactions": transactions,
}
return render(request, "transactions/fragments/bulk_edit.html", context)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
@@ -102,6 +200,7 @@ def transaction_clone(request, transaction_id, **kwargs):
new_transaction.installment_plan = None
new_transaction.installment_id = None
new_transaction.recurring_transaction = None
new_transaction.internal_id = None
new_transaction.save()
new_transaction.tags.add(*transaction.tags.all())
@@ -143,7 +242,6 @@ def transaction_clone(request, transaction_id, **kwargs):
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def transaction_delete(request, transaction_id, **kwargs):
transaction = get_object_or_404(Transaction, id=transaction_id)
@@ -173,7 +271,7 @@ def transactions_transfer(request):
).date()
if request.method == "POST":
form = TransferForm(request.POST, user=request.user)
form = TransferForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, _("Transfer added successfully"))
@@ -187,7 +285,6 @@ def transactions_transfer(request):
"reference_date": expected_date,
"date": expected_date,
},
user=request.user,
)
return render(request, "transactions/fragments/transfer.html", {"form": form})
@@ -216,7 +313,7 @@ def transaction_pay(request, transaction_id):
@login_required
@require_http_methods(["GET"])
def transaction_all_index(request):
f = TransactionsFilter(request.GET, user=request.user)
f = TransactionsFilter(request.GET)
return render(request, "transactions/pages/transactions.html", {"filter": f})
@@ -238,7 +335,7 @@ def transaction_all_list(request):
transactions = default_order(transactions, order=order)
f = TransactionsFilter(request.GET, user=request.user, queryset=transactions)
f = TransactionsFilter(request.GET, queryset=transactions)
page_number = request.GET.get("page", 1)
paginator = Paginator(f.qs, 100)
@@ -268,7 +365,7 @@ def transaction_all_summary(request):
"installment_plan",
).all()
f = TransactionsFilter(request.GET, user=request.user, queryset=transactions)
f = TransactionsFilter(request.GET, queryset=transactions)
currency_data = calculate_currency_totals(f.qs.all(), ignore_empty=True)
currency_percentages = calculate_percentage_distribution(currency_data)

View File

@@ -81,6 +81,12 @@ class UserSettingsForm(forms.ModelForm):
("Y.m.d h:i A", "2025.01.20 03:30 PM"),
]
NUMBER_FORMAT_CHOICES = [
("AA", _("Default")),
("DC", "1.234,50"),
("CD", "1,234.50"),
]
date_format = forms.ChoiceField(
choices=DATE_FORMAT_CHOICES, initial="SHORT_DATE_FORMAT", label=_("Date Format")
)
@@ -90,6 +96,12 @@ class UserSettingsForm(forms.ModelForm):
label=_("Datetime Format"),
)
number_format = forms.ChoiceField(
choices=NUMBER_FORMAT_CHOICES,
initial="AA",
label=_("Number Format"),
)
class Meta:
model = UserSettings
fields = [
@@ -98,6 +110,7 @@ class UserSettingsForm(forms.ModelForm):
"start_page",
"date_format",
"datetime_format",
"number_format",
]
def __init__(self, *args, **kwargs):
@@ -111,6 +124,7 @@ class UserSettingsForm(forms.ModelForm):
"timezone",
"date_format",
"datetime_format",
"number_format",
"start_page",
FormActions(
NoClassSubmit(

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.5 on 2025-01-24 19:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0014_alter_usersettings_date_format_and_more'),
]
operations = [
migrations.AlterField(
model_name='usersettings',
name='language',
field=models.CharField(choices=[('auto', 'Auto'), ('en', 'English'), ('pt-br', 'Português (Brasil)')], default='auto', max_length=10, verbose_name='Language'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.5 on 2025-01-25 18:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0015_alter_usersettings_language'),
]
operations = [
migrations.AlterField(
model_name='usersettings',
name='language',
field=models.CharField(choices=[('auto', 'Auto'), ('en', 'English'), ('nl', 'Nederlands'), ('pt-br', 'Português (Brasil)')], default='auto', max_length=10, verbose_name='Language'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.5 on 2025-01-27 12:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0016_alter_usersettings_language'),
]
operations = [
migrations.AddField(
model_name='usersettings',
name='number_format',
field=models.CharField(default='AA', max_length=2, verbose_name='Number Format'),
),
]

View File

@@ -1,8 +1,8 @@
import pytz
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import models
from django.contrib.auth.models import AbstractUser, Group
from django.db import models
from django.utils.translation import gettext_lazy as _
from apps.users.managers import UserManager
@@ -44,6 +44,9 @@ class UserSettings(models.Model):
default="SHORT_DATETIME_FORMAT",
verbose_name=_("Datetime Format"),
)
number_format = models.CharField(
max_length=2, default="AA", verbose_name=_("Number Format")
)
language = models.CharField(
max_length=10,
@@ -66,3 +69,6 @@ class UserSettings(models.Model):
def __str__(self):
return f"{self.user.email}'s settings"
def clean(self):
super().clean()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,8 @@
{% extends 'extends/offcanvas.html' %}
{% load date %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Transactions on' %} {{ date|custom_date:request.user }}{% endblock %}
{% block title %}{% translate 'Transactions on' %} {{ date|date:"SHORT_DATE_FORMAT" }}{% 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,4 +1,3 @@
{% load date %}
{% load i18n %}
<div class="transaction d-flex my-1 {% if transaction.type == "EX" %}expense{% else %}income{% endif %}">
{% if not disable_selection %}
@@ -27,7 +26,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|custom_date:request.user }} • {{ transaction.reference_date|date:"b/Y" }}</div>
<div class="col ps-0">{{ transaction.date|date:"SHORT_DATE_FORMAT" }} • {{ transaction.reference_date|date:"b/Y" }}</div>
</div>
{# Description#}
<div class="mb-2 mb-lg-1 text-white tw-text-base">

View File

@@ -2,46 +2,80 @@
<div class="tw-sticky tw-bottom-4 tw-left-0 tw-right-0 tw-z-50 tw-hidden mx-auto tw-w-fit" id="actions-bar"
_="on change from #transactions-list or htmx:afterSettle from window
if no <input[type='checkbox']:checked/> in #transactions-list
add .tw-hidden to #actions-bar
if #actions-bar
add .slide-in-bottom-reverse then settle
then add .tw-hidden to #actions-bar
then remove .slide-in-bottom-reverse
end
else
remove .tw-hidden from #actions-bar
then trigger selected_transactions_updated
if #actions-bar
remove .tw-hidden from #actions-bar
then trigger selected_transactions_updated
end
end
end">
<div class="card slide-in-left">
<div class="card-body p-2">
<div class="card slide-in-bottom">
<div class="card-body p-2 d-flex justify-content-between align-items-center gap-3">
{% spaceless %}
<div class="btn-group" role="group">
<button class="btn btn-secondary btn-sm"
data-bs-toggle="tooltip"
data-bs-title="{% translate 'Select All' %}"
_="on click set <#transactions-list input[type='checkbox']/>'s checked to true then call me.blur() then trigger change">
<i class="fa-regular fa-square-check tw-text-green-400"></i>
</button>
<button class="btn btn-secondary btn-sm"
data-bs-toggle="tooltip"
data-bs-title="{% translate 'Unselect All' %}"
_="on click set <#transactions-list input[type='checkbox']/>'s checked to false then call me.blur() then trigger change">
<i class="fa-regular fa-square tw-text-red-400"></i>
<div class="dropdown">
<button class="btn btn-secondary btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown"
aria-expanded="false">
<i class="fa-regular fa-square-check fa-fw"></i>
</button>
<ul class="dropdown-menu">
<li>
<div class="dropdown-item px-3 tw-cursor-pointer"
_="on click set <#transactions-list input[type='checkbox']/>'s checked to true then call me.blur() then trigger change">
<i class="fa-regular fa-square-check tw-text-green-400 me-3"></i>{% translate 'Select All' %}
</div>
</li>
<li>
<div class="dropdown-item px-3 tw-cursor-pointer"
_="on click set <#transactions-list input[type='checkbox']/>'s checked to false then call me.blur() then trigger change">
<i class="fa-regular fa-square tw-text-red-400 me-3"></i>{% translate 'Unselect All' %}
</div>
</li>
</ul>
</div>
<div class="vr mx-3 tw-align-middle"></div>
<div class="btn-group me-3" role="group">
<div class="vr tw-align-middle"></div>
<div class="btn-group">
<button class="btn btn-secondary btn-sm"
hx-get="{% url 'transactions_bulk_pay' %}"
hx-get="{% url 'transactions_bulk_edit' %}"
hx-target="#generic-offcanvas"
hx-include=".transaction"
data-bs-toggle="tooltip"
data-bs-title="{% translate 'Mark as paid' %}">
<i class="fa-regular fa-circle-check tw-text-green-400"></i>
data-bs-title="{% translate 'Edit' %}">
<i class="fa-solid fa-pencil"></i>
</button>
<button class="btn btn-secondary btn-sm"
hx-get="{% url 'transactions_bulk_unpay' %}"
hx-include=".transaction"
data-bs-toggle="tooltip"
data-bs-title="{% translate 'Mark as unpaid' %}">
<i class="fa-regular fa-circle tw-text-red-400"></i>
<button type="button" class="btn btn-sm btn-secondary dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown" aria-expanded="false" data-bs-auto-close="outside">
<span class="visually-hidden">{% trans "Toggle Dropdown" %}</span>
</button>
<ul class="dropdown-menu">
<li>
<div class="dropdown-item px-3 tw-cursor-pointer"
hx-get="{% url 'transactions_bulk_unpay' %}"
hx-include=".transaction">
<i class="fa-regular fa-circle tw-text-red-400 fa-fw me-3"></i>{% translate 'Mark as unpaid' %}
</div>
</li>
<li>
<div class="dropdown-item px-3 tw-cursor-pointer"
hx-get="{% url 'transactions_bulk_pay' %}"
hx-include=".transaction">
<i class="fa-regular fa-circle-check tw-text-green-400 fa-fw me-3"></i>{% translate 'Mark as paid' %}
</div>
</li>
</ul>
</div>
<button class="btn btn-secondary btn-sm"
hx-get="{% url 'transactions_bulk_clone' %}"
hx-include=".transaction"
data-bs-toggle="tooltip"
data-bs-title="{% translate 'Duplicate' %}">
<i class="fa-solid fa-clone fa-fw"></i>
</button>
<button class="btn btn-secondary btn-sm"
hx-get="{% url 'transactions_bulk_delete' %}"
hx-include=".transaction"
@@ -55,9 +89,9 @@
_="install prompt_swal">
<i class="fa-solid fa-trash text-danger"></i>
</button>
<div class="vr mx-3 tw-align-middle"></div>
<div class="vr tw-align-middle"></div>
<div class="btn-group"
_="on selected_transactions_updated from #actions-bar
_="on selected_transactions_updated from #actions-bar
set realTotal to math.bignumber(0)
set flatTotal to math.bignumber(0)
set transactions to <.transaction:has(input[name='transactions']:checked)/>
@@ -93,8 +127,7 @@
put Math.min.apply(Math, realAmountValues).toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-min's innerText
put mean.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-mean's innerText
put flatAmountValues.length.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-count's innerText
end"
>
end">
<button class="btn btn-secondary btn-sm" _="on click
set original_value to #real-total-front's innerText
writeText(original_value) on navigator.clipboard
@@ -102,8 +135,8 @@
wait 1s
put original_value into #real-total-front's innerText
end">
<i class="fa-solid fa-plus fa-fw me-2 text-primary"></i>
<span id="real-total-front">0</span>
<i class="fa-solid fa-plus fa-fw me-md-2 text-primary"></i>
<span class="d-none d-md-inline-block" id="real-total-front">0</span>
</button>
<button type="button" class="btn btn-sm btn-secondary dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown" aria-expanded="false" data-bs-auto-close="outside">

View File

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

@@ -20,7 +20,7 @@
{% for preset in presets %}
<a class="text-decoration-none"
role="button"
hx-get="{% url 'import_profiles_add' %}"
hx-post="{% url 'import_profiles_add' %}"
hx-vals='{"yaml_config": {{ preset.config }}, "name": "{{ preset.name }}", "version": "{{ preset.schema_version }}", "message": {{ preset.message }}}'
hx-target="#generic-offcanvas">

View File

@@ -2,7 +2,7 @@
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Runs for ' %}{{ profile.name }}{% endblock %}
{% block title %}{% translate 'Runs for' %} {{ profile.name }}{% endblock %}
{% block body %}
<div hx-get="{% url "import_profile_runs_list" profile_id=profile.id %}"

View File

@@ -1,8 +0,0 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% block title %}{% translate 'Import Runs' %}{% endblock %}
{% block content %}
<div hx-get="{% url 'impor' %}" hx-trigger="load, updated from:window" class="show-loading"></div>
{% endblock %}

View File

@@ -9,4 +9,10 @@
end
end
end
on reset
for elm in <select/> in event.target
call elm.tomselect.clear()
end
end
</script>

View File

@@ -28,7 +28,8 @@
<body class="font-monospace">
<div _="install hide_amounts
install htmx_error_handler
{% block body_hyperscript %}{% endblock %}">
{% block body_hyperscript %}{% endblock %}"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
{% include 'includes/navbar.html' %}
<div id="content">

View File

@@ -129,6 +129,7 @@
id="filter">
{% crispy filter.form %}
</form>
<button class="btn btn-outline-danger btn-sm" _="on click call #filter.reset() then trigger change on #filter">{% translate 'Clear' %}</button>
</div>
</div>
{# Transactions list#}

View File

@@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Offline</title>
<style>
.offline, body {
text-align: center;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100vh;
background-color: #222;
color: #fbb700;
font-family: Arial, sans-serif;
}
.wifi-icon {
width: 100px;
height: 100px;
}
@keyframes flash {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.flashing {
animation: flash 1s infinite;
}
#offline-countdown {
margin-top: 20px;
font-size: 14px;
}
</style>
</head>
<body>
<div class="offline">
<svg class="wifi-icon flashing" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M1 9l2 2c4.97-4.97 13.03-4.97 18 0l2-2C16.93 2.93 7.08 2.93 1 9zm8 8l3 3 3-3c-1.65-1.66-4.34-1.66-6 0zm-4-4l2 2c2.76-2.76 7.24-2.76 10 0l2-2C15.14 9.14 8.87 9.14 5 13z" fill="#fbb700"/>
<path d="M23 21L1 3" stroke="#fbb700" stroke-width="2"/>
</svg>
<p>Either you or your WYGIWYH instance is offline.</p>
<div id="offline-countdown"></div>
</div>
<script>
function attemptReload() {
const countdownElement = document.getElementById('offline-countdown');
let secondsLeft = 30;
function updateCountdown() {
countdownElement.textContent = `Retrying in ${secondsLeft} seconds...`;
secondsLeft--;
if (secondsLeft < 0) {
window.location.reload();
} else {
setTimeout(updateCountdown, 1000);
}
}
updateCountdown();
}
// Start the reload attempt process immediately
attemptReload();
// Also attempt reload when coming back online
window.addEventListener('online', () => {
window.location.reload();
});
// For HTMX compatibility
document.body.addEventListener('htmx:load', attemptReload);
</script>
</body>
</html>

View File

@@ -0,0 +1,74 @@
// Base Service Worker implementation. To use your own Service Worker, set the PWA_SERVICE_WORKER_PATH variable in settings.py
var staticCacheName = "django-pwa-v" + new Date().getTime();
var filesToCache = [
'/offline/',
'/static/css/django-pwa-app.css',
'/static/img/favicon/android-icon-192x192.png',
'/static/img/favicon/apple-icon-180x180.png',
'/static/img/pwa/splash-640x1136.png',
'/static/img/pwa/splash-750x1334.png',
];
// Cache on install
self.addEventListener("install", event => {
this.skipWaiting();
event.waitUntil(
caches.open(staticCacheName)
.then(cache => {
return cache.addAll(filesToCache);
})
);
});
// Clear cache on activate
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames
.filter(cacheName => (cacheName.startsWith("django-pwa-")))
.filter(cacheName => (cacheName !== staticCacheName))
.map(cacheName => caches.delete(cacheName))
);
})
);
});
// Serve from Cache
self.addEventListener("fetch", event => {
event.respondWith(
caches.match(event.request)
.then(response => {
if (response) {
return response;
}
return fetch(event.request).catch(() => {
const isHtmxRequest = event.request.headers.get('HX-Request') === 'true';
const isHtmxBoosted = event.request.headers.get('HX-Boosted') === 'true';
if (!isHtmxRequest || isHtmxBoosted) {
// Serve offline content without changing URL
return caches.match('/offline/').then(offlineResponse => {
if (offlineResponse) {
return offlineResponse.text().then(offlineText => {
return new Response(offlineText, {
status: 200,
headers: {'Content-Type': 'text/html'}
});
});
}
// If offline page is not in cache, return a simple offline message
return new Response('<h1>Offline</h1><p>The page is not available offline.</p>', {
status: 200,
headers: {'Content-Type': 'text/html'}
});
});
} else {
// For non-boosted HTMX requests, let it fail normally
throw new Error('Network request failed');
}
});
})
);
});

View File

@@ -0,0 +1,17 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Bulk Editing' %}{% endblock %}
{% block body %}
<p>{% trans 'Editing' %} {{ transactions|length }} {% trans 'transactions' %}</p>
<div class="editing-transactions">
{% for transaction in transactions %}
<input type="hidden" name="transactions" value="{{ transaction.id }}"/>
{% endfor %}
</div>
<form hx-post="{% url 'transactions_bulk_edit' %}" hx-target="#generic-offcanvas" hx-include=".editing-transactions" novalidate>
{% crispy form %}
</form>
{% endblock %}

View File

@@ -0,0 +1,15 @@
{% extends 'layouts/base.html' %}
{% load crispy_forms_tags %}
{% load i18n %}
{% block title %}{% translate 'New transaction' %}{% endblock %}
{% block content %}
<div class="container py-3 column-gap-5"
_="install init_tom_select
install init_datepicker">
<form hx-post="{% url 'transaction_simple_add' %}" hx-swap="outerHTML" hx-target="body" novalidate>
{% crispy form form.helper_simple %}
</form>
</div>
{% endblock %}

View File

@@ -14,8 +14,7 @@
<div class="d-flex mb-3 align-self-center">
<div class="me-auto"><h4><i class="fa-solid fa-filter me-2"></i>{% translate 'Filter' %}</h4></div>
<div class="align-self-center">
<a href="{% url 'transactions_all_index' %}" type="button" class="btn btn-outline-danger btn-sm"
hx-target="body" hx-boost="true">{% translate 'Clear' %}</a>
<button class="btn btn-outline-danger btn-sm" _="on click call #filter.reset() then trigger change on #filter">{% translate 'Clear' %}</button>
</div>
</div>
<hr>

View File

@@ -9,7 +9,7 @@
id="{{ field.html_name }}_{{ forloop.counter }}_tr"
value="{{ choice.0 }}"
{% if choice.0 == field.value %}checked{% endif %}>
<label class="btn {% if forloop.first %}btn-outline-success{% elif forloop.last %}btn-outline-danger{% else %}btn-outline-primary{% endif %} {% if field.errors %}is-invalid{% endif %}"
<label class="btn {% if forloop.first %}btn-outline-success{% elif forloop.last %}btn-outline-danger{% else %}btn-outline-primary{% endif %} {% if field.errors %}is-invalid{% endif %} w-100"
for="{{ field.html_name }}_{{ forloop.counter }}_tr">
{{ choice.1 }}
</label>

View File

@@ -0,0 +1,18 @@
{% load i18n %}
{% load crispy_forms_field %}
<div class="form-group mb-3">
<div class="btn-group w-100" role="group" aria-label="{{ field.label }}">
<input type="radio" class="btn-check" name="{{ field.name }}" id="{{ field.id_for_label }}_false"
value="false" {% if not field.value %}checked{% endif %}>
<label class="btn btn-outline-primary w-50" for="{{ field.id_for_label }}_false">{% trans 'Projected' %}</label>
<input type="radio" class="btn-check" name="{{ field.name }}" id="{{ field.id_for_label }}_true"
value="true" {% if field.value %}checked{% endif %}>
<label class="btn btn-outline-success w-50" for="{{ field.id_for_label }}_true">{% trans 'Paid' %}</label>
</div>
{% if field.help_text %}
<div class="help-text">{{ field.help_text|safe }}</div>
{% endif %}
</div>

View File

@@ -15,7 +15,7 @@
id="{{ field.html_name }}_{{ forloop.counter }}"
value="{{ choice.0 }}"
{% if choice.0 in field.value %}checked{% endif %}>
<label class="btn btn-outline-dark"
<label class="btn btn-outline-dark w-100"
for="{{ field.html_name }}_{{ forloop.counter }}">
{{ choice.1 }}
</label>

View File

@@ -0,0 +1,40 @@
{% load i18n %}
{% load crispy_forms_field %}
<div class="form-group mb-3">
<div class="btn-group w-100" role="group" aria-label="{{ field.label }}">
<input type="radio"
class="btn-check"
name="{{ field.html_name }}"
id="{{ field.html_name }}_none_tr"
value=""
{% if field.value is None %}checked{% endif %}>
<label class="btn btn-outline-secondary {% if field.errors %}is-invalid{% endif %} w-100"
for="{{ field.html_name }}_none_tr">
{% trans 'Unchanged' %}
</label>
{% for choice in field.field.choices %}
<input type="radio"
class="btn-check"
name="{{ field.html_name }}"
id="{{ field.html_name }}_{{ forloop.counter }}_tr"
value="{{ choice.0 }}"
{% if choice.0 == field.value %}checked{% endif %}>
<label class="btn {% if forloop.first %}btn-outline-success{% elif forloop.last %}btn-outline-danger{% else %}btn-outline-primary{% endif %} {% if field.errors %}is-invalid{% endif %} w-100"
for="{{ field.html_name }}_{{ forloop.counter }}_tr">
{{ choice.1 }}
</label>
{% endfor %}
</div>
{% if field.errors %}
<div class="invalid-feedback d-block">
{% for error in field.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
{% if field.help_text %}
<small class="form-text text-muted">{{ field.help_text }}</small>
{% endif %}
</div>

View File

@@ -0,0 +1,22 @@
{% load i18n %}
{% load crispy_forms_field %}
<div class="form-group mb-3">
<div class="btn-group w-100" role="group" aria-label="{{ field.label }}">
<input type="radio" class="btn-check" name="{{ field.name }}" id="{{ field.id_for_label }}_null"
value="" {% if field.value is None %}checked{% endif %}>
<label class="btn btn-outline-secondary w-100" for="{{ field.id_for_label }}_null">{% trans 'Unchanged' %}</label>
<input type="radio" class="btn-check" name="{{ field.name }}" id="{{ field.id_for_label }}_false"
value="false" {% if field.value is False %}checked{% endif %}">
<label class="btn btn-outline-primary w-100" for="{{ field.id_for_label }}_false">{% trans 'Projected' %}</label>
<input type="radio" class="btn-check" name="{{ field.name }}" id="{{ field.id_for_label }}_true"
value="true" {% if field.value is True %}checked{% endif %}>
<label class="btn btn-outline-success w-100" for="{{ field.id_for_label }}_true">{% trans 'Paid' %}</label>
</div>
{% if field.help_text %}
<div class="help-text">{{ field.help_text|safe }}</div>
{% endif %}
</div>

View File

@@ -1,4 +1,4 @@
FROM python:3.11-slim-buster AS python-build-stage
FROM python:3.11-slim-bookworm AS python-build-stage
RUN apt-get update && apt-get install --no-install-recommends -y \
build-essential \
@@ -8,7 +8,7 @@ RUN apt-get update && apt-get install --no-install-recommends -y \
COPY ../requirements.txt .
RUN pip wheel --wheel-dir /usr/src/app/wheels -r requirements.txt
FROM python:3.11-slim-buster AS python-run-stage
FROM python:3.11-slim-bookworm AS python-run-stage
WORKDIR /usr/src/app

View File

@@ -1,4 +1,4 @@
FROM python:3.11-slim-buster AS python-build-stage
FROM python:3.11-slim-bookworm AS python-build-stage
RUN apt-get update && apt-get install --no-install-recommends -y \
build-essential \
@@ -17,7 +17,7 @@ RUN --mount=type=cache,target=/root/.npm \
npm install --verbose && \
npm run build
FROM python:3.11-slim-buster AS python-run-stage
FROM python:3.11-slim-bookworm AS python-run-stage
COPY --from=webpack_build /usr/src/frontend/build /usr/src/frontend/build
WORKDIR /usr/src/app

View File

@@ -1,11 +1,13 @@
import AirDatepicker from 'air-datepicker';
import en from 'air-datepicker/locale/en';
import ptBr from 'air-datepicker/locale/pt-BR';
import nl from 'air-datepicker/locale/nl';
import {createPopper} from '@popperjs/core';
const locales = {
'pt': ptBr,
'en': en
'en': en,
'nl': nl
};
function isMobileDevice() {
@@ -161,8 +163,8 @@ window.MonthYearPicker = function createDynamicDatePicker(element) {
let opts = {...baseOpts, ...positionConfig};
if (element.dataset.value) {
opts["selectedDates"] = [element.dataset.value];
opts["startDate"] = [element.dataset.value];
opts["selectedDates"] = [new Date(element.dataset.value + "T00:00:00")];
opts["startDate"] = [new Date(element.dataset.value + "T00:00:00")];
}
return new AirDatepicker(element, opts);
};

View File

@@ -58,13 +58,21 @@
// HTMX Loading
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes fade-in {
0% { opacity: 0; }
100% { opacity: 1; }
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.show-loading.htmx-request {
@@ -103,7 +111,7 @@
}
.swing-out-top-bck {
animation: swing-out-top-bck 0.45s cubic-bezier(0.600, -0.280, 0.735, 0.045) both;
animation: swing-out-top-bck 0.45s cubic-bezier(0.600, -0.280, 0.735, 0.045) both;
}
/* ----------------------------------------------
@@ -155,7 +163,7 @@
}
.scale-in-center {
animation: scale-in-center 0.5s cubic-bezier(0.250, 0.460, 0.450, 0.940) both;
animation: scale-in-center 0.5s cubic-bezier(0.250, 0.460, 0.450, 0.940) both;
}
/* ----------------------------------------------
@@ -182,5 +190,50 @@
}
.scale-out-center {
animation: scale-out-center 0.5s cubic-bezier(0.550, 0.085, 0.680, 0.530) both;
animation: scale-out-center 0.5s cubic-bezier(0.550, 0.085, 0.680, 0.530) both;
}
@keyframes flash {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
.flashing {
animation: flash 1s infinite;
}
.slide-in-bottom {
animation: slide-in-bottom 0.3s cubic-bezier(0.250, 0.460, 0.450, 0.940) both;
}
.slide-in-bottom-reverse {
animation: slide-in-bottom 0.3s cubic-bezier(0.250, 0.460, 0.450, 0.940) reverse both;
}
/* ----------------------------------------------
* Generated by Animista on 2025-1-25 12:30:4
* Licensed under FreeBSD License.
* See http://animista.net/license for more info.
* w: http://animista.net, t: @cssanimista
* ---------------------------------------------- */
/**
* ----------------------------------------
* animation slide-in-bottom
* ----------------------------------------
*/
@keyframes slide-in-bottom {
0% {
transform: translateY(1000px);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}

View File

@@ -53,3 +53,27 @@ select[multiple] {
.transaction:has(input[type="checkbox"]:checked) > .transaction-item {
background-color: $primary-bg-subtle-dark;
}
.offline {
text-align: center;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100vh;
background-color: #222;
color: #fbb700;
font-family: Arial, sans-serif;
}
.wifi-icon {
width: 100px;
height: 100px;
}
#offline-countdown {
margin-top: 20px;
font-size: 14px;
}