Compare commits

...

77 Commits
0.1.0 ... 0.4.0

Author SHA1 Message Date
Herculino Trotta
011e926e02 Merge pull request #37
locale(pt-BR): update translation
2025-01-11 13:42:11 -03:00
Herculino Trotta
cd1b872b27 locale(pt-BR): update translation 2025-01-11 13:41:40 -03:00
Herculino Trotta
3791edce63 Merge pull request #36
feat(recurring-transaction): when explicitly finishing, delete any upcoming unpaid transactions
2025-01-11 13:40:28 -03:00
Herculino Trotta
2cb8100129 feat(recurring-transaction): when explicitly finishing, delete any upcoming unpaid transactions 2025-01-11 13:40:10 -03:00
Herculino Trotta
e7e4ccafb6 Merge pull request #35 from eitchtee/dev
feat(recurring-transaction): when unpause start generating transactions from today or from existing date, whichever is higher
2025-01-11 13:39:26 -03:00
Herculino Trotta
afbbf7b25d feat(recurring-transaction): when unpause start generating transactions from today or from existing date, whichever is higher 2025-01-11 13:38:51 -03:00
Herculino Trotta
1eba2b8731 Merge pull request #34 from eitchtee/dev
feat(installment-plan): don't update paid transactions amount
2025-01-11 13:37:19 -03:00
Herculino Trotta
afe366c359 feat(installment-plan): don't update paid transactions amount 2025-01-11 13:35:52 -03:00
Herculino Trotta
3ee2bebc5c Merge pull request #33
feat(recurring-transaction): update unpaid transactions info when recurring transaction is updated
2025-01-11 13:35:14 -03:00
Herculino Trotta
b951e5f069 feat(recurring-transaction): update unpaid transactions info when recurring transaction is updated 2025-01-11 13:34:49 -03:00
Herculino Trotta
4005a83a0d Merge pull request #32
fix(calculator): rounding errors
2025-01-07 16:17:00 -03:00
Herculino Trotta
f81f1d83fd fix(calculator): rounding errors 2025-01-07 16:16:26 -03:00
Herculino Trotta
7816d6c55d Merge pull request #31
fix(transactions:action-bar): rounding errors when summing (again)
2025-01-06 00:50:41 -03:00
Herculino Trotta
6e3fdae4fe fix(transactions:action-bar): rounding errors when summing (again) 2025-01-06 00:50:17 -03:00
Herculino Trotta
e2da996217 Merge pull request #30
fix(networth): chart initializing multiple times resulting in weird animation
2025-01-06 00:14:48 -03:00
Herculino Trotta
cc2e2293ed fix(networth): chart initializing multiple times resulting in weird animation 2025-01-06 00:14:15 -03:00
Herculino Trotta
7060f07ccd Merge pull request #29
feat(calculator): localize result
2025-01-06 00:14:12 -03:00
Herculino Trotta
0adb991879 feat(calculator): localize result 2025-01-06 00:13:47 -03:00
Herculino Trotta
20e03df661 Merge pull request #28
fix(transactions:action-bar): rounding errors when summing
2025-01-06 00:11:55 -03:00
Herculino Trotta
71f59bfd68 fix(transactions:action-bar): rounding errors when summing 2025-01-06 00:10:40 -03:00
Herculino Trotta
6c76535f91 Merge pull request #27
fix(transactions:action-bar): min and max calculations take into account if value is income or expense
2025-01-05 15:53:21 -03:00
Herculino Trotta
5c8fbc9278 fix(transactions:action-bar): min and max calculations take into account if value is income or expense 2025-01-05 15:52:58 -03:00
Herculino Trotta
89b11421c2 Merge pull request #26
feat(transactions:action-bar): localize calculation results
2025-01-05 15:42:51 -03:00
Herculino Trotta
056fc4fced feat(transactions:action-bar): localize calculation results 2025-01-05 15:42:28 -03:00
Herculino Trotta
3f9765ec7b Merge pull request #25
refactor(transactions:action-bar): remove debug log
2025-01-05 15:22:52 -03:00
Herculino Trotta
0d9d13bf31 refactor(transactions:action-bar): remove debug log 2025-01-05 15:22:18 -03:00
Herculino Trotta
2f6c396eaf Merge pull request #24
fix(transactions:action-bar): sum button not copying correctly
2025-01-05 15:20:24 -03:00
Herculino Trotta
d12b920e54 fix(transactions:action-bar): sum button not copying correctly 2025-01-05 15:19:58 -03:00
Herculino Trotta
9edbf7bd5a Merge pull request #23
feat(transactions:action-bar): add more math options in a dropdown
2025-01-05 14:36:07 -03:00
Herculino Trotta
dbd3eea29a locale(pt-BR): update translation 2025-01-05 14:35:33 -03:00
Herculino Trotta
881fed1895 feat(transactions:action-bar): add more math options in a dropdown 2025-01-05 14:35:23 -03:00
Herculino Trotta
10a0ac42a2 Merge pull request #22
feat(api): add RecurringTransaction and InstallmentPlan endpoints
2025-01-05 11:14:03 -03:00
Herculino Trotta
1b47c12a22 feat(api): add RecurringTransaction and InstallmentPlan endpoints 2025-01-05 11:13:23 -03:00
Herculino Trotta
091f73bf8d feat(api): support string name and ids for installmentplan endpoint 2025-01-05 11:07:38 -03:00
Herculino Trotta
73fe17de64 feat(api): add auth permission to all api endpoint 2025-01-05 11:04:50 -03:00
Herculino Trotta
52af1b2260 Merge pull request #21
feat(api): add API endpoints to add DCA entries and strategies
2025-01-05 10:54:55 -03:00
Herculino Trotta
8efa087aee feat(api): add API endpoints to add DCA entries and strategies 2025-01-05 10:54:31 -03:00
Herculino Trotta
6f69f15474 Merge pull request #20
feat: archived tabs for categories, tags and entities
2025-01-05 01:46:01 -03:00
Herculino Trotta
905e80cffe fix: overflowing empty message 2025-01-05 01:45:11 -03:00
Herculino Trotta
baae6bb96a feat(entities): add tab to show archived entities 2025-01-05 01:43:24 -03:00
Herculino Trotta
f5132e24bd feat(tags): add tab to show archived tags 2025-01-05 01:36:30 -03:00
Herculino Trotta
41303f39a0 fix: typo 2025-01-05 01:35:34 -03:00
Herculino Trotta
0fc8b0ee49 feat(tags): add tab to show archived tags 2025-01-05 01:35:25 -03:00
Herculino Trotta
037014d024 feat(categories): add tab to show archived categories 2025-01-05 01:22:14 -03:00
Herculino Trotta
8c5a9efe05 Merge pull request #19 from eitchtee/dev
locale(pt-BR): update translation
2025-01-04 18:24:47 -03:00
Herculino Trotta
f940414b5c locale(pt-BR): update translation 2025-01-04 18:23:01 -03:00
Herculino Trotta
2d8e97a27e Merge pull request #18
feat: allow for deactivating Tags, Categories and Entities, hiding them from menus
2025-01-04 18:17:42 -03:00
Herculino Trotta
5ccb9ff152 locale: add lazy translations to missing ValidationErrors 2025-01-04 18:17:06 -03:00
Herculino Trotta
3c0a2d82ac feat: allow for deactivating Tags, Categories and Entities, hiding them from menus 2025-01-04 18:13:11 -03:00
Herculino Trotta
62f049cbb2 Merge pull request #17
feat(fields:forms:dynamic-select): support existing objects not currently on the queryset
2025-01-04 18:00:33 -03:00
Herculino Trotta
7a759be357 feat(fields:forms:dynamic-select): support existing objects not currently on the queryset
and add create_field to DynamicModelChoiceField
2025-01-04 17:59:59 -03:00
Herculino Trotta
6297e73307 Merge pull request #16
feat(transactions): properly sum income and expense when selected
2025-01-04 01:33:05 -03:00
Herculino Trotta
eb753bb30e feat(transactions): properly sum income and expense when selected
also added a flatTotal (old behavior) for future use
2025-01-04 01:32:09 -03:00
Herculino Trotta
1047fb23dd fix(networth): charts not changing between views 2025-01-03 17:50:41 -03:00
Herculino Trotta
c861b9ae07 fix(networth): charts not changing between views 2025-01-03 17:36:10 -03:00
Herculino Trotta
4be849f5de github(release): add gha cache 2024-12-28 02:42:43 -03:00
Herculino Trotta
3e73332a93 locale(pt-BR): update translation 2024-12-28 02:32:43 -03:00
Herculino Trotta
ae2217e760 feat(tools:currency-converter): add a button to invert the selected currencies 2024-12-28 00:56:15 -03:00
Herculino Trotta
e2bf699be0 feat(tools:currency-converter): make it responsive 2024-12-28 00:56:15 -03:00
Herculino Trotta
e760d42c2d github(release): drop ghcr.io in favor of DockerHub 2024-12-27 12:53:43 -03:00
Herculino Trotta
d541b30280 docs: registry changes (#12)
* docker(prod): update docker-compose.prod.yml to use registry image

* docs(README): update install instructions to use registry image
2024-12-27 12:25:12 -03:00
dependabot[bot]
366c0b475d build(deps): bump nanoid from 3.3.7 to 3.3.8 in /frontend (#8)
Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.7 to 3.3.8.
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.3.7...3.3.8)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-27 12:17:41 -03:00
dependabot[bot]
8576b74aff build(deps): bump http-proxy-middleware from 2.0.6 to 2.0.7 in /frontend (#9)
Bumps [http-proxy-middleware](https://github.com/chimurai/http-proxy-middleware) from 2.0.6 to 2.0.7.
- [Release notes](https://github.com/chimurai/http-proxy-middleware/releases)
- [Changelog](https://github.com/chimurai/http-proxy-middleware/blob/v2.0.7/CHANGELOG.md)
- [Commits](https://github.com/chimurai/http-proxy-middleware/compare/v2.0.6...v2.0.7)

---
updated-dependencies:
- dependency-name: http-proxy-middleware
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-27 12:17:34 -03:00
dependabot[bot]
d4b11bd350 build(deps): bump path-to-regexp and express in /frontend (#10)
Bumps [path-to-regexp](https://github.com/pillarjs/path-to-regexp) and [express](https://github.com/expressjs/express). These dependencies needed to be updated together.

Updates `path-to-regexp` from 0.1.10 to 0.1.12
- [Release notes](https://github.com/pillarjs/path-to-regexp/releases)
- [Changelog](https://github.com/pillarjs/path-to-regexp/blob/master/History.md)
- [Commits](https://github.com/pillarjs/path-to-regexp/compare/v0.1.10...v0.1.12)

Updates `express` from 4.21.0 to 4.21.2
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/4.21.2/History.md)
- [Commits](https://github.com/expressjs/express/compare/4.21.0...4.21.2)

---
updated-dependencies:
- dependency-name: path-to-regexp
  dependency-type: indirect
- dependency-name: express
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-27 12:17:28 -03:00
dependabot[bot]
c8c34c2c56 build(deps): bump cookie and express in /frontend (#11)
Bumps [cookie](https://github.com/jshttp/cookie) and [express](https://github.com/expressjs/express). These dependencies needed to be updated together.

Updates `cookie` from 0.6.0 to 0.7.1
- [Release notes](https://github.com/jshttp/cookie/releases)
- [Commits](https://github.com/jshttp/cookie/compare/v0.6.0...v0.7.1)

Updates `express` from 4.21.0 to 4.21.2
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/4.21.2/History.md)
- [Commits](https://github.com/expressjs/express/compare/4.21.0...4.21.2)

---
updated-dependencies:
- dependency-name: cookie
  dependency-type: indirect
- dependency-name: express
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-27 12:17:18 -03:00
Herculino Trotta
023ceb898f Merge pull request #7
github(release): cache build process
2024-12-27 11:25:52 -03:00
Herculino Trotta
1243dddd5d github(release): cache build process 2024-12-27 11:24:56 -03:00
Herculino Trotta
8661fb39e8 github(release): disable provenance when building image 2024-12-27 11:20:44 -03:00
Herculino Trotta
5752606fec docker(prod): update docker-compose.prod.yml to use registry image 2024-12-27 11:20:14 -03:00
Herculino Trotta
7250ce0dbb Merge pull request #6
github(release): drop support for arm besides arm64
2024-12-27 03:24:25 -03:00
Herculino Trotta
b963a3cfb8 github(release): drop support for arm besides arm64 2024-12-27 03:23:51 -03:00
Herculino Trotta
1f14eb011f Merge pull request #5
github: fix "repository name must be lowercase"
2024-12-27 03:13:13 -03:00
Herculino Trotta
265af71ac5 github: fix "repository name must be lowercase" 2024-12-27 03:12:59 -03:00
Herculino Trotta
4c003d4456 Merge pull request #4
dev
2024-12-27 03:09:33 -03:00
Herculino Trotta
d66a2e2856 github: remove changelog creation from release.yml 2 2024-12-27 03:09:16 -03:00
Herculino Trotta
74bf6a655d Merge pull request #3
github: remove changelog creation from release.yml
2024-12-27 03:07:19 -03:00
Herculino Trotta
114cf2622e github: remove changelog creation from release.yml 2024-12-27 03:06:33 -03:00
44 changed files with 1410 additions and 538 deletions

View File

@@ -5,40 +5,23 @@ on:
types: [created] types: [created]
env: env:
REGISTRY: ghcr.io IMAGE_NAME: wygiwyh
IMAGE_NAME: WYGIWYH
jobs: jobs:
build-and-push: build-and-push:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
packages: write
attestations: write
id-token: write
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Generate changelog - name: Log in to Docker Hub
id: changelog
uses: metcalfc/changelog-generator@v4.1.0
with:
myToken: ${{ secrets.GITHUB_TOKEN }}
- name: Update release with changelog
uses: softprops/action-gh-release@v1
with:
body: ${{ steps.changelog.outputs.changelog }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with: with:
registry: ${{ env.REGISTRY }} username: ${{ secrets.DOCKERHUB_USERNAME }}
username: ${{ github.actor }} password: ${{ secrets.DOCKERHUB_TOKEN }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
@@ -52,7 +35,10 @@ jobs:
context: . context: .
file: ./docker/prod/django/Dockerfile file: ./docker/prod/django/Dockerfile
push: true push: true
provenance: false
tags: | tags: |
${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:latest ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:${{ github.event.release.tag_name }} ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:${{ github.event.release.tag_name }}
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v8,linux/arm64/v8 platforms: linux/amd64,linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -48,29 +48,28 @@ Frustrated by the lack of comprehensive options, I set out to build **WYGIWYH**
# How To Use # How To Use
To run this application, you'll need [Git](https://git-scm.com) and [Docker](https://docs.docker.com/engine/install/) with the [docker-compose](https://docs.docker.com/compose/install/). To run this application, you'll need [Docker](https://docs.docker.com/engine/install/) with [docker-compose](https://docs.docker.com/compose/install/).
From your command line: From your command line:
> [!NOTE]
> Docker images for this project are currently under development, but manual setup is available now.
```bash ```bash
# Clone this repository # Clone this repository
$ git clone https://github.com/eitchtee/WYGIWYH $ mkdir WYGIWYH
# Go into the repository # Go into the repository
$ cd WYGIWYH $ cd WYGIWYH
# Fill the .env file with your configurations $ touch docker-compose.yml
$ cp .env.example .env $ nano docker-compose.yml
$ nano .env # or any other editor you want to use # Paste the contents of https://github.com/eitchtee/WYGIWYH/blob/main/docker-compose.prod.yml and edit according to your needs
# Create docker-compose file # Fill the .env file with your configurations
$ cp docker-compose.prod.yml docker-compose.yml $ touch .env
$ nano .env # or any other editor you want to use
# Paste the contents of https://github.com/eitchtee/WYGIWYH/blob/main/.env.example and edit accordingly
# Run the app # Run the app
$ docker compose up -d --build $ docker compose up -d
# Create the first admin account # Create the first admin account
$ docker compose exec -it web python manage.py createsuperuser $ docker compose exec -it web python manage.py createsuperuser

View File

@@ -53,6 +53,7 @@ class AccountGroupForm(forms.ModelForm):
class AccountForm(forms.ModelForm): class AccountForm(forms.ModelForm):
group = DynamicModelChoiceField( group = DynamicModelChoiceField(
create_field="name",
label=_("Group"), label=_("Group"),
model=AccountGroup, model=AccountGroup,
required=False, required=False,
@@ -112,6 +113,7 @@ class AccountBalanceForm(forms.Form):
max_digits=42, decimal_places=30, required=False, label=_("New balance") max_digits=42, decimal_places=30, required=False, label=_("New balance")
) )
category = DynamicModelChoiceField( category = DynamicModelChoiceField(
create_field="name",
model=TransactionCategory, model=TransactionCategory,
required=False, required=False,
label=_("Category"), label=_("Category"),

View File

@@ -1,6 +1,7 @@
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers from rest_framework import serializers
from django.utils.translation import gettext_lazy as _
from apps.transactions.models import ( from apps.transactions.models import (
TransactionCategory, TransactionCategory,
@@ -25,13 +26,13 @@ class TransactionCategoryField(serializers.Field):
return TransactionCategory.objects.get(pk=data) return TransactionCategory.objects.get(pk=data)
except TransactionCategory.DoesNotExist: except TransactionCategory.DoesNotExist:
raise serializers.ValidationError( raise serializers.ValidationError(
"Category with this ID does not exist." _("Category with this ID does not exist.")
) )
elif isinstance(data, str): elif isinstance(data, str):
category, created = TransactionCategory.objects.get_or_create(name=data) category, created = TransactionCategory.objects.get_or_create(name=data)
return category return category
raise serializers.ValidationError( raise serializers.ValidationError(
"Invalid category data. Provide an ID or name." _("Invalid category data. Provide an ID or name.")
) )
@staticmethod @staticmethod
@@ -61,13 +62,13 @@ class TransactionTagField(serializers.Field):
tag = TransactionTag.objects.get(pk=item) tag = TransactionTag.objects.get(pk=item)
except TransactionTag.DoesNotExist: except TransactionTag.DoesNotExist:
raise serializers.ValidationError( raise serializers.ValidationError(
f"Tag with ID {item} does not exist." _("Tag with this ID does not exist.")
) )
elif isinstance(item, str): elif isinstance(item, str):
tag, created = TransactionTag.objects.get_or_create(name=item) tag, created = TransactionTag.objects.get_or_create(name=item)
else: else:
raise serializers.ValidationError( raise serializers.ValidationError(
"Invalid tag data. Provide an ID or name." _("Invalid tag data. Provide an ID or name.")
) )
tags.append(tag) tags.append(tag)
return tags return tags
@@ -85,13 +86,13 @@ class TransactionEntityField(serializers.Field):
entity = TransactionEntity.objects.get(pk=item) entity = TransactionEntity.objects.get(pk=item)
except TransactionTag.DoesNotExist: except TransactionTag.DoesNotExist:
raise serializers.ValidationError( raise serializers.ValidationError(
f"Entity with ID {item} does not exist." _("Entity with this ID does not exist.")
) )
elif isinstance(item, str): elif isinstance(item, str):
entity, created = TransactionEntity.objects.get_or_create(name=item) entity, created = TransactionEntity.objects.get_or_create(name=item)
else: else:
raise serializers.ValidationError( raise serializers.ValidationError(
"Invalid entity data. Provide an ID or name." _("Invalid entity data. Provide an ID or name.")
) )
entities.append(entity) entities.append(entity)
return entities return entities

View File

@@ -1,3 +1,4 @@
from .transactions import * from .transactions import *
from .accounts import * from .accounts import *
from .currencies import * from .currencies import *
from .dca import *

View File

@@ -1,4 +1,5 @@
from rest_framework import serializers from rest_framework import serializers
from rest_framework.permissions import IsAuthenticated
from apps.api.serializers.currencies import CurrencySerializer from apps.api.serializers.currencies import CurrencySerializer
from apps.accounts.models import AccountGroup, Account from apps.accounts.models import AccountGroup, Account
@@ -6,6 +7,8 @@ from apps.currencies.models import Currency
class AccountGroupSerializer(serializers.ModelSerializer): class AccountGroupSerializer(serializers.ModelSerializer):
permission_classes = [IsAuthenticated]
class Meta: class Meta:
model = AccountGroup model = AccountGroup
fields = "__all__" fields = "__all__"
@@ -31,6 +34,8 @@ class AccountSerializer(serializers.ModelSerializer):
allow_null=True, allow_null=True,
) )
permission_classes = [IsAuthenticated]
class Meta: class Meta:
model = Account model = Account
fields = [ fields = [

View File

@@ -1,8 +1,12 @@
from rest_framework import serializers from rest_framework import serializers
from rest_framework.permissions import IsAuthenticated
from apps.currencies.models import Currency, ExchangeRate from apps.currencies.models import Currency, ExchangeRate
class CurrencySerializer(serializers.ModelSerializer): class CurrencySerializer(serializers.ModelSerializer):
permission_classes = [IsAuthenticated]
class Meta: class Meta:
model = Currency model = Currency
fields = "__all__" fields = "__all__"
@@ -24,6 +28,8 @@ class ExchangeRateSerializer(serializers.ModelSerializer):
queryset=Currency.objects.all(), source="to_currency", write_only=True queryset=Currency.objects.all(), source="to_currency", write_only=True
) )
permission_classes = [IsAuthenticated]
class Meta: class Meta:
model = ExchangeRate model = ExchangeRate
fields = "__all__" fields = "__all__"

View File

@@ -0,0 +1,85 @@
from rest_framework import serializers
from rest_framework.permissions import IsAuthenticated
from apps.dca.models import DCAEntry, DCAStrategy
class DCAEntrySerializer(serializers.ModelSerializer):
profit_loss = serializers.DecimalField(
max_digits=42, decimal_places=30, read_only=True
)
profit_loss_percentage = serializers.DecimalField(
max_digits=42, decimal_places=30, read_only=True
)
current_value = serializers.DecimalField(
max_digits=42, decimal_places=30, read_only=True
)
entry_price = serializers.DecimalField(
max_digits=42, decimal_places=30, read_only=True
)
permission_classes = [IsAuthenticated]
class Meta:
model = DCAEntry
fields = [
"id",
"strategy",
"date",
"amount_paid",
"amount_received",
"notes",
"created_at",
"updated_at",
"profit_loss",
"profit_loss_percentage",
"current_value",
"entry_price",
]
read_only_fields = ["created_at", "updated_at"]
class DCAStrategySerializer(serializers.ModelSerializer):
entries = DCAEntrySerializer(many=True, read_only=True)
total_invested = serializers.DecimalField(
max_digits=42, decimal_places=30, read_only=True
)
total_received = serializers.DecimalField(
max_digits=42, decimal_places=30, read_only=True
)
average_entry_price = serializers.DecimalField(
max_digits=42, decimal_places=30, read_only=True
)
total_entries = serializers.IntegerField(read_only=True)
current_total_value = serializers.DecimalField(
max_digits=42, decimal_places=30, read_only=True
)
total_profit_loss = serializers.DecimalField(
max_digits=42, decimal_places=30, read_only=True
)
total_profit_loss_percentage = serializers.DecimalField(
max_digits=42, decimal_places=30, read_only=True
)
permission_classes = [IsAuthenticated]
class Meta:
model = DCAStrategy
fields = [
"id",
"name",
"target_currency",
"payment_currency",
"notes",
"created_at",
"updated_at",
"entries",
"total_invested",
"total_received",
"average_entry_price",
"total_entries",
"current_total_value",
"total_profit_loss",
"total_profit_loss_percentage",
]
read_only_fields = ["created_at", "updated_at"]

View File

@@ -19,6 +19,7 @@ from apps.transactions.models import (
TransactionTag, TransactionTag,
InstallmentPlan, InstallmentPlan,
TransactionEntity, TransactionEntity,
RecurringTransaction,
) )
@@ -47,11 +48,82 @@ class TransactionEntitySerializer(serializers.ModelSerializer):
class InstallmentPlanSerializer(serializers.ModelSerializer): class InstallmentPlanSerializer(serializers.ModelSerializer):
category = TransactionCategoryField(required=False)
tags = TransactionTagField(required=False)
entities = TransactionEntityField(required=False)
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
class Meta: class Meta:
model = InstallmentPlan model = InstallmentPlan
fields = "__all__" fields = [
"id",
"account",
"type",
"description",
"number_of_installments",
"installment_start",
"installment_total_number",
"start_date",
"reference_date",
"end_date",
"recurrence",
"installment_amount",
"category",
"tags",
"entities",
"notes",
]
read_only_fields = ["installment_total_number", "end_date"]
def create(self, validated_data):
instance = super().create(validated_data)
instance.create_transactions()
return instance
def update(self, instance, validated_data):
instance = super().update(instance, validated_data)
instance.update_transactions()
return instance
class RecurringTransactionSerializer(serializers.ModelSerializer):
category = TransactionCategoryField(required=False)
tags = TransactionTagField(required=False)
entities = TransactionEntityField(required=False)
class Meta:
model = RecurringTransaction
fields = [
"id",
"is_paused",
"account",
"type",
"amount",
"description",
"category",
"tags",
"entities",
"notes",
"reference_date",
"start_date",
"end_date",
"recurrence_type",
"recurrence_interval",
"last_generated_date",
"last_generated_reference_date",
]
read_only_fields = ["last_generated_date", "last_generated_reference_date"]
def create(self, validated_data):
instance = super().create(validated_data)
instance.create_upcoming_transactions()
return instance
def update(self, instance, validated_data):
instance = super().update(instance, validated_data)
instance.update_unpaid_transactions()
return instance
class TransactionSerializer(serializers.ModelSerializer): class TransactionSerializer(serializers.ModelSerializer):

View File

@@ -9,10 +9,13 @@ router.register(r"categories", views.TransactionCategoryViewSet)
router.register(r"tags", views.TransactionTagViewSet) router.register(r"tags", views.TransactionTagViewSet)
router.register(r"entities", views.TransactionEntityViewSet) router.register(r"entities", views.TransactionEntityViewSet)
router.register(r"installment-plans", views.InstallmentPlanViewSet) router.register(r"installment-plans", views.InstallmentPlanViewSet)
router.register(r"recurring-transactions", views.RecurringTransactionViewSet)
router.register(r"account-groups", views.AccountGroupViewSet) router.register(r"account-groups", views.AccountGroupViewSet)
router.register(r"accounts", views.AccountViewSet) router.register(r"accounts", views.AccountViewSet)
router.register(r"currencies", views.CurrencyViewSet) router.register(r"currencies", views.CurrencyViewSet)
router.register(r"exchange-rates", views.ExchangeRateViewSet) router.register(r"exchange-rates", views.ExchangeRateViewSet)
router.register(r"dca/strategies", views.DCAStrategyViewSet)
router.register(r"dca/entries", views.DCAEntryViewSet)
urlpatterns = [ urlpatterns = [
path("", include(router.urls)), path("", include(router.urls)),

View File

@@ -1,3 +1,4 @@
from .transactions import * from .transactions import *
from .accounts import * from .accounts import *
from .currencies import * from .currencies import *
from .dca import *

41
app/apps/api/views/dca.py Normal file
View File

@@ -0,0 +1,41 @@
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from apps.dca.models import DCAStrategy, DCAEntry
from apps.api.serializers import DCAStrategySerializer, DCAEntrySerializer
class DCAStrategyViewSet(viewsets.ModelViewSet):
queryset = DCAStrategy.objects.all()
serializer_class = DCAStrategySerializer
@action(detail=True, methods=["get"])
def investment_frequency(self, request, pk=None):
strategy = self.get_object()
return Response(strategy.investment_frequency_data())
@action(detail=True, methods=["get"])
def price_comparison(self, request, pk=None):
strategy = self.get_object()
return Response(strategy.price_comparison_data())
@action(detail=True, methods=["get"])
def current_price(self, request, pk=None):
strategy = self.get_object()
price_data = strategy.current_price()
if price_data:
price, date = price_data
return Response({"price": price, "date": date})
return Response({"price": None, "date": None})
class DCAEntryViewSet(viewsets.ModelViewSet):
queryset = DCAEntry.objects.all()
serializer_class = DCAEntrySerializer
def get_queryset(self):
queryset = DCAEntry.objects.all()
strategy_id = self.request.query_params.get("strategy", None)
if strategy_id is not None:
queryset = queryset.filter(strategy_id=strategy_id)
return queryset

View File

@@ -1,4 +1,4 @@
from rest_framework import permissions, viewsets from rest_framework import viewsets
from apps.api.serializers import ( from apps.api.serializers import (
TransactionSerializer, TransactionSerializer,
@@ -6,6 +6,7 @@ from apps.api.serializers import (
TransactionTagSerializer, TransactionTagSerializer,
InstallmentPlanSerializer, InstallmentPlanSerializer,
TransactionEntitySerializer, TransactionEntitySerializer,
RecurringTransactionSerializer,
) )
from apps.transactions.models import ( from apps.transactions.models import (
Transaction, Transaction,
@@ -13,6 +14,7 @@ from apps.transactions.models import (
TransactionTag, TransactionTag,
InstallmentPlan, InstallmentPlan,
TransactionEntity, TransactionEntity,
RecurringTransaction,
) )
from apps.rules.signals import transaction_updated, transaction_created from apps.rules.signals import transaction_updated, transaction_created
@@ -53,10 +55,7 @@ class InstallmentPlanViewSet(viewsets.ModelViewSet):
queryset = InstallmentPlan.objects.all() queryset = InstallmentPlan.objects.all()
serializer_class = InstallmentPlanSerializer serializer_class = InstallmentPlanSerializer
def perform_create(self, serializer):
instance = serializer.save()
instance.create_transactions()
def perform_update(self, serializer): class RecurringTransactionViewSet(viewsets.ModelViewSet):
instance = serializer.save() queryset = RecurringTransaction.objects.all()
instance.create_transactions() serializer_class = RecurringTransactionSerializer

View File

@@ -1,6 +1,7 @@
from django import forms from django import forms
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import transaction from django.db import transaction
from django.utils.translation import gettext_lazy as _
from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple
@@ -8,6 +9,12 @@ from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple
class DynamicModelChoiceField(forms.ModelChoiceField): class DynamicModelChoiceField(forms.ModelChoiceField):
def __init__(self, model, *args, **kwargs): def __init__(self, model, *args, **kwargs):
self.model = model self.model = model
self.to_field_name = kwargs.pop("to_field_name", "pk")
self.create_field = kwargs.pop("create_field", None)
if not self.create_field:
raise ValueError("The 'create_field' parameter is required.")
self.queryset = kwargs.pop("queryset", model.objects.all()) self.queryset = kwargs.pop("queryset", model.objects.all())
super().__init__(queryset=self.queryset, *args, **kwargs) super().__init__(queryset=self.queryset, *args, **kwargs)
self._created_instance = None self._created_instance = None
@@ -18,8 +25,7 @@ class DynamicModelChoiceField(forms.ModelChoiceField):
if value in self.empty_values: if value in self.empty_values:
return None return None
try: try:
key = self.to_field_name or "pk" return self.model.objects.get(**{self.to_field_name: value})
return self.model.objects.get(**{key: value})
except (ValueError, TypeError, self.model.DoesNotExist): except (ValueError, TypeError, self.model.DoesNotExist):
return value # Return the raw value; we'll handle creation in clean() return value # Return the raw value; we'll handle creation in clean()
@@ -49,10 +55,13 @@ class DynamicModelChoiceField(forms.ModelChoiceField):
except self.model.DoesNotExist: except self.model.DoesNotExist:
try: try:
with transaction.atomic(): with transaction.atomic():
instance = self.model.objects.create(name=value) instance, _ = self.model.objects.update_or_create(
**{self.create_field: value}
)
self._created_instance = instance self._created_instance = instance
return instance return instance
except Exception as e: except Exception as e:
print(e)
raise ValidationError( raise ValidationError(
self.error_messages["invalid_choice"], code="invalid_choice" self.error_messages["invalid_choice"], code="invalid_choice"
) )
@@ -111,12 +120,12 @@ class DynamicModelMultipleChoiceField(forms.ModelMultipleChoiceField):
""" """
try: try:
with transaction.atomic(): with transaction.atomic():
new_instance = self.queryset.model(**{self.create_field: value}) instance, _ = self.model.objects.update_or_create(
new_instance.full_clean() **{self.create_field: value}
new_instance.save() )
return new_instance return instance
except Exception as e: except Exception as e:
raise ValidationError(f"Error creating new instance: {str(e)}") raise ValidationError(_("Error creating new instance"))
def clean(self, value): def clean(self, value):
""" """
@@ -152,6 +161,6 @@ class DynamicModelMultipleChoiceField(forms.ModelMultipleChoiceField):
try: try:
new_objects.append(self._create_new_instance(new_value)) new_objects.append(self._create_new_instance(new_value))
except ValidationError as e: except ValidationError as e:
raise ValidationError(f"Error creating '{new_value}': {str(e)}") raise ValidationError(_("Error creating new instance"))
return existing_objects + new_objects return existing_objects + new_objects

View File

@@ -18,7 +18,7 @@ class MonthYearModelField(models.DateField):
# Set the day to 1 # Set the day to 1
return date.replace(day=1).date() return date.replace(day=1).date()
except ValueError: except ValueError:
raise ValidationError("Invalid date format. Use YYYY-MM.") raise ValidationError(_("Invalid date format. Use YYYY-MM."))
def formfield(self, **kwargs): def formfield(self, **kwargs):
kwargs["widget"] = MonthYearWidget kwargs["widget"] = MonthYearWidget

View File

@@ -8,6 +8,7 @@ from crispy_forms.layout import (
Field, Field,
) )
from django import forms from django import forms
from django.db.models import Q
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from apps.accounts.models import Account from apps.accounts.models import Account
@@ -32,9 +33,11 @@ from apps.rules.signals import transaction_created, transaction_updated
class TransactionForm(forms.ModelForm): class TransactionForm(forms.ModelForm):
category = DynamicModelChoiceField( category = DynamicModelChoiceField(
create_field="name",
model=TransactionCategory, model=TransactionCategory,
required=False, required=False,
label=_("Category"), label=_("Category"),
queryset=TransactionCategory.objects.filter(active=True),
) )
tags = DynamicModelMultipleChoiceField( tags = DynamicModelMultipleChoiceField(
model=TransactionTag, model=TransactionTag,
@@ -42,6 +45,7 @@ class TransactionForm(forms.ModelForm):
create_field="name", create_field="name",
required=False, required=False,
label=_("Tags"), label=_("Tags"),
queryset=TransactionTag.objects.filter(active=True),
) )
entities = DynamicModelMultipleChoiceField( entities = DynamicModelMultipleChoiceField(
model=TransactionEntity, model=TransactionEntity,
@@ -81,6 +85,24 @@ class TransactionForm(forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# if editing a transaction display non-archived items and it's own item even if it's archived
if self.instance.id:
self.fields["account"].queryset = Account.objects.filter(
Q(is_archived=False) | Q(transactions=self.instance.id)
).distinct()
self.fields["category"].queryset = TransactionCategory.objects.filter(
Q(active=True) | Q(transaction=self.instance.id)
).distinct()
self.fields["tags"].queryset = TransactionTag.objects.filter(
Q(active=True) | Q(transaction=self.instance.id)
).distinct()
self.fields["entities"].queryset = TransactionEntity.objects.filter(
Q(active=True) | Q(transactions=self.instance.id)
).distinct()
self.helper = FormHelper() self.helper = FormHelper()
self.helper.form_tag = False self.helper.form_tag = False
self.helper.form_method = "post" self.helper.form_method = "post"
@@ -181,14 +203,18 @@ class TransferForm(forms.Form):
) )
from_category = DynamicModelChoiceField( from_category = DynamicModelChoiceField(
create_field="name",
model=TransactionCategory, model=TransactionCategory,
required=False, required=False,
label=_("Category"), label=_("Category"),
queryset=TransactionCategory.objects.filter(active=True),
) )
to_category = DynamicModelChoiceField( to_category = DynamicModelChoiceField(
create_field="name",
model=TransactionCategory, model=TransactionCategory,
required=False, required=False,
label=_("Category"), label=_("Category"),
queryset=TransactionCategory.objects.filter(active=True),
) )
from_tags = DynamicModelMultipleChoiceField( from_tags = DynamicModelMultipleChoiceField(
@@ -197,6 +223,7 @@ class TransferForm(forms.Form):
create_field="name", create_field="name",
required=False, required=False,
label=_("Tags"), label=_("Tags"),
queryset=TransactionTag.objects.filter(active=True),
) )
to_tags = DynamicModelMultipleChoiceField( to_tags = DynamicModelMultipleChoiceField(
model=TransactionTag, model=TransactionTag,
@@ -204,6 +231,7 @@ class TransferForm(forms.Form):
create_field="name", create_field="name",
required=False, required=False,
label=_("Tags"), label=_("Tags"),
queryset=TransactionTag.objects.filter(active=True),
) )
date = forms.DateField( date = forms.DateField(
@@ -299,7 +327,7 @@ class TransferForm(forms.Form):
to_account = cleaned_data.get("to_account") to_account = cleaned_data.get("to_account")
if from_account == to_account: if from_account == to_account:
raise forms.ValidationError("From and To accounts must be different.") raise forms.ValidationError(_("From and To accounts must be different."))
return cleaned_data return cleaned_data
@@ -358,11 +386,14 @@ class InstallmentPlanForm(forms.ModelForm):
create_field="name", create_field="name",
required=False, required=False,
label=_("Tags"), label=_("Tags"),
queryset=TransactionTag.objects.filter(active=True),
) )
category = DynamicModelChoiceField( category = DynamicModelChoiceField(
create_field="name",
model=TransactionCategory, model=TransactionCategory,
required=False, required=False,
label=_("Category"), label=_("Category"),
queryset=TransactionCategory.objects.filter(active=True),
) )
entities = DynamicModelMultipleChoiceField( entities = DynamicModelMultipleChoiceField(
model=TransactionEntity, model=TransactionEntity,
@@ -370,6 +401,7 @@ class InstallmentPlanForm(forms.ModelForm):
create_field="name", create_field="name",
required=False, required=False,
label=_("Entities"), label=_("Entities"),
queryset=TransactionEntity.objects.filter(active=True),
) )
type = forms.ChoiceField(choices=Transaction.Type.choices) type = forms.ChoiceField(choices=Transaction.Type.choices)
reference_date = MonthYearFormField(label=_("Reference Date"), required=False) reference_date = MonthYearFormField(label=_("Reference Date"), required=False)
@@ -401,6 +433,24 @@ class InstallmentPlanForm(forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# if editing display non-archived items and it's own item even if it's archived
if self.instance.id:
self.fields["account"].queryset = Account.objects.filter(
Q(is_archived=False) | Q(installmentplan=self.instance.id)
).distinct()
self.fields["category"].queryset = TransactionCategory.objects.filter(
Q(active=True) | Q(installmentplan=self.instance.id)
).distinct()
self.fields["tags"].queryset = TransactionTag.objects.filter(
Q(active=True) | Q(installmentplan=self.instance.id)
).distinct()
self.fields["entities"].queryset = TransactionEntity.objects.filter(
Q(active=True) | Q(installmentplan=self.instance.id)
).distinct()
self.helper = FormHelper() self.helper = FormHelper()
self.helper.form_tag = False self.helper.form_tag = False
self.helper.form_method = "post" self.helper.form_method = "post"
@@ -470,7 +520,7 @@ class InstallmentPlanForm(forms.ModelForm):
class TransactionTagForm(forms.ModelForm): class TransactionTagForm(forms.ModelForm):
class Meta: class Meta:
model = TransactionTag model = TransactionTag
fields = ["name"] fields = ["name", "active"]
labels = {"name": _("Tag name")} labels = {"name": _("Tag name")}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -479,7 +529,7 @@ class TransactionTagForm(forms.ModelForm):
self.helper = FormHelper() self.helper = FormHelper()
self.helper.form_tag = False self.helper.form_tag = False
self.helper.form_method = "post" self.helper.form_method = "post"
self.helper.layout = Layout(Field("name", css_class="mb-3")) self.helper.layout = Layout(Field("name", css_class="mb-3"), Switch("active"))
if self.instance and self.instance.pk: if self.instance and self.instance.pk:
self.helper.layout.append( self.helper.layout.append(
@@ -502,7 +552,7 @@ class TransactionTagForm(forms.ModelForm):
class TransactionEntityForm(forms.ModelForm): class TransactionEntityForm(forms.ModelForm):
class Meta: class Meta:
model = TransactionEntity model = TransactionEntity
fields = ["name"] fields = ["name", "active"]
labels = {"name": _("Entity name")} labels = {"name": _("Entity name")}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -511,7 +561,7 @@ class TransactionEntityForm(forms.ModelForm):
self.helper = FormHelper() self.helper = FormHelper()
self.helper.form_tag = False self.helper.form_tag = False
self.helper.form_method = "post" self.helper.form_method = "post"
self.helper.layout = Layout(Field("name", css_class="mb-3")) self.helper.layout = Layout(Field("name"), Switch("active"))
if self.instance and self.instance.pk: if self.instance and self.instance.pk:
self.helper.layout.append( self.helper.layout.append(
@@ -534,7 +584,7 @@ class TransactionEntityForm(forms.ModelForm):
class TransactionCategoryForm(forms.ModelForm): class TransactionCategoryForm(forms.ModelForm):
class Meta: class Meta:
model = TransactionCategory model = TransactionCategory
fields = ["name", "mute"] fields = ["name", "mute", "active"]
labels = {"name": _("Category name")} labels = {"name": _("Category name")}
help_texts = { help_texts = {
"mute": _("Muted categories won't count towards your monthly total") "mute": _("Muted categories won't count towards your monthly total")
@@ -546,7 +596,7 @@ class TransactionCategoryForm(forms.ModelForm):
self.helper = FormHelper() self.helper = FormHelper()
self.helper.form_tag = False self.helper.form_tag = False
self.helper.form_method = "post" self.helper.form_method = "post"
self.helper.layout = Layout(Field("name", css_class="mb-3"), Switch("mute")) self.helper.layout = Layout(Field("name"), Switch("mute"), Switch("active"))
if self.instance and self.instance.pk: if self.instance and self.instance.pk:
self.helper.layout.append( self.helper.layout.append(
@@ -578,11 +628,14 @@ class RecurringTransactionForm(forms.ModelForm):
create_field="name", create_field="name",
required=False, required=False,
label=_("Tags"), label=_("Tags"),
queryset=TransactionTag.objects.filter(active=True),
) )
category = DynamicModelChoiceField( category = DynamicModelChoiceField(
create_field="name",
model=TransactionCategory, model=TransactionCategory,
required=False, required=False,
label=_("Category"), label=_("Category"),
queryset=TransactionCategory.objects.filter(active=True),
) )
entities = DynamicModelMultipleChoiceField( entities = DynamicModelMultipleChoiceField(
model=TransactionEntity, model=TransactionEntity,
@@ -590,6 +643,7 @@ class RecurringTransactionForm(forms.ModelForm):
create_field="name", create_field="name",
required=False, required=False,
label=_("Entities"), label=_("Entities"),
queryset=TransactionEntity.objects.filter(active=True),
) )
type = forms.ChoiceField(choices=Transaction.Type.choices) type = forms.ChoiceField(choices=Transaction.Type.choices)
reference_date = MonthYearFormField(label=_("Reference Date"), required=False) reference_date = MonthYearFormField(label=_("Reference Date"), required=False)
@@ -624,6 +678,25 @@ class RecurringTransactionForm(forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# if editing display non-archived items and it's own item even if it's archived
if self.instance.id:
self.fields["account"].queryset = Account.objects.filter(
Q(is_archived=False) | Q(recurringtransaction=self.instance.id)
).distinct()
self.fields["category"].queryset = TransactionCategory.objects.filter(
Q(active=True) | Q(recurringtransaction=self.instance.id)
).distinct()
self.fields["tags"].queryset = TransactionTag.objects.filter(
Q(active=True) | Q(recurringtransaction=self.instance.id)
).distinct()
self.fields["entities"].queryset = TransactionEntity.objects.filter(
Q(active=True) | Q(recurringtransaction=self.instance.id)
).distinct()
self.helper = FormHelper() self.helper = FormHelper()
self.helper.form_method = "post" self.helper.form_method = "post"
self.helper.form_tag = False self.helper.form_tag = False
@@ -694,5 +767,7 @@ class RecurringTransactionForm(forms.ModelForm):
instance = super().save(**kwargs) instance = super().save(**kwargs)
if is_new: if is_new:
instance.create_upcoming_transactions() instance.create_upcoming_transactions()
else:
instance.update_unpaid_transactions()
return instance return instance

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.1.3 on 2025-01-04 19:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('transactions', '0024_installmentplan_entities_and_more'),
]
operations = [
migrations.AddField(
model_name='transactioncategory',
name='active',
field=models.BooleanField(default=True, help_text="Deactivated categories won't be able to be selected when creating new transactions", verbose_name='Active'),
),
migrations.AddField(
model_name='transactiontag',
name='active',
field=models.BooleanField(default=True, help_text="Deactivated tags won't be able to be selected when creating new transactions", verbose_name='Active'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.3 on 2025-01-04 19:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('transactions', '0025_transactioncategory_active_transactiontag_active'),
]
operations = [
migrations.AddField(
model_name='transactionentity',
name='active',
field=models.BooleanField(default=True, help_text="Deactivated entities won't be able to be selected when creating new transactions", verbose_name='Active'),
),
]

View File

@@ -18,6 +18,13 @@ logger = logging.getLogger()
class TransactionCategory(models.Model): class TransactionCategory(models.Model):
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True) name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True)
mute = models.BooleanField(default=False, verbose_name=_("Mute")) mute = models.BooleanField(default=False, verbose_name=_("Mute"))
active = models.BooleanField(
default=True,
verbose_name=_("Active"),
help_text=_(
"Deactivated categories won't be able to be selected when creating new transactions"
),
)
class Meta: class Meta:
verbose_name = _("Transaction Category") verbose_name = _("Transaction Category")
@@ -30,6 +37,13 @@ class TransactionCategory(models.Model):
class TransactionTag(models.Model): class TransactionTag(models.Model):
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True) name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True)
active = models.BooleanField(
default=True,
verbose_name=_("Active"),
help_text=_(
"Deactivated tags won't be able to be selected when creating new transactions"
),
)
class Meta: class Meta:
verbose_name = _("Transaction Tags") verbose_name = _("Transaction Tags")
@@ -42,8 +56,13 @@ class TransactionTag(models.Model):
class TransactionEntity(models.Model): class TransactionEntity(models.Model):
name = models.CharField(max_length=255, verbose_name=_("Name")) name = models.CharField(max_length=255, verbose_name=_("Name"))
active = models.BooleanField(
# Add any other fields you might want for entities default=True,
verbose_name=_("Active"),
help_text=_(
"Deactivated entities won't be able to be selected when creating new transactions"
),
)
class Meta: class Meta:
verbose_name = _("Entity") verbose_name = _("Entity")
@@ -315,10 +334,15 @@ class InstallmentPlan(models.Model):
existing_transaction.type = self.type existing_transaction.type = self.type
existing_transaction.date = transaction_date existing_transaction.date = transaction_date
existing_transaction.reference_date = transaction_reference_date existing_transaction.reference_date = transaction_reference_date
existing_transaction.amount = self.installment_amount
existing_transaction.description = self.description existing_transaction.description = self.description
existing_transaction.category = self.category existing_transaction.category = self.category
existing_transaction.notes = self.notes existing_transaction.notes = self.notes
if (
not existing_transaction.is_paid
): # Don't update value for paid transactions
existing_transaction.amount = self.installment_amount
existing_transaction.save() existing_transaction.save()
# Update tags # Update tags
@@ -521,3 +545,33 @@ class RecurringTransaction(models.Model):
recurring_transaction.save( recurring_transaction.save(
update_fields=["last_generated_date", "last_generated_reference_date"] update_fields=["last_generated_date", "last_generated_reference_date"]
) )
def update_unpaid_transactions(self):
"""
Updates all unpaid transactions associated with this RecurringTransaction.
Only unpaid transactions (`is_paid=False`) are modified. Updates fields like
amount, description, category, notes, and many-to-many relationships (tags, entities).
"""
unpaid_transactions = self.transactions.filter(is_paid=False)
for existing_transaction in unpaid_transactions:
# Update fields based on RecurringTransaction
existing_transaction.amount = self.amount
existing_transaction.description = self.description
existing_transaction.category = self.category
existing_transaction.notes = self.notes
# Update many-to-many relationships
existing_transaction.tags.set(self.tags.all())
existing_transaction.entities.set(self.entities.all())
# Save updated transaction
existing_transaction.save()
def delete_unpaid_transactions(self):
"""
Deletes all unpaid transactions associated with this RecurringTransaction.
"""
today = timezone.localdate(timezone.now())
self.transactions.filter(is_paid=False, date__gt=today).delete()

View File

@@ -53,6 +53,8 @@ urlpatterns = [
), ),
path("tags/", views.tags_index, name="tags_index"), path("tags/", views.tags_index, name="tags_index"),
path("tags/list/", views.tags_list, name="tags_list"), path("tags/list/", views.tags_list, name="tags_list"),
path("tags/table/active/", views.tags_table_active, name="tags_table_active"),
path("tags/table/archived/", views.tags_table_archived, name="tags_table_archived"),
path("tags/add/", views.tag_add, name="tag_add"), path("tags/add/", views.tag_add, name="tag_add"),
path( path(
"tags/<int:tag_id>/edit/", "tags/<int:tag_id>/edit/",
@@ -66,6 +68,16 @@ urlpatterns = [
), ),
path("entities/", views.entities_index, name="entities_index"), path("entities/", views.entities_index, name="entities_index"),
path("entities/list/", views.entities_list, name="entities_list"), path("entities/list/", views.entities_list, name="entities_list"),
path(
"entities/table/active/",
views.entities_table_active,
name="entities_table_active",
),
path(
"entities/table/archived/",
views.entities_table_archived,
name="entities_table_archived",
),
path("entities/add/", views.entity_add, name="entity_add"), path("entities/add/", views.entity_add, name="entity_add"),
path( path(
"entities/<int:entity_id>/edit/", "entities/<int:entity_id>/edit/",
@@ -79,6 +91,16 @@ urlpatterns = [
), ),
path("categories/", views.categories_index, name="categories_index"), path("categories/", views.categories_index, name="categories_index"),
path("categories/list/", views.categories_list, name="categories_list"), path("categories/list/", views.categories_list, name="categories_list"),
path(
"categories/table/active/",
views.categories_table_active,
name="categories_table_active",
),
path(
"categories/table/archived/",
views.categories_table_archived,
name="categories_table_archived",
),
path("categories/add/", views.category_add, name="category_add"), path("categories/add/", views.category_add, name="category_add"),
path( path(
"categories/<int:category_id>/edit/", "categories/<int:category_id>/edit/",

View File

@@ -25,11 +25,33 @@ def categories_index(request):
@login_required @login_required
@require_http_methods(["GET"]) @require_http_methods(["GET"])
def categories_list(request): def categories_list(request):
categories = TransactionCategory.objects.all().order_by("id")
return render( return render(
request, request,
"categories/fragments/list.html", "categories/fragments/list.html",
{"categories": categories}, )
@only_htmx
@login_required
@require_http_methods(["GET"])
def categories_table_active(request):
categories = TransactionCategory.objects.filter(active=True).order_by("id")
return render(
request,
"categories/fragments/table.html",
{"categories": categories, "active": True},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def categories_table_archived(request):
categories = TransactionCategory.objects.filter(active=False).order_by("id")
return render(
request,
"categories/fragments/table.html",
{"categories": categories, "active": False},
) )

View File

@@ -24,11 +24,33 @@ def entities_index(request):
@login_required @login_required
@require_http_methods(["GET"]) @require_http_methods(["GET"])
def entities_list(request): def entities_list(request):
entities = TransactionEntity.objects.all().order_by("id")
return render( return render(
request, request,
"entities/fragments/list.html", "entities/fragments/list.html",
{"entities": entities}, )
@only_htmx
@login_required
@require_http_methods(["GET"])
def entities_table_active(request):
entities = TransactionEntity.objects.filter(active=True).order_by("id")
return render(
request,
"entities/fragments/table.html",
{"entities": entities, "active": True},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def entities_table_archived(request):
entities = TransactionEntity.objects.filter(active=False).order_by("id")
return render(
request,
"entities/fragments/table.html",
{"entities": entities, "active": False},
) )

View File

@@ -168,12 +168,26 @@ def recurring_transaction_toggle_pause(request, recurring_transaction_id):
) )
current_paused = recurring_transaction.is_paused current_paused = recurring_transaction.is_paused
recurring_transaction.is_paused = not current_paused recurring_transaction.is_paused = not current_paused
recurring_transaction.save(update_fields=["is_paused"])
if current_paused: if current_paused:
messages.success(request, _("Recurring transaction unpaused successfully")) today = timezone.localdate(timezone.now())
recurring_transaction.last_generated_date = max(
recurring_transaction.last_generated_date, today
)
recurring_transaction.last_generated_reference_date = max(
recurring_transaction.last_generated_reference_date, today
)
recurring_transaction.save(
update_fields=[
"last_generated_date",
"last_generated_reference_date",
"is_paused",
]
)
generate_recurring_transactions.defer() generate_recurring_transactions.defer()
messages.success(request, _("Recurring transaction unpaused successfully"))
else: else:
recurring_transaction.save(update_fields=["is_paused"])
messages.success(request, _("Recurring transaction paused successfully")) messages.success(request, _("Recurring transaction paused successfully"))
return HttpResponse( return HttpResponse(
@@ -188,7 +202,7 @@ def recurring_transaction_toggle_pause(request, recurring_transaction_id):
@login_required @login_required
@require_http_methods(["GET"]) @require_http_methods(["GET"])
def recurring_transaction_finish(request, recurring_transaction_id): def recurring_transaction_finish(request, recurring_transaction_id):
recurring_transaction = get_object_or_404( recurring_transaction: RecurringTransaction = get_object_or_404(
RecurringTransaction, id=recurring_transaction_id RecurringTransaction, id=recurring_transaction_id
) )
today = timezone.localdate(timezone.now()) - relativedelta(days=1) today = timezone.localdate(timezone.now()) - relativedelta(days=1)
@@ -197,6 +211,9 @@ def recurring_transaction_finish(request, recurring_transaction_id):
recurring_transaction.is_paused = True recurring_transaction.is_paused = True
recurring_transaction.save(update_fields=["end_date", "is_paused"]) recurring_transaction.save(update_fields=["end_date", "is_paused"])
# Delete all unpaid transactions associated with this RecurringTransaction
recurring_transaction.delete_unpaid_transactions()
messages.success(request, _("Recurring transaction finished successfully")) messages.success(request, _("Recurring transaction finished successfully"))
return HttpResponse( return HttpResponse(

View File

@@ -24,11 +24,33 @@ def tags_index(request):
@login_required @login_required
@require_http_methods(["GET"]) @require_http_methods(["GET"])
def tags_list(request): def tags_list(request):
tags = TransactionTag.objects.all().order_by("id")
return render( return render(
request, request,
"tags/fragments/list.html", "tags/fragments/list.html",
{"tags": tags}, )
@only_htmx
@login_required
@require_http_methods(["GET"])
def tags_table_active(request):
tags = TransactionTag.objects.filter(active=True).order_by("id")
return render(
request,
"tags/fragments/table.html",
{"tags": tags, "active": True},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def tags_table_archived(request):
tags = TransactionTag.objects.filter(active=False).order_by("id")
return render(
request,
"tags/fragments/table.html",
{"tags": tags, "active": False},
) )

File diff suppressed because it is too large Load Diff

View File

@@ -15,53 +15,18 @@
</div> </div>
<div class="card"> <div class="card">
<div class="card-body table-responsive"> <div class="card-header">
{% if categories %} <ul class="nav nav-pills card-header-pills" id="myTab" role="tablist">
<c-config.search></c-config.search> <li class="nav-item" role="presentation">
<table class="table table-hover"> <button class="nav-link active" data-bs-toggle="tab" type="button" role="tab" aria-selected="true" hx-get="{% url 'categories_table_active' %}" hx-trigger="load, click" hx-target="#categories-table">{% translate 'Active' %}</button>
<thead> </li>
<tr> <li class="nav-item" role="presentation">
<th scope="col" class="col-auto"></th> <button class="nav-link" hx-get="{% url 'categories_table_archived' %}" hx-target="#categories-table" data-bs-toggle="tab" type="button" role="tab" aria-selected="false">{% translate 'Archived' %}</button>
<th scope="col" class="col">{% translate 'Name' %}</th> </li>
<th scope="col" class="col">{% translate 'Muted' %}</th> </ul>
</tr> </div>
</thead> <div class="card-body">
<tbody> <div id="categories-table"></div>
{% for category in categories %}
<tr class="category">
<td class="col-auto text-center">
<div class="btn-group" role="group" aria-label="{% translate 'Actions' %}">
<a class="btn btn-secondary btn-sm"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'category_edit' category_id=category.id %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="btn btn-secondary btn-sm text-danger"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'category_delete' category_id=category.id %}"
hx-trigger='confirmed'
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a>
</div>
</td>
<td class="col">{{ category.name }}</td>
<td class="col">
{% if category.mute %}<i class="fa-solid fa-check text-success"></i>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<c-msg.empty title="{% translate "No categories" %}" remove-padding></c-msg.empty>
{% endif %}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,59 @@
{% load i18n %}
{% if active %}
<div class="show-loading" hx-get="{% url 'categories_table_active' %}" hx-trigger="updated from:window"
hx-swap="outerHTML">
{% else %}
<div class="show-loading" hx-get="{% url 'categories_table_archived' %}" hx-trigger="updated from:window"
hx-swap="outerHTML">
{% endif %}
{% if categories %}
<div class="table-responsive">
<c-config.search></c-config.search>
<table class="table table-hover">
<thead>
<tr>
<th scope="col" class="col-auto"></th>
<th scope="col" class="col">{% translate 'Name' %}</th>
<th scope="col" class="col">{% translate 'Muted' %}</th>
</tr>
</thead>
<tbody>
{% for category in categories %}
<tr class="category">
<td class="col-auto text-center">
<div class="btn-group" role="group" aria-label="{% translate 'Actions' %}">
<a class="btn btn-secondary btn-sm"
role="button"
data-bs-toggle="tooltip"
hx-swap="innerHTML"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'category_edit' category_id=category.id %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="btn btn-secondary btn-sm text-danger"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'category_delete' category_id=category.id %}"
hx-trigger='confirmed'
hx-swap="innerHTML"
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a>
</div>
</td>
<td class="col">{{ category.name }}</td>
<td class="col">
{% if category.mute %}<i class="fa-solid fa-check text-success"></i>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<c-msg.empty title="{% translate "No categories" %}" remove-padding></c-msg.empty>
{% endif %}
</div>

View File

@@ -4,5 +4,5 @@
{% block title %}{% translate 'Categories' %}{% endblock %} {% block title %}{% translate 'Categories' %}{% endblock %}
{% block content %} {% block content %}
<div hx-get="{% url 'categories_list' %}" hx-trigger="load, updated from:window" class="show-loading"></div> <div hx-get="{% url 'categories_list' %}" hx-trigger="load" class="show-loading"></div>
{% endblock %} {% endblock %}

View File

@@ -1,5 +1,5 @@
{% load i18n %} {% load i18n %}
<div class="transaction d-flex my-1"> <div class="transaction d-flex my-1 {% if transaction.type == "EX" %}expense{% else %}income{% endif %}">
{% if not disable_selection %} {% if not disable_selection %}
<label class="px-3 d-flex align-items-center justify-content-center"> <label class="px-3 d-flex align-items-center justify-content-center">
<input class="form-check-input" type="checkbox" name="transactions" value="{{ transaction.id }}" id="check-{{ transaction.id }}" aria-label="{% translate 'Select' %}" hx-preserve> <input class="form-check-input" type="checkbox" name="transactions" value="{{ transaction.id }}" id="check-{{ transaction.id }}" aria-label="{% translate 'Select' %}" hx-preserve>

View File

@@ -11,70 +11,228 @@
<div class="card slide-in-left"> <div class="card slide-in-left">
<div class="card-body p-2"> <div class="card-body p-2">
{% spaceless %} {% spaceless %}
<div class="btn-group" role="group"> <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>
</button>
</div>
<div class="vr mx-3 tw-align-middle"></div>
<div class="btn-group me-3" role="group">
<button class="btn btn-secondary btn-sm"
hx-get="{% url 'transactions_bulk_pay' %}"
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>
</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>
</div>
<button class="btn btn-secondary btn-sm" <button class="btn btn-secondary btn-sm"
data-bs-toggle="tooltip" hx-get="{% url 'transactions_bulk_delete' %}"
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>
</button>
</div>
<div class="vr mx-3 tw-align-middle"></div>
<div class="btn-group me-3" role="group">
<button class="btn btn-secondary btn-sm"
hx-get="{% url 'transactions_bulk_pay' %}"
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>
</button>
<button class="btn btn-secondary btn-sm"
hx-get="{% url 'transactions_bulk_unpay' %}"
hx-include=".transaction" hx-include=".transaction"
hx-trigger="confirmed"
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
data-bs-title="{% translate 'Mark as unpaid' %}"> data-bs-title="{% translate 'Delete' %}"
<i class="fa-regular fa-circle tw-text-red-400"></i> data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete them!" %}"
_="install prompt_swal">
<i class="fa-solid fa-trash text-danger"></i>
</button> </button>
</div> <div class="vr mx-3 tw-align-middle"></div>
<button class="btn btn-secondary btn-sm" <div class="btn-group"
hx-get="{% url 'transactions_bulk_delete' %}" _="on selected_transactions_updated from #actions-bar
hx-include=".transaction" set realTotal to math.bignumber(0)
hx-trigger="confirmed" set flatTotal to math.bignumber(0)
data-bs-toggle="tooltip" set transactions to <.transaction:has(input[name='transactions']:checked)/>
data-bs-title="{% translate 'Delete' %}" set flatAmountValues to []
data-bypass-on-ctrl="true" set realAmountValues to []
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this!" %}" for transaction in transactions
data-confirm-text="{% translate "Yes, delete them!" %}" set amt to first <.main-amount .amount/> in transaction
_="install prompt_swal"> set amountValue to parseFloat(amt.getAttribute('data-amount'))
<i class="fa-solid fa-trash text-danger"></i> append amountValue to flatAmountValues
</button>
<div class="vr mx-3 tw-align-middle"></div> if not isNaN(amountValue)
<span _="on selected_transactions_updated from #actions-bar set flatTotal to math.chain(flatTotal).add(amountValue)
set total to 0.0
for amt in <.transaction:has(input[name='transactions']:checked) .main-amount .amount/> if transaction match .income
set amountValue to parseFloat(amt.getAttribute('data-amount')) append amountValue to realAmountValues
if not isNaN(amountValue) set realTotal to math.chain(realTotal).add(amountValue)
set total to total + (amountValue * 100) else
append -amountValue to realAmountValues
set realTotal to math.chain(realTotal).subtract(amountValue)
end
end
end end
end
set total to total / 100 set mean to flatTotal.divide(flatAmountValues.length).done().toNumber()
put total.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into me set realTotal to realTotal.done().toNumber()
end set flatTotal to flatTotal.done().toNumber()
on click
set original_value to my innerText put realTotal.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #real-total-front's innerText
writeText(my innerText) on navigator.clipboard put realTotal.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-real-total's innerText
put '{% translate "copied!" %}' into me put flatTotal.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-flat-total's innerText
wait 1s put Math.max.apply(Math, realAmountValues).toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-max's innerText
put original_value into me put Math.min.apply(Math, realAmountValues).toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-min's innerText
end" put mean.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-mean's innerText
class="" role="button"></span> put flatAmountValues.length.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-count's innerText
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
put '{% translate "copied!" %}' into #real-total-front's innerText
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>
</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">
<span class="visually-hidden">{% trans "Toggle Dropdown" %}</span>
</button>
<ul class="dropdown-menu">
<li>
<div class="dropdown-item-text p-0">
<div>
<div class="text-body-secondary tw-text-xs tw-font-medium px-3">
{% trans "Flat Total" %}
</div>
<div class="dropdown-item px-3 tw-cursor-pointer"
id="calc-menu-flat-total"
_="on click
set original_value to my innerText
writeText(my innerText) on navigator.clipboard
put '{% translate "copied!" %}' into me
wait 1s
put original_value into me
end">
0
</div>
</div>
</div>
</li>
<li>
<div class="dropdown-item-text p-0">
<div>
<div class="text-body-secondary tw-text-xs tw-font-medium px-3">
{% trans "Real Total" %}
</div>
<div class="dropdown-item px-3 tw-cursor-pointer"
id="calc-menu-real-total"
_="on click
set original_value to my innerText
writeText(my innerText) on navigator.clipboard
put '{% translate "copied!" %}' into me
wait 1s
put original_value into me
end">
0
</div>
</div>
</div>
</li>
<li>
<div class="dropdown-item-text p-0">
<div>
<div class="text-body-secondary tw-text-xs tw-font-medium px-3">
{% trans "Mean" %}
</div>
<div class="dropdown-item px-3 tw-cursor-pointer"
id="calc-menu-mean"
_="on click
set original_value to my innerText
writeText(my innerText) on navigator.clipboard
put '{% translate "copied!" %}' into me
wait 1s
put original_value into me
end">
0
</div>
</div>
</div>
</li>
<li>
<div class="dropdown-item-text p-0">
<div>
<div class="text-body-secondary tw-text-xs tw-font-medium px-3">
{% trans "Max" %}
</div>
<div class="dropdown-item px-3 tw-cursor-pointer"
id="calc-menu-max"
_="on click
set original_value to my innerText
writeText(my innerText) on navigator.clipboard
put '{% translate "copied!" %}' into me
wait 1s
put original_value into me
end">
0
</div>
</div>
</div>
</li>
<li>
<div class="dropdown-item-text p-0">
<div>
<div class="text-body-secondary tw-text-xs tw-font-medium px-3">
{% trans "Min" %}
</div>
<div class="dropdown-item px-3 tw-cursor-pointer"
id="calc-menu-min"
_="on click
set original_value to my innerText
writeText(my innerText) on navigator.clipboard
put '{% translate "copied!" %}' into me
wait 1s
put original_value into me
end">
0
</div>
</div>
</div>
</li>
<li>
<div class="dropdown-item-text p-0">
<div>
<div class="text-body-secondary tw-text-xs tw-font-medium px-3">
{% trans "Count" %}
</div>
<div class="dropdown-item px-3 tw-cursor-pointer"
id="calc-menu-count"
_="on click
set original_value to my innerText
writeText(my innerText) on navigator.clipboard
put '{% translate "copied!" %}' into me
wait 1s
put original_value into me
end">
0
</div>
</div>
</div>
</li>
</ul>
</div>
{% endspaceless %} {% endspaceless %}
</div> </div>
</div> </div>

View File

@@ -15,49 +15,18 @@
</div> </div>
<div class="card"> <div class="card">
<div class="card-body table-responsive"> <div class="card-header">
{% if entities %} <ul class="nav nav-pills card-header-pills" id="myTab" role="tablist">
<c-config.search></c-config.search> <li class="nav-item" role="presentation">
<table class="table table-hover"> <button class="nav-link active" data-bs-toggle="tab" type="button" role="tab" aria-selected="true" hx-get="{% url 'entities_table_active' %}" hx-trigger="load, click" hx-target="#entities-table">{% translate 'Active' %}</button>
<thead> </li>
<tr> <li class="nav-item" role="presentation">
<th scope="col" class="col-auto"></th> <button class="nav-link" hx-get="{% url 'entities_table_archived' %}" hx-target="#entities-table" data-bs-toggle="tab" type="button" role="tab" aria-selected="false">{% translate 'Archived' %}</button>
<th scope="col" class="col">{% translate 'Name' %}</th> </li>
</tr> </ul>
</thead> </div>
<tbody> <div class="card-body">
{% for entity in entities %} <div id="entities-table"></div>
<tr class="entity">
<td class="col-auto">
<div class="btn-group" role="group" aria-label="{% translate 'Actions' %}">
<a class="btn btn-secondary btn-sm"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'entity_edit' entity_id=entity.id %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="btn btn-secondary btn-sm text-danger"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'entity_delete' entity_id=entity.id %}"
hx-trigger='confirmed'
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a>
</div>
</td>
<td class="col">{{ entity.name }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<c-msg.empty title="{% translate "No entities" %}" remove-padding></c-msg.empty>
{% endif %}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,55 @@
{% load i18n %}
{% if active %}
<div class="show-loading" hx-get="{% url 'entities_table_active' %}" hx-trigger="updated from:window"
hx-swap="outerHTML">
{% else %}
<div class="show-loading" hx-get="{% url 'entities_table_archived' %}" hx-trigger="updated from:window"
hx-swap="outerHTML">
{% endif %}
{% if entities %}
<div class="table-responsive">
<c-config.search></c-config.search>
<table class="table table-hover">
<thead>
<tr>
<th scope="col" class="col-auto"></th>
<th scope="col" class="col">{% translate 'Name' %}</th>
</tr>
</thead>
<tbody>
{% for entity in entities %}
<tr class="entity">
<td class="col-auto">
<div class="btn-group" role="group" aria-label="{% translate 'Actions' %}">
<a class="btn btn-secondary btn-sm"
role="button"
hx-swap="innerHTML"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'entity_edit' entity_id=entity.id %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="btn btn-secondary btn-sm text-danger"
role="button"
hx-swap="innerHTML"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'entity_delete' entity_id=entity.id %}"
hx-trigger='confirmed'
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a>
</div>
</td>
<td class="col">{{ entity.name }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<c-msg.empty title="{% translate "No entities" %}" remove-padding></c-msg.empty>
{% endif %}
</div>

View File

@@ -4,5 +4,5 @@
{% block title %}{% translate 'Entities' %}{% endblock %} {% block title %}{% translate 'Entities' %}{% endblock %}
{% block content %} {% block content %}
<div hx-get="{% url 'entities_list' %}" hx-trigger="load, updated from:window" class="show-loading"></div> <div hx-get="{% url 'entities_list' %}" hx-trigger="load" class="show-loading"></div>
{% endblock %} {% endblock %}

View File

@@ -66,11 +66,10 @@
}) })
end end
then set expr to it then set expr to it
then call math.evaluate(expr) then call math.evaluate(expr).toNumber()
if result exists and result is a Number if result exists and result is a Number
js(result) js(result)
return result.toString().replace(new RegExp(',|\\.', 'g'), return result.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40})
match => match === '.' ? window.decimalSeparator : window.argSeparator)
end end
then set localizedResult to it then set localizedResult to it
set #calculator-result.innerText to localizedResult set #calculator-result.innerText to localizedResult

View File

@@ -13,7 +13,7 @@
<div>{% translate 'Currency Converter' %}</div> <div>{% translate 'Currency Converter' %}</div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-5"> <div class="col-12 col-lg-5">
<div> <div>
<input class="form-control form-control-lg mb-3" <input class="form-control form-control-lg mb-3"
type="text" type="text"
@@ -25,13 +25,14 @@
</div> </div>
<div>{{ form.from_currency|as_crispy_field }}</div> <div>{{ form.from_currency|as_crispy_field }}</div>
</div> </div>
<div class="col text-primary tw-flex tw-items-center tw-justify-center"> <div class="col text-primary tw-flex tw-items-center tw-justify-center my-3 my-lg-0">
<i class="fa-solid fa-equals"></i> <i class="fa-solid fa-equals"></i>
</div> </div>
<div class="col-5"> <div class="col-12 col-lg-5">
<div hx-get="{% url 'currency_converter_convert' %}" <div hx-get="{% url 'currency_converter_convert' %}"
hx-trigger="input from:#from_value, input from:#id_from_currency, input from:#id_to_currency" hx-trigger="input from:#from_value, input from:#id_from_currency, input from:#id_to_currency, updated"
hx-include="#from_value, #id_from_currency, #id_to_currency"> hx-include="#from_value, #id_from_currency, #id_to_currency"
id="result">
<input class="form-control form-control-lg mb-3" <input class="form-control form-control-lg mb-3"
type="text" type="text"
name="to_value" name="to_value"
@@ -41,5 +42,19 @@
<div>{{ form.to_currency|as_crispy_field }}</div> <div>{{ form.to_currency|as_crispy_field }}</div>
</div> </div>
</div> </div>
<div class="row">
<div class="tw-cursor-pointer text-primary text-end"
_="on click
set from_value to #id_from_currency's value
set to_value to #id_to_currency's value
set #id_from_currency's value to to_value
set #id_to_currency's value to from_value
call #id_from_currency.tomselect.sync()
call #id_to_currency.tomselect.sync()
trigger updated on #result
end">
<i class="fa-solid fa-rotate me-2"></i><span>{% trans 'Invert' %}</span>
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -9,7 +9,7 @@
{% block title %}{% if type == "current" %}{% translate 'Current Net Worth' %}{% else %}{% translate 'Projected Net Worth' %}{% endif %}{% endblock %} {% block title %}{% if type == "current" %}{% translate 'Current Net Worth' %}{% else %}{% translate 'Projected Net Worth' %}{% endif %}{% endblock %}
{% block content %} {% block content %}
<div class="container px-md-3 py-3"> <div class="container px-md-3 py-3" _="init call initializeAccountChart() then initializeCurrencyChart() end">
<div class="row gx-xl-4 gy-3 mb-4"> <div class="row gx-xl-4 gy-3 mb-4">
<div class="col-12 col-xl-5"> <div class="col-12 col-xl-5">
<div class="row row-cols-1 g-4"> <div class="row row-cols-1 g-4">
@@ -52,7 +52,7 @@
</div> </div>
</div> </div>
<div class="col-12 col-xl-7"> <div class="col-12 col-xl-7">
<div class="chart-container position-relativo tw-min-h-[40vh] tw-h-full"> <div class="chart-container position-relative tw-min-h-[40vh] tw-h-full">
<canvas id="currencyBalanceChart"></canvas> <canvas id="currencyBalanceChart"></canvas>
</div> </div>
</div> </div>
@@ -136,7 +136,7 @@
</div> </div>
</div> </div>
<div class="col-12 col-xl-7"> <div class="col-12 col-xl-7">
<div class="chart-container position-relativo tw-min-h-[40vh] tw-h-full"> <div class="chart-container position-relative tw-min-h-[40vh] tw-h-full">
<canvas id="accountBalanceChart"></canvas> <canvas id="accountBalanceChart"></canvas>
</div> </div>
</div> </div>
@@ -144,13 +144,19 @@
</div> </div>
<script> <script>
document.body.addEventListener('htmx:load', function (evt) { var currencyChart;
function initializeCurrencyChart() {
// Destroy existing chart if it exists
if (currencyChart) {
currencyChart.destroy();
}
var chartData = JSON.parse('{{ chart_data_currency_json|safe }}'); var chartData = JSON.parse('{{ chart_data_currency_json|safe }}');
var currencies = {{ currencies|safe }}; var currencies = {{ currencies|safe }};
var ctx = document.getElementById('currencyBalanceChart').getContext('2d'); var ctx = document.getElementById('currencyBalanceChart').getContext('2d');
new Chart(ctx, { currencyChart = new Chart(ctx, {
type: 'line', type: 'line',
data: chartData, data: chartData,
options: { options: {
@@ -197,17 +203,23 @@
} }
} }
}); });
}); }
</script> </script>
<script> <script id="accountBalanceChartScript">
document.body.addEventListener('htmx:load', function (evt) { var accountChart;
function initializeAccountChart() {
// Destroy existing chart if it exists
if (accountChart) {
accountChart.destroy();
}
var chartData = JSON.parse('{{ chart_data_accounts_json|safe }}'); var chartData = JSON.parse('{{ chart_data_accounts_json|safe }}');
var accounts = {{ accounts|safe }}; var accounts = {{ accounts|safe }};
var ctx = document.getElementById('accountBalanceChart').getContext('2d'); var ctx = document.getElementById('accountBalanceChart').getContext('2d');
new Chart(ctx, { accountChart = new Chart(ctx, {
type: 'line', type: 'line',
data: chartData, data: chartData,
options: { options: {
@@ -256,43 +268,38 @@
} }
} }
}); });
}); }
</script> </script>
<script type="text/hyperscript"> <script type="text/hyperscript">
def showOnlyAccountDataset(datasetName) def showOnlyAccountDataset(datasetName)
set chart to Chart.getChart('accountBalanceChart') for dataset in accountChart.data.datasets
for dataset in chart.data.datasets
set isMatch to dataset.label is datasetName set isMatch to dataset.label is datasetName
call chart.setDatasetVisibility(chart.data.datasets.indexOf(dataset), isMatch) call accountChart.setDatasetVisibility(accountChart.data.datasets.indexOf(dataset), isMatch)
end end
call chart.update() call accountChart.update()
end end
def showOnlyCurrencyDataset(datasetName) def showOnlyCurrencyDataset(datasetName)
set chart to Chart.getChart('currencyBalanceChart') for dataset in currencyChart.data.datasets
for dataset in chart.data.datasets
set isMatch to dataset.label is datasetName set isMatch to dataset.label is datasetName
call chart.setDatasetVisibility(chart.data.datasets.indexOf(dataset), isMatch) call currencyChart.setDatasetVisibility(currencyChart.data.datasets.indexOf(dataset), isMatch)
end end
call chart.update() call currencyChart.update()
end end
def showAllDatasetsAccount() def showAllDatasetsAccount()
set chart to Chart.getChart('accountBalanceChart') for dataset in accountChart.data.datasets
for dataset in chart.data.datasets call accountChart.setDatasetVisibility(accountChart.data.datasets.indexOf(dataset), true)
call chart.setDatasetVisibility(chart.data.datasets.indexOf(dataset), true)
end end
call chart.update() call accountChart.update()
end end
def showAllDatasetsCurrency() def showAllDatasetsCurrency()
set chart to Chart.getChart('currencyBalanceChart') for dataset in currencyChart.data.datasets
for dataset in chart.data.datasets call currencyChart.setDatasetVisibility(currencyChart.data.datasets.indexOf(dataset), true)
call chart.setDatasetVisibility(chart.data.datasets.indexOf(dataset), true)
end end
call chart.update() call currencyChart.update()
end end
</script> </script>

View File

@@ -80,7 +80,7 @@
hx-swap="innerHTML" hx-swap="innerHTML"
data-bypass-on-ctrl="true" data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}" data-title="{% translate "Are you sure?" %}"
data-text="{% translate "This will stop the creation of new transactions" %}" data-text="{% translate "This will stop the creation of new transactions and delete any unpaid transactions after today" %}"
data-confirm-text="{% translate "Yes, finish it!" %}" data-confirm-text="{% translate "Yes, finish it!" %}"
_="install prompt_swal"> _="install prompt_swal">
<i class="fa-solid fa-flag-checkered fa-fw"></i></a> <i class="fa-solid fa-flag-checkered fa-fw"></i></a>

View File

@@ -15,49 +15,18 @@
</div> </div>
<div class="card"> <div class="card">
<div class="card-body table-responsive"> <div class="card-header">
{% if tags %} <ul class="nav nav-pills card-header-pills" id="myTab" role="tablist">
<c-config.search></c-config.search> <li class="nav-item" role="presentation">
<table class="table table-hover"> <button class="nav-link active" data-bs-toggle="tab" type="button" role="tab" aria-selected="true" hx-get="{% url 'tags_table_active' %}" hx-trigger="load, click" hx-target="#tags-table">{% translate 'Active' %}</button>
<thead> </li>
<tr> <li class="nav-item" role="presentation">
<th scope="col" class="col-auto"></th> <button class="nav-link" hx-get="{% url 'tags_table_archived' %}" hx-target="#tags-table" data-bs-toggle="tab" type="button" role="tab" aria-selected="false">{% translate 'Archived' %}</button>
<th scope="col" class="col">{% translate 'Name' %}</th> </li>
</tr> </ul>
</thead> </div>
<tbody> <div class="card-body">
{% for tag in tags %} <div id="tags-table"></div>
<tr class="tag">
<td class="col-auto">
<div class="btn-group" role="group" aria-label="{% translate 'Actions' %}">
<a class="btn btn-secondary btn-sm"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'tag_edit' tag_id=tag.id %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="btn btn-secondary btn-sm text-danger"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'tag_delete' tag_id=tag.id %}"
hx-trigger='confirmed'
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a>
</div>
</td>
<td class="col">{{ tag.name }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<c-msg.empty title="{% translate "No tags" %}" remove-padding></c-msg.empty>
{% endif %}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,55 @@
{% load i18n %}
{% if active %}
<div class="show-loading" hx-get="{% url 'tags_table_active' %}" hx-trigger="updated from:window"
hx-swap="outerHTML">
{% else %}
<div class="show-loading" hx-get="{% url 'tags_table_archived' %}" hx-trigger="updated from:window"
hx-swap="outerHTML">
{% endif %}
{% if tags %}
<div class="table-responsive">
<c-config.search></c-config.search>
<table class="table table-hover">
<thead>
<tr>
<th scope="col" class="col-auto"></th>
<th scope="col" class="col">{% translate 'Name' %}</th>
</tr>
</thead>
<tbody>
{% for tag in tags %}
<tr class="tag">
<td class="col-auto">
<div class="btn-group" role="group" aria-label="{% translate 'Actions' %}">
<a class="btn btn-secondary btn-sm"
role="button"
hx-swap="innerHTML"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'tag_edit' tag_id=tag.id %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="btn btn-secondary btn-sm text-danger"
role="button"
data-bs-toggle="tooltip"
hx-swap="innerHTML"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'tag_delete' tag_id=tag.id %}"
hx-trigger='confirmed'
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a>
</div>
</td>
<td class="col">{{ tag.name }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<c-msg.empty title="{% translate "No tags" %}" remove-padding></c-msg.empty>
{% endif %}
</div>

View File

@@ -4,5 +4,5 @@
{% block title %}{% translate 'Tags' %}{% endblock %} {% block title %}{% translate 'Tags' %}{% endblock %}
{% block content %} {% block content %}
<div hx-get="{% url 'tags_list' %}" hx-trigger="load, updated from:window" class="show-loading"></div> <div hx-get="{% url 'tags_list' %}" hx-trigger="load" class="show-loading"></div>
{% endblock %} {% endblock %}

View File

@@ -1,9 +1,6 @@
services: services:
web: &django web: &django
build: image: eitchtee/wygiwyh:latest
context: .
dockerfile: ./docker/prod/django/Dockerfile
image: ${SERVER_NAME}
container_name: ${SERVER_NAME} container_name: ${SERVER_NAME}
command: /start command: /start
ports: ports:
@@ -27,7 +24,6 @@ services:
procrastinate: procrastinate:
<<: *django <<: *django
image: ${PROCRASTINATE_NAME}
container_name: ${PROCRASTINATE_NAME} container_name: ${PROCRASTINATE_NAME}
depends_on: depends_on:
- db - db

View File

@@ -6,16 +6,16 @@ RUN apt-get update && apt-get install --no-install-recommends -y \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY ./requirements.txt . COPY ./requirements.txt .
RUN pip wheel --wheel-dir /usr/src/app/wheels -r requirements.txt RUN --mount=type=cache,target=/root/.cache/pip \
pip wheel --wheel-dir /usr/src/app/wheels -r requirements.txt
FROM node:lts-alpine AS webpack_build FROM node:lts-alpine AS webpack_build
WORKDIR /usr/src/frontend WORKDIR /usr/src/frontend
COPY ./frontend . COPY ./frontend .
COPY ./app/templates /usr/src/app/templates COPY ./app/templates /usr/src/app/templates
RUN npm config set registry https://registry.npmmirror.com/ && \ RUN --mount=type=cache,target=/root/.npm \
npm install --verbose && \ npm install --verbose && \
npm run build && \ npm run build
npm cache clean --force
FROM python:3.11-slim-buster AS python-run-stage FROM python:3.11-slim-buster AS python-run-stage
COPY --from=webpack_build /usr/src/frontend/build /usr/src/frontend/build COPY --from=webpack_build /usr/src/frontend/build /usr/src/frontend/build
@@ -29,12 +29,13 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 PYTHONUNBUFFERED=1
COPY --from=python-build-stage /usr/src/app/wheels /wheels/ COPY --from=python-build-stage /usr/src/app/wheels /wheels/
RUN apt-get update && \ RUN --mount=type=cache,target=/root/.cache/apt \
apt-get update && \
apt-get install --no-install-recommends -y gettext && \ apt-get install --no-install-recommends -y gettext && \
rm -rf /var/lib/apt/lists/* && \ rm -rf /var/lib/apt/lists/* && \
pip install --upgrade pip && \ pip install --upgrade pip && \
pip install --no-cache-dir --no-index --find-links=/wheels/ /wheels/* && \ pip install --no-cache-dir --no-index --find-links=/wheels/ /wheels/* && \
rm -rf /wheels/ ~/.cache/pip/* rm -rf /wheels/
COPY --chown=app:app ./docker/prod/django/start /start COPY --chown=app:app ./docker/prod/django/start /start
COPY --chown=app:app ./docker/prod/procrastinate/start /start-procrastinate COPY --chown=app:app ./docker/prod/procrastinate/start /start-procrastinate

View File

@@ -3441,9 +3441,9 @@
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
}, },
"node_modules/cookie": { "node_modules/cookie": {
"version": "0.6.0", "version": "0.7.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 0.6"
} }
@@ -4499,16 +4499,16 @@
} }
}, },
"node_modules/express": { "node_modules/express": {
"version": "4.21.0", "version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"dependencies": { "dependencies": {
"accepts": "~1.3.8", "accepts": "~1.3.8",
"array-flatten": "1.1.1", "array-flatten": "1.1.1",
"body-parser": "1.20.3", "body-parser": "1.20.3",
"content-disposition": "0.5.4", "content-disposition": "0.5.4",
"content-type": "~1.0.4", "content-type": "~1.0.4",
"cookie": "0.6.0", "cookie": "0.7.1",
"cookie-signature": "1.0.6", "cookie-signature": "1.0.6",
"debug": "2.6.9", "debug": "2.6.9",
"depd": "2.0.0", "depd": "2.0.0",
@@ -4522,7 +4522,7 @@
"methods": "~1.1.2", "methods": "~1.1.2",
"on-finished": "2.4.1", "on-finished": "2.4.1",
"parseurl": "~1.3.3", "parseurl": "~1.3.3",
"path-to-regexp": "0.1.10", "path-to-regexp": "0.1.12",
"proxy-addr": "~2.0.7", "proxy-addr": "~2.0.7",
"qs": "6.13.0", "qs": "6.13.0",
"range-parser": "~1.2.1", "range-parser": "~1.2.1",
@@ -4537,6 +4537,10 @@
}, },
"engines": { "engines": {
"node": ">= 0.10.0" "node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/express/node_modules/debug": { "node_modules/express/node_modules/debug": {
@@ -5219,9 +5223,9 @@
} }
}, },
"node_modules/http-proxy-middleware": { "node_modules/http-proxy-middleware": {
"version": "2.0.6", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz", "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.7.tgz",
"integrity": "sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==", "integrity": "sha512-fgVY8AV7qU7z/MmXJ/rxwbrtQH4jBQ9m7kp3llF0liB7glmFeVZFBepQb32T3y8n8k2+AEYuMPCpinYW+/CuRA==",
"dependencies": { "dependencies": {
"@types/http-proxy": "^1.17.8", "@types/http-proxy": "^1.17.8",
"http-proxy": "^1.18.1", "http-proxy": "^1.18.1",
@@ -6179,9 +6183,9 @@
} }
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.7", "version": "3.3.8",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -6535,9 +6539,9 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
}, },
"node_modules/path-to-regexp": { "node_modules/path-to-regexp": {
"version": "0.1.10", "version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
}, },
"node_modules/path-type": { "node_modules/path-type": {
"version": "4.0.0", "version": "4.0.0",

View File

@@ -2,18 +2,23 @@ import _hyperscript from 'hyperscript.org/dist/_hyperscript.min';
import './_htmx.js'; import './_htmx.js';
import Alpine from "alpinejs"; import Alpine from "alpinejs";
import mask from '@alpinejs/mask'; import mask from '@alpinejs/mask';
import { create, all } from 'mathjs'; import {create, all} from 'mathjs';
window.Alpine = Alpine; window.Alpine = Alpine;
window._hyperscript = _hyperscript; window._hyperscript = _hyperscript;
window.math = create(all, { }); window.math = create(all, {
number: 'BigNumber', // Default type of number:
// 'number' (default), 'BigNumber', or 'Fraction'
precision: 64, // Number of significant digits for BigNumbers
relTol: 1e-60,
absTol: 1e-63
});
Alpine.plugin(mask); Alpine.plugin(mask);
Alpine.start(); Alpine.start();
_hyperscript.browserInit(); _hyperscript.browserInit();
const successAudio = new Audio("/static/sounds/success.mp3"); const successAudio = new Audio("/static/sounds/success.mp3");
const popAudio = new Audio("/static/sounds/pop.mp3"); const popAudio = new Audio("/static/sounds/pop.mp3");
window.paidSound = successAudio; window.paidSound = successAudio;