Compare commits
189 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
b18273a562 | ||
|
|
60fe4c9681 | ||
|
|
0fccdbe573 | ||
|
|
b9810ce062 | ||
|
|
4cc32e3f57 | ||
|
|
8db13b082b | ||
|
|
e73e1dfc25 | ||
|
|
ae91c51967 | ||
|
|
3ef6b0ac5c | ||
|
|
ba0c54767c | ||
|
|
2d8864773c | ||
|
|
f96d8d2862 | ||
|
|
3ccb0e19eb | ||
|
|
238f205513 | ||
|
|
a94e0b4904 | ||
|
|
86dac632c4 | ||
|
|
f68e954bc0 | ||
|
|
404036bafa | ||
|
|
5e8074ea01 | ||
|
|
c9cc942a10 | ||
|
|
315f4e1269 | ||
|
|
fbb26b8442 | ||
|
|
c171e0419a | ||
|
|
b025ab7d24 | ||
|
|
e2134e98a5 | ||
|
|
3f250338a3 | ||
|
|
97c6b13d57 | ||
|
|
3dcee4dbf2 | ||
|
|
09d14b44fe | ||
|
|
a5b78f7c83 | ||
|
|
9543881aae | ||
|
|
6955294283 | ||
|
|
2b6a73af18 | ||
|
|
526c2cb191 | ||
|
|
4fe62244cd | ||
|
|
011e926e02 | ||
|
|
cd1b872b27 | ||
|
|
3791edce63 | ||
|
|
2cb8100129 | ||
|
|
e7e4ccafb6 | ||
|
|
afbbf7b25d | ||
|
|
1eba2b8731 | ||
|
|
afe366c359 | ||
|
|
3ee2bebc5c | ||
|
|
b951e5f069 | ||
|
|
4005a83a0d | ||
|
|
f81f1d83fd | ||
|
|
7816d6c55d | ||
|
|
6e3fdae4fe | ||
|
|
e2da996217 | ||
|
|
cc2e2293ed | ||
|
|
7060f07ccd | ||
|
|
0adb991879 | ||
|
|
20e03df661 | ||
|
|
71f59bfd68 | ||
|
|
6c76535f91 | ||
|
|
5c8fbc9278 | ||
|
|
89b11421c2 | ||
|
|
056fc4fced | ||
|
|
3f9765ec7b | ||
|
|
0d9d13bf31 |
@@ -9,7 +9,6 @@ SECRET_KEY=<GENERATE A SAFE SECRET KEY AND PLACE IT HERE>
|
|||||||
DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1]
|
DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1]
|
||||||
OUTBOUND_PORT=9005
|
OUTBOUND_PORT=9005
|
||||||
|
|
||||||
SQL_ENGINE=django.db.backends.postgresql
|
|
||||||
SQL_DATABASE=wygiwyh
|
SQL_DATABASE=wygiwyh
|
||||||
SQL_USER=wygiwyh
|
SQL_USER=wygiwyh
|
||||||
SQL_PASSWORD=<INSERT A SAFE PASSWORD HERE>
|
SQL_PASSWORD=<INSERT A SAFE PASSWORD HERE>
|
||||||
@@ -18,3 +17,9 @@ SQL_PORT=5432
|
|||||||
|
|
||||||
# Gunicorn
|
# Gunicorn
|
||||||
WEB_CONCURRENCY=4
|
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
|
||||||
|
|||||||
BIN
.github/img/all_transactions.png
vendored
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
.github/img/calendar.png
vendored
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
.github/img/monthly_view.png
vendored
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
.github/img/networth.png
vendored
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
.github/img/yearly.png
vendored
Normal file
|
After Width: | Height: | Size: 32 KiB |
273
README.md
@@ -6,17 +6,21 @@
|
|||||||
<br>
|
<br>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<h4 align="center">An optionated and powerful finance tracker.</h4>
|
<h4 align="center">An opinionated and powerful finance tracker.</h4>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="#why-wygiwyh">Why</a> •
|
<a href="#why-wygiwyh">Why</a> •
|
||||||
<a href="#key-features">Features</a> •
|
<a href="#key-features">Features</a> •
|
||||||
<a href="#how-to-use">Usage</a> •
|
<a href="#how-to-use">Usage</a> •
|
||||||
<a href="#how-it-works">How</a>
|
<a href="#how-it-works">How</a> •
|
||||||
|
<a href="#caveats-and-warnings">Caveats and Warnings</a> •
|
||||||
|
<a href="#built-with">Built with</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**WYGIWYH** (_What You Get Is What You Have_) is a powerful, principles-first finance tracker designed for people who prefer a no-budget, straightforward approach to managing their money. With features like multi-currency support, customizable transactions, and a built-in dollar-cost averaging tracker, WYGIWYH helps you take control of your finances with simplicity and flexibility.
|
**WYGIWYH** (_What You Get Is What You Have_) is a powerful, principles-first finance tracker designed for people who prefer a no-budget, straightforward approach to managing their money. With features like multi-currency support, customizable transactions, and a built-in dollar-cost averaging tracker, WYGIWYH helps you take control of your finances with simplicity and flexibility.
|
||||||
|
|
||||||
|
<img src=".github/img/monthly_view.png" width="18%"></img> <img src=".github/img/yearly.png" width="18%"></img> <img src=".github/img/networth.png" width="18%"></img> <img src=".github/img/calendar.png" width="18%"></img> <img src=".github/img/all_transactions.png" width="18%"></img>
|
||||||
|
|
||||||
# Why WYGIWYH?
|
# Why WYGIWYH?
|
||||||
Managing money can feel unnecessarily complex, but it doesn’t have to be. WYGIWYH (pronounced "wiggy-wih") is based on a simple principle:
|
Managing money can feel unnecessarily complex, but it doesn’t have to be. WYGIWYH (pronounced "wiggy-wih") is based on a simple principle:
|
||||||
|
|
||||||
@@ -53,10 +57,10 @@ To run this application, you'll need [Docker](https://docs.docker.com/engine/ins
|
|||||||
From your command line:
|
From your command line:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone this repository
|
# Create a folder for WYGIWYH (optional)
|
||||||
$ mkdir WYGIWYH
|
$ mkdir WYGIWYH
|
||||||
|
|
||||||
# Go into the repository
|
# Go into the folder
|
||||||
$ cd WYGIWYH
|
$ cd WYGIWYH
|
||||||
|
|
||||||
$ touch docker-compose.yml
|
$ touch docker-compose.yml
|
||||||
@@ -75,6 +79,33 @@ $ docker compose up -d
|
|||||||
$ docker compose exec -it web python manage.py createsuperuser
|
$ docker compose exec -it web python manage.py createsuperuser
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Running locally
|
||||||
|
|
||||||
|
If you want to run WYGIWYH locally, on your env file:
|
||||||
|
|
||||||
|
1. Remove `URL`
|
||||||
|
2. Set `HTTPS_ENABLED` to `false`
|
||||||
|
3. Leave the default `DJANGO_ALLOWED_HOSTS` (localhost 127.0.0.1 [::1])
|
||||||
|
|
||||||
|
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://`
|
||||||
|
|
||||||
|
## Building from source
|
||||||
|
Features are only added to main when ready, if you want to run the latest version, you must build from source.
|
||||||
|
|
||||||
|
All the required Dockerfiles are [here](https://github.com/eitchtee/WYGIWYH/tree/main/docker/prod).
|
||||||
|
|
||||||
|
## Unraid
|
||||||
|
|
||||||
|
[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.
|
||||||
|
|
||||||
|
WYGIWYH and WYGIWYH--Procrastinate should be available on the Unraid Store. You need both for all features.
|
||||||
|
|
||||||
# How it works
|
# How it works
|
||||||
|
|
||||||
## Models
|
## Models
|
||||||
@@ -210,35 +241,61 @@ A Recurring Transaction is a helper model that generates recurring transactions
|
|||||||
|
|
||||||
### Account
|
### Account
|
||||||
|
|
||||||
TO-DO
|
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
|
||||||
|
|
||||||
TO-DO
|
Account Groups are used to organize accounts into logical categories. They consist of:
|
||||||
|
|
||||||
|
- **Name**: A unique identifier for the group.
|
||||||
|
|
||||||
### Currency
|
### Currency
|
||||||
|
|
||||||
TO-DO
|
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 Rate
|
||||||
|
|
||||||
TO-DO
|
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
|
### Category
|
||||||
|
|
||||||
TO-DO
|
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
|
### Tag
|
||||||
|
|
||||||
TO-DO
|
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
|
### Entity
|
||||||
|
|
||||||
TO-DO
|
Entities represent parties involved in transactions:
|
||||||
|
|
||||||
### Rule
|
* **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.
|
||||||
TO-DO
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -264,37 +321,98 @@ This can be useful for savings accounts or other interest accruing investments.!
|
|||||||
|
|
||||||
### Monthly
|
### Monthly
|
||||||
|
|
||||||
TO-DO
|
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
|
### Yearly by currency
|
||||||
|
|
||||||
TO-DO
|
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
|
### Yearly by account
|
||||||
|
|
||||||
TO-DO
|
Similar to the [yearly by currency](#yearly-by-currency) view, but groups the data by account instead.
|
||||||
|
|
||||||
### Calendar
|
### Calendar
|
||||||
|
|
||||||
TO-DO
|
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
|
### Networh
|
||||||
|
|
||||||
#### Current
|
#### Current
|
||||||
|
|
||||||
TO-DO
|
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
|
#### Projected
|
||||||
|
|
||||||
TO-DO
|
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
|
### All Transactions
|
||||||
|
|
||||||
TO-DO
|
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
|
### Configuration and Management
|
||||||
|
|
||||||
TO-DO
|
#### 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -302,7 +420,7 @@ TO-DO
|
|||||||
|
|
||||||
### Calculator
|
### Calculator
|
||||||
|
|
||||||
The calculator is a floating widget that can be toggled by clicking the calculator icon on the navbar.
|
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).
|
It allows for any math expression supported by [math.js](https://mathjs.org).
|
||||||
|
|
||||||
@@ -336,16 +454,109 @@ You can add additional items by clicking the _Add_ button at the end of the page
|
|||||||
|
|
||||||
### Currency Converter
|
### Currency Converter
|
||||||
|
|
||||||
TO-DO
|
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.
|
||||||
|
- Pretty much all calculations are done at run time, this can lead to some performance degradation. On my personal instance, I have 3000+ transactions over 4+ years and 4000+ exchange rates, and load times average at around 500ms for each page, not bad overall.
|
||||||
|
- This isn't a budgeting or double-entry-accounting application, if you need those features there's a lot of options out there, if you really need them in WYGIWYH, open a discussion.
|
||||||
|
|
||||||
# Built with
|
# Built with
|
||||||
|
|
||||||
WYGIWYH is possible thanks to a lot of amazing open source tools, to name a few:
|
WYGIWYH is possible thanks to a lot of amazing open source tools, to name a few:
|
||||||
|
|
||||||
- Django
|
* Django
|
||||||
- HTMX
|
* HTMX
|
||||||
- _hyperscript
|
* _hyperscript
|
||||||
- Procrastinate
|
* Procrastinate
|
||||||
- Bootstrap
|
* Bootstrap
|
||||||
- Tailwind
|
* Tailwind
|
||||||
- Webpack
|
* Webpack
|
||||||
|
* PostgreSQL
|
||||||
|
* Django REST framework
|
||||||
|
* Alpine.js
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ ROOT_DIR = Path(__file__).resolve().parent.parent.parent
|
|||||||
# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/
|
# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/
|
||||||
|
|
||||||
# SECURITY WARNING: keep the secret key used in production secret!
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
SECRET_KEY = "django-insecure-##6^&g49xwn7s67xc&33vf&=*4ibqfzn#xa*p-1sy8ag+zjjb9"
|
SECRET_KEY = os.getenv("SECRET_KEY", "")
|
||||||
|
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
|
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
|
||||||
@@ -64,6 +64,7 @@ INSTALLED_APPS = [
|
|||||||
"apps.accounts.apps.AccountsConfig",
|
"apps.accounts.apps.AccountsConfig",
|
||||||
"apps.common.apps.CommonConfig",
|
"apps.common.apps.CommonConfig",
|
||||||
"apps.net_worth.apps.NetWorthConfig",
|
"apps.net_worth.apps.NetWorthConfig",
|
||||||
|
"apps.import_app.apps.ImportConfig",
|
||||||
"apps.api.apps.ApiConfig",
|
"apps.api.apps.ApiConfig",
|
||||||
"cachalot",
|
"cachalot",
|
||||||
"rest_framework",
|
"rest_framework",
|
||||||
@@ -72,9 +73,11 @@ INSTALLED_APPS = [
|
|||||||
"apps.rules.apps.RulesConfig",
|
"apps.rules.apps.RulesConfig",
|
||||||
"apps.calendar_view.apps.CalendarViewConfig",
|
"apps.calendar_view.apps.CalendarViewConfig",
|
||||||
"apps.dca.apps.DcaConfig",
|
"apps.dca.apps.DcaConfig",
|
||||||
|
"pwa",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
"apps.common.middleware.thread_local.ThreadLocalMiddleware",
|
||||||
"debug_toolbar.middleware.DebugToolbarMiddleware",
|
"debug_toolbar.middleware.DebugToolbarMiddleware",
|
||||||
"django.middleware.security.SecurityMiddleware",
|
"django.middleware.security.SecurityMiddleware",
|
||||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||||
@@ -124,12 +127,12 @@ WSGI_APPLICATION = "WYGIWYH.wsgi.application"
|
|||||||
|
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
"default": {
|
"default": {
|
||||||
"ENGINE": os.environ.get("SQL_ENGINE", "django.db.backends.sqlite3"),
|
"ENGINE": "django.db.backends.postgresql",
|
||||||
"NAME": os.environ.get("SQL_DATABASE", BASE_DIR / "db.sqlite3"),
|
"NAME": os.environ.get("SQL_DATABASE"),
|
||||||
"USER": os.environ.get("SQL_USER", "user"),
|
"USER": os.environ.get("SQL_USER", "user"),
|
||||||
"PASSWORD": os.environ.get("SQL_PASSWORD", "password"),
|
"PASSWORD": os.environ.get("SQL_PASSWORD", "password"),
|
||||||
"HOST": os.environ.get("SQL_HOST", "localhost"),
|
"HOST": os.environ.get("SQL_HOST", "localhost"),
|
||||||
"PORT": "5432",
|
"PORT": os.environ.get("SQL_PORT", "5432"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,6 +164,7 @@ AUTH_USER_MODEL = "users.User"
|
|||||||
LANGUAGE_CODE = "en"
|
LANGUAGE_CODE = "en"
|
||||||
LANGUAGES = (
|
LANGUAGES = (
|
||||||
("en", "English"),
|
("en", "English"),
|
||||||
|
("nl", "Nederlands"),
|
||||||
("pt-br", "Português (Brasil)"),
|
("pt-br", "Português (Brasil)"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -334,3 +338,53 @@ else:
|
|||||||
}
|
}
|
||||||
|
|
||||||
CACHALOT_UNCACHABLE_TABLES = ("django_migrations", "procrastinate_jobs")
|
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"))
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ urlpatterns = [
|
|||||||
path("hijack/", include("hijack.urls")),
|
path("hijack/", include("hijack.urls")),
|
||||||
path("__debug__/", include("debug_toolbar.urls")),
|
path("__debug__/", include("debug_toolbar.urls")),
|
||||||
path("__reload__/", include("django_browser_reload.urls")),
|
path("__reload__/", include("django_browser_reload.urls")),
|
||||||
|
path("", include("pwa.urls")),
|
||||||
# path("api/", include("rest_framework.urls")),
|
# path("api/", include("rest_framework.urls")),
|
||||||
path("api/", include("apps.api.urls")),
|
path("api/", include("apps.api.urls")),
|
||||||
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
|
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
|
||||||
@@ -47,4 +48,5 @@ urlpatterns = [
|
|||||||
path("", include("apps.calendar_view.urls")),
|
path("", include("apps.calendar_view.urls")),
|
||||||
path("", include("apps.dca.urls")),
|
path("", include("apps.dca.urls")),
|
||||||
path("", include("apps.mini_tools.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
@@ -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):
|
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(
|
group = models.ForeignKey(
|
||||||
AccountGroup,
|
AccountGroup,
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ from django.contrib import messages
|
|||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.shortcuts import render, get_object_or_404
|
from django.shortcuts import render, get_object_or_404
|
||||||
from django.urls import reverse
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
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 django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
from apps.accounts.forms import AccountGroupForm
|
from apps.accounts.forms import AccountGroupForm
|
||||||
@@ -89,7 +87,6 @@ def account_group_edit(request, pk):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
@csrf_exempt
|
|
||||||
@require_http_methods(["DELETE"])
|
@require_http_methods(["DELETE"])
|
||||||
def account_group_delete(request, pk):
|
def account_group_delete(request, pk):
|
||||||
account_group = get_object_or_404(AccountGroup, id=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.contrib.auth.decorators import login_required
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.shortcuts import render, get_object_or_404
|
from django.shortcuts import render, get_object_or_404
|
||||||
from django.urls import reverse
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
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 django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
from apps.accounts.forms import AccountForm
|
from apps.accounts.forms import AccountForm
|
||||||
@@ -89,7 +87,6 @@ def account_edit(request, pk):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
@csrf_exempt
|
|
||||||
@require_http_methods(["DELETE"])
|
@require_http_methods(["DELETE"])
|
||||||
def account_delete(request, pk):
|
def account_delete(request, pk):
|
||||||
account = get_object_or_404(Account, id=pk)
|
account = get_object_or_404(Account, id=pk)
|
||||||
|
|||||||
@@ -120,6 +120,11 @@ class RecurringTransactionSerializer(serializers.ModelSerializer):
|
|||||||
instance.create_upcoming_transactions()
|
instance.create_upcoming_transactions()
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
instance = super().update(instance, validated_data)
|
||||||
|
instance.update_unpaid_transactions()
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
class TransactionSerializer(serializers.ModelSerializer):
|
class TransactionSerializer(serializers.ModelSerializer):
|
||||||
category = TransactionCategoryField(required=False)
|
category = TransactionCategoryField(required=False)
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ class DynamicModelChoiceField(forms.ModelChoiceField):
|
|||||||
self._created_instance = instance
|
self._created_instance = instance
|
||||||
return instance
|
return instance
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(e)
|
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
self.error_messages["invalid_choice"], code="invalid_choice"
|
self.error_messages["invalid_choice"], code="invalid_choice"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.db import models
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.db import models
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from apps.common.widgets.datepicker import AirMonthYearPickerInput
|
||||||
from apps.common.widgets.month_year import MonthYearWidget
|
from apps.common.widgets.month_year import MonthYearWidget
|
||||||
|
|
||||||
|
|
||||||
@@ -27,7 +29,7 @@ class MonthYearModelField(models.DateField):
|
|||||||
|
|
||||||
|
|
||||||
class MonthYearFormField(forms.DateField):
|
class MonthYearFormField(forms.DateField):
|
||||||
widget = MonthYearWidget
|
widget = AirMonthYearPickerInput
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
@@ -42,7 +44,11 @@ class MonthYearFormField(forms.DateField):
|
|||||||
date = datetime.datetime.strptime(value, "%Y-%m")
|
date = datetime.datetime.strptime(value, "%Y-%m")
|
||||||
return date.replace(day=1).date()
|
return date.replace(day=1).date()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise ValidationError(_("Invalid date format. Use YYYY-MM."))
|
try:
|
||||||
|
date = datetime.datetime.strptime(value, "%Y-%m-%d")
|
||||||
|
return date.replace(day=1).date()
|
||||||
|
except ValueError:
|
||||||
|
raise ValidationError(_("Invalid date format. Use YYYY-MM."))
|
||||||
|
|
||||||
def prepare_value(self, value):
|
def prepare_value(self, value):
|
||||||
if isinstance(value, datetime.date):
|
if isinstance(value, datetime.date):
|
||||||
|
|||||||
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
|
import zoneinfo
|
||||||
|
|
||||||
|
from django.utils import formats
|
||||||
from django.utils import timezone, translation
|
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
|
from apps.users.models import UserSettings
|
||||||
|
|
||||||
|
|
||||||
class LocalizationMiddleware:
|
class LocalizationMiddleware:
|
||||||
def __init__(self, get_response):
|
def __init__(self, get_response):
|
||||||
self.get_response = get_response
|
self.get_response = get_response
|
||||||
|
self.patch_get_format()
|
||||||
|
|
||||||
def __call__(self, request):
|
def __call__(self, request):
|
||||||
tz = request.COOKIES.get("mytz")
|
tz = request.COOKIES.get("mytz")
|
||||||
@@ -33,9 +36,14 @@ class LocalizationMiddleware:
|
|||||||
timezone.activate(zoneinfo.ZoneInfo("UTC"))
|
timezone.activate(zoneinfo.ZoneInfo("UTC"))
|
||||||
|
|
||||||
if user_language and user_language != "auto":
|
if user_language and user_language != "auto":
|
||||||
activate(user_language)
|
translation.activate(user_language)
|
||||||
else:
|
else:
|
||||||
detected_language = translation.get_language_from_request(request)
|
detected_language = translation.get_language_from_request(request)
|
||||||
activate(detected_language)
|
translation.activate(detected_language)
|
||||||
|
|
||||||
return self.get_response(request)
|
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
@@ -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,6 +1,6 @@
|
|||||||
from django import template
|
from django import template
|
||||||
from django.utils.formats import get_format
|
|
||||||
|
|
||||||
|
from apps.common.functions.format import get_format
|
||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
|
|
||||||
|
|||||||
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)
|
||||||
@@ -13,4 +13,9 @@ urlpatterns = [
|
|||||||
views.month_year_picker,
|
views.month_year_picker,
|
||||||
name="month_year_picker",
|
name="month_year_picker",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"cache/invalidate/",
|
||||||
|
views.invalidate_cache,
|
||||||
|
name="invalidate_cache",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
161
app/apps/common/utils/django.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
def django_to_python_datetime(django_format):
|
||||||
|
mapping = {
|
||||||
|
# Day
|
||||||
|
"j": "%d", # Day of the month without leading zeros
|
||||||
|
"d": "%d", # Day of the month with leading zeros
|
||||||
|
"D": "%a", # Day of the week, short version
|
||||||
|
"l": "%A", # Day of the week, full version
|
||||||
|
# Month
|
||||||
|
"n": "%m", # Month without leading zeros
|
||||||
|
"m": "%m", # Month with leading zeros
|
||||||
|
"M": "%b", # Month, short version
|
||||||
|
"F": "%B", # Month, full version
|
||||||
|
# Year
|
||||||
|
"y": "%y", # Year, 2 digits
|
||||||
|
"Y": "%Y", # Year, 4 digits
|
||||||
|
# Time
|
||||||
|
"g": "%I", # Hour (12-hour), without leading zeros
|
||||||
|
"G": "%H", # Hour (24-hour), without leading zeros
|
||||||
|
"h": "%I", # Hour (12-hour), with leading zeros
|
||||||
|
"H": "%H", # Hour (24-hour), with leading zeros
|
||||||
|
"i": "%M", # Minutes
|
||||||
|
"s": "%S", # Seconds
|
||||||
|
"a": "%p", # am/pm
|
||||||
|
"A": "%p", # AM/PM
|
||||||
|
"P": "%I:%M %p",
|
||||||
|
}
|
||||||
|
|
||||||
|
python_format = django_format
|
||||||
|
for django_code, python_code in mapping.items():
|
||||||
|
python_format = python_format.replace(django_code, python_code)
|
||||||
|
|
||||||
|
return python_format
|
||||||
|
|
||||||
|
|
||||||
|
def django_to_airdatepicker_datetime(django_format):
|
||||||
|
format_map = {
|
||||||
|
# Time
|
||||||
|
"h": "hh", # Hour (12-hour)
|
||||||
|
"H": "H", # Hour (24-hour)
|
||||||
|
"i": "m", # Minutes
|
||||||
|
"A": "AA", # AM/PM uppercase
|
||||||
|
"a": "aa", # am/pm lowercase
|
||||||
|
"P": "h:mm AA", # Localized time format (e.g., "2:30 PM")
|
||||||
|
# Date
|
||||||
|
"D": "E", # Short weekday name
|
||||||
|
"l": "EEEE", # Full weekday name
|
||||||
|
"j": "d", # Day of month without leading zero
|
||||||
|
"d": "dd", # Day of month with leading zero
|
||||||
|
"n": "M", # Month without leading zero
|
||||||
|
"m": "MM", # Month with leading zero
|
||||||
|
"M": "MMM", # Short month name
|
||||||
|
"F": "MMMM", # Full month name
|
||||||
|
"y": "yy", # Year, 2 digits
|
||||||
|
"Y": "yyyy", # Year, 4 digits
|
||||||
|
}
|
||||||
|
|
||||||
|
result = ""
|
||||||
|
i = 0
|
||||||
|
while i < len(django_format):
|
||||||
|
char = django_format[i]
|
||||||
|
if char == "\\": # Handle escaped characters
|
||||||
|
if i + 1 < len(django_format):
|
||||||
|
result += django_format[i + 1]
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
|
||||||
|
if char in format_map:
|
||||||
|
result += format_map[char]
|
||||||
|
else:
|
||||||
|
result += char
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def django_to_airdatepicker_datetime_separated(django_format):
|
||||||
|
format_map = {
|
||||||
|
# Time formats
|
||||||
|
"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
|
||||||
|
# Date formats
|
||||||
|
"D": "E", # Short weekday name
|
||||||
|
"l": "EEEE", # Full weekday name
|
||||||
|
"j": "d", # Day of month without leading zero
|
||||||
|
"d": "dd", # Day of month with leading zero
|
||||||
|
"n": "M", # Month without leading zero
|
||||||
|
"m": "MM", # Month with leading zero
|
||||||
|
"M": "MMM", # Short month name
|
||||||
|
"F": "MMMM", # Full month name
|
||||||
|
"y": "yy", # Year, 2 digits
|
||||||
|
"Y": "yyyy", # Year, 4 digits
|
||||||
|
}
|
||||||
|
|
||||||
|
# Define which characters belong to time format
|
||||||
|
time_chars = {"h", "H", "i", "A", "a", "P"}
|
||||||
|
date_chars = {"D", "l", "j", "d", "n", "m", "M", "F", "y", "Y"}
|
||||||
|
|
||||||
|
date_parts = []
|
||||||
|
time_parts = []
|
||||||
|
current_part = []
|
||||||
|
is_time = False
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
while i < len(django_format):
|
||||||
|
char = django_format[i]
|
||||||
|
|
||||||
|
if char == "\\": # Handle escaped characters
|
||||||
|
if i + 1 < len(django_format):
|
||||||
|
current_part.append(django_format[i + 1])
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
|
||||||
|
if char in format_map:
|
||||||
|
if char in time_chars:
|
||||||
|
# If we were building a date part, save it and start a time part
|
||||||
|
if current_part and not is_time:
|
||||||
|
date_parts.append("".join(current_part))
|
||||||
|
current_part = []
|
||||||
|
is_time = True
|
||||||
|
current_part.append(format_map[char])
|
||||||
|
elif char in date_chars:
|
||||||
|
# If we were building a time part, save it and start a date part
|
||||||
|
if current_part and is_time:
|
||||||
|
time_parts.append("".join(current_part))
|
||||||
|
current_part = []
|
||||||
|
is_time = False
|
||||||
|
current_part.append(format_map[char])
|
||||||
|
else:
|
||||||
|
# Handle separators
|
||||||
|
if char in "/:.-":
|
||||||
|
current_part.append(char)
|
||||||
|
elif char == " ":
|
||||||
|
if current_part:
|
||||||
|
if is_time:
|
||||||
|
time_parts.append("".join(current_part))
|
||||||
|
else:
|
||||||
|
date_parts.append("".join(current_part))
|
||||||
|
current_part = []
|
||||||
|
current_part.append(char)
|
||||||
|
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# Don't forget the last part
|
||||||
|
if current_part:
|
||||||
|
if is_time:
|
||||||
|
time_parts.append("".join(current_part))
|
||||||
|
else:
|
||||||
|
date_parts.append("".join(current_part))
|
||||||
|
|
||||||
|
date_format = "".join(date_parts)
|
||||||
|
time_format = "".join(time_parts)
|
||||||
|
|
||||||
|
# Clean up multiple spaces while preserving necessary ones
|
||||||
|
date_format = " ".join(filter(None, date_format.split()))
|
||||||
|
time_format = " ".join(filter(None, time_format.split()))
|
||||||
|
|
||||||
|
return date_format, time_format
|
||||||
@@ -1,17 +1,32 @@
|
|||||||
from dateutil.relativedelta import relativedelta
|
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 import Count
|
||||||
from django.db.models.functions import ExtractYear, ExtractMonth
|
from django.db.models.functions import ExtractYear, ExtractMonth
|
||||||
|
from django.http import HttpResponse
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
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
|
from apps.transactions.models import Transaction
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET"])
|
||||||
def toasts(request):
|
def toasts(request):
|
||||||
return render(request, "common/fragments/toasts.html")
|
return render(request, "common/fragments/toasts.html")
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET"])
|
||||||
def month_year_picker(request):
|
def month_year_picker(request):
|
||||||
field = request.GET.get("field", "reference_date")
|
field = request.GET.get("field", "reference_date")
|
||||||
for_ = request.GET.get("for", None)
|
for_ = request.GET.get("for", None)
|
||||||
@@ -84,3 +99,19 @@ def month_year_picker(request):
|
|||||||
"current_year": current_year,
|
"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",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
229
app/apps/common/widgets/datepicker.py
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import datetime
|
||||||
|
|
||||||
|
from django.forms import widgets
|
||||||
|
from django.utils import formats, translation, dates
|
||||||
|
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):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
attrs=None,
|
||||||
|
format=None,
|
||||||
|
clear_button=True,
|
||||||
|
auto_close=True,
|
||||||
|
*args,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
attrs = attrs or {}
|
||||||
|
super().__init__(attrs=attrs, format=format, *args, **kwargs)
|
||||||
|
self.clear_button = clear_button
|
||||||
|
self.auto_close = auto_close
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_current_language():
|
||||||
|
"""Get current language code in format compatible with AirDatepicker"""
|
||||||
|
lang_code = translation.get_language()
|
||||||
|
# AirDatepicker uses simple language codes
|
||||||
|
return lang_code.split("-")[0]
|
||||||
|
|
||||||
|
def _get_format(self):
|
||||||
|
"""Get the format string based on user settings or default"""
|
||||||
|
if self.format:
|
||||||
|
return self.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()
|
||||||
|
attrs["data-date-format"] = django_to_airdatepicker_datetime(self._get_format())
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
def format_value(self, value):
|
||||||
|
"""Format the value for display in the widget."""
|
||||||
|
if value:
|
||||||
|
self.attrs["data-value"] = value
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(value, (datetime.date, datetime.datetime)):
|
||||||
|
return formats.date_format(value, format=self._get_format(), use_l10n=True)
|
||||||
|
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
def value_from_datadict(self, data, files, name):
|
||||||
|
"""Parse the datetime string from the form data."""
|
||||||
|
value = super().value_from_datadict(data, files, name)
|
||||||
|
if value:
|
||||||
|
try:
|
||||||
|
# This does it's best to convert Django's PHP-Style date format to a datetime format and reformat the
|
||||||
|
# value to be read by Django. Probably could be improved
|
||||||
|
return datetime.datetime.strptime(
|
||||||
|
value.strip(),
|
||||||
|
django_to_python_datetime(self._get_format())
|
||||||
|
or django_to_python_datetime(get_format("SHORT_DATETIME_FORMAT")),
|
||||||
|
).strftime("%Y-%m-%d")
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class AirDateTimePickerInput(widgets.DateTimeInput):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
attrs=None,
|
||||||
|
format=None,
|
||||||
|
timepicker=True,
|
||||||
|
clear_button=True,
|
||||||
|
auto_close=True,
|
||||||
|
*args,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
attrs = attrs or {}
|
||||||
|
super().__init__(attrs=attrs, format=format, *args, **kwargs)
|
||||||
|
self.timepicker = timepicker
|
||||||
|
self.clear_button = clear_button
|
||||||
|
self.auto_close = auto_close
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_current_language():
|
||||||
|
"""Get current language code in format compatible with AirDatepicker"""
|
||||||
|
lang_code = translation.get_language()
|
||||||
|
# AirDatepicker uses simple language codes
|
||||||
|
return lang_code.split("-")[0]
|
||||||
|
|
||||||
|
def _get_format(self):
|
||||||
|
"""Get the format string based on user settings or default"""
|
||||||
|
if self.format:
|
||||||
|
return self.format
|
||||||
|
|
||||||
|
return get_format("SHORT_DATETIME_FORMAT", use_l10n=True)
|
||||||
|
|
||||||
|
def build_attrs(self, base_attrs, extra_attrs=None):
|
||||||
|
attrs = super().build_attrs(base_attrs, extra_attrs)
|
||||||
|
|
||||||
|
date_format, time_format = django_to_airdatepicker_datetime_separated(
|
||||||
|
self._get_format()
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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()
|
||||||
|
attrs["data-language"] = self._get_current_language()
|
||||||
|
attrs["data-date-format"] = date_format
|
||||||
|
attrs["data-time-format"] = time_format
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
def format_value(self, value):
|
||||||
|
"""Format the value for display in the widget."""
|
||||||
|
if value and isinstance(value, (datetime.date, datetime.datetime)):
|
||||||
|
self.attrs["data-value"] = datetime.datetime.strftime(
|
||||||
|
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:
|
||||||
|
return ""
|
||||||
|
if isinstance(value, (datetime.date, datetime.datetime)):
|
||||||
|
return formats.date_format(value, format=self._get_format(), use_l10n=True)
|
||||||
|
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
def value_from_datadict(self, data, files, name):
|
||||||
|
"""Parse the datetime string from the form data."""
|
||||||
|
value = super().value_from_datadict(data, files, name)
|
||||||
|
if value:
|
||||||
|
try:
|
||||||
|
# This does it's best to convert Django's PHP-Style date format to a datetime format and reformat the
|
||||||
|
# value to be read by Django. Probably could be improved
|
||||||
|
return datetime.datetime.strptime(
|
||||||
|
value.strip(),
|
||||||
|
django_to_python_datetime(self._get_format())
|
||||||
|
or django_to_python_datetime(get_format("SHORT_DATETIME_FORMAT")),
|
||||||
|
).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class AirMonthYearPickerInput(AirDatePickerInput):
|
||||||
|
def __init__(self, attrs=None, format=None, *args, **kwargs):
|
||||||
|
super().__init__(attrs=attrs, format=format, *args, **kwargs)
|
||||||
|
# Store the display format for AirDatepicker
|
||||||
|
self.display_format = "MMMM yyyy"
|
||||||
|
# Store the Python format for internal use
|
||||||
|
self.python_format = "%B %Y"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_month_names():
|
||||||
|
"""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:
|
||||||
|
self.attrs["data-value"] = (
|
||||||
|
value # We use this to dynamically select the initial date on AirDatePicker
|
||||||
|
)
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
value = datetime.datetime.strptime(value, "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
return value
|
||||||
|
if isinstance(value, (datetime.datetime, datetime.date)):
|
||||||
|
# Use Django's date translation
|
||||||
|
month_name = dates.MONTHS[value.month]
|
||||||
|
return f"{month_name} {value.year}"
|
||||||
|
return value
|
||||||
|
|
||||||
|
def value_from_datadict(self, data, files, name):
|
||||||
|
"""Convert the value from the widget format back to a format Django can handle."""
|
||||||
|
value = super().value_from_datadict(data, files, name)
|
||||||
|
if value:
|
||||||
|
try:
|
||||||
|
# Split the value into month name and year
|
||||||
|
month_str, year_str = value.rsplit(" ", 1)
|
||||||
|
year = int(year_str)
|
||||||
|
|
||||||
|
# Get month number from translated month name
|
||||||
|
month_names = self._get_month_names()
|
||||||
|
month = month_names.get(month_str)
|
||||||
|
|
||||||
|
if month and year:
|
||||||
|
# Return the first day of the month in Django's expected format
|
||||||
|
return datetime.date(year, month, 1).strftime("%Y-%m-%d")
|
||||||
|
except (ValueError, KeyError):
|
||||||
|
return None
|
||||||
|
return None
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
from django import forms
|
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):
|
def convert_to_decimal(value: str):
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ from django.forms import CharField
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
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.decimal import ArbitraryDecimalDisplayNumberInput
|
||||||
from apps.currencies.models import Currency, ExchangeRate
|
|
||||||
from apps.common.widgets.tom_select import TomSelect
|
from apps.common.widgets.tom_select import TomSelect
|
||||||
|
from apps.currencies.models import Currency, ExchangeRate
|
||||||
|
|
||||||
|
|
||||||
class CurrencyForm(forms.ModelForm):
|
class CurrencyForm(forms.ModelForm):
|
||||||
@@ -64,9 +65,7 @@ class CurrencyForm(forms.ModelForm):
|
|||||||
|
|
||||||
class ExchangeRateForm(forms.ModelForm):
|
class ExchangeRateForm(forms.ModelForm):
|
||||||
date = forms.DateTimeField(
|
date = forms.DateTimeField(
|
||||||
widget=forms.DateTimeInput(
|
label=_("Date"),
|
||||||
attrs={"type": "datetime-local"}, format="%Y-%m-%dT%H:%M"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -82,6 +81,7 @@ class ExchangeRateForm(forms.ModelForm):
|
|||||||
self.helper.layout = Layout("date", "from_currency", "to_currency", "rate")
|
self.helper.layout = Layout("date", "from_currency", "to_currency", "rate")
|
||||||
|
|
||||||
self.fields["rate"].widget = ArbitraryDecimalDisplayNumberInput()
|
self.fields["rate"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||||
|
self.fields["date"].widget = AirDateTimePickerInput(clear_button=False)
|
||||||
|
|
||||||
if self.instance and self.instance.pk:
|
if self.instance and self.instance.pk:
|
||||||
self.helper.layout.append(
|
self.helper.layout.append(
|
||||||
|
|||||||
@@ -72,7 +72,9 @@ class ExchangeRate(models.Model):
|
|||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
super().clean()
|
super().clean()
|
||||||
if self.from_currency == self.to_currency:
|
# Check if the attributes exist before comparing them
|
||||||
raise ValidationError(
|
if hasattr(self, "from_currency") and hasattr(self, "to_currency"):
|
||||||
{"to_currency": _("From and To currencies cannot be the same.")}
|
if self.from_currency == self.to_currency:
|
||||||
)
|
raise ValidationError(
|
||||||
|
{"to_currency": _("From and To currencies cannot be the same.")}
|
||||||
|
)
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ from django.contrib import messages
|
|||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.shortcuts import render, get_object_or_404
|
from django.shortcuts import render, get_object_or_404
|
||||||
from django.urls import reverse
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
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 django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
from apps.common.decorators.htmx import only_htmx
|
from apps.common.decorators.htmx import only_htmx
|
||||||
@@ -89,7 +87,6 @@ def currency_edit(request, pk):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
@csrf_exempt
|
|
||||||
@require_http_methods(["DELETE"])
|
@require_http_methods(["DELETE"])
|
||||||
def currency_delete(request, pk):
|
def currency_delete(request, pk):
|
||||||
currency = get_object_or_404(Currency, id=pk)
|
currency = get_object_or_404(Currency, id=pk)
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.core.paginator import Paginator
|
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.db.models.functions import Concat
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.shortcuts import render, get_object_or_404
|
from django.shortcuts import render, get_object_or_404
|
||||||
from django.utils.translation import gettext_lazy as _
|
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 django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
from apps.common.decorators.htmx import only_htmx
|
from apps.common.decorators.htmx import only_htmx
|
||||||
@@ -135,7 +134,6 @@ def exchange_rate_edit(request, pk):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
@csrf_exempt
|
|
||||||
@require_http_methods(["DELETE"])
|
@require_http_methods(["DELETE"])
|
||||||
def exchange_rate_delete(request, pk):
|
def exchange_rate_delete(request, pk):
|
||||||
exchange_rate = get_object_or_404(ExchangeRate, id=pk)
|
exchange_rate = get_object_or_404(ExchangeRate, id=pk)
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
from crispy_forms.bootstrap import FormActions
|
from crispy_forms.bootstrap import FormActions
|
||||||
from django import forms
|
|
||||||
from crispy_forms.helper import FormHelper
|
from crispy_forms.helper import FormHelper
|
||||||
from crispy_forms.layout import Layout, Row, Column
|
from crispy_forms.layout import Layout, Row, Column
|
||||||
|
from django import forms
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||||
|
from apps.common.widgets.datepicker import AirDatePickerInput
|
||||||
|
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
|
||||||
from apps.common.widgets.tom_select import TomSelect
|
from apps.common.widgets.tom_select import TomSelect
|
||||||
from apps.dca.models import DCAStrategy, DCAEntry
|
from apps.dca.models import DCAStrategy, DCAEntry
|
||||||
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
|
|
||||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
|
||||||
|
|
||||||
|
|
||||||
class DCAStrategyForm(forms.ModelForm):
|
class DCAStrategyForm(forms.ModelForm):
|
||||||
@@ -61,7 +62,6 @@ class DCAEntryForm(forms.ModelForm):
|
|||||||
"notes",
|
"notes",
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
"date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
|
|
||||||
"notes": forms.Textarea(attrs={"rows": 3}),
|
"notes": forms.Textarea(attrs={"rows": 3}),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,3 +106,4 @@ class DCAEntryForm(forms.ModelForm):
|
|||||||
|
|
||||||
self.fields["amount_paid"].widget = ArbitraryDecimalDisplayNumberInput()
|
self.fields["amount_paid"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||||
self.fields["amount_received"].widget = ArbitraryDecimalDisplayNumberInput()
|
self.fields["amount_received"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||||
|
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.http import HttpResponse
|
||||||
from django.shortcuts import render, get_object_or_404
|
from django.shortcuts import render, get_object_or_404
|
||||||
from django.utils.translation import gettext_lazy as _
|
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 django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
from apps.common.decorators.htmx import only_htmx
|
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.forms import DCAEntryForm, DCAStrategyForm
|
||||||
|
from apps.dca.models import DCAStrategy, DCAEntry
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -82,7 +81,6 @@ def strategy_edit(request, strategy_id):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
@csrf_exempt
|
|
||||||
@require_http_methods(["DELETE"])
|
@require_http_methods(["DELETE"])
|
||||||
def strategy_delete(request, strategy_id):
|
def strategy_delete(request, strategy_id):
|
||||||
dca_strategy = get_object_or_404(DCAStrategy, id=strategy_id)
|
dca_strategy = get_object_or_404(DCAStrategy, id=strategy_id)
|
||||||
@@ -209,7 +207,6 @@ def strategy_entry_edit(request, strategy_id, entry_id):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
@csrf_exempt
|
|
||||||
@require_http_methods(["DELETE"])
|
@require_http_methods(["DELETE"])
|
||||||
def strategy_entry_delete(request, entry_id, strategy_id):
|
def strategy_entry_delete(request, entry_id, strategy_id):
|
||||||
dca_entry = get_object_or_404(DCAEntry, id=entry_id, strategy__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
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
@@ -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
@@ -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
@@ -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
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
@@ -0,0 +1 @@
|
|||||||
|
import apps.import_app.schemas.v1 as version_1
|
||||||
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] = 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
@@ -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
@@ -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
|
||||||
632
app/apps/import_app/services/v1.py
Normal file
@@ -0,0 +1,632 @@
|
|||||||
|
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():
|
||||||
|
# If source is None, use None as the initial value
|
||||||
|
value = row.get(mapping.source) if mapping.source else None
|
||||||
|
|
||||||
|
# Use default_value if value is None
|
||||||
|
if value is None:
|
||||||
|
value = mapping.default
|
||||||
|
|
||||||
|
# Apply transformations
|
||||||
|
if mapping.transformations:
|
||||||
|
value = self._transform_value(value, mapping, row)
|
||||||
|
|
||||||
|
value = self._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
@@ -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
|
||||||
|
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
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
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
@@ -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",
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -52,19 +52,4 @@ urlpatterns = [
|
|||||||
views.transaction_rule_action_delete,
|
views.transaction_rule_action_delete,
|
||||||
name="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.http import HttpResponse
|
||||||
from django.shortcuts import render, get_object_or_404, redirect
|
from django.shortcuts import render, get_object_or_404, redirect
|
||||||
from django.utils.translation import gettext_lazy as _
|
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 django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
from apps.common.decorators.htmx import only_htmx
|
from apps.common.decorators.htmx import only_htmx
|
||||||
@@ -118,7 +117,6 @@ def transaction_rule_view(request, transaction_rule_id):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
@csrf_exempt
|
|
||||||
@require_http_methods(["DELETE"])
|
@require_http_methods(["DELETE"])
|
||||||
def transaction_rule_delete(request, transaction_rule_id):
|
def transaction_rule_delete(request, transaction_rule_id):
|
||||||
transaction_rule = get_object_or_404(TransactionRule, id=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
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
@csrf_exempt
|
|
||||||
@require_http_methods(["DELETE"])
|
@require_http_methods(["DELETE"])
|
||||||
def transaction_rule_action_delete(request, transaction_rule_action_id):
|
def transaction_rule_action_delete(request, transaction_rule_action_id):
|
||||||
transaction_rule_action = get_object_or_404(
|
transaction_rule_action = get_object_or_404(
|
||||||
|
|||||||
@@ -12,15 +12,34 @@ from apps.transactions.models import (
|
|||||||
|
|
||||||
@admin.register(Transaction)
|
@admin.register(Transaction)
|
||||||
class TransactionModelAdmin(admin.ModelAdmin):
|
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 = [
|
list_display = [
|
||||||
|
"date",
|
||||||
"description",
|
"description",
|
||||||
"type",
|
"type",
|
||||||
"account__name",
|
"account__name",
|
||||||
"amount",
|
"amount",
|
||||||
"account__currency__code",
|
"account__currency__code",
|
||||||
"date",
|
|
||||||
"reference_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):
|
class TransactionInline(admin.TabularInline):
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from django_filters import Filter
|
|||||||
|
|
||||||
from apps.accounts.models import Account
|
from apps.accounts.models import Account
|
||||||
from apps.common.fields.month_year import MonthYearFormField
|
from apps.common.fields.month_year import MonthYearFormField
|
||||||
|
from apps.common.widgets.datepicker import AirDatePickerInput
|
||||||
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
|
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
|
||||||
from apps.common.widgets.tom_select import TomSelectMultiple
|
from apps.common.widgets.tom_select import TomSelectMultiple
|
||||||
from apps.currencies.models import Currency
|
from apps.currencies.models import Currency
|
||||||
@@ -87,13 +88,11 @@ class TransactionsFilter(django_filters.FilterSet):
|
|||||||
date_start = django_filters.DateFilter(
|
date_start = django_filters.DateFilter(
|
||||||
field_name="date",
|
field_name="date",
|
||||||
lookup_expr="gte",
|
lookup_expr="gte",
|
||||||
widget=forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
|
|
||||||
label=_("Date from"),
|
label=_("Date from"),
|
||||||
)
|
)
|
||||||
date_end = django_filters.DateFilter(
|
date_end = django_filters.DateFilter(
|
||||||
field_name="date",
|
field_name="date",
|
||||||
lookup_expr="lte",
|
lookup_expr="lte",
|
||||||
widget=forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
|
|
||||||
label=_("Until"),
|
label=_("Until"),
|
||||||
)
|
)
|
||||||
reference_date_start = MonthYearFilter(
|
reference_date_start = MonthYearFilter(
|
||||||
@@ -183,3 +182,5 @@ class TransactionsFilter(django_filters.FilterSet):
|
|||||||
|
|
||||||
self.form.fields["to_amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
self.form.fields["to_amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||||
self.form.fields["from_amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
self.form.fields["from_amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||||
|
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_bootstrap5.bootstrap5 import Switch, BS5Accordion
|
||||||
from crispy_forms.bootstrap import FormActions
|
from crispy_forms.bootstrap import FormActions, AccordionGroup
|
||||||
from crispy_forms.helper import FormHelper
|
from crispy_forms.helper import FormHelper
|
||||||
from crispy_forms.layout import (
|
from crispy_forms.layout import (
|
||||||
Layout,
|
Layout,
|
||||||
@@ -16,10 +16,11 @@ from apps.common.fields.forms.dynamic_select import (
|
|||||||
DynamicModelChoiceField,
|
DynamicModelChoiceField,
|
||||||
DynamicModelMultipleChoiceField,
|
DynamicModelMultipleChoiceField,
|
||||||
)
|
)
|
||||||
from apps.common.fields.month_year import MonthYearFormField
|
|
||||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||||
|
from apps.common.widgets.datepicker import AirDatePickerInput, AirMonthYearPickerInput
|
||||||
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
|
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
|
||||||
from apps.common.widgets.tom_select import TomSelect
|
from apps.common.widgets.tom_select import TomSelect
|
||||||
|
from apps.rules.signals import transaction_created, transaction_updated
|
||||||
from apps.transactions.models import (
|
from apps.transactions.models import (
|
||||||
Transaction,
|
Transaction,
|
||||||
TransactionCategory,
|
TransactionCategory,
|
||||||
@@ -28,7 +29,6 @@ from apps.transactions.models import (
|
|||||||
RecurringTransaction,
|
RecurringTransaction,
|
||||||
TransactionEntity,
|
TransactionEntity,
|
||||||
)
|
)
|
||||||
from apps.rules.signals import transaction_created, transaction_updated
|
|
||||||
|
|
||||||
|
|
||||||
class TransactionForm(forms.ModelForm):
|
class TransactionForm(forms.ModelForm):
|
||||||
@@ -59,7 +59,12 @@ class TransactionForm(forms.ModelForm):
|
|||||||
label=_("Account"),
|
label=_("Account"),
|
||||||
widget=TomSelect(clear_button=False, group_by="group"),
|
widget=TomSelect(clear_button=False, group_by="group"),
|
||||||
)
|
)
|
||||||
reference_date = MonthYearFormField(label=_("Reference Date"), required=False)
|
|
||||||
|
date = forms.DateField(label=_("Date"))
|
||||||
|
|
||||||
|
reference_date = forms.DateField(
|
||||||
|
widget=AirMonthYearPickerInput(), label=_("Reference Date"), required=False
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Transaction
|
model = Transaction
|
||||||
@@ -77,7 +82,6 @@ class TransactionForm(forms.ModelForm):
|
|||||||
"entities",
|
"entities",
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
"date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
|
|
||||||
"notes": forms.Textarea(attrs={"rows": 3}),
|
"notes": forms.Textarea(attrs={"rows": 3}),
|
||||||
"account": TomSelect(clear_button=False, group_by="group"),
|
"account": TomSelect(clear_button=False, group_by="group"),
|
||||||
}
|
}
|
||||||
@@ -111,15 +115,15 @@ class TransactionForm(forms.ModelForm):
|
|||||||
"type",
|
"type",
|
||||||
template="transactions/widgets/income_expense_toggle_buttons.html",
|
template="transactions/widgets/income_expense_toggle_buttons.html",
|
||||||
),
|
),
|
||||||
Switch("is_paid"),
|
Field("is_paid", template="transactions/widgets/paid_toggle_button.html"),
|
||||||
Row(
|
Row(
|
||||||
Column("account", css_class="form-group col-md-6 mb-0"),
|
Column("account", css_class="form-group col-md-6 mb-0"),
|
||||||
Column("entities", css_class="form-group col-md-6 mb-0"),
|
Column("entities", css_class="form-group col-md-6 mb-0"),
|
||||||
css_class="form-row",
|
css_class="form-row",
|
||||||
),
|
),
|
||||||
Row(
|
Row(
|
||||||
Column("date", css_class="form-group col-md-6 mb-0"),
|
Column(Field("date"), css_class="form-group col-md-6 mb-0"),
|
||||||
Column("reference_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",
|
css_class="form-row",
|
||||||
),
|
),
|
||||||
"description",
|
"description",
|
||||||
@@ -132,7 +136,48 @@ class TransactionForm(forms.ModelForm):
|
|||||||
"notes",
|
"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["reference_date"].required = False
|
||||||
|
self.fields["date"].widget = AirDatePickerInput(clear_button=False)
|
||||||
|
|
||||||
if self.instance and self.instance.pk:
|
if self.instance and self.instance.pk:
|
||||||
decimal_places = self.instance.account.currency.decimal_places
|
decimal_places = self.instance.account.currency.decimal_places
|
||||||
@@ -178,6 +223,43 @@ class TransactionForm(forms.ModelForm):
|
|||||||
return instance
|
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):
|
class TransferForm(forms.Form):
|
||||||
from_account = forms.ModelChoiceField(
|
from_account = forms.ModelChoiceField(
|
||||||
queryset=Account.objects.filter(is_archived=False),
|
queryset=Account.objects.filter(is_archived=False),
|
||||||
@@ -234,11 +316,12 @@ class TransferForm(forms.Form):
|
|||||||
queryset=TransactionTag.objects.filter(active=True),
|
queryset=TransactionTag.objects.filter(active=True),
|
||||||
)
|
)
|
||||||
|
|
||||||
date = forms.DateField(
|
date = forms.DateField(label=_("Date"))
|
||||||
label=_("Date"),
|
|
||||||
widget=forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
|
reference_date = forms.DateField(
|
||||||
|
widget=AirMonthYearPickerInput(), label=_("Reference Date"), required=False
|
||||||
)
|
)
|
||||||
reference_date = MonthYearFormField(label=_("Reference Date"), required=False)
|
|
||||||
description = forms.CharField(max_length=500, label=_("Description"))
|
description = forms.CharField(max_length=500, label=_("Description"))
|
||||||
notes = forms.CharField(
|
notes = forms.CharField(
|
||||||
required=False,
|
required=False,
|
||||||
@@ -318,8 +401,8 @@ class TransferForm(forms.Form):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.fields["from_amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
self.fields["from_amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||||
|
|
||||||
self.fields["to_amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
self.fields["to_amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||||
|
self.fields["date"].widget = AirDatePickerInput(clear_button=False)
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
cleaned_data = super().clean()
|
cleaned_data = super().clean()
|
||||||
@@ -404,7 +487,10 @@ class InstallmentPlanForm(forms.ModelForm):
|
|||||||
queryset=TransactionEntity.objects.filter(active=True),
|
queryset=TransactionEntity.objects.filter(active=True),
|
||||||
)
|
)
|
||||||
type = forms.ChoiceField(choices=Transaction.Type.choices)
|
type = forms.ChoiceField(choices=Transaction.Type.choices)
|
||||||
reference_date = MonthYearFormField(label=_("Reference Date"), required=False)
|
|
||||||
|
reference_date = forms.DateField(
|
||||||
|
widget=AirMonthYearPickerInput(), label=_("Reference Date"), required=False
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = InstallmentPlan
|
model = InstallmentPlan
|
||||||
@@ -424,7 +510,6 @@ class InstallmentPlanForm(forms.ModelForm):
|
|||||||
"entities",
|
"entities",
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
"start_date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
|
|
||||||
"account": TomSelect(),
|
"account": TomSelect(),
|
||||||
"recurrence": TomSelect(clear_button=False),
|
"recurrence": TomSelect(clear_button=False),
|
||||||
"notes": forms.Textarea(attrs={"rows": 3}),
|
"notes": forms.Textarea(attrs={"rows": 3}),
|
||||||
@@ -487,6 +572,7 @@ class InstallmentPlanForm(forms.ModelForm):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.fields["installment_amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
self.fields["installment_amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||||
|
self.fields["start_date"].widget = AirDatePickerInput(clear_button=False)
|
||||||
|
|
||||||
if self.instance and self.instance.pk:
|
if self.instance and self.instance.pk:
|
||||||
self.helper.layout.append(
|
self.helper.layout.append(
|
||||||
@@ -646,7 +732,6 @@ class RecurringTransactionForm(forms.ModelForm):
|
|||||||
queryset=TransactionEntity.objects.filter(active=True),
|
queryset=TransactionEntity.objects.filter(active=True),
|
||||||
)
|
)
|
||||||
type = forms.ChoiceField(choices=Transaction.Type.choices)
|
type = forms.ChoiceField(choices=Transaction.Type.choices)
|
||||||
reference_date = MonthYearFormField(label=_("Reference Date"), required=False)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = RecurringTransaction
|
model = RecurringTransaction
|
||||||
@@ -666,8 +751,7 @@ class RecurringTransactionForm(forms.ModelForm):
|
|||||||
"entities",
|
"entities",
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
"start_date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
|
"reference_date": AirMonthYearPickerInput(),
|
||||||
"end_date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
|
|
||||||
"recurrence_type": TomSelect(clear_button=False),
|
"recurrence_type": TomSelect(clear_button=False),
|
||||||
"notes": forms.Textarea(
|
"notes": forms.Textarea(
|
||||||
attrs={
|
attrs={
|
||||||
@@ -733,6 +817,8 @@ class RecurringTransactionForm(forms.ModelForm):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||||
|
self.fields["start_date"].widget = AirDatePickerInput(clear_button=False)
|
||||||
|
self.fields["end_date"].widget = AirDatePickerInput()
|
||||||
|
|
||||||
if self.instance and self.instance.pk:
|
if self.instance and self.instance.pk:
|
||||||
self.helper.layout.append(
|
self.helper.layout.append(
|
||||||
@@ -767,5 +853,7 @@ class RecurringTransactionForm(forms.ModelForm):
|
|||||||
instance = super().save(**kwargs)
|
instance = super().save(**kwargs)
|
||||||
if is_new:
|
if is_new:
|
||||||
instance.create_upcoming_transactions()
|
instance.create_upcoming_transactions()
|
||||||
|
else:
|
||||||
|
instance.update_unpaid_transactions()
|
||||||
|
|
||||||
return instance
|
return instance
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.1.4 on 2025-01-14 12:55
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('transactions', '0026_transactionentity_active'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='transaction',
|
||||||
|
name='description',
|
||||||
|
field=models.CharField(blank=True, max_length=500, verbose_name='Description'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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.db.models import Q
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
from apps.common.fields.month_year import MonthYearModelField
|
from apps.common.fields.month_year import MonthYearModelField
|
||||||
from apps.common.functions.decimals import truncate_decimal
|
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()
|
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):
|
class TransactionCategory(models.Model):
|
||||||
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True)
|
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True)
|
||||||
mute = models.BooleanField(default=False, verbose_name=_("Mute"))
|
mute = models.BooleanField(default=False, verbose_name=_("Mute"))
|
||||||
@@ -101,7 +149,9 @@ class Transaction(models.Model):
|
|||||||
validators=[validate_non_negative, validate_decimal_places],
|
validators=[validate_non_negative, validate_decimal_places],
|
||||||
)
|
)
|
||||||
|
|
||||||
description = models.CharField(max_length=500, verbose_name=_("Description"))
|
description = models.CharField(
|
||||||
|
max_length=500, verbose_name=_("Description"), blank=True
|
||||||
|
)
|
||||||
notes = models.TextField(blank=True, verbose_name=_("Notes"))
|
notes = models.TextField(blank=True, verbose_name=_("Notes"))
|
||||||
category = models.ForeignKey(
|
category = models.ForeignKey(
|
||||||
TransactionCategory,
|
TransactionCategory,
|
||||||
@@ -139,11 +189,29 @@ class Transaction(models.Model):
|
|||||||
related_name="transactions",
|
related_name="transactions",
|
||||||
verbose_name=_("Recurring Transaction"),
|
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:
|
class Meta:
|
||||||
verbose_name = _("Transaction")
|
verbose_name = _("Transaction")
|
||||||
verbose_name_plural = _("Transactions")
|
verbose_name_plural = _("Transactions")
|
||||||
db_table = "transactions"
|
db_table = "transactions"
|
||||||
|
default_manager_name = "objects"
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
self.amount = truncate_decimal(
|
self.amount = truncate_decimal(
|
||||||
@@ -158,6 +226,17 @@ class Transaction(models.Model):
|
|||||||
self.full_clean()
|
self.full_clean()
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def delete(self, *args, **kwargs):
|
||||||
|
if settings.ENABLE_SOFT_DELETE:
|
||||||
|
self.deleted = True
|
||||||
|
self.deleted_at = timezone.now()
|
||||||
|
self.save()
|
||||||
|
else:
|
||||||
|
super().delete(*args, **kwargs)
|
||||||
|
|
||||||
|
def hard_delete(self, *args, **kwargs):
|
||||||
|
super().delete(*args, **kwargs)
|
||||||
|
|
||||||
def exchanged_amount(self):
|
def exchanged_amount(self):
|
||||||
if self.account.exchange_currency:
|
if self.account.exchange_currency:
|
||||||
converted_amount, prefix, suffix, decimal_places = convert(
|
converted_amount, prefix, suffix, decimal_places = convert(
|
||||||
@@ -176,6 +255,10 @@ class Transaction(models.Model):
|
|||||||
|
|
||||||
return None
|
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 InstallmentPlan(models.Model):
|
||||||
class Recurrence(models.TextChoices):
|
class Recurrence(models.TextChoices):
|
||||||
@@ -334,10 +417,15 @@ class InstallmentPlan(models.Model):
|
|||||||
existing_transaction.type = self.type
|
existing_transaction.type = self.type
|
||||||
existing_transaction.date = transaction_date
|
existing_transaction.date = transaction_date
|
||||||
existing_transaction.reference_date = transaction_reference_date
|
existing_transaction.reference_date = transaction_reference_date
|
||||||
existing_transaction.amount = self.installment_amount
|
|
||||||
existing_transaction.description = self.description
|
existing_transaction.description = self.description
|
||||||
existing_transaction.category = self.category
|
existing_transaction.category = self.category
|
||||||
existing_transaction.notes = self.notes
|
existing_transaction.notes = self.notes
|
||||||
|
|
||||||
|
if (
|
||||||
|
not existing_transaction.is_paid
|
||||||
|
): # Don't update value for paid transactions
|
||||||
|
existing_transaction.amount = self.installment_amount
|
||||||
|
|
||||||
existing_transaction.save()
|
existing_transaction.save()
|
||||||
|
|
||||||
# Update tags
|
# Update tags
|
||||||
@@ -540,3 +628,33 @@ class RecurringTransaction(models.Model):
|
|||||||
recurring_transaction.save(
|
recurring_transaction.save(
|
||||||
update_fields=["last_generated_date", "last_generated_reference_date"]
|
update_fields=["last_generated_date", "last_generated_reference_date"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def update_unpaid_transactions(self):
|
||||||
|
"""
|
||||||
|
Updates all unpaid transactions associated with this RecurringTransaction.
|
||||||
|
|
||||||
|
Only unpaid transactions (`is_paid=False`) are modified. Updates fields like
|
||||||
|
amount, description, category, notes, and many-to-many relationships (tags, entities).
|
||||||
|
"""
|
||||||
|
unpaid_transactions = self.transactions.filter(is_paid=False)
|
||||||
|
|
||||||
|
for existing_transaction in unpaid_transactions:
|
||||||
|
# Update fields based on RecurringTransaction
|
||||||
|
existing_transaction.amount = self.amount
|
||||||
|
existing_transaction.description = self.description
|
||||||
|
existing_transaction.category = self.category
|
||||||
|
existing_transaction.notes = self.notes
|
||||||
|
|
||||||
|
# Update many-to-many relationships
|
||||||
|
existing_transaction.tags.set(self.tags.all())
|
||||||
|
existing_transaction.entities.set(self.entities.all())
|
||||||
|
|
||||||
|
# Save updated transaction
|
||||||
|
existing_transaction.save()
|
||||||
|
|
||||||
|
def delete_unpaid_transactions(self):
|
||||||
|
"""
|
||||||
|
Deletes all unpaid transactions associated with this RecurringTransaction.
|
||||||
|
"""
|
||||||
|
today = timezone.localdate(timezone.now())
|
||||||
|
self.transactions.filter(is_paid=False, date__gt=today).delete()
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import logging
|
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 procrastinate.contrib.django import app
|
||||||
|
|
||||||
from apps.transactions.models import RecurringTransaction
|
from apps.transactions.models import RecurringTransaction, Transaction
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -19,3 +23,31 @@ def generate_recurring_transactions(timestamp=None):
|
|||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
|
|
||||||
|
@app.periodic(cron="10 1 * * *")
|
||||||
|
@app.task
|
||||||
|
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."
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ urlpatterns = [
|
|||||||
name="transactions_all_summary",
|
name="transactions_all_summary",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"transactions/actions/pay",
|
"transactions/actions/pay/",
|
||||||
views.bulk_pay_transactions,
|
views.bulk_pay_transactions,
|
||||||
name="transactions_bulk_pay",
|
name="transactions_bulk_pay",
|
||||||
),
|
),
|
||||||
@@ -27,27 +27,47 @@ urlpatterns = [
|
|||||||
name="transactions_bulk_delete",
|
name="transactions_bulk_delete",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"transaction/<int:transaction_id>/pay",
|
"transactions/actions/duplicate/",
|
||||||
|
views.bulk_clone_transactions,
|
||||||
|
name="transactions_bulk_clone",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"transaction/<int:transaction_id>/pay/",
|
||||||
views.transaction_pay,
|
views.transaction_pay,
|
||||||
name="transaction_pay",
|
name="transaction_pay",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"transaction/<int:transaction_id>/delete",
|
"transaction/<int:transaction_id>/delete/",
|
||||||
views.transaction_delete,
|
views.transaction_delete,
|
||||||
name="transaction_delete",
|
name="transaction_delete",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"transaction/<int:transaction_id>/edit",
|
"transaction/<int:transaction_id>/edit/",
|
||||||
views.transaction_edit,
|
views.transaction_edit,
|
||||||
name="transaction_edit",
|
name="transaction_edit",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"transaction/add",
|
"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/",
|
||||||
views.transaction_add,
|
views.transaction_add,
|
||||||
name="transaction_add",
|
name="transaction_add",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"transactions/transfer",
|
"add/",
|
||||||
|
views.transaction_simple_add,
|
||||||
|
name="transaction_simple_add",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"transactions/transfer/",
|
||||||
views.transactions_transfer,
|
views.transactions_transfer,
|
||||||
name="transactions_transfer",
|
name="transactions_transfer",
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
from django.contrib import messages
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.http import HttpResponse
|
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.common.decorators.htmx import only_htmx
|
||||||
from apps.transactions.models import Transaction
|
from apps.transactions.models import Transaction
|
||||||
@@ -9,7 +13,19 @@ from apps.transactions.models import Transaction
|
|||||||
@login_required
|
@login_required
|
||||||
def bulk_pay_transactions(request):
|
def bulk_pay_transactions(request):
|
||||||
selected_transactions = request.GET.getlist("transactions", [])
|
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(
|
return HttpResponse(
|
||||||
status=204,
|
status=204,
|
||||||
@@ -21,7 +37,19 @@ def bulk_pay_transactions(request):
|
|||||||
@login_required
|
@login_required
|
||||||
def bulk_unpay_transactions(request):
|
def bulk_unpay_transactions(request):
|
||||||
selected_transactions = request.GET.getlist("transactions", [])
|
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(
|
return HttpResponse(
|
||||||
status=204,
|
status=204,
|
||||||
@@ -33,7 +61,54 @@ def bulk_unpay_transactions(request):
|
|||||||
@login_required
|
@login_required
|
||||||
def bulk_delete_transactions(request):
|
def bulk_delete_transactions(request):
|
||||||
selected_transactions = request.GET.getlist("transactions", [])
|
selected_transactions = request.GET.getlist("transactions", [])
|
||||||
Transaction.objects.filter(id__in=selected_transactions).delete()
|
transactions = Transaction.objects.filter(id__in=selected_transactions)
|
||||||
|
count = transactions.count()
|
||||||
|
transactions.delete()
|
||||||
|
|
||||||
|
messages.success(
|
||||||
|
request,
|
||||||
|
ngettext_lazy(
|
||||||
|
"%(count)s transaction deleted successfully",
|
||||||
|
"%(count)s transactions deleted successfully",
|
||||||
|
count,
|
||||||
|
)
|
||||||
|
% {"count": count},
|
||||||
|
)
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
headers={"HX-Trigger": "updated"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
def bulk_clone_transactions(request):
|
||||||
|
selected_transactions = request.GET.getlist("transactions", [])
|
||||||
|
transactions = Transaction.objects.filter(id__in=selected_transactions)
|
||||||
|
count = transactions.count()
|
||||||
|
|
||||||
|
for transaction in transactions:
|
||||||
|
new_transaction = deepcopy(transaction)
|
||||||
|
new_transaction.pk = None
|
||||||
|
new_transaction.installment_plan = None
|
||||||
|
new_transaction.installment_id = None
|
||||||
|
new_transaction.recurring_transaction = None
|
||||||
|
new_transaction.internal_id = None
|
||||||
|
new_transaction.save()
|
||||||
|
|
||||||
|
new_transaction.tags.add(*transaction.tags.all())
|
||||||
|
new_transaction.entities.add(*transaction.entities.all())
|
||||||
|
|
||||||
|
messages.success(
|
||||||
|
request,
|
||||||
|
ngettext_lazy(
|
||||||
|
"%(count)s transaction duplicated successfully",
|
||||||
|
"%(count)s transactions duplicated successfully",
|
||||||
|
count,
|
||||||
|
)
|
||||||
|
% {"count": count},
|
||||||
|
)
|
||||||
|
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
status=204,
|
status=204,
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ from django.contrib import messages
|
|||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.shortcuts import render, get_object_or_404
|
from django.shortcuts import render, get_object_or_404
|
||||||
from django.urls import reverse
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
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 django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
from apps.common.decorators.htmx import only_htmx
|
from apps.common.decorators.htmx import only_htmx
|
||||||
@@ -111,7 +109,6 @@ def category_edit(request, category_id):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
@csrf_exempt
|
|
||||||
@require_http_methods(["DELETE"])
|
@require_http_methods(["DELETE"])
|
||||||
def category_delete(request, category_id):
|
def category_delete(request, category_id):
|
||||||
category = get_object_or_404(TransactionCategory, id=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.http import HttpResponse
|
||||||
from django.shortcuts import render, get_object_or_404
|
from django.shortcuts import render, get_object_or_404
|
||||||
from django.utils.translation import gettext_lazy as _
|
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 django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
from apps.common.decorators.htmx import only_htmx
|
from apps.common.decorators.htmx import only_htmx
|
||||||
@@ -110,7 +109,6 @@ def entity_edit(request, entity_id):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
@csrf_exempt
|
|
||||||
@require_http_methods(["DELETE"])
|
@require_http_methods(["DELETE"])
|
||||||
def entity_delete(request, entity_id):
|
def entity_delete(request, entity_id):
|
||||||
entity = get_object_or_404(TransactionEntity, id=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.shortcuts import render, get_object_or_404
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
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 django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
from apps.common.decorators.htmx import only_htmx
|
from apps.common.decorators.htmx import only_htmx
|
||||||
@@ -150,7 +149,6 @@ def installment_plan_refresh(request, installment_plan_id):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
@csrf_exempt
|
|
||||||
@require_http_methods(["DELETE"])
|
@require_http_methods(["DELETE"])
|
||||||
def installment_plan_delete(request, installment_plan_id):
|
def installment_plan_delete(request, installment_plan_id):
|
||||||
installment_plan = get_object_or_404(InstallmentPlan, id=installment_plan_id)
|
installment_plan = get_object_or_404(InstallmentPlan, id=installment_plan_id)
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.db.models import Q
|
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.shortcuts import render, get_object_or_404
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
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 django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
from apps.common.decorators.htmx import only_htmx
|
from apps.common.decorators.htmx import only_htmx
|
||||||
@@ -168,12 +166,26 @@ def recurring_transaction_toggle_pause(request, recurring_transaction_id):
|
|||||||
)
|
)
|
||||||
current_paused = recurring_transaction.is_paused
|
current_paused = recurring_transaction.is_paused
|
||||||
recurring_transaction.is_paused = not current_paused
|
recurring_transaction.is_paused = not current_paused
|
||||||
recurring_transaction.save(update_fields=["is_paused"])
|
|
||||||
|
|
||||||
if current_paused:
|
if current_paused:
|
||||||
messages.success(request, _("Recurring transaction unpaused successfully"))
|
today = timezone.localdate(timezone.now())
|
||||||
|
recurring_transaction.last_generated_date = max(
|
||||||
|
recurring_transaction.last_generated_date, today
|
||||||
|
)
|
||||||
|
recurring_transaction.last_generated_reference_date = max(
|
||||||
|
recurring_transaction.last_generated_reference_date, today
|
||||||
|
)
|
||||||
|
recurring_transaction.save(
|
||||||
|
update_fields=[
|
||||||
|
"last_generated_date",
|
||||||
|
"last_generated_reference_date",
|
||||||
|
"is_paused",
|
||||||
|
]
|
||||||
|
)
|
||||||
generate_recurring_transactions.defer()
|
generate_recurring_transactions.defer()
|
||||||
|
messages.success(request, _("Recurring transaction unpaused successfully"))
|
||||||
else:
|
else:
|
||||||
|
recurring_transaction.save(update_fields=["is_paused"])
|
||||||
messages.success(request, _("Recurring transaction paused successfully"))
|
messages.success(request, _("Recurring transaction paused successfully"))
|
||||||
|
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
@@ -188,7 +200,7 @@ def recurring_transaction_toggle_pause(request, recurring_transaction_id):
|
|||||||
@login_required
|
@login_required
|
||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def recurring_transaction_finish(request, recurring_transaction_id):
|
def recurring_transaction_finish(request, recurring_transaction_id):
|
||||||
recurring_transaction = get_object_or_404(
|
recurring_transaction: RecurringTransaction = get_object_or_404(
|
||||||
RecurringTransaction, id=recurring_transaction_id
|
RecurringTransaction, id=recurring_transaction_id
|
||||||
)
|
)
|
||||||
today = timezone.localdate(timezone.now()) - relativedelta(days=1)
|
today = timezone.localdate(timezone.now()) - relativedelta(days=1)
|
||||||
@@ -197,6 +209,9 @@ def recurring_transaction_finish(request, recurring_transaction_id):
|
|||||||
recurring_transaction.is_paused = True
|
recurring_transaction.is_paused = True
|
||||||
recurring_transaction.save(update_fields=["end_date", "is_paused"])
|
recurring_transaction.save(update_fields=["end_date", "is_paused"])
|
||||||
|
|
||||||
|
# Delete all unpaid transactions associated with this RecurringTransaction
|
||||||
|
recurring_transaction.delete_unpaid_transactions()
|
||||||
|
|
||||||
messages.success(request, _("Recurring transaction finished successfully"))
|
messages.success(request, _("Recurring transaction finished successfully"))
|
||||||
|
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
@@ -209,7 +224,6 @@ def recurring_transaction_finish(request, recurring_transaction_id):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
@csrf_exempt
|
|
||||||
@require_http_methods(["DELETE"])
|
@require_http_methods(["DELETE"])
|
||||||
def recurring_transaction_delete(request, recurring_transaction_id):
|
def recurring_transaction_delete(request, recurring_transaction_id):
|
||||||
recurring_transaction = get_object_or_404(
|
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.http import HttpResponse
|
||||||
from django.shortcuts import render, get_object_or_404
|
from django.shortcuts import render, get_object_or_404
|
||||||
from django.utils.translation import gettext_lazy as _
|
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 django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
from apps.common.decorators.htmx import only_htmx
|
from apps.common.decorators.htmx import only_htmx
|
||||||
@@ -110,7 +109,6 @@ def tag_edit(request, tag_id):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
@csrf_exempt
|
|
||||||
@require_http_methods(["DELETE"])
|
@require_http_methods(["DELETE"])
|
||||||
def tag_delete(request, tag_id):
|
def tag_delete(request, tag_id):
|
||||||
tag = get_object_or_404(TransactionTag, id=tag_id)
|
tag = get_object_or_404(TransactionTag, id=tag_id)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
@@ -6,14 +7,18 @@ from django.core.paginator import Paginator
|
|||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.shortcuts import render, get_object_or_404
|
from django.shortcuts import render, get_object_or_404
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _, ngettext_lazy
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
|
||||||
from django.views.decorators.http import require_http_methods
|
from django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
from apps.common.decorators.htmx import only_htmx
|
from apps.common.decorators.htmx import only_htmx
|
||||||
from apps.common.utils.dicts import remove_falsey_entries
|
from apps.common.utils.dicts import remove_falsey_entries
|
||||||
|
from apps.rules.signals import transaction_created, transaction_updated
|
||||||
from apps.transactions.filters import TransactionsFilter
|
from apps.transactions.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.models import Transaction
|
||||||
from apps.transactions.utils.calculations import (
|
from apps.transactions.utils.calculations import (
|
||||||
calculate_currency_totals,
|
calculate_currency_totals,
|
||||||
@@ -53,7 +58,7 @@ def transaction_add(request):
|
|||||||
initial={
|
initial={
|
||||||
"date": expected_date,
|
"date": expected_date,
|
||||||
"type": transaction_type,
|
"type": transaction_type,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
@@ -63,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
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET", "POST"])
|
||||||
@@ -91,7 +138,110 @@ def transaction_edit(request, transaction_id, **kwargs):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
@csrf_exempt
|
@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"])
|
||||||
|
def transaction_clone(request, transaction_id, **kwargs):
|
||||||
|
transaction = get_object_or_404(Transaction, id=transaction_id)
|
||||||
|
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, _("Transaction duplicated successfully"))
|
||||||
|
|
||||||
|
transaction_created.send(sender=transaction)
|
||||||
|
|
||||||
|
# THIS HAS BEEN DISABLE DUE TO HTMX INCOMPATIBILITY
|
||||||
|
# SEE https://github.com/bigskysoftware/htmx/issues/3115 and https://github.com/bigskysoftware/htmx/issues/2706
|
||||||
|
|
||||||
|
# if request.GET.get("edit") == "true":
|
||||||
|
# return HttpResponse(
|
||||||
|
# status=200,
|
||||||
|
# headers={
|
||||||
|
# "HX-Trigger": "updated",
|
||||||
|
# "HX-Push-Url": "false",
|
||||||
|
# "HX-Location": json.dumps(
|
||||||
|
# {
|
||||||
|
# "path": reverse(
|
||||||
|
# "transaction_edit",
|
||||||
|
# kwargs={"transaction_id": new_transaction.id},
|
||||||
|
# ),
|
||||||
|
# "target": "#generic-offcanvas",
|
||||||
|
# "swap": "innerHTML",
|
||||||
|
# }
|
||||||
|
# ),
|
||||||
|
# },
|
||||||
|
# )
|
||||||
|
# else:
|
||||||
|
# transaction_created.send(sender=transaction)
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
headers={"HX-Trigger": "updated"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
@require_http_methods(["DELETE"])
|
@require_http_methods(["DELETE"])
|
||||||
def transaction_delete(request, transaction_id, **kwargs):
|
def transaction_delete(request, transaction_id, **kwargs):
|
||||||
transaction = get_object_or_404(Transaction, id=transaction_id)
|
transaction = get_object_or_404(Transaction, id=transaction_id)
|
||||||
@@ -134,7 +284,7 @@ def transactions_transfer(request):
|
|||||||
initial={
|
initial={
|
||||||
"reference_date": expected_date,
|
"reference_date": expected_date,
|
||||||
"date": expected_date,
|
"date": expected_date,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
return render(request, "transactions/fragments/transfer.html", {"form": form})
|
return render(request, "transactions/fragments/transfer.html", {"form": form})
|
||||||
|
|||||||
@@ -46,9 +46,72 @@ class LoginForm(AuthenticationForm):
|
|||||||
|
|
||||||
|
|
||||||
class UserSettingsForm(forms.ModelForm):
|
class UserSettingsForm(forms.ModelForm):
|
||||||
|
DATE_FORMAT_CHOICES = [
|
||||||
|
("SHORT_DATE_FORMAT", _("Default")),
|
||||||
|
("d-m-Y", "20-01-2025"),
|
||||||
|
("m-d-Y", "01-20-2025"),
|
||||||
|
("Y-m-d", "2025-01-20"),
|
||||||
|
("d/m/Y", "20/01/2025"),
|
||||||
|
("m/d/Y", "01/20/2025"),
|
||||||
|
("Y/m/d", "2025/01/20"),
|
||||||
|
("d.m.Y", "20.01.2025"),
|
||||||
|
("m.d.Y", "01.20.2025"),
|
||||||
|
("Y.m.d", "2025.01.20"),
|
||||||
|
]
|
||||||
|
|
||||||
|
DATETIME_FORMAT_CHOICES = [
|
||||||
|
("SHORT_DATETIME_FORMAT", _("Default")),
|
||||||
|
("d-m-Y H:i", "20-01-2025 15:30"),
|
||||||
|
("m-d-Y H:i", "01-20-2025 15:30"),
|
||||||
|
("Y-m-d H:i", "2025-01-20 15:30"),
|
||||||
|
("d-m-Y h:i A", "20-01-2025 03:30 PM"),
|
||||||
|
("m-d-Y h:i A", "01-20-2025 03:30 PM"),
|
||||||
|
("Y-m-d h:i A", "2025-01-20 03:30 PM"),
|
||||||
|
("d/m/Y H:i", "20/01/2025 15:30"),
|
||||||
|
("m/d/Y H:i", "01/20/2025 15:30"),
|
||||||
|
("Y/m/d H:i", "2025/01/20 15:30"),
|
||||||
|
("d/m/Y h:i A", "20/01/2025 03:30 PM"),
|
||||||
|
("m/d/Y h:i A", "01/20/2025 03:30 PM"),
|
||||||
|
("Y/m/d h:i A", "2025/01/20 03:30 PM"),
|
||||||
|
("d.m.Y H:i", "20.01.2025 15:30"),
|
||||||
|
("m.d.Y H:i", "01.20.2025 15:30"),
|
||||||
|
("Y.m.d H:i", "2025.01.20 15:30"),
|
||||||
|
("d.m.Y h:i A", "20.01.2025 03:30 PM"),
|
||||||
|
("m.d.Y h:i A", "01.20.2025 03:30 PM"),
|
||||||
|
("Y.m.d h:i A", "2025.01.20 03:30 PM"),
|
||||||
|
]
|
||||||
|
|
||||||
|
NUMBER_FORMAT_CHOICES = [
|
||||||
|
("AA", _("Default")),
|
||||||
|
("DC", "1.234,50"),
|
||||||
|
("CD", "1,234.50"),
|
||||||
|
]
|
||||||
|
|
||||||
|
date_format = forms.ChoiceField(
|
||||||
|
choices=DATE_FORMAT_CHOICES, initial="SHORT_DATE_FORMAT", label=_("Date Format")
|
||||||
|
)
|
||||||
|
datetime_format = forms.ChoiceField(
|
||||||
|
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:
|
class Meta:
|
||||||
model = UserSettings
|
model = UserSettings
|
||||||
fields = ["language", "timezone", "start_page"]
|
fields = [
|
||||||
|
"language",
|
||||||
|
"timezone",
|
||||||
|
"start_page",
|
||||||
|
"date_format",
|
||||||
|
"datetime_format",
|
||||||
|
"number_format",
|
||||||
|
]
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
@@ -59,6 +122,9 @@ class UserSettingsForm(forms.ModelForm):
|
|||||||
self.helper.layout = Layout(
|
self.helper.layout = Layout(
|
||||||
"language",
|
"language",
|
||||||
"timezone",
|
"timezone",
|
||||||
|
"date_format",
|
||||||
|
"datetime_format",
|
||||||
|
"number_format",
|
||||||
"start_page",
|
"start_page",
|
||||||
FormActions(
|
FormActions(
|
||||||
NoClassSubmit(
|
NoClassSubmit(
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.1.5 on 2025-01-20 17:39
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('users', '0012_alter_usersettings_start_page'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='usersettings',
|
||||||
|
name='date_format',
|
||||||
|
field=models.CharField(default='SHORT_DATE_FORMAT', max_length=100),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='usersettings',
|
||||||
|
name='datetime_format',
|
||||||
|
field=models.CharField(default='SHORT_DATETIME_FORMAT', max_length=100),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import pytz
|
import pytz
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.db import models
|
|
||||||
from django.contrib.auth.models import AbstractUser, Group
|
from django.contrib.auth.models import AbstractUser, Group
|
||||||
|
from django.db import models
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from apps.users.managers import UserManager
|
from apps.users.managers import UserManager
|
||||||
@@ -36,6 +36,18 @@ class UserSettings(models.Model):
|
|||||||
hide_amounts = models.BooleanField(default=False)
|
hide_amounts = models.BooleanField(default=False)
|
||||||
mute_sounds = models.BooleanField(default=False)
|
mute_sounds = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
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(
|
language = models.CharField(
|
||||||
max_length=10,
|
max_length=10,
|
||||||
choices=(("auto", _("Auto")),) + settings.LANGUAGES,
|
choices=(("auto", _("Auto")),) + settings.LANGUAGES,
|
||||||
@@ -57,3 +69,6 @@ class UserSettings(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.user.email}'s settings"
|
return f"{self.user.email}'s settings"
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
super().clean()
|
||||||
|
|||||||
0
app/import_presets/.gitkeep
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
@@ -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"
|
||||||
|
}
|
||||||
2338
app/locale/nl/LC_MESSAGES/django.po
Normal file
@@ -1,41 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "WYGIWYH",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "\/static\/img\/favicon\/android-icon-36x36.png",
|
|
||||||
"sizes": "36x36",
|
|
||||||
"type": "image\/png",
|
|
||||||
"density": "0.75"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "\/static\/img\/favicon\/android-icon-48x48.png",
|
|
||||||
"sizes": "48x48",
|
|
||||||
"type": "image\/png",
|
|
||||||
"density": "1.0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "\/static\/img\/favicon\/android-icon-72x72.png",
|
|
||||||
"sizes": "72x72",
|
|
||||||
"type": "image\/png",
|
|
||||||
"density": "1.5"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "\/static\/img\/favicon\/android-icon-96x96.png",
|
|
||||||
"sizes": "96x96",
|
|
||||||
"type": "image\/png",
|
|
||||||
"density": "2.0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "\/static\/img\/favicon\/android-icon-144x144.png",
|
|
||||||
"sizes": "144x144",
|
|
||||||
"type": "image\/png",
|
|
||||||
"density": "3.0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "\/static\/img\/favicon\/android-icon-192x192.png",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image\/png",
|
|
||||||
"density": "4.0"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
BIN
app/static/img/pwa/splash-640x1136.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
app/static/img/pwa/splash-750x1334.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
@@ -110,6 +110,14 @@
|
|||||||
hx-get="{% url 'transaction_edit' transaction_id=transaction.id %}"
|
hx-get="{% url 'transaction_edit' transaction_id=transaction.id %}"
|
||||||
hx-target="#generic-offcanvas" hx-swap="innerHTML">
|
hx-target="#generic-offcanvas" hx-swap="innerHTML">
|
||||||
<i class="fa-solid fa-pencil fa-fw"></i></a>
|
<i class="fa-solid fa-pencil fa-fw"></i></a>
|
||||||
|
<a class="btn btn-secondary btn-sm transaction-action"
|
||||||
|
role="button"
|
||||||
|
data-bs-toggle="tooltip"
|
||||||
|
data-bs-title="{% translate "Duplicate" %}"
|
||||||
|
hx-get="{% url 'transaction_clone' transaction_id=transaction.id %}"
|
||||||
|
_="on click if event.ctrlKey set @hx-get to `{% url 'transaction_clone' transaction_id=transaction.id %}?edit=true` then call htmx.process(me) end then trigger ready"
|
||||||
|
hx-trigger="ready" >
|
||||||
|
<i class="fa-solid fa-clone fa-fw"></i></a>
|
||||||
<a class="btn btn-secondary btn-sm transaction-action"
|
<a class="btn btn-secondary btn-sm transaction-action"
|
||||||
role="button"
|
role="button"
|
||||||
data-bs-toggle="tooltip"
|
data-bs-toggle="tooltip"
|
||||||
|
|||||||
@@ -2,46 +2,80 @@
|
|||||||
<div class="tw-sticky tw-bottom-4 tw-left-0 tw-right-0 tw-z-50 tw-hidden mx-auto tw-w-fit" id="actions-bar"
|
<div class="tw-sticky tw-bottom-4 tw-left-0 tw-right-0 tw-z-50 tw-hidden mx-auto tw-w-fit" id="actions-bar"
|
||||||
_="on change from #transactions-list or htmx:afterSettle from window
|
_="on change from #transactions-list or htmx:afterSettle from window
|
||||||
if no <input[type='checkbox']:checked/> in #transactions-list
|
if no <input[type='checkbox']:checked/> in #transactions-list
|
||||||
add .tw-hidden to #actions-bar
|
if #actions-bar
|
||||||
|
add .slide-in-bottom-reverse then settle
|
||||||
|
then add .tw-hidden to #actions-bar
|
||||||
|
then remove .slide-in-bottom-reverse
|
||||||
|
end
|
||||||
else
|
else
|
||||||
remove .tw-hidden from #actions-bar
|
if #actions-bar
|
||||||
then trigger selected_transactions_updated
|
remove .tw-hidden from #actions-bar
|
||||||
|
then trigger selected_transactions_updated
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end">
|
end">
|
||||||
<div class="card slide-in-left">
|
<div class="card slide-in-bottom">
|
||||||
<div class="card-body p-2">
|
<div class="card-body p-2 d-flex justify-content-between align-items-center gap-3">
|
||||||
{% spaceless %}
|
{% spaceless %}
|
||||||
<div class="btn-group" role="group">
|
<div class="dropdown">
|
||||||
<button class="btn btn-secondary btn-sm"
|
<button class="btn btn-secondary btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown"
|
||||||
data-bs-toggle="tooltip"
|
aria-expanded="false">
|
||||||
data-bs-title="{% translate 'Select All' %}"
|
<i class="fa-regular fa-square-check fa-fw"></i>
|
||||||
_="on click set <#transactions-list input[type='checkbox']/>'s checked to true then call me.blur() then trigger change">
|
|
||||||
<i class="fa-regular fa-square-check tw-text-green-400"></i>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-secondary btn-sm"
|
|
||||||
data-bs-toggle="tooltip"
|
|
||||||
data-bs-title="{% translate 'Unselect All' %}"
|
|
||||||
_="on click set <#transactions-list input[type='checkbox']/>'s checked to false then call me.blur() then trigger change">
|
|
||||||
<i class="fa-regular fa-square tw-text-red-400"></i>
|
|
||||||
</button>
|
</button>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li>
|
||||||
|
<div class="dropdown-item px-3 tw-cursor-pointer"
|
||||||
|
_="on click set <#transactions-list input[type='checkbox']/>'s checked to true then call me.blur() then trigger change">
|
||||||
|
<i class="fa-regular fa-square-check tw-text-green-400 me-3"></i>{% translate 'Select All' %}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div class="dropdown-item px-3 tw-cursor-pointer"
|
||||||
|
_="on click set <#transactions-list input[type='checkbox']/>'s checked to false then call me.blur() then trigger change">
|
||||||
|
<i class="fa-regular fa-square tw-text-red-400 me-3"></i>{% translate 'Unselect All' %}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="vr mx-3 tw-align-middle"></div>
|
<div class="vr tw-align-middle"></div>
|
||||||
<div class="btn-group me-3" role="group">
|
<div class="btn-group">
|
||||||
<button class="btn btn-secondary btn-sm"
|
<button class="btn btn-secondary btn-sm"
|
||||||
hx-get="{% url 'transactions_bulk_pay' %}"
|
hx-get="{% url 'transactions_bulk_edit' %}"
|
||||||
|
hx-target="#generic-offcanvas"
|
||||||
hx-include=".transaction"
|
hx-include=".transaction"
|
||||||
data-bs-toggle="tooltip"
|
data-bs-toggle="tooltip"
|
||||||
data-bs-title="{% translate 'Mark as paid' %}">
|
data-bs-title="{% translate 'Edit' %}">
|
||||||
<i class="fa-regular fa-circle-check tw-text-green-400"></i>
|
<i class="fa-solid fa-pencil"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-secondary btn-sm"
|
<button type="button" class="btn btn-sm btn-secondary dropdown-toggle dropdown-toggle-split"
|
||||||
hx-get="{% url 'transactions_bulk_unpay' %}"
|
data-bs-toggle="dropdown" aria-expanded="false" data-bs-auto-close="outside">
|
||||||
hx-include=".transaction"
|
<span class="visually-hidden">{% trans "Toggle Dropdown" %}</span>
|
||||||
data-bs-toggle="tooltip"
|
|
||||||
data-bs-title="{% translate 'Mark as unpaid' %}">
|
|
||||||
<i class="fa-regular fa-circle tw-text-red-400"></i>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li>
|
||||||
|
<div class="dropdown-item px-3 tw-cursor-pointer"
|
||||||
|
hx-get="{% url 'transactions_bulk_unpay' %}"
|
||||||
|
hx-include=".transaction">
|
||||||
|
<i class="fa-regular fa-circle tw-text-red-400 fa-fw me-3"></i>{% translate 'Mark as unpaid' %}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div class="dropdown-item px-3 tw-cursor-pointer"
|
||||||
|
hx-get="{% url 'transactions_bulk_pay' %}"
|
||||||
|
hx-include=".transaction">
|
||||||
|
<i class="fa-regular fa-circle-check tw-text-green-400 fa-fw me-3"></i>{% translate 'Mark as paid' %}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="btn btn-secondary btn-sm"
|
||||||
|
hx-get="{% url 'transactions_bulk_clone' %}"
|
||||||
|
hx-include=".transaction"
|
||||||
|
data-bs-toggle="tooltip"
|
||||||
|
data-bs-title="{% translate 'Duplicate' %}">
|
||||||
|
<i class="fa-solid fa-clone fa-fw"></i>
|
||||||
|
</button>
|
||||||
<button class="btn btn-secondary btn-sm"
|
<button class="btn btn-secondary btn-sm"
|
||||||
hx-get="{% url 'transactions_bulk_delete' %}"
|
hx-get="{% url 'transactions_bulk_delete' %}"
|
||||||
hx-include=".transaction"
|
hx-include=".transaction"
|
||||||
@@ -55,69 +89,45 @@
|
|||||||
_="install prompt_swal">
|
_="install prompt_swal">
|
||||||
<i class="fa-solid fa-trash text-danger"></i>
|
<i class="fa-solid fa-trash text-danger"></i>
|
||||||
</button>
|
</button>
|
||||||
<div class="vr mx-3 tw-align-middle"></div>
|
<div class="vr tw-align-middle"></div>
|
||||||
{# <span _="on selected_transactions_updated from #actions-bar#}
|
|
||||||
{# set realTotal to 0.0#}
|
|
||||||
{# set flatTotal to 0.0#}
|
|
||||||
{# for transaction in <.transaction:has(input[name='transactions']:checked)/>#}
|
|
||||||
{# set amt to first <.main-amount .amount/> in transaction#}
|
|
||||||
{# set amountValue to parseFloat(amt.getAttribute('data-amount'))#}
|
|
||||||
{# if not isNaN(amountValue)#}
|
|
||||||
{# set flatTotal to flatTotal + (amountValue * 100)#}
|
|
||||||
{##}
|
|
||||||
{# if transaction match .income#}
|
|
||||||
{# set realTotal to realTotal + (amountValue * 100)#}
|
|
||||||
{# else#}
|
|
||||||
{# set realTotal to realTotal - (amountValue * 100)#}
|
|
||||||
{# end#}
|
|
||||||
{# end#}
|
|
||||||
{# end#}
|
|
||||||
{# set realTotal to realTotal / 100#}
|
|
||||||
{# put realTotal.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into me#}
|
|
||||||
{# end#}
|
|
||||||
{# on click#}
|
|
||||||
{# set original_value to my innerText#}
|
|
||||||
{# writeText(my innerText) on navigator.clipboard#}
|
|
||||||
{# put '{% translate "copied!" %}' into me#}
|
|
||||||
{# wait 1s#}
|
|
||||||
{# put original_value into me#}
|
|
||||||
{# end"#}
|
|
||||||
{# class="" role="button"></span>#}
|
|
||||||
<div class="btn-group"
|
<div class="btn-group"
|
||||||
_="on selected_transactions_updated from #actions-bar
|
_="on selected_transactions_updated from #actions-bar
|
||||||
set realTotal to 0.0
|
set realTotal to math.bignumber(0)
|
||||||
set flatTotal to 0.0
|
set flatTotal to math.bignumber(0)
|
||||||
set transactions to <.transaction:has(input[name='transactions']:checked)/>
|
set transactions to <.transaction:has(input[name='transactions']:checked)/>
|
||||||
set amountValues to []
|
set flatAmountValues to []
|
||||||
|
set realAmountValues to []
|
||||||
|
|
||||||
for transaction in transactions
|
for transaction in transactions
|
||||||
set amt to first <.main-amount .amount/> in transaction
|
set amt to first <.main-amount .amount/> in transaction
|
||||||
set amountValue to parseFloat(amt.getAttribute('data-amount'))
|
set amountValue to parseFloat(amt.getAttribute('data-amount'))
|
||||||
append amountValue to amountValues
|
append amountValue to flatAmountValues
|
||||||
|
|
||||||
if not isNaN(amountValue)
|
if not isNaN(amountValue)
|
||||||
set flatTotal to flatTotal + (amountValue * 100)
|
set flatTotal to math.chain(flatTotal).add(amountValue)
|
||||||
|
|
||||||
if transaction match .income
|
if transaction match .income
|
||||||
set realTotal to realTotal + (amountValue * 100)
|
append amountValue to realAmountValues
|
||||||
|
set realTotal to math.chain(realTotal).add(amountValue)
|
||||||
else
|
else
|
||||||
set realTotal to realTotal - (amountValue * 100)
|
append -amountValue to realAmountValues
|
||||||
|
set realTotal to math.chain(realTotal).subtract(amountValue)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
set mean to flatTotal.divide(flatAmountValues.length).done().toNumber()
|
||||||
|
set realTotal to realTotal.done().toNumber()
|
||||||
|
set flatTotal to flatTotal.done().toNumber()
|
||||||
|
|
||||||
set realTotal to realTotal / 100
|
|
||||||
put realTotal.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #real-total-front's innerText
|
put realTotal.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #real-total-front's innerText
|
||||||
put realTotal.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-real-total's innerText
|
put realTotal.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-real-total's innerText
|
||||||
set flatTotal to flatTotal / 100
|
|
||||||
put flatTotal.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-flat-total's innerText
|
put flatTotal.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-flat-total's innerText
|
||||||
log amountValues
|
put Math.max.apply(Math, realAmountValues).toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-max's innerText
|
||||||
put Math.max.apply(Math, amountValues) into #calc-menu-max's innerText
|
put Math.min.apply(Math, realAmountValues).toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-min's innerText
|
||||||
put Math.min.apply(Math, amountValues) into #calc-menu-min's innerText
|
put mean.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-mean's innerText
|
||||||
put flatTotal / amountValues.length into #calc-menu-mean's innerText
|
put flatAmountValues.length.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-count's innerText
|
||||||
put amountValues.length into #calc-menu-count's innerText
|
end">
|
||||||
end"
|
|
||||||
>
|
|
||||||
<button class="btn btn-secondary btn-sm" _="on click
|
<button class="btn btn-secondary btn-sm" _="on click
|
||||||
set original_value to #real-total-front's innerText
|
set original_value to #real-total-front's innerText
|
||||||
writeText(original_value) on navigator.clipboard
|
writeText(original_value) on navigator.clipboard
|
||||||
@@ -125,8 +135,8 @@
|
|||||||
wait 1s
|
wait 1s
|
||||||
put original_value into #real-total-front's innerText
|
put original_value into #real-total-front's innerText
|
||||||
end">
|
end">
|
||||||
<i class="fa-solid fa-plus fa-fw me-2 text-primary"></i>
|
<i class="fa-solid fa-plus fa-fw me-md-2 text-primary"></i>
|
||||||
<span id="real-total-front">0</span>
|
<span class="d-none d-md-inline-block" id="real-total-front">0</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-sm btn-secondary dropdown-toggle dropdown-toggle-split"
|
<button type="button" class="btn btn-sm btn-secondary dropdown-toggle dropdown-toggle-split"
|
||||||
data-bs-toggle="dropdown" aria-expanded="false" data-bs-auto-close="outside">
|
data-bs-toggle="dropdown" aria-expanded="false" data-bs-auto-close="outside">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label={% translate 'Close' %}></button>
|
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label={% translate 'Close' %}></button>
|
||||||
</div>
|
</div>
|
||||||
<div id="generic-offcanvas-body" class="offcanvas-body"
|
<div id="generic-offcanvas-body" class="offcanvas-body"
|
||||||
_="install init_tom_select">
|
_="install init_tom_select
|
||||||
|
install init_datepicker">
|
||||||
{% block body %}{% endblock %}
|
{% block body %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
19
app/templates/import_app/fragments/profiles/add.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{% extends 'extends/offcanvas.html' %}
|
||||||
|
{% load json %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block title %}{% translate 'Add new import profile' %}{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
{% if message %}
|
||||||
|
<div class="alert alert-info" role="alert" id="msg" hx-preserve="true">
|
||||||
|
<h6 class="alert-heading tw-italic tw-font-bold">{% trans 'A message from the author' %}</h6>
|
||||||
|
<hr>
|
||||||
|
<p class="mb-0">{{ message|linebreaksbr }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<form hx-post="{% url 'import_profiles_add' %}" hx-target="#generic-offcanvas" novalidate hx-vals='{"message": {% if message %}{{ message|json }}{% else %}""{% endif %}}'>
|
||||||
|
{% crispy form %}
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
11
app/templates/import_app/fragments/profiles/edit.html
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{% extends 'extends/offcanvas.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block title %}{% translate 'Edit import profile' %}{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<form hx-post="{% url 'import_profile_edit' profile_id=profile.id %}" hx-target="#generic-offcanvas" novalidate>
|
||||||
|
{% crispy form %}
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
90
app/templates/import_app/fragments/profiles/list.html
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
<div class="container px-md-3 py-3 column-gap-5">
|
||||||
|
<div class="tw-text-3xl fw-bold font-monospace tw-w-full mb-3">
|
||||||
|
{% spaceless %}
|
||||||
|
<div>{% translate 'Import Profiles' %}<span>
|
||||||
|
<span class="dropdown" data-bs-toggle="tooltip"
|
||||||
|
data-bs-title="{% translate "Add" %}">
|
||||||
|
<a class="text-decoration-none tw-text-2xl p-1" role="button"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
data-bs-title="{% translate "Add" %}" aria-expanded="false">
|
||||||
|
<i class="fa-solid fa-circle-plus fa-fw"></i>
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item"
|
||||||
|
role="button"
|
||||||
|
hx-get="{% url 'import_profiles_add' %}"
|
||||||
|
hx-target="#generic-offcanvas">{% trans 'New' %}</a></li>
|
||||||
|
<li><a class="dropdown-item"
|
||||||
|
role="button"
|
||||||
|
hx-get="{% url 'import_presets_list' %}"
|
||||||
|
hx-target="#persistent-generic-offcanvas-left">{% trans 'From preset' %}</a></li>
|
||||||
|
</ul>
|
||||||
|
</span>
|
||||||
|
</span></div>
|
||||||
|
{% endspaceless %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body table-responsive">
|
||||||
|
{% if profiles %}
|
||||||
|
<c-config.search></c-config.search>
|
||||||
|
<table class="table table-hover text-nowrap">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="col-auto"></th>
|
||||||
|
<th scope="col" class="col">{% translate 'Name' %}</th>
|
||||||
|
<th scope="col" class="col">{% translate 'Version' %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for profile in profiles %}
|
||||||
|
<tr class="profile">
|
||||||
|
<td class="col-auto">
|
||||||
|
<div class="btn-group" role="group" aria-label="{% translate 'Actions' %}">
|
||||||
|
<a class="btn btn-secondary btn-sm"
|
||||||
|
role="button"
|
||||||
|
data-bs-toggle="tooltip"
|
||||||
|
data-bs-title="{% translate "Edit" %}"
|
||||||
|
hx-get="{% url 'import_profile_edit' profile_id=profile.id %}"
|
||||||
|
hx-target="#generic-offcanvas">
|
||||||
|
<i class="fa-solid fa-pencil fa-fw"></i></a>
|
||||||
|
<a class="btn btn-secondary btn-sm text-success"
|
||||||
|
role="button"
|
||||||
|
data-bs-toggle="tooltip"
|
||||||
|
data-bs-title="{% translate "Runs" %}"
|
||||||
|
hx-get="{% url 'import_profile_runs_list' profile_id=profile.id %}"
|
||||||
|
hx-target="#persistent-generic-offcanvas-left">
|
||||||
|
<i class="fa-solid fa-person-running fa-fw"></i></a>
|
||||||
|
<a class="btn btn-secondary btn-sm text-primary"
|
||||||
|
role="button"
|
||||||
|
data-bs-toggle="tooltip"
|
||||||
|
data-bs-title="{% translate "Import" %}"
|
||||||
|
hx-get="{% url 'import_run_add' profile_id=profile.id %}"
|
||||||
|
hx-target="#generic-offcanvas">
|
||||||
|
<i class="fa-solid fa-file-import fa-fw"></i></a>
|
||||||
|
<a class="btn btn-secondary btn-sm text-danger"
|
||||||
|
role="button"
|
||||||
|
data-bs-toggle="tooltip"
|
||||||
|
data-bs-title="{% translate "Delete" %}"
|
||||||
|
hx-delete="{% url 'import_profile_delete' profile_id=profile.id %}"
|
||||||
|
hx-trigger='confirmed'
|
||||||
|
data-bypass-on-ctrl="true"
|
||||||
|
data-title="{% translate "Are you sure?" %}"
|
||||||
|
data-text="{% translate "You won't be able to revert this!" %}"
|
||||||
|
data-confirm-text="{% translate "Yes, delete it!" %}"
|
||||||
|
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="col">{{ profile.name }}</td>
|
||||||
|
<td class="col">{{ profile.get_version_display }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<c-msg.empty title="{% translate "No import profiles" %}" remove-padding></c-msg.empty>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{% extends 'extends/offcanvas.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block title %}{% translate 'Import Presets' %}{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
{% if presets %}
|
||||||
|
<div id="search" class="mb-3">
|
||||||
|
<label class="w-100">
|
||||||
|
<input type="search"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="{% translate 'Search' %}"
|
||||||
|
_="on input or search
|
||||||
|
show < .col /> in <#items/>
|
||||||
|
when its textContent.toLowerCase() contains my value.toLowerCase()"/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="row row-cols-1 g-4" id="items">
|
||||||
|
{% for preset in presets %}
|
||||||
|
<a class="text-decoration-none"
|
||||||
|
role="button"
|
||||||
|
hx-post="{% url 'import_profiles_add' %}"
|
||||||
|
hx-vals='{"yaml_config": {{ preset.config }}, "name": "{{ preset.name }}", "version": "{{ preset.schema_version }}", "message": {{ preset.message }}}'
|
||||||
|
hx-target="#generic-offcanvas">
|
||||||
|
|
||||||
|
<div class="col">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">{{ preset.name }}</h5>
|
||||||
|
<hr>
|
||||||
|
<p>{{ preset.description }}</p>
|
||||||
|
<p>{% trans 'By' %} {{ preset.authors|join:", " }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<c-msg.empty title="{% translate "No presets yet" %}"></c-msg.empty>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
11
app/templates/import_app/fragments/runs/add.html
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{% extends 'extends/offcanvas.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block title %}{% translate 'Import file with profile' %} {{ profile.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<form hx-post="{% url 'import_run_add' profile_id=profile.id %}" hx-target="#generic-offcanvas" enctype="multipart/form-data" novalidate>
|
||||||
|
{% crispy form %}
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
120
app/templates/import_app/fragments/runs/list.html
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
{% extends 'extends/offcanvas.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block title %}{% translate 'Runs for' %} {{ profile.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div hx-get="{% url "import_profile_runs_list" profile_id=profile.id %}"
|
||||||
|
hx-trigger="updated from:window"
|
||||||
|
hx-target="closest .offcanvas"
|
||||||
|
class="show-loading"
|
||||||
|
hx-swap="show:none scroll:none">
|
||||||
|
{% if runs %}
|
||||||
|
<div class="row row-cols-1 g-4">
|
||||||
|
{% for run in runs %}
|
||||||
|
<div class="col">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header tw-text-sm {% if run.status == run.Status.QUEUED %}tw-text-white{% elif run.status == run.Status.PROCESSING %}text-warning{% elif run.status == run.Status.FINISHED %}text-success{% else %}text-danger{% endif %}">
|
||||||
|
<span><i class="fa-solid {% if run.status == run.Status.QUEUED %}fa-hourglass-half{% elif run.status == run.Status.PROCESSING %}fa-spinner{% elif run.status == run.Status.FINISHED %}fa-check{% else %}fa-xmark{% endif %} fa-fw me-2"></i>{{ run.get_status_display }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title"><i class="fa-solid fa-hashtag me-1 tw-text-xs tw-text-gray-400"></i>{{ run.id }}<span class="tw-text-xs tw-text-gray-400 ms-1">({{ run.file_name }})</span></h5>
|
||||||
|
<hr>
|
||||||
|
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 w-100 g-4">
|
||||||
|
<div class="col">
|
||||||
|
<div class="d-flex flex-row">
|
||||||
|
<div class="d-flex flex-column">
|
||||||
|
<div class="text-body-secondary tw-text-xs tw-font-medium">
|
||||||
|
{% trans 'Total Items' %}
|
||||||
|
</div>
|
||||||
|
<div class="tw-text-sm">
|
||||||
|
{{ run.total_rows }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col">
|
||||||
|
<div class="d-flex flex-row">
|
||||||
|
<div class="d-flex flex-column">
|
||||||
|
<div class="text-body-secondary tw-text-xs tw-font-medium">
|
||||||
|
{% trans 'Processed Items' %}
|
||||||
|
</div>
|
||||||
|
<div class="tw-text-sm">
|
||||||
|
{{ run.processed_rows }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col">
|
||||||
|
<div class="d-flex flex-row">
|
||||||
|
<div class="d-flex flex-column">
|
||||||
|
<div class="text-body-secondary tw-text-xs tw-font-medium">
|
||||||
|
{% trans 'Skipped Items' %}
|
||||||
|
</div>
|
||||||
|
<div class="tw-text-sm">
|
||||||
|
{{ run.skipped_rows }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col">
|
||||||
|
<div class="d-flex flex-row">
|
||||||
|
<div class="d-flex flex-column">
|
||||||
|
<div class="text-body-secondary tw-text-xs tw-font-medium">
|
||||||
|
{% trans 'Failed Items' %}
|
||||||
|
</div>
|
||||||
|
<div class="tw-text-sm">
|
||||||
|
{{ run.failed_rows }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col">
|
||||||
|
<div class="d-flex flex-row">
|
||||||
|
<div class="d-flex flex-column">
|
||||||
|
<div class="text-body-secondary tw-text-xs tw-font-medium">
|
||||||
|
{% trans 'Successful Items' %}
|
||||||
|
</div>
|
||||||
|
<div class="tw-text-sm">
|
||||||
|
{{ run.successful_rows }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer text-body-secondary">
|
||||||
|
<a class="text-decoration-none text-info"
|
||||||
|
role="button"
|
||||||
|
data-bs-toggle="tooltip"
|
||||||
|
data-bs-title="{% translate "Logs" %}"
|
||||||
|
hx-get="{% url 'import_run_log' profile_id=profile.id run_id=run.id %}"
|
||||||
|
hx-target="#generic-offcanvas"><i class="fa-solid fa-file-lines"></i></a>
|
||||||
|
<a class="text-decoration-none text-danger"
|
||||||
|
role="button"
|
||||||
|
data-bs-toggle="tooltip"
|
||||||
|
data-bs-title="{% translate "Delete" %}"
|
||||||
|
hx-delete="{% url 'import_run_delete' profile_id=profile.id run_id=run.id %}"
|
||||||
|
hx-trigger='confirmed'
|
||||||
|
data-bypass-on-ctrl="true"
|
||||||
|
data-title="{% translate "Are you sure?" %}"
|
||||||
|
data-text="{% translate "You won't be able to revert this! All imported items will be kept." %}"
|
||||||
|
data-confirm-text="{% translate "Yes, delete it!" %}"
|
||||||
|
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<c-msg.empty title="{% translate "No runs yet" %}"></c-msg.empty>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
13
app/templates/import_app/fragments/runs/log.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{% extends 'extends/offcanvas.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block title %}{% translate 'Logs for' %} #{{ run.id }}{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="card tw-max-h-full tw-overflow-auto">
|
||||||
|
<div class="card-body">
|
||||||
|
{{ run.logs|linebreaks }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
8
app/templates/import_app/pages/profiles_index.html
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block title %}{% translate 'Import Profiles' %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div hx-get="{% url 'import_profiles_list' %}" hx-trigger="load, updated from:window" class="show-loading"></div>
|
||||||
|
{% endblock %}
|
||||||
@@ -12,7 +12,6 @@
|
|||||||
<link rel="icon" type="image/png" sizes="32x32" href="{% static 'img/favicon/favicon-32x32.png' %}">
|
<link rel="icon" type="image/png" sizes="32x32" href="{% static 'img/favicon/favicon-32x32.png' %}">
|
||||||
<link rel="icon" type="image/png" sizes="96x96" href="{% static 'img/favicon/favicon-96x96.png' %}">
|
<link rel="icon" type="image/png" sizes="96x96" href="{% static 'img/favicon/favicon-96x96.png' %}">
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="{% static 'img/favicon/favicon-16x16.png' %}">
|
<link rel="icon" type="image/png" sizes="16x16" href="{% static 'img/favicon/favicon-16x16.png' %}">
|
||||||
<link rel="manifest" href="{% static 'img/favicon/manifest.json' %}">
|
|
||||||
<meta name="msapplication-TileColor" content="#ffffff">
|
<meta name="msapplication-TileColor" content="#ffffff">
|
||||||
<meta name="msapplication-TileImage" content="{% static 'img/favicon/ms-icon-144x144.png' %}">
|
<meta name="msapplication-TileImage" content="{% static 'img/favicon/ms-icon-144x144.png' %}">
|
||||||
<meta name="theme-color" content="#ffffff">
|
<meta name="theme-color" content="#ffffff">
|
||||||
|
|||||||
@@ -120,6 +120,8 @@
|
|||||||
<li><h6 class="dropdown-header">{% trans 'Automation' %}</h6></li>
|
<li><h6 class="dropdown-header">{% trans 'Automation' %}</h6></li>
|
||||||
<li><a class="dropdown-item {% active_link views='rules_index' %}"
|
<li><a class="dropdown-item {% active_link views='rules_index' %}"
|
||||||
href="{% url 'rules_index' %}">{% translate 'Rules' %}</a></li>
|
href="{% url 'rules_index' %}">{% translate 'Rules' %}</a></li>
|
||||||
|
<li><a class="dropdown-item {% active_link views='import_profiles_index' %}"
|
||||||
|
href="{% url 'import_profiles_index' %}">{% translate 'Import' %} <span class="badge text-bg-primary">beta</span></a></li>
|
||||||
<li>
|
<li>
|
||||||
<hr class="dropdown-divider">
|
<hr class="dropdown-divider">
|
||||||
</li>
|
</li>
|
||||||
|
|||||||