mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-01-14 21:23:29 +01:00
Compare commits
232 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19c92e0014 | ||
|
|
6459f2eb46 | ||
|
|
7926e081ef | ||
|
|
ceefe7075f | ||
|
|
ad3230fd83 | ||
|
|
c89b07ed93 | ||
|
|
201ccea842 | ||
|
|
32ada488b4 | ||
|
|
794d11a355 | ||
|
|
67f8f5fe89 | ||
|
|
9ac69fd92a | ||
|
|
069f1b450c | ||
|
|
2f388af928 | ||
|
|
beeb0579ce | ||
|
|
a8666da57b | ||
|
|
835316d0f3 | ||
|
|
f5feeb9617 | ||
|
|
09e380a480 | ||
|
|
3080df9b66 | ||
|
|
ebc41a8049 | ||
|
|
635628e30e | ||
|
|
819a58ac06 | ||
|
|
d433375522 | ||
|
|
c0150f71a8 | ||
|
|
6119698d38 | ||
|
|
f5ae231601 | ||
|
|
972d23abbd | ||
|
|
9a514a8a69 | ||
|
|
7325231548 | ||
|
|
570657371a | ||
|
|
67da60b5b0 | ||
|
|
84c047c5ab | ||
|
|
23f5d09bec | ||
|
|
2a19075e23 | ||
|
|
7f231175b2 | ||
|
|
062e84f864 | ||
|
|
5521eb20bf | ||
|
|
627b5d250b | ||
|
|
195a8a68d6 | ||
|
|
daf1f68b82 | ||
|
|
dd24fd56d3 | ||
|
|
7a2acb6497 | ||
|
|
9c339faa72 | ||
|
|
02376ad02b | ||
|
|
b53a4a0286 | ||
|
|
a1f618434b | ||
|
|
7b5be29f0d | ||
|
|
56a73b181a | ||
|
|
865618e054 | ||
|
|
9e912b2736 | ||
|
|
da7680e70f | ||
|
|
ab594eb511 | ||
|
|
cffaaa369a | ||
|
|
5f414e82ee | ||
|
|
f3bcef534e | ||
|
|
d140ff5b70 | ||
|
|
7eceacfe68 | ||
|
|
038438fba7 | ||
|
|
ee98a5ef12 | ||
|
|
28b12faaf0 | ||
|
|
d0f2742637 | ||
|
|
9c55dac866 | ||
|
|
e6d8b548b7 | ||
|
|
4f8c2215c1 | ||
|
|
851b34f07a | ||
|
|
546ed5c6af | ||
|
|
04ae7337f5 | ||
|
|
a3a8791e96 | ||
|
|
63069f0ec9 | ||
|
|
32b522dad2 | ||
|
|
0c20a079e3 | ||
|
|
7c9697f683 | ||
|
|
15d04230ae | ||
|
|
ecc09ca6a6 | ||
|
|
cd753c5dd5 | ||
|
|
a3b9952f80 | ||
|
|
e93969c035 | ||
|
|
6ec5b5df1e | ||
|
|
93e7adeea8 | ||
|
|
37b5a43c1f | ||
|
|
87a07c25d1 | ||
|
|
9e27fef5e5 | ||
|
|
2cbba53e06 | ||
|
|
d9e8be7efb | ||
|
|
7dc9ef9950 | ||
|
|
00e83cf6a2 | ||
|
|
039242b48a | ||
|
|
94e2bdf93d | ||
|
|
79b387ce60 | ||
|
|
43eb87d3ba | ||
|
|
0110220b72 | ||
|
|
f5c86f3d97 | ||
|
|
7b7f58d34d | ||
|
|
86112931d9 | ||
|
|
e6e0e4caea | ||
|
|
942154480e | ||
|
|
467131d9f1 | ||
|
|
fee1db8660 | ||
|
|
4f7fc1c9c8 | ||
|
|
f788709f97 | ||
|
|
1a0de32ef8 | ||
|
|
8315adeb4a | ||
|
|
5296820d46 | ||
|
|
d5f5053821 | ||
|
|
852ffd5634 | ||
|
|
8cb3f51ea4 | ||
|
|
62bfaaa62a | ||
|
|
dd1d4292d3 | ||
|
|
93bb34166e | ||
|
|
8f311d9924 | ||
|
|
a5a9f838f5 | ||
|
|
6c17b3babb | ||
|
|
d207760ae9 | ||
|
|
996e0ee0eb | ||
|
|
80edf557cb | ||
|
|
2f3207b1f6 | ||
|
|
7b95c806fb | ||
|
|
06e9383689 | ||
|
|
56862cd025 | ||
|
|
35782cf14c | ||
|
|
f7768c8658 | ||
|
|
7f8fe6a516 | ||
|
|
aa8abe0e1c | ||
|
|
3190f3ae09 | ||
|
|
757f6647da | ||
|
|
6721d9dfee | ||
|
|
9705441e2d | ||
|
|
7123aefad0 | ||
|
|
712f5f428e | ||
|
|
a2e97b4ba2 | ||
|
|
60a694635b | ||
|
|
877816b649 | ||
|
|
0a3e47819a | ||
|
|
f9d299cb78 | ||
|
|
52934124c1 | ||
|
|
39c1f634b6 | ||
|
|
fee5b93cea | ||
|
|
a7d8f94412 | ||
|
|
44b87da423 | ||
|
|
85794f5c01 | ||
|
|
f246d115e2 | ||
|
|
aae85ecf94 | ||
|
|
ec911c0085 | ||
|
|
7b77f6f363 | ||
|
|
239e9c4b2a | ||
|
|
5abd0b8d3c | ||
|
|
320217f64a | ||
|
|
2735906d5e | ||
|
|
1f03edcc2e | ||
|
|
1405976292 | ||
|
|
6a06d0ee88 | ||
|
|
49c17f75b4 | ||
|
|
2ff6d69fac | ||
|
|
3023f33d3d | ||
|
|
b5671fcd0e | ||
|
|
48408cead8 | ||
|
|
cd7ecd42ea | ||
|
|
0b83ad6b3e | ||
|
|
d0ef08252e | ||
|
|
1140d9c896 | ||
|
|
b2843a1ec1 | ||
|
|
d25aba7be9 | ||
|
|
c3eaca3e9a | ||
|
|
5677706452 | ||
|
|
5bf7f9f272 | ||
|
|
448841dadc | ||
|
|
1b6934694e | ||
|
|
d4d00ba02f | ||
|
|
19a65ac45f | ||
|
|
b72e7bd707 | ||
|
|
190be3e813 | ||
|
|
88300b314c | ||
|
|
fab77c8d9f | ||
|
|
1ae7158d7e | ||
|
|
05f0356288 | ||
|
|
b3cea17b8d | ||
|
|
0b66b23f16 | ||
|
|
80fdf70f7d | ||
|
|
fa931b0db2 | ||
|
|
cab79b4203 | ||
|
|
ddab3db6b5 | ||
|
|
9fa704811c | ||
|
|
4c0d14def0 | ||
|
|
43382d2ffe | ||
|
|
65ad51c273 | ||
|
|
27d448afd6 | ||
|
|
1dd90974bd | ||
|
|
31cc8db3ac | ||
|
|
3d85a15ec9 | ||
|
|
90f98c2d15 | ||
|
|
643855e60e | ||
|
|
e0f7b532f8 | ||
|
|
b4d3e4b42f | ||
|
|
9a7ccb0973 | ||
|
|
a9b67ff272 | ||
|
|
233b9629a2 | ||
|
|
4180c177f1 | ||
|
|
f1bc04756f | ||
|
|
13795c797f | ||
|
|
331a7d5b18 | ||
|
|
81b8da30d6 | ||
|
|
80bad240e7 | ||
|
|
187c56c96c | ||
|
|
3796112d77 | ||
|
|
958940089a | ||
|
|
a08548bb13 | ||
|
|
7fe446e510 | ||
|
|
eccb0d15ee | ||
|
|
7ebd329706 | ||
|
|
d3fcd5fe7e | ||
|
|
b0a3acbdde | ||
|
|
33ce38d74c | ||
|
|
fa51a7fef9 | ||
|
|
d7c072a35c | ||
|
|
c88a6dcf3a | ||
|
|
fcb54a0af2 | ||
|
|
eec2ced481 | ||
|
|
58a6048857 | ||
|
|
93774cca64 | ||
|
|
679f49badc | ||
|
|
b535a12014 | ||
|
|
72876bff43 | ||
|
|
4411022027 | ||
|
|
086210b39d | ||
|
|
73cb2d861b | ||
|
|
1c479ef85a | ||
|
|
51b2b11825 | ||
|
|
414a9bb88a | ||
|
|
f6d1a42b35 | ||
|
|
eb25f8aeb3 | ||
|
|
2ee64a534e | ||
|
|
14073d3555 |
@@ -1,6 +1,7 @@
|
||||
SERVER_NAME=wygiwyh_server
|
||||
DB_NAME=wygiwyh_pg
|
||||
PROCRASTINATE_NAME=wygiwyh_procrastinate
|
||||
|
||||
TZ=UTC # Change to your timezone. This only affects some async tasks.
|
||||
|
||||
DEBUG=false
|
||||
URL = https://...
|
||||
@@ -9,7 +10,6 @@ SECRET_KEY=<GENERATE A SAFE SECRET KEY AND PLACE IT HERE>
|
||||
DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1]
|
||||
OUTBOUND_PORT=9005
|
||||
|
||||
SQL_ENGINE=django.db.backends.postgresql
|
||||
SQL_DATABASE=wygiwyh
|
||||
SQL_USER=wygiwyh
|
||||
SQL_PASSWORD=<INSERT A SAFE PASSWORD HERE>
|
||||
@@ -24,3 +24,5 @@ WEB_CONCURRENCY=4
|
||||
ENABLE_SOFT_DELETE=false
|
||||
# If ENABLE_SOFT_DELETE is true, transactions deleted for more than KEEP_DELETED_TRANSACTIONS_FOR days will be truly deleted. Set to 0 to keep all.
|
||||
KEEP_DELETED_TRANSACTIONS_FOR=365
|
||||
|
||||
TASK_WORKERS=1 # This only work if you're using the single container option. Increase to have more open queues via procrastinate, you probably don't need to increase this.
|
||||
|
||||
57
.github/workflows/release.yml
vendored
57
.github/workflows/release.yml
vendored
@@ -2,7 +2,20 @@ name: Release Pipeline
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [created]
|
||||
types: [ created ]
|
||||
push:
|
||||
branches: [ main ]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Custom tag name for the image'
|
||||
required: true
|
||||
type: string
|
||||
ref:
|
||||
description: 'Git ref to checkout (branch, tag, or SHA)'
|
||||
required: true
|
||||
default: 'main'
|
||||
type: string
|
||||
|
||||
env:
|
||||
IMAGE_NAME: wygiwyh
|
||||
@@ -16,6 +29,13 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.ref }}
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
|
||||
- name: Checkout code (non-manual)
|
||||
uses: actions/checkout@v4
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
|
||||
@@ -29,16 +49,49 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push image
|
||||
- name: Build and push nightly image
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/prod/django/Dockerfile
|
||||
push: true
|
||||
provenance: false
|
||||
build-args: |
|
||||
VERSION=nightly
|
||||
tags: ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:nightly
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Build and push release image
|
||||
if: github.event_name == 'release'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/prod/django/Dockerfile
|
||||
push: true
|
||||
provenance: false
|
||||
build-args: |
|
||||
VERSION=${{ github.event.release.tag_name }}
|
||||
tags: |
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:latest
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:${{ github.event.release.tag_name }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Build and push custom image
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/prod/django/Dockerfile
|
||||
push: true
|
||||
provenance: false
|
||||
build-args: |
|
||||
VERSION=${{ github.event.inputs.tag }}
|
||||
tags: ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:${{ github.event.inputs.tag }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
482
README.md
482
README.md
@@ -79,6 +79,9 @@ $ docker compose up -d
|
||||
$ docker compose exec -it web python manage.py createsuperuser
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> If you're using Unraid, you don't need to follow these steps, use the app on the store. Make sure to read the [Unraid section](#unraid) and [Environment Variables](#environment-variables) for an explanation of all available variables
|
||||
|
||||
## Running locally
|
||||
|
||||
If you want to run WYGIWYH locally, on your env file:
|
||||
@@ -90,471 +93,46 @@ If you want to run WYGIWYH locally, on your env file:
|
||||
You can now access localhost:OUTBOUND_PORT
|
||||
|
||||
> [!NOTE]
|
||||
> If you're planning on running this behind Tailscale or other similar service also add your machine given IP to `DJANGO_ALLOWED_HOSTS`
|
||||
|
||||
> [!NOTE]
|
||||
> If you're going to use another IP that isn't localhost, add it to `DJANGO_ALLOWED_HOSTS`, without `http://`
|
||||
> - If you're planning on running this behind Tailscale or other similar service also add your machine given IP to `DJANGO_ALLOWED_HOSTS`
|
||||
> - If you're going to use another IP that isn't localhost, add it to `DJANGO_ALLOWED_HOSTS`, without `http://`
|
||||
|
||||
|
||||
## Building from source
|
||||
## Latest changes
|
||||
Features are only added to `main` when ready, if you want to run the latest version, you must build from source or use the `:nightly` tag on docker. Keep in mind that there can be undocumented breaking changes.
|
||||
|
||||
Features are only added to `main` when ready, if you want to run the latest version, you must build from source.
|
||||
All the required Dockerfiles are [here](https://github.com/eitchtee/WYGIWYH/tree/main/docker/prod).
|
||||
|
||||
```bash
|
||||
# Create a folder for WYGIWYH (optional)
|
||||
$ mkdir WYGIWYH
|
||||
## Unraid
|
||||
|
||||
# Go into the folder
|
||||
$ cd WYGIWYH
|
||||
[nwithan8](https://github.com/nwithan8) has kindly provided a Unraid template for WYGIWYH, have a look at the [unraid_templates](https://github.com/nwithan8/unraid_templates) repo.
|
||||
|
||||
# Clone this repository
|
||||
$ git clone https://github.com/eitchtee/WYGIWYH.git .
|
||||
WYGIWYH is available on the Unraid Store. You'll need to provision your own postgres (version 15 or up) database.
|
||||
|
||||
$ cp docker-compose.prod.yml docker-compose.yml
|
||||
$ cp .env.example .env
|
||||
# Now edit both files as you see fit
|
||||
To create the first user, open the container's console using Unraid's UI, by clicking on WYGIWYH icon on the Docker page and selecting `Console`, then type `python manage.py createsuperuser`, you'll them be prompted to input your e-mail and password.
|
||||
|
||||
# Run the app
|
||||
$ docker compose up -d --build
|
||||
## Environment Variables
|
||||
|
||||
# Create the first admin account
|
||||
$ docker compose exec -it web python manage.py createsuperuser
|
||||
```
|
||||
| variable | type | default | explanation |
|
||||
|-------------------------------|-------------|-----------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| DJANGO_ALLOWED_HOSTS | string | localhost 127.0.0.1 | A list of space separated domains and IPs representing the host/domain names that WYGIWYH site can serve. [Click here](https://docs.djangoproject.com/en/5.1/ref/settings/#allowed-hosts) for more details |
|
||||
| HTTPS_ENABLED | true\|false | false | Whether to use secure cookies. If this is set to true, the cookie will be marked as “secure”, which means browsers may ensure that the cookie is only sent under an HTTPS connection |
|
||||
| URL | string | http://localhost http://127.0.0.1 | A list of space separated domains and IPs (with the protocol) representing the trusted origins for unsafe requests (e.g. POST). [Click here](https://docs.djangoproject.com/en/5.1/ref/settings/#csrf-trusted-origins ) for more details |
|
||||
| SECRET_KEY | string | "" | This is used to provide cryptographic signing, and should be set to a unique, unpredictable value. |
|
||||
| DEBUG | true\|false | false | Turns DEBUG mode on or off, this is useful to gather more data about possible errors you're having. Don't use in production. |
|
||||
| SQL_DATABASE | string | None *required | The name of your postgres database |
|
||||
| SQL_USER | string | user | The username used to connect to your postgres database |
|
||||
| SQL_PASSWORD | string | password | The password used to connect to your postgres database |
|
||||
| SQL_HOST | string | localhost | The address used to connect to your postgres database |
|
||||
| SQL_PORT | string | 5432 | The port used to connect to your postgres database |
|
||||
| SESSION_EXPIRY_TIME | int | 2678400 (31 days) | The age of session cookies, in seconds. E.g. how long you will stay logged in |
|
||||
| ENABLE_SOFT_DELETE | true\|false | false | Whether to enable transactions soft delete, if enabled, deleted transactions will remain in the database. Useful for imports and avoiding duplicate entries. |
|
||||
| KEEP_DELETED_TRANSACTIONS_FOR | int | 365 | Time in days to keep soft deleted transactions for. If 0, will keep all transactions indefinitely. Only works if ENABLE_SOFT_DELETE is true. |
|
||||
| TASK_WORKERS | int | 1 | How many workers to have for async tasks. One should be enough for most use cases |
|
||||
|
||||
# How it works
|
||||
|
||||
## Models
|
||||
Check out our [Wiki](https://github.com/eitchtee/WYGIWYH/wiki) for more information.
|
||||
|
||||
### Transactions
|
||||
|
||||
Transactions are the core feature of WYGIWYH, representing expenses or income in your accounts. Each transaction consists of the following fields:
|
||||
|
||||
#### Type
|
||||
|
||||
- **Income**: A positive amount entering your account
|
||||
- **Expense**: A negative amount exiting your account
|
||||
|
||||
#### Paid Status
|
||||
|
||||
A transaction can be either:
|
||||
|
||||
- **Current**: When marked as paid
|
||||
- **Projected**: When marked as unpaid
|
||||
|
||||
#### Account
|
||||
|
||||
The account associated with the transaction. Required, limited to one account per transaction.
|
||||
|
||||
#### Entity
|
||||
|
||||
The party involved in the transaction:
|
||||
|
||||
- For **Income**: The paying entity
|
||||
- For **Expense**: The receiving entity
|
||||
|
||||
Optional field.
|
||||
|
||||
#### Date
|
||||
|
||||
The date when the transaction occurred. Required.
|
||||
|
||||
#### Reference Date
|
||||
|
||||
One of **WYGIWYH**'s key features. The reference date determines which month a transaction should count towards. For example, you can have a transaction that occurred on January 26th count towards February's finances.
|
||||
|
||||
Optional - defaults to the transaction date's month if not specified.
|
||||
|
||||
> [!CAUTION]
|
||||
> While designed primarily for credit card closing dates, this feature allows for debt rolling across months. Use responsibly to maintain accurate financial tracking.
|
||||
|
||||
#### Type
|
||||
|
||||
- Income, meaning a positive amount (usually) entering your account
|
||||
- Expense, meaning a negative amount exiting your account
|
||||
|
||||
#### Description
|
||||
|
||||
The name or purpose of the transaction. Required.
|
||||
|
||||
#### Amount
|
||||
|
||||
The monetary value of the transaction. Required.
|
||||
|
||||
#### Category
|
||||
|
||||
The primary classification of the transaction. Optional.
|
||||
|
||||
#### Tags
|
||||
|
||||
Additional labels for transaction categorization. Optional.
|
||||
|
||||
#### Notes
|
||||
|
||||
Additional information about the transaction. Optional.
|
||||
|
||||

|
||||
|
||||
### Installment Plan
|
||||
|
||||
An Installment Plan is a helper model that generates a series of recurring transactions over a fixed period.
|
||||
|
||||
#### Core Fields
|
||||
|
||||
- **Account**: The account for all transactions in the plan. Required.
|
||||
- **Entity**: The paying or receiving party for all transactions. Optional.
|
||||
- **Description**: The name of the installment plan, used for all transactions. Required.
|
||||
- **Notes**: Additional information applied to all transactions. Optional.
|
||||
|
||||
#### Installment Configuration
|
||||
|
||||
- **Number of Installments**: Total number of transactions to create (e.g., 1/10, 2/10)
|
||||
- **Installment Start**: Initial counting point
|
||||
- **Start Date**: Date of the first transaction
|
||||
- **Reference Date**: Reference date for the first transaction
|
||||
- **Recurrence**: Frequency of transactions (e.g., Monthly)
|
||||
|
||||

|
||||
|
||||
### Transaction Details
|
||||
|
||||
- **Amount**: Value for each transaction. Required.
|
||||
- **Category**: Primary classification for all transactions. Optional.
|
||||
- **Tags**: Labels applied to all transactions. Optional.
|
||||
|
||||
### Recurring Transaction
|
||||
A Recurring Transaction is a helper model that generates recurring transactions indefinitely or until a certain date.
|
||||
|
||||
#### Core Fields
|
||||
|
||||
- **Account**: The account for all transactions in the plan. Required.
|
||||
- **Entity**: The paying or receiving party for all transactions. Optional.
|
||||
- **Description**: The name of the recurring transaction, used for all transactions. Required.
|
||||
- **Notes**: Additional information applied to all transactions. Optional.
|
||||
|
||||
#### Recurring Transaction Configuration
|
||||
|
||||
- **Start Date**: Date of the first transaction. Required.
|
||||
- **Reference Date**: Reference date for the first transaction. Optional.
|
||||
- **Recurrence Type**: Frequency of transactions (e.g., Monthly). Required.
|
||||
- **Recurrence Interval**: The interval between transactions (e.g. every 1 month, every 2 weeks, etc.). Required.
|
||||
- **End date**: When new transactions should stop being created. Optional.
|
||||
|
||||
#### Transaction Details
|
||||
|
||||
- **Amount**: Value for each transaction. Required.
|
||||
- **Category**: Primary classification for all transactions. Optional.
|
||||
- **Tags**: Labels applied to all transactions. Optional.
|
||||
|
||||
#### Other information
|
||||
|
||||
- Recurring transactions are checked and created every midnight using Procrastinate.
|
||||
- **WYGIWYH** tries to keep at most **6** future transactions created at any time.
|
||||
- If you delete a recurring transaction it will not be recreated.
|
||||
- You can stop or pause a recurring transaction at any time on the config page (/recurring-trasanctions/)
|
||||
|
||||

|
||||
|
||||
### Account
|
||||
|
||||
Accounts represent different financial entities where transactions occur. They have the following attributes:
|
||||
|
||||
- **Name**: A unique identifier for the account.
|
||||
- **Group**: An optional [account group](#account-groups) the account belongs to for organizational purposes.
|
||||
- **Currency**: The primary [currency](#currency) of the account.
|
||||
- **Exchange Currency**: An optional currency used for exchange rate calculations.
|
||||
- **Is Asset**: A boolean indicating if the account is considered an asset (counts towards net worth).
|
||||
- **Is Archived**: A boolean indicating if the account is archived (doesn't show up in active lists or count towards net worth).
|
||||
|
||||
### Account Groups
|
||||
|
||||
Account Groups are used to organize accounts into logical categories. They consist of:
|
||||
|
||||
- **Name**: A unique identifier for the group.
|
||||
|
||||
### Currency
|
||||
|
||||
Currencies represent different monetary units. They include:
|
||||
|
||||
* **Code**: A unique identifier for the currency (e.g., USD, EUR).
|
||||
* **Name**: The full name of the currency.
|
||||
* **Decimal Place**: The number of decimal places used for the currency.
|
||||
* **Prefix**: An optional symbol or text that comes before the amount.
|
||||
* **Suffix**: An optional symbol or text that comes after the amount.
|
||||
|
||||
### Exchange Rate
|
||||
|
||||
Exchange Rates store conversion rates between currencies:
|
||||
|
||||
* **From Currency**: The source currency.
|
||||
* **To Currency**: The target currency.
|
||||
* **Rate**: The conversion rate.
|
||||
* **Date**: The date the rate was recorded or is valid for.
|
||||
|
||||
### Category
|
||||
|
||||
Categories are used to classify transactions:
|
||||
|
||||
* **Name**: A unique identifier for the category.
|
||||
* **Muted**: Muted categories won't count towards your monthly total.
|
||||
* **Active**: A boolean indicating if the category is currently in use. This will disable its use on new transactions.
|
||||
|
||||
### Tag
|
||||
|
||||
Tags provide additional labeling for transactions:
|
||||
|
||||
* **Name**: A unique identifier for the tag.
|
||||
* **Active**: A boolean indicating if the category is currently in use. This will disable its use on new transactions.
|
||||
|
||||
### Entity
|
||||
|
||||
Entities represent parties involved in transactions:
|
||||
|
||||
* **Name**: A unique identifier for the entity.
|
||||
* **Active**: A boolean indicating if the entity is currently in use. This will disable its use on new transactions.
|
||||
|
||||
---
|
||||
|
||||
## Helper actions
|
||||
|
||||
### Transfer
|
||||
|
||||
A transfer happens when you move a monetary value from one account to another. This will create two transactions, one expense and one income with the values set by the user.
|
||||
|
||||
Contrary to other finance trackers, due to our multi-currency support, **WYGIWYH**'s transfer system allows for non-zero transfers.
|
||||
|
||||

|
||||
|
||||
### Balance (Account Reconciliation)
|
||||
|
||||
A balance is a easy way of updating your accounts balance. It creates a transaction with the difference between the balance currently in **WYGIWYH** and the new balance informed by you.
|
||||
|
||||
This can be useful for savings accounts or other interest accruing investments.
|
||||
|
||||
---
|
||||
|
||||
## Views
|
||||
|
||||
### Monthly
|
||||
|
||||
The Monthly view provides an overview of your financial activity for a specific month. It includes:
|
||||
|
||||
* Total income and expenses for the month
|
||||
* Daily spending allowance calculation
|
||||
* List of transactions for the month
|
||||
|
||||
> [!NOTE]
|
||||
> Reference dates are taken into account here.
|
||||
|
||||
### Yearly by currency
|
||||
|
||||
This view gives you a yearly summary of your finances grouped by currency. It shows:
|
||||
|
||||
* Total income and expenses for each currency
|
||||
* Monthly breakdown of income and expenses
|
||||
|
||||
### Yearly by account
|
||||
|
||||
Similar to the [yearly by currency](#yearly-by-currency) view, but groups the data by account instead.
|
||||
|
||||
### Calendar
|
||||
|
||||
The Calendar view presents your transactions in a monthly calendar format, allowing you to see your financial activity day by day. It includes:
|
||||
|
||||
* Visual representation of daily transaction totals
|
||||
* Ability to view details of transactions for each day
|
||||
|
||||
> [!NOTE]
|
||||
> Reference dates are **not** taken into account here.
|
||||
|
||||
### Networh
|
||||
|
||||
#### Current
|
||||
|
||||
The Current Net Worth view shows your present financial standing, including:
|
||||
|
||||
* Total value of all asset accounts
|
||||
* Breakdown of assets by account and currency
|
||||
* Historical net worth trend
|
||||
|
||||
#### Projected
|
||||
|
||||
The Projected Net Worth view estimates your future financial position based on current data and recurring transactions. It includes:
|
||||
|
||||
* Your total net worth with projected and current transactions
|
||||
* Breakdown of assets by account and currency
|
||||
* Historical and future net worth trend
|
||||
|
||||
### All Transactions
|
||||
|
||||
This view provides a comprehensive list of all transactions across all accounts. Features include:
|
||||
|
||||
* Advanced filtering and sorting options
|
||||
* Detailed information
|
||||
|
||||
You can use this to see how much you spent on a given category, or a given day, etc..
|
||||
|
||||
### Configuration and Management
|
||||
|
||||
#### Management
|
||||
The Management section in the navbar allows you to add and edit most elements of WYGIWYH, including:
|
||||
|
||||
* Accounts and Groups
|
||||
* Currencies and Exchange Rates
|
||||
* Categories, Tags and Entities
|
||||
* Rules
|
||||
|
||||
#### User Settings
|
||||
|
||||
WYGIWYH allows users to personalize their experience through customizable settings. Each user can configure:
|
||||
|
||||
* **Language**: Choose your preferred interface language.
|
||||
* **Timezone**: Set your local timezone for accurate date and time display.
|
||||
* **Start Page**: Select which page you want to see first when you log in.
|
||||
* **Sound Preferences**: Toggle sound effects on or off.
|
||||
* **Amount Display**: Choose to show or hide monetary amounts by default.
|
||||
|
||||
To access and modify these settings:
|
||||
|
||||
1. Click on your username in the top-right corner of the page.
|
||||
2. Select "Settings" from the dropdown menu.
|
||||
3. Adjust your preferences as desired.
|
||||
4. Click "Save" to apply your changes.
|
||||
|
||||
These settings ensure that WYGIWYH adapts to your personal preferences and working style.
|
||||
|
||||
#### Django Admin
|
||||
From here you can also access Django's own admin site.
|
||||
|
||||
> [!WARNING]
|
||||
> Most side effects aren't triggered from the admin.
|
||||
> Only use it if you know what you're doing or were told by a developer to do so.
|
||||
|
||||
---
|
||||
|
||||
## Tools
|
||||
|
||||
### Calculator
|
||||
|
||||
The calculator is a floating widget that can be toggled by clicking the calculator icon on the navbar or by pressing <kbd>Alt</kbd> + <kbd>C</kbd> on any page.
|
||||
|
||||
It allows for any math expression supported by [math.js](https://mathjs.org).
|
||||
|
||||

|
||||
|
||||
### Dollar Cost Average Tracker
|
||||
|
||||
The DCA Tracker can be accessed from the navbar's **Tools** menu.
|
||||
|
||||
It allows for tracking DCA strategies and getting helpful information and insights.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Currently DCA exists separately from your main transactions. You will need to add your entries manually.
|
||||
|
||||
<img src=".github/img/readme_dca_1.png" width="45%"></img> <img src=".github/img/readme_dca_2.png" width="45%"></img>
|
||||
|
||||
### Unit Price Calculator
|
||||
|
||||
The Unit Price Calculator can be accessed from the navbar's **Tools** menu.
|
||||
|
||||
This is a self-contained tool for comparing and finding the most cost-efficient item quickly and easily.
|
||||
|
||||
Input the price and the amount of each item, the cheapeast will be highlighted in green, and the most expensive in red.
|
||||
|
||||
You can add additional items by clicking the _Add_ button at the end of the page.
|
||||
|
||||
> [!NOTE]
|
||||
> This doesn't do unit convertion. The amount of all items needs to be on the same the unit for proper functioning.
|
||||
|
||||

|
||||
|
||||
### Currency Converter
|
||||
|
||||
The Currency Converter is a tool that allows you to quickly convert amounts between different currencies.
|
||||
|
||||
> [!NOTE]
|
||||
> There's no external Exchange Rate fetching. This uses the Exchange Rates configured in the [Management](#configuration-and-management) page for [Exchange Rates](#exchange-rate)
|
||||
|
||||
## Automation
|
||||
|
||||
### API
|
||||
|
||||
WYGIWYH has a comprehensive API, it's documentation can be accessed on `<your-wygiwyh-url>/api/docs/`
|
||||
|
||||
> [!NOTE]
|
||||
> While the API works, there's still much to be added to it to equipare functionality with the main web app.
|
||||
|
||||
### Transaction Rules
|
||||
|
||||
Transaction Rules are a powerful feature in WYGIWYH that allow for automatic modification of transactions based on specified criteria. This can save time and ensure consistency in your financial tracking.
|
||||
|
||||
Key Aspects of Transaction Rules:
|
||||
|
||||
* **Conditions**: Set specific criteria that a transaction must meet for the rule to apply. This can include attributes like description, amount, account, etc.
|
||||
* **Actions**: Define what changes should be made to a transaction when the conditions are met. This can include setting categories, tags, or modifying other fields.
|
||||
* **Activation Options**: Rules can be set to apply when transactions are created, updated, or both.
|
||||
|
||||
#### Actions and Conditions
|
||||
|
||||
When creating a new rule, you will need to add a Condition and, later, Actions.
|
||||
|
||||
Both use a limited subset of Python, via [SimpleEval](https://github.com/danthedeckie/simpleeval).
|
||||
|
||||
The Condition must evaluate to True or False, and the Action must evaluate to a value that will be set on the selected field.
|
||||
|
||||
You may use any of the available [variables](#available-variables) and [functions](#available-functions).
|
||||
|
||||
#### Available variables
|
||||
|
||||
* `account_name`
|
||||
* `account_id`
|
||||
* `account_group_name`
|
||||
* `account_group_id`
|
||||
* `is_asset_account`
|
||||
* `is_archived_account`
|
||||
* `category_name`
|
||||
* `category_id`
|
||||
* `tag_names`
|
||||
* `tag_ids`
|
||||
* `entities_names`
|
||||
* `entities_ids`
|
||||
* `is_expense`
|
||||
* `is_income`
|
||||
* `is_paid`
|
||||
* `description`
|
||||
* `amount`
|
||||
* `notes`
|
||||
* `date`
|
||||
* `reference_date`
|
||||
|
||||
#### Available functions
|
||||
|
||||
* `relativedelta`
|
||||
|
||||
#### Examples
|
||||
|
||||
Add a tag to an income transaction if it happens in a specific account
|
||||
|
||||
```
|
||||
If...
|
||||
account_name == "My Investing Account" and is_income
|
||||
|
||||
Then...
|
||||
Set Tags to
|
||||
tag_names + ["Yield"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Move credit card transactions to next month when they happen at a cutoff date
|
||||
|
||||
```
|
||||
If...
|
||||
account_name == "My credit card" and date.day >= 26 and reference_date.month == date.month
|
||||
|
||||
Then...
|
||||
Set Reference Date to
|
||||
reference_date + relativedelta(months=1)).replace(day=1)
|
||||
```
|
||||
# Caveats and Warnings
|
||||
|
||||
- I'm not an accountant, some terms and even calculations might be wrong. Make sure to open an issue if you see anything that could be improved.
|
||||
|
||||
@@ -31,10 +31,8 @@ SECRET_KEY = os.getenv("SECRET_KEY", "")
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
|
||||
|
||||
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "localhost 127.0.0.1").split(" ")
|
||||
CSRF_TRUSTED_ORIGINS = os.environ.get("URL", "http://localhost http://127.0.0.1").split(
|
||||
" "
|
||||
)
|
||||
ALLOWED_HOSTS = os.getenv("DJANGO_ALLOWED_HOSTS", "localhost 127.0.0.1").split(" ")
|
||||
CSRF_TRUSTED_ORIGINS = os.getenv("URL", "http://localhost http://127.0.0.1").split(" ")
|
||||
|
||||
# Application definition
|
||||
|
||||
@@ -57,6 +55,7 @@ INSTALLED_APPS = [
|
||||
"hijack",
|
||||
"hijack.contrib.admin",
|
||||
"django_filters",
|
||||
"import_export",
|
||||
"apps.users.apps.UsersConfig",
|
||||
"procrastinate.contrib.django",
|
||||
"apps.transactions.apps.TransactionsConfig",
|
||||
@@ -65,6 +64,7 @@ INSTALLED_APPS = [
|
||||
"apps.common.apps.CommonConfig",
|
||||
"apps.net_worth.apps.NetWorthConfig",
|
||||
"apps.import_app.apps.ImportConfig",
|
||||
"apps.export_app.apps.ExportConfig",
|
||||
"apps.api.apps.ApiConfig",
|
||||
"cachalot",
|
||||
"rest_framework",
|
||||
@@ -77,6 +77,8 @@ INSTALLED_APPS = [
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django_browser_reload.middleware.BrowserReloadMiddleware",
|
||||
"apps.common.middleware.thread_local.ThreadLocalMiddleware",
|
||||
"debug_toolbar.middleware.DebugToolbarMiddleware",
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
@@ -88,7 +90,6 @@ MIDDLEWARE = [
|
||||
"apps.common.middleware.localization.LocalizationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"django_browser_reload.middleware.BrowserReloadMiddleware",
|
||||
"hijack.middleware.HijackUserMiddleware",
|
||||
]
|
||||
|
||||
@@ -126,12 +127,12 @@ WSGI_APPLICATION = "WYGIWYH.wsgi.application"
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": os.environ.get("SQL_ENGINE", "django.db.backends.sqlite3"),
|
||||
"NAME": os.environ.get("SQL_DATABASE", BASE_DIR / "db.sqlite3"),
|
||||
"USER": os.environ.get("SQL_USER", "user"),
|
||||
"PASSWORD": os.environ.get("SQL_PASSWORD", "password"),
|
||||
"HOST": os.environ.get("SQL_HOST", "localhost"),
|
||||
"PORT": "5432",
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": os.getenv("SQL_DATABASE"),
|
||||
"USER": os.getenv("SQL_USER", "user"),
|
||||
"PASSWORD": os.getenv("SQL_PASSWORD", "password"),
|
||||
"HOST": os.getenv("SQL_HOST", "localhost"),
|
||||
"PORT": os.getenv("SQL_PORT", "5432"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,11 +164,11 @@ AUTH_USER_MODEL = "users.User"
|
||||
LANGUAGE_CODE = "en"
|
||||
LANGUAGES = (
|
||||
("en", "English"),
|
||||
# ("nl", "Nederlands"),
|
||||
("nl", "Nederlands"),
|
||||
("pt-br", "Português (Brasil)"),
|
||||
)
|
||||
|
||||
TIME_ZONE = "UTC"
|
||||
TIME_ZONE = os.getenv("TZ", "UTC")
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
@@ -221,7 +222,7 @@ SESSION_COOKIE_SECURE = os.getenv("HTTPS_ENABLED", "false").lower() == "true"
|
||||
|
||||
DEBUG_TOOLBAR_CONFIG = {
|
||||
"ROOT_TAG_EXTRA_ATTRS": "hx-preserve",
|
||||
"SHOW_TOOLBAR_CALLBACK": lambda r: False, # disables it}
|
||||
# "SHOW_TOOLBAR_CALLBACK": lambda r: False, # disables it
|
||||
}
|
||||
DEBUG_TOOLBAR_PANELS = [
|
||||
"debug_toolbar.panels.history.HistoryPanel",
|
||||
@@ -278,29 +279,32 @@ if "procrastinate" in sys.argv:
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"procrastinate": {
|
||||
"format": "%(asctime)s %(levelname)-7s %(name)s %(message)s"
|
||||
"standard": {
|
||||
"format": "[%(asctime)s] - %(levelname)s - %(name)s - %(message)s",
|
||||
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"procrastinate": {
|
||||
"level": "DEBUG",
|
||||
"level": "INFO",
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "procrastinate",
|
||||
"formatter": "standard",
|
||||
},
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "standard",
|
||||
"level": "INFO",
|
||||
},
|
||||
},
|
||||
"loggers": {
|
||||
"procrastinate": {
|
||||
"handlers": ["procrastinate"],
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
"root": {
|
||||
"handlers": ["console"],
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -309,24 +313,25 @@ else:
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"procrastinate": {
|
||||
"format": "%(asctime)s %(levelname)-7s %(name)s %(message)s"
|
||||
"standard": {
|
||||
"format": "[%(asctime)s] - %(levelname)s - %(name)s - %(message)s",
|
||||
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"procrastinate": {
|
||||
"level": "DEBUG",
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "procrastinate",
|
||||
},
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "standard",
|
||||
"level": "INFO",
|
||||
},
|
||||
"procrastinate": {
|
||||
"level": "INFO",
|
||||
"class": "logging.StreamHandler",
|
||||
},
|
||||
},
|
||||
"loggers": {
|
||||
"procrastinate": {
|
||||
"handlers": None,
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
"root": {
|
||||
@@ -387,3 +392,4 @@ PWA_SERVICE_WORKER_PATH = BASE_DIR / "templates" / "pwa" / "serviceworker.js"
|
||||
|
||||
ENABLE_SOFT_DELETE = os.getenv("ENABLE_SOFT_DELETE", "false").lower() == "true"
|
||||
KEEP_DELETED_TRANSACTIONS_FOR = int(os.getenv("KEEP_DELETED_ENTRIES_FOR", "365"))
|
||||
APP_VERSION = os.getenv("APP_VERSION", "unknown")
|
||||
|
||||
@@ -49,4 +49,6 @@ urlpatterns = [
|
||||
path("", include("apps.dca.urls")),
|
||||
path("", include("apps.mini_tools.urls")),
|
||||
path("", include("apps.import_app.urls")),
|
||||
path("", include("apps.export_app.urls")),
|
||||
path("", include("apps.insights.urls")),
|
||||
]
|
||||
|
||||
@@ -12,15 +12,14 @@ class DynamicModelChoiceField(forms.ModelChoiceField):
|
||||
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())
|
||||
super().__init__(queryset=self.queryset, *args, **kwargs)
|
||||
self._created_instance = None
|
||||
|
||||
self.widget = TomSelect(clear_button=True, create=True)
|
||||
|
||||
super().__init__(queryset=self.queryset, *args, **kwargs)
|
||||
self._created_instance = None
|
||||
|
||||
def to_python(self, value):
|
||||
if value in self.empty_values:
|
||||
return None
|
||||
@@ -53,14 +52,19 @@ class DynamicModelChoiceField(forms.ModelChoiceField):
|
||||
else:
|
||||
raise self.model.DoesNotExist
|
||||
except self.model.DoesNotExist:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
instance, _ = self.model.objects.update_or_create(
|
||||
**{self.create_field: value}
|
||||
if self.create_field:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
instance, _ = self.model.objects.update_or_create(
|
||||
**{self.create_field: value}
|
||||
)
|
||||
self._created_instance = instance
|
||||
return instance
|
||||
except Exception as e:
|
||||
raise ValidationError(
|
||||
self.error_messages["invalid_choice"], code="invalid_choice"
|
||||
)
|
||||
self._created_instance = instance
|
||||
return instance
|
||||
except Exception as e:
|
||||
else:
|
||||
raise ValidationError(
|
||||
self.error_messages["invalid_choice"], code="invalid_choice"
|
||||
)
|
||||
|
||||
31
app/apps/common/functions/format.py
Normal file
31
app/apps/common/functions/format.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from apps.common.middleware.thread_local import get_current_user
|
||||
from django.utils.formats import get_format as original_get_format
|
||||
|
||||
|
||||
def get_format(format_type=None, lang=None, use_l10n=None):
|
||||
user = get_current_user()
|
||||
|
||||
if user and user.is_authenticated and hasattr(user, "settings"):
|
||||
user_settings = user.settings
|
||||
if format_type == "THOUSAND_SEPARATOR":
|
||||
number_format = getattr(user_settings, "number_format", None)
|
||||
if number_format == "DC":
|
||||
return "."
|
||||
elif number_format == "CD":
|
||||
return ","
|
||||
elif format_type == "DECIMAL_SEPARATOR":
|
||||
number_format = getattr(user_settings, "number_format", None)
|
||||
if number_format == "DC":
|
||||
return ","
|
||||
elif number_format == "CD":
|
||||
return "."
|
||||
elif format_type == "SHORT_DATE_FORMAT":
|
||||
date_format = getattr(user_settings, "date_format", None)
|
||||
if date_format and date_format != "SHORT_DATE_FORMAT":
|
||||
return date_format
|
||||
elif format_type == "SHORT_DATETIME_FORMAT":
|
||||
datetime_format = getattr(user_settings, "datetime_format", None)
|
||||
if datetime_format and datetime_format != "SHORT_DATETIME_FORMAT":
|
||||
return datetime_format
|
||||
|
||||
return original_get_format(format_type, lang, use_l10n)
|
||||
@@ -1,14 +1,17 @@
|
||||
import zoneinfo
|
||||
|
||||
from django.utils import formats
|
||||
from django.utils import timezone, translation
|
||||
from django.utils.translation import activate
|
||||
from django.utils.functional import lazy
|
||||
|
||||
from apps.common.functions.format import get_format as custom_get_format
|
||||
from apps.users.models import UserSettings
|
||||
|
||||
|
||||
class LocalizationMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
self.patch_get_format()
|
||||
|
||||
def __call__(self, request):
|
||||
tz = request.COOKIES.get("mytz")
|
||||
@@ -33,9 +36,14 @@ class LocalizationMiddleware:
|
||||
timezone.activate(zoneinfo.ZoneInfo("UTC"))
|
||||
|
||||
if user_language and user_language != "auto":
|
||||
activate(user_language)
|
||||
translation.activate(user_language)
|
||||
else:
|
||||
detected_language = translation.get_language_from_request(request)
|
||||
activate(detected_language)
|
||||
translation.activate(detected_language)
|
||||
|
||||
return self.get_response(request)
|
||||
|
||||
@staticmethod
|
||||
def patch_get_format():
|
||||
formats.get_format = custom_get_format
|
||||
formats.get_format_lazy = lazy(custom_get_format, str, list, tuple)
|
||||
|
||||
73
app/apps/common/middleware/thread_local.py
Normal file
73
app/apps/common/middleware/thread_local.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
threadlocals middleware
|
||||
~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
make the request object everywhere available (e.g. in model instance).
|
||||
|
||||
based on: http://code.djangoproject.com/wiki/CookBookThreadlocalsAndUser
|
||||
|
||||
Put this into your settings:
|
||||
--------------------------------------------------------------------------
|
||||
MIDDLEWARE_CLASSES = (
|
||||
...
|
||||
'django_tools.middlewares.ThreadLocal.ThreadLocalMiddleware',
|
||||
...
|
||||
)
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
|
||||
Usage:
|
||||
--------------------------------------------------------------------------
|
||||
from django_tools.middlewares import ThreadLocal
|
||||
|
||||
# Get the current request object:
|
||||
request = ThreadLocal.get_current_request()
|
||||
|
||||
# You can get the current user directly with:
|
||||
user = ThreadLocal.get_current_user()
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
:copyleft: 2009-2017 by the django-tools team, see AUTHORS for more details.
|
||||
:license: GNU GPL v3 or above, see LICENSE for more details.
|
||||
"""
|
||||
|
||||
try:
|
||||
from threading import local
|
||||
except ImportError:
|
||||
from django.utils._threading_local import local
|
||||
|
||||
try:
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
except ImportError:
|
||||
MiddlewareMixin = object # fallback for Django < 1.10
|
||||
|
||||
|
||||
_thread_locals = local()
|
||||
|
||||
|
||||
def get_current_request():
|
||||
"""returns the request object for this thread"""
|
||||
return getattr(_thread_locals, "request", None)
|
||||
|
||||
|
||||
def get_current_user():
|
||||
"""returns the current user, if exist, otherwise returns None"""
|
||||
request = get_current_request()
|
||||
if request:
|
||||
return getattr(request, "user", None)
|
||||
|
||||
|
||||
class ThreadLocalMiddleware(MiddlewareMixin):
|
||||
"""Simple middleware that adds the request object in thread local storage."""
|
||||
|
||||
def process_request(self, request):
|
||||
_thread_locals.request = request
|
||||
|
||||
def process_response(self, request, response):
|
||||
if hasattr(_thread_locals, "request"):
|
||||
del _thread_locals.request
|
||||
return response
|
||||
|
||||
def process_exception(self, request, exception):
|
||||
if hasattr(_thread_locals, "request"):
|
||||
del _thread_locals.request
|
||||
@@ -1,5 +1,8 @@
|
||||
import logging
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
from django.core import management
|
||||
|
||||
from procrastinate import builtin_tasks
|
||||
from procrastinate.contrib.django import app
|
||||
|
||||
@@ -8,7 +11,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@app.periodic(cron="0 4 * * *")
|
||||
@app.task(queueing_lock="remove_old_jobs", pass_context=True)
|
||||
@app.task(queueing_lock="remove_old_jobs", pass_context=True, name="remove_old_jobs")
|
||||
async def remove_old_jobs(context, timestamp):
|
||||
try:
|
||||
return await builtin_tasks.remove_old_jobs(
|
||||
@@ -24,3 +27,16 @@ async def remove_old_jobs(context, timestamp):
|
||||
exc_info=True,
|
||||
)
|
||||
raise e
|
||||
|
||||
|
||||
@app.periodic(cron="0 6 1 * *")
|
||||
@app.task(queueing_lock="remove_expired_sessions", name="remove_expired_sessions")
|
||||
async def remove_expired_sessions(timestamp=None):
|
||||
"""Cleanup expired sessions by using Django management command."""
|
||||
try:
|
||||
await sync_to_async(management.call_command)("clearsessions", verbosity=0)
|
||||
except Exception:
|
||||
logger.error(
|
||||
"Error while executing 'remove_expired_sessions' task",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
from django import template
|
||||
from django.template.defaultfilters import date as date_filter
|
||||
from django.utils import formats, timezone
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter
|
||||
def custom_date(value, user=None):
|
||||
if not value:
|
||||
return ""
|
||||
|
||||
# Determine if the value is a datetime or just a date
|
||||
is_datetime = hasattr(value, "hour")
|
||||
|
||||
# Convert to current timezone if it's a datetime
|
||||
if is_datetime and timezone.is_aware(value):
|
||||
value = timezone.localtime(value)
|
||||
|
||||
if user and user.is_authenticated:
|
||||
user_settings = user.settings
|
||||
|
||||
if is_datetime:
|
||||
format_setting = user_settings.datetime_format
|
||||
else:
|
||||
format_setting = user_settings.date_format
|
||||
|
||||
return formats.date_format(value, format_setting, use_l10n=True)
|
||||
|
||||
return date_filter(
|
||||
value, "SHORT_DATE_FORMAT" if not is_datetime else "SHORT_DATETIME_FORMAT"
|
||||
)
|
||||
@@ -1,6 +1,6 @@
|
||||
from django import template
|
||||
from django.utils.formats import get_format
|
||||
|
||||
from apps.common.functions.format import get_format
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
52
app/apps/common/templatetags/markdown.py
Normal file
52
app/apps/common/templatetags/markdown.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from typing import Optional
|
||||
|
||||
import mistune
|
||||
from django import template
|
||||
from django.utils.safestring import mark_safe
|
||||
from mistune import HTMLRenderer, Markdown, BlockParser, InlineParser, safe_entity
|
||||
from mistune.plugins.formatting import strikethrough as plugin_strikethrough
|
||||
from mistune.plugins.url import url as plugin_url
|
||||
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
class CustomRenderer(HTMLRenderer):
|
||||
def link(self, text: str, url: str, title: Optional[str] = None) -> str:
|
||||
s = '<a rel="nofollow" target="_blank" href="' + self.safe_url(url) + '"'
|
||||
if title:
|
||||
s += ' title="' + safe_entity(title) + '"'
|
||||
return s + ">" + text + "</a>"
|
||||
|
||||
def paragraph(self, text: str) -> str:
|
||||
return text + "\n"
|
||||
|
||||
def softbreak(self) -> str:
|
||||
return "\n"
|
||||
|
||||
def blank_line(self) -> str:
|
||||
return "\n"
|
||||
|
||||
|
||||
block = BlockParser()
|
||||
block.rules = ["blank_line"]
|
||||
inline = InlineParser(hard_wrap=False)
|
||||
inline.rules = [
|
||||
"emphasis",
|
||||
"link",
|
||||
"auto_link",
|
||||
"auto_email",
|
||||
"linebreak",
|
||||
"softbreak",
|
||||
]
|
||||
markdown = Markdown(
|
||||
renderer=CustomRenderer(escape=False),
|
||||
block=block,
|
||||
inline=inline,
|
||||
plugins=[plugin_strikethrough, plugin_url],
|
||||
)
|
||||
|
||||
|
||||
@register.filter(name="limited_markdown")
|
||||
def limited_markdown(value):
|
||||
return mark_safe(markdown(value))
|
||||
9
app/apps/common/templatetags/settings.py
Normal file
9
app/apps/common/templatetags/settings.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from django import template
|
||||
from django.conf import settings
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.simple_tag(name="settings")
|
||||
def settings_value(name):
|
||||
return getattr(settings, name, "")
|
||||
@@ -13,4 +13,9 @@ urlpatterns = [
|
||||
views.month_year_picker,
|
||||
name="month_year_picker",
|
||||
),
|
||||
path(
|
||||
"cache/invalidate/",
|
||||
views.invalidate_cache,
|
||||
name="invalidate_cache",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -35,7 +35,7 @@ def django_to_python_datetime(django_format):
|
||||
def django_to_airdatepicker_datetime(django_format):
|
||||
format_map = {
|
||||
# Time
|
||||
"h": "h", # Hour (12-hour)
|
||||
"h": "hh", # Hour (12-hour)
|
||||
"H": "H", # Hour (24-hour)
|
||||
"i": "m", # Minutes
|
||||
"A": "AA", # AM/PM uppercase
|
||||
@@ -76,7 +76,7 @@ def django_to_airdatepicker_datetime(django_format):
|
||||
def django_to_airdatepicker_datetime_separated(django_format):
|
||||
format_map = {
|
||||
# Time formats
|
||||
"h": "hH", # Hour (12-hour)
|
||||
"h": "hh", # Hour (12-hour)
|
||||
"H": "HH", # Hour (24-hour)
|
||||
"i": "mm", # Minutes
|
||||
"A": "AA", # AM/PM uppercase
|
||||
|
||||
@@ -1,17 +1,32 @@
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Count
|
||||
from django.db.models.functions import ExtractYear, ExtractMonth
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from cachalot.api import invalidate
|
||||
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
from apps.transactions.models import Transaction
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def toasts(request):
|
||||
return render(request, "common/fragments/toasts.html")
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def month_year_picker(request):
|
||||
field = request.GET.get("field", "reference_date")
|
||||
for_ = request.GET.get("for", None)
|
||||
@@ -84,3 +99,19 @@ def month_year_picker(request):
|
||||
"current_year": current_year,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def invalidate_cache(request):
|
||||
invalidate()
|
||||
|
||||
messages.success(request, _("Cache cleared successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2,7 +2,6 @@ import datetime
|
||||
|
||||
from django.forms import widgets
|
||||
from django.utils import formats, translation, dates
|
||||
from django.utils.formats import get_format
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.common.utils.django import (
|
||||
@@ -10,6 +9,7 @@ from apps.common.utils.django import (
|
||||
django_to_airdatepicker_datetime,
|
||||
django_to_airdatepicker_datetime_separated,
|
||||
)
|
||||
from apps.common.functions.format import get_format
|
||||
|
||||
|
||||
class AirDatePickerInput(widgets.DateInput):
|
||||
@@ -19,15 +19,19 @@ class AirDatePickerInput(widgets.DateInput):
|
||||
format=None,
|
||||
clear_button=True,
|
||||
auto_close=True,
|
||||
user=None,
|
||||
read_only=True,
|
||||
toggle_selected=None,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
attrs = attrs or {}
|
||||
self.user = user
|
||||
super().__init__(attrs=attrs, format=format, *args, **kwargs)
|
||||
self.clear_button = clear_button
|
||||
self.auto_close = auto_close
|
||||
self.read_only = read_only
|
||||
self.toggle_selected = (
|
||||
toggle_selected if isinstance(toggle_selected, bool) else self.clear_button
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_current_language():
|
||||
@@ -41,12 +45,6 @@ class AirDatePickerInput(widgets.DateInput):
|
||||
if self.format:
|
||||
return self.format
|
||||
|
||||
if self.user and hasattr(self.user, "settings"):
|
||||
user_format = self.user.settings.date_format
|
||||
if user_format == "SHORT_DATE_FORMAT":
|
||||
return get_format("SHORT_DATE_FORMAT", use_l10n=True)
|
||||
return user_format
|
||||
|
||||
return get_format("SHORT_DATE_FORMAT", use_l10n=True)
|
||||
|
||||
def build_attrs(self, base_attrs, extra_attrs=None):
|
||||
@@ -55,9 +53,13 @@ class AirDatePickerInput(widgets.DateInput):
|
||||
attrs["data-now-button-txt"] = _("Today")
|
||||
attrs["data-auto-close"] = str(self.auto_close).lower()
|
||||
attrs["data-clear-button"] = str(self.clear_button).lower()
|
||||
attrs["data-toggle-selected"] = str(self.toggle_selected).lower()
|
||||
attrs["data-language"] = self._get_current_language()
|
||||
attrs["data-date-format"] = django_to_airdatepicker_datetime(self._get_format())
|
||||
|
||||
if self.read_only:
|
||||
attrs["readonly"] = True
|
||||
|
||||
return attrs
|
||||
|
||||
def format_value(self, value):
|
||||
@@ -97,16 +99,20 @@ class AirDateTimePickerInput(widgets.DateTimeInput):
|
||||
timepicker=True,
|
||||
clear_button=True,
|
||||
auto_close=True,
|
||||
user=None,
|
||||
read_only=True,
|
||||
toggle_selected=None,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
attrs = attrs or {}
|
||||
self.user = user
|
||||
super().__init__(attrs=attrs, format=format, *args, **kwargs)
|
||||
self.timepicker = timepicker
|
||||
self.clear_button = clear_button
|
||||
self.auto_close = auto_close
|
||||
self.read_only = read_only
|
||||
self.toggle_selected = (
|
||||
toggle_selected if isinstance(toggle_selected, bool) else self.clear_button
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_current_language():
|
||||
@@ -120,12 +126,6 @@ class AirDateTimePickerInput(widgets.DateTimeInput):
|
||||
if self.format:
|
||||
return self.format
|
||||
|
||||
if self.user and hasattr(self.user, "settings"):
|
||||
user_format = self.user.settings.datetime_format
|
||||
if user_format == "SHORT_DATETIME_FORMAT":
|
||||
return get_format("SHORT_DATETIME_FORMAT", use_l10n=True)
|
||||
return user_format
|
||||
|
||||
return get_format("SHORT_DATETIME_FORMAT", use_l10n=True)
|
||||
|
||||
def build_attrs(self, base_attrs, extra_attrs=None):
|
||||
@@ -139,18 +139,27 @@ class AirDateTimePickerInput(widgets.DateTimeInput):
|
||||
attrs["data-now-button-txt"] = _("Now")
|
||||
attrs["data-timepicker"] = str(self.timepicker).lower()
|
||||
attrs["data-auto-close"] = str(self.auto_close).lower()
|
||||
attrs["data-toggle-selected"] = str(self.toggle_selected).lower()
|
||||
attrs["data-clear-button"] = str(self.clear_button).lower()
|
||||
attrs["data-language"] = self._get_current_language()
|
||||
attrs["data-date-format"] = date_format
|
||||
attrs["data-time-format"] = time_format
|
||||
|
||||
if self.read_only:
|
||||
attrs["readonly"] = True
|
||||
|
||||
return attrs
|
||||
|
||||
def format_value(self, value):
|
||||
"""Format the value for display in the widget."""
|
||||
if value:
|
||||
if value and isinstance(value, (datetime.date, datetime.datetime)):
|
||||
self.attrs["data-value"] = datetime.datetime.strftime(
|
||||
value, "%Y-%m-%d %H:%M:00"
|
||||
value, "%Y-%m-%dT%H:%M:00"
|
||||
)
|
||||
elif value and isinstance(value, str):
|
||||
value = datetime.datetime.strptime(value, "%Y-%m-%d %H:%M:00")
|
||||
self.attrs["data-value"] = datetime.datetime.strftime(
|
||||
value, "%Y-%m-%dT%H:%M:00"
|
||||
)
|
||||
|
||||
if value is None:
|
||||
@@ -195,6 +204,7 @@ class AirMonthYearPickerInput(AirDatePickerInput):
|
||||
|
||||
# Add data attributes for AirDatepicker configuration
|
||||
attrs["data-now-button-txt"] = _("Today")
|
||||
attrs["data-date-format"] = "MMMM yyyy"
|
||||
|
||||
return attrs
|
||||
|
||||
@@ -237,3 +247,56 @@ class AirMonthYearPickerInput(AirDatePickerInput):
|
||||
except (ValueError, KeyError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
class AirYearPickerInput(AirDatePickerInput):
|
||||
def __init__(self, attrs=None, format=None, *args, **kwargs):
|
||||
super().__init__(attrs=attrs, format=format, *args, **kwargs)
|
||||
# Store the display format for AirDatepicker
|
||||
self.display_format = "yyyy"
|
||||
# Store the Python format for internal use
|
||||
self.python_format = "%Y"
|
||||
|
||||
def build_attrs(self, base_attrs, extra_attrs=None):
|
||||
attrs = super().build_attrs(base_attrs, extra_attrs)
|
||||
|
||||
# Add data attributes for AirDatepicker configuration
|
||||
attrs["data-now-button-txt"] = _("Today")
|
||||
attrs["data-date-format"] = "yyyy"
|
||||
|
||||
return attrs
|
||||
|
||||
def format_value(self, value):
|
||||
"""Format the value for display in the widget."""
|
||||
if value:
|
||||
self.attrs["data-value"] = (
|
||||
value # We use this to dynamically select the initial date on AirDatePicker
|
||||
)
|
||||
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = datetime.datetime.strptime(value, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
return value
|
||||
if isinstance(value, (datetime.datetime, datetime.date)):
|
||||
# Use Django's date translation
|
||||
return f"{value.year}"
|
||||
return value
|
||||
|
||||
def value_from_datadict(self, data, files, name):
|
||||
"""Convert the value from the widget format back to a format Django can handle."""
|
||||
value = super().value_from_datadict(data, files, name)
|
||||
if value:
|
||||
try:
|
||||
# Split the value into month name and year
|
||||
year_str = value
|
||||
year = int(year_str)
|
||||
|
||||
if year:
|
||||
# Return the first day of the month in Django's expected format
|
||||
return datetime.date(year, 1, 1).strftime("%Y-%m-%d")
|
||||
except (ValueError, KeyError):
|
||||
return None
|
||||
return None
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from django import forms
|
||||
from django.utils.formats import get_format, number_format
|
||||
from django.utils.formats import number_format
|
||||
|
||||
from apps.common.functions.format import get_format
|
||||
|
||||
|
||||
def convert_to_decimal(value: str):
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.forms import widgets, SelectMultiple
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
@@ -129,3 +130,28 @@ class TomSelect(widgets.Select):
|
||||
|
||||
class TomSelectMultiple(SelectMultiple, TomSelect):
|
||||
pass
|
||||
|
||||
|
||||
class TransactionSelect(TomSelect):
|
||||
def __init__(self, income: bool = True, expense: bool = True, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.load_income = income
|
||||
self.load_expense = expense
|
||||
self.create = False
|
||||
|
||||
def build_attrs(self, base_attrs, extra_attrs=None):
|
||||
attrs = super().build_attrs(base_attrs, extra_attrs)
|
||||
|
||||
if self.load_income and self.load_expense:
|
||||
attrs["data-load"] = reverse("transactions_search")
|
||||
elif self.load_income and not self.load_expense:
|
||||
attrs["data-load"] = reverse(
|
||||
"transactions_search", kwargs={"filter_type": "income"}
|
||||
)
|
||||
elif self.load_expense and not self.load_income:
|
||||
attrs["data-load"] = reverse(
|
||||
"transactions_search", kwargs={"filter_type": "expenses"}
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from apps.currencies.models import Currency, ExchangeRate
|
||||
from apps.currencies.models import Currency, ExchangeRate, ExchangeRateService
|
||||
|
||||
|
||||
@admin.register(Currency)
|
||||
@@ -11,4 +11,19 @@ class CurrencyAdmin(admin.ModelAdmin):
|
||||
return super().formfield_for_dbfield(db_field, request, **kwargs)
|
||||
|
||||
|
||||
@admin.register(ExchangeRateService)
|
||||
class ExchangeRateServiceAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"name",
|
||||
"service_type",
|
||||
"is_active",
|
||||
"interval_type",
|
||||
"fetch_interval",
|
||||
"last_fetch",
|
||||
]
|
||||
list_filter = ["is_active", "service_type"]
|
||||
search_fields = ["name"]
|
||||
filter_horizontal = ["target_currencies"]
|
||||
|
||||
|
||||
admin.site.register(ExchangeRate)
|
||||
|
||||
0
app/apps/currencies/exchange_rates/__init__.py
Normal file
0
app/apps/currencies/exchange_rates/__init__.py
Normal file
30
app/apps/currencies/exchange_rates/base.py
Normal file
30
app/apps/currencies/exchange_rates/base.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from decimal import Decimal
|
||||
from typing import List, Tuple, Optional
|
||||
from django.db.models import QuerySet
|
||||
|
||||
from apps.currencies.models import Currency
|
||||
|
||||
|
||||
class ExchangeRateProvider(ABC):
|
||||
rates_inverted = False
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None):
|
||||
self.api_key = api_key
|
||||
|
||||
@abstractmethod
|
||||
def get_rates(
|
||||
self, target_currencies: QuerySet, exchange_currencies: set
|
||||
) -> List[Tuple[Currency, Currency, Decimal]]:
|
||||
"""Fetch exchange rates for multiple currency pairs"""
|
||||
raise NotImplementedError("Subclasses must implement get_rates method")
|
||||
|
||||
@classmethod
|
||||
def requires_api_key(cls) -> bool:
|
||||
"""Return True if the service requires an API key"""
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def invert_rate(rate: Decimal) -> Decimal:
|
||||
"""Invert the given rate."""
|
||||
return Decimal("1") / rate
|
||||
223
app/apps/currencies/exchange_rates/fetcher.py
Normal file
223
app/apps/currencies/exchange_rates/fetcher.py
Normal file
@@ -0,0 +1,223 @@
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db.models import QuerySet
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.currencies.exchange_rates.providers import (
|
||||
SynthFinanceProvider,
|
||||
CoinGeckoFreeProvider,
|
||||
CoinGeckoProProvider,
|
||||
)
|
||||
from apps.currencies.models import ExchangeRateService, ExchangeRate, Currency
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Map service types to provider classes
|
||||
PROVIDER_MAPPING = {
|
||||
"synth_finance": SynthFinanceProvider,
|
||||
"coingecko_free": CoinGeckoFreeProvider,
|
||||
"coingecko_pro": CoinGeckoProProvider,
|
||||
}
|
||||
|
||||
|
||||
class ExchangeRateFetcher:
|
||||
def _should_fetch_at_hour(service: ExchangeRateService, current_hour: int) -> bool:
|
||||
"""Check if service should fetch rates at given hour based on interval type."""
|
||||
try:
|
||||
if service.interval_type == ExchangeRateService.IntervalType.NOT_ON:
|
||||
blocked_hours = ExchangeRateService._parse_hour_ranges(
|
||||
service.fetch_interval
|
||||
)
|
||||
should_fetch = current_hour not in blocked_hours
|
||||
logger.info(
|
||||
f"NOT_ON check for {service.name}: "
|
||||
f"current_hour={current_hour}, "
|
||||
f"blocked_hours={blocked_hours}, "
|
||||
f"should_fetch={should_fetch}"
|
||||
)
|
||||
return should_fetch
|
||||
|
||||
if service.interval_type == ExchangeRateService.IntervalType.ON:
|
||||
allowed_hours = ExchangeRateService._parse_hour_ranges(
|
||||
service.fetch_interval
|
||||
)
|
||||
|
||||
should_fetch = current_hour in allowed_hours
|
||||
|
||||
logger.info(
|
||||
f"ON check for {service.name}: "
|
||||
f"current_hour={current_hour}, "
|
||||
f"allowed_hours={allowed_hours}, "
|
||||
f"should_fetch={should_fetch}"
|
||||
)
|
||||
|
||||
return should_fetch
|
||||
|
||||
if service.interval_type == ExchangeRateService.IntervalType.EVERY:
|
||||
try:
|
||||
interval_hours = int(service.fetch_interval)
|
||||
|
||||
if service.last_fetch is None:
|
||||
return True
|
||||
|
||||
# Round down to nearest hour
|
||||
now = timezone.now().replace(minute=0, second=0, microsecond=0)
|
||||
last_fetch = service.last_fetch.replace(
|
||||
minute=0, second=0, microsecond=0
|
||||
)
|
||||
|
||||
hours_since_last = (now - last_fetch).total_seconds() / 3600
|
||||
should_fetch = hours_since_last >= interval_hours
|
||||
|
||||
logger.info(
|
||||
f"EVERY check for {service.name}: "
|
||||
f"hours_since_last={hours_since_last:.1f}, "
|
||||
f"interval={interval_hours}, "
|
||||
f"should_fetch={should_fetch}"
|
||||
)
|
||||
return should_fetch
|
||||
except ValueError:
|
||||
logger.error(
|
||||
f"Invalid EVERY interval format for {service.name}: "
|
||||
f"expected single number, got '{service.fetch_interval}'"
|
||||
)
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"Error parsing fetch_interval for {service.name}: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def fetch_due_rates(force: bool = False) -> None:
|
||||
"""
|
||||
Fetch rates for all services that are due for update.
|
||||
Args:
|
||||
force (bool): If True, fetches all active services regardless of their schedule.
|
||||
"""
|
||||
services = ExchangeRateService.objects.filter(is_active=True)
|
||||
current_time = timezone.now().astimezone()
|
||||
current_hour = current_time.hour
|
||||
|
||||
for service in services:
|
||||
try:
|
||||
if force:
|
||||
logger.info(f"Force fetching rates for {service.name}")
|
||||
ExchangeRateFetcher._fetch_service_rates(service)
|
||||
continue
|
||||
|
||||
# Check if service should fetch based on interval type
|
||||
if ExchangeRateFetcher._should_fetch_at_hour(service, current_hour):
|
||||
logger.info(
|
||||
f"Fetching rates for {service.name}. "
|
||||
f"Last fetch: {service.last_fetch}, "
|
||||
f"Interval type: {service.interval_type}, "
|
||||
f"Current hour: {current_hour}"
|
||||
)
|
||||
ExchangeRateFetcher._fetch_service_rates(service)
|
||||
else:
|
||||
logger.debug(
|
||||
f"Skipping {service.name}. "
|
||||
f"Current hour: {current_hour}, "
|
||||
f"Interval type: {service.interval_type}, "
|
||||
f"Fetch interval: {service.fetch_interval}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking fetch schedule for {service.name}: {e}")
|
||||
|
||||
@staticmethod
|
||||
def _get_unique_currency_pairs(
|
||||
service: ExchangeRateService,
|
||||
) -> tuple[QuerySet, set]:
|
||||
"""
|
||||
Get unique currency pairs from both target_currencies and target_accounts
|
||||
Returns a tuple of (target_currencies QuerySet, exchange_currencies set)
|
||||
"""
|
||||
# Get currencies from target_currencies
|
||||
target_currencies = set(service.target_currencies.all())
|
||||
|
||||
# Add currencies from target_accounts
|
||||
for account in service.target_accounts.all():
|
||||
if account.currency and account.exchange_currency:
|
||||
target_currencies.add(account.currency)
|
||||
|
||||
# Convert back to QuerySet for compatibility with existing code
|
||||
target_currencies_qs = Currency.objects.filter(
|
||||
id__in=[curr.id for curr in target_currencies]
|
||||
)
|
||||
|
||||
# Get unique exchange currencies
|
||||
exchange_currencies = set()
|
||||
|
||||
# From target_currencies
|
||||
for currency in target_currencies:
|
||||
if currency.exchange_currency:
|
||||
exchange_currencies.add(currency.exchange_currency)
|
||||
|
||||
# From target_accounts
|
||||
for account in service.target_accounts.all():
|
||||
if account.exchange_currency:
|
||||
exchange_currencies.add(account.exchange_currency)
|
||||
|
||||
return target_currencies_qs, exchange_currencies
|
||||
|
||||
@staticmethod
|
||||
def _fetch_service_rates(service: ExchangeRateService) -> None:
|
||||
"""Fetch rates for a specific service"""
|
||||
try:
|
||||
provider = service.get_provider()
|
||||
|
||||
# Check if API key is required but missing
|
||||
if provider.requires_api_key() and not service.api_key:
|
||||
logger.error(f"API key required but not provided for {service.name}")
|
||||
return
|
||||
|
||||
# Get unique currency pairs from both sources
|
||||
target_currencies, exchange_currencies = (
|
||||
ExchangeRateFetcher._get_unique_currency_pairs(service)
|
||||
)
|
||||
|
||||
# Skip if no currencies to process
|
||||
if not target_currencies or not exchange_currencies:
|
||||
logger.info(f"No currency pairs to process for service {service.name}")
|
||||
return
|
||||
|
||||
rates = provider.get_rates(target_currencies, exchange_currencies)
|
||||
|
||||
# Track processed currency pairs to avoid duplicates
|
||||
processed_pairs = set()
|
||||
|
||||
for from_currency, to_currency, rate in rates:
|
||||
# Create a unique identifier for this currency pair
|
||||
pair_key = (from_currency.id, to_currency.id)
|
||||
if pair_key in processed_pairs:
|
||||
continue
|
||||
|
||||
if provider.rates_inverted:
|
||||
# If rates are inverted, we need to swap currencies
|
||||
ExchangeRate.objects.create(
|
||||
from_currency=to_currency,
|
||||
to_currency=from_currency,
|
||||
rate=rate,
|
||||
date=timezone.now(),
|
||||
)
|
||||
processed_pairs.add((to_currency.id, from_currency.id))
|
||||
else:
|
||||
# If rates are not inverted, we can use them as is
|
||||
ExchangeRate.objects.create(
|
||||
from_currency=from_currency,
|
||||
to_currency=to_currency,
|
||||
rate=rate,
|
||||
date=timezone.now(),
|
||||
)
|
||||
processed_pairs.add((from_currency.id, to_currency.id))
|
||||
|
||||
service.last_fetch = timezone.now()
|
||||
service.save()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching rates for {service.name}: {e}")
|
||||
152
app/apps/currencies/exchange_rates/providers.py
Normal file
152
app/apps/currencies/exchange_rates/providers.py
Normal file
@@ -0,0 +1,152 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
import requests
|
||||
from decimal import Decimal
|
||||
from typing import Tuple, List
|
||||
|
||||
from django.db.models import QuerySet
|
||||
|
||||
from apps.currencies.models import Currency
|
||||
from apps.currencies.exchange_rates.base import ExchangeRateProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SynthFinanceProvider(ExchangeRateProvider):
|
||||
"""Implementation for Synth Finance API (synthfinance.com)"""
|
||||
|
||||
BASE_URL = "https://api.synthfinance.com/rates/live"
|
||||
rates_inverted = False # SynthFinance returns non-inverted rates
|
||||
|
||||
def __init__(self, api_key: str = None):
|
||||
super().__init__(api_key)
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({"Authorization": f"Bearer {self.api_key}"})
|
||||
|
||||
def get_rates(
|
||||
self, target_currencies: QuerySet, exchange_currencies: set
|
||||
) -> List[Tuple[Currency, Currency, Decimal]]:
|
||||
results = []
|
||||
currency_groups = {}
|
||||
for currency in target_currencies:
|
||||
if currency.exchange_currency in exchange_currencies:
|
||||
group = currency_groups.setdefault(currency.exchange_currency.code, [])
|
||||
group.append(currency)
|
||||
|
||||
for base_currency, currencies in currency_groups.items():
|
||||
try:
|
||||
to_currencies = ",".join(
|
||||
currency.code
|
||||
for currency in currencies
|
||||
if currency.code != base_currency
|
||||
)
|
||||
response = self.session.get(
|
||||
f"{self.BASE_URL}",
|
||||
params={"from": base_currency, "to": to_currencies},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
rates = data["data"]["rates"]
|
||||
|
||||
for currency in currencies:
|
||||
if currency.code == base_currency:
|
||||
rate = Decimal("1")
|
||||
else:
|
||||
rate = Decimal(str(rates[currency.code]))
|
||||
# Return the rate as is, without inversion
|
||||
results.append((currency.exchange_currency, currency, rate))
|
||||
|
||||
credits_used = data["meta"]["credits_used"]
|
||||
credits_remaining = data["meta"]["credits_remaining"]
|
||||
logger.info(
|
||||
f"Synth Finance API call: {credits_used} credits used, {credits_remaining} remaining"
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
logger.error(
|
||||
f"Error fetching rates from Synth Finance API for base {base_currency}: {e}"
|
||||
)
|
||||
except KeyError as e:
|
||||
logger.error(
|
||||
f"Unexpected response structure from Synth Finance API for base {base_currency}: {e}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error processing Synth Finance data for base {base_currency}: {e}"
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
class CoinGeckoFreeProvider(ExchangeRateProvider):
|
||||
"""Implementation for CoinGecko Free API"""
|
||||
|
||||
BASE_URL = "https://api.coingecko.com/api/v3"
|
||||
rates_inverted = True
|
||||
|
||||
def __init__(self, api_key: str):
|
||||
super().__init__(api_key)
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({"x-cg-demo-api-key": api_key})
|
||||
|
||||
@classmethod
|
||||
def requires_api_key(cls) -> bool:
|
||||
return True
|
||||
|
||||
def get_rates(
|
||||
self, target_currencies: QuerySet, exchange_currencies: set
|
||||
) -> List[Tuple[Currency, Currency, Decimal]]:
|
||||
results = []
|
||||
all_currencies = set(currency.code.lower() for currency in target_currencies)
|
||||
all_currencies.update(currency.code.lower() for currency in exchange_currencies)
|
||||
|
||||
try:
|
||||
response = self.session.get(
|
||||
f"{self.BASE_URL}/simple/price",
|
||||
params={
|
||||
"ids": ",".join(all_currencies),
|
||||
"vs_currencies": ",".join(all_currencies),
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
rates_data = response.json()
|
||||
|
||||
for target_currency in target_currencies:
|
||||
if target_currency.exchange_currency in exchange_currencies:
|
||||
try:
|
||||
rate = Decimal(
|
||||
str(
|
||||
rates_data[target_currency.code.lower()][
|
||||
target_currency.exchange_currency.code.lower()
|
||||
]
|
||||
)
|
||||
)
|
||||
# The rate is already inverted, so we don't need to invert it again
|
||||
results.append(
|
||||
(target_currency.exchange_currency, target_currency, rate)
|
||||
)
|
||||
except KeyError:
|
||||
logger.error(
|
||||
f"Rate not found for {target_currency.code} or {target_currency.exchange_currency.code}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error calculating rate for {target_currency.code}: {e}"
|
||||
)
|
||||
|
||||
time.sleep(1) # CoinGecko allows 10-30 calls/minute for free tier
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Error fetching rates from CoinGecko API: {e}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
class CoinGeckoProProvider(CoinGeckoFreeProvider):
|
||||
"""Implementation for CoinGecko Pro API"""
|
||||
|
||||
BASE_URL = "https://pro-api.coingecko.com/api/v3/simple/price"
|
||||
rates_inverted = True
|
||||
|
||||
def __init__(self, api_key: str):
|
||||
super().__init__(api_key)
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({"x-cg-pro-api-key": api_key})
|
||||
@@ -1,6 +1,7 @@
|
||||
from crispy_bootstrap5.bootstrap5 import Switch
|
||||
from crispy_forms.bootstrap import FormActions
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout
|
||||
from crispy_forms.layout import Layout, Row, Column
|
||||
from django import forms
|
||||
from django.forms import CharField
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -9,7 +10,7 @@ from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
from apps.common.widgets.datepicker import AirDateTimePickerInput
|
||||
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
|
||||
from apps.common.widgets.tom_select import TomSelect
|
||||
from apps.currencies.models import Currency, ExchangeRate
|
||||
from apps.currencies.models import Currency, ExchangeRate, ExchangeRateService
|
||||
|
||||
|
||||
class CurrencyForm(forms.ModelForm):
|
||||
@@ -72,7 +73,7 @@ class ExchangeRateForm(forms.ModelForm):
|
||||
model = ExchangeRate
|
||||
fields = ["from_currency", "to_currency", "rate", "date"]
|
||||
|
||||
def __init__(self, *args, user=None, **kwargs):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper()
|
||||
@@ -81,8 +82,57 @@ class ExchangeRateForm(forms.ModelForm):
|
||||
self.helper.layout = Layout("date", "from_currency", "to_currency", "rate")
|
||||
|
||||
self.fields["rate"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
self.fields["date"].widget = AirDateTimePickerInput(
|
||||
clear_button=False, user=user
|
||||
self.fields["date"].widget = AirDateTimePickerInput(clear_button=False)
|
||||
|
||||
if self.instance and self.instance.pk:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
),
|
||||
)
|
||||
else:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ExchangeRateServiceForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = ExchangeRateService
|
||||
fields = [
|
||||
"name",
|
||||
"service_type",
|
||||
"is_active",
|
||||
"api_key",
|
||||
"interval_type",
|
||||
"fetch_interval",
|
||||
"target_currencies",
|
||||
"target_accounts",
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.form_method = "post"
|
||||
self.helper.layout = Layout(
|
||||
"name",
|
||||
"service_type",
|
||||
Switch("is_active"),
|
||||
"api_key",
|
||||
Row(
|
||||
Column("interval_type", css_class="form-group col-md-6"),
|
||||
Column("fetch_interval", css_class="form-group col-md-6"),
|
||||
),
|
||||
"target_currencies",
|
||||
"target_accounts",
|
||||
)
|
||||
|
||||
if self.instance and self.instance.pk:
|
||||
|
||||
32
app/apps/currencies/migrations/0007_exchangerateservice.py
Normal file
32
app/apps/currencies/migrations/0007_exchangerateservice.py
Normal file
@@ -0,0 +1,32 @@
|
||||
# Generated by Django 5.1.5 on 2025-02-02 20:35
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('currencies', '0006_currency_exchange_currency'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ExchangeRateService',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255, unique=True, verbose_name='Service Name')),
|
||||
('service_type', models.CharField(choices=[('synth_finance', 'Synth Finance'), ('coingecko', 'CoinGecko')], max_length=255, verbose_name='Service Type')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='Active')),
|
||||
('api_key', models.CharField(blank=True, help_text='API key for the service (if required)', max_length=255, null=True, verbose_name='API Key')),
|
||||
('fetch_interval_hours', models.PositiveIntegerField(default=24, validators=[django.core.validators.MinValueValidator(1)], verbose_name='Fetch Interval (hours)')),
|
||||
('last_fetch', models.DateTimeField(blank=True, null=True, verbose_name='Last Successful Fetch')),
|
||||
('target_currencies', models.ManyToManyField(help_text='Select currencies to fetch exchange rates for. Rates will be fetched for each currency against their exchange_currency.', related_name='exchange_services', to='currencies.currency', verbose_name='Target Currencies')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Exchange Rate Service',
|
||||
'verbose_name_plural': 'Exchange Rate Services',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.1.5 on 2025-02-03 01:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0008_alter_account_name'),
|
||||
('currencies', '0007_exchangerateservice'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='exchangerateservice',
|
||||
name='target_accounts',
|
||||
field=models.ManyToManyField(help_text="Select accounts to fetch exchange rates for. Rates will be fetched for each account's currency against their set exchange currency.", related_name='exchange_services', to='accounts.account', verbose_name='Target Accounts'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='exchangerateservice',
|
||||
name='target_currencies',
|
||||
field=models.ManyToManyField(help_text='Select currencies to fetch exchange rates for. Rates will be fetched for each currency against their set exchange currency.', related_name='exchange_services', to='currencies.currency', verbose_name='Target Currencies'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.1.5 on 2025-02-03 01:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0008_alter_account_name'),
|
||||
('currencies', '0008_exchangerateservice_target_accounts_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='exchangerateservice',
|
||||
name='target_accounts',
|
||||
field=models.ManyToManyField(blank=True, help_text="Select accounts to fetch exchange rates for. Rates will be fetched for each account's currency against their set exchange currency.", related_name='exchange_services', to='accounts.account', verbose_name='Target Accounts'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='exchangerateservice',
|
||||
name='target_currencies',
|
||||
field=models.ManyToManyField(blank=True, help_text='Select currencies to fetch exchange rates for. Rates will be fetched for each currency against their set exchange currency.', related_name='exchange_services', to='currencies.currency', verbose_name='Target Currencies'),
|
||||
),
|
||||
]
|
||||
18
app/apps/currencies/migrations/0010_alter_currency_code.py
Normal file
18
app/apps/currencies/migrations/0010_alter_currency_code.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.5 on 2025-02-03 03:17
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('currencies', '0009_alter_exchangerateservice_target_accounts_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='currency',
|
||||
name='code',
|
||||
field=models.CharField(max_length=255, verbose_name='Currency Code'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,32 @@
|
||||
# Generated by Django 5.1.5 on 2025-02-07 02:02
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('currencies', '0010_alter_currency_code'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='exchangerateservice',
|
||||
name='fetch_interval_hours',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='exchangerateservice',
|
||||
name='fetch_interval',
|
||||
field=models.CharField(default='24', max_length=1000, verbose_name='Interval'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='exchangerateservice',
|
||||
name='interval_type',
|
||||
field=models.CharField(choices=[('on', 'On'), ('every', 'Every X hours'), ('not_on', 'Not on')], default='every', max_length=255, verbose_name='Interval Type'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='exchangerateservice',
|
||||
name='service_type',
|
||||
field=models.CharField(choices=[('synth_finance', 'Synth Finance'), ('coingecko_free', 'CoinGecko (Demo/Free)'), ('coingecko_pro', 'CoinGecko (Pro)')], max_length=255, verbose_name='Service Type'),
|
||||
),
|
||||
]
|
||||
@@ -1,11 +1,18 @@
|
||||
import logging
|
||||
from typing import Set
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Currency(models.Model):
|
||||
code = models.CharField(max_length=10, unique=True, verbose_name=_("Currency Code"))
|
||||
code = models.CharField(
|
||||
max_length=255, unique=False, verbose_name=_("Currency Code")
|
||||
)
|
||||
name = models.CharField(max_length=50, verbose_name=_("Currency Name"), unique=True)
|
||||
decimal_places = models.PositiveIntegerField(
|
||||
default=2,
|
||||
@@ -72,7 +79,161 @@ class ExchangeRate(models.Model):
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if self.from_currency == self.to_currency:
|
||||
# Check if the attributes exist before comparing them
|
||||
if hasattr(self, "from_currency") and hasattr(self, "to_currency"):
|
||||
if self.from_currency == self.to_currency:
|
||||
raise ValidationError(
|
||||
{"to_currency": _("From and To currencies cannot be the same.")}
|
||||
)
|
||||
|
||||
|
||||
class ExchangeRateService(models.Model):
|
||||
"""Configuration for exchange rate services"""
|
||||
|
||||
class ServiceType(models.TextChoices):
|
||||
SYNTH_FINANCE = "synth_finance", "Synth Finance"
|
||||
COINGECKO_FREE = "coingecko_free", "CoinGecko (Demo/Free)"
|
||||
COINGECKO_PRO = "coingecko_pro", "CoinGecko (Pro)"
|
||||
|
||||
class IntervalType(models.TextChoices):
|
||||
ON = "on", _("On")
|
||||
EVERY = "every", _("Every X hours")
|
||||
NOT_ON = "not_on", _("Not on")
|
||||
|
||||
name = models.CharField(max_length=255, unique=True, verbose_name=_("Service Name"))
|
||||
service_type = models.CharField(
|
||||
max_length=255, choices=ServiceType.choices, verbose_name=_("Service Type")
|
||||
)
|
||||
is_active = models.BooleanField(default=True, verbose_name=_("Active"))
|
||||
api_key = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_("API Key"),
|
||||
help_text=_("API key for the service (if required)"),
|
||||
)
|
||||
interval_type = models.CharField(
|
||||
max_length=255,
|
||||
choices=IntervalType.choices,
|
||||
verbose_name=_("Interval Type"),
|
||||
default=IntervalType.EVERY,
|
||||
)
|
||||
fetch_interval = models.CharField(
|
||||
max_length=1000, verbose_name=_("Interval"), default="24"
|
||||
)
|
||||
last_fetch = models.DateTimeField(
|
||||
null=True, blank=True, verbose_name=_("Last Successful Fetch")
|
||||
)
|
||||
|
||||
target_currencies = models.ManyToManyField(
|
||||
Currency,
|
||||
verbose_name=_("Target Currencies"),
|
||||
help_text=_(
|
||||
"Select currencies to fetch exchange rates for. Rates will be fetched for each currency against their set exchange currency."
|
||||
),
|
||||
related_name="exchange_services",
|
||||
blank=True,
|
||||
)
|
||||
|
||||
target_accounts = models.ManyToManyField(
|
||||
"accounts.Account",
|
||||
verbose_name=_("Target Accounts"),
|
||||
help_text=_(
|
||||
"Select accounts to fetch exchange rates for. Rates will be fetched for each account's currency against their set exchange currency."
|
||||
),
|
||||
related_name="exchange_services",
|
||||
blank=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Exchange Rate Service")
|
||||
verbose_name_plural = _("Exchange Rate Services")
|
||||
ordering = ["name"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_provider(self):
|
||||
from apps.currencies.exchange_rates.fetcher import PROVIDER_MAPPING
|
||||
|
||||
provider_class = PROVIDER_MAPPING[self.service_type]
|
||||
return provider_class(self.api_key)
|
||||
|
||||
@staticmethod
|
||||
def _parse_hour_ranges(interval_str: str) -> Set[int]:
|
||||
"""
|
||||
Parse hour ranges and individual hours from string.
|
||||
|
||||
Valid formats:
|
||||
- Single hours: "1,5,9"
|
||||
- Ranges: "1-5"
|
||||
- Mixed: "1-5,8,10-12"
|
||||
|
||||
Returns set of hours.
|
||||
"""
|
||||
hours = set()
|
||||
|
||||
for part in interval_str.strip().split(","):
|
||||
part = part.strip()
|
||||
if "-" in part:
|
||||
start, end = part.split("-")
|
||||
start, end = int(start), int(end)
|
||||
if not (0 <= start <= 23 and 0 <= end <= 23):
|
||||
raise ValueError("Hours must be between 0 and 23")
|
||||
if start > end:
|
||||
raise ValueError(f"Invalid range: {start}-{end}")
|
||||
hours.update(range(start, end + 1))
|
||||
else:
|
||||
hour = int(part)
|
||||
if not 0 <= hour <= 23:
|
||||
raise ValueError("Hours must be between 0 and 23")
|
||||
hours.add(hour)
|
||||
|
||||
return hours
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
try:
|
||||
if self.interval_type == self.IntervalType.EVERY:
|
||||
if not self.fetch_interval.isdigit():
|
||||
raise ValidationError(
|
||||
{
|
||||
"fetch_interval": _(
|
||||
"'Every X hours' interval type requires a positive integer."
|
||||
)
|
||||
}
|
||||
)
|
||||
hours = int(self.fetch_interval)
|
||||
if hours < 0 or hours > 23:
|
||||
raise ValidationError(
|
||||
{
|
||||
"fetch_interval": _(
|
||||
"'Every X hours' interval must be between 0 and 23."
|
||||
)
|
||||
}
|
||||
)
|
||||
else:
|
||||
try:
|
||||
# Parse and validate hour ranges
|
||||
hours = self._parse_hour_ranges(self.fetch_interval)
|
||||
# Store in normalized format (optional)
|
||||
self.fetch_interval = ",".join(str(h) for h in sorted(hours))
|
||||
except ValueError as e:
|
||||
raise ValidationError(
|
||||
{
|
||||
"fetch_interval": _(
|
||||
"Invalid hour format. Use comma-separated hours (0-23) "
|
||||
"and/or ranges (e.g., '1-5,8,10-12')."
|
||||
)
|
||||
}
|
||||
)
|
||||
except ValidationError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise ValidationError(
|
||||
{"to_currency": _("From and To currencies cannot be the same.")}
|
||||
{
|
||||
"fetch_interval": _(
|
||||
"Invalid format. Please check the requirements for your selected interval type."
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
30
app/apps/currencies/tasks.py
Normal file
30
app/apps/currencies/tasks.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import logging
|
||||
|
||||
from procrastinate.contrib.django import app
|
||||
|
||||
from apps.currencies.exchange_rates.fetcher import ExchangeRateFetcher
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@app.periodic(cron="0 * * * *") # Run every hour
|
||||
@app.task(name="automatic_fetch_exchange_rates")
|
||||
def automatic_fetch_exchange_rates(timestamp=None):
|
||||
"""Fetch exchange rates for all due services"""
|
||||
fetcher = ExchangeRateFetcher()
|
||||
|
||||
try:
|
||||
fetcher.fetch_due_rates()
|
||||
except Exception as e:
|
||||
logger.error(e, exc_info=True)
|
||||
|
||||
|
||||
@app.task(name="manual_fetch_exchange_rates")
|
||||
def manual_fetch_exchange_rates(timestamp=None):
|
||||
"""Fetch exchange rates for all due services"""
|
||||
fetcher = ExchangeRateFetcher()
|
||||
|
||||
try:
|
||||
fetcher.fetch_due_rates(force=True)
|
||||
except Exception as e:
|
||||
logger.error(e, exc_info=True)
|
||||
@@ -34,4 +34,34 @@ urlpatterns = [
|
||||
views.exchange_rate_delete,
|
||||
name="exchange_rate_delete",
|
||||
),
|
||||
path(
|
||||
"automatic-exchange-rates/",
|
||||
views.exchange_rates_services_index,
|
||||
name="automatic_exchange_rates_index",
|
||||
),
|
||||
path(
|
||||
"automatic-exchange-rates/list/",
|
||||
views.exchange_rates_services_list,
|
||||
name="automatic_exchange_rates_list",
|
||||
),
|
||||
path(
|
||||
"automatic-exchange-rates/add/",
|
||||
views.exchange_rate_service_add,
|
||||
name="automatic_exchange_rate_add",
|
||||
),
|
||||
path(
|
||||
"automatic-exchange-rates/force-fetch/",
|
||||
views.exchange_rate_service_force_fetch,
|
||||
name="automatic_exchange_rate_force_fetch",
|
||||
),
|
||||
path(
|
||||
"automatic-exchange-rates/<int:pk>/edit/",
|
||||
views.exchange_rate_service_edit,
|
||||
name="automatic_exchange_rate_edit",
|
||||
),
|
||||
path(
|
||||
"automatic-exchange-rates/<int:pk>/delete/",
|
||||
views.exchange_rate_service_delete,
|
||||
name="automatic_exchange_rate_delete",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
from .currencies import *
|
||||
from .exchange_rates import *
|
||||
from .exchange_rates_services import *
|
||||
|
||||
@@ -27,17 +27,17 @@ def exchange_rates_index(request):
|
||||
@require_http_methods(["GET"])
|
||||
def exchange_rates_list(request):
|
||||
pairings = (
|
||||
ExchangeRate.objects.values("from_currency__code", "to_currency__code")
|
||||
ExchangeRate.objects.values("from_currency__name", "to_currency__name")
|
||||
.distinct()
|
||||
.annotate(
|
||||
pair=Concat(
|
||||
"from_currency__code",
|
||||
"from_currency__name",
|
||||
Value(" x "),
|
||||
"to_currency__code",
|
||||
"to_currency__name",
|
||||
output_field=CharField(),
|
||||
)
|
||||
)
|
||||
.values_list("pair", "from_currency__code", "to_currency__code")
|
||||
.values_list("pair", "from_currency__name", "to_currency__name")
|
||||
)
|
||||
|
||||
return render(
|
||||
@@ -56,7 +56,7 @@ def exchange_rates_list_pair(request):
|
||||
|
||||
if from_currency and to_currency:
|
||||
exchange_rates = ExchangeRate.objects.filter(
|
||||
from_currency__code=from_currency, to_currency__code=to_currency
|
||||
from_currency__name=from_currency, to_currency__name=to_currency
|
||||
).order_by("-date")
|
||||
else:
|
||||
exchange_rates = ExchangeRate.objects.all().order_by("-date")
|
||||
@@ -83,7 +83,7 @@ def exchange_rates_list_pair(request):
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def exchange_rate_add(request):
|
||||
if request.method == "POST":
|
||||
form = ExchangeRateForm(request.POST, user=request.user)
|
||||
form = ExchangeRateForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Exchange rate added successfully"))
|
||||
@@ -95,7 +95,7 @@ def exchange_rate_add(request):
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = ExchangeRateForm(user=request.user)
|
||||
form = ExchangeRateForm()
|
||||
|
||||
return render(
|
||||
request,
|
||||
@@ -111,7 +111,7 @@ def exchange_rate_edit(request, pk):
|
||||
exchange_rate = get_object_or_404(ExchangeRate, id=pk)
|
||||
|
||||
if request.method == "POST":
|
||||
form = ExchangeRateForm(request.POST, instance=exchange_rate, user=request.user)
|
||||
form = ExchangeRateForm(request.POST, instance=exchange_rate)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Exchange rate updated successfully"))
|
||||
@@ -123,7 +123,7 @@ def exchange_rate_edit(request, pk):
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = ExchangeRateForm(instance=exchange_rate, user=request.user)
|
||||
form = ExchangeRateForm(instance=exchange_rate)
|
||||
|
||||
return render(
|
||||
request,
|
||||
|
||||
122
app/apps/currencies/views/exchange_rates_services.py
Normal file
122
app/apps/currencies/views/exchange_rates_services.py
Normal file
@@ -0,0 +1,122 @@
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import CharField, Value
|
||||
from django.db.models.functions import Concat
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import render, get_object_or_404
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
from apps.currencies.forms import ExchangeRateForm, ExchangeRateServiceForm
|
||||
from apps.currencies.models import ExchangeRate, ExchangeRateService
|
||||
from apps.currencies.tasks import manual_fetch_exchange_rates
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def exchange_rates_services_index(request):
|
||||
return render(
|
||||
request,
|
||||
"exchange_rates_services/pages/index.html",
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def exchange_rates_services_list(request):
|
||||
services = ExchangeRateService.objects.all()
|
||||
|
||||
return render(
|
||||
request,
|
||||
"exchange_rates_services/fragments/list.html",
|
||||
{"services": services},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def exchange_rate_service_add(request):
|
||||
if request.method == "POST":
|
||||
form = ExchangeRateServiceForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Service added successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = ExchangeRateServiceForm()
|
||||
|
||||
return render(
|
||||
request,
|
||||
"exchange_rates_services/fragments/add.html",
|
||||
{"form": form},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def exchange_rate_service_edit(request, pk):
|
||||
service = get_object_or_404(ExchangeRateService, id=pk)
|
||||
|
||||
if request.method == "POST":
|
||||
form = ExchangeRateServiceForm(request.POST, instance=service)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Service updated successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = ExchangeRateServiceForm(instance=service)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"exchange_rates_services/fragments/edit.html",
|
||||
{"form": form, "service": service},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["DELETE"])
|
||||
def exchange_rate_service_delete(request, pk):
|
||||
service = get_object_or_404(ExchangeRateService, id=pk)
|
||||
|
||||
service.delete()
|
||||
|
||||
messages.success(request, _("Service deleted successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def exchange_rate_service_force_fetch(request):
|
||||
manual_fetch_exchange_rates.defer()
|
||||
messages.success(request, _("Services queued successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "toasts",
|
||||
},
|
||||
)
|
||||
@@ -1,14 +1,22 @@
|
||||
from crispy_forms.bootstrap import FormActions
|
||||
from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion
|
||||
from crispy_forms.bootstrap import FormActions, AccordionGroup
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, Row, Column
|
||||
from crispy_forms.layout import Layout, Row, Column, HTML
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.accounts.models import Account
|
||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
from apps.common.widgets.datepicker import AirDatePickerInput
|
||||
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
|
||||
from apps.common.widgets.tom_select import TomSelect
|
||||
from apps.dca.models import DCAStrategy, DCAEntry
|
||||
from apps.common.widgets.tom_select import TransactionSelect
|
||||
from apps.transactions.models import Transaction, TransactionTag, TransactionCategory
|
||||
from apps.common.fields.forms.dynamic_select import (
|
||||
DynamicModelChoiceField,
|
||||
DynamicModelMultipleChoiceField,
|
||||
)
|
||||
|
||||
|
||||
class DCAStrategyForm(forms.ModelForm):
|
||||
@@ -53,6 +61,75 @@ class DCAStrategyForm(forms.ModelForm):
|
||||
|
||||
|
||||
class DCAEntryForm(forms.ModelForm):
|
||||
create_transaction = forms.BooleanField(
|
||||
label=_("Create transaction"), initial=False, required=False
|
||||
)
|
||||
|
||||
from_account = forms.ModelChoiceField(
|
||||
queryset=Account.objects.filter(is_archived=False),
|
||||
label=_("From Account"),
|
||||
widget=TomSelect(clear_button=False, group_by="group"),
|
||||
required=False,
|
||||
)
|
||||
to_account = forms.ModelChoiceField(
|
||||
queryset=Account.objects.filter(is_archived=False),
|
||||
label=_("To Account"),
|
||||
widget=TomSelect(clear_button=False, group_by="group"),
|
||||
required=False,
|
||||
)
|
||||
|
||||
from_category = DynamicModelChoiceField(
|
||||
create_field="name",
|
||||
model=TransactionCategory,
|
||||
required=False,
|
||||
label=_("Category"),
|
||||
queryset=TransactionCategory.objects.filter(active=True),
|
||||
)
|
||||
to_category = DynamicModelChoiceField(
|
||||
create_field="name",
|
||||
model=TransactionCategory,
|
||||
required=False,
|
||||
label=_("Category"),
|
||||
queryset=TransactionCategory.objects.filter(active=True),
|
||||
)
|
||||
|
||||
from_tags = DynamicModelMultipleChoiceField(
|
||||
model=TransactionTag,
|
||||
to_field_name="name",
|
||||
create_field="name",
|
||||
required=False,
|
||||
label=_("Tags"),
|
||||
queryset=TransactionTag.objects.filter(active=True),
|
||||
)
|
||||
to_tags = DynamicModelMultipleChoiceField(
|
||||
model=TransactionTag,
|
||||
to_field_name="name",
|
||||
create_field="name",
|
||||
required=False,
|
||||
label=_("Tags"),
|
||||
queryset=TransactionTag.objects.filter(active=True),
|
||||
)
|
||||
|
||||
expense_transaction = DynamicModelChoiceField(
|
||||
model=Transaction,
|
||||
to_field_name="id",
|
||||
label=_("Expense Transaction"),
|
||||
required=False,
|
||||
queryset=Transaction.objects.none(),
|
||||
widget=TransactionSelect(clear_button=True, income=False, expense=True),
|
||||
help_text=_("Type to search for a transaction to link to this entry"),
|
||||
)
|
||||
|
||||
income_transaction = DynamicModelChoiceField(
|
||||
model=Transaction,
|
||||
to_field_name="id",
|
||||
label=_("Income Transaction"),
|
||||
required=False,
|
||||
queryset=Transaction.objects.none(),
|
||||
widget=TransactionSelect(clear_button=True, income=True, expense=False),
|
||||
help_text=_("Type to search for a transaction to link to this entry"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = DCAEntry
|
||||
fields = [
|
||||
@@ -60,13 +137,19 @@ class DCAEntryForm(forms.ModelForm):
|
||||
"amount_paid",
|
||||
"amount_received",
|
||||
"notes",
|
||||
"expense_transaction",
|
||||
"income_transaction",
|
||||
]
|
||||
widgets = {
|
||||
"notes": forms.Textarea(attrs={"rows": 3}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, user=None, **kwargs):
|
||||
def __init__(self, *args, **kwargs):
|
||||
strategy = kwargs.pop("strategy", None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.strategy = strategy if strategy else self.instance.strategy
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.layout = Layout(
|
||||
@@ -75,18 +158,66 @@ class DCAEntryForm(forms.ModelForm):
|
||||
Column("amount_paid", css_class="form-group col-md-6"),
|
||||
Column("amount_received", css_class="form-group col-md-6"),
|
||||
),
|
||||
Row(
|
||||
Column("expense_transaction", css_class="form-group col-md-6"),
|
||||
Column("income_transaction", css_class="form-group col-md-6"),
|
||||
),
|
||||
"notes",
|
||||
BS5Accordion(
|
||||
AccordionGroup(
|
||||
_("Create transaction"),
|
||||
Switch("create_transaction"),
|
||||
Row(
|
||||
Column(
|
||||
Row(
|
||||
Column(
|
||||
"from_account",
|
||||
css_class="form-group col-md-6 mb-0",
|
||||
),
|
||||
css_class="form-row",
|
||||
),
|
||||
Row(
|
||||
Column(
|
||||
"from_category",
|
||||
css_class="form-group col-md-6 mb-0",
|
||||
),
|
||||
Column(
|
||||
"from_tags", css_class="form-group col-md-6 mb-0"
|
||||
),
|
||||
css_class="form-row",
|
||||
),
|
||||
),
|
||||
css_class="p-1 mx-1 my-3 border rounded-3",
|
||||
),
|
||||
Row(
|
||||
Column(
|
||||
Row(
|
||||
Column(
|
||||
"to_account",
|
||||
css_class="form-group col-md-6 mb-0",
|
||||
),
|
||||
css_class="form-row",
|
||||
),
|
||||
Row(
|
||||
Column(
|
||||
"to_category", css_class="form-group col-md-6 mb-0"
|
||||
),
|
||||
Column("to_tags", css_class="form-group col-md-6 mb-0"),
|
||||
css_class="form-row",
|
||||
),
|
||||
),
|
||||
css_class="p-1 mx-1 my-3 border rounded-3",
|
||||
),
|
||||
active=False,
|
||||
),
|
||||
AccordionGroup(
|
||||
_("Link transaction"),
|
||||
"income_transaction",
|
||||
"expense_transaction",
|
||||
),
|
||||
flush=False,
|
||||
always_open=False,
|
||||
css_class="mb-3",
|
||||
),
|
||||
)
|
||||
|
||||
if self.instance and self.instance.pk:
|
||||
# decimal_places = self.instance.account.currency.decimal_places
|
||||
# self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput(
|
||||
# decimal_places=decimal_places
|
||||
# )
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
@@ -95,7 +226,6 @@ class DCAEntryForm(forms.ModelForm):
|
||||
),
|
||||
)
|
||||
else:
|
||||
# self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
@@ -106,4 +236,119 @@ class DCAEntryForm(forms.ModelForm):
|
||||
|
||||
self.fields["amount_paid"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
self.fields["amount_received"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
self.fields["date"].widget = AirDatePickerInput(clear_button=False, user=user)
|
||||
self.fields["date"].widget = AirDatePickerInput(clear_button=False)
|
||||
|
||||
expense_transaction = None
|
||||
income_transaction = None
|
||||
if self.instance and self.instance.pk:
|
||||
# Edit mode - get from instance
|
||||
expense_transaction = self.instance.expense_transaction
|
||||
income_transaction = self.instance.income_transaction
|
||||
elif self.data.get("expense_transaction"):
|
||||
# Form validation - get from submitted data
|
||||
try:
|
||||
expense_transaction = Transaction.objects.get(
|
||||
id=self.data["expense_transaction"]
|
||||
)
|
||||
income_transaction = Transaction.objects.get(
|
||||
id=self.data["income_transaction"]
|
||||
)
|
||||
except Transaction.DoesNotExist:
|
||||
pass
|
||||
|
||||
# If we have a current transaction, ensure it's in the queryset
|
||||
if income_transaction:
|
||||
self.fields["income_transaction"].queryset = Transaction.objects.filter(
|
||||
id=income_transaction.id
|
||||
)
|
||||
if expense_transaction:
|
||||
self.fields["expense_transaction"].queryset = Transaction.objects.filter(
|
||||
id=expense_transaction.id
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
|
||||
if cleaned_data.get("create_transaction"):
|
||||
from_account = cleaned_data.get("from_account")
|
||||
to_account = cleaned_data.get("to_account")
|
||||
|
||||
if not from_account and not to_account:
|
||||
raise forms.ValidationError(
|
||||
{
|
||||
"from_account": _("You must provide an account."),
|
||||
"to_account": _("You must provide an account."),
|
||||
}
|
||||
)
|
||||
elif not from_account and to_account:
|
||||
raise forms.ValidationError(
|
||||
{"from_account": _("You must provide an account.")}
|
||||
)
|
||||
elif not to_account and from_account:
|
||||
raise forms.ValidationError(
|
||||
{"to_account": _("You must provide an account.")}
|
||||
)
|
||||
|
||||
if from_account == to_account:
|
||||
raise forms.ValidationError(
|
||||
_("From and To accounts must be different.")
|
||||
)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
def save(self, **kwargs):
|
||||
instance = super().save(commit=False)
|
||||
|
||||
if self.cleaned_data.get("create_transaction"):
|
||||
from_account = self.cleaned_data["from_account"]
|
||||
to_account = self.cleaned_data["to_account"]
|
||||
from_amount = instance.amount_paid
|
||||
to_amount = instance.amount_received
|
||||
date = instance.date
|
||||
description = _("DCA for %(strategy_name)s") % {
|
||||
"strategy_name": self.strategy.name
|
||||
}
|
||||
from_category = self.cleaned_data.get("from_category")
|
||||
to_category = self.cleaned_data.get("to_category")
|
||||
notes = self.cleaned_data.get("notes")
|
||||
|
||||
# Create "From" transaction
|
||||
from_transaction = Transaction.objects.create(
|
||||
account=from_account,
|
||||
type=Transaction.Type.EXPENSE,
|
||||
is_paid=True,
|
||||
date=date,
|
||||
amount=from_amount,
|
||||
description=description,
|
||||
category=from_category,
|
||||
notes=notes,
|
||||
)
|
||||
from_transaction.tags.set(self.cleaned_data.get("from_tags", []))
|
||||
|
||||
# Create "To" transaction
|
||||
to_transaction = Transaction.objects.create(
|
||||
account=to_account,
|
||||
type=Transaction.Type.INCOME,
|
||||
is_paid=True,
|
||||
date=date,
|
||||
amount=to_amount,
|
||||
description=description,
|
||||
category=to_category,
|
||||
notes=notes,
|
||||
)
|
||||
to_transaction.tags.set(self.cleaned_data.get("to_tags", []))
|
||||
|
||||
instance.expense_transaction = from_transaction
|
||||
instance.income_transaction = to_transaction
|
||||
else:
|
||||
if instance.expense_transaction:
|
||||
instance.expense_transaction.amount = instance.amount_paid
|
||||
instance.expense_transaction.save()
|
||||
if instance.income_transaction:
|
||||
instance.income_transaction.amount = instance.amount_received
|
||||
instance.income_transaction.save()
|
||||
|
||||
instance.strategy = self.strategy
|
||||
instance.save()
|
||||
|
||||
return instance
|
||||
|
||||
@@ -155,11 +155,9 @@ def strategy_detail(request, strategy_id):
|
||||
def strategy_entry_add(request, strategy_id):
|
||||
strategy = get_object_or_404(DCAStrategy, id=strategy_id)
|
||||
if request.method == "POST":
|
||||
form = DCAEntryForm(request.POST, user=request.user)
|
||||
form = DCAEntryForm(request.POST, strategy=strategy)
|
||||
if form.is_valid():
|
||||
entry = form.save(commit=False)
|
||||
entry.strategy = strategy
|
||||
entry.save()
|
||||
entry = form.save()
|
||||
messages.success(request, _("Entry added successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
@@ -169,7 +167,7 @@ def strategy_entry_add(request, strategy_id):
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = DCAEntryForm(user=request.user)
|
||||
form = DCAEntryForm(strategy=strategy)
|
||||
|
||||
return render(
|
||||
request,
|
||||
@@ -184,7 +182,7 @@ def strategy_entry_edit(request, strategy_id, entry_id):
|
||||
dca_entry = get_object_or_404(DCAEntry, id=entry_id, strategy__id=strategy_id)
|
||||
|
||||
if request.method == "POST":
|
||||
form = DCAEntryForm(request.POST, instance=dca_entry, user=request.user)
|
||||
form = DCAEntryForm(request.POST, instance=dca_entry)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Entry updated successfully"))
|
||||
@@ -196,7 +194,7 @@ def strategy_entry_edit(request, strategy_id, entry_id):
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = DCAEntryForm(instance=dca_entry, user=request.user)
|
||||
form = DCAEntryForm(instance=dca_entry)
|
||||
|
||||
return render(
|
||||
request,
|
||||
|
||||
0
app/apps/export_app/__init__.py
Normal file
0
app/apps/export_app/__init__.py
Normal file
3
app/apps/export_app/admin.py
Normal file
3
app/apps/export_app/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
6
app/apps/export_app/apps.py
Normal file
6
app/apps/export_app/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ExportConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.export_app"
|
||||
189
app/apps/export_app/forms.py
Normal file
189
app/apps/export_app/forms.py
Normal file
@@ -0,0 +1,189 @@
|
||||
from crispy_forms.bootstrap import FormActions
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, HTML
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
|
||||
|
||||
class ExportForm(forms.Form):
|
||||
accounts = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
label=_("Accounts"),
|
||||
initial=True,
|
||||
)
|
||||
currencies = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
label=_("Currencies"),
|
||||
initial=True,
|
||||
)
|
||||
transactions = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
label=_("Transactions"),
|
||||
initial=True,
|
||||
)
|
||||
categories = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
label=_("Categories"),
|
||||
initial=True,
|
||||
)
|
||||
tags = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
label=_("Tags"),
|
||||
initial=False,
|
||||
)
|
||||
entities = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
label=_("Entities"),
|
||||
initial=False,
|
||||
)
|
||||
recurring_transactions = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
label=_("Recurring Transactions"),
|
||||
initial=True,
|
||||
)
|
||||
installment_plans = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
label=_("Installment Plans"),
|
||||
initial=True,
|
||||
)
|
||||
exchange_rates = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
label=_("Exchange Rates"),
|
||||
initial=False,
|
||||
)
|
||||
exchange_rates_services = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
label=_("Automatic Exchange Rates"),
|
||||
initial=False,
|
||||
)
|
||||
rules = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
label=_("Rules"),
|
||||
initial=True,
|
||||
)
|
||||
dca = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
label=_("DCA"),
|
||||
initial=False,
|
||||
)
|
||||
import_profiles = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
label=_("Import Profiles"),
|
||||
initial=True,
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.form_method = "post"
|
||||
self.helper.layout = Layout(
|
||||
"accounts",
|
||||
"currencies",
|
||||
"transactions",
|
||||
"categories",
|
||||
"entities",
|
||||
"tags",
|
||||
"installment_plans",
|
||||
"recurring_transactions",
|
||||
"exchange_rates_services",
|
||||
"exchange_rates",
|
||||
"rules",
|
||||
"dca",
|
||||
"import_profiles",
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Export"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class RestoreForm(forms.Form):
|
||||
zip_file = forms.FileField(
|
||||
required=False,
|
||||
help_text=_("Import a ZIP file exported from WYGIWYH"),
|
||||
label=_("ZIP File"),
|
||||
)
|
||||
accounts = forms.FileField(required=False, label=_("Accounts"))
|
||||
currencies = forms.FileField(required=False, label=_("Currencies"))
|
||||
transactions_categories = forms.FileField(required=False, label=_("Categories"))
|
||||
transactions_tags = forms.FileField(required=False, label=_("Tags"))
|
||||
transactions_entities = forms.FileField(required=False, label=_("Entities"))
|
||||
transactions = forms.FileField(required=False, label=_("Transactions"))
|
||||
installment_plans = forms.FileField(required=False, label=_("Installment Plans"))
|
||||
recurring_transactions = forms.FileField(
|
||||
required=False, label=_("Recurring Transactions")
|
||||
)
|
||||
automatic_exchange_rates = forms.FileField(
|
||||
required=False, label=_("Automatic Exchange Rates")
|
||||
)
|
||||
exchange_rates = forms.FileField(required=False, label=_("Exchange Rates"))
|
||||
transaction_rules = forms.FileField(required=False, label=_("Transaction rules"))
|
||||
transaction_rules_actions = forms.FileField(
|
||||
required=False, label=_("Edit transaction action")
|
||||
)
|
||||
transaction_rules_update_or_create = forms.FileField(
|
||||
required=False, label=_("Update or create transaction actions")
|
||||
)
|
||||
dca_strategies = forms.FileField(required=False, label=_("DCA Strategies"))
|
||||
dca_entries = forms.FileField(required=False, label=_("DCA Entries"))
|
||||
import_profiles = forms.FileField(required=False, label=_("Import Profiles"))
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.form_method = "post"
|
||||
self.helper.layout = Layout(
|
||||
"zip_file",
|
||||
HTML("<hr />"),
|
||||
"accounts",
|
||||
"currencies",
|
||||
"transactions",
|
||||
"transactions_categories",
|
||||
"transactions_entities",
|
||||
"transactions_tags",
|
||||
"installment_plans",
|
||||
"recurring_transactions",
|
||||
"automatic_exchange_rates",
|
||||
"exchange_rates",
|
||||
"transaction_rules",
|
||||
"transaction_rules_actions",
|
||||
"transaction_rules_update_or_create",
|
||||
"dca_strategies",
|
||||
"dca_entries",
|
||||
"import_profiles",
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Restore"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
if not cleaned_data.get("zip_file") and not any(
|
||||
cleaned_data.get(field) for field in self.fields if field != "zip_file"
|
||||
):
|
||||
raise forms.ValidationError(
|
||||
_("Please upload either a ZIP file or at least one CSV file")
|
||||
)
|
||||
return cleaned_data
|
||||
0
app/apps/export_app/migrations/__init__.py
Normal file
0
app/apps/export_app/migrations/__init__.py
Normal file
3
app/apps/export_app/models.py
Normal file
3
app/apps/export_app/models.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
0
app/apps/export_app/resources/__init__.py
Normal file
0
app/apps/export_app/resources/__init__.py
Normal file
26
app/apps/export_app/resources/accounts.py
Normal file
26
app/apps/export_app/resources/accounts.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from import_export import fields, resources, widgets
|
||||
|
||||
from apps.accounts.models import Account, AccountGroup
|
||||
from apps.export_app.widgets.foreign_key import AutoCreateForeignKeyWidget
|
||||
from apps.currencies.models import Currency
|
||||
|
||||
|
||||
class AccountResource(resources.ModelResource):
|
||||
group = fields.Field(
|
||||
attribute="group",
|
||||
column_name="group",
|
||||
widget=AutoCreateForeignKeyWidget(AccountGroup, "name"),
|
||||
)
|
||||
currency = fields.Field(
|
||||
attribute="currency",
|
||||
column_name="currency",
|
||||
widget=widgets.ForeignKeyWidget(Currency, "name"),
|
||||
)
|
||||
exchange_currency = fields.Field(
|
||||
attribute="exchange_currency",
|
||||
column_name="exchange_currency",
|
||||
widget=widgets.ForeignKeyWidget(Currency, "name"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Account
|
||||
47
app/apps/export_app/resources/currencies.py
Normal file
47
app/apps/export_app/resources/currencies.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from import_export import fields, resources, widgets
|
||||
|
||||
from apps.accounts.models import Account
|
||||
from apps.currencies.models import Currency, ExchangeRate, ExchangeRateService
|
||||
|
||||
|
||||
class CurrencyResource(resources.ModelResource):
|
||||
exchange_currency = fields.Field(
|
||||
attribute="exchange_currency",
|
||||
column_name="exchange_currency",
|
||||
widget=widgets.ForeignKeyWidget(Currency, "name"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Currency
|
||||
|
||||
|
||||
class ExchangeRateResource(resources.ModelResource):
|
||||
from_currency = fields.Field(
|
||||
attribute="from_currency",
|
||||
column_name="from_currency",
|
||||
widget=widgets.ForeignKeyWidget(Currency, "name"),
|
||||
)
|
||||
to_currency = fields.Field(
|
||||
attribute="to_currency",
|
||||
column_name="to_currency",
|
||||
widget=widgets.ForeignKeyWidget(Currency, "name"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ExchangeRate
|
||||
|
||||
|
||||
class ExchangeRateServiceResource(resources.ModelResource):
|
||||
target_currencies = fields.Field(
|
||||
attribute="target_currencies",
|
||||
column_name="target_currencies",
|
||||
widget=widgets.ManyToManyWidget(Currency, field="name"),
|
||||
)
|
||||
target_accounts = fields.Field(
|
||||
attribute="target_accounts",
|
||||
column_name="target_accounts",
|
||||
widget=widgets.ManyToManyWidget(Account, field="name"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ExchangeRateService
|
||||
26
app/apps/export_app/resources/dca.py
Normal file
26
app/apps/export_app/resources/dca.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from import_export import fields, resources
|
||||
from import_export.widgets import ForeignKeyWidget
|
||||
|
||||
from apps.dca.models import DCAStrategy, DCAEntry
|
||||
from apps.currencies.models import Currency
|
||||
|
||||
|
||||
class DCAStrategyResource(resources.ModelResource):
|
||||
target_currency = fields.Field(
|
||||
attribute="target_currency",
|
||||
column_name="target_currency",
|
||||
widget=ForeignKeyWidget(Currency, "name"),
|
||||
)
|
||||
payment_currency = fields.Field(
|
||||
attribute="payment_currency",
|
||||
column_name="payment_currency",
|
||||
widget=ForeignKeyWidget(Currency, "name"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = DCAStrategy
|
||||
|
||||
|
||||
class DCAEntryResource(resources.ModelResource):
|
||||
class Meta:
|
||||
model = DCAEntry
|
||||
8
app/apps/export_app/resources/import_app.py
Normal file
8
app/apps/export_app/resources/import_app.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from import_export import resources
|
||||
|
||||
from apps.import_app.models import ImportProfile
|
||||
|
||||
|
||||
class ImportProfileResource(resources.ModelResource):
|
||||
class Meta:
|
||||
model = ImportProfile
|
||||
25
app/apps/export_app/resources/rules.py
Normal file
25
app/apps/export_app/resources/rules.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from import_export import fields, resources
|
||||
from import_export.widgets import ForeignKeyWidget
|
||||
|
||||
from apps.export_app.widgets.foreign_key import AutoCreateForeignKeyWidget
|
||||
from apps.export_app.widgets.many_to_many import AutoCreateManyToManyWidget
|
||||
from apps.rules.models import (
|
||||
TransactionRule,
|
||||
TransactionRuleAction,
|
||||
UpdateOrCreateTransactionRuleAction,
|
||||
)
|
||||
|
||||
|
||||
class TransactionRuleResource(resources.ModelResource):
|
||||
class Meta:
|
||||
model = TransactionRule
|
||||
|
||||
|
||||
class TransactionRuleActionResource(resources.ModelResource):
|
||||
class Meta:
|
||||
model = TransactionRuleAction
|
||||
|
||||
|
||||
class UpdateOrCreateTransactionRuleResource(resources.ModelResource):
|
||||
class Meta:
|
||||
model = UpdateOrCreateTransactionRuleAction
|
||||
124
app/apps/export_app/resources/transactions.py
Normal file
124
app/apps/export_app/resources/transactions.py
Normal file
@@ -0,0 +1,124 @@
|
||||
from import_export import fields, resources
|
||||
from import_export.widgets import ForeignKeyWidget
|
||||
|
||||
from apps.accounts.models import Account
|
||||
from apps.export_app.widgets.foreign_key import AutoCreateForeignKeyWidget
|
||||
from apps.export_app.widgets.many_to_many import AutoCreateManyToManyWidget
|
||||
from apps.export_app.widgets.string import EmptyStringToNoneField
|
||||
from apps.transactions.models import (
|
||||
Transaction,
|
||||
TransactionCategory,
|
||||
TransactionTag,
|
||||
TransactionEntity,
|
||||
RecurringTransaction,
|
||||
InstallmentPlan,
|
||||
)
|
||||
|
||||
|
||||
class TransactionResource(resources.ModelResource):
|
||||
account = fields.Field(
|
||||
attribute="account",
|
||||
column_name="account",
|
||||
widget=ForeignKeyWidget(Account, "name"),
|
||||
)
|
||||
|
||||
category = fields.Field(
|
||||
attribute="category",
|
||||
column_name="category",
|
||||
widget=AutoCreateForeignKeyWidget(TransactionCategory, "name"),
|
||||
)
|
||||
|
||||
tags = fields.Field(
|
||||
attribute="tags",
|
||||
column_name="tags",
|
||||
widget=AutoCreateManyToManyWidget(TransactionTag, field="name"),
|
||||
)
|
||||
|
||||
entities = fields.Field(
|
||||
attribute="entities",
|
||||
column_name="entities",
|
||||
widget=AutoCreateManyToManyWidget(TransactionEntity, field="name"),
|
||||
)
|
||||
|
||||
internal_id = EmptyStringToNoneField(
|
||||
column_name="internal_id", attribute="internal_id"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Transaction
|
||||
|
||||
def get_queryset(self):
|
||||
return Transaction.all_objects.all()
|
||||
|
||||
|
||||
class TransactionTagResource(resources.ModelResource):
|
||||
class Meta:
|
||||
model = TransactionTag
|
||||
|
||||
|
||||
class TransactionEntityResource(resources.ModelResource):
|
||||
class Meta:
|
||||
model = TransactionEntity
|
||||
|
||||
|
||||
class TransactionCategoyResource(resources.ModelResource):
|
||||
class Meta:
|
||||
model = TransactionCategory
|
||||
|
||||
|
||||
class RecurringTransactionResource(resources.ModelResource):
|
||||
account = fields.Field(
|
||||
attribute="account",
|
||||
column_name="account",
|
||||
widget=ForeignKeyWidget(Account, "name"),
|
||||
)
|
||||
|
||||
category = fields.Field(
|
||||
attribute="category",
|
||||
column_name="category",
|
||||
widget=AutoCreateForeignKeyWidget(TransactionCategory, "name"),
|
||||
)
|
||||
|
||||
tags = fields.Field(
|
||||
attribute="tags",
|
||||
column_name="tags",
|
||||
widget=AutoCreateManyToManyWidget(TransactionTag, field="name"),
|
||||
)
|
||||
|
||||
entities = fields.Field(
|
||||
attribute="entities",
|
||||
column_name="entities",
|
||||
widget=AutoCreateManyToManyWidget(TransactionEntity, field="name"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = RecurringTransaction
|
||||
|
||||
|
||||
class InstallmentPlanResource(resources.ModelResource):
|
||||
account = fields.Field(
|
||||
attribute="account",
|
||||
column_name="account",
|
||||
widget=ForeignKeyWidget(Account, "name"),
|
||||
)
|
||||
|
||||
category = fields.Field(
|
||||
attribute="category",
|
||||
column_name="category",
|
||||
widget=AutoCreateForeignKeyWidget(TransactionCategory, "name"),
|
||||
)
|
||||
|
||||
tags = fields.Field(
|
||||
attribute="tags",
|
||||
column_name="tags",
|
||||
widget=AutoCreateManyToManyWidget(TransactionTag, field="name"),
|
||||
)
|
||||
|
||||
entities = fields.Field(
|
||||
attribute="entities",
|
||||
column_name="entities",
|
||||
widget=AutoCreateManyToManyWidget(TransactionEntity, field="name"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = InstallmentPlan
|
||||
3
app/apps/export_app/tests.py
Normal file
3
app/apps/export_app/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
8
app/apps/export_app/urls.py
Normal file
8
app/apps/export_app/urls.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from django.urls import path
|
||||
import apps.export_app.views as views
|
||||
|
||||
urlpatterns = [
|
||||
path("export/", views.export_index, name="export_index"),
|
||||
path("export/form/", views.export_form, name="export_form"),
|
||||
path("export/restore/", views.import_form, name="restore_form"),
|
||||
]
|
||||
285
app/apps/export_app/views.py
Normal file
285
app/apps/export_app/views.py
Normal file
@@ -0,0 +1,285 @@
|
||||
import logging
|
||||
import zipfile
|
||||
from io import BytesIO, TextIOWrapper
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db import transaction
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import render
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from tablib import Dataset
|
||||
|
||||
from apps.export_app.forms import ExportForm, RestoreForm
|
||||
from apps.export_app.resources.accounts import AccountResource
|
||||
from apps.export_app.resources.transactions import (
|
||||
TransactionResource,
|
||||
TransactionTagResource,
|
||||
TransactionEntityResource,
|
||||
TransactionCategoyResource,
|
||||
InstallmentPlanResource,
|
||||
RecurringTransactionResource,
|
||||
)
|
||||
from apps.export_app.resources.currencies import (
|
||||
CurrencyResource,
|
||||
ExchangeRateResource,
|
||||
ExchangeRateServiceResource,
|
||||
)
|
||||
from apps.export_app.resources.rules import (
|
||||
TransactionRuleResource,
|
||||
TransactionRuleActionResource,
|
||||
UpdateOrCreateTransactionRuleResource,
|
||||
)
|
||||
from apps.export_app.resources.dca import (
|
||||
DCAStrategyResource,
|
||||
DCAEntryResource,
|
||||
)
|
||||
from apps.export_app.resources.import_app import (
|
||||
ImportProfileResource,
|
||||
)
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def export_index(request):
|
||||
return render(request, "export_app/pages/index.html")
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def export_form(request):
|
||||
timestamp = timezone.localtime(timezone.now()).strftime("%Y-%m-%dT%H-%M-%S")
|
||||
|
||||
if request.method == "POST":
|
||||
form = ExportForm(request.POST)
|
||||
if form.is_valid():
|
||||
zip_buffer = BytesIO()
|
||||
|
||||
export_accounts = form.cleaned_data.get("accounts", False)
|
||||
export_currencies = form.cleaned_data.get("currencies", False)
|
||||
export_transactions = form.cleaned_data.get("transactions", False)
|
||||
export_categories = form.cleaned_data.get("categories", False)
|
||||
export_tags = form.cleaned_data.get("tags", False)
|
||||
export_entities = form.cleaned_data.get("entities", False)
|
||||
export_installment_plans = form.cleaned_data.get("installment_plans", False)
|
||||
export_recurring_transactions = form.cleaned_data.get(
|
||||
"recurring_transactions", False
|
||||
)
|
||||
|
||||
export_exchange_rates_services = form.cleaned_data.get(
|
||||
"exchange_rates_services", False
|
||||
)
|
||||
export_exchange_rates = form.cleaned_data.get("exchange_rates", False)
|
||||
export_rules = form.cleaned_data.get("rules", False)
|
||||
export_dca = form.cleaned_data.get("dca", False)
|
||||
export_import_profiles = form.cleaned_data.get("import_profiles", False)
|
||||
|
||||
exports = []
|
||||
if export_accounts:
|
||||
exports.append((AccountResource().export(), "accounts"))
|
||||
if export_currencies:
|
||||
exports.append((CurrencyResource().export(), "currencies"))
|
||||
if export_transactions:
|
||||
exports.append((TransactionResource().export(), "transactions"))
|
||||
if export_categories:
|
||||
exports.append(
|
||||
(TransactionCategoyResource().export(), "transactions_categories")
|
||||
)
|
||||
if export_tags:
|
||||
exports.append((TransactionTagResource().export(), "transactions_tags"))
|
||||
if export_entities:
|
||||
exports.append(
|
||||
(TransactionEntityResource().export(), "transactions_entities")
|
||||
)
|
||||
if export_installment_plans:
|
||||
exports.append(
|
||||
(InstallmentPlanResource().export(), "installment_plans")
|
||||
)
|
||||
if export_recurring_transactions:
|
||||
exports.append(
|
||||
(RecurringTransactionResource().export(), "recurring_transactions")
|
||||
)
|
||||
if export_exchange_rates_services:
|
||||
exports.append(
|
||||
(ExchangeRateServiceResource().export(), "automatic_exchange_rates")
|
||||
)
|
||||
if export_exchange_rates:
|
||||
exports.append((ExchangeRateResource().export(), "exchange_rates"))
|
||||
if export_rules:
|
||||
exports.append(
|
||||
(TransactionRuleResource().export(), "transaction_rules")
|
||||
)
|
||||
exports.append(
|
||||
(
|
||||
TransactionRuleActionResource().export(),
|
||||
"transaction_rules_actions",
|
||||
)
|
||||
)
|
||||
exports.append(
|
||||
(
|
||||
UpdateOrCreateTransactionRuleResource().export(),
|
||||
"transaction_rules_update_or_create",
|
||||
)
|
||||
)
|
||||
if export_dca:
|
||||
exports.append((DCAStrategyResource().export(), "dca_strategies"))
|
||||
exports.append(
|
||||
(
|
||||
DCAEntryResource().export(),
|
||||
"dca_entries",
|
||||
)
|
||||
)
|
||||
if export_import_profiles:
|
||||
exports.append((ImportProfileResource().export(), "import_profiles"))
|
||||
|
||||
if len(exports) >= 2:
|
||||
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
|
||||
for dataset, name in exports:
|
||||
zip_file.writestr(f"{name}.csv", dataset.csv)
|
||||
|
||||
response = HttpResponse(
|
||||
zip_buffer.getvalue(),
|
||||
content_type="application/zip",
|
||||
headers={
|
||||
"HX-Trigger": "hide_offcanvas, updated",
|
||||
"Content-Disposition": f'attachment; filename="{timestamp}_WYGIWYH_export.zip"',
|
||||
},
|
||||
)
|
||||
return response
|
||||
elif len(exports) == 1:
|
||||
dataset, name = exports[0]
|
||||
|
||||
response = HttpResponse(
|
||||
dataset.csv,
|
||||
content_type="text/csv",
|
||||
headers={
|
||||
"HX-Trigger": "hide_offcanvas, updated",
|
||||
"Content-Disposition": f'attachment; filename="{timestamp}_WYGIWYH_export_{name}.csv"',
|
||||
},
|
||||
)
|
||||
return response
|
||||
else:
|
||||
return HttpResponse(
|
||||
_("You have to select at least one export"),
|
||||
)
|
||||
|
||||
else:
|
||||
form = ExportForm()
|
||||
|
||||
return render(request, "export_app/fragments/export.html", context={"form": form})
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def import_form(request):
|
||||
if request.method == "POST":
|
||||
form = RestoreForm(request.POST, request.FILES)
|
||||
if form.is_valid():
|
||||
try:
|
||||
process_imports(request, form.cleaned_data)
|
||||
messages.success(request, _("Data restored successfully"))
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "hide_offcanvas, updated",
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error importing", exc_info=e)
|
||||
messages.error(
|
||||
request,
|
||||
_(
|
||||
"There was an error restoring your data. Check the logs for more details."
|
||||
),
|
||||
)
|
||||
else:
|
||||
form = RestoreForm()
|
||||
|
||||
response = render(request, "export_app/fragments/restore.html", {"form": form})
|
||||
response["HX-Trigger"] = "updated"
|
||||
return response
|
||||
|
||||
|
||||
def process_imports(request, cleaned_data):
|
||||
# Define import order to handle dependencies
|
||||
import_order = [
|
||||
("currencies", CurrencyResource),
|
||||
(
|
||||
"currencies",
|
||||
CurrencyResource,
|
||||
), # We do a double pass because exchange_currency may not exist when currency is initially created
|
||||
("accounts", AccountResource),
|
||||
("transactions_categories", TransactionCategoyResource),
|
||||
("transactions_tags", TransactionTagResource),
|
||||
("transactions_entities", TransactionEntityResource),
|
||||
("automatic_exchange_rates", ExchangeRateServiceResource),
|
||||
("exchange_rates", ExchangeRateResource),
|
||||
("installment_plans", InstallmentPlanResource),
|
||||
("recurring_transactions", RecurringTransactionResource),
|
||||
("transactions", TransactionResource),
|
||||
("dca_strategies", DCAStrategyResource),
|
||||
("dca_entries", DCAEntryResource),
|
||||
("import_profiles", ImportProfileResource),
|
||||
("transaction_rules", TransactionRuleResource),
|
||||
("transaction_rules_actions", TransactionRuleActionResource),
|
||||
("transaction_rules_update_or_create", UpdateOrCreateTransactionRuleResource),
|
||||
]
|
||||
|
||||
def import_dataset(content, resource_class, field_name):
|
||||
try:
|
||||
# Create a new resource instance
|
||||
resource = resource_class()
|
||||
|
||||
# Create dataset from CSV content
|
||||
dataset = Dataset()
|
||||
dataset.load(content, format="csv")
|
||||
|
||||
# Debug logging
|
||||
logger.debug(f"Importing {field_name}")
|
||||
logger.debug(f"Headers: {dataset.headers}")
|
||||
logger.debug(f"First row: {dataset[0] if len(dataset) > 0 else 'No data'}")
|
||||
|
||||
# Perform the import
|
||||
result = resource.import_data(
|
||||
dataset,
|
||||
dry_run=False,
|
||||
raise_errors=True,
|
||||
collect_failed_rows=True,
|
||||
use_transactions=False,
|
||||
skip_unchanged=True,
|
||||
)
|
||||
|
||||
if result.has_errors():
|
||||
raise ImportError(f"Failed rows: {result.failed_dataset}")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error importing {field_name}: {str(e)}")
|
||||
raise ImportError(f"Error importing {field_name}: {str(e)}")
|
||||
|
||||
with transaction.atomic():
|
||||
if zip_file := cleaned_data.get("zip_file"):
|
||||
# Process ZIP file
|
||||
with zipfile.ZipFile(zip_file) as z:
|
||||
for filename in z.namelist():
|
||||
name = filename.replace(".csv", "")
|
||||
with z.open(filename) as f:
|
||||
content = f.read().decode("utf-8")
|
||||
|
||||
for field_name, resource_class in import_order:
|
||||
if name == field_name:
|
||||
import_dataset(content, resource_class, field_name)
|
||||
break
|
||||
else:
|
||||
# Process individual files
|
||||
for field_name, resource_class in import_order:
|
||||
if csv_file := cleaned_data.get(field_name):
|
||||
content = csv_file.read().decode("utf-8")
|
||||
import_dataset(content, resource_class, field_name)
|
||||
0
app/apps/export_app/widgets/__init__.py
Normal file
0
app/apps/export_app/widgets/__init__.py
Normal file
11
app/apps/export_app/widgets/foreign_key.py
Normal file
11
app/apps/export_app/widgets/foreign_key.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from import_export.widgets import ForeignKeyWidget
|
||||
|
||||
|
||||
class AutoCreateForeignKeyWidget(ForeignKeyWidget):
|
||||
def clean(self, value, row=None, *args, **kwargs):
|
||||
if value:
|
||||
try:
|
||||
return super().clean(value, row, **kwargs)
|
||||
except self.model.DoesNotExist:
|
||||
return self.model.objects.create(name=value)
|
||||
return None
|
||||
21
app/apps/export_app/widgets/many_to_many.py
Normal file
21
app/apps/export_app/widgets/many_to_many.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from import_export.widgets import ManyToManyWidget
|
||||
|
||||
|
||||
class AutoCreateManyToManyWidget(ManyToManyWidget):
|
||||
def clean(self, value, row=None, *args, **kwargs):
|
||||
if not value:
|
||||
return []
|
||||
|
||||
values = value.split(self.separator)
|
||||
cleaned_values = []
|
||||
|
||||
for val in values:
|
||||
val = val.strip()
|
||||
if val:
|
||||
try:
|
||||
obj = self.model.objects.get(**{self.field: val})
|
||||
except self.model.DoesNotExist:
|
||||
obj = self.model.objects.create(name=val)
|
||||
cleaned_values.append(obj)
|
||||
|
||||
return cleaned_values
|
||||
7
app/apps/export_app/widgets/string.py
Normal file
7
app/apps/export_app/widgets/string.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from import_export import fields
|
||||
|
||||
|
||||
class EmptyStringToNoneField(fields.Field):
|
||||
def clean(self, data, **kwargs):
|
||||
value = super().clean(data)
|
||||
return None if value == "" else value
|
||||
@@ -47,6 +47,34 @@ class SplitTransformationRule(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class AddTransformationRule(BaseModel):
|
||||
type: Literal["add"]
|
||||
field: str = Field(..., description="Field to add to the source value")
|
||||
absolute_values: bool = Field(
|
||||
default=False, description="Use absolute values for addition"
|
||||
)
|
||||
thousand_separator: str = Field(
|
||||
default="", description="Thousand separator character"
|
||||
)
|
||||
decimal_separator: str = Field(
|
||||
default=".", description="Decimal separator character"
|
||||
)
|
||||
|
||||
|
||||
class SubtractTransformationRule(BaseModel):
|
||||
type: Literal["subtract"]
|
||||
field: str = Field(..., description="Field to subtract from the source value")
|
||||
absolute_values: bool = Field(
|
||||
default=False, description="Use absolute values for subtraction"
|
||||
)
|
||||
thousand_separator: str = Field(
|
||||
default="", description="Thousand separator character"
|
||||
)
|
||||
decimal_separator: str = Field(
|
||||
default=".", description="Decimal separator character"
|
||||
)
|
||||
|
||||
|
||||
class CSVImportSettings(BaseModel):
|
||||
skip_errors: bool = Field(
|
||||
default=False,
|
||||
@@ -64,8 +92,22 @@ class CSVImportSettings(BaseModel):
|
||||
]
|
||||
|
||||
|
||||
class ExcelImportSettings(BaseModel):
|
||||
skip_errors: bool = Field(
|
||||
default=False,
|
||||
description="If True, errors during import will be logged and skipped",
|
||||
)
|
||||
file_type: Literal["xls", "xlsx"]
|
||||
trigger_transaction_rules: bool = True
|
||||
importing: Literal[
|
||||
"transactions", "accounts", "currencies", "categories", "tags", "entities"
|
||||
]
|
||||
start_row: int = Field(default=1, description="Where your header is located")
|
||||
sheets: list[str] | str = "*"
|
||||
|
||||
|
||||
class ColumnMapping(BaseModel):
|
||||
source: Optional[str] = Field(
|
||||
source: Optional[str] | Optional[list[str]] = Field(
|
||||
default=None,
|
||||
description="CSV column header. If None, the field will be generated from transformations",
|
||||
)
|
||||
@@ -78,6 +120,8 @@ class ColumnMapping(BaseModel):
|
||||
| HashTransformationRule
|
||||
| MergeTransformationRule
|
||||
| SplitTransformationRule
|
||||
| AddTransformationRule
|
||||
| SubtractTransformationRule
|
||||
]
|
||||
] = Field(default_factory=list)
|
||||
|
||||
@@ -86,7 +130,6 @@ class TransactionAccountMapping(ColumnMapping):
|
||||
target: Literal["account"] = Field(..., description="Transaction field to map to")
|
||||
type: Literal["id", "name"] = "name"
|
||||
coerce_to: Literal["str|int"] = Field("str|int", frozen=True)
|
||||
required: bool = Field(True, frozen=True)
|
||||
|
||||
|
||||
class TransactionTypeMapping(ColumnMapping):
|
||||
@@ -105,7 +148,6 @@ class TransactionDateMapping(ColumnMapping):
|
||||
target: Literal["date"] = Field(..., description="Transaction field to map to")
|
||||
format: List[str] | str
|
||||
coerce_to: Literal["date"] = Field("date", frozen=True)
|
||||
required: bool = Field(True, frozen=True)
|
||||
|
||||
|
||||
class TransactionReferenceDateMapping(ColumnMapping):
|
||||
@@ -119,7 +161,6 @@ class TransactionReferenceDateMapping(ColumnMapping):
|
||||
class TransactionAmountMapping(ColumnMapping):
|
||||
target: Literal["amount"] = Field(..., description="Transaction field to map to")
|
||||
coerce_to: Literal["positive_decimal"] = Field("positive_decimal", frozen=True)
|
||||
required: bool = Field(True, frozen=True)
|
||||
|
||||
|
||||
class TransactionDescriptionMapping(ColumnMapping):
|
||||
@@ -301,7 +342,7 @@ class CurrencyExchangeMapping(ColumnMapping):
|
||||
|
||||
|
||||
class ImportProfileSchema(BaseModel):
|
||||
settings: CSVImportSettings
|
||||
settings: CSVImportSettings | ExcelImportSettings
|
||||
mapping: Dict[
|
||||
str,
|
||||
TransactionAccountMapping
|
||||
|
||||
@@ -3,14 +3,16 @@ import hashlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from datetime import datetime, date
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Dict, Any, Literal, Union
|
||||
|
||||
import cachalot.api
|
||||
import openpyxl
|
||||
import xlrd
|
||||
import yaml
|
||||
from cachalot.api import cachalot_disabled
|
||||
from django.utils import timezone
|
||||
from openpyxl.utils.exceptions import InvalidFileException
|
||||
|
||||
from apps.accounts.models import Account, AccountGroup
|
||||
from apps.currencies.models import Currency
|
||||
@@ -40,7 +42,9 @@ class ImportService:
|
||||
self.import_run: ImportRun = import_run
|
||||
self.profile: ImportProfile = import_run.profile
|
||||
self.config: version_1.ImportProfileSchema = self._load_config()
|
||||
self.settings: version_1.CSVImportSettings = self.config.settings
|
||||
self.settings: version_1.CSVImportSettings | version_1.ExcelImportSettings = (
|
||||
self.config.settings
|
||||
)
|
||||
self.deduplication: list[version_1.CompareDeduplicationRule] = (
|
||||
self.config.deduplication
|
||||
)
|
||||
@@ -75,6 +79,13 @@ class ImportService:
|
||||
self.import_run.logs += log_line
|
||||
self.import_run.save(update_fields=["logs"])
|
||||
|
||||
if level == "info":
|
||||
logger.info(log_line)
|
||||
elif level == "warning":
|
||||
logger.warning(log_line)
|
||||
elif level == "error":
|
||||
logger.error(log_line, exc_info=True)
|
||||
|
||||
def _update_totals(
|
||||
self,
|
||||
field: Literal["total", "processed", "successful", "skipped", "failed"],
|
||||
@@ -129,9 +140,12 @@ class ImportService:
|
||||
|
||||
self.import_run.save(update_fields=["status"])
|
||||
|
||||
@staticmethod
|
||||
def _transform_value(
|
||||
value: str, mapping: version_1.ColumnMapping, row: Dict[str, str] = None
|
||||
self,
|
||||
value: str,
|
||||
mapping: version_1.ColumnMapping,
|
||||
row: Dict[str, str] = None,
|
||||
mapped_data: Dict[str, Any] = None,
|
||||
) -> Any:
|
||||
transformed = value
|
||||
|
||||
@@ -142,8 +156,12 @@ class ImportService:
|
||||
for field in transform.fields:
|
||||
if field in row:
|
||||
values_to_hash.append(str(row[field]))
|
||||
|
||||
# Create hash from concatenated values
|
||||
elif (
|
||||
field.startswith("__")
|
||||
and mapped_data
|
||||
and field[2:] in mapped_data
|
||||
):
|
||||
values_to_hash.append(str(mapped_data[field[2:]]))
|
||||
if values_to_hash:
|
||||
concatenated = "|".join(values_to_hash)
|
||||
transformed = hashlib.sha256(concatenated.encode()).hexdigest()
|
||||
@@ -157,6 +175,7 @@ class ImportService:
|
||||
transformed = transformed.replace(
|
||||
transform.pattern, transform.replacement
|
||||
)
|
||||
|
||||
elif transform.type == "regex":
|
||||
if transform.exclusive:
|
||||
transformed = re.sub(
|
||||
@@ -166,16 +185,25 @@ class ImportService:
|
||||
transformed = re.sub(
|
||||
transform.pattern, transform.replacement, transformed
|
||||
)
|
||||
|
||||
elif transform.type == "date_format":
|
||||
transformed = datetime.strptime(
|
||||
transformed, transform.original_format
|
||||
).strftime(transform.new_format)
|
||||
|
||||
elif transform.type == "merge":
|
||||
values_to_merge = []
|
||||
for field in transform.fields:
|
||||
if field in row:
|
||||
values_to_merge.append(str(row[field]))
|
||||
elif (
|
||||
field.startswith("__")
|
||||
and mapped_data
|
||||
and field[2:] in mapped_data
|
||||
):
|
||||
values_to_merge.append(str(mapped_data[field[2:]]))
|
||||
transformed = transform.separator.join(values_to_merge)
|
||||
|
||||
elif transform.type == "split":
|
||||
parts = transformed.split(transform.separator)
|
||||
if transform.index is not None:
|
||||
@@ -183,6 +211,38 @@ class ImportService:
|
||||
else:
|
||||
transformed = parts
|
||||
|
||||
elif transform.type in ["add", "subtract"]:
|
||||
try:
|
||||
source_value = Decimal(transformed)
|
||||
|
||||
# First check row data, then mapped data if not found
|
||||
field_value = row.get(transform.field)
|
||||
if field_value is None and transform.field.startswith("__"):
|
||||
field_value = mapped_data.get(transform.field[2:])
|
||||
|
||||
if field_value is None:
|
||||
raise KeyError(
|
||||
f"Field '{transform.field}' not found in row or mapped data"
|
||||
)
|
||||
|
||||
field_value = self._prepare_numeric_value(
|
||||
str(field_value),
|
||||
transform.thousand_separator,
|
||||
transform.decimal_separator,
|
||||
)
|
||||
|
||||
if transform.absolute_values:
|
||||
source_value = abs(source_value)
|
||||
field_value = abs(field_value)
|
||||
|
||||
if transform.type == "add":
|
||||
transformed = str(source_value + field_value)
|
||||
else: # subtract
|
||||
transformed = str(source_value - field_value)
|
||||
except (InvalidOperation, KeyError, AttributeError) as e:
|
||||
logger.warning(
|
||||
f"Error in {transform.type} transformation: {e}. Values: {transformed}, {transform.field}"
|
||||
)
|
||||
return transformed
|
||||
|
||||
def _create_transaction(self, data: Dict[str, Any]) -> Transaction:
|
||||
@@ -399,7 +459,7 @@ class ImportService:
|
||||
|
||||
def _coerce_type(
|
||||
self, value: str, mapping: version_1.ColumnMapping
|
||||
) -> Union[str, int, bool, Decimal, datetime, list]:
|
||||
) -> Union[str, int, bool, Decimal, datetime, list, None]:
|
||||
if not value:
|
||||
return None
|
||||
|
||||
@@ -434,6 +494,11 @@ class ImportService:
|
||||
version_1.TransactionReferenceDateMapping,
|
||||
),
|
||||
):
|
||||
if isinstance(value, datetime):
|
||||
return value.date()
|
||||
elif isinstance(value, date):
|
||||
return value
|
||||
|
||||
formats = (
|
||||
mapping.format
|
||||
if isinstance(mapping.format, list)
|
||||
@@ -484,18 +549,30 @@ class ImportService:
|
||||
|
||||
def _map_row(self, row: Dict[str, str]) -> Dict[str, Any]:
|
||||
mapped_data = {}
|
||||
|
||||
for field, mapping in self.mapping.items():
|
||||
# If source is None, use None as the initial value
|
||||
value = row.get(mapping.source) if mapping.source else None
|
||||
value = None
|
||||
if isinstance(mapping.source, str):
|
||||
if mapping.source in row:
|
||||
value = row[mapping.source]
|
||||
elif (
|
||||
mapping.source.startswith("__")
|
||||
and mapping.source[2:] in mapped_data
|
||||
):
|
||||
value = mapped_data[mapping.source[2:]]
|
||||
elif isinstance(mapping.source, list):
|
||||
for source in mapping.source:
|
||||
if source in row:
|
||||
value = row[source]
|
||||
break
|
||||
elif source.startswith("__") and source[2:] in mapped_data:
|
||||
value = mapped_data[source[2:]]
|
||||
break
|
||||
|
||||
# Use default_value if value is None
|
||||
if value is None:
|
||||
value = mapping.default
|
||||
|
||||
# Apply transformations
|
||||
if mapping.transformations:
|
||||
value = self._transform_value(value, mapping, row)
|
||||
value = self._transform_value(value, mapping, row, mapped_data)
|
||||
|
||||
value = self._coerce_type(value, mapping)
|
||||
|
||||
@@ -503,17 +580,29 @@ class ImportService:
|
||||
raise ValueError(f"Required field {field} is missing")
|
||||
|
||||
if value is not None:
|
||||
# Remove the prefix from the target field
|
||||
target = mapping.target
|
||||
if self.settings.importing == "transactions":
|
||||
mapped_data[target] = value
|
||||
else:
|
||||
# Remove the model prefix (e.g., "account_" from "account_name")
|
||||
field_name = target.split("_", 1)[1]
|
||||
mapped_data[field_name] = value
|
||||
|
||||
return mapped_data
|
||||
|
||||
@staticmethod
|
||||
def _prepare_numeric_value(
|
||||
value: str, thousand_separator: str, decimal_separator: str
|
||||
) -> Decimal:
|
||||
# Remove thousand separators
|
||||
if thousand_separator:
|
||||
value = value.replace(thousand_separator, "")
|
||||
|
||||
# Replace decimal separator with dot
|
||||
if decimal_separator != ".":
|
||||
value = value.replace(decimal_separator, ".")
|
||||
|
||||
return Decimal(value)
|
||||
|
||||
def _process_row(self, row: Dict[str, str], row_number: int) -> None:
|
||||
try:
|
||||
mapped_data = self._map_row(row)
|
||||
@@ -579,6 +668,151 @@ class ImportService:
|
||||
for row_number, row in enumerate(reader, start=1):
|
||||
self._process_row(row, row_number)
|
||||
|
||||
def _process_excel(self, file_path):
|
||||
try:
|
||||
if self.settings.file_type == "xlsx":
|
||||
workbook = openpyxl.load_workbook(
|
||||
file_path, read_only=True, data_only=True
|
||||
)
|
||||
sheets_to_process = (
|
||||
workbook.sheetnames
|
||||
if self.settings.sheets == "*"
|
||||
else (
|
||||
self.settings.sheets
|
||||
if isinstance(self.settings.sheets, list)
|
||||
else [self.settings.sheets]
|
||||
)
|
||||
)
|
||||
|
||||
# Calculate total rows
|
||||
total_rows = sum(
|
||||
max(0, workbook[sheet_name].max_row - self.settings.start_row)
|
||||
for sheet_name in sheets_to_process
|
||||
if sheet_name in workbook.sheetnames
|
||||
)
|
||||
self._update_totals("total", value=total_rows)
|
||||
|
||||
# Process sheets
|
||||
for sheet_name in sheets_to_process:
|
||||
if sheet_name not in workbook.sheetnames:
|
||||
self._log(
|
||||
"warning",
|
||||
f"Sheet '{sheet_name}' not found in the Excel file. Skipping.",
|
||||
)
|
||||
continue
|
||||
|
||||
sheet = workbook[sheet_name]
|
||||
self._log("info", f"Processing sheet: {sheet_name}")
|
||||
headers = [
|
||||
str(cell.value or "") for cell in sheet[self.settings.start_row]
|
||||
]
|
||||
|
||||
for row_number, row in enumerate(
|
||||
sheet.iter_rows(
|
||||
min_row=self.settings.start_row + 1, values_only=True
|
||||
),
|
||||
start=1,
|
||||
):
|
||||
try:
|
||||
row_data = {
|
||||
key: str(value) if value is not None else None
|
||||
for key, value in zip(headers, row)
|
||||
}
|
||||
self._process_row(row_data, row_number)
|
||||
except Exception as e:
|
||||
if self.settings.skip_errors:
|
||||
self._log(
|
||||
"warning",
|
||||
f"Error processing row {row_number} in sheet '{sheet_name}': {str(e)}",
|
||||
)
|
||||
self._increment_totals("failed", value=1)
|
||||
else:
|
||||
raise
|
||||
|
||||
workbook.close()
|
||||
|
||||
else: # xls
|
||||
workbook = xlrd.open_workbook(file_path)
|
||||
sheets_to_process = (
|
||||
workbook.sheet_names()
|
||||
if self.settings.sheets == "*"
|
||||
else (
|
||||
self.settings.sheets
|
||||
if isinstance(self.settings.sheets, list)
|
||||
else [self.settings.sheets]
|
||||
)
|
||||
)
|
||||
# Calculate total rows
|
||||
total_rows = sum(
|
||||
max(
|
||||
0,
|
||||
workbook.sheet_by_name(sheet_name).nrows
|
||||
- self.settings.start_row,
|
||||
)
|
||||
for sheet_name in sheets_to_process
|
||||
if sheet_name in workbook.sheet_names()
|
||||
)
|
||||
self._update_totals("total", value=total_rows)
|
||||
# Process sheets
|
||||
for sheet_name in sheets_to_process:
|
||||
if sheet_name not in workbook.sheet_names():
|
||||
self._log(
|
||||
"warning",
|
||||
f"Sheet '{sheet_name}' not found in the Excel file. Skipping.",
|
||||
)
|
||||
continue
|
||||
sheet = workbook.sheet_by_name(sheet_name)
|
||||
self._log("info", f"Processing sheet: {sheet_name}")
|
||||
headers = [
|
||||
str(sheet.cell_value(self.settings.start_row - 1, col) or "")
|
||||
for col in range(sheet.ncols)
|
||||
]
|
||||
for row_number in range(self.settings.start_row, sheet.nrows):
|
||||
try:
|
||||
row_data = {}
|
||||
for col, key in enumerate(headers):
|
||||
cell_type = sheet.cell_type(row_number, col)
|
||||
cell_value = sheet.cell_value(row_number, col)
|
||||
|
||||
if cell_type == xlrd.XL_CELL_DATE:
|
||||
# Convert Excel date to Python datetime
|
||||
try:
|
||||
python_date = datetime(
|
||||
*xlrd.xldate_as_tuple(
|
||||
cell_value, workbook.datemode
|
||||
)
|
||||
)
|
||||
row_data[key] = python_date
|
||||
except Exception:
|
||||
# If date conversion fails, use the original value
|
||||
row_data[key] = (
|
||||
str(cell_value)
|
||||
if cell_value is not None
|
||||
else None
|
||||
)
|
||||
elif cell_value is None:
|
||||
row_data[key] = None
|
||||
else:
|
||||
row_data[key] = str(cell_value)
|
||||
|
||||
self._process_row(
|
||||
row_data, row_number - self.settings.start_row + 1
|
||||
)
|
||||
except Exception as e:
|
||||
if self.settings.skip_errors:
|
||||
self._log(
|
||||
"warning",
|
||||
f"Error processing row {row_number} in sheet '{sheet_name}': {str(e)}",
|
||||
)
|
||||
self._increment_totals("failed", value=1)
|
||||
else:
|
||||
raise
|
||||
|
||||
except (InvalidFileException, xlrd.XLRDError) as e:
|
||||
raise ValueError(
|
||||
f"Invalid {self.settings.file_type.upper()} file format: {str(e)}"
|
||||
)
|
||||
|
||||
def _validate_file_path(self, file_path: str) -> str:
|
||||
"""
|
||||
Validates that the file path is within the allowed temporary directory.
|
||||
@@ -601,8 +835,10 @@ class ImportService:
|
||||
self._log("info", "Starting import process")
|
||||
|
||||
try:
|
||||
if self.settings.file_type == "csv":
|
||||
if isinstance(self.settings, version_1.CSVImportSettings):
|
||||
self._process_csv(file_path)
|
||||
elif isinstance(self.settings, version_1.ExcelImportSettings):
|
||||
self._process_excel(file_path)
|
||||
|
||||
self._update_status("FINISHED")
|
||||
self._log(
|
||||
@@ -629,4 +865,3 @@ class ImportService:
|
||||
|
||||
self.import_run.finished_at = timezone.now()
|
||||
self.import_run.save(update_fields=["finished_at"])
|
||||
cachalot.api.invalidate()
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import logging
|
||||
|
||||
import cachalot.api
|
||||
from procrastinate.contrib.django import app
|
||||
|
||||
from apps.import_app.models import ImportRun
|
||||
@@ -9,13 +8,11 @@ from apps.import_app.services import ImportServiceV1
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@app.task
|
||||
@app.task(name="process_import")
|
||||
def process_import(import_run_id: int, file_path: str):
|
||||
try:
|
||||
import_run = ImportRun.objects.get(id=import_run_id)
|
||||
import_service = ImportServiceV1(import_run)
|
||||
import_service.process_file(file_path)
|
||||
cachalot.api.invalidate()
|
||||
except ImportRun.DoesNotExist:
|
||||
cachalot.api.invalidate()
|
||||
raise ValueError(f"ImportRun with id {import_run_id} not found")
|
||||
|
||||
0
app/apps/insights/__init__.py
Normal file
0
app/apps/insights/__init__.py
Normal file
3
app/apps/insights/admin.py
Normal file
3
app/apps/insights/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
6
app/apps/insights/apps.py
Normal file
6
app/apps/insights/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class InsightsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.insights"
|
||||
131
app/apps/insights/forms.py
Normal file
131
app/apps/insights/forms.py
Normal file
@@ -0,0 +1,131 @@
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, Field, Row, Column
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.common.widgets.datepicker import (
|
||||
AirMonthYearPickerInput,
|
||||
AirYearPickerInput,
|
||||
AirDatePickerInput,
|
||||
)
|
||||
from apps.transactions.models import TransactionCategory
|
||||
from apps.common.widgets.tom_select import TomSelect
|
||||
|
||||
|
||||
class SingleMonthForm(forms.Form):
|
||||
month = forms.DateField(
|
||||
widget=AirMonthYearPickerInput(clear_button=False), label="", required=True
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.disable_csrf = True
|
||||
|
||||
self.helper.layout = Layout(Field("month"))
|
||||
|
||||
|
||||
class SingleYearForm(forms.Form):
|
||||
year = forms.DateField(
|
||||
widget=AirYearPickerInput(clear_button=False), label="", required=True
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.disable_csrf = True
|
||||
|
||||
self.helper.layout = Layout(Field("year"))
|
||||
|
||||
|
||||
class MonthRangeForm(forms.Form):
|
||||
month_from = forms.DateField(
|
||||
widget=AirMonthYearPickerInput(clear_button=False), label="", required=True
|
||||
)
|
||||
month_to = forms.DateField(
|
||||
widget=AirMonthYearPickerInput(clear_button=False), label="", required=True
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.disable_csrf = True
|
||||
|
||||
self.helper.layout = Layout(
|
||||
Row(
|
||||
Column("month_from", css_class="form-group col-md-6"),
|
||||
Column("month_to", css_class="form-group col-md-6"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class YearRangeForm(forms.Form):
|
||||
year_from = forms.DateField(
|
||||
widget=AirYearPickerInput(clear_button=False), label="", required=True
|
||||
)
|
||||
year_to = forms.DateField(
|
||||
widget=AirYearPickerInput(clear_button=False), label="", required=True
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.disable_csrf = True
|
||||
|
||||
self.helper.layout = Layout(
|
||||
Row(
|
||||
Column("year_from", css_class="form-group col-md-6"),
|
||||
Column("year_to", css_class="form-group col-md-6"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class DateRangeForm(forms.Form):
|
||||
date_from = forms.DateField(
|
||||
widget=AirDatePickerInput(clear_button=False), label="", required=True
|
||||
)
|
||||
date_to = forms.DateField(
|
||||
widget=AirDatePickerInput(clear_button=False), label="", required=True
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.disable_csrf = True
|
||||
|
||||
self.helper.layout = Layout(
|
||||
Row(
|
||||
Column("date_from", css_class="form-group col-md-6"),
|
||||
Column("date_to", css_class="form-group col-md-6"),
|
||||
css_class="mb-0",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class CategoryForm(forms.Form):
|
||||
category = forms.ModelChoiceField(
|
||||
required=False,
|
||||
label=_("Category"),
|
||||
empty_label=_("Uncategorized"),
|
||||
queryset=TransactionCategory.objects.filter(active=True),
|
||||
widget=TomSelect(clear_button=True),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.disable_csrf = True
|
||||
|
||||
self.helper.layout = Layout("category")
|
||||
0
app/apps/insights/migrations/__init__.py
Normal file
0
app/apps/insights/migrations/__init__.py
Normal file
3
app/apps/insights/models.py
Normal file
3
app/apps/insights/models.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
3
app/apps/insights/tests.py
Normal file
3
app/apps/insights/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
32
app/apps/insights/urls.py
Normal file
32
app/apps/insights/urls.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path("insights/", views.index, name="insights_index"),
|
||||
path(
|
||||
"insights/sankey/account/",
|
||||
views.sankey_by_account,
|
||||
name="insights_sankey_by_account",
|
||||
),
|
||||
path(
|
||||
"insights/sankey/currency/",
|
||||
views.sankey_by_currency,
|
||||
name="insights_sankey_by_currency",
|
||||
),
|
||||
path(
|
||||
"insights/category-explorer/",
|
||||
views.category_explorer_index,
|
||||
name="category_explorer_index",
|
||||
),
|
||||
path(
|
||||
"insights/category-explorer/account/",
|
||||
views.category_sum_by_account,
|
||||
name="category_sum_by_account",
|
||||
),
|
||||
path(
|
||||
"insights/category-explorer/currency/",
|
||||
views.category_sum_by_currency,
|
||||
name="category_sum_by_currency",
|
||||
),
|
||||
]
|
||||
0
app/apps/insights/utils/__init__.py
Normal file
0
app/apps/insights/utils/__init__.py
Normal file
169
app/apps/insights/utils/category_explorer.py
Normal file
169
app/apps/insights/utils/category_explorer.py
Normal file
@@ -0,0 +1,169 @@
|
||||
from django.db.models import Sum, Case, When, F, DecimalField, Value
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
def get_category_sums_by_account(queryset, category=None):
|
||||
"""
|
||||
Returns income/expense sums per account for a specific category.
|
||||
"""
|
||||
sums = (
|
||||
queryset.filter(category=category)
|
||||
.values("account__name")
|
||||
.annotate(
|
||||
current_income=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(type="IN", then="amount"),
|
||||
When(is_paid=True, then="amount"),
|
||||
default=Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
)
|
||||
),
|
||||
Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
),
|
||||
current_expense=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(type="EX", then=-F("amount")),
|
||||
When(is_paid=True, then="amount"),
|
||||
default=Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
)
|
||||
),
|
||||
Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
),
|
||||
projected_income=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(type="IN", then="amount"),
|
||||
When(is_paid=False, then="amount"),
|
||||
default=Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
)
|
||||
),
|
||||
Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
),
|
||||
projected_expense=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(type="EX", then=-F("amount")),
|
||||
When(is_paid=False, then="amount"),
|
||||
default=Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
)
|
||||
),
|
||||
Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
),
|
||||
)
|
||||
.order_by("account__name")
|
||||
)
|
||||
|
||||
return {
|
||||
"labels": [item["account__name"] for item in sums],
|
||||
"datasets": [
|
||||
{
|
||||
"label": _("Current Income"),
|
||||
"data": [float(item["current_income"]) for item in sums],
|
||||
},
|
||||
{
|
||||
"label": _("Current Expenses"),
|
||||
"data": [float(item["current_expense"]) for item in sums],
|
||||
},
|
||||
{
|
||||
"label": _("Projected Income"),
|
||||
"data": [float(item["projected_income"]) for item in sums],
|
||||
},
|
||||
{
|
||||
"label": _("Projected Expenses"),
|
||||
"data": [float(item["projected_expense"]) for item in sums],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def get_category_sums_by_currency(queryset, category=None):
|
||||
"""
|
||||
Returns income/expense sums per currency for a specific category.
|
||||
"""
|
||||
sums = (
|
||||
queryset.filter(category=category)
|
||||
.values("account__currency__name")
|
||||
.annotate(
|
||||
current_income=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(type="IN", then="amount"),
|
||||
When(is_paid=True, then="amount"),
|
||||
default=Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
)
|
||||
),
|
||||
Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
),
|
||||
current_expense=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(type="EX", then=-F("amount")),
|
||||
When(is_paid=True, then="amount"),
|
||||
default=Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
)
|
||||
),
|
||||
Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
),
|
||||
projected_income=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(type="IN", then="amount"),
|
||||
When(is_paid=False, then="amount"),
|
||||
default=Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
)
|
||||
),
|
||||
Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
),
|
||||
projected_expense=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(type="EX", then=-F("amount")),
|
||||
When(is_paid=False, then="amount"),
|
||||
default=Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
)
|
||||
),
|
||||
Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
),
|
||||
)
|
||||
.order_by("account__currency__name")
|
||||
)
|
||||
|
||||
return {
|
||||
"labels": [item["account__currency__name"] for item in sums],
|
||||
"datasets": [
|
||||
{
|
||||
"label": _("Current Income"),
|
||||
"data": [float(item["current_income"]) for item in sums],
|
||||
},
|
||||
{
|
||||
"label": _("Current Expenses"),
|
||||
"data": [float(item["current_expense"]) for item in sums],
|
||||
},
|
||||
{
|
||||
"label": _("Projected Income"),
|
||||
"data": [float(item["projected_income"]) for item in sums],
|
||||
},
|
||||
{
|
||||
"label": _("Projected Expenses"),
|
||||
"data": [float(item["projected_expense"]) for item in sums],
|
||||
},
|
||||
],
|
||||
}
|
||||
280
app/apps/insights/utils/sankey.py
Normal file
280
app/apps/insights/utils/sankey.py
Normal file
@@ -0,0 +1,280 @@
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from decimal import Decimal
|
||||
from typing import Dict, List, TypedDict
|
||||
|
||||
|
||||
class SankeyNode(TypedDict):
|
||||
name: str
|
||||
|
||||
|
||||
class SankeyFlow(TypedDict):
|
||||
from_node: str
|
||||
to_node: str
|
||||
flow: float
|
||||
currency: Dict
|
||||
original_amount: float
|
||||
percentage: float
|
||||
|
||||
|
||||
def generate_sankey_data_by_account(transactions_queryset):
|
||||
"""
|
||||
Generates Sankey diagram data from transaction queryset using account as intermediary.
|
||||
"""
|
||||
nodes: Dict[str, Dict] = {}
|
||||
flows: List[SankeyFlow] = []
|
||||
|
||||
# Aggregate transactions
|
||||
income_data = {} # {(category, currency, account) -> amount}
|
||||
expense_data = {} # {(category, currency, account) -> amount}
|
||||
total_income_by_currency = {} # {currency -> amount}
|
||||
total_expense_by_currency = {} # {currency -> amount}
|
||||
total_volume_by_currency = {} # {currency -> amount}
|
||||
|
||||
for transaction in transactions_queryset:
|
||||
currency = transaction.account.currency
|
||||
account = transaction.account
|
||||
category = transaction.category or _("Uncategorized")
|
||||
key = (category, currency, account)
|
||||
amount = transaction.amount
|
||||
|
||||
if transaction.type == "IN":
|
||||
income_data[key] = income_data.get(key, Decimal("0")) + amount
|
||||
total_income_by_currency[currency] = (
|
||||
total_income_by_currency.get(currency, Decimal("0")) + amount
|
||||
)
|
||||
else:
|
||||
expense_data[key] = expense_data.get(key, Decimal("0")) + amount
|
||||
total_expense_by_currency[currency] = (
|
||||
total_expense_by_currency.get(currency, Decimal("0")) + amount
|
||||
)
|
||||
|
||||
total_volume_by_currency[currency] = (
|
||||
total_volume_by_currency.get(currency, Decimal("0")) + amount
|
||||
)
|
||||
|
||||
unique_accounts = {
|
||||
account_id: idx
|
||||
for idx, account_id in enumerate(
|
||||
transactions_queryset.values_list("account", flat=True).distinct()
|
||||
)
|
||||
}
|
||||
|
||||
def get_node_priority(node_id: str) -> int:
|
||||
"""Get priority based on the account ID embedded in the node ID."""
|
||||
account_id = int(node_id.split("_")[-1])
|
||||
return unique_accounts[account_id]
|
||||
|
||||
def get_node_id(node_type: str, name: str, account_id: int) -> str:
|
||||
"""Generate unique node ID."""
|
||||
return f"{node_type}_{name}_{account_id}".lower().replace(" ", "_")
|
||||
|
||||
def add_node(node_id: str, display_name: str) -> None:
|
||||
"""Add node with ID, display name and priority."""
|
||||
nodes[node_id] = {
|
||||
"id": node_id,
|
||||
"name": display_name,
|
||||
"priority": get_node_priority(node_id),
|
||||
}
|
||||
|
||||
def add_flow(
|
||||
from_node_id: str, to_node_id: str, amount: Decimal, currency, is_income: bool
|
||||
) -> None:
|
||||
"""
|
||||
Add flow with percentage based on total transaction volume for the specific currency.
|
||||
"""
|
||||
total_volume = total_volume_by_currency.get(currency, Decimal("0"))
|
||||
percentage = (amount / total_volume) * 100 if total_volume else 0
|
||||
scaled_flow = percentage / 100
|
||||
|
||||
flows.append(
|
||||
{
|
||||
"from_node": from_node_id,
|
||||
"to_node": to_node_id,
|
||||
"flow": float(scaled_flow),
|
||||
"currency": {
|
||||
"code": currency.code,
|
||||
"prefix": currency.prefix,
|
||||
"suffix": currency.suffix,
|
||||
"decimal_places": currency.decimal_places,
|
||||
},
|
||||
"original_amount": float(amount),
|
||||
"percentage": float(percentage),
|
||||
}
|
||||
)
|
||||
|
||||
# Process income
|
||||
for (category, currency, account), amount in income_data.items():
|
||||
category_node_id = get_node_id("income", category, account.id)
|
||||
account_node_id = get_node_id("account", account.name, account.id)
|
||||
add_node(category_node_id, str(category))
|
||||
add_node(account_node_id, account.name)
|
||||
add_flow(category_node_id, account_node_id, amount, currency, is_income=True)
|
||||
|
||||
# Process expenses
|
||||
for (category, currency, account), amount in expense_data.items():
|
||||
category_node_id = get_node_id("expense", category, account.id)
|
||||
account_node_id = get_node_id("account", account.name, account.id)
|
||||
add_node(category_node_id, str(category))
|
||||
add_node(account_node_id, account.name)
|
||||
add_flow(account_node_id, category_node_id, amount, currency, is_income=False)
|
||||
|
||||
# Calculate and add savings flows
|
||||
savings_data = {} # {(account, currency) -> amount}
|
||||
for (category, currency, account), amount in income_data.items():
|
||||
key = (account, currency)
|
||||
savings_data[key] = savings_data.get(key, Decimal("0")) + amount
|
||||
for (category, currency, account), amount in expense_data.items():
|
||||
key = (account, currency)
|
||||
savings_data[key] = savings_data.get(key, Decimal("0")) - amount
|
||||
|
||||
for (account, currency), amount in savings_data.items():
|
||||
if amount > 0:
|
||||
account_node_id = get_node_id("account", account.name, account.id)
|
||||
savings_node_id = get_node_id("savings", _("Saved"), account.id)
|
||||
add_node(savings_node_id, str(_("Saved")))
|
||||
add_flow(account_node_id, savings_node_id, amount, currency, is_income=True)
|
||||
|
||||
# Calculate total across all currencies (for reference only)
|
||||
total_amount = sum(float(amount) for amount in total_income_by_currency.values())
|
||||
|
||||
return {
|
||||
"nodes": list(nodes.values()),
|
||||
"flows": flows,
|
||||
"total_amount": total_amount,
|
||||
"total_by_currency": {
|
||||
curr.code: float(amount)
|
||||
for curr, amount in total_income_by_currency.items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def generate_sankey_data_by_currency(transactions_queryset):
|
||||
"""
|
||||
Generates Sankey diagram data from transaction queryset, using currency as intermediary.
|
||||
"""
|
||||
nodes: Dict[str, Dict] = {}
|
||||
flows: List[SankeyFlow] = []
|
||||
|
||||
# Aggregate transactions
|
||||
income_data = {} # {(category, currency) -> amount}
|
||||
expense_data = {} # {(category, currency) -> amount}
|
||||
total_income_by_currency = {} # {currency -> amount}
|
||||
total_expense_by_currency = {} # {currency -> amount}
|
||||
total_volume_by_currency = {} # {currency -> amount}
|
||||
|
||||
for transaction in transactions_queryset:
|
||||
currency = transaction.account.currency
|
||||
category = transaction.category or _("Uncategorized")
|
||||
key = (category, currency)
|
||||
amount = transaction.amount
|
||||
|
||||
if transaction.type == "IN":
|
||||
income_data[key] = income_data.get(key, Decimal("0")) + amount
|
||||
total_income_by_currency[currency] = (
|
||||
total_income_by_currency.get(currency, Decimal("0")) + amount
|
||||
)
|
||||
else:
|
||||
expense_data[key] = expense_data.get(key, Decimal("0")) + amount
|
||||
total_expense_by_currency[currency] = (
|
||||
total_expense_by_currency.get(currency, Decimal("0")) + amount
|
||||
)
|
||||
|
||||
total_volume_by_currency[currency] = (
|
||||
total_volume_by_currency.get(currency, Decimal("0")) + amount
|
||||
)
|
||||
|
||||
unique_currencies = {
|
||||
currency_id: idx
|
||||
for idx, currency_id in enumerate(
|
||||
transactions_queryset.values_list("account__currency", flat=True).distinct()
|
||||
)
|
||||
}
|
||||
|
||||
def get_node_priority(node_id: str) -> int:
|
||||
"""Get priority based on the currency ID embedded in the node ID."""
|
||||
currency_id = int(node_id.split("_")[-1])
|
||||
return unique_currencies[currency_id]
|
||||
|
||||
def get_node_id(node_type: str, name: str, currency_id: int) -> str:
|
||||
"""Generate unique node ID including currency information."""
|
||||
return f"{node_type}_{name}_{currency_id}".lower().replace(" ", "_")
|
||||
|
||||
def add_node(node_id: str, display_name: str) -> None:
|
||||
"""Add node with ID, display name and priority."""
|
||||
nodes[node_id] = {
|
||||
"id": node_id,
|
||||
"name": display_name,
|
||||
"priority": get_node_priority(node_id),
|
||||
}
|
||||
|
||||
def add_flow(
|
||||
from_node_id: str, to_node_id: str, amount: Decimal, currency, is_income: bool
|
||||
) -> None:
|
||||
"""
|
||||
Add flow with percentage based on total transaction volume for the specific currency.
|
||||
"""
|
||||
total_volume = total_volume_by_currency.get(currency, Decimal("0"))
|
||||
percentage = (amount / total_volume) * 100 if total_volume else 0
|
||||
scaled_flow = percentage / 100
|
||||
|
||||
flows.append(
|
||||
{
|
||||
"from_node": from_node_id,
|
||||
"to_node": to_node_id,
|
||||
"flow": float(scaled_flow),
|
||||
"currency": {
|
||||
"code": currency.code,
|
||||
"name": currency.name,
|
||||
"prefix": currency.prefix,
|
||||
"suffix": currency.suffix,
|
||||
"decimal_places": currency.decimal_places,
|
||||
},
|
||||
"original_amount": float(amount),
|
||||
"percentage": float(percentage),
|
||||
}
|
||||
)
|
||||
|
||||
# Process income
|
||||
for (category, currency), amount in income_data.items():
|
||||
category_node_id = get_node_id("income", category, currency.id)
|
||||
currency_node_id = get_node_id("currency", currency.name, currency.id)
|
||||
add_node(category_node_id, str(category))
|
||||
add_node(currency_node_id, currency.name)
|
||||
add_flow(category_node_id, currency_node_id, amount, currency, is_income=True)
|
||||
|
||||
# Process expenses
|
||||
for (category, currency), amount in expense_data.items():
|
||||
category_node_id = get_node_id("expense", category, currency.id)
|
||||
currency_node_id = get_node_id("currency", currency.name, currency.id)
|
||||
add_node(category_node_id, str(category))
|
||||
add_node(currency_node_id, currency.name)
|
||||
add_flow(currency_node_id, category_node_id, amount, currency, is_income=False)
|
||||
|
||||
# Calculate and add savings flows
|
||||
savings_data = {} # {currency -> amount}
|
||||
for (category, currency), amount in income_data.items():
|
||||
savings_data[currency] = savings_data.get(currency, Decimal("0")) + amount
|
||||
for (category, currency), amount in expense_data.items():
|
||||
savings_data[currency] = savings_data.get(currency, Decimal("0")) - amount
|
||||
|
||||
for currency, amount in savings_data.items():
|
||||
if amount > 0:
|
||||
currency_node_id = get_node_id("currency", currency.name, currency.id)
|
||||
savings_node_id = get_node_id("savings", _("Saved"), currency.id)
|
||||
add_node(savings_node_id, str(_("Saved")))
|
||||
add_flow(
|
||||
currency_node_id, savings_node_id, amount, currency, is_income=True
|
||||
)
|
||||
|
||||
# Calculate total across all currencies (for reference only)
|
||||
total_amount = sum(float(amount) for amount in total_income_by_currency.values())
|
||||
|
||||
return {
|
||||
"nodes": list(nodes.values()),
|
||||
"flows": flows,
|
||||
"total_amount": total_amount,
|
||||
"total_by_currency": {
|
||||
curr.name: float(amount)
|
||||
for curr, amount in total_income_by_currency.items()
|
||||
},
|
||||
}
|
||||
96
app/apps/insights/utils/transactions.py
Normal file
96
app/apps/insights/utils/transactions.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from apps.transactions.models import Transaction
|
||||
from apps.insights.forms import (
|
||||
SingleMonthForm,
|
||||
SingleYearForm,
|
||||
MonthRangeForm,
|
||||
YearRangeForm,
|
||||
DateRangeForm,
|
||||
)
|
||||
|
||||
|
||||
def get_transactions(request, include_unpaid=True, include_silent=False):
|
||||
transactions = Transaction.objects.all()
|
||||
|
||||
filter_type = request.GET.get("type", None)
|
||||
|
||||
if filter_type is not None:
|
||||
if filter_type == "month":
|
||||
form = SingleMonthForm(request.GET)
|
||||
|
||||
if form.is_valid():
|
||||
month = form.cleaned_data["month"].replace(day=1)
|
||||
else:
|
||||
month = timezone.localdate(timezone.now()).replace(day=1)
|
||||
|
||||
transactions = transactions.filter(
|
||||
reference_date__month=month.month, reference_date__year=month.year
|
||||
)
|
||||
elif filter_type == "year":
|
||||
form = SingleYearForm(request.GET)
|
||||
if form.is_valid():
|
||||
year = form.cleaned_data["year"].replace(day=1, month=1)
|
||||
else:
|
||||
year = timezone.localdate(timezone.now()).replace(day=1, month=1)
|
||||
|
||||
transactions = transactions.filter(reference_date__year=year.year)
|
||||
elif filter_type == "month-range":
|
||||
form = MonthRangeForm(request.GET)
|
||||
if form.is_valid():
|
||||
month_from = form.cleaned_data["month_from"].replace(day=1)
|
||||
month_to = form.cleaned_data["month_to"].replace(day=1)
|
||||
else:
|
||||
month_from = timezone.localdate(timezone.now()).replace(day=1)
|
||||
month_to = (
|
||||
timezone.localdate(timezone.now()) + relativedelta(months=1)
|
||||
).replace(day=1)
|
||||
|
||||
transactions = transactions.filter(
|
||||
reference_date__gte=month_from,
|
||||
reference_date__lte=month_to,
|
||||
)
|
||||
elif filter_type == "year-range":
|
||||
form = YearRangeForm(request.GET)
|
||||
if form.is_valid():
|
||||
year_from = form.cleaned_data["year_from"].replace(day=1, month=1)
|
||||
year_to = form.cleaned_data["year_to"].replace(day=31, month=12)
|
||||
else:
|
||||
year_from = timezone.localdate(timezone.now()).replace(day=1, month=1)
|
||||
year_to = (
|
||||
timezone.localdate(timezone.now()) + relativedelta(years=1)
|
||||
).replace(day=31, month=12)
|
||||
|
||||
transactions = transactions.filter(
|
||||
reference_date__gte=year_from,
|
||||
reference_date__lte=year_to,
|
||||
)
|
||||
elif filter_type == "date-range":
|
||||
form = DateRangeForm(request.GET)
|
||||
if form.is_valid():
|
||||
date_from = form.cleaned_data["date_from"]
|
||||
date_to = form.cleaned_data["date_to"]
|
||||
else:
|
||||
date_from = timezone.localdate(timezone.now())
|
||||
date_to = timezone.localdate(timezone.now()) + relativedelta(months=1)
|
||||
|
||||
transactions = transactions.filter(
|
||||
date__gte=date_from,
|
||||
date__lte=date_to,
|
||||
)
|
||||
else: # Default to current month
|
||||
month = timezone.localdate(timezone.now())
|
||||
transactions = transactions.filter(
|
||||
reference_date__month=month.month, reference_date__year=month.year
|
||||
)
|
||||
|
||||
if not include_unpaid:
|
||||
transactions = transactions.filter(is_paid=True)
|
||||
|
||||
if not include_silent:
|
||||
transactions = transactions.exclude(Q(category__mute=True) & ~Q(category=None))
|
||||
|
||||
return transactions
|
||||
159
app/apps/insights/views.py
Normal file
159
app/apps/insights/views.py
Normal file
@@ -0,0 +1,159 @@
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.shortcuts import render
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
from apps.insights.forms import (
|
||||
SingleMonthForm,
|
||||
SingleYearForm,
|
||||
MonthRangeForm,
|
||||
YearRangeForm,
|
||||
DateRangeForm,
|
||||
CategoryForm,
|
||||
)
|
||||
from apps.insights.utils.category_explorer import (
|
||||
get_category_sums_by_account,
|
||||
get_category_sums_by_currency,
|
||||
)
|
||||
from apps.insights.utils.sankey import (
|
||||
generate_sankey_data_by_account,
|
||||
generate_sankey_data_by_currency,
|
||||
)
|
||||
from apps.insights.utils.transactions import get_transactions
|
||||
from apps.transactions.models import TransactionCategory
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def index(request):
|
||||
date = timezone.localdate(timezone.now())
|
||||
month_form = SingleMonthForm(initial={"month": date.replace(day=1)})
|
||||
year_form = SingleYearForm(initial={"year": date.replace(day=1)})
|
||||
month_range_form = MonthRangeForm(
|
||||
initial={
|
||||
"month_from": date.replace(day=1),
|
||||
"month_to": date.replace(day=1) + relativedelta(months=1),
|
||||
}
|
||||
)
|
||||
year_range_form = YearRangeForm(
|
||||
initial={
|
||||
"year_from": date.replace(day=1, month=1),
|
||||
"year_to": date.replace(day=1, month=1) + relativedelta(years=1),
|
||||
}
|
||||
)
|
||||
date_range_form = DateRangeForm(
|
||||
initial={
|
||||
"date_from": date,
|
||||
"date_to": date + relativedelta(months=1),
|
||||
}
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"insights/pages/index.html",
|
||||
context={
|
||||
"month_form": month_form,
|
||||
"year_form": year_form,
|
||||
"month_range_form": month_range_form,
|
||||
"year_range_form": year_range_form,
|
||||
"date_range_form": date_range_form,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def sankey_by_account(request):
|
||||
# Get filtered transactions
|
||||
|
||||
transactions = get_transactions(request)
|
||||
|
||||
# Generate Sankey data
|
||||
sankey_data = generate_sankey_data_by_account(transactions)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"insights/fragments/sankey.html",
|
||||
{"sankey_data": sankey_data, "type": "account"},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def sankey_by_currency(request):
|
||||
# Get filtered transactions
|
||||
transactions = get_transactions(request)
|
||||
|
||||
# Generate Sankey data
|
||||
sankey_data = generate_sankey_data_by_currency(transactions)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"insights/fragments/sankey.html",
|
||||
{"sankey_data": sankey_data, "type": "currency"},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def category_explorer_index(request):
|
||||
category_form = CategoryForm()
|
||||
|
||||
return render(
|
||||
request,
|
||||
"insights/fragments/category_explorer/index.html",
|
||||
{"category_form": category_form},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def category_sum_by_account(request):
|
||||
# Get filtered transactions
|
||||
transactions = get_transactions(request, include_silent=True)
|
||||
|
||||
category = request.GET.get("category")
|
||||
|
||||
if category:
|
||||
category = TransactionCategory.objects.get(id=category)
|
||||
|
||||
# Generate data
|
||||
account_data = get_category_sums_by_account(transactions, category)
|
||||
else:
|
||||
account_data = get_category_sums_by_account(transactions, category=None)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"insights/fragments/category_explorer/charts/account.html",
|
||||
{"account_data": account_data},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def category_sum_by_currency(request):
|
||||
# Get filtered transactions
|
||||
transactions = get_transactions(request, include_silent=True)
|
||||
|
||||
category = request.GET.get("category")
|
||||
|
||||
if category:
|
||||
category = TransactionCategory.objects.get(id=category)
|
||||
|
||||
# Generate data
|
||||
currency_data = get_category_sums_by_currency(transactions, category)
|
||||
else:
|
||||
currency_data = get_category_sums_by_currency(transactions, category=None)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"insights/fragments/category_explorer/charts/currency.html",
|
||||
{"currency_data": currency_data},
|
||||
)
|
||||
@@ -19,4 +19,19 @@ urlpatterns = [
|
||||
views.monthly_summary,
|
||||
name="monthly_summary",
|
||||
),
|
||||
path(
|
||||
"monthly/<int:month>/<int:year>/summary/accounts/",
|
||||
views.monthly_account_summary,
|
||||
name="monthly_account_summary",
|
||||
),
|
||||
path(
|
||||
"monthly/<int:month>/<int:year>/summary/currencies/",
|
||||
views.monthly_currency_summary,
|
||||
name="monthly_currency_summary",
|
||||
),
|
||||
path(
|
||||
"monthly/summary/select/<str:selected>/",
|
||||
views.monthly_summary_select,
|
||||
name="monthly_summary_select",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -2,6 +2,7 @@ from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import (
|
||||
Q,
|
||||
)
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import render, redirect
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.http import require_http_methods
|
||||
@@ -16,6 +17,7 @@ from apps.transactions.models import Transaction
|
||||
from apps.transactions.utils.calculations import (
|
||||
calculate_currency_totals,
|
||||
calculate_percentage_distribution,
|
||||
calculate_account_totals,
|
||||
)
|
||||
from apps.transactions.utils.default_ordering import default_order
|
||||
|
||||
@@ -30,6 +32,9 @@ def index(request):
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def monthly_overview(request, month: int, year: int):
|
||||
order = request.session.get("monthly_transactions_order", "default")
|
||||
summary_tab = request.session.get("monthly_summary_tab", "summary")
|
||||
|
||||
if month < 1 or month > 12:
|
||||
from django.http import Http404
|
||||
|
||||
@@ -41,7 +46,7 @@ def monthly_overview(request, month: int, year: int):
|
||||
previous_month = 12 if month == 1 else month - 1
|
||||
previous_year = year - 1 if previous_month == 12 and month == 1 else year
|
||||
|
||||
f = TransactionsFilter(request.GET, user=request.user)
|
||||
f = TransactionsFilter(request.GET)
|
||||
|
||||
return render(
|
||||
request,
|
||||
@@ -54,6 +59,8 @@ def monthly_overview(request, month: int, year: int):
|
||||
"previous_month": previous_month,
|
||||
"previous_year": previous_year,
|
||||
"filter": f,
|
||||
"order": order,
|
||||
"summary_tab": summary_tab,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -62,9 +69,14 @@ def monthly_overview(request, month: int, year: int):
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def transactions_list(request, month: int, year: int):
|
||||
order = request.GET.get("order")
|
||||
order = request.session.get("monthly_transactions_order", "default")
|
||||
|
||||
f = TransactionsFilter(request.GET, user=request.user)
|
||||
if "order" in request.GET:
|
||||
order = request.GET["order"]
|
||||
if order != request.session.get("monthly_transactions_order", "default"):
|
||||
request.session["monthly_transactions_order"] = order
|
||||
|
||||
f = TransactionsFilter(request.GET)
|
||||
transactions_filtered = (
|
||||
f.qs.filter()
|
||||
.filter(
|
||||
@@ -79,6 +91,9 @@ def transactions_list(request, month: int, year: int):
|
||||
"account__exchange_currency",
|
||||
"account__currency",
|
||||
"installment_plan",
|
||||
"entities",
|
||||
"dca_expense_entries",
|
||||
"dca_income_entries",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -122,3 +137,61 @@ def monthly_summary(request, month: int, year: int):
|
||||
"monthly_overview/fragments/monthly_summary.html",
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def monthly_account_summary(request, month: int, year: int):
|
||||
# Base queryset with all required filters
|
||||
base_queryset = Transaction.objects.filter(
|
||||
reference_date__year=year,
|
||||
reference_date__month=month,
|
||||
).exclude(Q(category__mute=True) & ~Q(category=None))
|
||||
|
||||
account_data = calculate_account_totals(transactions_queryset=base_queryset.all())
|
||||
account_percentages = calculate_percentage_distribution(account_data)
|
||||
|
||||
context = {
|
||||
"account_data": account_data,
|
||||
"account_percentages": account_percentages,
|
||||
}
|
||||
|
||||
return render(
|
||||
request,
|
||||
"monthly_overview/fragments/monthly_account_summary.html",
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def monthly_currency_summary(request, month: int, year: int):
|
||||
# Base queryset with all required filters
|
||||
base_queryset = Transaction.objects.filter(
|
||||
reference_date__year=year,
|
||||
reference_date__month=month,
|
||||
).exclude(Q(category__mute=True) & ~Q(category=None))
|
||||
|
||||
currency_data = calculate_currency_totals(base_queryset.all(), ignore_empty=True)
|
||||
currency_percentages = calculate_percentage_distribution(currency_data)
|
||||
|
||||
context = {
|
||||
"currency_data": currency_data,
|
||||
"currency_percentages": currency_percentages,
|
||||
}
|
||||
|
||||
return render(
|
||||
request, "monthly_overview/fragments/monthly_currency_summary.html", context
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def monthly_summary_select(request, selected):
|
||||
request.session["monthly_summary_tab"] = selected
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from apps.rules.models import TransactionRule, TransactionRuleAction
|
||||
from apps.rules.models import (
|
||||
TransactionRule,
|
||||
TransactionRuleAction,
|
||||
UpdateOrCreateTransactionRuleAction,
|
||||
)
|
||||
|
||||
|
||||
# Register your models here.
|
||||
admin.site.register(TransactionRule)
|
||||
admin.site.register(TransactionRuleAction)
|
||||
admin.site.register(UpdateOrCreateTransactionRuleAction)
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
from crispy_bootstrap5.bootstrap5 import Switch
|
||||
from crispy_forms.bootstrap import FormActions
|
||||
from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion
|
||||
from crispy_forms.bootstrap import FormActions, AccordionGroup
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, Field, Row, Column
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.rules.models import TransactionRule
|
||||
from apps.rules.models import TransactionRuleAction
|
||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
from apps.common.widgets.tom_select import TomSelect
|
||||
from apps.rules.models import TransactionRule, UpdateOrCreateTransactionRuleAction
|
||||
from apps.rules.models import TransactionRuleAction
|
||||
|
||||
|
||||
class TransactionRuleForm(forms.ModelForm):
|
||||
@@ -123,3 +123,255 @@ class TransactionRuleActionForm(forms.ModelForm):
|
||||
if commit:
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
|
||||
class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = UpdateOrCreateTransactionRuleAction
|
||||
exclude = ("rule",)
|
||||
widgets = {
|
||||
"search_account_operator": TomSelect(clear_button=False),
|
||||
"search_type_operator": TomSelect(clear_button=False),
|
||||
"search_is_paid_operator": TomSelect(clear_button=False),
|
||||
"search_date_operator": TomSelect(clear_button=False),
|
||||
"search_reference_date_operator": TomSelect(clear_button=False),
|
||||
"search_amount_operator": TomSelect(clear_button=False),
|
||||
"search_description_operator": TomSelect(clear_button=False),
|
||||
"search_notes_operator": TomSelect(clear_button=False),
|
||||
"search_category_operator": TomSelect(clear_button=False),
|
||||
"search_internal_note_operator": TomSelect(clear_button=False),
|
||||
"search_internal_id_operator": TomSelect(clear_button=False),
|
||||
}
|
||||
|
||||
labels = {
|
||||
"search_account_operator": _("Operator"),
|
||||
"search_type_operator": _("Operator"),
|
||||
"search_is_paid_operator": _("Operator"),
|
||||
"search_date_operator": _("Operator"),
|
||||
"search_reference_date_operator": _("Operator"),
|
||||
"search_amount_operator": _("Operator"),
|
||||
"search_description_operator": _("Operator"),
|
||||
"search_notes_operator": _("Operator"),
|
||||
"search_category_operator": _("Operator"),
|
||||
"search_internal_note_operator": _("Operator"),
|
||||
"search_internal_id_operator": _("Operator"),
|
||||
"search_tags_operator": _("Operator"),
|
||||
"search_entities_operator": _("Operator"),
|
||||
"search_account": _("Account"),
|
||||
"search_type": _("Type"),
|
||||
"search_is_paid": _("Paid"),
|
||||
"search_date": _("Date"),
|
||||
"search_reference_date": _("Reference Date"),
|
||||
"search_amount": _("Amount"),
|
||||
"search_description": _("Description"),
|
||||
"search_notes": _("Notes"),
|
||||
"search_category": _("Category"),
|
||||
"search_internal_note": _("Internal Note"),
|
||||
"search_internal_id": _("Internal ID"),
|
||||
"search_tags": _("Tags"),
|
||||
"search_entities": _("Entities"),
|
||||
"set_account": _("Account"),
|
||||
"set_type": _("Type"),
|
||||
"set_is_paid": _("Paid"),
|
||||
"set_date": _("Date"),
|
||||
"set_reference_date": _("Reference Date"),
|
||||
"set_amount": _("Amount"),
|
||||
"set_description": _("Description"),
|
||||
"set_tags": _("Tags"),
|
||||
"set_entities": _("Entities"),
|
||||
"set_notes": _("Notes"),
|
||||
"set_category": _("Category"),
|
||||
"set_internal_note": _("Internal Note"),
|
||||
"set_internal_id": _("Internal ID"),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.rule = kwargs.pop("rule", None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.form_method = "post"
|
||||
|
||||
self.helper.layout = Layout(
|
||||
BS5Accordion(
|
||||
AccordionGroup(
|
||||
_("Search Criteria"),
|
||||
Field("filter", rows=1),
|
||||
Row(
|
||||
Column(
|
||||
Field("search_type_operator"),
|
||||
css_class="form-group col-md-4",
|
||||
),
|
||||
Column(
|
||||
Field("search_type", rows=1),
|
||||
css_class="form-group col-md-8",
|
||||
),
|
||||
),
|
||||
Row(
|
||||
Column(
|
||||
Field("search_is_paid_operator"),
|
||||
css_class="form-group col-md-4",
|
||||
),
|
||||
Column(
|
||||
Field("search_is_paid", rows=1),
|
||||
css_class="form-group col-md-8",
|
||||
),
|
||||
),
|
||||
Row(
|
||||
Column(
|
||||
Field("search_account_operator"),
|
||||
css_class="form-group col-md-4",
|
||||
),
|
||||
Column(
|
||||
Field("search_account", rows=1),
|
||||
css_class="form-group col-md-8",
|
||||
),
|
||||
),
|
||||
Row(
|
||||
Column(
|
||||
Field("search_entities_operator"),
|
||||
css_class="form-group col-md-4",
|
||||
),
|
||||
Column(
|
||||
Field("search_entities", rows=1),
|
||||
css_class="form-group col-md-8",
|
||||
),
|
||||
),
|
||||
Row(
|
||||
Column(
|
||||
Field("search_date_operator"),
|
||||
css_class="form-group col-md-4",
|
||||
),
|
||||
Column(
|
||||
Field("search_date", rows=1),
|
||||
css_class="form-group col-md-8",
|
||||
),
|
||||
),
|
||||
Row(
|
||||
Column(
|
||||
Field("search_reference_date_operator"),
|
||||
css_class="form-group col-md-4",
|
||||
),
|
||||
Column(
|
||||
Field("search_reference_date", rows=1),
|
||||
css_class="form-group col-md-8",
|
||||
),
|
||||
),
|
||||
Row(
|
||||
Column(
|
||||
Field("search_description_operator"),
|
||||
css_class="form-group col-md-4",
|
||||
),
|
||||
Column(
|
||||
Field("search_description", rows=1),
|
||||
css_class="form-group col-md-8",
|
||||
),
|
||||
),
|
||||
Row(
|
||||
Column(
|
||||
Field("search_amount_operator"),
|
||||
css_class="form-group col-md-4",
|
||||
),
|
||||
Column(
|
||||
Field("search_amount", rows=1),
|
||||
css_class="form-group col-md-8",
|
||||
),
|
||||
),
|
||||
Row(
|
||||
Column(
|
||||
Field("search_category_operator"),
|
||||
css_class="form-group col-md-4",
|
||||
),
|
||||
Column(
|
||||
Field("search_category", rows=1),
|
||||
css_class="form-group col-md-8",
|
||||
),
|
||||
),
|
||||
Row(
|
||||
Column(
|
||||
Field("search_tags_operator"),
|
||||
css_class="form-group col-md-4",
|
||||
),
|
||||
Column(
|
||||
Field("search_tags", rows=1),
|
||||
css_class="form-group col-md-8",
|
||||
),
|
||||
),
|
||||
Row(
|
||||
Column(
|
||||
Field("search_notes_operator"),
|
||||
css_class="form-group col-md-4",
|
||||
),
|
||||
Column(
|
||||
Field("search_notes", rows=1),
|
||||
css_class="form-group col-md-8",
|
||||
),
|
||||
),
|
||||
Row(
|
||||
Column(
|
||||
Field("search_internal_note_operator"),
|
||||
css_class="form-group col-md-4",
|
||||
),
|
||||
Column(
|
||||
Field("search_internal_note", rows=1),
|
||||
css_class="form-group col-md-8",
|
||||
),
|
||||
),
|
||||
Row(
|
||||
Column(
|
||||
Field("search_internal_id_operator"),
|
||||
css_class="form-group col-md-4",
|
||||
),
|
||||
Column(
|
||||
Field("search_internal_id", rows=1),
|
||||
css_class="form-group col-md-8",
|
||||
),
|
||||
),
|
||||
active=True,
|
||||
),
|
||||
AccordionGroup(
|
||||
_("Set Values"),
|
||||
Field("set_type", rows=1),
|
||||
Field("set_is_paid", rows=1),
|
||||
Field("set_account", rows=1),
|
||||
Field("set_entities", rows=1),
|
||||
Field("set_date", rows=1),
|
||||
Field("set_reference_date", rows=1),
|
||||
Field("set_description", rows=1),
|
||||
Field("set_amount", rows=1),
|
||||
Field("set_category", rows=1),
|
||||
Field("set_tags", rows=1),
|
||||
Field("set_notes", rows=1),
|
||||
Field("set_internal_note", rows=1),
|
||||
Field("set_internal_id", rows=1),
|
||||
css_class="mb-3",
|
||||
active=True,
|
||||
),
|
||||
always_open=True,
|
||||
),
|
||||
)
|
||||
|
||||
if self.instance and self.instance.pk:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
),
|
||||
)
|
||||
else:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def save(self, commit=True):
|
||||
instance = super().save(commit=False)
|
||||
instance.rule = self.rule
|
||||
if commit:
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# Generated by Django 5.1.5 on 2025-02-08 03:16
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('rules', '0005_alter_transactionruleaction_rule'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='UpdateOrCreateTransactionRuleAction',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('search_account', models.TextField(blank=True, help_text='Expression to match transaction account (ID or name)', verbose_name='Search Account')),
|
||||
('search_account_operator', models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='exact', max_length=10, verbose_name='Account Operator')),
|
||||
('search_type', models.TextField(blank=True, help_text="Expression to match transaction type ('IN' or 'EX')", verbose_name='Search Type')),
|
||||
('search_type_operator', models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='exact', max_length=10, verbose_name='Type Operator')),
|
||||
('search_is_paid', models.TextField(blank=True, help_text='Expression to match transaction paid status', verbose_name='Search Is Paid')),
|
||||
('search_is_paid_operator', models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='exact', max_length=10, verbose_name='Is Paid Operator')),
|
||||
('search_date', models.TextField(blank=True, help_text='Expression to match transaction date', verbose_name='Search Date')),
|
||||
('search_date_operator', models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='exact', max_length=10, verbose_name='Date Operator')),
|
||||
('search_reference_date', models.TextField(blank=True, help_text='Expression to match transaction reference date', verbose_name='Search Reference Date')),
|
||||
('search_reference_date_operator', models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='exact', max_length=10, verbose_name='Reference Date Operator')),
|
||||
('search_amount', models.TextField(blank=True, help_text='Expression to match transaction amount', verbose_name='Search Amount')),
|
||||
('search_amount_operator', models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='exact', max_length=10, verbose_name='Amount Operator')),
|
||||
('search_description', models.TextField(blank=True, help_text='Expression to match transaction description', verbose_name='Search Description')),
|
||||
('search_description_operator', models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='contains', max_length=10, verbose_name='Description Operator')),
|
||||
('search_notes', models.TextField(blank=True, help_text='Expression to match transaction notes', verbose_name='Search Notes')),
|
||||
('search_notes_operator', models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='contains', max_length=10, verbose_name='Notes Operator')),
|
||||
('search_category', models.TextField(blank=True, help_text='Expression to match transaction category (ID or name)', verbose_name='Search Category')),
|
||||
('search_category_operator', models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='exact', max_length=10, verbose_name='Category Operator')),
|
||||
('search_internal_note', models.TextField(blank=True, help_text='Expression to match transaction internal note', verbose_name='Search Internal Note')),
|
||||
('search_internal_note_operator', models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='exact', max_length=10, verbose_name='Internal Note Operator')),
|
||||
('search_internal_id', models.TextField(blank=True, help_text='Expression to match transaction internal ID', verbose_name='Search Internal ID')),
|
||||
('search_internal_id_operator', models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='exact', max_length=10, verbose_name='Internal ID Operator')),
|
||||
('set_account', models.TextField(blank=True, help_text='Expression for account to set (ID or name)', verbose_name='Set Account')),
|
||||
('set_type', models.TextField(blank=True, help_text="Expression for type to set ('IN' or 'EX')", verbose_name='Set Type')),
|
||||
('set_is_paid', models.TextField(blank=True, help_text='Expression for paid status to set', verbose_name='Set Is Paid')),
|
||||
('set_date', models.TextField(blank=True, help_text='Expression for date to set', verbose_name='Set Date')),
|
||||
('set_reference_date', models.TextField(blank=True, help_text='Expression for reference date to set', verbose_name='Set Reference Date')),
|
||||
('set_amount', models.TextField(blank=True, help_text='Expression for amount to set', verbose_name='Set Amount')),
|
||||
('set_description', models.TextField(blank=True, help_text='Expression for description to set', verbose_name='Set Description')),
|
||||
('set_notes', models.TextField(blank=True, help_text='Expression for notes to set', verbose_name='Set Notes')),
|
||||
('set_internal_note', models.TextField(blank=True, help_text='Expression for internal note to set', verbose_name='Set Internal Note')),
|
||||
('set_internal_id', models.TextField(blank=True, help_text='Expression for internal ID to set', verbose_name='Set Internal ID')),
|
||||
('set_category', models.TextField(blank=True, help_text='Expression for category to set (ID or name)', verbose_name='Set Category')),
|
||||
('set_tags', models.TextField(blank=True, help_text='Expression for tags to set (list of IDs or names)', verbose_name='Set Tags')),
|
||||
('set_entities', models.TextField(blank=True, help_text='Expression for entities to set (list of IDs or names)', verbose_name='Set Entities')),
|
||||
('rule', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='update_or_create_transaction_actions', to='rules.transactionrule', verbose_name='Rule')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'pdate or Create Transaction Action',
|
||||
'verbose_name_plural': 'pdate or Create Transaction Action Actions',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.1.5 on 2025-02-08 04:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('rules', '0006_updateorcreatetransactionruleaction'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='updateorcreatetransactionruleaction',
|
||||
options={'verbose_name': 'Update or Create Transaction Action', 'verbose_name_plural': 'Update or Create Transaction Action Actions'},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='filter',
|
||||
field=models.TextField(blank=True, help_text='Generic expression to enable or disable execution. Should evaluate to True or False', verbose_name='Filter'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.1.5 on 2025-02-08 06:13
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('rules', '0007_alter_updateorcreatetransactionruleaction_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='search_entities',
|
||||
field=models.TextField(blank=True, help_text='Expression to match transaction entities (list of IDs or names)', verbose_name='Search Entities'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='search_entities_operator',
|
||||
field=models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='contains', max_length=10, verbose_name='Entities Operator'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='search_tags',
|
||||
field=models.TextField(blank=True, help_text='Expression to match transaction tags (list of IDs or names)', verbose_name='Search Tags'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='search_tags_operator',
|
||||
field=models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='contains', max_length=10, verbose_name='Tags Operator'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 5.1.5 on 2025-02-08 06:40
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('rules', '0008_updateorcreatetransactionruleaction_search_entities_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='transactionrule',
|
||||
options={'verbose_name': 'Transaction rule', 'verbose_name_plural': 'Transaction rules'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='transactionruleaction',
|
||||
options={'verbose_name': 'Edit transaction action', 'verbose_name_plural': 'Edit transaction actions'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='updateorcreatetransactionruleaction',
|
||||
options={'verbose_name': 'Update or create transaction action', 'verbose_name_plural': 'Update or create transaction actions'},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,138 @@
|
||||
# Generated by Django 5.1.6 on 2025-02-09 20:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('rules', '0009_alter_transactionrule_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='search_account',
|
||||
field=models.TextField(blank=True, verbose_name='Search Account'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='search_amount',
|
||||
field=models.TextField(blank=True, verbose_name='Search Amount'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='search_category',
|
||||
field=models.TextField(blank=True, verbose_name='Search Category'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='search_description',
|
||||
field=models.TextField(blank=True, verbose_name='Search Description'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='search_entities',
|
||||
field=models.TextField(blank=True, verbose_name='Search Entities'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='search_internal_id',
|
||||
field=models.TextField(blank=True, verbose_name='Search Internal ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='search_internal_note',
|
||||
field=models.TextField(blank=True, verbose_name='Search Internal Note'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='search_is_paid',
|
||||
field=models.TextField(blank=True, verbose_name='Search Is Paid'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='search_notes',
|
||||
field=models.TextField(blank=True, verbose_name='Search Notes'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='search_reference_date',
|
||||
field=models.TextField(blank=True, verbose_name='Search Reference Date'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='search_tags',
|
||||
field=models.TextField(blank=True, verbose_name='Search Tags'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='search_type',
|
||||
field=models.TextField(blank=True, verbose_name='Search Type'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='set_account',
|
||||
field=models.TextField(blank=True, verbose_name='Account'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='set_amount',
|
||||
field=models.TextField(blank=True, verbose_name='Amount'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='set_category',
|
||||
field=models.TextField(blank=True, verbose_name='Category'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='set_date',
|
||||
field=models.TextField(blank=True, verbose_name='Date'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='set_description',
|
||||
field=models.TextField(blank=True, verbose_name='Description'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='set_entities',
|
||||
field=models.TextField(blank=True, verbose_name='Entities'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='set_internal_id',
|
||||
field=models.TextField(blank=True, verbose_name='Internal ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='set_internal_note',
|
||||
field=models.TextField(blank=True, verbose_name='Internal Note'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='set_is_paid',
|
||||
field=models.TextField(blank=True, verbose_name='Is Paid'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='set_notes',
|
||||
field=models.TextField(blank=True, verbose_name='Notes'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='set_reference_date',
|
||||
field=models.TextField(blank=True, verbose_name='Reference Date'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='set_tags',
|
||||
field=models.TextField(blank=True, verbose_name='Tags'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='set_type',
|
||||
field=models.TextField(blank=True, verbose_name='Type'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.6 on 2025-02-09 20:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('rules', '0010_alter_updateorcreatetransactionruleaction_search_account_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='set_is_paid',
|
||||
field=models.TextField(blank=True, verbose_name='Paid'),
|
||||
),
|
||||
]
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
@@ -10,6 +11,10 @@ class TransactionRule(models.Model):
|
||||
description = models.TextField(blank=True, null=True, verbose_name=_("Description"))
|
||||
trigger = models.TextField(verbose_name=_("Trigger"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Transaction rule")
|
||||
verbose_name_plural = _("Transaction rules")
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@@ -45,4 +50,350 @@ class TransactionRuleAction(models.Model):
|
||||
return f"{self.rule} - {self.field} - {self.value}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Edit transaction action")
|
||||
verbose_name_plural = _("Edit transaction actions")
|
||||
unique_together = (("rule", "field"),)
|
||||
|
||||
|
||||
class UpdateOrCreateTransactionRuleAction(models.Model):
|
||||
"""
|
||||
Will attempt to find and update latest matching transaction, or create new if none found.
|
||||
"""
|
||||
|
||||
class SearchOperator(models.TextChoices):
|
||||
EXACT = "exact", _("is exactly")
|
||||
CONTAINS = "contains", _("contains")
|
||||
STARTSWITH = "startswith", _("starts with")
|
||||
ENDSWITH = "endswith", _("ends with")
|
||||
EQ = "eq", _("equals")
|
||||
GT = "gt", _("greater than")
|
||||
LT = "lt", _("less than")
|
||||
GTE = "gte", _("greater than or equal")
|
||||
LTE = "lte", _("less than or equal")
|
||||
|
||||
rule = models.ForeignKey(
|
||||
TransactionRule,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="update_or_create_transaction_actions",
|
||||
verbose_name=_("Rule"),
|
||||
)
|
||||
|
||||
filter = models.TextField(
|
||||
verbose_name=_("Filter"),
|
||||
blank=True,
|
||||
help_text=_(
|
||||
"Generic expression to enable or disable execution. Should evaluate to True or False"
|
||||
),
|
||||
)
|
||||
|
||||
# Search fields with operators
|
||||
search_account = models.TextField(
|
||||
verbose_name="Search Account",
|
||||
blank=True,
|
||||
)
|
||||
search_account_operator = models.CharField(
|
||||
max_length=10,
|
||||
choices=SearchOperator.choices,
|
||||
default=SearchOperator.EXACT,
|
||||
verbose_name="Account Operator",
|
||||
)
|
||||
|
||||
search_type = models.TextField(
|
||||
verbose_name="Search Type",
|
||||
blank=True,
|
||||
)
|
||||
search_type_operator = models.CharField(
|
||||
max_length=10,
|
||||
choices=SearchOperator.choices,
|
||||
default=SearchOperator.EXACT,
|
||||
verbose_name="Type Operator",
|
||||
)
|
||||
|
||||
search_is_paid = models.TextField(
|
||||
verbose_name="Search Is Paid",
|
||||
blank=True,
|
||||
)
|
||||
search_is_paid_operator = models.CharField(
|
||||
max_length=10,
|
||||
choices=SearchOperator.choices,
|
||||
default=SearchOperator.EXACT,
|
||||
verbose_name="Is Paid Operator",
|
||||
)
|
||||
|
||||
search_date = models.TextField(
|
||||
verbose_name="Search Date",
|
||||
blank=True,
|
||||
help_text="Expression to match transaction date",
|
||||
)
|
||||
search_date_operator = models.CharField(
|
||||
max_length=10,
|
||||
choices=SearchOperator.choices,
|
||||
default=SearchOperator.EXACT,
|
||||
verbose_name="Date Operator",
|
||||
)
|
||||
|
||||
search_reference_date = models.TextField(
|
||||
verbose_name="Search Reference Date",
|
||||
blank=True,
|
||||
)
|
||||
search_reference_date_operator = models.CharField(
|
||||
max_length=10,
|
||||
choices=SearchOperator.choices,
|
||||
default=SearchOperator.EXACT,
|
||||
verbose_name="Reference Date Operator",
|
||||
)
|
||||
|
||||
search_amount = models.TextField(
|
||||
verbose_name="Search Amount",
|
||||
blank=True,
|
||||
)
|
||||
search_amount_operator = models.CharField(
|
||||
max_length=10,
|
||||
choices=SearchOperator.choices,
|
||||
default=SearchOperator.EXACT,
|
||||
verbose_name="Amount Operator",
|
||||
)
|
||||
|
||||
search_description = models.TextField(
|
||||
verbose_name="Search Description",
|
||||
blank=True,
|
||||
)
|
||||
search_description_operator = models.CharField(
|
||||
max_length=10,
|
||||
choices=SearchOperator.choices,
|
||||
default=SearchOperator.CONTAINS,
|
||||
verbose_name="Description Operator",
|
||||
)
|
||||
|
||||
search_notes = models.TextField(
|
||||
verbose_name="Search Notes",
|
||||
blank=True,
|
||||
)
|
||||
search_notes_operator = models.CharField(
|
||||
max_length=10,
|
||||
choices=SearchOperator.choices,
|
||||
default=SearchOperator.CONTAINS,
|
||||
verbose_name="Notes Operator",
|
||||
)
|
||||
|
||||
search_category = models.TextField(
|
||||
verbose_name="Search Category",
|
||||
blank=True,
|
||||
)
|
||||
search_category_operator = models.CharField(
|
||||
max_length=10,
|
||||
choices=SearchOperator.choices,
|
||||
default=SearchOperator.EXACT,
|
||||
verbose_name="Category Operator",
|
||||
)
|
||||
|
||||
search_tags = models.TextField(
|
||||
verbose_name="Search Tags",
|
||||
blank=True,
|
||||
)
|
||||
search_tags_operator = models.CharField(
|
||||
max_length=10,
|
||||
choices=SearchOperator.choices,
|
||||
default=SearchOperator.CONTAINS,
|
||||
verbose_name="Tags Operator",
|
||||
)
|
||||
|
||||
search_entities = models.TextField(
|
||||
verbose_name="Search Entities",
|
||||
blank=True,
|
||||
)
|
||||
search_entities_operator = models.CharField(
|
||||
max_length=10,
|
||||
choices=SearchOperator.choices,
|
||||
default=SearchOperator.CONTAINS,
|
||||
verbose_name="Entities Operator",
|
||||
)
|
||||
|
||||
search_internal_note = models.TextField(
|
||||
verbose_name="Search Internal Note",
|
||||
blank=True,
|
||||
)
|
||||
search_internal_note_operator = models.CharField(
|
||||
max_length=10,
|
||||
choices=SearchOperator.choices,
|
||||
default=SearchOperator.EXACT,
|
||||
verbose_name="Internal Note Operator",
|
||||
)
|
||||
|
||||
search_internal_id = models.TextField(
|
||||
verbose_name="Search Internal ID",
|
||||
blank=True,
|
||||
)
|
||||
search_internal_id_operator = models.CharField(
|
||||
max_length=10,
|
||||
choices=SearchOperator.choices,
|
||||
default=SearchOperator.EXACT,
|
||||
verbose_name="Internal ID Operator",
|
||||
)
|
||||
|
||||
# Set fields
|
||||
set_account = models.TextField(
|
||||
verbose_name=_("Account"),
|
||||
blank=True,
|
||||
)
|
||||
set_type = models.TextField(
|
||||
verbose_name=_("Type"),
|
||||
blank=True,
|
||||
)
|
||||
set_is_paid = models.TextField(
|
||||
verbose_name=_("Paid"),
|
||||
blank=True,
|
||||
)
|
||||
set_date = models.TextField(
|
||||
verbose_name=_("Date"),
|
||||
blank=True,
|
||||
)
|
||||
set_reference_date = models.TextField(
|
||||
verbose_name=_("Reference Date"),
|
||||
blank=True,
|
||||
)
|
||||
set_amount = models.TextField(
|
||||
verbose_name=_("Amount"),
|
||||
blank=True,
|
||||
)
|
||||
set_description = models.TextField(
|
||||
verbose_name=_("Description"),
|
||||
blank=True,
|
||||
)
|
||||
set_notes = models.TextField(
|
||||
verbose_name=_("Notes"),
|
||||
blank=True,
|
||||
)
|
||||
set_internal_note = models.TextField(
|
||||
verbose_name=_("Internal Note"),
|
||||
blank=True,
|
||||
)
|
||||
set_internal_id = models.TextField(
|
||||
verbose_name=_("Internal ID"),
|
||||
blank=True,
|
||||
)
|
||||
set_entities = models.TextField(
|
||||
verbose_name=_("Entities"),
|
||||
blank=True,
|
||||
)
|
||||
set_category = models.TextField(
|
||||
verbose_name=_("Category"),
|
||||
blank=True,
|
||||
)
|
||||
set_tags = models.TextField(
|
||||
verbose_name=_("Tags"),
|
||||
blank=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Update or create transaction action")
|
||||
verbose_name_plural = _("Update or create transaction actions")
|
||||
|
||||
def __str__(self):
|
||||
return f"Update or create transaction action for {self.rule}"
|
||||
|
||||
def build_search_query(self, simple):
|
||||
"""Builds Q objects based on search fields and their operators"""
|
||||
search_query = Q()
|
||||
|
||||
def add_to_query(field_name, value, operator):
|
||||
if isinstance(value, (int, str)):
|
||||
lookup = f"{field_name}__{operator}"
|
||||
return Q(**{lookup: value})
|
||||
return Q()
|
||||
|
||||
if self.search_account:
|
||||
value = simple.eval(self.search_account)
|
||||
if isinstance(value, int):
|
||||
search_query &= add_to_query(
|
||||
"account_id", value, self.search_account_operator
|
||||
)
|
||||
else:
|
||||
search_query &= add_to_query(
|
||||
"account__name", value, self.search_account_operator
|
||||
)
|
||||
|
||||
if self.search_type:
|
||||
value = simple.eval(self.search_type)
|
||||
search_query &= add_to_query("type", value, self.search_type_operator)
|
||||
|
||||
if self.search_is_paid:
|
||||
value = simple.eval(self.search_is_paid)
|
||||
search_query &= add_to_query("is_paid", value, self.search_is_paid_operator)
|
||||
|
||||
if self.search_date:
|
||||
value = simple.eval(self.search_date)
|
||||
search_query &= add_to_query("date", value, self.search_date_operator)
|
||||
|
||||
if self.search_reference_date:
|
||||
value = simple.eval(self.search_reference_date)
|
||||
search_query &= add_to_query(
|
||||
"reference_date", value, self.search_reference_date_operator
|
||||
)
|
||||
|
||||
if self.search_amount:
|
||||
value = simple.eval(self.search_amount)
|
||||
search_query &= add_to_query("amount", value, self.search_amount_operator)
|
||||
|
||||
if self.search_description:
|
||||
value = simple.eval(self.search_description)
|
||||
search_query &= add_to_query(
|
||||
"description", value, self.search_description_operator
|
||||
)
|
||||
|
||||
if self.search_notes:
|
||||
value = simple.eval(self.search_notes)
|
||||
search_query &= add_to_query("notes", value, self.search_notes_operator)
|
||||
|
||||
if self.search_internal_note:
|
||||
value = simple.eval(self.search_internal_note)
|
||||
search_query &= add_to_query(
|
||||
"internal_note", value, self.search_internal_note_operator
|
||||
)
|
||||
|
||||
if self.search_internal_id:
|
||||
value = simple.eval(self.search_internal_id)
|
||||
search_query &= add_to_query(
|
||||
"internal_id", value, self.search_internal_id_operator
|
||||
)
|
||||
|
||||
if self.search_category:
|
||||
value = simple.eval(self.search_category)
|
||||
if isinstance(value, int):
|
||||
search_query &= add_to_query(
|
||||
"category_id", value, self.search_category_operator
|
||||
)
|
||||
else:
|
||||
search_query &= add_to_query(
|
||||
"category__name", value, self.search_category_operator
|
||||
)
|
||||
|
||||
if self.search_tags:
|
||||
tags_value = simple.eval(self.search_tags)
|
||||
if isinstance(tags_value, (list, tuple)):
|
||||
for tag in tags_value:
|
||||
if isinstance(tag, int):
|
||||
search_query &= Q(tags__id=tag)
|
||||
else:
|
||||
search_query &= Q(tags__name__iexact=tag)
|
||||
elif isinstance(tags_value, (int, str)):
|
||||
if isinstance(tags_value, int):
|
||||
search_query &= Q(tags__id=tags_value)
|
||||
else:
|
||||
search_query &= Q(tags__name__iexact=tags_value)
|
||||
|
||||
if self.search_entities:
|
||||
entities_value = simple.eval(self.search_entities)
|
||||
if isinstance(entities_value, (list, tuple)):
|
||||
for entity in entities_value:
|
||||
if isinstance(entity, int):
|
||||
search_query &= Q(entities__id=entity)
|
||||
else:
|
||||
search_query &= Q(entities__name__iexact=entity)
|
||||
elif isinstance(entities_value, (int, str)):
|
||||
if isinstance(entities_value, int):
|
||||
search_query &= Q(entities__id=entities_value)
|
||||
else:
|
||||
search_query &= Q(entities__name__iexact=entities_value)
|
||||
|
||||
return search_query
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
from django.dispatch import Signal, receiver
|
||||
from django.dispatch import receiver
|
||||
|
||||
from apps.transactions.models import Transaction
|
||||
from apps.transactions.models import (
|
||||
Transaction,
|
||||
transaction_created,
|
||||
transaction_updated,
|
||||
)
|
||||
from apps.rules.tasks import check_for_transaction_rules
|
||||
|
||||
transaction_created = Signal()
|
||||
transaction_updated = Signal()
|
||||
|
||||
|
||||
@receiver(transaction_created)
|
||||
@receiver(transaction_updated)
|
||||
def transaction_changed_receiver(sender: Transaction, signal, **kwargs):
|
||||
for dca_entry in sender.dca_expense_entries.all():
|
||||
dca_entry.amount_paid = sender.amount
|
||||
dca_entry.save()
|
||||
for dca_entry in sender.dca_income_entries.all():
|
||||
dca_entry.amount_received = sender.amount
|
||||
dca_entry.save()
|
||||
|
||||
check_for_transaction_rules.defer(
|
||||
instance_id=sender.id,
|
||||
signal=(
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import decimal
|
||||
import logging
|
||||
from datetime import datetime, date
|
||||
|
||||
from cachalot.api import cachalot_disabled
|
||||
from dateutil.relativedelta import relativedelta
|
||||
@@ -6,7 +8,10 @@ from procrastinate.contrib.django import app
|
||||
from simpleeval import EvalWithCompoundTypes
|
||||
|
||||
from apps.accounts.models import Account
|
||||
from apps.rules.models import TransactionRule, TransactionRuleAction
|
||||
from apps.rules.models import (
|
||||
TransactionRule,
|
||||
TransactionRuleAction,
|
||||
)
|
||||
from apps.transactions.models import (
|
||||
Transaction,
|
||||
TransactionCategory,
|
||||
@@ -14,148 +19,342 @@ from apps.transactions.models import (
|
||||
TransactionEntity,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@app.task
|
||||
@app.task(name="check_for_transaction_rules")
|
||||
def check_for_transaction_rules(
|
||||
instance_id: int,
|
||||
signal,
|
||||
):
|
||||
try:
|
||||
with cachalot_disabled():
|
||||
|
||||
instance = Transaction.objects.get(id=instance_id)
|
||||
|
||||
context = {
|
||||
"account_name": instance.account.name,
|
||||
"account_id": instance.account.id,
|
||||
"account_group_name": (
|
||||
instance.account.group.name if instance.account.group else None
|
||||
),
|
||||
"account_group_id": (
|
||||
instance.account.group.id if instance.account.group else None
|
||||
),
|
||||
"is_asset_account": instance.account.is_asset,
|
||||
"is_archived_account": instance.account.is_archived,
|
||||
"category_name": instance.category.name if instance.category else None,
|
||||
"category_id": instance.category.id if instance.category else None,
|
||||
"tag_names": [x.name for x in instance.tags.all()],
|
||||
"tag_ids": [x.id for x in instance.tags.all()],
|
||||
"entities_names": [x.name for x in instance.entities.all()],
|
||||
"entities_ids": [x.id for x in instance.entities.all()],
|
||||
"is_expense": instance.type == Transaction.Type.EXPENSE,
|
||||
"is_income": instance.type == Transaction.Type.INCOME,
|
||||
"is_paid": instance.is_paid,
|
||||
"description": instance.description,
|
||||
"amount": instance.amount,
|
||||
"notes": instance.notes,
|
||||
"date": instance.date,
|
||||
"reference_date": instance.reference_date,
|
||||
functions = {
|
||||
"relativedelta": relativedelta,
|
||||
"str": str,
|
||||
"int": int,
|
||||
"float": float,
|
||||
"decimal": decimal.Decimal,
|
||||
"datetime": datetime,
|
||||
"date": date,
|
||||
}
|
||||
|
||||
functions = {"relativedelta": relativedelta}
|
||||
|
||||
simple = EvalWithCompoundTypes(names=context, functions=functions)
|
||||
simple = EvalWithCompoundTypes(
|
||||
names=_get_names(instance), functions=functions
|
||||
)
|
||||
|
||||
if signal == "transaction_created":
|
||||
rules = TransactionRule.objects.filter(active=True, on_create=True)
|
||||
rules = TransactionRule.objects.filter(
|
||||
active=True, on_create=True
|
||||
).order_by("id")
|
||||
elif signal == "transaction_updated":
|
||||
rules = TransactionRule.objects.filter(active=True, on_update=True)
|
||||
rules = TransactionRule.objects.filter(
|
||||
active=True, on_update=True
|
||||
).order_by("id")
|
||||
else:
|
||||
rules = TransactionRule.objects.filter(active=True)
|
||||
rules = TransactionRule.objects.filter(active=True).order_by("id")
|
||||
|
||||
for rule in rules:
|
||||
if simple.eval(rule.trigger):
|
||||
for action in rule.transaction_actions.all():
|
||||
if action.field in [
|
||||
TransactionRuleAction.Field.type,
|
||||
TransactionRuleAction.Field.is_paid,
|
||||
TransactionRuleAction.Field.date,
|
||||
TransactionRuleAction.Field.reference_date,
|
||||
TransactionRuleAction.Field.amount,
|
||||
TransactionRuleAction.Field.description,
|
||||
TransactionRuleAction.Field.notes,
|
||||
]:
|
||||
setattr(
|
||||
instance,
|
||||
action.field,
|
||||
simple.eval(action.value),
|
||||
try:
|
||||
instance = _process_edit_transaction_action(
|
||||
instance=instance, action=action, simple_eval=simple
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error processing edit transaction action {action.id}",
|
||||
exc_info=True,
|
||||
)
|
||||
# else:
|
||||
# simple.names.update(_get_names(instance))
|
||||
# instance.save()
|
||||
|
||||
simple.names.update(_get_names(instance))
|
||||
instance.save()
|
||||
|
||||
for action in rule.update_or_create_transaction_actions.all():
|
||||
try:
|
||||
_process_update_or_create_transaction_action(
|
||||
action=action, simple_eval=simple
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error processing update or create transaction action {action.id}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
elif action.field == TransactionRuleAction.Field.account:
|
||||
value = simple.eval(action.value)
|
||||
if isinstance(value, int):
|
||||
account = Account.objects.get(id=value)
|
||||
instance.account = account
|
||||
elif isinstance(value, str):
|
||||
account = Account.objects.filter(name=value).first()
|
||||
instance.account = account
|
||||
|
||||
elif action.field == TransactionRuleAction.Field.category:
|
||||
value = simple.eval(action.value)
|
||||
if isinstance(value, int):
|
||||
category = TransactionCategory.objects.get(id=value)
|
||||
instance.category = category
|
||||
elif isinstance(value, str):
|
||||
category = TransactionCategory.objects.get(name=value)
|
||||
instance.category = category
|
||||
|
||||
elif action.field == TransactionRuleAction.Field.tags:
|
||||
value = simple.eval(action.value)
|
||||
if isinstance(value, list):
|
||||
# Clear existing tags
|
||||
instance.tags.clear()
|
||||
for tag_value in value:
|
||||
if isinstance(tag_value, int):
|
||||
tag = TransactionTag.objects.get(id=tag_value)
|
||||
instance.tags.add(tag)
|
||||
elif isinstance(tag_value, str):
|
||||
tag = TransactionTag.objects.get(name=tag_value)
|
||||
instance.tags.add(tag)
|
||||
|
||||
elif isinstance(value, (int, str)):
|
||||
# If a single value is provided, treat it as a single tag
|
||||
instance.tags.clear()
|
||||
if isinstance(value, int):
|
||||
tag = TransactionTag.objects.get(id=value)
|
||||
else:
|
||||
tag = TransactionTag.objects.get(name=value)
|
||||
|
||||
instance.tags.add(tag)
|
||||
|
||||
elif action.field == TransactionRuleAction.Field.entities:
|
||||
value = simple.eval(action.value)
|
||||
if isinstance(value, list):
|
||||
# Clear existing entities
|
||||
instance.entities.clear()
|
||||
for entity_value in value:
|
||||
if isinstance(entity_value, int):
|
||||
entity = TransactionEntity.objects.get(
|
||||
id=entity_value
|
||||
)
|
||||
instance.entities.add(entity)
|
||||
elif isinstance(entity_value, str):
|
||||
entity = TransactionEntity.objects.get(
|
||||
name=entity_value
|
||||
)
|
||||
instance.entities.add(entity)
|
||||
|
||||
elif isinstance(value, (int, str)):
|
||||
# If a single value is provided, treat it as a single entity
|
||||
instance.entities.clear()
|
||||
if isinstance(value, int):
|
||||
entity = TransactionEntity.objects.get(id=value)
|
||||
else:
|
||||
entity = TransactionEntity.objects.get(name=value)
|
||||
|
||||
instance.entities.add(entity)
|
||||
|
||||
instance.save()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error while executing 'check_for_transaction_rules' task",
|
||||
exc_info=True,
|
||||
)
|
||||
raise e
|
||||
|
||||
|
||||
def _get_names(instance):
|
||||
return {
|
||||
"id": instance.id,
|
||||
"account_name": instance.account.name,
|
||||
"account_id": instance.account.id,
|
||||
"account_group_name": (
|
||||
instance.account.group.name if instance.account.group else None
|
||||
),
|
||||
"account_group_id": (
|
||||
instance.account.group.id if instance.account.group else None
|
||||
),
|
||||
"is_asset_account": instance.account.is_asset,
|
||||
"is_archived_account": instance.account.is_archived,
|
||||
"category_name": instance.category.name if instance.category else None,
|
||||
"category_id": instance.category.id if instance.category else None,
|
||||
"tag_names": [x.name for x in instance.tags.all()],
|
||||
"tag_ids": [x.id for x in instance.tags.all()],
|
||||
"entities_names": [x.name for x in instance.entities.all()],
|
||||
"entities_ids": [x.id for x in instance.entities.all()],
|
||||
"is_expense": instance.type == Transaction.Type.EXPENSE,
|
||||
"is_income": instance.type == Transaction.Type.INCOME,
|
||||
"is_paid": instance.is_paid,
|
||||
"description": instance.description,
|
||||
"amount": instance.amount,
|
||||
"notes": instance.notes,
|
||||
"date": instance.date,
|
||||
"reference_date": instance.reference_date,
|
||||
"internal_note": instance.internal_note,
|
||||
"internal_id": instance.internal_id,
|
||||
}
|
||||
|
||||
|
||||
def _process_update_or_create_transaction_action(action, simple_eval):
|
||||
"""Helper to process a single linked transaction action"""
|
||||
|
||||
# Build search query using the helper method
|
||||
search_query = action.build_search_query(simple_eval)
|
||||
|
||||
# Find latest matching transaction or create new
|
||||
if search_query:
|
||||
transaction = (
|
||||
Transaction.objects.filter(search_query).order_by("-date", "-id").first()
|
||||
)
|
||||
else:
|
||||
transaction = None
|
||||
|
||||
if not transaction:
|
||||
transaction = Transaction()
|
||||
|
||||
simple_eval.names.update(
|
||||
{
|
||||
"my_account_name": (transaction.account.name if transaction.id else None),
|
||||
"my_account_id": transaction.account.id if transaction.id else None,
|
||||
"my_account_group_name": (
|
||||
transaction.account.group.name
|
||||
if transaction.id and transaction.account.group
|
||||
else None
|
||||
),
|
||||
"my_account_group_id": (
|
||||
transaction.account.group.id
|
||||
if transaction.id and transaction.account.group
|
||||
else None
|
||||
),
|
||||
"my_is_asset_account": (
|
||||
transaction.account.is_asset if transaction.id else None
|
||||
),
|
||||
"my_is_archived_account": (
|
||||
transaction.account.is_archived if transaction.id else None
|
||||
),
|
||||
"my_category_name": (
|
||||
transaction.category.name if transaction.category else None
|
||||
),
|
||||
"my_category_id": transaction.category.id if transaction.category else None,
|
||||
"my_tag_names": (
|
||||
[x.name for x in transaction.tags.all()] if transaction.id else []
|
||||
),
|
||||
"my_tag_ids": (
|
||||
[x.id for x in transaction.tags.all()] if transaction.id else []
|
||||
),
|
||||
"my_entities_names": (
|
||||
[x.name for x in transaction.entities.all()] if transaction.id else []
|
||||
),
|
||||
"my_entities_ids": (
|
||||
[x.id for x in transaction.entities.all()] if transaction.id else []
|
||||
),
|
||||
"my_is_expense": transaction.type == Transaction.Type.EXPENSE,
|
||||
"my_is_income": transaction.type == Transaction.Type.INCOME,
|
||||
"my_is_paid": transaction.is_paid,
|
||||
"my_description": transaction.description,
|
||||
"my_amount": transaction.amount or 0,
|
||||
"my_notes": transaction.notes,
|
||||
"my_date": transaction.date,
|
||||
"my_reference_date": transaction.reference_date,
|
||||
"my_internal_note": transaction.internal_note,
|
||||
"my_internal_id": transaction.reference_date,
|
||||
}
|
||||
)
|
||||
|
||||
if action.filter:
|
||||
value = simple_eval.eval(action.filter)
|
||||
if not value:
|
||||
return # Short-circuit execution if filter evaluates to false
|
||||
|
||||
# Set fields if provided
|
||||
if action.set_account:
|
||||
value = simple_eval.eval(action.set_account)
|
||||
if isinstance(value, int):
|
||||
transaction.account = Account.objects.get(id=value)
|
||||
else:
|
||||
transaction.account = Account.objects.get(name=value)
|
||||
|
||||
if action.set_type:
|
||||
transaction.type = simple_eval.eval(action.set_type)
|
||||
|
||||
if action.set_is_paid:
|
||||
transaction.is_paid = simple_eval.eval(action.set_is_paid)
|
||||
|
||||
if action.set_date:
|
||||
transaction.date = simple_eval.eval(action.set_date)
|
||||
|
||||
if action.set_reference_date:
|
||||
transaction.reference_date = simple_eval.eval(action.set_reference_date)
|
||||
|
||||
if action.set_amount:
|
||||
transaction.amount = simple_eval.eval(action.set_amount)
|
||||
|
||||
if action.set_description:
|
||||
transaction.description = simple_eval.eval(action.set_description)
|
||||
|
||||
if action.set_internal_note:
|
||||
transaction.internal_note = simple_eval.eval(action.set_internal_note)
|
||||
|
||||
if action.set_internal_id:
|
||||
transaction.internal_id = simple_eval.eval(action.set_internal_id)
|
||||
|
||||
if action.set_notes:
|
||||
transaction.notes = simple_eval.eval(action.set_notes)
|
||||
|
||||
if action.set_category:
|
||||
value = simple_eval.eval(action.set_category)
|
||||
if isinstance(value, int):
|
||||
transaction.category = TransactionCategory.objects.get(id=value)
|
||||
else:
|
||||
transaction.category = TransactionCategory.objects.get(name=value)
|
||||
|
||||
transaction.save()
|
||||
|
||||
# Handle M2M fields after save
|
||||
if action.set_tags:
|
||||
tags_value = simple_eval.eval(action.set_tags)
|
||||
transaction.tags.clear()
|
||||
if isinstance(tags_value, (list, tuple)):
|
||||
for tag in tags_value:
|
||||
if isinstance(tag, int):
|
||||
transaction.tags.add(TransactionTag.objects.get(id=tag))
|
||||
else:
|
||||
transaction.tags.add(TransactionTag.objects.get(name=tag))
|
||||
elif isinstance(tags_value, (int, str)):
|
||||
if isinstance(tags_value, int):
|
||||
transaction.tags.add(TransactionTag.objects.get(id=tags_value))
|
||||
else:
|
||||
transaction.tags.add(TransactionTag.objects.get(name=tags_value))
|
||||
|
||||
if action.set_entities:
|
||||
entities_value = simple_eval.eval(action.set_entities)
|
||||
transaction.entities.clear()
|
||||
if isinstance(entities_value, (list, tuple)):
|
||||
for entity in entities_value:
|
||||
if isinstance(entity, int):
|
||||
transaction.entities.add(TransactionEntity.objects.get(id=entity))
|
||||
else:
|
||||
transaction.entities.add(TransactionEntity.objects.get(name=entity))
|
||||
elif isinstance(entities_value, (int, str)):
|
||||
if isinstance(entities_value, int):
|
||||
transaction.entities.add(
|
||||
TransactionEntity.objects.get(id=entities_value)
|
||||
)
|
||||
else:
|
||||
transaction.entities.add(
|
||||
TransactionEntity.objects.get(name=entities_value)
|
||||
)
|
||||
|
||||
|
||||
def _process_edit_transaction_action(instance, action, simple_eval) -> Transaction:
|
||||
if action.field in [
|
||||
TransactionRuleAction.Field.type,
|
||||
TransactionRuleAction.Field.is_paid,
|
||||
TransactionRuleAction.Field.date,
|
||||
TransactionRuleAction.Field.reference_date,
|
||||
TransactionRuleAction.Field.amount,
|
||||
TransactionRuleAction.Field.description,
|
||||
TransactionRuleAction.Field.notes,
|
||||
]:
|
||||
setattr(
|
||||
instance,
|
||||
action.field,
|
||||
simple_eval.eval(action.value),
|
||||
)
|
||||
|
||||
elif action.field == TransactionRuleAction.Field.account:
|
||||
value = simple_eval.eval(action.value)
|
||||
if isinstance(value, int):
|
||||
account = Account.objects.get(id=value)
|
||||
instance.account = account
|
||||
elif isinstance(value, str):
|
||||
account = Account.objects.filter(name=value).first()
|
||||
instance.account = account
|
||||
|
||||
elif action.field == TransactionRuleAction.Field.category:
|
||||
value = simple_eval.eval(action.value)
|
||||
if isinstance(value, int):
|
||||
category = TransactionCategory.objects.get(id=value)
|
||||
instance.category = category
|
||||
elif isinstance(value, str):
|
||||
category = TransactionCategory.objects.get(name=value)
|
||||
instance.category = category
|
||||
|
||||
elif action.field == TransactionRuleAction.Field.tags:
|
||||
value = simple_eval.eval(action.value)
|
||||
if isinstance(value, list):
|
||||
# Clear existing tags
|
||||
instance.tags.clear()
|
||||
for tag_value in value:
|
||||
if isinstance(tag_value, int):
|
||||
tag = TransactionTag.objects.get(id=tag_value)
|
||||
instance.tags.add(tag)
|
||||
elif isinstance(tag_value, str):
|
||||
tag = TransactionTag.objects.get(name=tag_value)
|
||||
instance.tags.add(tag)
|
||||
|
||||
elif isinstance(value, (int, str)):
|
||||
# If a single value is provided, treat it as a single tag
|
||||
instance.tags.clear()
|
||||
if isinstance(value, int):
|
||||
tag = TransactionTag.objects.get(id=value)
|
||||
else:
|
||||
tag = TransactionTag.objects.get(name=value)
|
||||
|
||||
instance.tags.add(tag)
|
||||
|
||||
elif action.field == TransactionRuleAction.Field.entities:
|
||||
value = simple_eval.eval(action.value)
|
||||
if isinstance(value, list):
|
||||
# Clear existing entities
|
||||
instance.entities.clear()
|
||||
for entity_value in value:
|
||||
if isinstance(entity_value, int):
|
||||
entity = TransactionEntity.objects.get(id=entity_value)
|
||||
instance.entities.add(entity)
|
||||
elif isinstance(entity_value, str):
|
||||
entity = TransactionEntity.objects.get(name=entity_value)
|
||||
instance.entities.add(entity)
|
||||
|
||||
elif isinstance(value, (int, str)):
|
||||
# If a single value is provided, treat it as a single entity
|
||||
instance.entities.clear()
|
||||
if isinstance(value, int):
|
||||
entity = TransactionEntity.objects.get(id=value)
|
||||
else:
|
||||
entity = TransactionEntity.objects.get(name=value)
|
||||
|
||||
instance.entities.add(entity)
|
||||
|
||||
return instance
|
||||
|
||||
@@ -38,18 +38,33 @@ urlpatterns = [
|
||||
name="transaction_rule_delete",
|
||||
),
|
||||
path(
|
||||
"rules/transaction/<int:transaction_rule_id>/action/add/",
|
||||
"rules/transaction/<int:transaction_rule_id>/transaction-action/add/",
|
||||
views.transaction_rule_action_add,
|
||||
name="transaction_rule_action_add",
|
||||
),
|
||||
path(
|
||||
"rules/transaction/action/<int:transaction_rule_action_id>/edit/",
|
||||
"rules/transaction/transaction-action/<int:transaction_rule_action_id>/edit/",
|
||||
views.transaction_rule_action_edit,
|
||||
name="transaction_rule_action_edit",
|
||||
),
|
||||
path(
|
||||
"rules/transaction/action/<int:transaction_rule_action_id>/delete/",
|
||||
"rules/transaction/transaction-action/<int:transaction_rule_action_id>/delete/",
|
||||
views.transaction_rule_action_delete,
|
||||
name="transaction_rule_action_delete",
|
||||
),
|
||||
path(
|
||||
"rules/transaction/<int:transaction_rule_id>/update-or-create-transaction-action/add/",
|
||||
views.update_or_create_transaction_rule_action_add,
|
||||
name="update_or_create_transaction_rule_action_add",
|
||||
),
|
||||
path(
|
||||
"rules/transaction/update-or-create-transaction-action/<int:pk>/edit/",
|
||||
views.update_or_create_transaction_rule_action_edit,
|
||||
name="update_or_create_transaction_rule_action_edit",
|
||||
),
|
||||
path(
|
||||
"rules/transaction/update-or-create-transaction-action/<int:pk>/delete/",
|
||||
views.update_or_create_transaction_rule_action_delete,
|
||||
name="update_or_create_transaction_rule_action_delete",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -6,8 +6,16 @@ from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
from apps.rules.forms import TransactionRuleForm, TransactionRuleActionForm
|
||||
from apps.rules.models import TransactionRule, TransactionRuleAction
|
||||
from apps.rules.forms import (
|
||||
TransactionRuleForm,
|
||||
TransactionRuleActionForm,
|
||||
UpdateOrCreateTransactionRuleActionForm,
|
||||
)
|
||||
from apps.rules.models import (
|
||||
TransactionRule,
|
||||
TransactionRuleAction,
|
||||
UpdateOrCreateTransactionRuleAction,
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -60,10 +68,15 @@ def transaction_rule_add(request, **kwargs):
|
||||
if request.method == "POST":
|
||||
form = TransactionRuleForm(request.POST)
|
||||
if form.is_valid():
|
||||
instance = form.save()
|
||||
form.save()
|
||||
messages.success(request, _("Rule added successfully"))
|
||||
|
||||
return redirect("transaction_rule_action_add", instance.id)
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = TransactionRuleForm()
|
||||
|
||||
@@ -215,3 +228,88 @@ def transaction_rule_action_delete(request, transaction_rule_action_id):
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def update_or_create_transaction_rule_action_add(request, transaction_rule_id):
|
||||
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
|
||||
|
||||
if request.method == "POST":
|
||||
form = UpdateOrCreateTransactionRuleActionForm(
|
||||
request.POST, rule=transaction_rule
|
||||
)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(
|
||||
request, _("Update or Create Transaction action added successfully")
|
||||
)
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = UpdateOrCreateTransactionRuleActionForm(rule=transaction_rule)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"rules/fragments/transaction_rule/update_or_create_transaction_rule_action/add.html",
|
||||
{"form": form, "transaction_rule_id": transaction_rule_id},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def update_or_create_transaction_rule_action_edit(request, pk):
|
||||
linked_action = get_object_or_404(UpdateOrCreateTransactionRuleAction, id=pk)
|
||||
transaction_rule = linked_action.rule
|
||||
|
||||
if request.method == "POST":
|
||||
form = UpdateOrCreateTransactionRuleActionForm(
|
||||
request.POST, instance=linked_action, rule=transaction_rule
|
||||
)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(
|
||||
request, _("Update or Create Transaction action updated successfully")
|
||||
)
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = UpdateOrCreateTransactionRuleActionForm(
|
||||
instance=linked_action, rule=transaction_rule
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"rules/fragments/transaction_rule/update_or_create_transaction_rule_action/edit.html",
|
||||
{"form": form, "action": linked_action},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["DELETE"])
|
||||
def update_or_create_transaction_rule_action_delete(request, pk):
|
||||
linked_action = get_object_or_404(UpdateOrCreateTransactionRuleAction, id=pk)
|
||||
|
||||
linked_action.delete()
|
||||
|
||||
messages.success(
|
||||
request, _("Update or Create Transaction action deleted successfully")
|
||||
)
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -133,7 +133,7 @@ class TransactionsFilter(django_filters.FilterSet):
|
||||
"to_amount",
|
||||
]
|
||||
|
||||
def __init__(self, data=None, user=None, *args, **kwargs):
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
# if filterset is bound, use initial values as defaults
|
||||
if data is not None:
|
||||
# get a mutable copy of the QueryDict
|
||||
@@ -182,5 +182,5 @@ class TransactionsFilter(django_filters.FilterSet):
|
||||
|
||||
self.form.fields["to_amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
self.form.fields["from_amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
self.form.fields["date_start"].widget = AirDatePickerInput(user=user)
|
||||
self.form.fields["date_end"].widget = AirDatePickerInput(user=user)
|
||||
self.form.fields["date_start"].widget = AirDatePickerInput()
|
||||
self.form.fields["date_end"].widget = AirDatePickerInput()
|
||||
|
||||
@@ -63,7 +63,9 @@ class TransactionForm(forms.ModelForm):
|
||||
date = forms.DateField(label=_("Date"))
|
||||
|
||||
reference_date = forms.DateField(
|
||||
widget=AirMonthYearPickerInput(), label=_("Reference Date"), required=False
|
||||
widget=AirMonthYearPickerInput(),
|
||||
label=_("Reference Date"),
|
||||
required=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -86,7 +88,7 @@ class TransactionForm(forms.ModelForm):
|
||||
"account": TomSelect(clear_button=False, group_by="group"),
|
||||
}
|
||||
|
||||
def __init__(self, *args, user=None, **kwargs):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# if editing a transaction display non-archived items and it's own item even if it's archived
|
||||
@@ -176,8 +178,7 @@ class TransactionForm(forms.ModelForm):
|
||||
),
|
||||
)
|
||||
|
||||
self.fields["reference_date"].required = False
|
||||
self.fields["date"].widget = AirDatePickerInput(clear_button=False, user=user)
|
||||
self.fields["date"].widget = AirDatePickerInput(clear_button=False)
|
||||
|
||||
if self.instance and self.instance.pk:
|
||||
decimal_places = self.instance.account.currency.decimal_places
|
||||
@@ -333,7 +334,7 @@ class TransferForm(forms.Form):
|
||||
label=_("Notes"),
|
||||
)
|
||||
|
||||
def __init__(self, *args, user=None, **kwargs):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper()
|
||||
@@ -402,7 +403,7 @@ class TransferForm(forms.Form):
|
||||
|
||||
self.fields["from_amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
self.fields["to_amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
self.fields["date"].widget = AirDatePickerInput(clear_button=False, user=user)
|
||||
self.fields["date"].widget = AirDatePickerInput(clear_button=False)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
@@ -515,7 +516,7 @@ class InstallmentPlanForm(forms.ModelForm):
|
||||
"notes": forms.Textarea(attrs={"rows": 3}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, user=None, **kwargs):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# if editing display non-archived items and it's own item even if it's archived
|
||||
@@ -572,9 +573,7 @@ class InstallmentPlanForm(forms.ModelForm):
|
||||
)
|
||||
|
||||
self.fields["installment_amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
self.fields["start_date"].widget = AirDatePickerInput(
|
||||
clear_button=False, user=user
|
||||
)
|
||||
self.fields["start_date"].widget = AirDatePickerInput(clear_button=False)
|
||||
|
||||
if self.instance and self.instance.pk:
|
||||
self.helper.layout.append(
|
||||
@@ -762,7 +761,7 @@ class RecurringTransactionForm(forms.ModelForm):
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self, *args, user=None, **kwargs):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# if editing display non-archived items and it's own item even if it's archived
|
||||
@@ -819,10 +818,8 @@ class RecurringTransactionForm(forms.ModelForm):
|
||||
)
|
||||
|
||||
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
self.fields["start_date"].widget = AirDatePickerInput(
|
||||
clear_button=False, user=user
|
||||
)
|
||||
self.fields["end_date"].widget = AirDatePickerInput(user=user)
|
||||
self.fields["start_date"].widget = AirDatePickerInput(clear_button=False)
|
||||
self.fields["end_date"].widget = AirDatePickerInput()
|
||||
|
||||
if self.instance and self.instance.pk:
|
||||
self.helper.layout.append(
|
||||
|
||||
@@ -1,22 +1,66 @@
|
||||
import logging
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.conf import settings
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models, transaction
|
||||
from django.db.models import Q
|
||||
from django.dispatch import Signal
|
||||
from django.template.defaultfilters import date
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.conf import settings
|
||||
|
||||
from apps.common.fields.month_year import MonthYearModelField
|
||||
from apps.common.functions.decimals import truncate_decimal
|
||||
from apps.common.templatetags.decimal import localize_number, drop_trailing_zeros
|
||||
from apps.currencies.utils.convert import convert
|
||||
from apps.transactions.validators import validate_decimal_places, validate_non_negative
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
|
||||
transaction_created = Signal()
|
||||
transaction_updated = Signal()
|
||||
|
||||
|
||||
class SoftDeleteQuerySet(models.QuerySet):
|
||||
@staticmethod
|
||||
def _emit_signals(instances, created=False):
|
||||
"""Helper to emit signals for multiple instances"""
|
||||
for instance in instances:
|
||||
if created:
|
||||
transaction_created.send(sender=instance)
|
||||
else:
|
||||
transaction_updated.send(sender=instance)
|
||||
|
||||
def bulk_create(self, objs, emit_signal=True, **kwargs):
|
||||
instances = super().bulk_create(objs, **kwargs)
|
||||
|
||||
if emit_signal:
|
||||
self._emit_signals(instances, created=True)
|
||||
|
||||
return instances
|
||||
|
||||
def bulk_update(self, objs, fields, emit_signal=True, **kwargs):
|
||||
result = super().bulk_update(objs, fields, **kwargs)
|
||||
|
||||
if emit_signal:
|
||||
self._emit_signals(objs, created=False)
|
||||
|
||||
return result
|
||||
|
||||
def update(self, emit_signal=True, **kwargs):
|
||||
# Get instances before update
|
||||
instances = list(self)
|
||||
result = super().update(**kwargs)
|
||||
|
||||
if emit_signal:
|
||||
# Refresh instances to get new values
|
||||
refreshed = self.model.objects.filter(pk__in=[obj.pk for obj in instances])
|
||||
self._emit_signals(refreshed, created=False)
|
||||
|
||||
return result
|
||||
|
||||
def delete(self):
|
||||
if not settings.ENABLE_SOFT_DELETE:
|
||||
# If soft deletion is disabled, perform a normal delete
|
||||
@@ -49,7 +93,7 @@ class SoftDeleteQuerySet(models.QuerySet):
|
||||
class SoftDeleteManager(models.Manager):
|
||||
def get_queryset(self):
|
||||
qs = SoftDeleteQuerySet(self.model, using=self._db)
|
||||
return qs if not settings.ENABLE_SOFT_DELETE else qs.filter(deleted=False)
|
||||
return qs.filter(deleted=False)
|
||||
|
||||
|
||||
class AllObjectsManager(models.Manager):
|
||||
@@ -60,7 +104,7 @@ class AllObjectsManager(models.Manager):
|
||||
class DeletedObjectsManager(models.Manager):
|
||||
def get_queryset(self):
|
||||
qs = SoftDeleteQuerySet(self.model, using=self._db)
|
||||
return qs if not settings.ENABLE_SOFT_DELETE else qs.filter(deleted=True)
|
||||
return qs.filter(deleted=True)
|
||||
|
||||
|
||||
class TransactionCategory(models.Model):
|
||||
@@ -228,9 +272,12 @@ class Transaction(models.Model):
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
if settings.ENABLE_SOFT_DELETE:
|
||||
self.deleted = True
|
||||
self.deleted_at = timezone.now()
|
||||
self.save()
|
||||
if not self.deleted:
|
||||
self.deleted = True
|
||||
self.deleted_at = timezone.now()
|
||||
self.save()
|
||||
else:
|
||||
super().delete(*args, **kwargs)
|
||||
else:
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
@@ -252,12 +299,32 @@ class Transaction(models.Model):
|
||||
"suffix": suffix,
|
||||
"decimal_places": decimal_places,
|
||||
}
|
||||
elif self.account.currency.exchange_currency:
|
||||
converted_amount, prefix, suffix, decimal_places = convert(
|
||||
self.amount,
|
||||
to_currency=self.account.currency.exchange_currency,
|
||||
from_currency=self.account.currency,
|
||||
date=self.date,
|
||||
)
|
||||
if converted_amount:
|
||||
return {
|
||||
"amount": converted_amount,
|
||||
"prefix": prefix,
|
||||
"suffix": suffix,
|
||||
"decimal_places": decimal_places,
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
def __str__(self):
|
||||
type_display = self.get_type_display()
|
||||
return f"{self.description} - {type_display} - {self.account} - {self.date}"
|
||||
frmt_date = date(self.date, "SHORT_DATE_FORMAT")
|
||||
account = self.account
|
||||
tags = ", ".join([x.name for x in self.tags.all()]) or _("No tags")
|
||||
category = self.category or _("No category")
|
||||
amount = localize_number(drop_trailing_zeros(self.amount))
|
||||
description = self.description or _("No description")
|
||||
return f"[{frmt_date}][{type_display}][{account}] {description} • {category} • {tags} • {amount}"
|
||||
|
||||
|
||||
class InstallmentPlan(models.Model):
|
||||
|
||||
@@ -13,7 +13,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@app.periodic(cron="0 0 * * *")
|
||||
@app.task
|
||||
@app.task(name="generate_recurring_transactions")
|
||||
def generate_recurring_transactions(timestamp=None):
|
||||
try:
|
||||
RecurringTransaction.generate_upcoming_transactions()
|
||||
@@ -26,8 +26,8 @@ def generate_recurring_transactions(timestamp=None):
|
||||
|
||||
|
||||
@app.periodic(cron="10 1 * * *")
|
||||
@app.task
|
||||
def cleanup_deleted_transactions():
|
||||
@app.task(name="cleanup_deleted_transactions")
|
||||
def cleanup_deleted_transactions(timestamp=None):
|
||||
with cachalot_disabled():
|
||||
if settings.ENABLE_SOFT_DELETE and settings.KEEP_DELETED_TRANSACTIONS_FOR == 0:
|
||||
return "KEEP_DELETED_TRANSACTIONS_FOR is 0, no cleanup performed."
|
||||
@@ -44,7 +44,7 @@ def cleanup_deleted_transactions():
|
||||
days=settings.KEEP_DELETED_TRANSACTIONS_FOR
|
||||
)
|
||||
|
||||
invalidate("transactions.Transaction")
|
||||
invalidate()
|
||||
|
||||
# Hard delete soft-deleted transactions older than the cutoff date
|
||||
old_transactions = Transaction.deleted_objects.filter(deleted_at__lt=cutoff_date)
|
||||
|
||||
@@ -6,11 +6,36 @@ urlpatterns = [
|
||||
path(
|
||||
"transactions/list/", views.transaction_all_list, name="transactions_all_list"
|
||||
),
|
||||
path(
|
||||
"transactions/trash/",
|
||||
views.transactions_trash_can_index,
|
||||
name="transactions_trash_index",
|
||||
),
|
||||
path(
|
||||
"transactions/trash/list/",
|
||||
views.transactions_trash_can_list,
|
||||
name="transactions_trash_list",
|
||||
),
|
||||
path(
|
||||
"transactions/summary/",
|
||||
views.transaction_all_summary,
|
||||
name="transactions_all_summary",
|
||||
),
|
||||
path(
|
||||
"transactions/summary/account/",
|
||||
views.transaction_all_account_summary,
|
||||
name="transaction_all_account_summary",
|
||||
),
|
||||
path(
|
||||
"transactions/summary/currency/",
|
||||
views.transaction_all_currency_summary,
|
||||
name="transaction_all_currency_summary",
|
||||
),
|
||||
path(
|
||||
"transactions/summary/select/<str:selected>/",
|
||||
views.transaction_all_summary_select,
|
||||
name="transaction_all_summary_select",
|
||||
),
|
||||
path(
|
||||
"transactions/actions/pay/",
|
||||
views.bulk_pay_transactions,
|
||||
@@ -26,6 +51,11 @@ urlpatterns = [
|
||||
views.bulk_delete_transactions,
|
||||
name="transactions_bulk_delete",
|
||||
),
|
||||
path(
|
||||
"transactions/actions/undelete/",
|
||||
views.bulk_undelete_transactions,
|
||||
name="transactions_bulk_undelete",
|
||||
),
|
||||
path(
|
||||
"transactions/actions/duplicate/",
|
||||
views.bulk_clone_transactions,
|
||||
@@ -41,6 +71,11 @@ urlpatterns = [
|
||||
views.transaction_delete,
|
||||
name="transaction_delete",
|
||||
),
|
||||
path(
|
||||
"transaction/<int:transaction_id>/undelete/",
|
||||
views.transaction_undelete,
|
||||
name="transaction_undelete",
|
||||
),
|
||||
path(
|
||||
"transaction/<int:transaction_id>/edit/",
|
||||
views.transaction_edit,
|
||||
@@ -51,6 +86,16 @@ urlpatterns = [
|
||||
views.transactions_bulk_edit,
|
||||
name="transactions_bulk_edit",
|
||||
),
|
||||
path(
|
||||
"transactions/json/search/",
|
||||
views.get_recent_transactions,
|
||||
name="transactions_search",
|
||||
),
|
||||
path(
|
||||
"transactions/json/search/<str:filter_type>/",
|
||||
views.get_recent_transactions,
|
||||
name="transactions_search",
|
||||
),
|
||||
path(
|
||||
"transaction/<int:transaction_id>/clone/",
|
||||
views.transaction_clone,
|
||||
|
||||
@@ -72,8 +72,12 @@ def calculate_currency_totals(transactions_queryset, ignore_empty=False):
|
||||
.order_by()
|
||||
)
|
||||
|
||||
# Process the results and calculate additional totals
|
||||
# First pass: Process basic totals and store all currency data
|
||||
result = {}
|
||||
currencies_using_exchange = (
|
||||
{}
|
||||
) # Track which currencies use which exchange currencies
|
||||
|
||||
for total in currency_totals:
|
||||
# Skip empty currencies if ignore_empty is True
|
||||
if ignore_empty and all(
|
||||
@@ -91,7 +95,6 @@ def calculate_currency_totals(transactions_queryset, ignore_empty=False):
|
||||
total_current = total["income_current"] - total["expense_current"]
|
||||
total_projected = total["income_projected"] - total["expense_projected"]
|
||||
total_final = total_current + total_projected
|
||||
|
||||
currency_id = total["account__currency"]
|
||||
from_currency = Currency.objects.get(id=currency_id)
|
||||
exchange_currency = (
|
||||
@@ -120,8 +123,6 @@ def calculate_currency_totals(transactions_queryset, ignore_empty=False):
|
||||
# Add exchanged values if exchange_currency exists
|
||||
if exchange_currency:
|
||||
exchanged = {}
|
||||
|
||||
# Convert each value
|
||||
for field in [
|
||||
"expense_current",
|
||||
"expense_projected",
|
||||
@@ -136,7 +137,6 @@ def calculate_currency_totals(transactions_queryset, ignore_empty=False):
|
||||
from_currency=from_currency,
|
||||
to_currency=exchange_currency,
|
||||
)
|
||||
|
||||
if amount is not None:
|
||||
exchanged[field] = amount
|
||||
if "currency" not in exchanged:
|
||||
@@ -148,12 +148,48 @@ def calculate_currency_totals(transactions_queryset, ignore_empty=False):
|
||||
"name": exchange_currency.name,
|
||||
}
|
||||
|
||||
# Only add exchanged data if at least one conversion was successful
|
||||
if exchanged:
|
||||
currency_data["exchanged"] = exchanged
|
||||
# Track which currencies are using which exchange currencies
|
||||
if exchange_currency.id not in currencies_using_exchange:
|
||||
currencies_using_exchange[exchange_currency.id] = []
|
||||
currencies_using_exchange[exchange_currency.id].append(
|
||||
{"currency_id": currency_id, "exchanged": exchanged}
|
||||
)
|
||||
|
||||
result[currency_id] = currency_data
|
||||
|
||||
# Second pass: Add consolidated totals for currencies that are used as exchange currencies
|
||||
for currency_id, currency_data in result.items():
|
||||
if currency_id in currencies_using_exchange:
|
||||
consolidated = {
|
||||
"currency": currency_data["currency"].copy(),
|
||||
"expense_current": currency_data["expense_current"],
|
||||
"expense_projected": currency_data["expense_projected"],
|
||||
"income_current": currency_data["income_current"],
|
||||
"income_projected": currency_data["income_projected"],
|
||||
"total_current": currency_data["total_current"],
|
||||
"total_projected": currency_data["total_projected"],
|
||||
"total_final": currency_data["total_final"],
|
||||
}
|
||||
|
||||
# Add exchanged values from all currencies using this as exchange currency
|
||||
for using_currency in currencies_using_exchange[currency_id]:
|
||||
exchanged = using_currency["exchanged"]
|
||||
for field in [
|
||||
"expense_current",
|
||||
"expense_projected",
|
||||
"income_current",
|
||||
"income_projected",
|
||||
"total_current",
|
||||
"total_projected",
|
||||
"total_final",
|
||||
]:
|
||||
if field in exchanged:
|
||||
consolidated[field] += exchanged[field]
|
||||
|
||||
result[currency_id]["consolidated"] = consolidated
|
||||
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from django.utils.translation import gettext_lazy as _, ngettext_lazy
|
||||
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
from apps.transactions.models import Transaction
|
||||
from apps.rules.signals import transaction_updated
|
||||
|
||||
|
||||
@only_htmx
|
||||
@@ -61,7 +62,7 @@ def bulk_unpay_transactions(request):
|
||||
@login_required
|
||||
def bulk_delete_transactions(request):
|
||||
selected_transactions = request.GET.getlist("transactions", [])
|
||||
transactions = Transaction.objects.filter(id__in=selected_transactions)
|
||||
transactions = Transaction.all_objects.filter(id__in=selected_transactions)
|
||||
count = transactions.count()
|
||||
transactions.delete()
|
||||
|
||||
@@ -81,6 +82,30 @@ def bulk_delete_transactions(request):
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
def bulk_undelete_transactions(request):
|
||||
selected_transactions = request.GET.getlist("transactions", [])
|
||||
transactions = Transaction.deleted_objects.filter(id__in=selected_transactions)
|
||||
count = transactions.count()
|
||||
transactions.update(deleted=False, deleted_at=None, emit_signal=False)
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
ngettext_lazy(
|
||||
"%(count)s transaction restored successfully",
|
||||
"%(count)s transactions restored successfully",
|
||||
count,
|
||||
)
|
||||
% {"count": count},
|
||||
)
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={"HX-Trigger": "updated"},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
def bulk_clone_transactions(request):
|
||||
|
||||
@@ -81,7 +81,7 @@ def installment_plan_transactions(request, installment_plan_id):
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def installment_plan_add(request):
|
||||
if request.method == "POST":
|
||||
form = InstallmentPlanForm(request.POST, user=request.user)
|
||||
form = InstallmentPlanForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Installment Plan added successfully"))
|
||||
@@ -93,7 +93,7 @@ def installment_plan_add(request):
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = InstallmentPlanForm(user=request.user)
|
||||
form = InstallmentPlanForm()
|
||||
|
||||
return render(
|
||||
request,
|
||||
@@ -109,9 +109,7 @@ def installment_plan_edit(request, installment_plan_id):
|
||||
installment_plan = get_object_or_404(InstallmentPlan, id=installment_plan_id)
|
||||
|
||||
if request.method == "POST":
|
||||
form = InstallmentPlanForm(
|
||||
request.POST, instance=installment_plan, user=request.user
|
||||
)
|
||||
form = InstallmentPlanForm(request.POST, instance=installment_plan)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Installment Plan updated successfully"))
|
||||
@@ -123,7 +121,7 @@ def installment_plan_edit(request, installment_plan_id):
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = InstallmentPlanForm(instance=installment_plan, user=request.user)
|
||||
form = InstallmentPlanForm(instance=installment_plan)
|
||||
|
||||
return render(
|
||||
request,
|
||||
|
||||
@@ -106,7 +106,7 @@ def recurring_transaction_transactions(request, recurring_transaction_id):
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def recurring_transaction_add(request):
|
||||
if request.method == "POST":
|
||||
form = RecurringTransactionForm(request.POST, user=request.user)
|
||||
form = RecurringTransactionForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Recurring Transaction added successfully"))
|
||||
@@ -118,7 +118,7 @@ def recurring_transaction_add(request):
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = RecurringTransactionForm(user=request.user)
|
||||
form = RecurringTransactionForm()
|
||||
|
||||
return render(
|
||||
request,
|
||||
@@ -136,9 +136,7 @@ def recurring_transaction_edit(request, recurring_transaction_id):
|
||||
)
|
||||
|
||||
if request.method == "POST":
|
||||
form = RecurringTransactionForm(
|
||||
request.POST, instance=recurring_transaction, user=request.user
|
||||
)
|
||||
form = RecurringTransactionForm(request.POST, instance=recurring_transaction)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Recurring Transaction updated successfully"))
|
||||
@@ -150,9 +148,7 @@ def recurring_transaction_edit(request, recurring_transaction_id):
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = RecurringTransactionForm(
|
||||
instance=recurring_transaction, user=request.user
|
||||
)
|
||||
form = RecurringTransactionForm(instance=recurring_transaction)
|
||||
|
||||
return render(
|
||||
request,
|
||||
|
||||
@@ -4,14 +4,14 @@ from copy import deepcopy
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.paginator import Paginator
|
||||
from django.http import HttpResponse
|
||||
from django.db.models import Q
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from django.shortcuts import render, get_object_or_404
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _, ngettext_lazy
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
from apps.common.utils.dicts import remove_falsey_entries
|
||||
from apps.rules.signals import transaction_created, transaction_updated
|
||||
from apps.transactions.filters import TransactionsFilter
|
||||
from apps.transactions.forms import (
|
||||
@@ -44,7 +44,7 @@ def transaction_add(request):
|
||||
).date()
|
||||
|
||||
if request.method == "POST":
|
||||
form = TransactionForm(request.POST, user=request.user)
|
||||
form = TransactionForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Transaction added successfully"))
|
||||
@@ -55,7 +55,6 @@ def transaction_add(request):
|
||||
)
|
||||
else:
|
||||
form = TransactionForm(
|
||||
user=request.user,
|
||||
initial={
|
||||
"date": expected_date,
|
||||
"type": transaction_type,
|
||||
@@ -84,13 +83,12 @@ def transaction_simple_add(request):
|
||||
).date()
|
||||
|
||||
if request.method == "POST":
|
||||
form = TransactionForm(request.POST, user=request.user)
|
||||
form = TransactionForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Transaction added successfully"))
|
||||
|
||||
form = TransactionForm(
|
||||
user=request.user,
|
||||
initial={
|
||||
"date": expected_date,
|
||||
"type": transaction_type,
|
||||
@@ -99,7 +97,6 @@ def transaction_simple_add(request):
|
||||
|
||||
else:
|
||||
form = TransactionForm(
|
||||
user=request.user,
|
||||
initial={
|
||||
"date": expected_date,
|
||||
"type": transaction_type,
|
||||
@@ -120,7 +117,7 @@ def transaction_edit(request, transaction_id, **kwargs):
|
||||
transaction = get_object_or_404(Transaction, id=transaction_id)
|
||||
|
||||
if request.method == "POST":
|
||||
form = TransactionForm(request.POST, user=request.user, instance=transaction)
|
||||
form = TransactionForm(request.POST, instance=transaction)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Transaction updated successfully"))
|
||||
@@ -130,7 +127,7 @@ def transaction_edit(request, transaction_id, **kwargs):
|
||||
headers={"HX-Trigger": "updated, hide_offcanvas"},
|
||||
)
|
||||
else:
|
||||
form = TransactionForm(instance=transaction, user=request.user)
|
||||
form = TransactionForm(instance=transaction)
|
||||
|
||||
return render(
|
||||
request,
|
||||
@@ -152,7 +149,7 @@ def transactions_bulk_edit(request):
|
||||
count = transactions.count()
|
||||
|
||||
if request.method == "POST":
|
||||
form = BulkEditTransactionForm(request.POST, user=request.user)
|
||||
form = BulkEditTransactionForm(request.POST)
|
||||
if form.is_valid():
|
||||
# Apply changes from the form to all selected transactions
|
||||
for transaction in transactions:
|
||||
@@ -184,9 +181,7 @@ def transactions_bulk_edit(request):
|
||||
headers={"HX-Trigger": "updated, hide_offcanvas"},
|
||||
)
|
||||
else:
|
||||
form = BulkEditTransactionForm(
|
||||
initial={"is_paid": None, "type": None}, user=request.user
|
||||
)
|
||||
form = BulkEditTransactionForm(initial={"is_paid": None, "type": None})
|
||||
|
||||
context = {
|
||||
"form": form,
|
||||
@@ -249,7 +244,7 @@ def transaction_clone(request, transaction_id, **kwargs):
|
||||
@login_required
|
||||
@require_http_methods(["DELETE"])
|
||||
def transaction_delete(request, transaction_id, **kwargs):
|
||||
transaction = get_object_or_404(Transaction, id=transaction_id)
|
||||
transaction = get_object_or_404(Transaction.all_objects, id=transaction_id)
|
||||
|
||||
transaction.delete()
|
||||
|
||||
@@ -261,6 +256,24 @@ def transaction_delete(request, transaction_id, **kwargs):
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def transaction_undelete(request, transaction_id, **kwargs):
|
||||
transaction = get_object_or_404(Transaction.deleted_objects, id=transaction_id)
|
||||
|
||||
transaction.deleted = False
|
||||
transaction.deleted_at = None
|
||||
transaction.save()
|
||||
|
||||
messages.success(request, _("Transaction restored successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={"HX-Trigger": "updated"},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
@@ -276,7 +289,7 @@ def transactions_transfer(request):
|
||||
).date()
|
||||
|
||||
if request.method == "POST":
|
||||
form = TransferForm(request.POST, user=request.user)
|
||||
form = TransferForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Transfer added successfully"))
|
||||
@@ -290,7 +303,6 @@ def transactions_transfer(request):
|
||||
"reference_date": expected_date,
|
||||
"date": expected_date,
|
||||
},
|
||||
user=request.user,
|
||||
)
|
||||
|
||||
return render(request, "transactions/fragments/transfer.html", {"form": form})
|
||||
@@ -304,6 +316,7 @@ def transaction_pay(request, transaction_id):
|
||||
new_is_paid = False if transaction.is_paid else True
|
||||
transaction.is_paid = new_is_paid
|
||||
transaction.save()
|
||||
transaction_updated.send(sender=transaction)
|
||||
|
||||
response = render(
|
||||
request,
|
||||
@@ -319,15 +332,27 @@ def transaction_pay(request, transaction_id):
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def transaction_all_index(request):
|
||||
f = TransactionsFilter(request.GET, user=request.user)
|
||||
return render(request, "transactions/pages/transactions.html", {"filter": f})
|
||||
order = request.session.get("all_transactions_order", "default")
|
||||
summary_tab = request.session.get("transaction_all_summary_tab", "currency")
|
||||
|
||||
f = TransactionsFilter(request.GET)
|
||||
return render(
|
||||
request,
|
||||
"transactions/pages/transactions.html",
|
||||
{"filter": f, "order": order, "summary_tab": summary_tab},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def transaction_all_list(request):
|
||||
order = request.GET.get("order")
|
||||
order = request.session.get("all_transactions_order", "default")
|
||||
|
||||
if "order" in request.GET:
|
||||
order = request.GET["order"]
|
||||
if order != request.session.get("all_transactions_order", "default"):
|
||||
request.session["all_transactions_order"] = order
|
||||
|
||||
transactions = Transaction.objects.prefetch_related(
|
||||
"account",
|
||||
@@ -337,11 +362,14 @@ def transaction_all_list(request):
|
||||
"account__exchange_currency",
|
||||
"account__currency",
|
||||
"installment_plan",
|
||||
"entities",
|
||||
"dca_expense_entries",
|
||||
"dca_income_entries",
|
||||
).all()
|
||||
|
||||
transactions = default_order(transactions, order=order)
|
||||
|
||||
f = TransactionsFilter(request.GET, user=request.user, queryset=transactions)
|
||||
f = TransactionsFilter(request.GET, queryset=transactions)
|
||||
|
||||
page_number = request.GET.get("page", 1)
|
||||
paginator = Paginator(f.qs, 100)
|
||||
@@ -369,9 +397,12 @@ def transaction_all_summary(request):
|
||||
"account__exchange_currency",
|
||||
"account__currency",
|
||||
"installment_plan",
|
||||
"entities",
|
||||
"dca_expense_entries",
|
||||
"dca_income_entries",
|
||||
).all()
|
||||
|
||||
f = TransactionsFilter(request.GET, user=request.user, queryset=transactions)
|
||||
f = TransactionsFilter(request.GET, queryset=transactions)
|
||||
|
||||
currency_data = calculate_currency_totals(f.qs.all(), ignore_empty=True)
|
||||
currency_percentages = calculate_percentage_distribution(currency_data)
|
||||
@@ -379,16 +410,149 @@ def transaction_all_summary(request):
|
||||
account_percentages = calculate_percentage_distribution(account_data)
|
||||
|
||||
context = {
|
||||
"income_current": remove_falsey_entries(currency_data, "income_current"),
|
||||
"income_projected": remove_falsey_entries(currency_data, "income_projected"),
|
||||
"expense_current": remove_falsey_entries(currency_data, "expense_current"),
|
||||
"expense_projected": remove_falsey_entries(currency_data, "expense_projected"),
|
||||
"total_current": remove_falsey_entries(currency_data, "total_current"),
|
||||
"total_final": remove_falsey_entries(currency_data, "total_final"),
|
||||
"total_projected": remove_falsey_entries(currency_data, "total_projected"),
|
||||
"currency_data": currency_data,
|
||||
"currency_percentages": currency_percentages,
|
||||
"account_data": account_data,
|
||||
"account_percentages": account_percentages,
|
||||
}
|
||||
|
||||
return render(request, "transactions/fragments/summary.html", context)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def transaction_all_account_summary(request):
|
||||
transactions = Transaction.objects.prefetch_related(
|
||||
"account",
|
||||
"account__group",
|
||||
"category",
|
||||
"tags",
|
||||
"account__exchange_currency",
|
||||
"account__currency",
|
||||
"installment_plan",
|
||||
"entities",
|
||||
"dca_expense_entries",
|
||||
"dca_income_entries",
|
||||
).all()
|
||||
|
||||
f = TransactionsFilter(request.GET, queryset=transactions)
|
||||
|
||||
account_data = calculate_account_totals(transactions_queryset=f.qs.all())
|
||||
account_percentages = calculate_percentage_distribution(account_data)
|
||||
|
||||
context = {
|
||||
"account_data": account_data,
|
||||
"account_percentages": account_percentages,
|
||||
}
|
||||
|
||||
return render(request, "transactions/fragments/all_account_summary.html", context)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def transaction_all_currency_summary(request):
|
||||
transactions = Transaction.objects.prefetch_related(
|
||||
"account",
|
||||
"account__group",
|
||||
"category",
|
||||
"tags",
|
||||
"account__exchange_currency",
|
||||
"account__currency",
|
||||
"installment_plan",
|
||||
"entities",
|
||||
"dca_expense_entries",
|
||||
"dca_income_entries",
|
||||
).all()
|
||||
|
||||
f = TransactionsFilter(request.GET, queryset=transactions)
|
||||
|
||||
currency_data = calculate_currency_totals(f.qs.all(), ignore_empty=True)
|
||||
currency_percentages = calculate_percentage_distribution(currency_data)
|
||||
|
||||
context = {
|
||||
"currency_data": currency_data,
|
||||
"currency_percentages": currency_percentages,
|
||||
}
|
||||
|
||||
return render(request, "transactions/fragments/all_currency_summary.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def transaction_all_summary_select(request, selected):
|
||||
request.session["transaction_all_summary_tab"] = selected
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def transactions_trash_can_index(request):
|
||||
return render(request, "transactions/pages/trash.html")
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def transactions_trash_can_list(request):
|
||||
transactions = Transaction.deleted_objects.prefetch_related(
|
||||
"account",
|
||||
"account__group",
|
||||
"category",
|
||||
"tags",
|
||||
"account__exchange_currency",
|
||||
"account__currency",
|
||||
"installment_plan",
|
||||
"entities",
|
||||
"entities",
|
||||
"dca_expense_entries",
|
||||
"dca_income_entries",
|
||||
).all()
|
||||
|
||||
return render(
|
||||
request,
|
||||
"transactions/fragments/trash_list.html",
|
||||
{"transactions": transactions},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def get_recent_transactions(request, filter_type=None):
|
||||
"""Return the 100 most recent non-deleted transactions with optional search."""
|
||||
# Get search term from query params
|
||||
search_term = request.GET.get("q", "").strip()
|
||||
|
||||
# Base queryset with selected fields
|
||||
queryset = (
|
||||
Transaction.objects.filter(deleted=False)
|
||||
.select_related("account", "category")
|
||||
.order_by("-created_at")
|
||||
)
|
||||
|
||||
if filter_type:
|
||||
if filter_type == "expenses":
|
||||
queryset = queryset.filter(type=Transaction.Type.EXPENSE)
|
||||
elif filter_type == "income":
|
||||
queryset = queryset.filter(type=Transaction.Type.INCOME)
|
||||
|
||||
# Apply search if provided
|
||||
if search_term:
|
||||
queryset = queryset.filter(
|
||||
Q(description__icontains=search_term)
|
||||
| Q(notes__icontains=search_term)
|
||||
| Q(internal_note__icontains=search_term)
|
||||
| Q(tags__name__icontains=search_term)
|
||||
| Q(category__name__icontains=search_term)
|
||||
)
|
||||
|
||||
# Prepare data for JSON response
|
||||
data = []
|
||||
for t in queryset:
|
||||
data.append({"text": str(t), "value": str(t.id)})
|
||||
|
||||
return JsonResponse(data, safe=False)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user