mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-02-25 08:54:52 +01:00
Compare commits
237 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
c9d1b5b5f3 | ||
|
|
a22a95cb9f | ||
|
|
5c46a2c15e | ||
|
|
4f091c601e | ||
|
|
0fac78d15a | ||
|
|
aa171c0e76 | ||
|
|
73ca418dc8 | ||
|
|
7c34f36ffb | ||
|
|
2b6be8c6ac | ||
|
|
f643c41cf1 | ||
|
|
1ef7a780fb | ||
|
|
c3a753d221 | ||
|
|
c474b6cda9 | ||
|
|
aff3aa7ed2 | ||
|
|
414a9bb88a | ||
|
|
5f202a3820 | ||
|
|
e71775292a | ||
|
|
01aa8acb71 | ||
|
|
d030f9686b | ||
|
|
56d7e41bc5 | ||
|
|
0857b44fc3 | ||
|
|
d4b5afd8b2 | ||
|
|
9c4ba3a6de | ||
|
|
ec8b0e21d8 | ||
|
|
6c60c3659c | ||
|
|
a040b8acd2 | ||
|
|
e72d6cd1ea | ||
|
|
3fb670ef00 | ||
|
|
b9cd97f0b8 | ||
|
|
011e0ad7c9 | ||
|
|
97465c07fe | ||
|
|
f6d1a42b35 | ||
|
|
eb25f8aeb3 | ||
|
|
36cbe2935a | ||
|
|
dbea78cd3c | ||
|
|
d50c84f8e6 | ||
|
|
f2d32fd7e9 | ||
|
|
53175aacb9 | ||
|
|
1dc03b0a84 | ||
|
|
ba2d654f15 | ||
|
|
93d04572df | ||
|
|
38379ab2b1 | ||
|
|
928ad33111 | ||
|
|
d0172b5524 | ||
|
|
e4a2b83c83 | ||
|
|
1c28dd5513 | ||
|
|
1c713fac19 | ||
|
|
096f24e0a2 | ||
|
|
f1cd658972 | ||
|
|
a85221468a | ||
|
|
e3d3a7cf91 | ||
|
|
4ef4609a96 | ||
|
|
962a8efa26 | ||
|
|
d7de6c17a9 | ||
|
|
a805880e9b | ||
|
|
aaee602b71 | ||
|
|
7635b66638 | ||
|
|
bcc96588bf | ||
|
|
cabd03e7e6 | ||
|
|
2ee64a534e | ||
|
|
14073d3555 | ||
|
|
16fbead2f9 | ||
|
|
ece44f2726 | ||
|
|
a415e285ee | ||
|
|
00b8727664 | ||
|
|
6f096fd3ff | ||
|
|
07fcbe1f45 | ||
|
|
0f14fd0c62 | ||
|
|
61d5aba67c | ||
|
|
76df16e489 | ||
|
|
34e6914d41 | ||
|
|
f2cc070505 | ||
|
|
18d8e8ed1a | ||
|
|
2ff33526ae | ||
|
|
8a127a9f4f | ||
|
|
a52f682c4f | ||
|
|
3440d4405e | ||
|
|
87345cf235 | ||
|
|
50efc51f87 | ||
|
|
493bf268bb | ||
|
|
8992cd98b5 | ||
|
|
f7c3a2f320 | ||
|
|
d96787cfeb | ||
|
|
32b5864736 | ||
|
|
02adfd828a | ||
|
|
c14b666921 | ||
|
|
5d2b9ae0b3 | ||
|
|
d5dfe5bba0 | ||
|
|
72ceec7452 | ||
|
|
eae0e00d1f | ||
|
|
cc0125241f | ||
|
|
e3bab503a0 | ||
|
|
c089c49b7d | ||
|
|
0fccdbe573 | ||
|
|
b9810ce062 | ||
|
|
4cc32e3f57 | ||
|
|
8db13b082b | ||
|
|
e73e1dfc25 | ||
|
|
ae91c51967 | ||
|
|
3ef6b0ac5c | ||
|
|
ba0c54767c | ||
|
|
2d8864773c | ||
|
|
f96d8d2862 | ||
|
|
3ccb0e19eb | ||
|
|
238f205513 | ||
|
|
a94e0b4904 | ||
|
|
86dac632c4 | ||
|
|
fbb26b8442 | ||
|
|
c171e0419a |
12
.env.example
12
.env.example
@@ -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>
|
||||
@@ -18,3 +18,11 @@ SQL_PORT=5432
|
||||
|
||||
# Gunicorn
|
||||
WEB_CONCURRENCY=4
|
||||
|
||||
# App Configs
|
||||
# Enable this if you want to keep deleted transactions in the database
|
||||
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 [Enviroment Variables](#enviroment-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
|
||||
## Enviroment 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 adress 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.
|
||||
|
||||
19
app/WYGIWYH/logs/ProcrastinateFilter.py
Normal file
19
app/WYGIWYH/logs/ProcrastinateFilter.py
Normal file
@@ -0,0 +1,19 @@
|
||||
import logging
|
||||
|
||||
|
||||
class ProcrastinateFilter(logging.Filter):
|
||||
# from https://github.com/madzak/python-json-logger/blob/master/src/pythonjsonlogger/jsonlogger.py#L19
|
||||
_reserved_log_keys = frozenset(
|
||||
"""args asctime created exc_info exc_text filename
|
||||
funcName levelname levelno lineno module msecs message msg name pathname
|
||||
process processName relativeCreated stack_info thread threadName""".split()
|
||||
)
|
||||
|
||||
def filter(self, record: logging.LogRecord):
|
||||
record.procrastinate = {}
|
||||
for key, value in vars(record).items():
|
||||
if not key.startswith("_") and key not in self._reserved_log_keys | {
|
||||
"procrastinate"
|
||||
}:
|
||||
record.procrastinate[key] = value # type: ignore
|
||||
return True
|
||||
0
app/WYGIWYH/logs/__init__.py
Normal file
0
app/WYGIWYH/logs/__init__.py
Normal file
@@ -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
|
||||
|
||||
@@ -64,6 +62,7 @@ INSTALLED_APPS = [
|
||||
"apps.accounts.apps.AccountsConfig",
|
||||
"apps.common.apps.CommonConfig",
|
||||
"apps.net_worth.apps.NetWorthConfig",
|
||||
"apps.import_app.apps.ImportConfig",
|
||||
"apps.api.apps.ApiConfig",
|
||||
"cachalot",
|
||||
"rest_framework",
|
||||
@@ -72,9 +71,11 @@ INSTALLED_APPS = [
|
||||
"apps.rules.apps.RulesConfig",
|
||||
"apps.calendar_view.apps.CalendarViewConfig",
|
||||
"apps.dca.apps.DcaConfig",
|
||||
"pwa",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"apps.common.middleware.thread_local.ThreadLocalMiddleware",
|
||||
"debug_toolbar.middleware.DebugToolbarMiddleware",
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
@@ -124,12 +125,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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,10 +162,11 @@ AUTH_USER_MODEL = "users.User"
|
||||
LANGUAGE_CODE = "en"
|
||||
LANGUAGES = (
|
||||
("en", "English"),
|
||||
("nl", "Nederlands"),
|
||||
("pt-br", "Português (Brasil)"),
|
||||
)
|
||||
|
||||
TIME_ZONE = "UTC"
|
||||
TIME_ZONE = os.getenv("TZ", "UTC")
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
@@ -218,7 +220,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",
|
||||
@@ -276,28 +278,42 @@ if "procrastinate" in sys.argv:
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"procrastinate": {
|
||||
"format": "%(asctime)s %(levelname)-7s %(name)s %(message)s"
|
||||
"format": "[%(asctime)s] - %(levelname)s - %(name)s - %(message)s -> %(procrastinate)s",
|
||||
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||
},
|
||||
"standard": {
|
||||
"format": "[%(asctime)s] - %(levelname)s - %(name)s - %(message)s",
|
||||
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||
},
|
||||
},
|
||||
"filters": {
|
||||
"procrastinate": {
|
||||
"()": "WYGIWYH.logs.ProcrastinateFilter.ProcrastinateFilter",
|
||||
"name": "procrastinate",
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"procrastinate": {
|
||||
"level": "DEBUG",
|
||||
"level": "INFO",
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "procrastinate",
|
||||
"filters": ["procrastinate"],
|
||||
},
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "standard",
|
||||
"level": "INFO",
|
||||
},
|
||||
},
|
||||
"loggers": {
|
||||
"procrastinate": {
|
||||
"handlers": ["procrastinate"],
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
"propagate": True,
|
||||
},
|
||||
"root": {
|
||||
"handlers": ["console"],
|
||||
"handlers": None,
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -306,24 +322,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": {
|
||||
@@ -334,3 +351,54 @@ else:
|
||||
}
|
||||
|
||||
CACHALOT_UNCACHABLE_TABLES = ("django_migrations", "procrastinate_jobs")
|
||||
|
||||
|
||||
# PWA
|
||||
PWA_APP_NAME = SITE_TITLE
|
||||
PWA_APP_DESCRIPTION = "A simple and powerful finance tracker"
|
||||
PWA_APP_THEME_COLOR = "#fbb700"
|
||||
PWA_APP_BACKGROUND_COLOR = "#222222"
|
||||
PWA_APP_DISPLAY = "standalone"
|
||||
PWA_APP_SCOPE = "/"
|
||||
PWA_APP_ORIENTATION = "any"
|
||||
PWA_APP_START_URL = "/"
|
||||
PWA_APP_STATUS_BAR_COLOR = "default"
|
||||
PWA_APP_ICONS = [
|
||||
{"src": "/static/img/favicon/android-icon-192x192.png", "sizes": "192x192"}
|
||||
]
|
||||
PWA_APP_ICONS_APPLE = [
|
||||
{"src": "/static/img/favicon/apple-icon-180x180.png", "sizes": "180x180"}
|
||||
]
|
||||
PWA_APP_SPLASH_SCREEN = [
|
||||
{
|
||||
"src": "/static/img/pwa/splash-640x1136.png",
|
||||
"media": "(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2)",
|
||||
}
|
||||
]
|
||||
PWA_APP_DIR = "ltr"
|
||||
PWA_APP_LANG = "en-US"
|
||||
PWA_APP_SHORTCUTS = [
|
||||
{
|
||||
"name": "New Transaction",
|
||||
"url": "/add/",
|
||||
"description": "Add new transaction",
|
||||
}
|
||||
]
|
||||
PWA_APP_SCREENSHOTS = [
|
||||
{
|
||||
"src": "/static/img/pwa/splash-750x1334.png",
|
||||
"sizes": "750x1334",
|
||||
"type": "image/png",
|
||||
"form_factor": "wide",
|
||||
},
|
||||
{
|
||||
"src": "/static/img/pwa/splash-750x1334.png",
|
||||
"sizes": "750x1334",
|
||||
"type": "image/png",
|
||||
},
|
||||
]
|
||||
PWA_SERVICE_WORKER_PATH = BASE_DIR / "templates" / "pwa" / "serviceworker.js"
|
||||
|
||||
ENABLE_SOFT_DELETE = os.getenv("ENABLE_SOFT_DELETE", "false").lower() == "true"
|
||||
KEEP_DELETED_TRANSACTIONS_FOR = int(os.getenv("KEEP_DELETED_ENTRIES_FOR", "365"))
|
||||
APP_VERSION = os.getenv("APP_VERSION", "unknown")
|
||||
|
||||
@@ -27,6 +27,7 @@ urlpatterns = [
|
||||
path("hijack/", include("hijack.urls")),
|
||||
path("__debug__/", include("debug_toolbar.urls")),
|
||||
path("__reload__/", include("django_browser_reload.urls")),
|
||||
path("", include("pwa.urls")),
|
||||
# path("api/", include("rest_framework.urls")),
|
||||
path("api/", include("apps.api.urls")),
|
||||
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
|
||||
@@ -47,4 +48,5 @@ urlpatterns = [
|
||||
path("", include("apps.calendar_view.urls")),
|
||||
path("", include("apps.dca.urls")),
|
||||
path("", include("apps.mini_tools.urls")),
|
||||
path("", include("apps.import_app.urls")),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def make_names_unique(apps, schema_editor):
|
||||
Account = apps.get_model("accounts", "Account")
|
||||
|
||||
# Get all accounts ordered by id
|
||||
accounts = Account.objects.all().order_by("id")
|
||||
|
||||
# Track seen names
|
||||
seen_names = {}
|
||||
|
||||
for account in accounts:
|
||||
original_name = account.name
|
||||
counter = seen_names.get(original_name, 0)
|
||||
|
||||
while account.name in seen_names:
|
||||
counter += 1
|
||||
account.name = f"{original_name} ({counter})"
|
||||
|
||||
seen_names[account.name] = counter
|
||||
account.save()
|
||||
|
||||
|
||||
def reverse_migration(apps, schema_editor):
|
||||
# Can't restore original names, so do nothing
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0006_rename_archived_account_is_archived_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(make_names_unique, reverse_migration),
|
||||
]
|
||||
18
app/apps/accounts/migrations/0008_alter_account_name.py
Normal file
18
app/apps/accounts/migrations/0008_alter_account_name.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.5 on 2025-01-24 00:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0007_make_account_names_unique'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='account',
|
||||
name='name',
|
||||
field=models.CharField(max_length=255, unique=True, verbose_name='Name'),
|
||||
),
|
||||
]
|
||||
@@ -18,7 +18,7 @@ class AccountGroup(models.Model):
|
||||
|
||||
|
||||
class Account(models.Model):
|
||||
name = models.CharField(max_length=255, verbose_name=_("Name"))
|
||||
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True)
|
||||
group = models.ForeignKey(
|
||||
AccountGroup,
|
||||
on_delete=models.SET_NULL,
|
||||
|
||||
@@ -2,9 +2,7 @@ from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import render, get_object_or_404
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from apps.accounts.forms import AccountGroupForm
|
||||
@@ -89,7 +87,6 @@ def account_group_edit(request, pk):
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@csrf_exempt
|
||||
@require_http_methods(["DELETE"])
|
||||
def account_group_delete(request, pk):
|
||||
account_group = get_object_or_404(AccountGroup, id=pk)
|
||||
|
||||
@@ -2,9 +2,7 @@ from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import render, get_object_or_404
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from apps.accounts.forms import AccountForm
|
||||
@@ -89,7 +87,6 @@ def account_edit(request, pk):
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@csrf_exempt
|
||||
@require_http_methods(["DELETE"])
|
||||
def account_delete(request, pk):
|
||||
account = get_object_or_404(Account, id=pk)
|
||||
|
||||
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()
|
||||
|
||||
|
||||
11
app/apps/common/templatetags/json.py
Normal file
11
app/apps/common/templatetags/json.py
Normal file
@@ -0,0 +1,11 @@
|
||||
import json
|
||||
|
||||
from django import template
|
||||
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter("json")
|
||||
def convert_to_json(value):
|
||||
return json.dumps(value)
|
||||
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,12 +76,12 @@ def django_to_airdatepicker_datetime(django_format):
|
||||
def django_to_airdatepicker_datetime_separated(django_format):
|
||||
format_map = {
|
||||
# Time formats
|
||||
"h": "h", # Hour (12-hour)
|
||||
"H": "H", # Hour (24-hour)
|
||||
"i": "m", # Minutes
|
||||
"h": "hh", # Hour (12-hour)
|
||||
"H": "HH", # Hour (24-hour)
|
||||
"i": "mm", # Minutes
|
||||
"A": "AA", # AM/PM uppercase
|
||||
"a": "aa", # am/pm lowercase
|
||||
"P": "h:mm AA", # Localized time format
|
||||
"P": "h:mm aa", # Localized time format
|
||||
# Date formats
|
||||
"D": "E", # Short weekday name
|
||||
"l": "EEEE", # Full weekday name
|
||||
|
||||
@@ -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,13 +2,14 @@ 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 (
|
||||
django_to_python_datetime,
|
||||
django_to_airdatepicker_datetime,
|
||||
django_to_airdatepicker_datetime_separated,
|
||||
)
|
||||
from apps.common.functions.format import get_format
|
||||
|
||||
|
||||
class AirDatePickerInput(widgets.DateInput):
|
||||
@@ -18,12 +19,10 @@ class AirDatePickerInput(widgets.DateInput):
|
||||
format=None,
|
||||
clear_button=True,
|
||||
auto_close=True,
|
||||
user=None,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
attrs = attrs or {}
|
||||
self.user = user
|
||||
super().__init__(attrs=attrs, format=format, *args, **kwargs)
|
||||
self.clear_button = clear_button
|
||||
self.auto_close = auto_close
|
||||
@@ -40,18 +39,12 @@ 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
|
||||
print(user_format)
|
||||
if user_format == "SHORT_DATE_FORMAT":
|
||||
return get_format("SHORT_DATE_FORMAT", use_l10n=True)
|
||||
return user_format
|
||||
|
||||
return get_format("SHORT_DATE_FORMAT", use_l10n=True)
|
||||
|
||||
def build_attrs(self, base_attrs, extra_attrs=None):
|
||||
attrs = super().build_attrs(base_attrs, extra_attrs)
|
||||
|
||||
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-language"] = self._get_current_language()
|
||||
@@ -96,12 +89,10 @@ class AirDateTimePickerInput(widgets.DateTimeInput):
|
||||
timepicker=True,
|
||||
clear_button=True,
|
||||
auto_close=True,
|
||||
user=None,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
attrs = attrs or {}
|
||||
self.user = user
|
||||
super().__init__(attrs=attrs, format=format, *args, **kwargs)
|
||||
self.timepicker = timepicker
|
||||
self.clear_button = clear_button
|
||||
@@ -119,12 +110,6 @@ class AirDateTimePickerInput(widgets.DateTimeInput):
|
||||
if self.format:
|
||||
return self.format
|
||||
|
||||
if self.user and hasattr(self.user, "settings"):
|
||||
user_format = self.user.settings.datetime_format
|
||||
if user_format == "SHORT_DATETIME_FORMAT":
|
||||
return get_format("SHORT_DATETIME_FORMAT", use_l10n=True)
|
||||
return user_format
|
||||
|
||||
return get_format("SHORT_DATETIME_FORMAT", use_l10n=True)
|
||||
|
||||
def build_attrs(self, base_attrs, extra_attrs=None):
|
||||
@@ -135,6 +120,7 @@ class AirDateTimePickerInput(widgets.DateTimeInput):
|
||||
)
|
||||
|
||||
# Add data attributes for AirDatepicker configuration
|
||||
attrs["data-now-button-txt"] = _("Now")
|
||||
attrs["data-timepicker"] = str(self.timepicker).lower()
|
||||
attrs["data-auto-close"] = str(self.auto_close).lower()
|
||||
attrs["data-clear-button"] = str(self.clear_button).lower()
|
||||
@@ -146,9 +132,14 @@ class AirDateTimePickerInput(widgets.DateTimeInput):
|
||||
|
||||
def format_value(self, value):
|
||||
"""Format the value for display in the widget."""
|
||||
if value:
|
||||
if value and isinstance(value, (datetime.date, datetime.datetime)):
|
||||
self.attrs["data-value"] = datetime.datetime.strftime(
|
||||
value, "%Y-%m-%d %H:%M:00"
|
||||
value, "%Y-%m-%dT%H:%M:00"
|
||||
)
|
||||
elif value and isinstance(value, str):
|
||||
value = datetime.datetime.strptime(value, "%Y-%m-%d %H:%M:00")
|
||||
self.attrs["data-value"] = datetime.datetime.strftime(
|
||||
value, "%Y-%m-%dT%H:%M:00"
|
||||
)
|
||||
|
||||
if value is None:
|
||||
@@ -188,6 +179,15 @@ class AirMonthYearPickerInput(AirDatePickerInput):
|
||||
"""Get month names using Django's date translation"""
|
||||
return {dates.MONTHS[i]: i for i in range(1, 13)}
|
||||
|
||||
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"] = "MMMM yyyy"
|
||||
|
||||
return attrs
|
||||
|
||||
def format_value(self, value):
|
||||
"""Format the value for display in the widget."""
|
||||
if value:
|
||||
|
||||
@@ -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,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
|
||||
206
app/apps/currencies/exchange_rates/fetcher.py
Normal file
206
app/apps/currencies/exchange_rates/fetcher.py
Normal file
@@ -0,0 +1,206 @@
|
||||
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.debug(
|
||||
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
|
||||
)
|
||||
return current_hour in allowed_hours
|
||||
|
||||
if service.interval_type == ExchangeRateService.IntervalType.EVERY:
|
||||
try:
|
||||
interval_hours = int(service.fetch_interval)
|
||||
if service.last_fetch is None:
|
||||
return True
|
||||
hours_since_last = (
|
||||
timezone.now() - service.last_fetch
|
||||
).total_seconds() / 3600
|
||||
should_fetch = hours_since_last >= interval_hours
|
||||
logger.debug(
|
||||
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 *
|
||||
|
||||
@@ -2,9 +2,7 @@ from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import render, get_object_or_404
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
@@ -89,7 +87,6 @@ def currency_edit(request, pk):
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@csrf_exempt
|
||||
@require_http_methods(["DELETE"])
|
||||
def currency_delete(request, pk):
|
||||
currency = get_object_or_404(Currency, id=pk)
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.paginator import Paginator
|
||||
from django.db.models import F, CharField, Value
|
||||
from django.db.models import CharField, Value
|
||||
from django.db.models.functions import Concat
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import render, get_object_or_404
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
@@ -28,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(
|
||||
@@ -57,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")
|
||||
@@ -84,7 +83,7 @@ def exchange_rates_list_pair(request):
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def exchange_rate_add(request):
|
||||
if request.method == "POST":
|
||||
form = ExchangeRateForm(request.POST, user=request.user)
|
||||
form = ExchangeRateForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Exchange rate added successfully"))
|
||||
@@ -96,7 +95,7 @@ def exchange_rate_add(request):
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = ExchangeRateForm(user=request.user)
|
||||
form = ExchangeRateForm()
|
||||
|
||||
return render(
|
||||
request,
|
||||
@@ -112,7 +111,7 @@ def exchange_rate_edit(request, pk):
|
||||
exchange_rate = get_object_or_404(ExchangeRate, id=pk)
|
||||
|
||||
if request.method == "POST":
|
||||
form = ExchangeRateForm(request.POST, instance=exchange_rate, user=request.user)
|
||||
form = ExchangeRateForm(request.POST, instance=exchange_rate)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Exchange rate updated successfully"))
|
||||
@@ -124,7 +123,7 @@ def exchange_rate_edit(request, pk):
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = ExchangeRateForm(instance=exchange_rate, user=request.user)
|
||||
form = ExchangeRateForm(instance=exchange_rate)
|
||||
|
||||
return render(
|
||||
request,
|
||||
@@ -135,7 +134,6 @@ def exchange_rate_edit(request, pk):
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@csrf_exempt
|
||||
@require_http_methods(["DELETE"])
|
||||
def exchange_rate_delete(request, pk):
|
||||
exchange_rate = get_object_or_404(ExchangeRate, id=pk)
|
||||
|
||||
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",
|
||||
},
|
||||
)
|
||||
@@ -65,7 +65,7 @@ class DCAEntryForm(forms.ModelForm):
|
||||
"notes": forms.Textarea(attrs={"rows": 3}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, user=None, **kwargs):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
@@ -106,4 +106,4 @@ class DCAEntryForm(forms.ModelForm):
|
||||
|
||||
self.fields["amount_paid"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
self.fields["amount_received"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
self.fields["date"].widget = AirDatePickerInput(clear_button=False, user=user)
|
||||
self.fields["date"].widget = AirDatePickerInput(clear_button=False)
|
||||
|
||||
@@ -6,12 +6,11 @@ from django.db.models.functions import TruncMonth
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import render, get_object_or_404
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
from apps.dca.models import DCAStrategy, DCAEntry
|
||||
from apps.dca.forms import DCAEntryForm, DCAStrategyForm
|
||||
from apps.dca.models import DCAStrategy, DCAEntry
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -82,7 +81,6 @@ def strategy_edit(request, strategy_id):
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@csrf_exempt
|
||||
@require_http_methods(["DELETE"])
|
||||
def strategy_delete(request, strategy_id):
|
||||
dca_strategy = get_object_or_404(DCAStrategy, id=strategy_id)
|
||||
@@ -157,7 +155,7 @@ def strategy_detail(request, strategy_id):
|
||||
def strategy_entry_add(request, strategy_id):
|
||||
strategy = get_object_or_404(DCAStrategy, id=strategy_id)
|
||||
if request.method == "POST":
|
||||
form = DCAEntryForm(request.POST, user=request.user)
|
||||
form = DCAEntryForm(request.POST)
|
||||
if form.is_valid():
|
||||
entry = form.save(commit=False)
|
||||
entry.strategy = strategy
|
||||
@@ -171,7 +169,7 @@ def strategy_entry_add(request, strategy_id):
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = DCAEntryForm(user=request.user)
|
||||
form = DCAEntryForm()
|
||||
|
||||
return render(
|
||||
request,
|
||||
@@ -186,7 +184,7 @@ def strategy_entry_edit(request, strategy_id, entry_id):
|
||||
dca_entry = get_object_or_404(DCAEntry, id=entry_id, strategy__id=strategy_id)
|
||||
|
||||
if request.method == "POST":
|
||||
form = DCAEntryForm(request.POST, instance=dca_entry, user=request.user)
|
||||
form = DCAEntryForm(request.POST, instance=dca_entry)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Entry updated successfully"))
|
||||
@@ -198,7 +196,7 @@ def strategy_entry_edit(request, strategy_id, entry_id):
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = DCAEntryForm(instance=dca_entry, user=request.user)
|
||||
form = DCAEntryForm(instance=dca_entry)
|
||||
|
||||
return render(
|
||||
request,
|
||||
@@ -209,7 +207,6 @@ def strategy_entry_edit(request, strategy_id, entry_id):
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@csrf_exempt
|
||||
@require_http_methods(["DELETE"])
|
||||
def strategy_entry_delete(request, entry_id, strategy_id):
|
||||
dca_entry = get_object_or_404(DCAEntry, id=entry_id, strategy__id=strategy_id)
|
||||
|
||||
0
app/apps/import_app/__init__.py
Normal file
0
app/apps/import_app/__init__.py
Normal file
6
app/apps/import_app/admin.py
Normal file
6
app/apps/import_app/admin.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.contrib import admin
|
||||
from apps.import_app import models
|
||||
|
||||
# Register your models here.
|
||||
admin.site.register(models.ImportRun)
|
||||
admin.site.register(models.ImportProfile)
|
||||
6
app/apps/import_app/apps.py
Normal file
6
app/apps/import_app/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ImportConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.import_app"
|
||||
64
app/apps/import_app/forms.py
Normal file
64
app/apps/import_app/forms.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from crispy_forms.bootstrap import FormActions
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import (
|
||||
Layout,
|
||||
)
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.import_app.models import ImportProfile
|
||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
|
||||
|
||||
class ImportProfileForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = ImportProfile
|
||||
fields = [
|
||||
"name",
|
||||
"version",
|
||||
"yaml_config",
|
||||
]
|
||||
|
||||
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", "version", "yaml_config")
|
||||
|
||||
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 ImportRunFileUploadForm(forms.Form):
|
||||
file = forms.FileField(label=_("Select a file"))
|
||||
|
||||
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(
|
||||
"file",
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Import"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
),
|
||||
)
|
||||
51
app/apps/import_app/migrations/0001_initial.py
Normal file
51
app/apps/import_app/migrations/0001_initial.py
Normal file
@@ -0,0 +1,51 @@
|
||||
# Generated by Django 5.1.5 on 2025-01-19 00:44
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('currencies', '0006_currency_exchange_currency'),
|
||||
('transactions', '0028_transaction_internal_note'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ImportProfile',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('yaml_config', models.TextField(help_text='YAML configuration')),
|
||||
('version', models.IntegerField(choices=[(1, 'Version 1')], default=1, verbose_name='Version')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ImportRun',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('status', models.CharField(choices=[('QUEUED', 'Queued'), ('PROCESSING', 'Processing'), ('FAILED', 'Failed'), ('FINISHED', 'Finished')], default='QUEUED', max_length=10, verbose_name='Status')),
|
||||
('file_name', models.CharField(help_text='File name', max_length=10000)),
|
||||
('logs', models.TextField(blank=True)),
|
||||
('processed_rows', models.IntegerField(default=0)),
|
||||
('total_rows', models.IntegerField(default=0)),
|
||||
('successful_rows', models.IntegerField(default=0)),
|
||||
('skipped_rows', models.IntegerField(default=0)),
|
||||
('failed_rows', models.IntegerField(default=0)),
|
||||
('started_at', models.DateTimeField(null=True)),
|
||||
('finished_at', models.DateTimeField(null=True)),
|
||||
('categories', models.ManyToManyField(related_name='import_runs', to='transactions.transactioncategory')),
|
||||
('currencies', models.ManyToManyField(related_name='import_runs', to='currencies.currency')),
|
||||
('entities', models.ManyToManyField(related_name='import_runs', to='transactions.transactionentity')),
|
||||
('profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='import_app.importprofile')),
|
||||
('tags', models.ManyToManyField(related_name='import_runs', to='transactions.transactiontag')),
|
||||
('transactions', models.ManyToManyField(related_name='import_runs', to='transactions.transaction')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.1.5 on 2025-01-23 03:03
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('import_app', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='importprofile',
|
||||
name='name',
|
||||
field=models.CharField(max_length=100, unique=True, verbose_name='Name'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='importprofile',
|
||||
name='yaml_config',
|
||||
field=models.TextField(verbose_name='YAML Configuration'),
|
||||
),
|
||||
]
|
||||
0
app/apps/import_app/migrations/__init__.py
Normal file
0
app/apps/import_app/migrations/__init__.py
Normal file
87
app/apps/import_app/models.py
Normal file
87
app/apps/import_app/models.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import yaml
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.import_app.schemas import version_1
|
||||
|
||||
|
||||
class ImportProfile(models.Model):
|
||||
class Versions(models.IntegerChoices):
|
||||
VERSION_1 = 1, "Version 1"
|
||||
|
||||
name = models.CharField(max_length=100, verbose_name=_("Name"), unique=True)
|
||||
yaml_config = models.TextField(verbose_name=_("YAML Configuration"))
|
||||
version = models.IntegerField(
|
||||
choices=Versions,
|
||||
default=Versions.VERSION_1,
|
||||
verbose_name=_("Version"),
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
ordering = ["name"]
|
||||
|
||||
def get_version_display(self):
|
||||
version_number = self.Versions(self.version).name.split("_")[1]
|
||||
return _("Version {number}").format(number=version_number)
|
||||
|
||||
def clean(self):
|
||||
if self.version and self.version == self.Versions.VERSION_1:
|
||||
try:
|
||||
yaml_data = yaml.safe_load(self.yaml_config)
|
||||
version_1.ImportProfileSchema(**yaml_data)
|
||||
except Exception as e:
|
||||
raise ValidationError(
|
||||
{"yaml_config": _("Invalid YAML Configuration: ") + str(e)}
|
||||
)
|
||||
|
||||
|
||||
class ImportRun(models.Model):
|
||||
class Status(models.TextChoices):
|
||||
QUEUED = "QUEUED", _("Queued")
|
||||
PROCESSING = "PROCESSING", _("Processing")
|
||||
FAILED = "FAILED", _("Failed")
|
||||
FINISHED = "FINISHED", _("Finished")
|
||||
|
||||
status = models.CharField(
|
||||
max_length=10,
|
||||
choices=Status,
|
||||
default=Status.QUEUED,
|
||||
verbose_name=_("Status"),
|
||||
)
|
||||
profile = models.ForeignKey(
|
||||
ImportProfile,
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
file_name = models.CharField(
|
||||
max_length=10000,
|
||||
help_text=_("File name"),
|
||||
)
|
||||
transactions = models.ManyToManyField(
|
||||
"transactions.Transaction", related_name="import_runs"
|
||||
)
|
||||
tags = models.ManyToManyField(
|
||||
"transactions.TransactionTag", related_name="import_runs"
|
||||
)
|
||||
categories = models.ManyToManyField(
|
||||
"transactions.TransactionCategory", related_name="import_runs"
|
||||
)
|
||||
entities = models.ManyToManyField(
|
||||
"transactions.TransactionEntity", related_name="import_runs"
|
||||
)
|
||||
currencies = models.ManyToManyField(
|
||||
"currencies.Currency", related_name="import_runs"
|
||||
)
|
||||
|
||||
logs = models.TextField(blank=True)
|
||||
processed_rows = models.IntegerField(default=0)
|
||||
total_rows = models.IntegerField(default=0)
|
||||
successful_rows = models.IntegerField(default=0)
|
||||
skipped_rows = models.IntegerField(default=0)
|
||||
failed_rows = models.IntegerField(default=0)
|
||||
started_at = models.DateTimeField(null=True)
|
||||
finished_at = models.DateTimeField(null=True)
|
||||
1
app/apps/import_app/schemas/__init__.py
Normal file
1
app/apps/import_app/schemas/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
import apps.import_app.schemas.v1 as version_1
|
||||
400
app/apps/import_app/schemas/v1.py
Normal file
400
app/apps/import_app/schemas/v1.py
Normal file
@@ -0,0 +1,400 @@
|
||||
from typing import Dict, List, Optional, Literal
|
||||
from pydantic import BaseModel, Field, model_validator, field_validator
|
||||
|
||||
|
||||
class CompareDeduplicationRule(BaseModel):
|
||||
type: Literal["compare"]
|
||||
fields: list[str] = Field(..., description="Compare fields for deduplication")
|
||||
match_type: Literal["lax", "strict"] = "lax"
|
||||
|
||||
|
||||
class ReplaceTransformationRule(BaseModel):
|
||||
type: Literal["replace", "regex"] = Field(
|
||||
..., description="Type of transformation: replace or regex"
|
||||
)
|
||||
pattern: str = Field(..., description="Pattern to match")
|
||||
replacement: str = Field(..., description="Value to replace with")
|
||||
exclusive: bool = Field(
|
||||
default=False,
|
||||
description="If it should match against the last transformation or the original value",
|
||||
)
|
||||
|
||||
|
||||
class DateFormatTransformationRule(BaseModel):
|
||||
type: Literal["date_format"] = Field(
|
||||
..., description="Type of transformation: date_format"
|
||||
)
|
||||
original_format: str = Field(..., description="Original date format")
|
||||
new_format: str = Field(..., description="New date format to use")
|
||||
|
||||
|
||||
class HashTransformationRule(BaseModel):
|
||||
fields: List[str]
|
||||
type: Literal["hash"]
|
||||
|
||||
|
||||
class MergeTransformationRule(BaseModel):
|
||||
fields: List[str]
|
||||
type: Literal["merge"]
|
||||
separator: str = Field(default=" ", description="Separator to use when merging")
|
||||
|
||||
|
||||
class SplitTransformationRule(BaseModel):
|
||||
type: Literal["split"]
|
||||
separator: str = Field(default=",", description="Separator to use when splitting")
|
||||
index: int | None = Field(
|
||||
default=0, description="Index to return as value. Empty to return all."
|
||||
)
|
||||
|
||||
|
||||
class CSVImportSettings(BaseModel):
|
||||
skip_errors: bool = Field(
|
||||
default=False,
|
||||
description="If True, errors during import will be logged and skipped",
|
||||
)
|
||||
file_type: Literal["csv"] = "csv"
|
||||
delimiter: str = Field(default=",", description="CSV delimiter character")
|
||||
encoding: str = Field(default="utf-8", description="File encoding")
|
||||
skip_lines: int = Field(
|
||||
default=0, description="Number of rows to skip at the beginning of the file"
|
||||
)
|
||||
trigger_transaction_rules: bool = True
|
||||
importing: Literal[
|
||||
"transactions", "accounts", "currencies", "categories", "tags", "entities"
|
||||
]
|
||||
|
||||
|
||||
class ColumnMapping(BaseModel):
|
||||
source: Optional[str] | Optional[list[str]] = Field(
|
||||
default=None,
|
||||
description="CSV column header. If None, the field will be generated from transformations",
|
||||
)
|
||||
default: Optional[str] = None
|
||||
required: bool = False
|
||||
transformations: Optional[
|
||||
List[
|
||||
ReplaceTransformationRule
|
||||
| DateFormatTransformationRule
|
||||
| HashTransformationRule
|
||||
| MergeTransformationRule
|
||||
| SplitTransformationRule
|
||||
]
|
||||
] = Field(default_factory=list)
|
||||
|
||||
|
||||
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):
|
||||
target: Literal["type"] = Field(..., description="Transaction field to map to")
|
||||
detection_method: Literal["sign", "always_income", "always_expense"] = "sign"
|
||||
coerce_to: Literal["transaction_type"] = Field("transaction_type", frozen=True)
|
||||
|
||||
|
||||
class TransactionIsPaidMapping(ColumnMapping):
|
||||
target: Literal["is_paid"] = Field(..., description="Transaction field to map to")
|
||||
detection_method: Literal["boolean", "always_paid", "always_unpaid"]
|
||||
coerce_to: Literal["is_paid"] = Field("is_paid", frozen=True)
|
||||
|
||||
|
||||
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):
|
||||
target: Literal["reference_date"] = Field(
|
||||
..., description="Transaction field to map to"
|
||||
)
|
||||
format: List[str] | str
|
||||
coerce_to: Literal["date"] = Field("date", frozen=True)
|
||||
|
||||
|
||||
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):
|
||||
target: Literal["description"] = Field(
|
||||
..., description="Transaction field to map to"
|
||||
)
|
||||
coerce_to: Literal["str"] = Field("str", frozen=True)
|
||||
|
||||
|
||||
class TransactionNotesMapping(ColumnMapping):
|
||||
target: Literal["notes"] = Field(..., description="Transaction field to map to")
|
||||
coerce_to: Literal["str"] = Field("str", frozen=True)
|
||||
|
||||
|
||||
class TransactionTagsMapping(ColumnMapping):
|
||||
target: Literal["tags"] = Field(..., description="Transaction field to map to")
|
||||
type: Literal["id", "name"] = "name"
|
||||
create: bool = Field(
|
||||
default=True, description="Create new tags if they doesn't exist"
|
||||
)
|
||||
coerce_to: Literal["list"] = Field("list", frozen=True)
|
||||
|
||||
|
||||
class TransactionEntitiesMapping(ColumnMapping):
|
||||
target: Literal["entities"] = Field(..., description="Transaction field to map to")
|
||||
type: Literal["id", "name"] = "name"
|
||||
create: bool = Field(
|
||||
default=True, description="Create new entities if they doesn't exist"
|
||||
)
|
||||
coerce_to: Literal["list"] = Field("list", frozen=True)
|
||||
|
||||
|
||||
class TransactionCategoryMapping(ColumnMapping):
|
||||
target: Literal["category"] = Field(..., description="Transaction field to map to")
|
||||
create: bool = Field(
|
||||
default=True, description="Create category if it doesn't exist"
|
||||
)
|
||||
type: Literal["id", "name"] = "name"
|
||||
coerce_to: Literal["str|int"] = Field("str|int", frozen=True)
|
||||
|
||||
|
||||
class TransactionInternalNoteMapping(ColumnMapping):
|
||||
target: Literal["internal_note"] = Field(
|
||||
..., description="Transaction field to map to"
|
||||
)
|
||||
coerce_to: Literal["str"] = Field("str", frozen=True)
|
||||
|
||||
|
||||
class TransactionInternalIDMapping(ColumnMapping):
|
||||
target: Literal["internal_id"] = Field(
|
||||
..., description="Transaction field to map to"
|
||||
)
|
||||
coerce_to: Literal["str"] = Field("str", frozen=True)
|
||||
|
||||
|
||||
class CategoryNameMapping(ColumnMapping):
|
||||
target: Literal["category_name"] = Field(
|
||||
..., description="Category field to map to"
|
||||
)
|
||||
coerce_to: Literal["str"] = Field("str", frozen=True)
|
||||
|
||||
|
||||
class CategoryMuteMapping(ColumnMapping):
|
||||
target: Literal["category_mute"] = Field(
|
||||
..., description="Category field to map to"
|
||||
)
|
||||
coerce_to: Literal["bool"] = Field("bool", frozen=True)
|
||||
|
||||
|
||||
class CategoryActiveMapping(ColumnMapping):
|
||||
target: Literal["category_active"] = Field(
|
||||
..., description="Category field to map to"
|
||||
)
|
||||
coerce_to: Literal["bool"] = Field("bool", frozen=True)
|
||||
|
||||
|
||||
class TagNameMapping(ColumnMapping):
|
||||
target: Literal["tag_name"] = Field(..., description="Tag field to map to")
|
||||
coerce_to: Literal["str"] = Field("str", frozen=True)
|
||||
|
||||
|
||||
class TagActiveMapping(ColumnMapping):
|
||||
target: Literal["tag_active"] = Field(..., description="Tag field to map to")
|
||||
coerce_to: Literal["bool"] = Field("bool", frozen=True)
|
||||
|
||||
|
||||
class EntityNameMapping(ColumnMapping):
|
||||
target: Literal["entity_name"] = Field(..., description="Entity field to map to")
|
||||
coerce_to: Literal["str"] = Field("str", frozen=True)
|
||||
|
||||
|
||||
class EntityActiveMapping(ColumnMapping):
|
||||
target: Literal["entity_active"] = Field(..., description="Entity field to map to")
|
||||
coerce_to: Literal["bool"] = Field("bool", frozen=True)
|
||||
|
||||
|
||||
class AccountNameMapping(ColumnMapping):
|
||||
target: Literal["account_name"] = Field(..., description="Account field to map to")
|
||||
coerce_to: Literal["str"] = Field("str", frozen=True)
|
||||
|
||||
|
||||
class AccountGroupMapping(ColumnMapping):
|
||||
target: Literal["account_group"] = Field(..., description="Account field to map to")
|
||||
type: Literal["id", "name"]
|
||||
coerce_to: Literal["str|int"] = Field("str|int", frozen=True)
|
||||
|
||||
|
||||
class AccountCurrencyMapping(ColumnMapping):
|
||||
target: Literal["account_currency"] = Field(
|
||||
..., description="Account field to map to"
|
||||
)
|
||||
type: Literal["id", "name", "code"]
|
||||
coerce_to: Literal["str|int"] = Field("str|int", frozen=True)
|
||||
|
||||
|
||||
class AccountExchangeCurrencyMapping(ColumnMapping):
|
||||
target: Literal["account_exchange_currency"] = Field(
|
||||
..., description="Account field to map to"
|
||||
)
|
||||
type: Literal["id", "name", "code"]
|
||||
coerce_to: Literal["str|int"] = Field("str|int", frozen=True)
|
||||
|
||||
|
||||
class AccountIsAssetMapping(ColumnMapping):
|
||||
target: Literal["account_is_asset"] = Field(
|
||||
..., description="Account field to map to"
|
||||
)
|
||||
coerce_to: Literal["bool"] = Field("bool", frozen=True)
|
||||
|
||||
|
||||
class AccountIsArchivedMapping(ColumnMapping):
|
||||
target: Literal["account_is_archived"] = Field(
|
||||
..., description="Account field to map to"
|
||||
)
|
||||
coerce_to: Literal["bool"] = Field("bool", frozen=True)
|
||||
|
||||
|
||||
class CurrencyCodeMapping(ColumnMapping):
|
||||
target: Literal["currency_code"] = Field(
|
||||
..., description="Currency field to map to"
|
||||
)
|
||||
coerce_to: Literal["str"] = Field("str", frozen=True)
|
||||
|
||||
|
||||
class CurrencyNameMapping(ColumnMapping):
|
||||
target: Literal["currency_name"] = Field(
|
||||
..., description="Currency field to map to"
|
||||
)
|
||||
coerce_to: Literal["str"] = Field("str", frozen=True)
|
||||
|
||||
|
||||
class CurrencyDecimalPlacesMapping(ColumnMapping):
|
||||
target: Literal["currency_decimal_places"] = Field(
|
||||
..., description="Currency field to map to"
|
||||
)
|
||||
coerce_to: Literal["int"] = Field("int", frozen=True)
|
||||
|
||||
|
||||
class CurrencyPrefixMapping(ColumnMapping):
|
||||
target: Literal["currency_prefix"] = Field(
|
||||
..., description="Currency field to map to"
|
||||
)
|
||||
coerce_to: Literal["str"] = Field("str", frozen=True)
|
||||
|
||||
|
||||
class CurrencySuffixMapping(ColumnMapping):
|
||||
target: Literal["currency_suffix"] = Field(
|
||||
..., description="Currency field to map to"
|
||||
)
|
||||
coerce_to: Literal["str"] = Field("str", frozen=True)
|
||||
|
||||
|
||||
class CurrencyExchangeMapping(ColumnMapping):
|
||||
target: Literal["currency_exchange"] = Field(
|
||||
..., description="Currency field to map to"
|
||||
)
|
||||
type: Literal["id", "name", "code"]
|
||||
coerce_to: Literal["str|int"] = Field("str|int", frozen=True)
|
||||
|
||||
|
||||
class ImportProfileSchema(BaseModel):
|
||||
settings: CSVImportSettings
|
||||
mapping: Dict[
|
||||
str,
|
||||
TransactionAccountMapping
|
||||
| TransactionTypeMapping
|
||||
| TransactionIsPaidMapping
|
||||
| TransactionDateMapping
|
||||
| TransactionReferenceDateMapping
|
||||
| TransactionAmountMapping
|
||||
| TransactionDescriptionMapping
|
||||
| TransactionNotesMapping
|
||||
| TransactionTagsMapping
|
||||
| TransactionEntitiesMapping
|
||||
| TransactionCategoryMapping
|
||||
| TransactionInternalNoteMapping
|
||||
| TransactionInternalIDMapping
|
||||
| CategoryNameMapping
|
||||
| CategoryMuteMapping
|
||||
| CategoryActiveMapping
|
||||
| TagNameMapping
|
||||
| TagActiveMapping
|
||||
| EntityNameMapping
|
||||
| EntityActiveMapping
|
||||
| AccountNameMapping
|
||||
| AccountGroupMapping
|
||||
| AccountCurrencyMapping
|
||||
| AccountExchangeCurrencyMapping
|
||||
| AccountIsAssetMapping
|
||||
| AccountIsArchivedMapping
|
||||
| CurrencyCodeMapping
|
||||
| CurrencyNameMapping
|
||||
| CurrencyDecimalPlacesMapping
|
||||
| CurrencyPrefixMapping
|
||||
| CurrencySuffixMapping
|
||||
| CurrencyExchangeMapping,
|
||||
]
|
||||
deduplication: List[CompareDeduplicationRule] = Field(
|
||||
default_factory=list,
|
||||
description="Rules for deduplicating records during import",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_mappings(self) -> "ImportProfileSchema":
|
||||
import_type = self.settings.importing
|
||||
|
||||
# Define allowed mapping types for each import type
|
||||
allowed_mappings = {
|
||||
"transactions": (
|
||||
TransactionAccountMapping,
|
||||
TransactionTypeMapping,
|
||||
TransactionIsPaidMapping,
|
||||
TransactionDateMapping,
|
||||
TransactionReferenceDateMapping,
|
||||
TransactionAmountMapping,
|
||||
TransactionDescriptionMapping,
|
||||
TransactionNotesMapping,
|
||||
TransactionTagsMapping,
|
||||
TransactionEntitiesMapping,
|
||||
TransactionCategoryMapping,
|
||||
TransactionInternalNoteMapping,
|
||||
TransactionInternalIDMapping,
|
||||
),
|
||||
"accounts": (
|
||||
AccountNameMapping,
|
||||
AccountGroupMapping,
|
||||
AccountCurrencyMapping,
|
||||
AccountExchangeCurrencyMapping,
|
||||
AccountIsAssetMapping,
|
||||
AccountIsArchivedMapping,
|
||||
),
|
||||
"currencies": (
|
||||
CurrencyCodeMapping,
|
||||
CurrencyNameMapping,
|
||||
CurrencyDecimalPlacesMapping,
|
||||
CurrencyPrefixMapping,
|
||||
CurrencySuffixMapping,
|
||||
CurrencyExchangeMapping,
|
||||
),
|
||||
"categories": (
|
||||
CategoryNameMapping,
|
||||
CategoryMuteMapping,
|
||||
CategoryActiveMapping,
|
||||
),
|
||||
"tags": (TagNameMapping, TagActiveMapping),
|
||||
"entities": (EntityNameMapping, EntityActiveMapping),
|
||||
}
|
||||
|
||||
allowed_types = allowed_mappings[import_type]
|
||||
|
||||
for field_name, mapping in self.mapping.items():
|
||||
if not isinstance(mapping, allowed_types):
|
||||
raise ValueError(
|
||||
f"Mapping type '{type(mapping).__name__}' is not allowed when importing {import_type}. "
|
||||
f"Allowed types are: {', '.join(t.__name__ for t in allowed_types)}"
|
||||
)
|
||||
|
||||
return self
|
||||
3
app/apps/import_app/services/__init__.py
Normal file
3
app/apps/import_app/services/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from apps.import_app.services.v1 import ImportService as ImportServiceV1
|
||||
|
||||
from apps.import_app.services.presets import PresetService
|
||||
45
app/apps/import_app/services/presets.py
Normal file
45
app/apps/import_app/services/presets.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from apps.import_app.models import ImportProfile
|
||||
|
||||
|
||||
class PresetService:
|
||||
PRESET_PATH = "/usr/src/app/import_presets"
|
||||
|
||||
@classmethod
|
||||
def get_all_presets(cls):
|
||||
presets = []
|
||||
|
||||
for folder in Path(cls.PRESET_PATH).iterdir():
|
||||
if folder.is_dir():
|
||||
manifest_path = folder / "manifest.json"
|
||||
config_path = folder / "config.yml"
|
||||
|
||||
if manifest_path.exists() and config_path.exists():
|
||||
with open(manifest_path) as f:
|
||||
manifest = json.load(f)
|
||||
|
||||
with open(config_path) as f:
|
||||
config = json.dumps(f.read())
|
||||
|
||||
try:
|
||||
preset = {
|
||||
"name": manifest.get("name", folder.name),
|
||||
"description": manifest.get("description", ""),
|
||||
"message": json.dumps(manifest.get("message", "")),
|
||||
"authors": manifest.get("author", "").split(","),
|
||||
"schema_version": (int(manifest.get("schema_version", 1))),
|
||||
"folder_name": folder.name,
|
||||
"config": config,
|
||||
}
|
||||
|
||||
ImportProfile.Versions(
|
||||
preset["schema_version"]
|
||||
) # Check if schema version is valid
|
||||
except Exception as e:
|
||||
pass
|
||||
else:
|
||||
presets.append(preset)
|
||||
|
||||
return presets
|
||||
642
app/apps/import_app/services/v1.py
Normal file
642
app/apps/import_app/services/v1.py
Normal file
@@ -0,0 +1,642 @@
|
||||
import csv
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Dict, Any, Literal, Union
|
||||
|
||||
import cachalot.api
|
||||
import yaml
|
||||
from cachalot.api import cachalot_disabled
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.accounts.models import Account, AccountGroup
|
||||
from apps.currencies.models import Currency
|
||||
from apps.import_app.models import ImportRun, ImportProfile
|
||||
from apps.import_app.schemas import version_1
|
||||
from apps.transactions.models import (
|
||||
Transaction,
|
||||
TransactionCategory,
|
||||
TransactionTag,
|
||||
TransactionEntity,
|
||||
)
|
||||
from apps.rules.signals import transaction_created
|
||||
from apps.import_app.schemas.v1 import (
|
||||
TransactionCategoryMapping,
|
||||
TransactionAccountMapping,
|
||||
TransactionTagsMapping,
|
||||
TransactionEntitiesMapping,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ImportService:
|
||||
TEMP_DIR = "/usr/src/app/temp"
|
||||
|
||||
def __init__(self, import_run: ImportRun):
|
||||
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.deduplication: list[version_1.CompareDeduplicationRule] = (
|
||||
self.config.deduplication
|
||||
)
|
||||
self.mapping: Dict[str, version_1.ColumnMapping] = self.config.mapping
|
||||
|
||||
# Ensure temp directory exists
|
||||
os.makedirs(self.TEMP_DIR, exist_ok=True)
|
||||
|
||||
def _load_config(self) -> version_1.ImportProfileSchema:
|
||||
yaml_data = yaml.safe_load(self.profile.yaml_config)
|
||||
try:
|
||||
config = version_1.ImportProfileSchema(**yaml_data)
|
||||
except Exception as e:
|
||||
self._log("error", f"Fatal error processing YAML config: {str(e)}")
|
||||
self._update_status("FAILED")
|
||||
raise e
|
||||
else:
|
||||
return config
|
||||
|
||||
def _log(self, level: str, message: str, **kwargs) -> None:
|
||||
"""Add a log entry to the import run logs"""
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# Format additional context if present
|
||||
context = ""
|
||||
if kwargs:
|
||||
context = " - " + ", ".join(f"{k}={v}" for k, v in kwargs.items())
|
||||
|
||||
log_line = f"[{timestamp}] {level.upper()}: {message}{context}\n"
|
||||
|
||||
# Append to existing logs
|
||||
self.import_run.logs += log_line
|
||||
self.import_run.save(update_fields=["logs"])
|
||||
|
||||
def _update_totals(
|
||||
self,
|
||||
field: Literal["total", "processed", "successful", "skipped", "failed"],
|
||||
value: int,
|
||||
) -> None:
|
||||
if field == "total":
|
||||
self.import_run.total_rows = value
|
||||
self.import_run.save(update_fields=["total_rows"])
|
||||
elif field == "processed":
|
||||
self.import_run.processed_rows = value
|
||||
self.import_run.save(update_fields=["processed_rows"])
|
||||
elif field == "successful":
|
||||
self.import_run.successful_rows = value
|
||||
self.import_run.save(update_fields=["successful_rows"])
|
||||
elif field == "skipped":
|
||||
self.import_run.skipped_rows = value
|
||||
self.import_run.save(update_fields=["skipped_rows"])
|
||||
elif field == "failed":
|
||||
self.import_run.failed_rows = value
|
||||
self.import_run.save(update_fields=["failed_rows"])
|
||||
|
||||
def _increment_totals(
|
||||
self,
|
||||
field: Literal["total", "processed", "successful", "skipped", "failed"],
|
||||
value: int,
|
||||
) -> None:
|
||||
if field == "total":
|
||||
self.import_run.total_rows = self.import_run.total_rows + value
|
||||
self.import_run.save(update_fields=["total_rows"])
|
||||
elif field == "processed":
|
||||
self.import_run.processed_rows = self.import_run.processed_rows + value
|
||||
self.import_run.save(update_fields=["processed_rows"])
|
||||
elif field == "successful":
|
||||
self.import_run.successful_rows = self.import_run.successful_rows + value
|
||||
self.import_run.save(update_fields=["successful_rows"])
|
||||
elif field == "skipped":
|
||||
self.import_run.skipped_rows = self.import_run.skipped_rows + value
|
||||
self.import_run.save(update_fields=["skipped_rows"])
|
||||
elif field == "failed":
|
||||
self.import_run.failed_rows = self.import_run.failed_rows + value
|
||||
self.import_run.save(update_fields=["failed_rows"])
|
||||
|
||||
def _update_status(
|
||||
self, new_status: Literal["PROCESSING", "FAILED", "FINISHED"]
|
||||
) -> None:
|
||||
if new_status == "PROCESSING":
|
||||
self.import_run.status = ImportRun.Status.PROCESSING
|
||||
elif new_status == "FAILED":
|
||||
self.import_run.status = ImportRun.Status.FAILED
|
||||
elif new_status == "FINISHED":
|
||||
self.import_run.status = ImportRun.Status.FINISHED
|
||||
|
||||
self.import_run.save(update_fields=["status"])
|
||||
|
||||
@staticmethod
|
||||
def _transform_value(
|
||||
value: str, mapping: version_1.ColumnMapping, row: Dict[str, str] = None
|
||||
) -> Any:
|
||||
transformed = value
|
||||
|
||||
for transform in mapping.transformations:
|
||||
if transform.type == "hash":
|
||||
# Collect all values to be hashed
|
||||
values_to_hash = []
|
||||
for field in transform.fields:
|
||||
if field in row:
|
||||
values_to_hash.append(str(row[field]))
|
||||
|
||||
# Create hash from concatenated values
|
||||
if values_to_hash:
|
||||
concatenated = "|".join(values_to_hash)
|
||||
transformed = hashlib.sha256(concatenated.encode()).hexdigest()
|
||||
|
||||
elif transform.type == "replace":
|
||||
if transform.exclusive:
|
||||
transformed = value.replace(
|
||||
transform.pattern, transform.replacement
|
||||
)
|
||||
else:
|
||||
transformed = transformed.replace(
|
||||
transform.pattern, transform.replacement
|
||||
)
|
||||
elif transform.type == "regex":
|
||||
if transform.exclusive:
|
||||
transformed = re.sub(
|
||||
transform.pattern, transform.replacement, value
|
||||
)
|
||||
else:
|
||||
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]))
|
||||
transformed = transform.separator.join(values_to_merge)
|
||||
elif transform.type == "split":
|
||||
parts = transformed.split(transform.separator)
|
||||
if transform.index is not None:
|
||||
transformed = parts[transform.index] if parts else ""
|
||||
else:
|
||||
transformed = parts
|
||||
|
||||
return transformed
|
||||
|
||||
def _create_transaction(self, data: Dict[str, Any]) -> Transaction:
|
||||
tags = []
|
||||
entities = []
|
||||
# Handle related objects first
|
||||
if "category" in data:
|
||||
if "category" in data:
|
||||
category_name = data.pop("category")
|
||||
category_mapping = next(
|
||||
(
|
||||
m
|
||||
for m in self.mapping.values()
|
||||
if isinstance(m, TransactionCategoryMapping)
|
||||
and m.target == "category"
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
try:
|
||||
if category_mapping:
|
||||
if category_mapping.type == "id":
|
||||
category = TransactionCategory.objects.get(id=category_name)
|
||||
else: # name
|
||||
if getattr(category_mapping, "create", False):
|
||||
category, _ = TransactionCategory.objects.get_or_create(
|
||||
name=category_name
|
||||
)
|
||||
else:
|
||||
category = TransactionCategory.objects.filter(
|
||||
name=category_name
|
||||
).first()
|
||||
|
||||
if category:
|
||||
data["category"] = category
|
||||
self.import_run.categories.add(category)
|
||||
except (TransactionCategory.DoesNotExist, ValueError):
|
||||
# Ignore if category doesn't exist and create is False or not set
|
||||
data["category"] = None
|
||||
|
||||
if "account" in data:
|
||||
account_id = data.pop("account")
|
||||
account_mapping = next(
|
||||
(
|
||||
m
|
||||
for m in self.mapping.values()
|
||||
if isinstance(m, TransactionAccountMapping)
|
||||
and m.target == "account"
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
try:
|
||||
if account_mapping and account_mapping.type == "id":
|
||||
account = Account.objects.filter(id=account_id).first()
|
||||
else: # name
|
||||
account = Account.objects.filter(name=account_id).first()
|
||||
|
||||
if account:
|
||||
data["account"] = account
|
||||
except ValueError:
|
||||
# Ignore if account doesn't exist
|
||||
pass
|
||||
|
||||
if "tags" in data:
|
||||
tag_names = data.pop("tags")
|
||||
tags_mapping = next(
|
||||
(
|
||||
m
|
||||
for m in self.mapping.values()
|
||||
if isinstance(m, TransactionTagsMapping) and m.target == "tags"
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
for tag_name in tag_names:
|
||||
try:
|
||||
if tags_mapping:
|
||||
if tags_mapping.type == "id":
|
||||
tag = TransactionTag.objects.filter(id=tag_name).first()
|
||||
else: # name
|
||||
if getattr(tags_mapping, "create", False):
|
||||
tag, _ = TransactionTag.objects.get_or_create(
|
||||
name=tag_name.strip()
|
||||
)
|
||||
else:
|
||||
tag = TransactionTag.objects.filter(
|
||||
name=tag_name.strip()
|
||||
).first()
|
||||
|
||||
if tag:
|
||||
tags.append(tag)
|
||||
self.import_run.tags.add(tag)
|
||||
except ValueError:
|
||||
# Ignore if tag doesn't exist and create is False or not set
|
||||
continue
|
||||
|
||||
if "entities" in data:
|
||||
entity_names = data.pop("entities")
|
||||
entities_mapping = next(
|
||||
(
|
||||
m
|
||||
for m in self.mapping.values()
|
||||
if isinstance(m, TransactionEntitiesMapping)
|
||||
and m.target == "entities"
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
for entity_name in entity_names:
|
||||
try:
|
||||
if entities_mapping:
|
||||
if entities_mapping.type == "id":
|
||||
entity = TransactionTag.objects.filter(
|
||||
id=entity_name
|
||||
).first()
|
||||
else: # name
|
||||
if getattr(entities_mapping, "create", False):
|
||||
entity, _ = TransactionEntity.objects.get_or_create(
|
||||
name=entity_name.strip()
|
||||
)
|
||||
else:
|
||||
entity = TransactionEntity.objects.filter(
|
||||
name=entity_name.strip()
|
||||
).first()
|
||||
|
||||
if entity:
|
||||
entities.append(entity)
|
||||
self.import_run.entities.add(entity)
|
||||
except ValueError:
|
||||
# Ignore if entity doesn't exist and create is False or not set
|
||||
continue
|
||||
|
||||
# Create the transaction
|
||||
new_transaction = Transaction.objects.create(**data)
|
||||
self.import_run.transactions.add(new_transaction)
|
||||
|
||||
# Add many-to-many relationships
|
||||
if tags:
|
||||
new_transaction.tags.set(tags)
|
||||
if entities:
|
||||
new_transaction.entities.set(entities)
|
||||
|
||||
if self.settings.trigger_transaction_rules:
|
||||
transaction_created.send(sender=new_transaction)
|
||||
|
||||
return new_transaction
|
||||
|
||||
def _create_account(self, data: Dict[str, Any]) -> Account:
|
||||
if "group" in data:
|
||||
group_name = data.pop("group")
|
||||
group, _ = AccountGroup.objects.get_or_create(name=group_name)
|
||||
data["group"] = group
|
||||
|
||||
# Handle currency references
|
||||
if "currency" in data:
|
||||
currency = Currency.objects.get(code=data["currency"])
|
||||
data["currency"] = currency
|
||||
self.import_run.currencies.add(currency)
|
||||
|
||||
if "exchange_currency" in data:
|
||||
exchange_currency = Currency.objects.get(code=data["exchange_currency"])
|
||||
data["exchange_currency"] = exchange_currency
|
||||
self.import_run.currencies.add(exchange_currency)
|
||||
|
||||
return Account.objects.create(**data)
|
||||
|
||||
def _create_currency(self, data: Dict[str, Any]) -> Currency:
|
||||
# Handle exchange currency reference
|
||||
if "exchange_currency" in data:
|
||||
exchange_currency = Currency.objects.get(code=data["exchange_currency"])
|
||||
data["exchange_currency"] = exchange_currency
|
||||
self.import_run.currencies.add(exchange_currency)
|
||||
|
||||
currency = Currency.objects.create(**data)
|
||||
self.import_run.currencies.add(currency)
|
||||
return currency
|
||||
|
||||
def _create_category(self, data: Dict[str, Any]) -> TransactionCategory:
|
||||
category = TransactionCategory.objects.create(**data)
|
||||
self.import_run.categories.add(category)
|
||||
return category
|
||||
|
||||
def _create_tag(self, data: Dict[str, Any]) -> TransactionTag:
|
||||
tag = TransactionTag.objects.create(**data)
|
||||
self.import_run.tags.add(tag)
|
||||
return tag
|
||||
|
||||
def _create_entity(self, data: Dict[str, Any]) -> TransactionEntity:
|
||||
entity = TransactionEntity.objects.create(**data)
|
||||
self.import_run.entities.add(entity)
|
||||
return entity
|
||||
|
||||
def _check_duplicate_transaction(self, transaction_data: Dict[str, Any]) -> bool:
|
||||
for rule in self.deduplication:
|
||||
if rule.type == "compare":
|
||||
query = Transaction.all_objects.all().values("id")
|
||||
|
||||
# Build query conditions for each field in the rule
|
||||
for field in rule.fields:
|
||||
if field in transaction_data:
|
||||
if rule.match_type == "strict":
|
||||
query = query.filter(**{field: transaction_data[field]})
|
||||
else: # lax matching
|
||||
query = query.filter(
|
||||
**{f"{field}__iexact": transaction_data[field]}
|
||||
)
|
||||
|
||||
# If we found any matching transaction, it's a duplicate
|
||||
if query.exists():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _coerce_type(
|
||||
self, value: str, mapping: version_1.ColumnMapping
|
||||
) -> Union[str, int, bool, Decimal, datetime, list]:
|
||||
if not value:
|
||||
return None
|
||||
|
||||
coerce_to = mapping.coerce_to
|
||||
|
||||
return self._coerce_single_type(value, coerce_to, mapping)
|
||||
|
||||
@staticmethod
|
||||
def _coerce_single_type(
|
||||
value: str, coerce_to: str, mapping: version_1.ColumnMapping
|
||||
) -> Union[str, int, bool, Decimal, datetime.date, list]:
|
||||
if coerce_to == "str":
|
||||
return str(value)
|
||||
elif coerce_to == "int":
|
||||
return int(value)
|
||||
elif coerce_to == "str|int":
|
||||
if hasattr(mapping, "type") and mapping.type == "id":
|
||||
return int(value)
|
||||
elif hasattr(mapping, "type") and mapping.type in ["name", "code"]:
|
||||
return str(value)
|
||||
else:
|
||||
return str(value)
|
||||
elif coerce_to == "bool":
|
||||
return value.lower() in ["true", "1", "yes", "y", "on"]
|
||||
elif coerce_to == "positive_decimal":
|
||||
return abs(Decimal(value))
|
||||
elif coerce_to == "date":
|
||||
if isinstance(
|
||||
mapping,
|
||||
(
|
||||
version_1.TransactionDateMapping,
|
||||
version_1.TransactionReferenceDateMapping,
|
||||
),
|
||||
):
|
||||
formats = (
|
||||
mapping.format
|
||||
if isinstance(mapping.format, list)
|
||||
else [mapping.format]
|
||||
)
|
||||
for fmt in formats:
|
||||
try:
|
||||
return datetime.strptime(value, fmt).date()
|
||||
except ValueError:
|
||||
continue
|
||||
raise ValueError(
|
||||
f"Could not parse date '{value}' with any of the provided formats"
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
"Date coercion is only supported for TransactionDateMapping and TransactionReferenceDateMapping"
|
||||
)
|
||||
elif coerce_to == "list":
|
||||
return (
|
||||
value
|
||||
if isinstance(value, list)
|
||||
else [item.strip() for item in value.split(",") if item.strip()]
|
||||
)
|
||||
elif coerce_to == "transaction_type":
|
||||
if isinstance(mapping, version_1.TransactionTypeMapping):
|
||||
if mapping.detection_method == "sign":
|
||||
return (
|
||||
Transaction.Type.EXPENSE
|
||||
if value.startswith("-")
|
||||
else Transaction.Type.INCOME
|
||||
)
|
||||
elif mapping.detection_method == "always_income":
|
||||
return Transaction.Type.INCOME
|
||||
elif mapping.detection_method == "always_expense":
|
||||
return Transaction.Type.EXPENSE
|
||||
raise ValueError("Invalid transaction type detection method")
|
||||
elif coerce_to == "is_paid":
|
||||
if isinstance(mapping, version_1.TransactionIsPaidMapping):
|
||||
if mapping.detection_method == "boolean":
|
||||
return value.lower() in ["true", "1", "yes", "y", "on"]
|
||||
elif mapping.detection_method == "always_paid":
|
||||
return True
|
||||
elif mapping.detection_method == "always_unpaid":
|
||||
return False
|
||||
raise ValueError("Invalid is_paid detection method")
|
||||
else:
|
||||
raise ValueError(f"Unsupported coercion type: {coerce_to}")
|
||||
|
||||
def _map_row(self, row: Dict[str, str]) -> Dict[str, Any]:
|
||||
mapped_data = {}
|
||||
|
||||
for field, mapping in self.mapping.items():
|
||||
value = None
|
||||
|
||||
if isinstance(mapping.source, str):
|
||||
value = row.get(mapping.source, None)
|
||||
elif isinstance(mapping.source, list):
|
||||
for source in mapping.source:
|
||||
value = row.get(source, None)
|
||||
if value:
|
||||
break
|
||||
else:
|
||||
# If source is None, use None as the initial value
|
||||
value = None
|
||||
|
||||
# Use default_value if value is None
|
||||
if not value:
|
||||
value = mapping.default
|
||||
|
||||
# Apply transformations
|
||||
if mapping.transformations:
|
||||
value = self._transform_value(value, mapping, row)
|
||||
|
||||
value = self._coerce_type(value, mapping)
|
||||
|
||||
if mapping.required and value is None:
|
||||
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
|
||||
|
||||
def _process_row(self, row: Dict[str, str], row_number: int) -> None:
|
||||
try:
|
||||
mapped_data = self._map_row(row)
|
||||
|
||||
if mapped_data:
|
||||
# Handle different import types
|
||||
if self.settings.importing == "transactions":
|
||||
if self.deduplication and self._check_duplicate_transaction(
|
||||
mapped_data
|
||||
):
|
||||
self._increment_totals("skipped", 1)
|
||||
self._log("info", f"Skipped duplicate row {row_number}")
|
||||
return
|
||||
self._create_transaction(mapped_data)
|
||||
elif self.settings.importing == "accounts":
|
||||
self._create_account(mapped_data)
|
||||
elif self.settings.importing == "currencies":
|
||||
self._create_currency(mapped_data)
|
||||
elif self.settings.importing == "categories":
|
||||
self._create_category(mapped_data)
|
||||
elif self.settings.importing == "tags":
|
||||
self._create_tag(mapped_data)
|
||||
elif self.settings.importing == "entities":
|
||||
self._create_entity(mapped_data)
|
||||
|
||||
self._increment_totals("successful", value=1)
|
||||
self._log("info", f"Successfully processed row {row_number}")
|
||||
|
||||
self._increment_totals("processed", value=1)
|
||||
|
||||
except Exception as e:
|
||||
if not self.settings.skip_errors:
|
||||
self._log("error", f"Fatal error processing row {row_number}: {str(e)}")
|
||||
self._update_status("FAILED")
|
||||
raise
|
||||
else:
|
||||
self._log("warning", f"Error processing row {row_number}: {str(e)}")
|
||||
self._increment_totals("failed", value=1)
|
||||
|
||||
logger.error(f"Fatal error processing row {row_number}", exc_info=e)
|
||||
|
||||
def _process_csv(self, file_path):
|
||||
# First pass: count rows
|
||||
with open(file_path, "r", encoding=self.settings.encoding) as csv_file:
|
||||
# Skip specified number of rows
|
||||
for _ in range(self.settings.skip_lines):
|
||||
next(csv_file)
|
||||
|
||||
reader = csv.DictReader(csv_file, delimiter=self.settings.delimiter)
|
||||
self._update_totals("total", value=sum(1 for _ in reader))
|
||||
|
||||
with open(file_path, "r", encoding=self.settings.encoding) as csv_file:
|
||||
# Skip specified number of rows
|
||||
for _ in range(self.settings.skip_lines):
|
||||
next(csv_file)
|
||||
if self.settings.skip_lines:
|
||||
self._log("info", f"Skipped {self.settings.skip_lines} initial lines")
|
||||
|
||||
reader = csv.DictReader(csv_file, delimiter=self.settings.delimiter)
|
||||
|
||||
self._log("info", f"Starting import with {self.import_run.total_rows} rows")
|
||||
|
||||
for row_number, row in enumerate(reader, start=1):
|
||||
self._process_row(row, row_number)
|
||||
|
||||
def _validate_file_path(self, file_path: str) -> str:
|
||||
"""
|
||||
Validates that the file path is within the allowed temporary directory.
|
||||
Returns the absolute path.
|
||||
"""
|
||||
abs_path = os.path.abspath(file_path)
|
||||
if not abs_path.startswith(self.TEMP_DIR):
|
||||
raise ValueError(f"Invalid file path. File must be in {self.TEMP_DIR}")
|
||||
return abs_path
|
||||
|
||||
def process_file(self, file_path: str):
|
||||
with cachalot_disabled():
|
||||
# Validate and get absolute path
|
||||
file_path = self._validate_file_path(file_path)
|
||||
|
||||
self._update_status("PROCESSING")
|
||||
self.import_run.started_at = timezone.now()
|
||||
self.import_run.save(update_fields=["started_at"])
|
||||
|
||||
self._log("info", "Starting import process")
|
||||
|
||||
try:
|
||||
if self.settings.file_type == "csv":
|
||||
self._process_csv(file_path)
|
||||
|
||||
self._update_status("FINISHED")
|
||||
self._log(
|
||||
"info",
|
||||
f"Import completed successfully. "
|
||||
f"Successful: {self.import_run.successful_rows}, "
|
||||
f"Failed: {self.import_run.failed_rows}, "
|
||||
f"Skipped: {self.import_run.skipped_rows}",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self._update_status("FAILED")
|
||||
self._log("error", f"Import failed: {str(e)}")
|
||||
raise Exception("Import failed")
|
||||
|
||||
finally:
|
||||
self._log("info", "Cleaning up temporary files")
|
||||
try:
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
self._log("info", f"Deleted temporary file: {file_path}")
|
||||
except OSError as e:
|
||||
self._log("warning", f"Failed to delete temporary file: {str(e)}")
|
||||
|
||||
self.import_run.finished_at = timezone.now()
|
||||
self.import_run.save(update_fields=["finished_at"])
|
||||
cachalot.api.invalidate()
|
||||
21
app/apps/import_app/tasks.py
Normal file
21
app/apps/import_app/tasks.py
Normal file
@@ -0,0 +1,21 @@
|
||||
import logging
|
||||
|
||||
import cachalot.api
|
||||
from procrastinate.contrib.django import app
|
||||
|
||||
from apps.import_app.models import ImportRun
|
||||
from apps.import_app.services import ImportServiceV1
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@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")
|
||||
3
app/apps/import_app/tests.py
Normal file
3
app/apps/import_app/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
56
app/apps/import_app/urls.py
Normal file
56
app/apps/import_app/urls.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from django.urls import path
|
||||
import apps.import_app.views as views
|
||||
|
||||
urlpatterns = [
|
||||
path("import/", views.import_view, name="import"),
|
||||
path(
|
||||
"import/presets/",
|
||||
views.import_presets_list,
|
||||
name="import_presets_list",
|
||||
),
|
||||
path(
|
||||
"import/profiles/",
|
||||
views.import_profile_index,
|
||||
name="import_profiles_index",
|
||||
),
|
||||
path(
|
||||
"import/profiles/list/",
|
||||
views.import_profile_list,
|
||||
name="import_profiles_list",
|
||||
),
|
||||
path(
|
||||
"import/profiles/<int:profile_id>/delete/",
|
||||
views.import_profile_delete,
|
||||
name="import_profile_delete",
|
||||
),
|
||||
path(
|
||||
"import/profiles/add/",
|
||||
views.import_profile_add,
|
||||
name="import_profiles_add",
|
||||
),
|
||||
path(
|
||||
"import/profiles/<int:profile_id>/edit/",
|
||||
views.import_profile_edit,
|
||||
name="import_profile_edit",
|
||||
),
|
||||
path(
|
||||
"import/profiles/<int:profile_id>/runs/list/",
|
||||
views.import_runs_list,
|
||||
name="import_profile_runs_list",
|
||||
),
|
||||
path(
|
||||
"import/profiles/<int:profile_id>/runs/<int:run_id>/log/",
|
||||
views.import_run_log,
|
||||
name="import_run_log",
|
||||
),
|
||||
path(
|
||||
"import/profiles/<int:profile_id>/runs/<int:run_id>/delete/",
|
||||
views.import_run_delete,
|
||||
name="import_run_delete",
|
||||
),
|
||||
path(
|
||||
"import/profiles/<int:profile_id>/runs/add/",
|
||||
views.import_run_add,
|
||||
name="import_run_add",
|
||||
),
|
||||
]
|
||||
227
app/apps/import_app/views.py
Normal file
227
app/apps/import_app/views.py
Normal file
@@ -0,0 +1,227 @@
|
||||
import shutil
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.files.storage import FileSystemStorage
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import render, get_object_or_404
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
from apps.import_app.forms import ImportRunFileUploadForm, ImportProfileForm
|
||||
from apps.import_app.models import ImportRun, ImportProfile
|
||||
from apps.import_app.services import PresetService
|
||||
from apps.import_app.tasks import process_import
|
||||
|
||||
|
||||
def import_view(request):
|
||||
import_profile = ImportProfile.objects.get(id=2)
|
||||
shutil.copyfile(
|
||||
"/usr/src/app/apps/import_app/teste2.csv", "/usr/src/app/temp/teste2.csv"
|
||||
)
|
||||
ir = ImportRun.objects.create(profile=import_profile, file_name="teste.csv")
|
||||
process_import.defer(
|
||||
import_run_id=ir.id,
|
||||
file_path="/usr/src/app/temp/teste2.csv",
|
||||
)
|
||||
return HttpResponse("Hello, world. You're at the polls page.")
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def import_presets_list(request):
|
||||
presets = PresetService.get_all_presets()
|
||||
return render(
|
||||
request,
|
||||
"import_app/fragments/profiles/list_presets.html",
|
||||
{"presets": presets},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def import_profile_index(request):
|
||||
return render(
|
||||
request,
|
||||
"import_app/pages/profiles_index.html",
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def import_profile_list(request):
|
||||
profiles = ImportProfile.objects.all()
|
||||
|
||||
return render(
|
||||
request,
|
||||
"import_app/fragments/profiles/list.html",
|
||||
{"profiles": profiles},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def import_profile_add(request):
|
||||
message = request.POST.get("message", None)
|
||||
|
||||
if request.method == "POST" and request.POST.get("submit"):
|
||||
form = ImportProfileForm(request.POST)
|
||||
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Import Profile added successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = ImportProfileForm(
|
||||
initial={
|
||||
"name": request.POST.get("name"),
|
||||
"version": int(request.POST.get("version", 1)),
|
||||
"yaml_config": request.POST.get("yaml_config"),
|
||||
}
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"import_app/fragments/profiles/add.html",
|
||||
{"form": form, "message": message},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def import_profile_edit(request, profile_id):
|
||||
profile = get_object_or_404(ImportProfile, id=profile_id)
|
||||
|
||||
if request.method == "POST":
|
||||
form = ImportProfileForm(request.POST, instance=profile)
|
||||
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Import Profile update successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = ImportProfileForm(instance=profile)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"import_app/fragments/profiles/edit.html",
|
||||
{"form": form, "profile": profile},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["DELETE"])
|
||||
def import_profile_delete(request, profile_id):
|
||||
profile = ImportProfile.objects.get(id=profile_id)
|
||||
|
||||
profile.delete()
|
||||
|
||||
messages.success(request, _("Import Profile deleted successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def import_runs_list(request, profile_id):
|
||||
profile = ImportProfile.objects.get(id=profile_id)
|
||||
|
||||
runs = ImportRun.objects.filter(profile=profile).order_by("-id")
|
||||
|
||||
return render(
|
||||
request,
|
||||
"import_app/fragments/runs/list.html",
|
||||
{"profile": profile, "runs": runs},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def import_run_log(request, profile_id, run_id):
|
||||
run = ImportRun.objects.get(profile__id=profile_id, id=run_id)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"import_app/fragments/runs/log.html",
|
||||
{"run": run},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def import_run_add(request, profile_id):
|
||||
profile = ImportProfile.objects.get(id=profile_id)
|
||||
|
||||
if request.method == "POST":
|
||||
form = ImportRunFileUploadForm(request.POST, request.FILES)
|
||||
|
||||
if form.is_valid():
|
||||
uploaded_file = request.FILES["file"]
|
||||
fs = FileSystemStorage(location="/usr/src/app/temp")
|
||||
filename = fs.save(uploaded_file.name, uploaded_file)
|
||||
file_path = fs.path(filename)
|
||||
|
||||
import_run = ImportRun.objects.create(profile=profile, file_name=filename)
|
||||
|
||||
# Defer the procrastinate task
|
||||
process_import.defer(import_run_id=import_run.id, file_path=file_path)
|
||||
|
||||
messages.success(request, _("Import Run queued successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = ImportRunFileUploadForm()
|
||||
|
||||
return render(
|
||||
request,
|
||||
"import_app/fragments/runs/add.html",
|
||||
{"form": form, "profile": profile},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["DELETE"])
|
||||
def import_run_delete(request, profile_id, run_id):
|
||||
run = ImportRun.objects.get(profile__id=profile_id, id=run_id)
|
||||
|
||||
run.delete()
|
||||
|
||||
messages.success(request, _("Run deleted successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated",
|
||||
},
|
||||
)
|
||||
@@ -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,7 @@ def transactions_list(request, month: int, year: int):
|
||||
"account__exchange_currency",
|
||||
"account__currency",
|
||||
"installment_plan",
|
||||
"entities",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -122,3 +135,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,
|
||||
)
|
||||
|
||||
@@ -18,7 +18,7 @@ from apps.transactions.models import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@app.task
|
||||
@app.task(name="check_for_transaction_rules")
|
||||
def check_for_transaction_rules(
|
||||
instance_id: int,
|
||||
signal,
|
||||
|
||||
@@ -52,19 +52,4 @@ urlpatterns = [
|
||||
views.transaction_rule_action_delete,
|
||||
name="transaction_rule_action_delete",
|
||||
),
|
||||
# path(
|
||||
# "rules/<int:installment_plan_id>/transactions/",
|
||||
# views.installment_plan_transactions,
|
||||
# name="rule_view",
|
||||
# ),
|
||||
# path(
|
||||
# "rules/<int:installment_plan_id>/edit/",
|
||||
# views.installment_plan_edit,
|
||||
# name="rule_edit",
|
||||
# ),
|
||||
# path(
|
||||
# "rules/<int:installment_plan_id>/delete/",
|
||||
# views.installment_plan_delete,
|
||||
# name="rule_delete",
|
||||
# ),
|
||||
]
|
||||
|
||||
@@ -3,7 +3,6 @@ from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import render, get_object_or_404, redirect
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
@@ -118,7 +117,6 @@ def transaction_rule_view(request, transaction_rule_id):
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@csrf_exempt
|
||||
@require_http_methods(["DELETE"])
|
||||
def transaction_rule_delete(request, transaction_rule_id):
|
||||
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
|
||||
@@ -201,7 +199,6 @@ def transaction_rule_action_edit(request, transaction_rule_action_id):
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@csrf_exempt
|
||||
@require_http_methods(["DELETE"])
|
||||
def transaction_rule_action_delete(request, transaction_rule_action_id):
|
||||
transaction_rule_action = get_object_or_404(
|
||||
|
||||
@@ -12,15 +12,34 @@ from apps.transactions.models import (
|
||||
|
||||
@admin.register(Transaction)
|
||||
class TransactionModelAdmin(admin.ModelAdmin):
|
||||
def get_queryset(self, request):
|
||||
# Use the all_objects manager to show all transactions, including deleted ones
|
||||
return self.model.all_objects.all()
|
||||
|
||||
list_filter = ["deleted", "type", "is_paid", "date", "account"]
|
||||
|
||||
list_display = [
|
||||
"date",
|
||||
"description",
|
||||
"type",
|
||||
"account__name",
|
||||
"amount",
|
||||
"account__currency__code",
|
||||
"date",
|
||||
"reference_date",
|
||||
"deleted",
|
||||
]
|
||||
readonly_fields = ["deleted_at"]
|
||||
|
||||
actions = ["hard_delete_selected"]
|
||||
|
||||
def hard_delete_selected(self, request, queryset):
|
||||
for obj in queryset:
|
||||
obj.hard_delete()
|
||||
self.message_user(
|
||||
request, f"Successfully hard deleted {queryset.count()} transactions."
|
||||
)
|
||||
|
||||
hard_delete_selected.short_description = "Hard delete selected transactions"
|
||||
|
||||
|
||||
class TransactionInline(admin.TabularInline):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from crispy_bootstrap5.bootstrap5 import Switch
|
||||
from crispy_forms.bootstrap import FormActions
|
||||
from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion
|
||||
from crispy_forms.bootstrap import FormActions, AccordionGroup
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import (
|
||||
Layout,
|
||||
@@ -86,7 +86,7 @@ class TransactionForm(forms.ModelForm):
|
||||
"account": TomSelect(clear_button=False, group_by="group"),
|
||||
}
|
||||
|
||||
def __init__(self, *args, user=None, **kwargs):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# if editing a transaction display non-archived items and it's own item even if it's archived
|
||||
@@ -115,7 +115,7 @@ class TransactionForm(forms.ModelForm):
|
||||
"type",
|
||||
template="transactions/widgets/income_expense_toggle_buttons.html",
|
||||
),
|
||||
Switch("is_paid"),
|
||||
Field("is_paid", template="transactions/widgets/paid_toggle_button.html"),
|
||||
Row(
|
||||
Column("account", css_class="form-group col-md-6 mb-0"),
|
||||
Column("entities", css_class="form-group col-md-6 mb-0"),
|
||||
@@ -136,8 +136,48 @@ class TransactionForm(forms.ModelForm):
|
||||
"notes",
|
||||
)
|
||||
|
||||
self.helper_simple = FormHelper()
|
||||
self.helper_simple.form_tag = False
|
||||
self.helper_simple.form_method = "post"
|
||||
self.helper_simple.layout = Layout(
|
||||
Field(
|
||||
"type",
|
||||
template="transactions/widgets/income_expense_toggle_buttons.html",
|
||||
),
|
||||
Field("is_paid", template="transactions/widgets/paid_toggle_button.html"),
|
||||
"account",
|
||||
Row(
|
||||
Column(Field("date"), css_class="form-group col-md-6 mb-0"),
|
||||
Column(Field("reference_date"), css_class="form-group col-md-6 mb-0"),
|
||||
css_class="form-row",
|
||||
),
|
||||
"description",
|
||||
Field("amount", inputmode="decimal"),
|
||||
BS5Accordion(
|
||||
AccordionGroup(
|
||||
_("More"),
|
||||
"entities",
|
||||
Row(
|
||||
Column("category", css_class="form-group col-md-6 mb-0"),
|
||||
Column("tags", css_class="form-group col-md-6 mb-0"),
|
||||
css_class="form-row",
|
||||
),
|
||||
"notes",
|
||||
active=False,
|
||||
),
|
||||
flush=False,
|
||||
always_open=False,
|
||||
css_class="mb-3",
|
||||
),
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
self.fields["reference_date"].required = False
|
||||
self.fields["date"].widget = AirDatePickerInput(clear_button=False, user=user)
|
||||
self.fields["date"].widget = AirDatePickerInput(clear_button=False)
|
||||
|
||||
if self.instance and self.instance.pk:
|
||||
decimal_places = self.instance.account.currency.decimal_places
|
||||
@@ -183,6 +223,43 @@ class TransactionForm(forms.ModelForm):
|
||||
return instance
|
||||
|
||||
|
||||
class BulkEditTransactionForm(TransactionForm):
|
||||
is_paid = forms.NullBooleanField(required=False)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Make all fields optional
|
||||
for field_name, field in self.fields.items():
|
||||
field.required = False
|
||||
|
||||
del self.helper.layout[-1] # Remove button
|
||||
del self.helper.layout[0:2] # Remove type, is_paid field
|
||||
|
||||
self.helper.layout.insert(
|
||||
0,
|
||||
Field(
|
||||
"type",
|
||||
template="transactions/widgets/unselectable_income_expense_toggle_buttons.html",
|
||||
),
|
||||
)
|
||||
|
||||
self.helper.layout.insert(
|
||||
1,
|
||||
Field(
|
||||
"is_paid",
|
||||
template="transactions/widgets/unselectable_paid_toggle_button.html",
|
||||
),
|
||||
)
|
||||
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TransferForm(forms.Form):
|
||||
from_account = forms.ModelChoiceField(
|
||||
queryset=Account.objects.filter(is_archived=False),
|
||||
@@ -256,7 +333,7 @@ class TransferForm(forms.Form):
|
||||
label=_("Notes"),
|
||||
)
|
||||
|
||||
def __init__(self, *args, user=None, **kwargs):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper()
|
||||
@@ -325,7 +402,7 @@ class TransferForm(forms.Form):
|
||||
|
||||
self.fields["from_amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
self.fields["to_amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
self.fields["date"].widget = AirDatePickerInput(clear_button=False, user=user)
|
||||
self.fields["date"].widget = AirDatePickerInput(clear_button=False)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
@@ -438,7 +515,7 @@ class InstallmentPlanForm(forms.ModelForm):
|
||||
"notes": forms.Textarea(attrs={"rows": 3}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, user=None, **kwargs):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# if editing display non-archived items and it's own item even if it's archived
|
||||
@@ -495,9 +572,7 @@ class InstallmentPlanForm(forms.ModelForm):
|
||||
)
|
||||
|
||||
self.fields["installment_amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
self.fields["start_date"].widget = AirDatePickerInput(
|
||||
clear_button=False, user=user
|
||||
)
|
||||
self.fields["start_date"].widget = AirDatePickerInput(clear_button=False)
|
||||
|
||||
if self.instance and self.instance.pk:
|
||||
self.helper.layout.append(
|
||||
@@ -685,7 +760,7 @@ class RecurringTransactionForm(forms.ModelForm):
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self, *args, user=None, **kwargs):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# if editing display non-archived items and it's own item even if it's archived
|
||||
@@ -742,10 +817,8 @@ class RecurringTransactionForm(forms.ModelForm):
|
||||
)
|
||||
|
||||
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
self.fields["start_date"].widget = AirDatePickerInput(
|
||||
clear_button=False, user=user
|
||||
)
|
||||
self.fields["end_date"].widget = AirDatePickerInput(user=user)
|
||||
self.fields["start_date"].widget = AirDatePickerInput(clear_button=False)
|
||||
self.fields["end_date"].widget = AirDatePickerInput()
|
||||
|
||||
if self.instance and self.instance.pk:
|
||||
self.helper.layout.append(
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.5 on 2025-01-19 00:44
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('transactions', '0027_alter_transaction_description'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='transaction',
|
||||
name='internal_note',
|
||||
field=models.TextField(blank=True, verbose_name='Internal Note'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.1.5 on 2025-01-19 14:59
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('transactions', '0028_transaction_internal_note'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='transaction',
|
||||
options={'default_manager_name': 'objects', 'verbose_name': 'Transaction', 'verbose_name_plural': 'Transactions'},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.1.5 on 2025-01-19 14:59
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('transactions', '0029_alter_transaction_options'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='transaction',
|
||||
name='deleted',
|
||||
field=models.BooleanField(default=False, verbose_name='Deleted'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='transaction',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.5 on 2025-01-19 15:14
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('transactions', '0030_transaction_deleted_transaction_deleted_at'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='transaction',
|
||||
name='deleted',
|
||||
field=models.BooleanField(db_index=True, default=False, verbose_name='Deleted'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 5.1.5 on 2025-01-19 16:48
|
||||
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('transactions', '0031_alter_transaction_deleted'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='transaction',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='transaction',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(auto_now=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 5.1.5 on 2025-01-21 01:56
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("transactions", "0032_transaction_created_at_transaction_updated_at"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="transaction",
|
||||
name="internal_id",
|
||||
field=models.TextField(
|
||||
blank=True, null=True, unique=True, verbose_name="Internal ID"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -6,6 +6,7 @@ from django.db import models, transaction
|
||||
from django.db.models import Q
|
||||
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
|
||||
@@ -15,6 +16,53 @@ from apps.transactions.validators import validate_decimal_places, validate_non_n
|
||||
logger = logging.getLogger()
|
||||
|
||||
|
||||
class SoftDeleteQuerySet(models.QuerySet):
|
||||
def delete(self):
|
||||
if not settings.ENABLE_SOFT_DELETE:
|
||||
# If soft deletion is disabled, perform a normal delete
|
||||
return super().delete()
|
||||
|
||||
# Separate the queryset into already deleted and not deleted objects
|
||||
already_deleted = self.filter(deleted=True)
|
||||
not_deleted = self.filter(deleted=False)
|
||||
|
||||
# Use a transaction to ensure atomicity
|
||||
with transaction.atomic():
|
||||
# Perform hard delete on already deleted objects
|
||||
hard_deleted_count = already_deleted._raw_delete(already_deleted.db)
|
||||
|
||||
# Perform soft delete on not deleted objects
|
||||
soft_deleted_count = not_deleted.update(
|
||||
deleted=True, deleted_at=timezone.now()
|
||||
)
|
||||
|
||||
# Return a tuple of counts as expected by Django's delete method
|
||||
return (
|
||||
hard_deleted_count + soft_deleted_count,
|
||||
{"Transaction": hard_deleted_count + soft_deleted_count},
|
||||
)
|
||||
|
||||
def hard_delete(self):
|
||||
return super().delete()
|
||||
|
||||
|
||||
class SoftDeleteManager(models.Manager):
|
||||
def get_queryset(self):
|
||||
qs = SoftDeleteQuerySet(self.model, using=self._db)
|
||||
return qs.filter(deleted=False)
|
||||
|
||||
|
||||
class AllObjectsManager(models.Manager):
|
||||
def get_queryset(self):
|
||||
return SoftDeleteQuerySet(self.model, using=self._db)
|
||||
|
||||
|
||||
class DeletedObjectsManager(models.Manager):
|
||||
def get_queryset(self):
|
||||
qs = SoftDeleteQuerySet(self.model, using=self._db)
|
||||
return qs.filter(deleted=True)
|
||||
|
||||
|
||||
class TransactionCategory(models.Model):
|
||||
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True)
|
||||
mute = models.BooleanField(default=False, verbose_name=_("Mute"))
|
||||
@@ -141,11 +189,29 @@ class Transaction(models.Model):
|
||||
related_name="transactions",
|
||||
verbose_name=_("Recurring Transaction"),
|
||||
)
|
||||
internal_note = models.TextField(blank=True, verbose_name=_("Internal Note"))
|
||||
internal_id = models.TextField(
|
||||
blank=True, null=True, unique=True, verbose_name=_("Internal ID")
|
||||
)
|
||||
|
||||
deleted = models.BooleanField(
|
||||
default=False, verbose_name=_("Deleted"), db_index=True
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
deleted_at = models.DateTimeField(
|
||||
null=True, blank=True, verbose_name=_("Deleted At")
|
||||
)
|
||||
|
||||
objects = SoftDeleteManager.from_queryset(SoftDeleteQuerySet)()
|
||||
all_objects = AllObjectsManager.from_queryset(SoftDeleteQuerySet)()
|
||||
deleted_objects = DeletedObjectsManager.from_queryset(SoftDeleteQuerySet)()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Transaction")
|
||||
verbose_name_plural = _("Transactions")
|
||||
db_table = "transactions"
|
||||
default_manager_name = "objects"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.amount = truncate_decimal(
|
||||
@@ -160,6 +226,20 @@ class Transaction(models.Model):
|
||||
self.full_clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
if settings.ENABLE_SOFT_DELETE:
|
||||
if not self.deleted:
|
||||
self.deleted = True
|
||||
self.deleted_at = timezone.now()
|
||||
self.save()
|
||||
else:
|
||||
super().delete(*args, **kwargs)
|
||||
else:
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
def hard_delete(self, *args, **kwargs):
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
def exchanged_amount(self):
|
||||
if self.account.exchange_currency:
|
||||
converted_amount, prefix, suffix, decimal_places = convert(
|
||||
@@ -175,9 +255,27 @@ 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}"
|
||||
|
||||
|
||||
class InstallmentPlan(models.Model):
|
||||
class Recurrence(models.TextChoices):
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from cachalot.api import cachalot_disabled, invalidate
|
||||
from django.utils import timezone
|
||||
from django.conf import settings
|
||||
|
||||
from procrastinate.contrib.django import app
|
||||
|
||||
from apps.transactions.models import RecurringTransaction
|
||||
|
||||
from apps.transactions.models import RecurringTransaction, Transaction
|
||||
|
||||
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()
|
||||
@@ -19,3 +23,31 @@ def generate_recurring_transactions(timestamp=None):
|
||||
exc_info=True,
|
||||
)
|
||||
raise e
|
||||
|
||||
|
||||
@app.periodic(cron="10 1 * * *")
|
||||
@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."
|
||||
|
||||
if not settings.ENABLE_SOFT_DELETE:
|
||||
# Hard delete all soft-deleted transactions
|
||||
deleted_count, _ = Transaction.deleted_objects.all().hard_delete()
|
||||
return (
|
||||
f"Hard deleted {deleted_count} transactions (soft deletion disabled)."
|
||||
)
|
||||
|
||||
# Calculate the cutoff date
|
||||
cutoff_date = timezone.now() - timedelta(
|
||||
days=settings.KEEP_DELETED_TRANSACTIONS_FOR
|
||||
)
|
||||
|
||||
invalidate()
|
||||
|
||||
# Hard delete soft-deleted transactions older than the cutoff date
|
||||
old_transactions = Transaction.deleted_objects.filter(deleted_at__lt=cutoff_date)
|
||||
deleted_count, _ = old_transactions.hard_delete()
|
||||
|
||||
return f"Hard deleted {deleted_count} objects older than {settings.KEEP_DELETED_TRANSACTIONS_FOR} days."
|
||||
|
||||
@@ -6,13 +6,38 @@ 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/actions/pay",
|
||||
"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,
|
||||
name="transactions_bulk_pay",
|
||||
),
|
||||
@@ -27,32 +52,57 @@ urlpatterns = [
|
||||
name="transactions_bulk_delete",
|
||||
),
|
||||
path(
|
||||
"transaction/<int:transaction_id>/pay",
|
||||
"transactions/actions/undelete/",
|
||||
views.bulk_undelete_transactions,
|
||||
name="transactions_bulk_undelete",
|
||||
),
|
||||
path(
|
||||
"transactions/actions/duplicate/",
|
||||
views.bulk_clone_transactions,
|
||||
name="transactions_bulk_clone",
|
||||
),
|
||||
path(
|
||||
"transaction/<int:transaction_id>/pay/",
|
||||
views.transaction_pay,
|
||||
name="transaction_pay",
|
||||
),
|
||||
path(
|
||||
"transaction/<int:transaction_id>/delete",
|
||||
"transaction/<int:transaction_id>/delete/",
|
||||
views.transaction_delete,
|
||||
name="transaction_delete",
|
||||
),
|
||||
path(
|
||||
"transaction/<int:transaction_id>/edit",
|
||||
"transaction/<int:transaction_id>/undelete/",
|
||||
views.transaction_undelete,
|
||||
name="transaction_undelete",
|
||||
),
|
||||
path(
|
||||
"transaction/<int:transaction_id>/edit/",
|
||||
views.transaction_edit,
|
||||
name="transaction_edit",
|
||||
),
|
||||
path(
|
||||
"transaction/<int:transaction_id>/clone",
|
||||
"transactions/bulk-edit/",
|
||||
views.transactions_bulk_edit,
|
||||
name="transactions_bulk_edit",
|
||||
),
|
||||
path(
|
||||
"transaction/<int:transaction_id>/clone/",
|
||||
views.transaction_clone,
|
||||
name="transaction_clone",
|
||||
),
|
||||
path(
|
||||
"transaction/add",
|
||||
"transaction/add/",
|
||||
views.transaction_add,
|
||||
name="transaction_add",
|
||||
),
|
||||
path(
|
||||
"transactions/transfer",
|
||||
"add/",
|
||||
views.transaction_simple_add,
|
||||
name="transaction_simple_add",
|
||||
),
|
||||
path(
|
||||
"transactions/transfer/",
|
||||
views.transactions_transfer,
|
||||
name="transactions_transfer",
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
from copy import deepcopy
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponse
|
||||
from django.utils.translation import gettext_lazy as _, ngettext_lazy
|
||||
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
from apps.transactions.models import Transaction
|
||||
@@ -9,7 +13,19 @@ from apps.transactions.models import Transaction
|
||||
@login_required
|
||||
def bulk_pay_transactions(request):
|
||||
selected_transactions = request.GET.getlist("transactions", [])
|
||||
Transaction.objects.filter(id__in=selected_transactions).update(is_paid=True)
|
||||
transactions = Transaction.objects.filter(id__in=selected_transactions)
|
||||
count = transactions.count()
|
||||
transactions.update(is_paid=True)
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
ngettext_lazy(
|
||||
"%(count)s transaction marked as paid",
|
||||
"%(count)s transactions marked as paid",
|
||||
count,
|
||||
)
|
||||
% {"count": count},
|
||||
)
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
@@ -21,7 +37,19 @@ def bulk_pay_transactions(request):
|
||||
@login_required
|
||||
def bulk_unpay_transactions(request):
|
||||
selected_transactions = request.GET.getlist("transactions", [])
|
||||
Transaction.objects.filter(id__in=selected_transactions).update(is_paid=False)
|
||||
transactions = Transaction.objects.filter(id__in=selected_transactions)
|
||||
count = transactions.count()
|
||||
transactions.update(is_paid=False)
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
ngettext_lazy(
|
||||
"%(count)s transaction marked as not paid",
|
||||
"%(count)s transactions marked as not paid",
|
||||
count,
|
||||
)
|
||||
% {"count": count},
|
||||
)
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
@@ -33,7 +61,78 @@ def bulk_unpay_transactions(request):
|
||||
@login_required
|
||||
def bulk_delete_transactions(request):
|
||||
selected_transactions = request.GET.getlist("transactions", [])
|
||||
Transaction.objects.filter(id__in=selected_transactions).delete()
|
||||
transactions = Transaction.all_objects.filter(id__in=selected_transactions)
|
||||
count = transactions.count()
|
||||
transactions.delete()
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
ngettext_lazy(
|
||||
"%(count)s transaction deleted successfully",
|
||||
"%(count)s transactions deleted successfully",
|
||||
count,
|
||||
)
|
||||
% {"count": count},
|
||||
)
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={"HX-Trigger": "updated"},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
def bulk_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)
|
||||
|
||||
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):
|
||||
selected_transactions = request.GET.getlist("transactions", [])
|
||||
transactions = Transaction.objects.filter(id__in=selected_transactions)
|
||||
count = transactions.count()
|
||||
|
||||
for transaction in transactions:
|
||||
new_transaction = deepcopy(transaction)
|
||||
new_transaction.pk = None
|
||||
new_transaction.installment_plan = None
|
||||
new_transaction.installment_id = None
|
||||
new_transaction.recurring_transaction = None
|
||||
new_transaction.internal_id = None
|
||||
new_transaction.save()
|
||||
|
||||
new_transaction.tags.add(*transaction.tags.all())
|
||||
new_transaction.entities.add(*transaction.entities.all())
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
ngettext_lazy(
|
||||
"%(count)s transaction duplicated successfully",
|
||||
"%(count)s transactions duplicated successfully",
|
||||
count,
|
||||
)
|
||||
% {"count": count},
|
||||
)
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
|
||||
@@ -2,9 +2,7 @@ from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import render, get_object_or_404
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
@@ -111,7 +109,6 @@ def category_edit(request, category_id):
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@csrf_exempt
|
||||
@require_http_methods(["DELETE"])
|
||||
def category_delete(request, category_id):
|
||||
category = get_object_or_404(TransactionCategory, id=category_id)
|
||||
|
||||
@@ -3,7 +3,6 @@ from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import render, get_object_or_404
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
@@ -110,7 +109,6 @@ def entity_edit(request, entity_id):
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@csrf_exempt
|
||||
@require_http_methods(["DELETE"])
|
||||
def entity_delete(request, entity_id):
|
||||
entity = get_object_or_404(TransactionEntity, id=entity_id)
|
||||
|
||||
@@ -4,7 +4,6 @@ from django.http import HttpResponse
|
||||
from django.shortcuts import render, get_object_or_404
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
@@ -82,7 +81,7 @@ def installment_plan_transactions(request, installment_plan_id):
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def installment_plan_add(request):
|
||||
if request.method == "POST":
|
||||
form = InstallmentPlanForm(request.POST, user=request.user)
|
||||
form = InstallmentPlanForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Installment Plan added successfully"))
|
||||
@@ -94,7 +93,7 @@ def installment_plan_add(request):
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = InstallmentPlanForm(user=request.user)
|
||||
form = InstallmentPlanForm()
|
||||
|
||||
return render(
|
||||
request,
|
||||
@@ -110,9 +109,7 @@ def installment_plan_edit(request, installment_plan_id):
|
||||
installment_plan = get_object_or_404(InstallmentPlan, id=installment_plan_id)
|
||||
|
||||
if request.method == "POST":
|
||||
form = InstallmentPlanForm(
|
||||
request.POST, instance=installment_plan, user=request.user
|
||||
)
|
||||
form = InstallmentPlanForm(request.POST, instance=installment_plan)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Installment Plan updated successfully"))
|
||||
@@ -124,7 +121,7 @@ def installment_plan_edit(request, installment_plan_id):
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = InstallmentPlanForm(instance=installment_plan, user=request.user)
|
||||
form = InstallmentPlanForm(instance=installment_plan)
|
||||
|
||||
return render(
|
||||
request,
|
||||
@@ -152,7 +149,6 @@ def installment_plan_refresh(request, installment_plan_id):
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@csrf_exempt
|
||||
@require_http_methods(["DELETE"])
|
||||
def installment_plan_delete(request, installment_plan_id):
|
||||
installment_plan = get_object_or_404(InstallmentPlan, id=installment_plan_id)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Q
|
||||
@@ -7,7 +6,6 @@ from django.http import HttpResponse
|
||||
from django.shortcuts import render, get_object_or_404
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
@@ -108,7 +106,7 @@ def recurring_transaction_transactions(request, recurring_transaction_id):
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def recurring_transaction_add(request):
|
||||
if request.method == "POST":
|
||||
form = RecurringTransactionForm(request.POST, user=request.user)
|
||||
form = RecurringTransactionForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Recurring Transaction added successfully"))
|
||||
@@ -120,7 +118,7 @@ def recurring_transaction_add(request):
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = RecurringTransactionForm(user=request.user)
|
||||
form = RecurringTransactionForm()
|
||||
|
||||
return render(
|
||||
request,
|
||||
@@ -138,9 +136,7 @@ def recurring_transaction_edit(request, recurring_transaction_id):
|
||||
)
|
||||
|
||||
if request.method == "POST":
|
||||
form = RecurringTransactionForm(
|
||||
request.POST, instance=recurring_transaction, user=request.user
|
||||
)
|
||||
form = RecurringTransactionForm(request.POST, instance=recurring_transaction)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Recurring Transaction updated successfully"))
|
||||
@@ -152,9 +148,7 @@ def recurring_transaction_edit(request, recurring_transaction_id):
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = RecurringTransactionForm(
|
||||
instance=recurring_transaction, user=request.user
|
||||
)
|
||||
form = RecurringTransactionForm(instance=recurring_transaction)
|
||||
|
||||
return render(
|
||||
request,
|
||||
@@ -230,7 +224,6 @@ def recurring_transaction_finish(request, recurring_transaction_id):
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@csrf_exempt
|
||||
@require_http_methods(["DELETE"])
|
||||
def recurring_transaction_delete(request, recurring_transaction_id):
|
||||
recurring_transaction = get_object_or_404(
|
||||
|
||||
@@ -3,7 +3,6 @@ from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import render, get_object_or_404
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
@@ -110,7 +109,6 @@ def tag_edit(request, tag_id):
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@csrf_exempt
|
||||
@require_http_methods(["DELETE"])
|
||||
def tag_delete(request, tag_id):
|
||||
tag = get_object_or_404(TransactionTag, id=tag_id)
|
||||
|
||||
@@ -7,15 +7,18 @@ from django.core.paginator import Paginator
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import render, get_object_or_404
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.utils.translation import gettext_lazy as _, ngettext_lazy
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
from apps.common.utils.dicts import remove_falsey_entries
|
||||
from apps.rules.signals import transaction_created
|
||||
from apps.rules.signals import transaction_created, transaction_updated
|
||||
from apps.transactions.filters import TransactionsFilter
|
||||
from apps.transactions.forms import TransactionForm, TransferForm
|
||||
from apps.transactions.forms import (
|
||||
TransactionForm,
|
||||
TransferForm,
|
||||
BulkEditTransactionForm,
|
||||
)
|
||||
from apps.transactions.models import Transaction
|
||||
from apps.transactions.utils.calculations import (
|
||||
calculate_currency_totals,
|
||||
@@ -41,7 +44,7 @@ def transaction_add(request):
|
||||
).date()
|
||||
|
||||
if request.method == "POST":
|
||||
form = TransactionForm(request.POST, user=request.user)
|
||||
form = TransactionForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Transaction added successfully"))
|
||||
@@ -52,7 +55,6 @@ def transaction_add(request):
|
||||
)
|
||||
else:
|
||||
form = TransactionForm(
|
||||
user=request.user,
|
||||
initial={
|
||||
"date": expected_date,
|
||||
"type": transaction_type,
|
||||
@@ -66,6 +68,48 @@ def transaction_add(request):
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def transaction_simple_add(request):
|
||||
month = int(request.GET.get("month", timezone.localdate(timezone.now()).month))
|
||||
year = int(request.GET.get("year", timezone.localdate(timezone.now()).year))
|
||||
transaction_type = Transaction.Type(request.GET.get("type", "IN"))
|
||||
|
||||
now = timezone.localdate(timezone.now())
|
||||
expected_date = datetime.datetime(
|
||||
day=now.day if month == now.month and year == now.year else 1,
|
||||
month=month,
|
||||
year=year,
|
||||
).date()
|
||||
|
||||
if request.method == "POST":
|
||||
form = TransactionForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Transaction added successfully"))
|
||||
|
||||
form = TransactionForm(
|
||||
initial={
|
||||
"date": expected_date,
|
||||
"type": transaction_type,
|
||||
},
|
||||
)
|
||||
|
||||
else:
|
||||
form = TransactionForm(
|
||||
initial={
|
||||
"date": expected_date,
|
||||
"type": transaction_type,
|
||||
},
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"transactions/pages/add.html",
|
||||
{"form": form},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
@@ -73,7 +117,7 @@ def transaction_edit(request, transaction_id, **kwargs):
|
||||
transaction = get_object_or_404(Transaction, id=transaction_id)
|
||||
|
||||
if request.method == "POST":
|
||||
form = TransactionForm(request.POST, user=request.user, instance=transaction)
|
||||
form = TransactionForm(request.POST, instance=transaction)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Transaction updated successfully"))
|
||||
@@ -83,7 +127,7 @@ def transaction_edit(request, transaction_id, **kwargs):
|
||||
headers={"HX-Trigger": "updated, hide_offcanvas"},
|
||||
)
|
||||
else:
|
||||
form = TransactionForm(instance=transaction, user=request.user)
|
||||
form = TransactionForm(instance=transaction)
|
||||
|
||||
return render(
|
||||
request,
|
||||
@@ -92,6 +136,60 @@ def transaction_edit(request, transaction_id, **kwargs):
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def transactions_bulk_edit(request):
|
||||
# Get selected transaction IDs from the URL parameter
|
||||
transaction_ids = request.GET.getlist("transactions") or request.POST.getlist(
|
||||
"transactions"
|
||||
)
|
||||
# Load the selected transactions
|
||||
transactions = Transaction.objects.filter(id__in=transaction_ids)
|
||||
count = transactions.count()
|
||||
|
||||
if request.method == "POST":
|
||||
form = BulkEditTransactionForm(request.POST)
|
||||
if form.is_valid():
|
||||
# Apply changes from the form to all selected transactions
|
||||
for transaction in transactions:
|
||||
for field_name, value in form.cleaned_data.items():
|
||||
if value or isinstance(
|
||||
value, bool
|
||||
): # Only update fields that have been filled in the form
|
||||
if field_name == "tags":
|
||||
transaction.tags.set(value)
|
||||
elif field_name == "entities":
|
||||
transaction.entities.set(value)
|
||||
else:
|
||||
setattr(transaction, field_name, value)
|
||||
|
||||
transaction.save()
|
||||
transaction_updated.send(sender=transaction)
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
ngettext_lazy(
|
||||
"%(count)s transaction updated successfully",
|
||||
"%(count)s transactions updated successfully",
|
||||
count,
|
||||
)
|
||||
% {"count": count},
|
||||
)
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={"HX-Trigger": "updated, hide_offcanvas"},
|
||||
)
|
||||
else:
|
||||
form = BulkEditTransactionForm(initial={"is_paid": None, "type": None})
|
||||
|
||||
context = {
|
||||
"form": form,
|
||||
"transactions": transactions,
|
||||
}
|
||||
return render(request, "transactions/fragments/bulk_edit.html", context)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
@@ -102,6 +200,7 @@ def transaction_clone(request, transaction_id, **kwargs):
|
||||
new_transaction.installment_plan = None
|
||||
new_transaction.installment_id = None
|
||||
new_transaction.recurring_transaction = None
|
||||
new_transaction.internal_id = None
|
||||
new_transaction.save()
|
||||
|
||||
new_transaction.tags.add(*transaction.tags.all())
|
||||
@@ -143,10 +242,9 @@ def transaction_clone(request, transaction_id, **kwargs):
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@csrf_exempt
|
||||
@require_http_methods(["DELETE"])
|
||||
def transaction_delete(request, transaction_id, **kwargs):
|
||||
transaction = get_object_or_404(Transaction, id=transaction_id)
|
||||
transaction = get_object_or_404(Transaction.all_objects, id=transaction_id)
|
||||
|
||||
transaction.delete()
|
||||
|
||||
@@ -158,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"])
|
||||
@@ -173,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"))
|
||||
@@ -187,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})
|
||||
@@ -216,15 +331,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",
|
||||
@@ -234,11 +361,12 @@ def transaction_all_list(request):
|
||||
"account__exchange_currency",
|
||||
"account__currency",
|
||||
"installment_plan",
|
||||
"entities",
|
||||
).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)
|
||||
@@ -268,7 +396,7 @@ def transaction_all_summary(request):
|
||||
"installment_plan",
|
||||
).all()
|
||||
|
||||
f = TransactionsFilter(request.GET, user=request.user, queryset=transactions)
|
||||
f = TransactionsFilter(request.GET, queryset=transactions)
|
||||
|
||||
currency_data = calculate_currency_totals(f.qs.all(), ignore_empty=True)
|
||||
currency_percentages = calculate_percentage_distribution(currency_data)
|
||||
@@ -276,16 +404,98 @@ 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",
|
||||
).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",
|
||||
).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")
|
||||
|
||||
|
||||
def transactions_trash_can_list(request):
|
||||
transactions = Transaction.deleted_objects.prefetch_related(
|
||||
"account",
|
||||
"account__group",
|
||||
"category",
|
||||
"tags",
|
||||
"account__exchange_currency",
|
||||
"account__currency",
|
||||
"installment_plan",
|
||||
).all()
|
||||
|
||||
return render(
|
||||
request,
|
||||
"transactions/fragments/trash_list.html",
|
||||
{"transactions": transactions},
|
||||
)
|
||||
|
||||
@@ -81,11 +81,25 @@ class UserSettingsForm(forms.ModelForm):
|
||||
("Y.m.d h:i A", "2025.01.20 03:30 PM"),
|
||||
]
|
||||
|
||||
NUMBER_FORMAT_CHOICES = [
|
||||
("AA", _("Default")),
|
||||
("DC", "1.234,50"),
|
||||
("CD", "1,234.50"),
|
||||
]
|
||||
|
||||
date_format = forms.ChoiceField(
|
||||
choices=DATE_FORMAT_CHOICES, initial="SHORT_DATE_FORMAT"
|
||||
choices=DATE_FORMAT_CHOICES, initial="SHORT_DATE_FORMAT", label=_("Date Format")
|
||||
)
|
||||
datetime_format = forms.ChoiceField(
|
||||
choices=DATETIME_FORMAT_CHOICES, initial="SHORT_DATETIME_FORMAT"
|
||||
choices=DATETIME_FORMAT_CHOICES,
|
||||
initial="SHORT_DATETIME_FORMAT",
|
||||
label=_("Datetime Format"),
|
||||
)
|
||||
|
||||
number_format = forms.ChoiceField(
|
||||
choices=NUMBER_FORMAT_CHOICES,
|
||||
initial="AA",
|
||||
label=_("Number Format"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -96,6 +110,7 @@ class UserSettingsForm(forms.ModelForm):
|
||||
"start_page",
|
||||
"date_format",
|
||||
"datetime_format",
|
||||
"number_format",
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -109,6 +124,7 @@ class UserSettingsForm(forms.ModelForm):
|
||||
"timezone",
|
||||
"date_format",
|
||||
"datetime_format",
|
||||
"number_format",
|
||||
"start_page",
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.1.5 on 2025-01-23 03:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0013_usersettings_date_format_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='usersettings',
|
||||
name='date_format',
|
||||
field=models.CharField(default='SHORT_DATE_FORMAT', max_length=100, verbose_name='Date Format'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='usersettings',
|
||||
name='datetime_format',
|
||||
field=models.CharField(default='SHORT_DATETIME_FORMAT', max_length=100, verbose_name='Datetime Format'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='usersettings',
|
||||
name='language',
|
||||
field=models.CharField(choices=[('auto', 'Auto'), ('en', 'English'), ('nl', 'Nederlands'), ('pt-br', 'Português (Brasil)')], default='auto', max_length=10, verbose_name='Language'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.5 on 2025-01-24 19:29
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0014_alter_usersettings_date_format_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='usersettings',
|
||||
name='language',
|
||||
field=models.CharField(choices=[('auto', 'Auto'), ('en', 'English'), ('pt-br', 'Português (Brasil)')], default='auto', max_length=10, verbose_name='Language'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.5 on 2025-01-25 18:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0015_alter_usersettings_language'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='usersettings',
|
||||
name='language',
|
||||
field=models.CharField(choices=[('auto', 'Auto'), ('en', 'English'), ('nl', 'Nederlands'), ('pt-br', 'Português (Brasil)')], default='auto', max_length=10, verbose_name='Language'),
|
||||
),
|
||||
]
|
||||
18
app/apps/users/migrations/0017_usersettings_number_format.py
Normal file
18
app/apps/users/migrations/0017_usersettings_number_format.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.5 on 2025-01-27 12:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0016_alter_usersettings_language'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='usersettings',
|
||||
name='number_format',
|
||||
field=models.CharField(default='AA', max_length=2, verbose_name='Number Format'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.5 on 2025-02-02 02:56
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0017_usersettings_number_format'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='usersettings',
|
||||
name='start_page',
|
||||
field=models.CharField(choices=[('MONTHLY_OVERVIEW', 'Monthly'), ('YEARLY_OVERVIEW_CURRENCY', 'Yearly by currency'), ('YEARLY_OVERVIEW_ACCOUNT', 'Yearly by account'), ('NETWORTH_CURRENT', 'Current Net Worth'), ('NETWORTH_PROJECTED', 'Projected Net Worth'), ('ALL_TRANSACTIONS', 'All Transactions'), ('CALENDAR', 'Calendar')], default='MONTHLY_OVERVIEW', max_length=255, verbose_name='Start page'),
|
||||
),
|
||||
]
|
||||
@@ -1,8 +1,8 @@
|
||||
import pytz
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import AbstractUser, Group
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.users.managers import UserManager
|
||||
@@ -26,7 +26,8 @@ class UserSettings(models.Model):
|
||||
MONTHLY = "MONTHLY_OVERVIEW", _("Monthly")
|
||||
YEARLY_CURRENCY = "YEARLY_OVERVIEW_CURRENCY", _("Yearly by currency")
|
||||
YEARLY_ACCOUNT = "YEARLY_OVERVIEW_ACCOUNT", _("Yearly by account")
|
||||
NETWORTH = "NETWORTH", _("Net Worth")
|
||||
NETWORTH_CURRENT = "NETWORTH_CURRENT", _("Current Net Worth")
|
||||
NETWORTH_PROJECTED = "NETWORTH_PROJECTED", _("Projected Net Worth")
|
||||
ALL_TRANSACTIONS = "ALL_TRANSACTIONS", _("All Transactions")
|
||||
CALENDAR = "CALENDAR", _("Calendar")
|
||||
|
||||
@@ -36,8 +37,17 @@ class UserSettings(models.Model):
|
||||
hide_amounts = models.BooleanField(default=False)
|
||||
mute_sounds = models.BooleanField(default=False)
|
||||
|
||||
date_format = models.CharField(max_length=100, default="SHORT_DATE_FORMAT")
|
||||
datetime_format = models.CharField(max_length=100, default="SHORT_DATETIME_FORMAT")
|
||||
date_format = models.CharField(
|
||||
max_length=100, default="SHORT_DATE_FORMAT", verbose_name=_("Date Format")
|
||||
)
|
||||
datetime_format = models.CharField(
|
||||
max_length=100,
|
||||
default="SHORT_DATETIME_FORMAT",
|
||||
verbose_name=_("Datetime Format"),
|
||||
)
|
||||
number_format = models.CharField(
|
||||
max_length=2, default="AA", verbose_name=_("Number Format")
|
||||
)
|
||||
|
||||
language = models.CharField(
|
||||
max_length=10,
|
||||
@@ -60,3 +70,6 @@ class UserSettings(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.email}'s settings"
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
@@ -26,10 +26,14 @@ def logout_view(request):
|
||||
def index(request):
|
||||
if request.user.settings.start_page == UserSettings.StartPage.MONTHLY:
|
||||
return redirect(reverse("monthly_index"))
|
||||
elif request.user.settings.start_page == UserSettings.StartPage.YEARLY:
|
||||
return redirect(reverse("yearly_index"))
|
||||
elif request.user.settings.start_page == UserSettings.StartPage.NETWORTH:
|
||||
return redirect(reverse("net_worth"))
|
||||
elif request.user.settings.start_page == UserSettings.StartPage.YEARLY_ACCOUNT:
|
||||
return redirect(reverse("yearly_index_account"))
|
||||
elif request.user.settings.start_page == UserSettings.StartPage.YEARLY_CURRENCY:
|
||||
return redirect(reverse("yearly_index_currency"))
|
||||
elif request.user.settings.start_page == UserSettings.StartPage.NETWORTH_CURRENT:
|
||||
return redirect(reverse("net_worth_current"))
|
||||
elif request.user.settings.start_page == UserSettings.StartPage.NETWORTH_PROJECTED:
|
||||
return redirect(reverse("net_worth_projected"))
|
||||
elif request.user.settings.start_page == UserSettings.StartPage.ALL_TRANSACTIONS:
|
||||
return redirect(reverse("transactions_all_index"))
|
||||
elif request.user.settings.start_page == UserSettings.StartPage.CALENDAR:
|
||||
|
||||
@@ -89,7 +89,6 @@ def yearly_overview_by_currency(request, year: int):
|
||||
"year": year,
|
||||
"totals": data,
|
||||
"percentages": percentages,
|
||||
"single": True if currency else False,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -159,6 +158,5 @@ def yearly_overview_by_account(request, year: int):
|
||||
"year": year,
|
||||
"totals": data,
|
||||
"percentages": percentages,
|
||||
"single": True if account else False,
|
||||
},
|
||||
)
|
||||
|
||||
0
app/import_presets/.gitkeep
Normal file
0
app/import_presets/.gitkeep
Normal file
54
app/import_presets/nuconta/config.yml
Normal file
54
app/import_presets/nuconta/config.yml
Normal file
@@ -0,0 +1,54 @@
|
||||
settings:
|
||||
file_type: csv
|
||||
delimiter: ","
|
||||
encoding: utf-8
|
||||
skip_lines: 0
|
||||
importing: transactions
|
||||
trigger_transaction_rules: true
|
||||
skip_errors: true
|
||||
|
||||
mapping:
|
||||
account:
|
||||
target: account
|
||||
default: <NOME DA SUA CONTA>
|
||||
type: name
|
||||
|
||||
date:
|
||||
target: date
|
||||
source: Data
|
||||
format: "%d/%m/%Y"
|
||||
|
||||
amount:
|
||||
target: amount
|
||||
source: Valor
|
||||
|
||||
description:
|
||||
target: description
|
||||
source: Descrição
|
||||
transformations:
|
||||
- type: split
|
||||
separator: " - "
|
||||
index: 0
|
||||
|
||||
type:
|
||||
source: "Valor"
|
||||
target: "type"
|
||||
detection_method: sign
|
||||
|
||||
notes:
|
||||
target: notes
|
||||
source: Notes
|
||||
|
||||
internal_id:
|
||||
target: internal_id
|
||||
source: Identificador
|
||||
|
||||
is_paid:
|
||||
target: is_paid
|
||||
detection_method: always_paid
|
||||
|
||||
deduplicate:
|
||||
- type: compare
|
||||
fields:
|
||||
- internal_id
|
||||
match_type: lax
|
||||
7
app/import_presets/nuconta/manifest.json
Normal file
7
app/import_presets/nuconta/manifest.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"author": "eitchtee",
|
||||
"description": "Importe suas transações da conta corrente do Nubank",
|
||||
"schema_version": 1,
|
||||
"name": "Nubank - Conta Corrente",
|
||||
"message": "Mude '<NOME DA SUA CONTA>' para o nome da sua Nuconta dentro do WYGIWYH"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user