mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-03-29 21:52:09 +02:00
Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
011e926e02 | ||
|
|
cd1b872b27 | ||
|
|
3791edce63 | ||
|
|
2cb8100129 | ||
|
|
e7e4ccafb6 | ||
|
|
afbbf7b25d | ||
|
|
1eba2b8731 | ||
|
|
afe366c359 | ||
|
|
3ee2bebc5c | ||
|
|
b951e5f069 | ||
|
|
4005a83a0d | ||
|
|
f81f1d83fd | ||
|
|
7816d6c55d | ||
|
|
6e3fdae4fe | ||
|
|
e2da996217 | ||
|
|
cc2e2293ed | ||
|
|
7060f07ccd | ||
|
|
0adb991879 | ||
|
|
20e03df661 | ||
|
|
71f59bfd68 | ||
|
|
6c76535f91 | ||
|
|
5c8fbc9278 | ||
|
|
89b11421c2 | ||
|
|
056fc4fced | ||
|
|
3f9765ec7b | ||
|
|
0d9d13bf31 | ||
|
|
2f6c396eaf | ||
|
|
d12b920e54 | ||
|
|
9edbf7bd5a | ||
|
|
dbd3eea29a | ||
|
|
881fed1895 | ||
|
|
10a0ac42a2 | ||
|
|
1b47c12a22 | ||
|
|
091f73bf8d | ||
|
|
73fe17de64 | ||
|
|
52af1b2260 | ||
|
|
8efa087aee | ||
|
|
6f69f15474 | ||
|
|
905e80cffe | ||
|
|
baae6bb96a | ||
|
|
f5132e24bd | ||
|
|
41303f39a0 | ||
|
|
0fc8b0ee49 | ||
|
|
037014d024 | ||
|
|
8c5a9efe05 | ||
|
|
f940414b5c | ||
|
|
2d8e97a27e | ||
|
|
5ccb9ff152 | ||
|
|
3c0a2d82ac | ||
|
|
62f049cbb2 | ||
|
|
7a759be357 | ||
|
|
6297e73307 | ||
|
|
eb753bb30e | ||
|
|
1047fb23dd | ||
|
|
c861b9ae07 | ||
|
|
4be849f5de | ||
|
|
3e73332a93 | ||
|
|
ae2217e760 | ||
|
|
e2bf699be0 | ||
|
|
e760d42c2d | ||
|
|
d541b30280 | ||
|
|
366c0b475d | ||
|
|
8576b74aff | ||
|
|
d4b11bd350 | ||
|
|
c8c34c2c56 | ||
|
|
023ceb898f | ||
|
|
1243dddd5d | ||
|
|
8661fb39e8 | ||
|
|
5752606fec | ||
|
|
7250ce0dbb | ||
|
|
b963a3cfb8 | ||
|
|
1f14eb011f | ||
|
|
265af71ac5 | ||
|
|
4c003d4456 | ||
|
|
d66a2e2856 | ||
|
|
74bf6a655d | ||
|
|
114cf2622e |
34
.github/workflows/release.yml
vendored
34
.github/workflows/release.yml
vendored
@@ -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
|
||||||
|
|||||||
21
README.md
21
README.md
@@ -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
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 *
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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__"
|
||||||
|
|||||||
85
app/apps/api/serializers/dca.py
Normal file
85
app/apps/api/serializers/dca.py
Normal 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"]
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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
41
app/apps/api/views/dca.py
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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/",
|
||||||
|
|||||||
@@ -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},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
59
app/templates/categories/fragments/table.html
Normal file
59
app/templates/categories/fragments/table.html
Normal 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>
|
||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
55
app/templates/entities/fragments/table.html
Normal file
55
app/templates/entities/fragments/table.html
Normal 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>
|
||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
55
app/templates/tags/fragments/table.html
Normal file
55
app/templates/tags/fragments/table.html
Normal 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>
|
||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
38
frontend/package-lock.json
generated
38
frontend/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user