mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-07-19 19:21:15 +02:00
Compare commits
428 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c14ce6366 | |||
| 8c133f92ce | |||
| 2dd887b0d9 | |||
| f3c9d8faea | |||
| 8be7758dc0 | |||
| 8f5204a17b | |||
| 05dd782df5 | |||
| 187fe43283 | |||
| cae73376db | |||
| 7225454a6e | |||
| 70c8c1e07c | |||
| 2235bdeabb | |||
| d724300513 | |||
| eacafa1def | |||
| c738f5ee29 | |||
| c392a2c988 | |||
| 17ea859fd2 | |||
| 8aae6f928f | |||
| 7c43b06b9f | |||
| 72904266bf | |||
| e16e279911 | |||
| 670bee4325 | |||
| 3e2c1184ce | |||
| 731f351eef | |||
| b7056e7aa1 | |||
| accceed630 | |||
| 76346cb503 | |||
| 3df8952ea2 | |||
| 9bd067da96 | |||
| 1abe9e9f62 | |||
| 1a86b5dea4 | |||
| 8f2f5a16c2 | |||
| 4565dc770b | |||
| 23673def09 | |||
| dd2b9ead7e | |||
| 2078e9f3e4 | |||
| e6bab57ab4 | |||
| 38d50a78f4 | |||
| 0d947f9ba6 | |||
| 99c85a56bb | |||
| ab1c074f27 | |||
| abf3a148cc | |||
| 2733c92da5 | |||
| 9bfbe54ed5 | |||
| 5b27dea07c | |||
| 791e1000a3 | |||
| 7301d9f475 | |||
| 47a44e96f8 | |||
| 7d247eb737 | |||
| 373616e7bb | |||
| bf3c11d582 | |||
| b4b1c10db9 | |||
| 5ca531f47d | |||
| 07673cb528 | |||
| 67c6d81897 | |||
| 3c85da46b0 | |||
| d263936be7 | |||
| 1524063d5a | |||
| c3a403b8f0 | |||
| 1c1018adae | |||
| 350d5adf25 | |||
| 7e4defb9cc | |||
| 7121e4bc09 | |||
| 4540e48fe5 | |||
| d06b51421f | |||
| e096912e41 | |||
| f0ad6e16fe | |||
| 734a302fa7 | |||
| 89b1b7bcb7 | |||
| 37b40f89bb | |||
| 0c63552d1b | |||
| 7db517e848 | |||
| 7e3ed6cf94 | |||
| e10a88c00e | |||
| b912a33b93 | |||
| d9fb3627cc | |||
| 78ffa68ba4 | |||
| 37f4ead058 | |||
| 61630fca5b | |||
| 910d4c84a3 | |||
| be1f29d8c1 | |||
| 9784d840cc | |||
| db5ce13ff3 | |||
| a2b943d949 | |||
| d8098b4486 | |||
| f8cff6623f | |||
| 65c61f76ff | |||
| 74899f63ab | |||
| 66a5e6d613 | |||
| e0ab32ec03 | |||
| a912e4a511 | |||
| 57ba672c91 | |||
| 20c6989ffb | |||
| c6cd525c49 | |||
| 55c4b920ee | |||
| 7f8261b9cc | |||
| 9102654eab | |||
| 1ff49a8a04 | |||
| 846dd1fd73 | |||
| 9eed3b6692 | |||
| b7c53a3c2d | |||
| b378c8f6f7 | |||
| ccc4deb1d8 | |||
| d3ecf55375 | |||
| 580f3e7345 | |||
| 0e5843094b | |||
| ed65945d19 | |||
| 18d8837c64 | |||
| 067d819077 | |||
| bbaae4746a | |||
| d2e5c1d6b3 | |||
| ffef61d514 | |||
| 9020f6f972 | |||
| 540235c1b0 | |||
| 9070bc5705 | |||
| ba5a6c9772 | |||
| 2f33b5989f | |||
| 5f24d05540 | |||
| 31cf62e277 | |||
| 15d990007e | |||
| 3d5bc9cd3f | |||
| a544dc4943 | |||
| b1178198e9 | |||
| 02a488bfff | |||
| b05285947b | |||
| d7b7dd28c7 | |||
| 9353d498ef | |||
| 4f6903e8e4 | |||
| 7d3d6ea2fc | |||
| cce9c7a7a5 | |||
| f80f74a01a | |||
| df47ffc49c | |||
| 4f35647a22 | |||
| 368342853f | |||
| 5a675f674d | |||
| 9ef8fdec49 | |||
| f29a8d8bc0 | |||
| 8c43365ec0 | |||
| 2cdcc4ee26 | |||
| 84852012f9 | |||
| edf0e2c66f | |||
| f90a31f2b9 | |||
| dd1f6a6ef2 | |||
| 57f98ba171 | |||
| f2e93f7df9 | |||
| 26cfa493b3 | |||
| c6e003ed86 | |||
| c09ad0e49d | |||
| 9250131396 | |||
| 5f503149ce | |||
| d45b4f2942 | |||
| 4a8493c7d9 | |||
| c39c3ccacb | |||
| 4bb8ef6582 | |||
| d711ccca69 | |||
| 76d59f1038 | |||
| 5b6c123fa1 | |||
| 782ab11ae4 | |||
| 8db885f47d | |||
| 01bd8710d8 | |||
| 569d08711c | |||
| a285f055e4 | |||
| 6aae9b1207 | |||
| 9d2206f8a4 | |||
| d7e3c50c2c | |||
| 789fd4eb80 | |||
| 586b3a5d44 | |||
| 9248e8bd77 | |||
| c44247f6a5 | |||
| 8ba89434f8 | |||
| f2f41981a3 | |||
| 1153fd6b0a | |||
| 76822224a0 | |||
| 31b2b98eb9 | |||
| d7a4e79321 | |||
| 985f07e792 | |||
| 5465bb1eeb | |||
| 451a85a998 | |||
| 54c74e7c07 | |||
| d6e9e123b7 | |||
| 80c9c43a02 | |||
| 3e34f088fc | |||
| 5b9e5c6003 | |||
| c266b8809f | |||
| 8cda4116bc | |||
| c2510b2261 | |||
| dcdaf756f9 | |||
| 50ca08165a | |||
| f85618fa01 | |||
| 635f87a8ad | |||
| 1a073ba53d | |||
| 5412e5b12c | |||
| 2103ba1b38 | |||
| 04fb15224c | |||
| 2fc526beac | |||
| cc3ca4f4a3 | |||
| 8d3844c431 | |||
| 5e7e918085 | |||
| c3f02320b5 | |||
| da8bbbfb0b | |||
| e3f74538d2 | |||
| d8234950c6 | |||
| 58f19ce1ca | |||
| ef5f3580a0 | |||
| efe0f99cb4 | |||
| dccb5079ad | |||
| 6c90150661 | |||
| c33d6fab69 | |||
| c0c57a6d77 | |||
| f19d58a2bd | |||
| dfe99093e9 | |||
| d737e573cc | |||
| 805d3f419e | |||
| 9777aac746 | |||
| 61b782104d | |||
| 79dec2b515 | |||
| db23e162c4 | |||
| d81d89d9f6 | |||
| 6826cfe79a | |||
| 0832ec75ca | |||
| 3090f632de | |||
| 6b4fbee7a6 | |||
| e7fe6622cd | |||
| 3017593ed5 | |||
| ceb8e9ea97 | |||
| 9b5b7683dd | |||
| 514600e34a | |||
| 07dd805b07 | |||
| 905e9b4c54 | |||
| 60d367dec5 | |||
| 6e0842a697 | |||
| 858934b7c5 | |||
| 47d9e4078c | |||
| fa6f3e87c0 | |||
| 5f101af879 | |||
| b27633a28e | |||
| 7716eee0b3 | |||
| 37c447ae0a | |||
| e544d7068b | |||
| 8d93da99c1 | |||
| cc87477a2e | |||
| e86e0b8c08 | |||
| eb0c872c50 | |||
| b4578df242 | |||
| 756de12835 | |||
| d573d02657 | |||
| 250b352217 | |||
| b4e9446cf6 | |||
| 90944f0179 | |||
| 008d34b1d0 | |||
| 46dfc7dad4 | |||
| 22900b5d9e | |||
| 0c48e9fe3d | |||
| b2e100d1b0 | |||
| e49b38a442 | |||
| 1f2902eea9 | |||
| 7d60db8716 | |||
| 873b0baed7 | |||
| 2313c97761 | |||
| 9cd7337153 | |||
| d3b354e2b8 | |||
| e137666e99 | |||
| 4291a5b97d | |||
| c8d316857f | |||
| 3395a96949 | |||
| 8ab9624619 | |||
| f9056c3a45 | |||
| a9df684ee2 | |||
| e4d07c94d4 | |||
| 5d5d172b3b | |||
| 99f746b6be | |||
| a461a33dc2 | |||
| 1213ffebeb | |||
| c5a352cf4d | |||
| cfcca54aa6 | |||
| 234f8cd669 | |||
| 43184140f0 | |||
| acc325c150 | |||
| 46eb471a34 | |||
| 6dc14c73d6 | |||
| f942924e7c | |||
| aa6019e0a9 | |||
| 9dfbd346bc | |||
| 73b1d36dfd | |||
| 3662fb030a | |||
| a423ee1032 | |||
| 72eb59d24f | |||
| 1a0247e028 | |||
| 281a0fccda | |||
| 59ce50299a | |||
| be89509beb | |||
| 80cded234d | |||
| 030bb63586 | |||
| 66e8fc5884 | |||
| 363047337d | |||
| c7e32d1576 | |||
| 157e59a1d1 | |||
| d9c505ac79 | |||
| 7274a13f3c | |||
| 5d64665ddd | |||
| e0d92d15c8 | |||
| 48dd658627 | |||
| 80dbbd02f0 | |||
| 4b7ca61c29 | |||
| b2f04ae1f9 | |||
| f34d4b5e28 | |||
| d2ebfbd615 | |||
| 812abbe488 | |||
| 9602a4affc | |||
| bf548c0747 | |||
| 55ad2be08b | |||
| 2cd58c2464 | |||
| 4675ba9d56 | |||
| a25c992d5c | |||
| 2eadfe99a5 | |||
| 11086a726f | |||
| cd99b40b0a | |||
| 63aa51dc0d | |||
| 4708c5bc7e | |||
| 5a8462c050 | |||
| 6cac02e01f | |||
| 8d12ceeebb | |||
| 4681d3ca1d | |||
| 60ded03ea9 | |||
| b20d137dc3 | |||
| 29ca6eed6c | |||
| fa85303f36 | |||
| a5f4f43678 | |||
| d807bd5da3 | |||
| 85314fb749 | |||
| c4d5e93a41 | |||
| 86f0c4365e | |||
| 202592b940 | |||
| aea149bd13 | |||
| 411365f101 | |||
| 2008476021 | |||
| 53afe5b8eb | |||
| 6193c7a048 | |||
| 41f81d90d7 | |||
| bf623cf16b | |||
| ec213330cd | |||
| 7aedf524c6 | |||
| 04602b1964 | |||
| 15cfc4f300 | |||
| 3463c7c62c | |||
| 7b76c10093 | |||
| 7ad26a2e7b | |||
| 7706ca2d5f | |||
| 56198e93ce | |||
| a74323f739 | |||
| e4efde177b | |||
| 5871a03ee2 | |||
| 67af4430e1 | |||
| 696dcdf951 | |||
| e35bad0e08 | |||
| 904f7cac22 | |||
| ccd73963ca | |||
| b5469b0413 | |||
| dae848d951 | |||
| 45a33ad0c0 | |||
| 89e50b17bd | |||
| ac54ba3da1 | |||
| 2da610f15e | |||
| 4ab6c4c6b6 | |||
| 68dbedd938 | |||
| 2800c53346 | |||
| 132547a074 | |||
| 61ed87dc45 | |||
| 96c1227c4f | |||
| 33f1ac1785 | |||
| e9e94a8343 | |||
| ba24a53853 | |||
| 4955fbde33 | |||
| d04067a91d | |||
| 01333a439b | |||
| d26907ea94 | |||
| c98d9d3ce9 | |||
| bfa4d3dea3 | |||
| 90323049eb | |||
| b62122ed23 | |||
| f74946cba7 | |||
| 585652064a | |||
| ea6f61d5e4 | |||
| e986f7d802 | |||
| 26b218ae51 | |||
| 19f0bc1034 | |||
| 47d34f3c27 | |||
| 046e02d506 | |||
| 92c7a29b6a | |||
| d95e5f71cc | |||
| 992c518dab | |||
| 29aa1c9d2b | |||
| 1b3b7a583d | |||
| 2d22f961ad | |||
| 71551d7651 | |||
| 62d58d1be3 | |||
| 21917437f2 | |||
| 59acb14d05 | |||
| 050f794f2b | |||
| a5958c0937 | |||
| ee73ada5ae | |||
| 736a116685 | |||
| 6c03c7b4eb | |||
| 960e537709 | |||
| e32285ce75 | |||
| 73e8fdbf04 | |||
| d4c15da051 | |||
| 187b3174d2 | |||
| c90ea7ef16 | |||
| 54713ecfe2 | |||
| cf693aa0c3 | |||
| 3580f1b132 | |||
| febd9a8ae7 | |||
| 3809f82b60 | |||
| 3c6b52462a | |||
| cc8a4c97a9 | |||
| 99fbb5f7db | |||
| 3d61068ecf | |||
| f6f06f4d65 | |||
| 56346c26ee | |||
| 23b74d73e5 | |||
| 17697dc565 | |||
| e9bc35d9b2 | |||
| d6fbb71f41 | |||
| 9a9cf75bcd | |||
| d6a8658fe1 | |||
| 211963ea7d | |||
| 776068a438 |
@@ -10,6 +10,11 @@ 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
|
||||||
|
|
||||||
|
# Uncomment these variables to automatically create an admin account using these credentials on startup.
|
||||||
|
# After your first successfull login you can remove these variables from your file for safety reasons.
|
||||||
|
#ADMIN_EMAIL=<ENTER YOUR EMAIL>
|
||||||
|
#ADMIN_PASSWORD=<YOUR SAFE PASSWORD>
|
||||||
|
|
||||||
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>
|
||||||
@@ -26,3 +31,10 @@ ENABLE_SOFT_DELETE=false
|
|||||||
KEEP_DELETED_TRANSACTIONS_FOR=365
|
KEEP_DELETED_TRANSACTIONS_FOR=365
|
||||||
|
|
||||||
TASK_WORKERS=1 # This only work if you're using the single container option. Increase to have more open queues via procrastinate, you probably don't need to increase this.
|
TASK_WORKERS=1 # This only work if you're using the single container option. Increase to have more open queues via procrastinate, you probably don't need to increase this.
|
||||||
|
|
||||||
|
# OIDC Configuration. Uncomment the lines below if you want to add OIDC login to your instance
|
||||||
|
#OIDC_CLIENT_NAME=""
|
||||||
|
#OIDC_CLIENT_ID=""
|
||||||
|
#OIDC_CLIENT_SECRET=""
|
||||||
|
#OIDC_SERVER_URL=""
|
||||||
|
#OIDC_ALLOW_SIGNUP=true
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
github: eitchtee
|
||||||
|
custom: ["https://www.paypal.com/donate/?hosted_button_id=FFWM4W9NQDMM6"]
|
||||||
@@ -29,15 +29,15 @@ Managing money can feel unnecessarily complex, but it doesn’t have to be. WYGI
|
|||||||
|
|
||||||
By sticking to this straightforward approach, you avoid dipping into your savings while still keeping tabs on where your money goes.
|
By sticking to this straightforward approach, you avoid dipping into your savings while still keeping tabs on where your money goes.
|
||||||
|
|
||||||
While this philosophy is simple, finding tools to make it work wasn’t. I initially used a spreadsheet, which served me well for years—until it became unwieldy as I started managing multiple currencies, accounts, and investments. I tried various financial management apps, but none met my key requirements:
|
While this philosophy is simple, finding tools to make it work wasn’t. I initially used a spreadsheet, which served me well for years, until it became unwieldy as I started managing multiple currencies, accounts, and investments. I tried various financial management apps, but none met my key requirements:
|
||||||
|
|
||||||
1. **Multi-currency support** to track income and expenses in different currencies.
|
1. **Multi-currency support** to track income and expenses in different currencies.
|
||||||
2. **Not a budgeting app** — as I dislike budgeting constraints.
|
2. **Not a budgeting app** as I dislike budgeting constraints.
|
||||||
3. **Web app usability** (ideally with mobile support, though optional).
|
3. **Web app usability** (ideally with mobile support, though optional).
|
||||||
4. **Automation-ready API** to integrate with other tools and services.
|
4. **Automation-ready API** to integrate with other tools and services.
|
||||||
5. **Custom transaction rules** for credit card billing cycles or similar quirks.
|
5. **Custom transaction rules** for credit card billing cycles or similar quirks.
|
||||||
|
|
||||||
Frustrated by the lack of comprehensive options, I set out to build **WYGIWYH** — an opinionated yet powerful tool that I believe will resonate with like-minded users.
|
Frustrated by the lack of comprehensive options, I set out to build **WYGIWYH**, an opinionated yet powerful tool that I believe will resonate with like-minded users.
|
||||||
|
|
||||||
# Key Features
|
# Key Features
|
||||||
|
|
||||||
@@ -51,6 +51,17 @@ Frustrated by the lack of comprehensive options, I set out to build **WYGIWYH**
|
|||||||
* **Built-in Dollar-Cost Average (DCA) tracker**: Essential for tracking recurring investments, especially for crypto and stocks.
|
* **Built-in Dollar-Cost Average (DCA) tracker**: Essential for tracking recurring investments, especially for crypto and stocks.
|
||||||
* **API support for automation**: Seamlessly integrate with existing services to synchronize transactions.
|
* **API support for automation**: Seamlessly integrate with existing services to synchronize transactions.
|
||||||
|
|
||||||
|
# Demo
|
||||||
|
|
||||||
|
You can try WYGIWYH on [wygiwyh-demo.herculino.com](https://wygiwyh-demo.herculino.com/) with the credentials below:
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> E-mail: `demo@demo.com`
|
||||||
|
>
|
||||||
|
> Password: `wygiwyhdemo`
|
||||||
|
|
||||||
|
Keep in mind that **any data you add will be wiped in 24 hours or less**. And that **most automation features like the API, Rules, Automatic Exchange Rates and Import/Export are disabled**.
|
||||||
|
|
||||||
# How To Use
|
# How To Use
|
||||||
|
|
||||||
To run this application, you'll need [Docker](https://docs.docker.com/engine/install/) with [docker-compose](https://docs.docker.com/compose/install/).
|
To run this application, you'll need [Docker](https://docs.docker.com/engine/install/) with [docker-compose](https://docs.docker.com/compose/install/).
|
||||||
@@ -76,7 +87,7 @@ $ nano .env # or any other editor you want to use
|
|||||||
# Run the app
|
# Run the app
|
||||||
$ docker compose up -d
|
$ docker compose up -d
|
||||||
|
|
||||||
# Create the first admin account
|
# Create the first admin account. This isn't required if you set the enviroment variables: ADMIN_EMAIL and ADMIN_PASSWORD.
|
||||||
$ docker compose exec -it web python manage.py createsuperuser
|
$ docker compose exec -it web python manage.py createsuperuser
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -129,6 +140,35 @@ To create the first user, open the container's console using Unraid's UI, by cli
|
|||||||
| ENABLE_SOFT_DELETE | true\|false | false | Whether to enable transactions soft delete, if enabled, deleted transactions will remain in the database. Useful for imports and avoiding duplicate entries. |
|
| ENABLE_SOFT_DELETE | true\|false | false | Whether to enable transactions soft delete, if enabled, deleted transactions will remain in the database. Useful for imports and avoiding duplicate entries. |
|
||||||
| KEEP_DELETED_TRANSACTIONS_FOR | int | 365 | Time in days to keep soft deleted transactions for. If 0, will keep all transactions indefinitely. Only works if ENABLE_SOFT_DELETE is true. |
|
| KEEP_DELETED_TRANSACTIONS_FOR | int | 365 | Time in days to keep soft deleted transactions for. If 0, will keep all transactions indefinitely. Only works if ENABLE_SOFT_DELETE is true. |
|
||||||
| TASK_WORKERS | int | 1 | How many workers to have for async tasks. One should be enough for most use cases |
|
| TASK_WORKERS | int | 1 | How many workers to have for async tasks. One should be enough for most use cases |
|
||||||
|
| DEMO | true\|false | false | If demo mode is enabled. |
|
||||||
|
| ADMIN_EMAIL | string | None | Automatically creates an admin account with this email. Must have `ADMIN_PASSWORD` also set. |
|
||||||
|
| ADMIN_PASSWORD | string | None | Automatically creates an admin account with this password. Must have `ADMIN_EMAIL` also set. |
|
||||||
|
| CHECK_FOR_UPDATES | bool | true | Check and notify users about new versions. The check is done by doing a single query to Github's API every 12 hours. |
|
||||||
|
|
||||||
|
## OIDC Configuration
|
||||||
|
|
||||||
|
WYGIWYH supports login via OpenID Connect (OIDC) through `django-allauth`. This allows users to authenticate using an external OIDC provider.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Currently only OpenID Connect is supported as a provider, open an issue if you need something else.
|
||||||
|
|
||||||
|
To configure OIDC, you need to set the following environment variables:
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
|
| `OIDC_CLIENT_NAME` | The name of the provider. will be displayed in the login page. Defaults to `OpenID Connect` |
|
||||||
|
| `OIDC_CLIENT_ID` | The Client ID provided by your OIDC provider. |
|
||||||
|
| `OIDC_CLIENT_SECRET` | The Client Secret provided by your OIDC provider. |
|
||||||
|
| `OIDC_SERVER_URL` | The base URL of your OIDC provider's discovery document or authorization server (e.g., `https://your-provider.com/auth/realms/your-realm`). `django-allauth` will use this to discover the necessary endpoints (authorization, token, userinfo, etc.). |
|
||||||
|
| `OIDC_ALLOW_SIGNUP` | Allow the automatic creation of inexistent accounts on a successfull authentication. Defaults to `true`. |
|
||||||
|
|
||||||
|
**Callback URL (Redirect URI):**
|
||||||
|
|
||||||
|
When configuring your OIDC provider, you will need to provide a callback URL (also known as a Redirect URI). For WYGIWYH, the default callback URL is:
|
||||||
|
|
||||||
|
`https://your.wygiwyh.domain/auth/oidc/<OIDC_CLIENT_NAME>/login/callback/`
|
||||||
|
|
||||||
|
Replace `https://your.wygiwyh.domain` with the actual URL where your WYGIWYH instance is accessible. And `<OIDC_CLIENT_NAME>` with the slugfied value set in OIDC_CLIENT_NAME or the default `openid-connect` if you haven't set this variable.
|
||||||
|
|
||||||
# How it works
|
# How it works
|
||||||
|
|
||||||
|
|||||||
+150
-3
@@ -14,6 +14,7 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.utils.text import slugify
|
||||||
|
|
||||||
SITE_TITLE = "WYGIWYH"
|
SITE_TITLE = "WYGIWYH"
|
||||||
TITLE_SEPARATOR = "::"
|
TITLE_SEPARATOR = "::"
|
||||||
@@ -42,6 +43,7 @@ INSTALLED_APPS = [
|
|||||||
"django.contrib.contenttypes",
|
"django.contrib.contenttypes",
|
||||||
"django.contrib.sessions",
|
"django.contrib.sessions",
|
||||||
"django.contrib.messages",
|
"django.contrib.messages",
|
||||||
|
"django.contrib.sites",
|
||||||
"whitenoise.runserver_nostatic",
|
"whitenoise.runserver_nostatic",
|
||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
"webpack_boilerplate",
|
"webpack_boilerplate",
|
||||||
@@ -61,7 +63,6 @@ INSTALLED_APPS = [
|
|||||||
"apps.transactions.apps.TransactionsConfig",
|
"apps.transactions.apps.TransactionsConfig",
|
||||||
"apps.currencies.apps.CurrenciesConfig",
|
"apps.currencies.apps.CurrenciesConfig",
|
||||||
"apps.accounts.apps.AccountsConfig",
|
"apps.accounts.apps.AccountsConfig",
|
||||||
"apps.common.apps.CommonConfig",
|
|
||||||
"apps.net_worth.apps.NetWorthConfig",
|
"apps.net_worth.apps.NetWorthConfig",
|
||||||
"apps.import_app.apps.ImportConfig",
|
"apps.import_app.apps.ImportConfig",
|
||||||
"apps.export_app.apps.ExportConfig",
|
"apps.export_app.apps.ExportConfig",
|
||||||
@@ -74,8 +75,15 @@ INSTALLED_APPS = [
|
|||||||
"apps.calendar_view.apps.CalendarViewConfig",
|
"apps.calendar_view.apps.CalendarViewConfig",
|
||||||
"apps.dca.apps.DcaConfig",
|
"apps.dca.apps.DcaConfig",
|
||||||
"pwa",
|
"pwa",
|
||||||
|
"allauth",
|
||||||
|
"allauth.account",
|
||||||
|
"allauth.socialaccount",
|
||||||
|
"allauth.socialaccount.providers.openid_connect",
|
||||||
|
"apps.common.apps.CommonConfig",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
SITE_ID = 1
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
"django_browser_reload.middleware.BrowserReloadMiddleware",
|
"django_browser_reload.middleware.BrowserReloadMiddleware",
|
||||||
"apps.common.middleware.thread_local.ThreadLocalMiddleware",
|
"apps.common.middleware.thread_local.ThreadLocalMiddleware",
|
||||||
@@ -91,6 +99,7 @@ MIDDLEWARE = [
|
|||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
"hijack.middleware.HijackUserMiddleware",
|
"hijack.middleware.HijackUserMiddleware",
|
||||||
|
"allauth.account.middleware.AccountMiddleware",
|
||||||
]
|
]
|
||||||
|
|
||||||
ROOT_URLCONF = "WYGIWYH.urls"
|
ROOT_URLCONF = "WYGIWYH.urls"
|
||||||
@@ -163,10 +172,105 @@ AUTH_USER_MODEL = "users.User"
|
|||||||
|
|
||||||
LANGUAGE_CODE = "en"
|
LANGUAGE_CODE = "en"
|
||||||
LANGUAGES = (
|
LANGUAGES = (
|
||||||
|
("af", "Afrikaans"),
|
||||||
|
("ar", "العربية"),
|
||||||
|
("ar-dz", "العربية (الجزائر)"), # Algerian Arabic often uses the base name + region
|
||||||
|
("ast", "Asturianu"),
|
||||||
|
("az", "Azərbaycan"),
|
||||||
|
("bg", "Български"),
|
||||||
|
("be", "Беларуская"),
|
||||||
|
("bn", "বাংলা"),
|
||||||
|
("br", "Brezhoneg"),
|
||||||
|
("bs", "Bosanski"),
|
||||||
|
("ca", "Català"),
|
||||||
|
("ckb", "کوردیی ناوەندی"), # Central Kurdish (Sorani)
|
||||||
|
("cs", "Čeština"),
|
||||||
|
("cy", "Cymraeg"),
|
||||||
|
("da", "Dansk"),
|
||||||
("de", "Deutsch"),
|
("de", "Deutsch"),
|
||||||
|
("dsb", "Dolnoserbšćina"),
|
||||||
|
("el", "Ελληνικά"),
|
||||||
("en", "English"),
|
("en", "English"),
|
||||||
|
("en-au", "English (Australia)"),
|
||||||
|
("en-gb", "English (UK)"),
|
||||||
|
("eo", "Esperanto"),
|
||||||
|
("es", "Español"),
|
||||||
|
("es-ar", "Español (Argentina)"),
|
||||||
|
("es-co", "Español (Colombia)"),
|
||||||
|
("es-mx", "Español (México)"),
|
||||||
|
("es-ni", "Español (Nicaragua)"),
|
||||||
|
("es-ve", "Español (Venezuela)"),
|
||||||
|
("et", "Eesti"),
|
||||||
|
("eu", "Euskara"),
|
||||||
|
("fa", "فارسی"),
|
||||||
|
("fi", "Suomi"),
|
||||||
|
("fr", "Français"),
|
||||||
|
("fy", "Frysk"),
|
||||||
|
("ga", "Gaeilge"),
|
||||||
|
("gd", "Gàidhlig"),
|
||||||
|
("gl", "Galego"),
|
||||||
|
("he", "עברית"),
|
||||||
|
("hi", "हिन्दी"),
|
||||||
|
("hr", "Hrvatski"),
|
||||||
|
("hsb", "Hornjoserbšćina"),
|
||||||
|
("hu", "Magyar"),
|
||||||
|
("hy", "Հայերեն"),
|
||||||
|
("ia", "Interlingua"),
|
||||||
|
("id", "Bahasa Indonesia"),
|
||||||
|
("ig", "Igbo"),
|
||||||
|
("io", "Ido"),
|
||||||
|
("is", "Íslenska"),
|
||||||
|
("it", "Italiano"),
|
||||||
|
("ja", "日本語"),
|
||||||
|
("ka", "ქართული"),
|
||||||
|
("kab", "Taqbaylit"),
|
||||||
|
("kk", "Қазақша"),
|
||||||
|
("km", "ខ្មែរ"),
|
||||||
|
("kn", "ಕನ್ನಡ"),
|
||||||
|
("ko", "한국어"),
|
||||||
|
("ky", "Кыргызча"),
|
||||||
|
("lb", "Lëtzebuergesch"),
|
||||||
|
("lt", "Lietuvių"),
|
||||||
|
("lv", "Latviešu"),
|
||||||
|
("mk", "Македонски"),
|
||||||
|
("ml", "മലയാളം"),
|
||||||
|
("mn", "Монгол"),
|
||||||
|
("mr", "मराठी"),
|
||||||
|
("ms", "Bahasa Melayu"),
|
||||||
|
("my", "မြန်မာဘာသာ"),
|
||||||
|
("nb", "Norsk (Bokmål)"),
|
||||||
|
("ne", "नेपाली"),
|
||||||
("nl", "Nederlands"),
|
("nl", "Nederlands"),
|
||||||
|
("nn", "Norsk (Nynorsk)"),
|
||||||
|
("os", "Ирон"), # Ossetic
|
||||||
|
("pa", "ਪੰਜਾਬੀ"),
|
||||||
|
("pl", "Polski"),
|
||||||
|
("pt", "Português"),
|
||||||
("pt-br", "Português (Brasil)"),
|
("pt-br", "Português (Brasil)"),
|
||||||
|
("ro", "Română"),
|
||||||
|
("ru", "Русский"),
|
||||||
|
("sk", "Slovenčina"),
|
||||||
|
("sl", "Slovenščina"),
|
||||||
|
("sq", "Shqip"),
|
||||||
|
("sr", "Српски"),
|
||||||
|
("sr-latn", "Srpski (Latinica)"),
|
||||||
|
("sv", "Svenska"),
|
||||||
|
("sw", "Kiswahili"),
|
||||||
|
("ta", "தமிழ்"),
|
||||||
|
("te", "తెలుగు"),
|
||||||
|
("tg", "Тоҷикӣ"),
|
||||||
|
("th", "ไทย"),
|
||||||
|
("tk", "Türkmençe"),
|
||||||
|
("tr", "Türkçe"),
|
||||||
|
("tt", "Татарча"),
|
||||||
|
("udm", "Удмурт"),
|
||||||
|
("ug", "ئۇيغۇرچە"),
|
||||||
|
("uk", "Українська"),
|
||||||
|
("ur", "اردو"),
|
||||||
|
("uz", "Oʻzbekcha"),
|
||||||
|
("vi", "Tiếng Việt"),
|
||||||
|
("zh-hans", "简体中文"),
|
||||||
|
("zh-hant", "繁體中文"),
|
||||||
)
|
)
|
||||||
|
|
||||||
TIME_ZONE = os.getenv("TZ", "UTC")
|
TIME_ZONE = os.getenv("TZ", "UTC")
|
||||||
@@ -212,6 +316,42 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
|||||||
|
|
||||||
LOGIN_REDIRECT_URL = "/"
|
LOGIN_REDIRECT_URL = "/"
|
||||||
LOGIN_URL = "/login/"
|
LOGIN_URL = "/login/"
|
||||||
|
LOGOUT_REDIRECT_URL = "/login/"
|
||||||
|
|
||||||
|
# Allauth settings
|
||||||
|
AUTHENTICATION_BACKENDS = [
|
||||||
|
"django.contrib.auth.backends.ModelBackend", # Keep default
|
||||||
|
"allauth.account.auth_backends.AuthenticationBackend",
|
||||||
|
]
|
||||||
|
|
||||||
|
SOCIALACCOUNT_PROVIDERS = {"openid_connect": {"APPS": []}}
|
||||||
|
|
||||||
|
if (
|
||||||
|
os.getenv("OIDC_CLIENT_ID")
|
||||||
|
and os.getenv("OIDC_CLIENT_SECRET")
|
||||||
|
and os.getenv("OIDC_SERVER_URL")
|
||||||
|
):
|
||||||
|
SOCIALACCOUNT_PROVIDERS["openid_connect"]["APPS"].append(
|
||||||
|
{
|
||||||
|
"provider_id": slugify(os.getenv("OIDC_CLIENT_NAME", "OpenID Connect")),
|
||||||
|
"name": os.getenv("OIDC_CLIENT_NAME", "OpenID Connect"),
|
||||||
|
"client_id": os.getenv("OIDC_CLIENT_ID"),
|
||||||
|
"secret": os.getenv("OIDC_CLIENT_SECRET"),
|
||||||
|
"settings": {
|
||||||
|
"server_url": os.getenv("OIDC_SERVER_URL"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ACCOUNT_LOGIN_METHODS = {"email"}
|
||||||
|
ACCOUNT_SIGNUP_FIELDS = ["email*", "password1*", "password2*"]
|
||||||
|
ACCOUNT_USER_MODEL_USERNAME_FIELD = None
|
||||||
|
ACCOUNT_EMAIL_VERIFICATION = "none"
|
||||||
|
SOCIALACCOUNT_LOGIN_ON_GET = True
|
||||||
|
SOCIALACCOUNT_ONLY = True
|
||||||
|
SOCIALACCOUNT_AUTO_SIGNUP = os.getenv("OIDC_ALLOW_SIGNUP", "true").lower() == "true"
|
||||||
|
ACCOUNT_ADAPTER = "allauth.account.adapter.DefaultAccountAdapter"
|
||||||
|
SOCIALACCOUNT_ADAPTER = "allauth.socialaccount.adapter.DefaultSocialAccountAdapter"
|
||||||
|
|
||||||
# CRISPY FORMS
|
# CRISPY FORMS
|
||||||
CRISPY_ALLOWED_TEMPLATE_PACKS = ["bootstrap5", "crispy_forms/pure_text"]
|
CRISPY_ALLOWED_TEMPLATE_PACKS = ["bootstrap5", "crispy_forms/pure_text"]
|
||||||
@@ -239,7 +379,7 @@ DEBUG_TOOLBAR_PANELS = [
|
|||||||
"debug_toolbar.panels.signals.SignalsPanel",
|
"debug_toolbar.panels.signals.SignalsPanel",
|
||||||
"debug_toolbar.panels.redirects.RedirectsPanel",
|
"debug_toolbar.panels.redirects.RedirectsPanel",
|
||||||
"debug_toolbar.panels.profiling.ProfilingPanel",
|
"debug_toolbar.panels.profiling.ProfilingPanel",
|
||||||
"cachalot.panels.CachalotPanel",
|
# "cachalot.panels.CachalotPanel",
|
||||||
]
|
]
|
||||||
INTERNAL_IPS = [
|
INTERNAL_IPS = [
|
||||||
"127.0.0.1",
|
"127.0.0.1",
|
||||||
@@ -261,7 +401,10 @@ if DEBUG:
|
|||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
# Use Django's standard `django.contrib.auth` permissions,
|
# Use Django's standard `django.contrib.auth` permissions,
|
||||||
# or allow read-only access for unauthenticated users.
|
# or allow read-only access for unauthenticated users.
|
||||||
"DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.DjangoModelPermissions"],
|
"DEFAULT_PERMISSION_CLASSES": [
|
||||||
|
"apps.api.permissions.NotInDemoMode",
|
||||||
|
"rest_framework.permissions.DjangoModelPermissions",
|
||||||
|
],
|
||||||
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
|
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
|
||||||
"PAGE_SIZE": 10,
|
"PAGE_SIZE": 10,
|
||||||
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
||||||
@@ -344,6 +487,8 @@ else:
|
|||||||
|
|
||||||
CACHALOT_UNCACHABLE_TABLES = ("django_migrations", "procrastinate_jobs")
|
CACHALOT_UNCACHABLE_TABLES = ("django_migrations", "procrastinate_jobs")
|
||||||
|
|
||||||
|
# Procrastinate
|
||||||
|
PROCRASTINATE_ON_APP_READY = "apps.common.procrastinate.on_app_ready"
|
||||||
|
|
||||||
# PWA
|
# PWA
|
||||||
PWA_APP_NAME = SITE_TITLE
|
PWA_APP_NAME = SITE_TITLE
|
||||||
@@ -392,5 +537,7 @@ PWA_APP_SCREENSHOTS = [
|
|||||||
PWA_SERVICE_WORKER_PATH = BASE_DIR / "templates" / "pwa" / "serviceworker.js"
|
PWA_SERVICE_WORKER_PATH = BASE_DIR / "templates" / "pwa" / "serviceworker.js"
|
||||||
|
|
||||||
ENABLE_SOFT_DELETE = os.getenv("ENABLE_SOFT_DELETE", "false").lower() == "true"
|
ENABLE_SOFT_DELETE = os.getenv("ENABLE_SOFT_DELETE", "false").lower() == "true"
|
||||||
|
CHECK_FOR_UPDATES = os.getenv("CHECK_FOR_UPDATES", "true").lower() == "true"
|
||||||
KEEP_DELETED_TRANSACTIONS_FOR = int(os.getenv("KEEP_DELETED_ENTRIES_FOR", "365"))
|
KEEP_DELETED_TRANSACTIONS_FOR = int(os.getenv("KEEP_DELETED_ENTRIES_FOR", "365"))
|
||||||
APP_VERSION = os.getenv("APP_VERSION", "unknown")
|
APP_VERSION = os.getenv("APP_VERSION", "unknown")
|
||||||
|
DEMO = os.getenv("DEMO", "false").lower() == "true"
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ from drf_spectacular.views import (
|
|||||||
SpectacularAPIView,
|
SpectacularAPIView,
|
||||||
SpectacularSwaggerView,
|
SpectacularSwaggerView,
|
||||||
)
|
)
|
||||||
|
from allauth.socialaccount.providers.openid_connect.views import login, callback
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
@@ -36,6 +38,13 @@ urlpatterns = [
|
|||||||
SpectacularSwaggerView.as_view(url_name="schema"),
|
SpectacularSwaggerView.as_view(url_name="schema"),
|
||||||
name="swagger-ui",
|
name="swagger-ui",
|
||||||
),
|
),
|
||||||
|
path("auth/", include("allauth.urls")), # allauth urls
|
||||||
|
# path("auth/oidc/<str:provider_id>/login/", login, name="openid_connect_login"),
|
||||||
|
# path(
|
||||||
|
# "auth/oidc/<str:provider_id>/login/callback/",
|
||||||
|
# callback,
|
||||||
|
# name="openid_connect_callback",
|
||||||
|
# ),
|
||||||
path("", include("apps.transactions.urls")),
|
path("", include("apps.transactions.urls")),
|
||||||
path("", include("apps.common.urls")),
|
path("", include("apps.common.urls")),
|
||||||
path("", include("apps.users.urls")),
|
path("", include("apps.users.urls")),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from crispy_forms.bootstrap import FormActions
|
|||||||
from crispy_forms.helper import FormHelper
|
from crispy_forms.helper import FormHelper
|
||||||
from crispy_forms.layout import Layout, Field, Column, Row
|
from crispy_forms.layout import Layout, Field, Column, Row
|
||||||
from django import forms
|
from django import forms
|
||||||
|
from django.db.models import Q
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from apps.accounts.models import Account
|
from apps.accounts.models import Account
|
||||||
@@ -15,6 +16,7 @@ from apps.common.widgets.crispy.submit import NoClassSubmit
|
|||||||
from apps.common.widgets.tom_select import TomSelect
|
from apps.common.widgets.tom_select import TomSelect
|
||||||
from apps.transactions.models import TransactionCategory, TransactionTag
|
from apps.transactions.models import TransactionCategory, TransactionTag
|
||||||
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
|
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
|
||||||
|
from apps.currencies.models import Currency
|
||||||
|
|
||||||
|
|
||||||
class AccountGroupForm(forms.ModelForm):
|
class AccountGroupForm(forms.ModelForm):
|
||||||
@@ -79,6 +81,24 @@ class AccountForm(forms.ModelForm):
|
|||||||
|
|
||||||
self.fields["group"].queryset = AccountGroup.objects.all()
|
self.fields["group"].queryset = AccountGroup.objects.all()
|
||||||
|
|
||||||
|
if self.instance.id:
|
||||||
|
self.fields["currency"].queryset = Currency.objects.filter(
|
||||||
|
Q(is_archived=False) | Q(accounts=self.instance.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.fields["exchange_currency"].queryset = Currency.objects.filter(
|
||||||
|
Q(is_archived=False) | Q(accounts=self.instance.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.fields["currency"].queryset = Currency.objects.filter(
|
||||||
|
Q(is_archived=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.fields["exchange_currency"].queryset = Currency.objects.filter(
|
||||||
|
Q(is_archived=False)
|
||||||
|
)
|
||||||
|
|
||||||
self.helper = FormHelper()
|
self.helper = FormHelper()
|
||||||
self.helper.form_tag = False
|
self.helper.form_tag = False
|
||||||
self.helper.form_method = "post"
|
self.helper.form_method = "post"
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Generated by Django 5.1.7 on 2025-03-09 21:54
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0013_alter_account_visibility_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='account',
|
||||||
|
options={'ordering': ['name', 'id'], 'verbose_name': 'Account', 'verbose_name_plural': 'Accounts'},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='accountgroup',
|
||||||
|
options={'ordering': ['name', 'id'], 'verbose_name': 'Account Group', 'verbose_name_plural': 'Account Groups'},
|
||||||
|
),
|
||||||
|
]
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
# Generated by Django 5.2.4 on 2025-07-28 02:15
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0014_alter_account_options_alter_accountgroup_options'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='account',
|
||||||
|
name='owner',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='account',
|
||||||
|
name='shared_with',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL, verbose_name='Shared with users'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='account',
|
||||||
|
name='visibility',
|
||||||
|
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10, verbose_name='Visibility'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='accountgroup',
|
||||||
|
name='owner',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='accountgroup',
|
||||||
|
name='shared_with',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL, verbose_name='Shared with users'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='accountgroup',
|
||||||
|
name='visibility',
|
||||||
|
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10, verbose_name='Visibility'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 5.2.4 on 2025-08-09 05:52
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0015_alter_account_owner_alter_account_shared_with_and_more'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='account',
|
||||||
|
name='untracked_by',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='untracked_accounts', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Q
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from apps.transactions.models import Transaction
|
from apps.common.middleware.thread_local import get_current_user
|
||||||
from apps.common.models import SharedObject, SharedObjectManager
|
from apps.common.models import SharedObject, SharedObjectManager
|
||||||
|
from apps.transactions.models import Transaction
|
||||||
|
|
||||||
|
|
||||||
class AccountGroup(SharedObject):
|
class AccountGroup(SharedObject):
|
||||||
@@ -19,6 +19,7 @@ class AccountGroup(SharedObject):
|
|||||||
verbose_name_plural = _("Account Groups")
|
verbose_name_plural = _("Account Groups")
|
||||||
db_table = "account_groups"
|
db_table = "account_groups"
|
||||||
unique_together = (("owner", "name"),)
|
unique_together = (("owner", "name"),)
|
||||||
|
ordering = ["name", "id"]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
@@ -61,6 +62,11 @@ class Account(SharedObject):
|
|||||||
verbose_name=_("Archived"),
|
verbose_name=_("Archived"),
|
||||||
help_text=_("Archived accounts don't show up nor count towards your net worth"),
|
help_text=_("Archived accounts don't show up nor count towards your net worth"),
|
||||||
)
|
)
|
||||||
|
untracked_by = models.ManyToManyField(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
blank=True,
|
||||||
|
related_name="untracked_accounts",
|
||||||
|
)
|
||||||
|
|
||||||
objects = SharedObjectManager()
|
objects = SharedObjectManager()
|
||||||
all_objects = models.Manager() # Unfiltered manager
|
all_objects = models.Manager() # Unfiltered manager
|
||||||
@@ -69,10 +75,15 @@ class Account(SharedObject):
|
|||||||
verbose_name = _("Account")
|
verbose_name = _("Account")
|
||||||
verbose_name_plural = _("Accounts")
|
verbose_name_plural = _("Accounts")
|
||||||
unique_together = (("owner", "name"),)
|
unique_together = (("owner", "name"),)
|
||||||
|
ordering = ["name", "id"]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def is_untracked_by(self):
|
||||||
|
user = get_current_user()
|
||||||
|
return self.untracked_by.filter(pk=user.pk).exists()
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
super().clean()
|
super().clean()
|
||||||
if self.exchange_currency == self.currency:
|
if self.exchange_currency == self.currency:
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ urlpatterns = [
|
|||||||
views.account_take_ownership,
|
views.account_take_ownership,
|
||||||
name="account_take_ownership",
|
name="account_take_ownership",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"account/<int:pk>/toggle-untracked/",
|
||||||
|
views.account_toggle_untracked,
|
||||||
|
name="account_toggle_untracked",
|
||||||
|
),
|
||||||
path("account-groups/", views.account_groups_index, name="account_groups_index"),
|
path("account-groups/", views.account_groups_index, name="account_groups_index"),
|
||||||
path("account-groups/list/", views.account_groups_list, name="account_groups_list"),
|
path("account-groups/list/", views.account_groups_list, name="account_groups_list"),
|
||||||
path("account-groups/add/", views.account_group_add, name="account_group_add"),
|
path("account-groups/add/", views.account_group_add, name="account_group_add"),
|
||||||
@@ -51,7 +56,7 @@ urlpatterns = [
|
|||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"account-groups/<int:pk>/share/",
|
"account-groups/<int:pk>/share/",
|
||||||
views.account_share,
|
views.account_group_share,
|
||||||
name="account_group_share_settings",
|
name="account_group_share_settings",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ def account_group_take_ownership(request, pk):
|
|||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET", "POST"])
|
||||||
def account_share(request, pk):
|
def account_group_share(request, pk):
|
||||||
obj = get_object_or_404(AccountGroup, id=pk)
|
obj = get_object_or_404(AccountGroup, id=pk)
|
||||||
|
|
||||||
if obj.owner and obj.owner != request.user:
|
if obj.owner and obj.owner != request.user:
|
||||||
|
|||||||
@@ -155,6 +155,26 @@ def account_delete(request, pk):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def account_toggle_untracked(request, pk):
|
||||||
|
account = get_object_or_404(Account, id=pk)
|
||||||
|
if account.is_untracked_by():
|
||||||
|
account.untracked_by.remove(request.user)
|
||||||
|
messages.success(request, _("Account is now tracked"))
|
||||||
|
else:
|
||||||
|
account.untracked_by.add(request.user)
|
||||||
|
messages.success(request, _("Account is now untracked"))
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
headers={
|
||||||
|
"HX-Trigger": "updated",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
|
|||||||
@@ -41,7 +41,10 @@ class TransactionCategoryField(serializers.Field):
|
|||||||
def get_schema():
|
def get_schema():
|
||||||
return {
|
return {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {"type": "string", "description": "TransactionTag ID or name"},
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "TransactionCategory ID or name",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from rest_framework.permissions import BasePermission
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
class NotInDemoMode(BasePermission):
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
if settings.DEMO and not request.user.is_superuser:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
return True
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from django.db.models import Q
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ class AccountSerializer(serializers.ModelSerializer):
|
|||||||
write_only=True,
|
write_only=True,
|
||||||
allow_null=True,
|
allow_null=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
currency = CurrencySerializer(read_only=True)
|
currency = CurrencySerializer(read_only=True)
|
||||||
currency_id = serializers.PrimaryKeyRelatedField(
|
currency_id = serializers.PrimaryKeyRelatedField(
|
||||||
queryset=Currency.objects.all(), source="currency", write_only=True
|
queryset=Currency.objects.all(), source="currency", write_only=True
|
||||||
@@ -50,6 +52,13 @@ class AccountSerializer(serializers.ModelSerializer):
|
|||||||
"is_asset",
|
"is_asset",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
request = self.context.get("request")
|
||||||
|
if request and request.user.is_authenticated:
|
||||||
|
# Reload the queryset to get an updated version with the requesting user
|
||||||
|
self.fields["group_id"].queryset = AccountGroup.objects.all()
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
return Account.objects.create(**validated_data)
|
return Account.objects.create(**validated_data)
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from apps.transactions.models import (
|
|||||||
TransactionEntity,
|
TransactionEntity,
|
||||||
RecurringTransaction,
|
RecurringTransaction,
|
||||||
)
|
)
|
||||||
|
from apps.common.middleware.thread_local import get_current_user
|
||||||
|
|
||||||
|
|
||||||
class TransactionCategorySerializer(serializers.ModelSerializer):
|
class TransactionCategorySerializer(serializers.ModelSerializer):
|
||||||
@@ -31,6 +32,10 @@ class TransactionCategorySerializer(serializers.ModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = TransactionCategory
|
model = TransactionCategory
|
||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
|
read_only_fields = [
|
||||||
|
"id",
|
||||||
|
"owner",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class TransactionTagSerializer(serializers.ModelSerializer):
|
class TransactionTagSerializer(serializers.ModelSerializer):
|
||||||
@@ -39,6 +44,10 @@ class TransactionTagSerializer(serializers.ModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = TransactionTag
|
model = TransactionTag
|
||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
|
read_only_fields = [
|
||||||
|
"id",
|
||||||
|
"owner",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class TransactionEntitySerializer(serializers.ModelSerializer):
|
class TransactionEntitySerializer(serializers.ModelSerializer):
|
||||||
@@ -47,6 +56,10 @@ class TransactionEntitySerializer(serializers.ModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = TransactionEntity
|
model = TransactionEntity
|
||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
|
read_only_fields = [
|
||||||
|
"id",
|
||||||
|
"owner",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class InstallmentPlanSerializer(serializers.ModelSerializer):
|
class InstallmentPlanSerializer(serializers.ModelSerializer):
|
||||||
@@ -125,6 +138,7 @@ class RecurringTransactionSerializer(serializers.ModelSerializer):
|
|||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
instance = super().update(instance, validated_data)
|
instance = super().update(instance, validated_data)
|
||||||
instance.update_unpaid_transactions()
|
instance.update_unpaid_transactions()
|
||||||
|
instance.generate_upcoming_transactions()
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
|
|
||||||
@@ -157,8 +171,16 @@ class TransactionSerializer(serializers.ModelSerializer):
|
|||||||
"installment_plan",
|
"installment_plan",
|
||||||
"recurring_transaction",
|
"recurring_transaction",
|
||||||
"installment_id",
|
"installment_id",
|
||||||
|
"owner",
|
||||||
|
"deleted_at",
|
||||||
|
"deleted",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.fields["account_id"].queryset = Account.objects.all()
|
||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
if not self.partial:
|
if not self.partial:
|
||||||
if "date" in data and "reference_date" not in data:
|
if "date" in data and "reference_date" not in data:
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ class AccountViewSet(viewsets.ModelViewSet):
|
|||||||
pagination_class = CustomPageNumberPagination
|
pagination_class = CustomPageNumberPagination
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return Account.objects.all().select_related(
|
return (
|
||||||
"group", "currency", "exchange_currency"
|
Account.objects.all()
|
||||||
|
.order_by("id")
|
||||||
|
.select_related("group", "currency", "exchange_currency")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
from rest_framework import viewsets
|
from rest_framework import viewsets
|
||||||
|
|
||||||
from apps.api.custom.pagination import CustomPageNumberPagination
|
from apps.api.custom.pagination import CustomPageNumberPagination
|
||||||
@@ -30,15 +32,16 @@ class TransactionViewSet(viewsets.ModelViewSet):
|
|||||||
transaction_created.send(sender=instance)
|
transaction_created.send(sender=instance)
|
||||||
|
|
||||||
def perform_update(self, serializer):
|
def perform_update(self, serializer):
|
||||||
|
old_data = deepcopy(Transaction.objects.get(pk=serializer.data["pk"]))
|
||||||
instance = serializer.save()
|
instance = serializer.save()
|
||||||
transaction_updated.send(sender=instance)
|
transaction_updated.send(sender=instance, old_data=old_data)
|
||||||
|
|
||||||
def partial_update(self, request, *args, **kwargs):
|
def partial_update(self, request, *args, **kwargs):
|
||||||
kwargs["partial"] = True
|
kwargs["partial"] = True
|
||||||
return self.update(request, *args, **kwargs)
|
return self.update(request, *args, **kwargs)
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return Transaction.objects.all().order_by("id")
|
return Transaction.objects.all().order_by("-id")
|
||||||
|
|
||||||
|
|
||||||
class TransactionCategoryViewSet(viewsets.ModelViewSet):
|
class TransactionCategoryViewSet(viewsets.ModelViewSet):
|
||||||
@@ -51,7 +54,7 @@ class TransactionCategoryViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
|
|
||||||
class TransactionTagViewSet(viewsets.ModelViewSet):
|
class TransactionTagViewSet(viewsets.ModelViewSet):
|
||||||
queryset = TransactionTag.objects.all().order_by("id")
|
queryset = TransactionTag.objects.all()
|
||||||
serializer_class = TransactionTagSerializer
|
serializer_class = TransactionTagSerializer
|
||||||
pagination_class = CustomPageNumberPagination
|
pagination_class = CustomPageNumberPagination
|
||||||
|
|
||||||
@@ -60,7 +63,7 @@ class TransactionTagViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
|
|
||||||
class TransactionEntityViewSet(viewsets.ModelViewSet):
|
class TransactionEntityViewSet(viewsets.ModelViewSet):
|
||||||
queryset = TransactionEntity.objects.all().order_by("id")
|
queryset = TransactionEntity.objects.all()
|
||||||
serializer_class = TransactionEntitySerializer
|
serializer_class = TransactionEntitySerializer
|
||||||
pagination_class = CustomPageNumberPagination
|
pagination_class = CustomPageNumberPagination
|
||||||
|
|
||||||
@@ -69,18 +72,18 @@ class TransactionEntityViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
|
|
||||||
class InstallmentPlanViewSet(viewsets.ModelViewSet):
|
class InstallmentPlanViewSet(viewsets.ModelViewSet):
|
||||||
queryset = InstallmentPlan.objects.all().order_by("id")
|
queryset = InstallmentPlan.objects.all()
|
||||||
serializer_class = InstallmentPlanSerializer
|
serializer_class = InstallmentPlanSerializer
|
||||||
pagination_class = CustomPageNumberPagination
|
pagination_class = CustomPageNumberPagination
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return InstallmentPlan.objects.all().order_by("id")
|
return InstallmentPlan.objects.all().order_by("-id")
|
||||||
|
|
||||||
|
|
||||||
class RecurringTransactionViewSet(viewsets.ModelViewSet):
|
class RecurringTransactionViewSet(viewsets.ModelViewSet):
|
||||||
queryset = RecurringTransaction.objects.all().order_by("id")
|
queryset = RecurringTransaction.objects.all()
|
||||||
serializer_class = RecurringTransactionSerializer
|
serializer_class = RecurringTransactionSerializer
|
||||||
pagination_class = CustomPageNumberPagination
|
pagination_class = CustomPageNumberPagination
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return RecurringTransaction.objects.all().order_by("id")
|
return RecurringTransaction.objects.all().order_by("-id")
|
||||||
|
|||||||
@@ -1,7 +1,26 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
@admin.action(description=_("Make public"))
|
||||||
|
def make_public(modeladmin, request, queryset):
|
||||||
|
queryset.update(visibility="public")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.action(description=_("Make private"))
|
||||||
|
def make_private(modeladmin, request, queryset):
|
||||||
|
queryset.update(visibility="private")
|
||||||
|
|
||||||
|
|
||||||
class SharedObjectModelAdmin(admin.ModelAdmin):
|
class SharedObjectModelAdmin(admin.ModelAdmin):
|
||||||
|
actions = [make_public, make_private]
|
||||||
|
|
||||||
|
list_display = ("__str__", "visibility", "owner", "get_shared_with")
|
||||||
|
|
||||||
|
@admin.display(description=_("Shared with users"))
|
||||||
|
def get_shared_with(self, obj):
|
||||||
|
return ", ".join([p.email for p in obj.shared_with.all()])
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
# Use the all_objects manager to show all transactions, including deleted ones
|
# Use the all_objects manager to show all transactions, including deleted ones
|
||||||
return self.model.all_objects.all()
|
return self.model.all_objects.all()
|
||||||
|
|||||||
@@ -1,6 +1,25 @@
|
|||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
|
||||||
class CommonConfig(AppConfig):
|
class CommonConfig(AppConfig):
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
name = "apps.common"
|
name = "apps.common"
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.contrib.sites.models import Site
|
||||||
|
from allauth.socialaccount.models import (
|
||||||
|
SocialAccount,
|
||||||
|
SocialApp,
|
||||||
|
SocialToken,
|
||||||
|
)
|
||||||
|
|
||||||
|
admin.site.unregister(Site)
|
||||||
|
admin.site.unregister(SocialAccount)
|
||||||
|
admin.site.unregister(SocialApp)
|
||||||
|
admin.site.unregister(SocialToken)
|
||||||
|
|
||||||
|
# Delete the cache for update checks to prevent false-positives when the app is restarted
|
||||||
|
# this will be recreated by the check_for_updates task
|
||||||
|
cache.delete("update_check")
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
|
|
||||||
|
|
||||||
|
def disabled_on_demo(view):
|
||||||
|
@wraps(view)
|
||||||
|
def _view(request, *args, **kwargs):
|
||||||
|
if settings.DEMO and not request.user.is_superuser:
|
||||||
|
raise PermissionDenied
|
||||||
|
|
||||||
|
return view(request, *args, **kwargs)
|
||||||
|
|
||||||
|
return _view
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
from django.urls import reverse, NoReverseMatch
|
||||||
|
|
||||||
|
|
||||||
|
def is_superuser(view):
|
||||||
|
@wraps(view)
|
||||||
|
def _view(request, *args, **kwargs):
|
||||||
|
if not request.user.is_superuser:
|
||||||
|
raise PermissionDenied
|
||||||
|
|
||||||
|
return view(request, *args, **kwargs)
|
||||||
|
|
||||||
|
return _view
|
||||||
|
|
||||||
|
|
||||||
|
def htmx_login_required(function=None, login_url=None):
|
||||||
|
"""
|
||||||
|
Decorator that checks if the user is logged in.
|
||||||
|
|
||||||
|
Allows overriding the default login URL.
|
||||||
|
|
||||||
|
If the user is not logged in:
|
||||||
|
- If "hx-request" is present in the request header, it returns a 200 response
|
||||||
|
with a "HX-Redirect" header containing the determined login URL (including the "next" parameter).
|
||||||
|
- If "hx-request" is not present, it redirects to the determined login page normally.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
function: The view function to decorate.
|
||||||
|
login_url: Optional. The URL or URL name to redirect to for login.
|
||||||
|
Defaults to settings.LOGIN_URL.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(view_func):
|
||||||
|
# Simplified @wraps usage - it handles necessary attribute assignments by default
|
||||||
|
@wraps(view_func)
|
||||||
|
def wrapped_view(request, *args, **kwargs):
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
return view_func(request, *args, **kwargs)
|
||||||
|
else:
|
||||||
|
# Determine the login URL
|
||||||
|
resolved_login_url = login_url
|
||||||
|
if not resolved_login_url:
|
||||||
|
resolved_login_url = settings.LOGIN_URL
|
||||||
|
|
||||||
|
# Try to reverse the URL name if it's not a path
|
||||||
|
try:
|
||||||
|
# Check if it looks like a URL path already
|
||||||
|
if "/" not in resolved_login_url and "." not in resolved_login_url:
|
||||||
|
login_url_path = reverse(resolved_login_url)
|
||||||
|
else:
|
||||||
|
login_url_path = resolved_login_url
|
||||||
|
except NoReverseMatch:
|
||||||
|
# If reverse fails, assume it's already a URL path
|
||||||
|
login_url_path = resolved_login_url
|
||||||
|
|
||||||
|
# Construct the full redirect path with 'next' parameter
|
||||||
|
# Ensure request.path is URL-encoded if needed, though Django usually handles this
|
||||||
|
redirect_path = f"{login_url_path}?next={request.get_full_path()}" # Use get_full_path() to include query params
|
||||||
|
|
||||||
|
if request.headers.get("hx-request"):
|
||||||
|
# For HTMX requests, return a 200 with the HX-Redirect header.
|
||||||
|
response = HttpResponse()
|
||||||
|
response["HX-Redirect"] = login_url_path
|
||||||
|
return response
|
||||||
|
else:
|
||||||
|
# For regular requests, redirect to the login page.
|
||||||
|
return redirect(redirect_path)
|
||||||
|
|
||||||
|
return wrapped_view
|
||||||
|
|
||||||
|
if function:
|
||||||
|
return decorator(function)
|
||||||
|
return decorator
|
||||||
@@ -139,7 +139,6 @@ class DynamicModelMultipleChoiceField(forms.ModelMultipleChoiceField):
|
|||||||
instance.save()
|
instance.save()
|
||||||
return instance
|
return instance
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(e)
|
|
||||||
raise ValidationError(_("Error creating new instance"))
|
raise ValidationError(_("Error creating new instance"))
|
||||||
|
|
||||||
def clean(self, value):
|
def clean(self, value):
|
||||||
|
|||||||
@@ -20,7 +20,15 @@ class MonthYearModelField(models.DateField):
|
|||||||
# Set the day to 1
|
# Set the day to 1
|
||||||
return date.replace(day=1).date()
|
return date.replace(day=1).date()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise ValidationError(_("Invalid date format. Use YYYY-MM."))
|
try:
|
||||||
|
# Also accept YYYY-MM-DD format (for loaddata)
|
||||||
|
return (
|
||||||
|
datetime.datetime.strptime(value, "%Y-%m-%d").replace(day=1).date()
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
raise ValidationError(
|
||||||
|
_("Invalid date format. Use YYYY-MM or YYYY-MM-DD.")
|
||||||
|
)
|
||||||
|
|
||||||
def formfield(self, **kwargs):
|
def formfield(self, **kwargs):
|
||||||
kwargs["widget"] = MonthYearWidget
|
kwargs["widget"] = MonthYearWidget
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from crispy_forms.bootstrap import FormActions
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from crispy_forms.helper import FormHelper
|
from crispy_forms.helper import FormHelper
|
||||||
from crispy_forms.layout import Layout, Field, Submit, Div, HTML
|
from crispy_forms.layout import Layout, Field, Submit, Div, HTML
|
||||||
|
|
||||||
@@ -81,6 +82,23 @@ class SharedObjectForm(forms.Form):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
owner = cleaned_data.get("owner")
|
||||||
|
shared_with_users = cleaned_data.get("shared_with_users", [])
|
||||||
|
|
||||||
|
# Raise validation error if owner is in shared_with_users
|
||||||
|
if owner and owner in shared_with_users:
|
||||||
|
self.add_error(
|
||||||
|
"shared_with_users",
|
||||||
|
ValidationError(
|
||||||
|
_("You cannot share this item with its owner."),
|
||||||
|
code="invalid_share",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
instance = self.instance
|
instance = self.instance
|
||||||
|
|
||||||
|
|||||||
@@ -9,5 +9,8 @@ def truncate_decimal(value, decimal_places):
|
|||||||
:param decimal_places: The number of decimal places to keep
|
:param decimal_places: The number of decimal places to keep
|
||||||
:return: Truncated Decimal value
|
:return: Truncated Decimal value
|
||||||
"""
|
"""
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
value = Decimal(str(value))
|
||||||
|
|
||||||
multiplier = Decimal(10**decimal_places)
|
multiplier = Decimal(10**decimal_places)
|
||||||
return (value * multiplier).to_integral_value(rounding=ROUND_DOWN) / multiplier
|
return (value * multiplier).to_integral_value(rounding=ROUND_DOWN) / multiplier
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ from django.utils.formats import get_format as original_get_format
|
|||||||
def get_format(format_type=None, lang=None, use_l10n=None):
|
def get_format(format_type=None, lang=None, use_l10n=None):
|
||||||
user = get_current_user()
|
user = get_current_user()
|
||||||
|
|
||||||
if user and user.is_authenticated and hasattr(user, "settings"):
|
if (
|
||||||
|
user
|
||||||
|
and user.is_authenticated
|
||||||
|
and hasattr(user, "settings")
|
||||||
|
and use_l10n is not False
|
||||||
|
):
|
||||||
user_settings = user.settings
|
user_settings = user.settings
|
||||||
if format_type == "THOUSAND_SEPARATOR":
|
if format_type == "THOUSAND_SEPARATOR":
|
||||||
number_format = getattr(user_settings, "number_format", None)
|
number_format = getattr(user_settings, "number_format", None)
|
||||||
@@ -13,11 +18,13 @@ def get_format(format_type=None, lang=None, use_l10n=None):
|
|||||||
return "."
|
return "."
|
||||||
elif number_format == "CD":
|
elif number_format == "CD":
|
||||||
return ","
|
return ","
|
||||||
|
elif number_format == "SD" or number_format == "SC":
|
||||||
|
return " "
|
||||||
elif format_type == "DECIMAL_SEPARATOR":
|
elif format_type == "DECIMAL_SEPARATOR":
|
||||||
number_format = getattr(user_settings, "number_format", None)
|
number_format = getattr(user_settings, "number_format", None)
|
||||||
if number_format == "DC":
|
if number_format == "DC" or number_format == "SC":
|
||||||
return ","
|
return ","
|
||||||
elif number_format == "CD":
|
elif number_format == "CD" or number_format == "SD":
|
||||||
return "."
|
return "."
|
||||||
elif format_type == "SHORT_DATE_FORMAT":
|
elif format_type == "SHORT_DATE_FORMAT":
|
||||||
date_format = getattr(user_settings, "date_format", None)
|
date_format = getattr(user_settings, "date_format", None)
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import os
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import IntegrityError
|
||||||
|
|
||||||
|
# Get the custom User model if defined, otherwise the default User model
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = (
|
||||||
|
"Creates a superuser from environment variables (ADMIN_EMAIL, ADMIN_PASSWORD) "
|
||||||
|
"and optionally creates a demo user (demo@demo.com) if settings.DEMO is True."
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
self.stdout.write("Starting user setup...")
|
||||||
|
|
||||||
|
# --- Create Superuser ---
|
||||||
|
admin_email = os.environ.get("ADMIN_EMAIL")
|
||||||
|
admin_password = os.environ.get("ADMIN_PASSWORD")
|
||||||
|
|
||||||
|
if admin_email and admin_password:
|
||||||
|
self.stdout.write(f"Attempting to create superuser: {admin_email}")
|
||||||
|
# Use email as username for simplicity, requires USERNAME_FIELD='email'
|
||||||
|
# or adapt if your USERNAME_FIELD is different.
|
||||||
|
# If USERNAME_FIELD is 'username', you might need ADMIN_USERNAME env var.
|
||||||
|
username_field = User.USERNAME_FIELD # Get the actual username field name
|
||||||
|
|
||||||
|
# Check if the user already exists by email or username
|
||||||
|
user_exists_kwargs = {"email": admin_email}
|
||||||
|
if username_field != "email":
|
||||||
|
# Assume username should also be the email if not explicitly provided
|
||||||
|
user_exists_kwargs[username_field] = admin_email
|
||||||
|
|
||||||
|
if User.objects.filter(**user_exists_kwargs).exists():
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(
|
||||||
|
f"Superuser with email '{admin_email}' (or corresponding username) already exists. Skipping creation."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
create_kwargs = {
|
||||||
|
username_field: admin_email, # Use email as username by default
|
||||||
|
"email": admin_email,
|
||||||
|
"password": admin_password,
|
||||||
|
}
|
||||||
|
User.objects.create_superuser(**create_kwargs)
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f"Superuser '{admin_email}' created successfully."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except IntegrityError as e:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.ERROR(
|
||||||
|
f"Failed to create superuser '{admin_email}'. IntegrityError: {e}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.ERROR(
|
||||||
|
f"An unexpected error occurred creating superuser '{admin_email}': {e}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.NOTICE(
|
||||||
|
"ADMIN_EMAIL or ADMIN_PASSWORD environment variables not set. Skipping superuser creation."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write("---") # Separator
|
||||||
|
|
||||||
|
# --- Create Demo User ---
|
||||||
|
# Use getattr to safely check for the DEMO setting, default to False if not present
|
||||||
|
create_demo_user = getattr(settings, "DEMO", False)
|
||||||
|
|
||||||
|
if create_demo_user:
|
||||||
|
demo_email = "demo@demo.com"
|
||||||
|
demo_password = (
|
||||||
|
"wygiwyhdemo" # Consider making this an env var too for security
|
||||||
|
)
|
||||||
|
demo_username = demo_email # Using email as username for consistency
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
f"DEMO setting is True. Attempting to create demo user: {demo_email}"
|
||||||
|
)
|
||||||
|
|
||||||
|
username_field = User.USERNAME_FIELD # Get the actual username field name
|
||||||
|
|
||||||
|
# Check if the user already exists by email or username
|
||||||
|
user_exists_kwargs = {"email": demo_email}
|
||||||
|
if username_field != "email":
|
||||||
|
user_exists_kwargs[username_field] = demo_username
|
||||||
|
|
||||||
|
if User.objects.filter(**user_exists_kwargs).exists():
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(
|
||||||
|
f"Demo user with email '{demo_email}' (or corresponding username) already exists. Skipping creation."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
create_kwargs = {
|
||||||
|
username_field: demo_username,
|
||||||
|
"email": demo_email,
|
||||||
|
"password": demo_password,
|
||||||
|
}
|
||||||
|
User.objects.create_user(**create_kwargs)
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f"Demo user '{demo_email}' created successfully."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except IntegrityError as e:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.ERROR(
|
||||||
|
f"Failed to create demo user '{demo_email}'. IntegrityError: {e}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.ERROR(
|
||||||
|
f"An unexpected error occurred creating demo user '{demo_email}': {e}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.NOTICE(
|
||||||
|
"DEMO setting is not True (or not set). Skipping demo user creation."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS("User setup command finished."))
|
||||||
@@ -36,12 +36,19 @@ class SharedObject(models.Model):
|
|||||||
related_name="%(class)s_owned",
|
related_name="%(class)s_owned",
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
|
verbose_name=_("Owner"),
|
||||||
)
|
)
|
||||||
visibility = models.CharField(
|
visibility = models.CharField(
|
||||||
max_length=10, choices=Visibility.choices, default=Visibility.private
|
max_length=10,
|
||||||
|
choices=Visibility.choices,
|
||||||
|
default=Visibility.private,
|
||||||
|
verbose_name=_("Visibility"),
|
||||||
)
|
)
|
||||||
shared_with = models.ManyToManyField(
|
shared_with = models.ManyToManyField(
|
||||||
settings.AUTH_USER_MODEL, related_name="%(class)s_shared", blank=True
|
settings.AUTH_USER_MODEL,
|
||||||
|
related_name="%(class)s_shared",
|
||||||
|
blank=True,
|
||||||
|
verbose_name=_("Shared with users"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Use as abstract base class
|
# Use as abstract base class
|
||||||
@@ -65,6 +72,18 @@ class SharedObject(models.Model):
|
|||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class OwnedObjectManager(models.Manager):
|
||||||
|
def get_queryset(self):
|
||||||
|
"""Return only objects the user can access"""
|
||||||
|
user = get_current_user()
|
||||||
|
base_qs = super().get_queryset()
|
||||||
|
|
||||||
|
if user and user.is_authenticated:
|
||||||
|
return base_qs.filter(Q(owner=user) | Q(owner=None)).distinct()
|
||||||
|
|
||||||
|
return base_qs
|
||||||
|
|
||||||
|
|
||||||
class OwnedObject(models.Model):
|
class OwnedObject(models.Model):
|
||||||
owner = models.ForeignKey(
|
owner = models.ForeignKey(
|
||||||
settings.AUTH_USER_MODEL,
|
settings.AUTH_USER_MODEL,
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import procrastinate
|
||||||
|
|
||||||
|
|
||||||
|
def on_app_ready(app: procrastinate.App):
|
||||||
|
"""This function is ran upon procrastinate initialization."""
|
||||||
|
...
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from packaging.version import parse as parse_version, InvalidVersion
|
||||||
|
|
||||||
from asgiref.sync import sync_to_async
|
from asgiref.sync import sync_to_async
|
||||||
|
from django.conf import settings
|
||||||
from django.core import management
|
from django.core import management
|
||||||
|
from django.db import DEFAULT_DB_ALIAS
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
from procrastinate import builtin_tasks
|
from procrastinate import builtin_tasks
|
||||||
from procrastinate.contrib.django import app
|
from procrastinate.contrib.django import app
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -17,7 +23,7 @@ async def remove_old_jobs(context, timestamp):
|
|||||||
return await builtin_tasks.remove_old_jobs(
|
return await builtin_tasks.remove_old_jobs(
|
||||||
context,
|
context,
|
||||||
max_hours=744,
|
max_hours=744,
|
||||||
remove_error=True,
|
remove_failed=True,
|
||||||
remove_cancelled=True,
|
remove_cancelled=True,
|
||||||
remove_aborted=True,
|
remove_aborted=True,
|
||||||
)
|
)
|
||||||
@@ -40,3 +46,86 @@ async def remove_expired_sessions(timestamp=None):
|
|||||||
"Error while executing 'remove_expired_sessions' task",
|
"Error while executing 'remove_expired_sessions' task",
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.periodic(cron="0 8 * * *")
|
||||||
|
@app.task(name="reset_demo_data")
|
||||||
|
def reset_demo_data(timestamp=None):
|
||||||
|
"""
|
||||||
|
Wipes the database and loads fresh demo data if DEMO mode is active.
|
||||||
|
Runs daily at 8:00 AM.
|
||||||
|
"""
|
||||||
|
if not settings.DEMO:
|
||||||
|
return # Exit if not in demo mode
|
||||||
|
|
||||||
|
logger.info("Demo mode active. Starting daily data reset...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. Flush the database (wipe all data)
|
||||||
|
logger.info("Flushing the database...")
|
||||||
|
|
||||||
|
management.call_command(
|
||||||
|
"flush", "--noinput", database=DEFAULT_DB_ALIAS, verbosity=1
|
||||||
|
)
|
||||||
|
logger.info("Database flushed successfully.")
|
||||||
|
|
||||||
|
# 2. Load data from the fixture
|
||||||
|
# TO-DO: Roll dates over based on today's date
|
||||||
|
fixture_name = "fixtures/demo_data.json"
|
||||||
|
logger.info(f"Loading data from fixture: {fixture_name}...")
|
||||||
|
management.call_command(
|
||||||
|
"loaddata", fixture_name, database=DEFAULT_DB_ALIAS, verbosity=1
|
||||||
|
)
|
||||||
|
logger.info(f"Data loaded successfully from {fixture_name}.")
|
||||||
|
|
||||||
|
logger.info("Daily demo data reset completed.")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Error during daily demo data reset: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
@app.periodic(cron="0 */12 * * *") # Every 12 hours
|
||||||
|
@app.task(
|
||||||
|
name="check_for_updates",
|
||||||
|
)
|
||||||
|
def check_for_updates(timestamp=None):
|
||||||
|
if not settings.CHECK_FOR_UPDATES:
|
||||||
|
return "CHECK_FOR_UPDATES is disabled"
|
||||||
|
|
||||||
|
url = "https://api.github.com/repos/eitchtee/WYGIWYH/releases/latest"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(url, timeout=60)
|
||||||
|
response.raise_for_status() # Raise an exception for bad status codes (4xx or 5xx)
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
latest_version = data.get("tag_name")
|
||||||
|
|
||||||
|
if latest_version:
|
||||||
|
try:
|
||||||
|
current_v = parse_version(settings.APP_VERSION)
|
||||||
|
except InvalidVersion:
|
||||||
|
current_v = parse_version("0.0.0")
|
||||||
|
try:
|
||||||
|
latest_v = parse_version(latest_version)
|
||||||
|
except InvalidVersion:
|
||||||
|
latest_v = parse_version("0.0.0")
|
||||||
|
|
||||||
|
update_info = {
|
||||||
|
"update_available": False,
|
||||||
|
"current_version": str(current_v),
|
||||||
|
"latest_version": str(latest_v),
|
||||||
|
}
|
||||||
|
|
||||||
|
if latest_v > current_v:
|
||||||
|
update_info["update_available"] = True
|
||||||
|
|
||||||
|
# Cache the entire dictionary
|
||||||
|
cache.set("update_check", update_info, 60 * 60 * 25)
|
||||||
|
logger.info(f"Update check complete. Result: {update_info}")
|
||||||
|
else:
|
||||||
|
logger.warning("Could not find 'tag_name' in GitHub API response.")
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"Failed to fetch updates from GitHub: {e}")
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# core/templatetags/update_tags.py
|
||||||
|
from django import template
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag
|
||||||
|
def get_update_check():
|
||||||
|
"""
|
||||||
|
Retrieves the update status dictionary from the cache.
|
||||||
|
Returns a default dictionary if nothing is found.
|
||||||
|
"""
|
||||||
|
return cache.get("update_check") or {
|
||||||
|
"update_available": False,
|
||||||
|
"latest_version": "N/A",
|
||||||
|
}
|
||||||
@@ -15,10 +15,11 @@ from cachalot.api import invalidate
|
|||||||
|
|
||||||
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
|
||||||
|
from apps.common.decorators.user import htmx_login_required
|
||||||
|
|
||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@htmx_login_required
|
||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def toasts(request):
|
def toasts(request):
|
||||||
return render(request, "common/fragments/toasts.html")
|
return render(request, "common/fragments/toasts.html")
|
||||||
@@ -90,6 +91,12 @@ def month_year_picker(request):
|
|||||||
for date in all_months
|
for date in all_months
|
||||||
]
|
]
|
||||||
|
|
||||||
|
today_url = (
|
||||||
|
reverse(url, kwargs={"month": current_date.month, "year": current_date.year})
|
||||||
|
if url
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"common/fragments/month_year_picker.html",
|
"common/fragments/month_year_picker.html",
|
||||||
@@ -97,6 +104,7 @@ def month_year_picker(request):
|
|||||||
"month_year_data": result,
|
"month_year_data": result,
|
||||||
"current_month": current_month,
|
"current_month": current_month,
|
||||||
"current_year": current_year,
|
"current_year": current_year,
|
||||||
|
"today_url": today_url,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ class AirDatePickerInput(widgets.DateInput):
|
|||||||
def _get_current_language():
|
def _get_current_language():
|
||||||
"""Get current language code in format compatible with AirDatepicker"""
|
"""Get current language code in format compatible with AirDatepicker"""
|
||||||
lang_code = translation.get_language()
|
lang_code = translation.get_language()
|
||||||
# AirDatepicker uses simple language codes
|
# AirDatepicker uses simple language codes, except for pt-br
|
||||||
|
if lang_code.lower() == "pt-br":
|
||||||
|
return "pt-BR"
|
||||||
return lang_code.split("-")[0]
|
return lang_code.split("-")[0]
|
||||||
|
|
||||||
def _get_format(self):
|
def _get_format(self):
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ class ArbitraryDecimalDisplayNumberInput(forms.TextInput):
|
|||||||
self.attrs.update(
|
self.attrs.update(
|
||||||
{
|
{
|
||||||
"x-data": "",
|
"x-data": "",
|
||||||
"x-mask:dynamic": f"$money($input, '{get_format('DECIMAL_SEPARATOR')}', "
|
"x-mask:dynamic": f"$money($input, '{get_format('DECIMAL_SEPARATOR')}', '{get_format('THOUSAND_SEPARATOR')}', '30')",
|
||||||
f"'{get_format('THOUSAND_SEPARATOR')}', '30')",
|
"x-on:keyup": "if (!['Control', 'Shift', 'Alt', 'Meta'].includes($event.key) && !(($event.ctrlKey || $event.metaKey) && $event.key.toLowerCase() === 'a')) $el.dispatchEvent(new Event('input'))",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,7 @@ from datetime import timedelta
|
|||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from apps.currencies.exchange_rates.providers import (
|
import apps.currencies.exchange_rates.providers as providers
|
||||||
SynthFinanceProvider,
|
|
||||||
SynthFinanceStockProvider,
|
|
||||||
CoinGeckoFreeProvider,
|
|
||||||
CoinGeckoProProvider,
|
|
||||||
TransitiveRateProvider,
|
|
||||||
)
|
|
||||||
from apps.currencies.models import ExchangeRateService, ExchangeRate, Currency
|
from apps.currencies.models import ExchangeRateService, ExchangeRate, Currency
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -18,11 +12,12 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# Map service types to provider classes
|
# Map service types to provider classes
|
||||||
PROVIDER_MAPPING = {
|
PROVIDER_MAPPING = {
|
||||||
"synth_finance": SynthFinanceProvider,
|
"coingecko_free": providers.CoinGeckoFreeProvider,
|
||||||
"synth_finance_stock": SynthFinanceStockProvider,
|
"coingecko_pro": providers.CoinGeckoProProvider,
|
||||||
"coingecko_free": CoinGeckoFreeProvider,
|
"transitive": providers.TransitiveRateProvider,
|
||||||
"coingecko_pro": CoinGeckoProProvider,
|
"frankfurter": providers.FrankfurterProvider,
|
||||||
"transitive": TransitiveRateProvider,
|
"twelvedata": providers.TwelveDataProvider,
|
||||||
|
"twelvedatamarkets": providers.TwelveDataMarketsProvider,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -203,21 +198,63 @@ class ExchangeRateFetcher:
|
|||||||
|
|
||||||
if provider.rates_inverted:
|
if provider.rates_inverted:
|
||||||
# If rates are inverted, we need to swap currencies
|
# If rates are inverted, we need to swap currencies
|
||||||
|
if service.singleton:
|
||||||
|
# Try to get the last automatically created exchange rate
|
||||||
|
exchange_rate = (
|
||||||
|
ExchangeRate.objects.filter(
|
||||||
|
automatic=True,
|
||||||
|
from_currency=to_currency,
|
||||||
|
to_currency=from_currency,
|
||||||
|
)
|
||||||
|
.order_by("-date")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
exchange_rate = None
|
||||||
|
|
||||||
|
if not exchange_rate:
|
||||||
ExchangeRate.objects.create(
|
ExchangeRate.objects.create(
|
||||||
|
automatic=True,
|
||||||
from_currency=to_currency,
|
from_currency=to_currency,
|
||||||
to_currency=from_currency,
|
to_currency=from_currency,
|
||||||
rate=rate,
|
rate=rate,
|
||||||
date=timezone.now(),
|
date=timezone.now(),
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
exchange_rate.rate = rate
|
||||||
|
exchange_rate.date = timezone.now()
|
||||||
|
exchange_rate.save()
|
||||||
|
|
||||||
processed_pairs.add((to_currency.id, from_currency.id))
|
processed_pairs.add((to_currency.id, from_currency.id))
|
||||||
else:
|
else:
|
||||||
# If rates are not inverted, we can use them as is
|
# If rates are not inverted, we can use them as is
|
||||||
|
if service.singleton:
|
||||||
|
# Try to get the last automatically created exchange rate
|
||||||
|
exchange_rate = (
|
||||||
|
ExchangeRate.objects.filter(
|
||||||
|
automatic=True,
|
||||||
|
from_currency=from_currency,
|
||||||
|
to_currency=to_currency,
|
||||||
|
)
|
||||||
|
.order_by("-date")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
exchange_rate = None
|
||||||
|
|
||||||
|
if not exchange_rate:
|
||||||
ExchangeRate.objects.create(
|
ExchangeRate.objects.create(
|
||||||
|
automatic=True,
|
||||||
from_currency=from_currency,
|
from_currency=from_currency,
|
||||||
to_currency=to_currency,
|
to_currency=to_currency,
|
||||||
rate=rate,
|
rate=rate,
|
||||||
date=timezone.now(),
|
date=timezone.now(),
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
exchange_rate.rate = rate
|
||||||
|
exchange_rate.date = timezone.now()
|
||||||
|
exchange_rate.save()
|
||||||
|
|
||||||
processed_pairs.add((from_currency.id, to_currency.id))
|
processed_pairs.add((from_currency.id, to_currency.id))
|
||||||
|
|
||||||
service.last_fetch = timezone.now()
|
service.last_fetch = timezone.now()
|
||||||
|
|||||||
@@ -13,70 +13,6 @@ from apps.currencies.exchange_rates.base import ExchangeRateProvider
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class SynthFinanceProvider(ExchangeRateProvider):
|
|
||||||
"""Implementation for Synth Finance API (synthfinance.com)"""
|
|
||||||
|
|
||||||
BASE_URL = "https://api.synthfinance.com/rates/live"
|
|
||||||
rates_inverted = False # SynthFinance returns non-inverted rates
|
|
||||||
|
|
||||||
def __init__(self, api_key: str = None):
|
|
||||||
super().__init__(api_key)
|
|
||||||
self.session = requests.Session()
|
|
||||||
self.session.headers.update({"Authorization": f"Bearer {self.api_key}"})
|
|
||||||
|
|
||||||
def get_rates(
|
|
||||||
self, target_currencies: QuerySet, exchange_currencies: set
|
|
||||||
) -> List[Tuple[Currency, Currency, Decimal]]:
|
|
||||||
results = []
|
|
||||||
currency_groups = {}
|
|
||||||
for currency in target_currencies:
|
|
||||||
if currency.exchange_currency in exchange_currencies:
|
|
||||||
group = currency_groups.setdefault(currency.exchange_currency.code, [])
|
|
||||||
group.append(currency)
|
|
||||||
|
|
||||||
for base_currency, currencies in currency_groups.items():
|
|
||||||
try:
|
|
||||||
to_currencies = ",".join(
|
|
||||||
currency.code
|
|
||||||
for currency in currencies
|
|
||||||
if currency.code != base_currency
|
|
||||||
)
|
|
||||||
response = self.session.get(
|
|
||||||
f"{self.BASE_URL}",
|
|
||||||
params={"from": base_currency, "to": to_currencies},
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
rates = data["data"]["rates"]
|
|
||||||
|
|
||||||
for currency in currencies:
|
|
||||||
if currency.code == base_currency:
|
|
||||||
rate = Decimal("1")
|
|
||||||
else:
|
|
||||||
rate = Decimal(str(rates[currency.code]))
|
|
||||||
# Return the rate as is, without inversion
|
|
||||||
results.append((currency.exchange_currency, currency, rate))
|
|
||||||
|
|
||||||
credits_used = data["meta"]["credits_used"]
|
|
||||||
credits_remaining = data["meta"]["credits_remaining"]
|
|
||||||
logger.info(
|
|
||||||
f"Synth Finance API call: {credits_used} credits used, {credits_remaining} remaining"
|
|
||||||
)
|
|
||||||
except requests.RequestException as e:
|
|
||||||
logger.error(
|
|
||||||
f"Error fetching rates from Synth Finance API for base {base_currency}: {e}"
|
|
||||||
)
|
|
||||||
except KeyError as e:
|
|
||||||
logger.error(
|
|
||||||
f"Unexpected response structure from Synth Finance API for base {base_currency}: {e}"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"Unexpected error processing Synth Finance data for base {base_currency}: {e}"
|
|
||||||
)
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
class CoinGeckoFreeProvider(ExchangeRateProvider):
|
class CoinGeckoFreeProvider(ExchangeRateProvider):
|
||||||
"""Implementation for CoinGecko Free API"""
|
"""Implementation for CoinGecko Free API"""
|
||||||
|
|
||||||
@@ -152,71 +88,6 @@ class CoinGeckoProProvider(CoinGeckoFreeProvider):
|
|||||||
self.session.headers.update({"x-cg-pro-api-key": api_key})
|
self.session.headers.update({"x-cg-pro-api-key": api_key})
|
||||||
|
|
||||||
|
|
||||||
class SynthFinanceStockProvider(ExchangeRateProvider):
|
|
||||||
"""Implementation for Synth Finance API Real-Time Prices endpoint (synthfinance.com)"""
|
|
||||||
|
|
||||||
BASE_URL = "https://api.synthfinance.com/tickers"
|
|
||||||
rates_inverted = True
|
|
||||||
|
|
||||||
def __init__(self, api_key: str = None):
|
|
||||||
super().__init__(api_key)
|
|
||||||
self.session = requests.Session()
|
|
||||||
self.session.headers.update(
|
|
||||||
{"Authorization": f"Bearer {self.api_key}", "accept": "application/json"}
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_rates(
|
|
||||||
self, target_currencies: QuerySet, exchange_currencies: set
|
|
||||||
) -> List[Tuple[Currency, Currency, Decimal]]:
|
|
||||||
results = []
|
|
||||||
|
|
||||||
for currency in target_currencies:
|
|
||||||
if currency.exchange_currency not in exchange_currencies:
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Same currency has rate of 1
|
|
||||||
if currency.code == currency.exchange_currency.code:
|
|
||||||
rate = Decimal("1")
|
|
||||||
results.append((currency.exchange_currency, currency, rate))
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Fetch real-time price for this ticker
|
|
||||||
response = self.session.get(
|
|
||||||
f"{self.BASE_URL}/{currency.code}/real-time"
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
# Use fair market value as the rate
|
|
||||||
rate = Decimal(data["data"]["fair_market_value"])
|
|
||||||
results.append((currency.exchange_currency, currency, rate))
|
|
||||||
|
|
||||||
# Log API usage
|
|
||||||
credits_used = data["meta"]["credits_used"]
|
|
||||||
credits_remaining = data["meta"]["credits_remaining"]
|
|
||||||
logger.info(
|
|
||||||
f"Synth Finance API call for {currency.code}: {credits_used} credits used, {credits_remaining} remaining"
|
|
||||||
)
|
|
||||||
except requests.RequestException as e:
|
|
||||||
logger.error(
|
|
||||||
f"Error fetching rate from Synth Finance API for ticker {currency.code}: {e}",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
except KeyError as e:
|
|
||||||
logger.error(
|
|
||||||
f"Unexpected response structure from Synth Finance API for ticker {currency.code}: {e}",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"Unexpected error processing Synth Finance data for ticker {currency.code}: {e}",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
class TransitiveRateProvider(ExchangeRateProvider):
|
class TransitiveRateProvider(ExchangeRateProvider):
|
||||||
"""Calculates exchange rates through paths of existing rates"""
|
"""Calculates exchange rates through paths of existing rates"""
|
||||||
|
|
||||||
@@ -306,3 +177,329 @@ class TransitiveRateProvider(ExchangeRateProvider):
|
|||||||
queue.append((neighbor, path + [neighbor], current_rate * rate))
|
queue.append((neighbor, path + [neighbor], current_rate * rate))
|
||||||
|
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
class FrankfurterProvider(ExchangeRateProvider):
|
||||||
|
"""Implementation for the Frankfurter API (frankfurter.dev)"""
|
||||||
|
|
||||||
|
BASE_URL = "https://api.frankfurter.dev/v1/latest"
|
||||||
|
rates_inverted = (
|
||||||
|
False # Frankfurter returns non-inverted rates (e.g., 1 EUR = 1.1 USD)
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, api_key: str = None):
|
||||||
|
"""
|
||||||
|
Initializes the provider. The Frankfurter API does not require an API key,
|
||||||
|
so the api_key parameter is ignored.
|
||||||
|
"""
|
||||||
|
super().__init__(api_key)
|
||||||
|
self.session = requests.Session()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def requires_api_key(cls) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_rates(
|
||||||
|
self, target_currencies: QuerySet, exchange_currencies: set
|
||||||
|
) -> List[Tuple[Currency, Currency, Decimal]]:
|
||||||
|
results = []
|
||||||
|
currency_groups = {}
|
||||||
|
# Group target currencies by their exchange (base) currency to minimize API calls
|
||||||
|
for currency in target_currencies:
|
||||||
|
if currency.exchange_currency in exchange_currencies:
|
||||||
|
group = currency_groups.setdefault(currency.exchange_currency.code, [])
|
||||||
|
group.append(currency)
|
||||||
|
|
||||||
|
# Make one API call for each base currency
|
||||||
|
for base_currency, currencies in currency_groups.items():
|
||||||
|
try:
|
||||||
|
# Create a comma-separated list of target currency codes
|
||||||
|
to_currencies = ",".join(
|
||||||
|
currency.code
|
||||||
|
for currency in currencies
|
||||||
|
if currency.code != base_currency
|
||||||
|
)
|
||||||
|
|
||||||
|
# If there are no target currencies other than the base, skip the API call
|
||||||
|
if not to_currencies:
|
||||||
|
# Handle the case where the only request is for the base rate (e.g., USD to USD)
|
||||||
|
for currency in currencies:
|
||||||
|
if currency.code == base_currency:
|
||||||
|
results.append(
|
||||||
|
(currency.exchange_currency, currency, Decimal("1"))
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
response = self.session.get(
|
||||||
|
self.BASE_URL,
|
||||||
|
params={"base": base_currency, "symbols": to_currencies},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
rates = data["rates"]
|
||||||
|
|
||||||
|
# Process the returned rates
|
||||||
|
for currency in currencies:
|
||||||
|
if currency.code == base_currency:
|
||||||
|
# The rate for the base currency to itself is always 1
|
||||||
|
rate = Decimal("1")
|
||||||
|
else:
|
||||||
|
rate = Decimal(str(rates[currency.code]))
|
||||||
|
|
||||||
|
results.append((currency.exchange_currency, currency, rate))
|
||||||
|
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error fetching rates from Frankfurter API for base {base_currency}: {e}"
|
||||||
|
)
|
||||||
|
except KeyError as e:
|
||||||
|
logger.error(
|
||||||
|
f"Unexpected response structure from Frankfurter API for base {base_currency}: {e}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Unexpected error processing Frankfurter data for base {base_currency}: {e}"
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
class TwelveDataProvider(ExchangeRateProvider):
|
||||||
|
"""Implementation for the Twelve Data API (twelvedata.com)"""
|
||||||
|
|
||||||
|
BASE_URL = "https://api.twelvedata.com/exchange_rate"
|
||||||
|
rates_inverted = (
|
||||||
|
False # The API returns direct rates, e.g., for EUR/USD it's 1 EUR = X USD
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, api_key: str):
|
||||||
|
"""
|
||||||
|
Initializes the provider with an API key and a requests session.
|
||||||
|
"""
|
||||||
|
super().__init__(api_key)
|
||||||
|
self.session = requests.Session()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def requires_api_key(cls) -> bool:
|
||||||
|
"""This provider requires an API key."""
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_rates(
|
||||||
|
self, target_currencies: QuerySet, exchange_currencies: set
|
||||||
|
) -> List[Tuple[Currency, Currency, Decimal]]:
|
||||||
|
"""
|
||||||
|
Fetches exchange rates from the Twelve Data API for the given currency pairs.
|
||||||
|
|
||||||
|
This provider makes one API call for each requested currency pair.
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for target_currency in target_currencies:
|
||||||
|
# Ensure the target currency's exchange currency is one we're interested in
|
||||||
|
if target_currency.exchange_currency not in exchange_currencies:
|
||||||
|
continue
|
||||||
|
|
||||||
|
base_currency = target_currency.exchange_currency
|
||||||
|
|
||||||
|
# The exchange rate for the same currency is always 1
|
||||||
|
if base_currency.code == target_currency.code:
|
||||||
|
rate = Decimal("1")
|
||||||
|
results.append((base_currency, target_currency, rate))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Construct the symbol in the format "BASE/TARGET", e.g., "EUR/USD"
|
||||||
|
symbol = f"{base_currency.code}/{target_currency.code}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
params = {
|
||||||
|
"symbol": symbol,
|
||||||
|
"apikey": self.api_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.session.get(self.BASE_URL, params=params)
|
||||||
|
response.raise_for_status() # Raise an HTTPError for bad responses (4xx or 5xx)
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# The API may return an error message in a JSON object
|
||||||
|
if "rate" not in data:
|
||||||
|
error_message = data.get("message", "Rate not found in response.")
|
||||||
|
logger.error(
|
||||||
|
f"Could not fetch rate for {symbol} from Twelve Data: {error_message}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Convert the rate to a Decimal for precision
|
||||||
|
rate = Decimal(str(data["rate"]))
|
||||||
|
results.append((base_currency, target_currency, rate))
|
||||||
|
|
||||||
|
logger.info(f"Successfully fetched rate for {symbol} from Twelve Data.")
|
||||||
|
|
||||||
|
time.sleep(
|
||||||
|
60
|
||||||
|
) # We sleep every pair as to not step over TwelveData's minute limit
|
||||||
|
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error fetching rate from Twelve Data API for symbol {symbol}: {e}"
|
||||||
|
)
|
||||||
|
except KeyError as e:
|
||||||
|
logger.error(
|
||||||
|
f"Unexpected response structure from Twelve Data API for symbol {symbol}: Missing key {e}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"An unexpected error occurred while processing Twelve Data for {symbol}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
class TwelveDataMarketsProvider(ExchangeRateProvider):
|
||||||
|
"""
|
||||||
|
Provides prices for market instruments (stocks, ETFs, etc.) using the Twelve Data API.
|
||||||
|
|
||||||
|
This provider performs a multi-step process:
|
||||||
|
1. Parses instrument codes which can be symbols, FIGI, CUSIP, or ISIN.
|
||||||
|
2. For CUSIPs, it defaults the currency to USD. For all others, it searches
|
||||||
|
for the instrument to determine its native trading currency.
|
||||||
|
3. Fetches the latest price for the instrument in its native currency.
|
||||||
|
4. Converts the price to the requested target exchange currency.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SYMBOL_SEARCH_URL = "https://api.twelvedata.com/symbol_search"
|
||||||
|
PRICE_URL = "https://api.twelvedata.com/price"
|
||||||
|
EXCHANGE_RATE_URL = "https://api.twelvedata.com/exchange_rate"
|
||||||
|
|
||||||
|
rates_inverted = True
|
||||||
|
|
||||||
|
def __init__(self, api_key: str):
|
||||||
|
super().__init__(api_key)
|
||||||
|
self.session = requests.Session()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def requires_api_key(cls) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _parse_code(self, raw_code: str) -> Tuple[str, str]:
|
||||||
|
"""Parses the raw code to determine its type and value."""
|
||||||
|
if raw_code.startswith("figi:"):
|
||||||
|
return "figi", raw_code.removeprefix("figi:")
|
||||||
|
if raw_code.startswith("cusip:"):
|
||||||
|
return "cusip", raw_code.removeprefix("cusip:")
|
||||||
|
if raw_code.startswith("isin:"):
|
||||||
|
return "isin", raw_code.removeprefix("isin:")
|
||||||
|
return "symbol", raw_code
|
||||||
|
|
||||||
|
def get_rates(
|
||||||
|
self, target_currencies: QuerySet, exchange_currencies: set
|
||||||
|
) -> List[Tuple[Currency, Currency, Decimal]]:
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for asset in target_currencies:
|
||||||
|
if asset.exchange_currency not in exchange_currencies:
|
||||||
|
continue
|
||||||
|
|
||||||
|
code_type, code_value = self._parse_code(asset.code)
|
||||||
|
original_currency_code = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Determine the instrument's native currency
|
||||||
|
if code_type == "cusip":
|
||||||
|
# CUSIP codes always default to USD
|
||||||
|
original_currency_code = "USD"
|
||||||
|
logger.info(f"Defaulting CUSIP {code_value} to USD currency.")
|
||||||
|
else:
|
||||||
|
# For all other types, find currency via symbol search
|
||||||
|
search_params = {"symbol": code_value, "apikey": "demo"}
|
||||||
|
search_res = self.session.get(
|
||||||
|
self.SYMBOL_SEARCH_URL, params=search_params
|
||||||
|
)
|
||||||
|
search_res.raise_for_status()
|
||||||
|
search_data = search_res.json()
|
||||||
|
|
||||||
|
if not search_data.get("data"):
|
||||||
|
logger.warning(
|
||||||
|
f"TwelveDataMarkets: Symbol search for '{code_value}' returned no results."
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
instrument_data = search_data["data"][0]
|
||||||
|
original_currency_code = instrument_data.get("currency")
|
||||||
|
|
||||||
|
if not original_currency_code:
|
||||||
|
logger.error(
|
||||||
|
f"TwelveDataMarkets: Could not determine original currency for '{code_value}'."
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get the instrument's price in its native currency
|
||||||
|
price_params = {code_type: code_value, "apikey": self.api_key}
|
||||||
|
price_res = self.session.get(self.PRICE_URL, params=price_params)
|
||||||
|
price_res.raise_for_status()
|
||||||
|
price_data = price_res.json()
|
||||||
|
|
||||||
|
if "price" not in price_data:
|
||||||
|
error_message = price_data.get(
|
||||||
|
"message", "Price key not found in response"
|
||||||
|
)
|
||||||
|
logger.error(
|
||||||
|
f"TwelveDataMarkets: Could not get price for {code_type} '{code_value}': {error_message}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
price_in_original_currency = Decimal(price_data["price"])
|
||||||
|
|
||||||
|
# Convert price to the target exchange currency
|
||||||
|
target_exchange_currency = asset.exchange_currency
|
||||||
|
|
||||||
|
if (
|
||||||
|
original_currency_code.upper()
|
||||||
|
== target_exchange_currency.code.upper()
|
||||||
|
):
|
||||||
|
final_price = price_in_original_currency
|
||||||
|
else:
|
||||||
|
rate_symbol = (
|
||||||
|
f"{original_currency_code}/{target_exchange_currency.code}"
|
||||||
|
)
|
||||||
|
rate_params = {"symbol": rate_symbol, "apikey": self.api_key}
|
||||||
|
rate_res = self.session.get(
|
||||||
|
self.EXCHANGE_RATE_URL, params=rate_params
|
||||||
|
)
|
||||||
|
rate_res.raise_for_status()
|
||||||
|
rate_data = rate_res.json()
|
||||||
|
|
||||||
|
if "rate" not in rate_data:
|
||||||
|
error_message = rate_data.get(
|
||||||
|
"message", "Rate key not found in response"
|
||||||
|
)
|
||||||
|
logger.error(
|
||||||
|
f"TwelveDataMarkets: Could not get conversion rate for '{rate_symbol}': {error_message}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
conversion_rate = Decimal(str(rate_data["rate"]))
|
||||||
|
final_price = price_in_original_currency * conversion_rate
|
||||||
|
|
||||||
|
results.append((target_exchange_currency, asset, final_price))
|
||||||
|
logger.info(
|
||||||
|
f"Successfully processed price for {asset.code} as {final_price} {target_exchange_currency.code}"
|
||||||
|
)
|
||||||
|
|
||||||
|
time.sleep(
|
||||||
|
60
|
||||||
|
) # We sleep every pair as to not step over TwelveData's minute limit
|
||||||
|
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.error(
|
||||||
|
f"TwelveDataMarkets: API request failed for {code_value}: {e}"
|
||||||
|
)
|
||||||
|
except (KeyError, IndexError) as e:
|
||||||
|
logger.error(
|
||||||
|
f"TwelveDataMarkets: Error processing API response for {code_value}: {e}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"TwelveDataMarkets: An unexpected error occurred for {code_value}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class CurrencyForm(forms.ModelForm):
|
|||||||
"suffix",
|
"suffix",
|
||||||
"code",
|
"code",
|
||||||
"exchange_currency",
|
"exchange_currency",
|
||||||
|
"is_archived",
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
"exchange_currency": TomSelect(),
|
"exchange_currency": TomSelect(),
|
||||||
@@ -40,6 +41,7 @@ class CurrencyForm(forms.ModelForm):
|
|||||||
self.helper.layout = Layout(
|
self.helper.layout = Layout(
|
||||||
"code",
|
"code",
|
||||||
"name",
|
"name",
|
||||||
|
Switch("is_archived"),
|
||||||
"decimal_places",
|
"decimal_places",
|
||||||
"prefix",
|
"prefix",
|
||||||
"suffix",
|
"suffix",
|
||||||
@@ -114,6 +116,7 @@ class ExchangeRateServiceForm(forms.ModelForm):
|
|||||||
"fetch_interval",
|
"fetch_interval",
|
||||||
"target_currencies",
|
"target_currencies",
|
||||||
"target_accounts",
|
"target_accounts",
|
||||||
|
"singleton",
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@@ -126,6 +129,7 @@ class ExchangeRateServiceForm(forms.ModelForm):
|
|||||||
"name",
|
"name",
|
||||||
"service_type",
|
"service_type",
|
||||||
Switch("is_active"),
|
Switch("is_active"),
|
||||||
|
Switch("singleton"),
|
||||||
"api_key",
|
"api_key",
|
||||||
Row(
|
Row(
|
||||||
Column("interval_type", css_class="form-group col-md-6"),
|
Column("interval_type", css_class="form-group col-md-6"),
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 5.1.7 on 2025-03-09 21:54
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('currencies', '0013_alter_exchangerateservice_service_type'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='currency',
|
||||||
|
options={'ordering': ['name', 'id'], 'verbose_name': 'Currency', 'verbose_name_plural': 'Currencies'},
|
||||||
|
),
|
||||||
|
]
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.4 on 2025-08-08 02:18
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('currencies', '0014_alter_currency_options'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='exchangerate',
|
||||||
|
name='automatic',
|
||||||
|
field=models.BooleanField(default=False, verbose_name='Automatic'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='exchangerateservice',
|
||||||
|
name='singleton',
|
||||||
|
field=models.BooleanField(default=False, help_text='Create one exchange rate and keep updating it. Avoids database clutter.', verbose_name='Single exchange rate'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.4 on 2025-08-08 02:38
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('currencies', '0015_exchangerate_automatic_exchangerateservice_singleton'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='exchangerate',
|
||||||
|
name='automatic',
|
||||||
|
field=models.BooleanField(default=False, verbose_name='Auto'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-08-16 22:18
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('currencies', '0016_alter_exchangerate_automatic'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='exchangerateservice',
|
||||||
|
name='service_type',
|
||||||
|
field=models.CharField(choices=[('synth_finance', 'Synth Finance'), ('synth_finance_stock', 'Synth Finance Stock'), ('coingecko_free', 'CoinGecko (Demo/Free)'), ('coingecko_pro', 'CoinGecko (Pro)'), ('transitive', 'Transitive (Calculated from Existing Rates)'), ('frankfurter', 'Frankfurter')], max_length=255, verbose_name='Service Type'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-08-17 03:54
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('currencies', '0017_alter_exchangerateservice_service_type'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='exchangerateservice',
|
||||||
|
name='service_type',
|
||||||
|
field=models.CharField(choices=[('synth_finance', 'Synth Finance'), ('synth_finance_stock', 'Synth Finance Stock'), ('coingecko_free', 'CoinGecko (Demo/Free)'), ('coingecko_pro', 'CoinGecko (Pro)'), ('transitive', 'Transitive (Calculated from Existing Rates)'), ('frankfurter', 'Frankfurter'), ('twelvedata', 'TwelveData')], max_length=255, verbose_name='Service Type'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-08-17 06:01
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('currencies', '0018_alter_exchangerateservice_service_type'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='exchangerateservice',
|
||||||
|
name='service_type',
|
||||||
|
field=models.CharField(choices=[('synth_finance', 'Synth Finance'), ('synth_finance_stock', 'Synth Finance Stock'), ('coingecko_free', 'CoinGecko (Demo/Free)'), ('coingecko_pro', 'CoinGecko (Pro)'), ('transitive', 'Transitive (Calculated from Existing Rates)'), ('frankfurter', 'Frankfurter'), ('twelvedata', 'TwelveData'), ('twelvedatamarkets', 'TwelveData Markets')], max_length=255, verbose_name='Service Type'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-08-17 06:25
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
# The new value we are migrating to
|
||||||
|
NEW_SERVICE_TYPE = "frankfurter"
|
||||||
|
# The old values we are deprecating
|
||||||
|
OLD_SERVICE_TYPE_TO_UPDATE = "synth_finance"
|
||||||
|
OLD_SERVICE_TYPE_TO_DELETE = "synth_finance_stock"
|
||||||
|
|
||||||
|
|
||||||
|
def forwards_func(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
Forward migration:
|
||||||
|
- Deletes all ExchangeRateService instances with service_type 'synth_finance_stock'.
|
||||||
|
- Updates all ExchangeRateService instances with service_type 'synth_finance' to 'frankfurter'.
|
||||||
|
"""
|
||||||
|
ExchangeRateService = apps.get_model("currencies", "ExchangeRateService")
|
||||||
|
db_alias = schema_editor.connection.alias
|
||||||
|
|
||||||
|
# 1. Delete the SYNTH_FINANCE_STOCK entries
|
||||||
|
ExchangeRateService.objects.using(db_alias).filter(
|
||||||
|
service_type=OLD_SERVICE_TYPE_TO_DELETE
|
||||||
|
).delete()
|
||||||
|
|
||||||
|
# 2. Update the SYNTH_FINANCE entries to FRANKFURTER
|
||||||
|
ExchangeRateService.objects.using(db_alias).filter(
|
||||||
|
service_type=OLD_SERVICE_TYPE_TO_UPDATE
|
||||||
|
).update(service_type=NEW_SERVICE_TYPE, api_key=None)
|
||||||
|
|
||||||
|
|
||||||
|
def backwards_func(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
Backward migration: This operation is not safely reversible.
|
||||||
|
- We cannot know which 'frankfurter' services were originally 'synth_finance'.
|
||||||
|
- The deleted 'synth_finance_stock' services cannot be recovered.
|
||||||
|
We will leave this function empty to allow migrating backwards without doing anything.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
# Add the previous migration file here
|
||||||
|
("currencies", "0019_alter_exchangerateservice_service_type"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(forwards_func, reverse_code=backwards_func),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-08-17 06:29
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('currencies', '0020_migrate_synth_finance_services'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='exchangerateservice',
|
||||||
|
name='service_type',
|
||||||
|
field=models.CharField(choices=[('coingecko_free', 'CoinGecko (Demo/Free)'), ('coingecko_pro', 'CoinGecko (Pro)'), ('transitive', 'Transitive (Calculated from Existing Rates)'), ('frankfurter', 'Frankfurter'), ('twelvedata', 'TwelveData'), ('twelvedatamarkets', 'TwelveData Markets')], max_length=255, verbose_name='Service Type'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-08-30 00:47
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('currencies', '0021_alter_exchangerateservice_service_type'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='currency',
|
||||||
|
name='is_archived',
|
||||||
|
field=models.BooleanField(default=False, verbose_name='Archived'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -32,12 +32,18 @@ class Currency(models.Model):
|
|||||||
help_text=_("Default currency for exchange calculations"),
|
help_text=_("Default currency for exchange calculations"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
is_archived = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name=_("Archived"),
|
||||||
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Currency")
|
verbose_name = _("Currency")
|
||||||
verbose_name_plural = _("Currencies")
|
verbose_name_plural = _("Currencies")
|
||||||
|
ordering = ["name", "id"]
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
super().clean()
|
super().clean()
|
||||||
@@ -69,6 +75,8 @@ class ExchangeRate(models.Model):
|
|||||||
)
|
)
|
||||||
date = models.DateTimeField(verbose_name=_("Date and Time"))
|
date = models.DateTimeField(verbose_name=_("Date and Time"))
|
||||||
|
|
||||||
|
automatic = models.BooleanField(verbose_name=_("Auto"), default=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Exchange Rate")
|
verbose_name = _("Exchange Rate")
|
||||||
verbose_name_plural = _("Exchange Rates")
|
verbose_name_plural = _("Exchange Rates")
|
||||||
@@ -91,11 +99,12 @@ class ExchangeRateService(models.Model):
|
|||||||
"""Configuration for exchange rate services"""
|
"""Configuration for exchange rate services"""
|
||||||
|
|
||||||
class ServiceType(models.TextChoices):
|
class ServiceType(models.TextChoices):
|
||||||
SYNTH_FINANCE = "synth_finance", "Synth Finance"
|
|
||||||
SYNTH_FINANCE_STOCK = "synth_finance_stock", "Synth Finance Stock"
|
|
||||||
COINGECKO_FREE = "coingecko_free", "CoinGecko (Demo/Free)"
|
COINGECKO_FREE = "coingecko_free", "CoinGecko (Demo/Free)"
|
||||||
COINGECKO_PRO = "coingecko_pro", "CoinGecko (Pro)"
|
COINGECKO_PRO = "coingecko_pro", "CoinGecko (Pro)"
|
||||||
TRANSITIVE = "transitive", "Transitive (Calculated from Existing Rates)"
|
TRANSITIVE = "transitive", "Transitive (Calculated from Existing Rates)"
|
||||||
|
FRANKFURTER = "frankfurter", "Frankfurter"
|
||||||
|
TWELVEDATA = "twelvedata", "TwelveData"
|
||||||
|
TWELVEDATA_MARKETS = "twelvedatamarkets", "TwelveData Markets"
|
||||||
|
|
||||||
class IntervalType(models.TextChoices):
|
class IntervalType(models.TextChoices):
|
||||||
ON = "on", _("On")
|
ON = "on", _("On")
|
||||||
@@ -147,6 +156,14 @@ class ExchangeRateService(models.Model):
|
|||||||
blank=True,
|
blank=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
singleton = models.BooleanField(
|
||||||
|
verbose_name=_("Single exchange rate"),
|
||||||
|
default=False,
|
||||||
|
help_text=_(
|
||||||
|
"Create one exchange rate and keep updating it. Avoids database clutter."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Exchange Rate Service")
|
verbose_name = _("Exchange Rate Service")
|
||||||
verbose_name_plural = _("Exchange Rate Services")
|
verbose_name_plural = _("Exchange Rate Services")
|
||||||
|
|||||||
@@ -40,12 +40,6 @@ class CurrencyTests(TestCase):
|
|||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
currency.full_clean()
|
currency.full_clean()
|
||||||
|
|
||||||
def test_currency_unique_code(self):
|
|
||||||
"""Test that currency codes must be unique"""
|
|
||||||
Currency.objects.create(code="USD", name="US Dollar", decimal_places=2)
|
|
||||||
with self.assertRaises(IntegrityError):
|
|
||||||
Currency.objects.create(code="USD", name="Another Dollar", decimal_places=2)
|
|
||||||
|
|
||||||
def test_currency_unique_name(self):
|
def test_currency_unique_name(self):
|
||||||
"""Test that currency names must be unique"""
|
"""Test that currency names must be unique"""
|
||||||
Currency.objects.create(code="USD", name="US Dollar", decimal_places=2)
|
Currency.objects.create(code="USD", name="US Dollar", decimal_places=2)
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ from apps.common.decorators.htmx import only_htmx
|
|||||||
from apps.currencies.forms import ExchangeRateForm, ExchangeRateServiceForm
|
from apps.currencies.forms import ExchangeRateForm, ExchangeRateServiceForm
|
||||||
from apps.currencies.models import ExchangeRate, ExchangeRateService
|
from apps.currencies.models import ExchangeRate, ExchangeRateService
|
||||||
from apps.currencies.tasks import manual_fetch_exchange_rates
|
from apps.currencies.tasks import manual_fetch_exchange_rates
|
||||||
|
from apps.common.decorators.demo import disabled_on_demo
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def exchange_rates_services_index(request):
|
def exchange_rates_services_index(request):
|
||||||
return render(
|
return render(
|
||||||
@@ -24,6 +26,7 @@ def exchange_rates_services_index(request):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def exchange_rates_services_list(request):
|
def exchange_rates_services_list(request):
|
||||||
services = ExchangeRateService.objects.all()
|
services = ExchangeRateService.objects.all()
|
||||||
@@ -37,6 +40,7 @@ def exchange_rates_services_list(request):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET", "POST"])
|
||||||
def exchange_rate_service_add(request):
|
def exchange_rate_service_add(request):
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
@@ -63,6 +67,7 @@ def exchange_rate_service_add(request):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET", "POST"])
|
||||||
def exchange_rate_service_edit(request, pk):
|
def exchange_rate_service_edit(request, pk):
|
||||||
service = get_object_or_404(ExchangeRateService, id=pk)
|
service = get_object_or_404(ExchangeRateService, id=pk)
|
||||||
@@ -91,6 +96,7 @@ def exchange_rate_service_edit(request, pk):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
@require_http_methods(["DELETE"])
|
@require_http_methods(["DELETE"])
|
||||||
def exchange_rate_service_delete(request, pk):
|
def exchange_rate_service_delete(request, pk):
|
||||||
service = get_object_or_404(ExchangeRateService, id=pk)
|
service = get_object_or_404(ExchangeRateService, id=pk)
|
||||||
@@ -109,6 +115,7 @@ def exchange_rate_service_delete(request, pk):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def exchange_rate_service_force_fetch(request):
|
def exchange_rate_service_force_fetch(request):
|
||||||
manual_fetch_exchange_rates.defer()
|
manual_fetch_exchange_rates.defer()
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Generated by Django 5.2.4 on 2025-07-28 02:15
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('dca', '0003_dcastrategy_owner_dcastrategy_shared_with_and_more'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='dcastrategy',
|
||||||
|
name='owner',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='dcastrategy',
|
||||||
|
name='shared_with',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL, verbose_name='Shared with users'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='dcastrategy',
|
||||||
|
name='visibility',
|
||||||
|
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10, verbose_name='Visibility'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -41,11 +41,13 @@ from apps.export_app.resources.transactions import (
|
|||||||
RecurringTransactionResource,
|
RecurringTransactionResource,
|
||||||
)
|
)
|
||||||
from apps.export_app.resources.users import UserResource
|
from apps.export_app.resources.users import UserResource
|
||||||
|
from apps.common.decorators.demo import disabled_on_demo
|
||||||
|
|
||||||
logger = logging.getLogger()
|
logger = logging.getLogger()
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
@user_passes_test(lambda u: u.is_superuser)
|
@user_passes_test(lambda u: u.is_superuser)
|
||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def export_index(request):
|
def export_index(request):
|
||||||
@@ -53,6 +55,7 @@ def export_index(request):
|
|||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
@user_passes_test(lambda u: u.is_superuser)
|
@user_passes_test(lambda u: u.is_superuser)
|
||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET", "POST"])
|
||||||
def export_form(request):
|
def export_form(request):
|
||||||
@@ -182,6 +185,7 @@ def export_form(request):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
@user_passes_test(lambda u: u.is_superuser)
|
@user_passes_test(lambda u: u.is_superuser)
|
||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET", "POST"])
|
||||||
def import_form(request):
|
def import_form(request):
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ from apps.import_app.forms import ImportRunFileUploadForm, ImportProfileForm
|
|||||||
from apps.import_app.models import ImportRun, ImportProfile
|
from apps.import_app.models import ImportRun, ImportProfile
|
||||||
from apps.import_app.services import PresetService
|
from apps.import_app.services import PresetService
|
||||||
from apps.import_app.tasks import process_import
|
from apps.import_app.tasks import process_import
|
||||||
|
from apps.common.decorators.demo import disabled_on_demo
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def import_presets_list(request):
|
def import_presets_list(request):
|
||||||
presets = PresetService.get_all_presets()
|
presets = PresetService.get_all_presets()
|
||||||
@@ -27,6 +29,7 @@ def import_presets_list(request):
|
|||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET", "POST"])
|
||||||
def import_profile_index(request):
|
def import_profile_index(request):
|
||||||
return render(
|
return render(
|
||||||
@@ -37,6 +40,7 @@ def import_profile_index(request):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET", "POST"])
|
||||||
def import_profile_list(request):
|
def import_profile_list(request):
|
||||||
profiles = ImportProfile.objects.all()
|
profiles = ImportProfile.objects.all()
|
||||||
@@ -50,6 +54,7 @@ def import_profile_list(request):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET", "POST"])
|
||||||
def import_profile_add(request):
|
def import_profile_add(request):
|
||||||
message = request.POST.get("message", None)
|
message = request.POST.get("message", None)
|
||||||
@@ -85,6 +90,7 @@ def import_profile_add(request):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET", "POST"])
|
||||||
def import_profile_edit(request, profile_id):
|
def import_profile_edit(request, profile_id):
|
||||||
profile = get_object_or_404(ImportProfile, id=profile_id)
|
profile = get_object_or_404(ImportProfile, id=profile_id)
|
||||||
@@ -114,6 +120,7 @@ def import_profile_edit(request, profile_id):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
@require_http_methods(["DELETE"])
|
@require_http_methods(["DELETE"])
|
||||||
def import_profile_delete(request, profile_id):
|
def import_profile_delete(request, profile_id):
|
||||||
profile = ImportProfile.objects.get(id=profile_id)
|
profile = ImportProfile.objects.get(id=profile_id)
|
||||||
@@ -132,6 +139,7 @@ def import_profile_delete(request, profile_id):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET", "POST"])
|
||||||
def import_runs_list(request, profile_id):
|
def import_runs_list(request, profile_id):
|
||||||
profile = ImportProfile.objects.get(id=profile_id)
|
profile = ImportProfile.objects.get(id=profile_id)
|
||||||
@@ -147,6 +155,7 @@ def import_runs_list(request, profile_id):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET", "POST"])
|
||||||
def import_run_log(request, profile_id, run_id):
|
def import_run_log(request, profile_id, run_id):
|
||||||
run = ImportRun.objects.get(profile__id=profile_id, id=run_id)
|
run = ImportRun.objects.get(profile__id=profile_id, id=run_id)
|
||||||
@@ -160,6 +169,7 @@ def import_run_log(request, profile_id, run_id):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET", "POST"])
|
||||||
def import_run_add(request, profile_id):
|
def import_run_add(request, profile_id):
|
||||||
profile = ImportProfile.objects.get(id=profile_id)
|
profile = ImportProfile.objects.get(id=profile_id)
|
||||||
@@ -202,6 +212,7 @@ def import_run_add(request, profile_id):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
@require_http_methods(["DELETE"])
|
@require_http_methods(["DELETE"])
|
||||||
def import_run_delete(request, profile_id, run_id):
|
def import_run_delete(request, profile_id, run_id):
|
||||||
run = ImportRun.objects.get(profile__id=profile_id, id=run_id)
|
run = ImportRun.objects.get(profile__id=profile_id, id=run_id)
|
||||||
|
|||||||
@@ -117,13 +117,15 @@ class CategoryForm(forms.Form):
|
|||||||
required=False,
|
required=False,
|
||||||
label=_("Category"),
|
label=_("Category"),
|
||||||
empty_label=_("Uncategorized"),
|
empty_label=_("Uncategorized"),
|
||||||
queryset=TransactionCategory.objects.filter(active=True),
|
queryset=TransactionCategory.objects.all(),
|
||||||
widget=TomSelect(clear_button=True),
|
widget=TomSelect(clear_button=True),
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.fields["category"].queryset = TransactionCategory.objects.all()
|
||||||
|
|
||||||
self.helper = FormHelper()
|
self.helper = FormHelper()
|
||||||
self.helper.form_tag = False
|
self.helper.form_tag = False
|
||||||
self.helper.disable_csrf = True
|
self.helper.disable_csrf = True
|
||||||
|
|||||||
@@ -9,8 +9,13 @@ from apps.currencies.models import Currency
|
|||||||
from apps.currencies.utils.convert import convert
|
from apps.currencies.utils.convert import convert
|
||||||
|
|
||||||
|
|
||||||
def get_categories_totals(transactions_queryset, ignore_empty=False):
|
def get_categories_totals(
|
||||||
# Get metrics for each category and currency in a single query
|
transactions_queryset, ignore_empty=False, show_entities=False
|
||||||
|
):
|
||||||
|
# Step 1: Aggregate transaction data by category and currency.
|
||||||
|
# This query calculates the total current and projected income/expense for each
|
||||||
|
# category by grouping transactions and summing up their amounts based on their
|
||||||
|
# type (income/expense) and payment status (paid/unpaid).
|
||||||
category_currency_metrics = (
|
category_currency_metrics = (
|
||||||
transactions_queryset.values(
|
transactions_queryset.values(
|
||||||
"category",
|
"category",
|
||||||
@@ -74,9 +79,70 @@ def get_categories_totals(transactions_queryset, ignore_empty=False):
|
|||||||
.order_by("category__name")
|
.order_by("category__name")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Process the results to structure by category
|
# Step 2: Aggregate transaction data by tag, category, and currency.
|
||||||
|
# This is similar to the category metrics but adds tags to the grouping,
|
||||||
|
# allowing for a breakdown of totals by tag within each category. It also
|
||||||
|
# handles untagged transactions, where the 'tags' field is None.
|
||||||
|
tag_metrics = transactions_queryset.values(
|
||||||
|
"category",
|
||||||
|
"tags",
|
||||||
|
"tags__name",
|
||||||
|
"account__currency",
|
||||||
|
"account__currency__code",
|
||||||
|
"account__currency__name",
|
||||||
|
"account__currency__decimal_places",
|
||||||
|
"account__currency__prefix",
|
||||||
|
"account__currency__suffix",
|
||||||
|
"account__currency__exchange_currency",
|
||||||
|
).annotate(
|
||||||
|
expense_current=Coalesce(
|
||||||
|
Sum(
|
||||||
|
Case(
|
||||||
|
When(type=Transaction.Type.EXPENSE, is_paid=True, then="amount"),
|
||||||
|
default=Value(0),
|
||||||
|
output_field=models.DecimalField(),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Decimal("0"),
|
||||||
|
),
|
||||||
|
expense_projected=Coalesce(
|
||||||
|
Sum(
|
||||||
|
Case(
|
||||||
|
When(type=Transaction.Type.EXPENSE, is_paid=False, then="amount"),
|
||||||
|
default=Value(0),
|
||||||
|
output_field=models.DecimalField(),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Decimal("0"),
|
||||||
|
),
|
||||||
|
income_current=Coalesce(
|
||||||
|
Sum(
|
||||||
|
Case(
|
||||||
|
When(type=Transaction.Type.INCOME, is_paid=True, then="amount"),
|
||||||
|
default=Value(0),
|
||||||
|
output_field=models.DecimalField(),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Decimal("0"),
|
||||||
|
),
|
||||||
|
income_projected=Coalesce(
|
||||||
|
Sum(
|
||||||
|
Case(
|
||||||
|
When(type=Transaction.Type.INCOME, is_paid=False, then="amount"),
|
||||||
|
default=Value(0),
|
||||||
|
output_field=models.DecimalField(),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Decimal("0"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 3: Initialize the main dictionary to structure the final results.
|
||||||
|
# The data will be organized hierarchically: category -> currency -> tags -> entities.
|
||||||
result = {}
|
result = {}
|
||||||
|
|
||||||
|
# Step 4: Process the aggregated category metrics to build the initial result structure.
|
||||||
|
# This loop iterates through each category's metrics and populates the `result` dict.
|
||||||
for metric in category_currency_metrics:
|
for metric in category_currency_metrics:
|
||||||
# Skip empty categories if ignore_empty is True
|
# Skip empty categories if ignore_empty is True
|
||||||
if ignore_empty and all(
|
if ignore_empty and all(
|
||||||
@@ -101,7 +167,11 @@ def get_categories_totals(transactions_queryset, ignore_empty=False):
|
|||||||
currency_id = metric["account__currency"]
|
currency_id = metric["account__currency"]
|
||||||
|
|
||||||
if category_id not in result:
|
if category_id not in result:
|
||||||
result[category_id] = {"name": metric["category__name"], "currencies": {}}
|
result[category_id] = {
|
||||||
|
"name": metric["category__name"],
|
||||||
|
"currencies": {},
|
||||||
|
"tags": {}, # Add tags container
|
||||||
|
}
|
||||||
|
|
||||||
# Add currency data
|
# Add currency data
|
||||||
currency_data = {
|
currency_data = {
|
||||||
@@ -123,7 +193,7 @@ def get_categories_totals(transactions_queryset, ignore_empty=False):
|
|||||||
"total_final": total_final,
|
"total_final": total_final,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add exchanged values if exchange_currency exists
|
# Step 4a: Handle currency conversion for category totals if an exchange currency is defined.
|
||||||
if metric["account__currency__exchange_currency"]:
|
if metric["account__currency__exchange_currency"]:
|
||||||
from_currency = Currency.objects.get(id=currency_id)
|
from_currency = Currency.objects.get(id=currency_id)
|
||||||
exchange_currency = Currency.objects.get(
|
exchange_currency = Currency.objects.get(
|
||||||
@@ -162,4 +232,273 @@ def get_categories_totals(transactions_queryset, ignore_empty=False):
|
|||||||
|
|
||||||
result[category_id]["currencies"][currency_id] = currency_data
|
result[category_id]["currencies"][currency_id] = currency_data
|
||||||
|
|
||||||
|
# Step 5: Process the aggregated tag metrics and integrate them into the result structure.
|
||||||
|
for tag_metric in tag_metrics:
|
||||||
|
category_id = tag_metric["category"]
|
||||||
|
tag_id = tag_metric["tags"] # Will be None for untagged transactions
|
||||||
|
|
||||||
|
if category_id in result:
|
||||||
|
# Initialize the tag container if not exists
|
||||||
|
if "tags" not in result[category_id]:
|
||||||
|
result[category_id]["tags"] = {}
|
||||||
|
|
||||||
|
# Determine if this is a tagged or untagged transaction
|
||||||
|
tag_key = tag_id if tag_id is not None else "untagged"
|
||||||
|
tag_name = tag_metric["tags__name"] if tag_id is not None else None
|
||||||
|
|
||||||
|
if tag_key not in result[category_id]["tags"]:
|
||||||
|
result[category_id]["tags"][tag_key] = {
|
||||||
|
"name": tag_name,
|
||||||
|
"currencies": {},
|
||||||
|
"entities": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
currency_id = tag_metric["account__currency"]
|
||||||
|
|
||||||
|
# Calculate tag totals
|
||||||
|
tag_total_current = (
|
||||||
|
tag_metric["income_current"] - tag_metric["expense_current"]
|
||||||
|
)
|
||||||
|
tag_total_projected = (
|
||||||
|
tag_metric["income_projected"] - tag_metric["expense_projected"]
|
||||||
|
)
|
||||||
|
tag_total_income = (
|
||||||
|
tag_metric["income_current"] + tag_metric["income_projected"]
|
||||||
|
)
|
||||||
|
tag_total_expense = (
|
||||||
|
tag_metric["expense_current"] + tag_metric["expense_projected"]
|
||||||
|
)
|
||||||
|
tag_total_final = tag_total_current + tag_total_projected
|
||||||
|
|
||||||
|
tag_currency_data = {
|
||||||
|
"currency": {
|
||||||
|
"code": tag_metric["account__currency__code"],
|
||||||
|
"name": tag_metric["account__currency__name"],
|
||||||
|
"decimal_places": tag_metric["account__currency__decimal_places"],
|
||||||
|
"prefix": tag_metric["account__currency__prefix"],
|
||||||
|
"suffix": tag_metric["account__currency__suffix"],
|
||||||
|
},
|
||||||
|
"expense_current": tag_metric["expense_current"],
|
||||||
|
"expense_projected": tag_metric["expense_projected"],
|
||||||
|
"total_expense": tag_total_expense,
|
||||||
|
"income_current": tag_metric["income_current"],
|
||||||
|
"income_projected": tag_metric["income_projected"],
|
||||||
|
"total_income": tag_total_income,
|
||||||
|
"total_current": tag_total_current,
|
||||||
|
"total_projected": tag_total_projected,
|
||||||
|
"total_final": tag_total_final,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 5a: Handle currency conversion for tag totals.
|
||||||
|
if tag_metric["account__currency__exchange_currency"]:
|
||||||
|
from_currency = Currency.objects.get(id=currency_id)
|
||||||
|
exchange_currency = Currency.objects.get(
|
||||||
|
id=tag_metric["account__currency__exchange_currency"]
|
||||||
|
)
|
||||||
|
|
||||||
|
exchanged = {}
|
||||||
|
for field in [
|
||||||
|
"expense_current",
|
||||||
|
"expense_projected",
|
||||||
|
"income_current",
|
||||||
|
"income_projected",
|
||||||
|
"total_income",
|
||||||
|
"total_expense",
|
||||||
|
"total_current",
|
||||||
|
"total_projected",
|
||||||
|
"total_final",
|
||||||
|
]:
|
||||||
|
amount, prefix, suffix, decimal_places = convert(
|
||||||
|
amount=tag_currency_data[field],
|
||||||
|
from_currency=from_currency,
|
||||||
|
to_currency=exchange_currency,
|
||||||
|
)
|
||||||
|
if amount is not None:
|
||||||
|
exchanged[field] = amount
|
||||||
|
if "currency" not in exchanged:
|
||||||
|
exchanged["currency"] = {
|
||||||
|
"prefix": prefix,
|
||||||
|
"suffix": suffix,
|
||||||
|
"decimal_places": decimal_places,
|
||||||
|
"code": exchange_currency.code,
|
||||||
|
"name": exchange_currency.name,
|
||||||
|
}
|
||||||
|
if exchanged:
|
||||||
|
tag_currency_data["exchanged"] = exchanged
|
||||||
|
|
||||||
|
result[category_id]["tags"][tag_key]["currencies"][
|
||||||
|
currency_id
|
||||||
|
] = tag_currency_data
|
||||||
|
|
||||||
|
# Step 6: If requested, aggregate and process entity-level data.
|
||||||
|
if show_entities:
|
||||||
|
entity_metrics = transactions_queryset.values(
|
||||||
|
"category",
|
||||||
|
"tags",
|
||||||
|
"entities",
|
||||||
|
"entities__name",
|
||||||
|
"account__currency",
|
||||||
|
"account__currency__code",
|
||||||
|
"account__currency__name",
|
||||||
|
"account__currency__decimal_places",
|
||||||
|
"account__currency__prefix",
|
||||||
|
"account__currency__suffix",
|
||||||
|
"account__currency__exchange_currency",
|
||||||
|
).annotate(
|
||||||
|
expense_current=Coalesce(
|
||||||
|
Sum(
|
||||||
|
Case(
|
||||||
|
When(
|
||||||
|
type=Transaction.Type.EXPENSE, is_paid=True, then="amount"
|
||||||
|
),
|
||||||
|
default=Value(0),
|
||||||
|
output_field=models.DecimalField(),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Decimal("0"),
|
||||||
|
),
|
||||||
|
expense_projected=Coalesce(
|
||||||
|
Sum(
|
||||||
|
Case(
|
||||||
|
When(
|
||||||
|
type=Transaction.Type.EXPENSE, is_paid=False, then="amount"
|
||||||
|
),
|
||||||
|
default=Value(0),
|
||||||
|
output_field=models.DecimalField(),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Decimal("0"),
|
||||||
|
),
|
||||||
|
income_current=Coalesce(
|
||||||
|
Sum(
|
||||||
|
Case(
|
||||||
|
When(type=Transaction.Type.INCOME, is_paid=True, then="amount"),
|
||||||
|
default=Value(0),
|
||||||
|
output_field=models.DecimalField(),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Decimal("0"),
|
||||||
|
),
|
||||||
|
income_projected=Coalesce(
|
||||||
|
Sum(
|
||||||
|
Case(
|
||||||
|
When(
|
||||||
|
type=Transaction.Type.INCOME, is_paid=False, then="amount"
|
||||||
|
),
|
||||||
|
default=Value(0),
|
||||||
|
output_field=models.DecimalField(),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Decimal("0"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
for entity_metric in entity_metrics:
|
||||||
|
category_id = entity_metric["category"]
|
||||||
|
tag_id = entity_metric["tags"]
|
||||||
|
entity_id = entity_metric["entities"]
|
||||||
|
|
||||||
|
if category_id in result:
|
||||||
|
tag_key = tag_id if tag_id is not None else "untagged"
|
||||||
|
if tag_key in result[category_id]["tags"]:
|
||||||
|
entity_key = entity_id if entity_id is not None else "no_entity"
|
||||||
|
entity_name = (
|
||||||
|
entity_metric["entities__name"]
|
||||||
|
if entity_id is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if "entities" not in result[category_id]["tags"][tag_key]:
|
||||||
|
result[category_id]["tags"][tag_key]["entities"] = {}
|
||||||
|
|
||||||
|
if (
|
||||||
|
entity_key
|
||||||
|
not in result[category_id]["tags"][tag_key]["entities"]
|
||||||
|
):
|
||||||
|
result[category_id]["tags"][tag_key]["entities"][entity_key] = {
|
||||||
|
"name": entity_name,
|
||||||
|
"currencies": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
currency_id = entity_metric["account__currency"]
|
||||||
|
|
||||||
|
entity_total_current = (
|
||||||
|
entity_metric["income_current"]
|
||||||
|
- entity_metric["expense_current"]
|
||||||
|
)
|
||||||
|
entity_total_projected = (
|
||||||
|
entity_metric["income_projected"]
|
||||||
|
- entity_metric["expense_projected"]
|
||||||
|
)
|
||||||
|
entity_total_income = (
|
||||||
|
entity_metric["income_current"]
|
||||||
|
+ entity_metric["income_projected"]
|
||||||
|
)
|
||||||
|
entity_total_expense = (
|
||||||
|
entity_metric["expense_current"]
|
||||||
|
+ entity_metric["expense_projected"]
|
||||||
|
)
|
||||||
|
entity_total_final = entity_total_current + entity_total_projected
|
||||||
|
|
||||||
|
entity_currency_data = {
|
||||||
|
"currency": {
|
||||||
|
"code": entity_metric["account__currency__code"],
|
||||||
|
"name": entity_metric["account__currency__name"],
|
||||||
|
"decimal_places": entity_metric[
|
||||||
|
"account__currency__decimal_places"
|
||||||
|
],
|
||||||
|
"prefix": entity_metric["account__currency__prefix"],
|
||||||
|
"suffix": entity_metric["account__currency__suffix"],
|
||||||
|
},
|
||||||
|
"expense_current": entity_metric["expense_current"],
|
||||||
|
"expense_projected": entity_metric["expense_projected"],
|
||||||
|
"total_expense": entity_total_expense,
|
||||||
|
"income_current": entity_metric["income_current"],
|
||||||
|
"income_projected": entity_metric["income_projected"],
|
||||||
|
"total_income": entity_total_income,
|
||||||
|
"total_current": entity_total_current,
|
||||||
|
"total_projected": entity_total_projected,
|
||||||
|
"total_final": entity_total_final,
|
||||||
|
}
|
||||||
|
|
||||||
|
if entity_metric["account__currency__exchange_currency"]:
|
||||||
|
from_currency = Currency.objects.get(id=currency_id)
|
||||||
|
exchange_currency = Currency.objects.get(
|
||||||
|
id=entity_metric["account__currency__exchange_currency"]
|
||||||
|
)
|
||||||
|
|
||||||
|
exchanged = {}
|
||||||
|
for field in [
|
||||||
|
"expense_current",
|
||||||
|
"expense_projected",
|
||||||
|
"income_current",
|
||||||
|
"income_projected",
|
||||||
|
"total_income",
|
||||||
|
"total_expense",
|
||||||
|
"total_current",
|
||||||
|
"total_projected",
|
||||||
|
"total_final",
|
||||||
|
]:
|
||||||
|
amount, prefix, suffix, decimal_places = convert(
|
||||||
|
amount=entity_currency_data[field],
|
||||||
|
from_currency=from_currency,
|
||||||
|
to_currency=exchange_currency,
|
||||||
|
)
|
||||||
|
if amount is not None:
|
||||||
|
exchanged[field] = amount
|
||||||
|
if "currency" not in exchanged:
|
||||||
|
exchanged["currency"] = {
|
||||||
|
"prefix": prefix,
|
||||||
|
"suffix": suffix,
|
||||||
|
"decimal_places": decimal_places,
|
||||||
|
"code": exchange_currency.code,
|
||||||
|
"name": exchange_currency.name,
|
||||||
|
}
|
||||||
|
if exchanged:
|
||||||
|
entity_currency_data["exchanged"] = exchanged
|
||||||
|
|
||||||
|
result[category_id]["tags"][tag_key]["entities"][entity_key][
|
||||||
|
"currencies"
|
||||||
|
][currency_id] = entity_currency_data
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ from apps.insights.forms import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_transactions(request, include_unpaid=True, include_silent=False):
|
def get_transactions(
|
||||||
|
request, include_unpaid=True, include_silent=False, include_untracked_accounts=False
|
||||||
|
):
|
||||||
transactions = Transaction.objects.all()
|
transactions = Transaction.objects.all()
|
||||||
|
|
||||||
filter_type = request.GET.get("type", None)
|
filter_type = request.GET.get("type", None)
|
||||||
@@ -91,6 +93,15 @@ def get_transactions(request, include_unpaid=True, include_silent=False):
|
|||||||
transactions = transactions.filter(is_paid=True)
|
transactions = transactions.filter(is_paid=True)
|
||||||
|
|
||||||
if not include_silent:
|
if not include_silent:
|
||||||
transactions = transactions.exclude(Q(category__mute=True) & ~Q(category=None))
|
transactions = transactions.exclude(
|
||||||
|
Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not include_untracked_accounts:
|
||||||
|
transactions = transactions.exclude(
|
||||||
|
account__in=request.user.untracked_accounts.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
transactions = transactions.exclude(account__currency__is_archived=True)
|
||||||
|
|
||||||
return transactions
|
return transactions
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import decimal
|
|
||||||
import json
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.db.models import Sum, Avg, F
|
from django.db.models import Sum
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.views.decorators.http import require_http_methods
|
from django.views.decorators.http import require_http_methods
|
||||||
@@ -22,13 +20,13 @@ from apps.insights.utils.category_explorer import (
|
|||||||
get_category_sums_by_account,
|
get_category_sums_by_account,
|
||||||
get_category_sums_by_currency,
|
get_category_sums_by_currency,
|
||||||
)
|
)
|
||||||
|
from apps.insights.utils.category_overview import get_categories_totals
|
||||||
from apps.insights.utils.sankey import (
|
from apps.insights.utils.sankey import (
|
||||||
generate_sankey_data_by_account,
|
generate_sankey_data_by_account,
|
||||||
generate_sankey_data_by_currency,
|
generate_sankey_data_by_currency,
|
||||||
)
|
)
|
||||||
from apps.insights.utils.transactions import get_transactions
|
from apps.insights.utils.transactions import get_transactions
|
||||||
from apps.transactions.models import TransactionCategory, Transaction
|
from apps.transactions.models import TransactionCategory, Transaction
|
||||||
from apps.insights.utils.category_overview import get_categories_totals
|
|
||||||
from apps.transactions.utils.calculations import calculate_currency_totals
|
from apps.transactions.utils.calculations import calculate_currency_totals
|
||||||
|
|
||||||
|
|
||||||
@@ -76,7 +74,7 @@ def index(request):
|
|||||||
def sankey_by_account(request):
|
def sankey_by_account(request):
|
||||||
# Get filtered transactions
|
# Get filtered transactions
|
||||||
|
|
||||||
transactions = get_transactions(request)
|
transactions = get_transactions(request, include_untracked_accounts=True)
|
||||||
|
|
||||||
# Generate Sankey data
|
# Generate Sankey data
|
||||||
sankey_data = generate_sankey_data_by_account(transactions)
|
sankey_data = generate_sankey_data_by_account(transactions)
|
||||||
@@ -170,17 +168,51 @@ def category_sum_by_currency(request):
|
|||||||
@login_required
|
@login_required
|
||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def category_overview(request):
|
def category_overview(request):
|
||||||
|
if "view_type" in request.GET:
|
||||||
|
view_type = request.GET["view_type"]
|
||||||
|
request.session["insights_category_explorer_view_type"] = view_type
|
||||||
|
else:
|
||||||
|
view_type = request.session.get("insights_category_explorer_view_type", "table")
|
||||||
|
|
||||||
|
if "show_tags" in request.GET:
|
||||||
|
show_tags = request.GET["show_tags"] == "on"
|
||||||
|
request.session["insights_category_explorer_show_tags"] = show_tags
|
||||||
|
else:
|
||||||
|
show_tags = request.session.get("insights_category_explorer_show_tags", True)
|
||||||
|
|
||||||
|
if "show_entities" in request.GET:
|
||||||
|
show_entities = request.GET["show_entities"] == "on"
|
||||||
|
request.session["insights_category_explorer_show_entities"] = show_entities
|
||||||
|
else:
|
||||||
|
show_entities = request.session.get(
|
||||||
|
"insights_category_explorer_show_entities", False
|
||||||
|
)
|
||||||
|
|
||||||
|
if "showing" in request.GET:
|
||||||
|
showing = request.GET["showing"]
|
||||||
|
request.session["insights_category_explorer_showing"] = showing
|
||||||
|
else:
|
||||||
|
showing = request.session.get("insights_category_explorer_showing", "final")
|
||||||
|
|
||||||
# Get filtered transactions
|
# Get filtered transactions
|
||||||
transactions = get_transactions(request, include_silent=True)
|
transactions = get_transactions(request, include_silent=True)
|
||||||
|
|
||||||
total_table = get_categories_totals(
|
total_table = get_categories_totals(
|
||||||
transactions_queryset=transactions, ignore_empty=False
|
transactions_queryset=transactions,
|
||||||
|
ignore_empty=False,
|
||||||
|
show_entities=show_entities,
|
||||||
)
|
)
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"insights/fragments/category_overview/index.html",
|
"insights/fragments/category_overview/index.html",
|
||||||
{"total_table": total_table},
|
{
|
||||||
|
"total_table": total_table,
|
||||||
|
"view_type": view_type,
|
||||||
|
"show_tags": show_tags,
|
||||||
|
"show_entities": show_entities,
|
||||||
|
"showing": showing,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -218,11 +250,15 @@ def late_transactions(request):
|
|||||||
@login_required
|
@login_required
|
||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def emergency_fund(request):
|
def emergency_fund(request):
|
||||||
transactions_currency_queryset = Transaction.objects.filter(
|
transactions_currency_queryset = (
|
||||||
|
Transaction.objects.filter(
|
||||||
is_paid=True, account__is_archived=False, account__is_asset=False
|
is_paid=True, account__is_archived=False, account__is_asset=False
|
||||||
).order_by(
|
)
|
||||||
|
.exclude(account__in=request.user.untracked_accounts.all())
|
||||||
|
.order_by(
|
||||||
"account__currency__name",
|
"account__currency__name",
|
||||||
)
|
)
|
||||||
|
)
|
||||||
currency_net_worth = calculate_currency_totals(
|
currency_net_worth = calculate_currency_totals(
|
||||||
transactions_queryset=transactions_currency_queryset, ignore_empty=False
|
transactions_queryset=transactions_currency_queryset, ignore_empty=False
|
||||||
)
|
)
|
||||||
@@ -239,7 +275,9 @@ def emergency_fund(request):
|
|||||||
reference_date__gte=start_date,
|
reference_date__gte=start_date,
|
||||||
reference_date__lte=end_date,
|
reference_date__lte=end_date,
|
||||||
category__mute=False,
|
category__mute=False,
|
||||||
|
mute=False,
|
||||||
)
|
)
|
||||||
|
.exclude(account__in=request.user.untracked_accounts.all())
|
||||||
.values("reference_date", "account__currency")
|
.values("reference_date", "account__currency")
|
||||||
.annotate(monthly_total=Sum("amount"))
|
.annotate(monthly_total=Sum("amount"))
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ def get_currency_exchange_map(date=None) -> Dict[str, dict]:
|
|||||||
date_diff=Func(Extract(F("date") - Value(date), "epoch"), function="ABS"),
|
date_diff=Func(Extract(F("date") - Value(date), "epoch"), function="ABS"),
|
||||||
effective_rate=F("rate"),
|
effective_rate=F("rate"),
|
||||||
)
|
)
|
||||||
.order_by("from_currency", "to_currency", "date_diff")
|
.order_by("from_currency", "to_currency", "-date_diff")
|
||||||
.distinct("from_currency", "to_currency")
|
.distinct()
|
||||||
)
|
)
|
||||||
|
|
||||||
# Initialize the result dictionary
|
# Initialize the result dictionary
|
||||||
|
|||||||
@@ -107,9 +107,15 @@ def transactions_list(request, month: int, year: int):
|
|||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def monthly_summary(request, month: int, year: int):
|
def monthly_summary(request, month: int, year: int):
|
||||||
# Base queryset with all required filters
|
# Base queryset with all required filters
|
||||||
base_queryset = Transaction.objects.filter(
|
base_queryset = (
|
||||||
reference_date__year=year, reference_date__month=month, account__is_asset=False
|
Transaction.objects.filter(
|
||||||
).exclude(Q(category__mute=True) & ~Q(category=None))
|
reference_date__year=year,
|
||||||
|
reference_date__month=month,
|
||||||
|
account__is_asset=False,
|
||||||
|
)
|
||||||
|
.exclude(Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True))
|
||||||
|
.exclude(account__in=request.user.untracked_accounts.all())
|
||||||
|
)
|
||||||
|
|
||||||
data = calculate_currency_totals(base_queryset, ignore_empty=True)
|
data = calculate_currency_totals(base_queryset, ignore_empty=True)
|
||||||
percentages = calculate_percentage_distribution(data)
|
percentages = calculate_percentage_distribution(data)
|
||||||
@@ -143,7 +149,7 @@ def monthly_account_summary(request, month: int, year: int):
|
|||||||
base_queryset = Transaction.objects.filter(
|
base_queryset = Transaction.objects.filter(
|
||||||
reference_date__year=year,
|
reference_date__year=year,
|
||||||
reference_date__month=month,
|
reference_date__month=month,
|
||||||
).exclude(Q(category__mute=True) & ~Q(category=None))
|
).exclude(Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True))
|
||||||
|
|
||||||
account_data = calculate_account_totals(transactions_queryset=base_queryset.all())
|
account_data = calculate_account_totals(transactions_queryset=base_queryset.all())
|
||||||
account_percentages = calculate_percentage_distribution(account_data)
|
account_percentages = calculate_percentage_distribution(account_data)
|
||||||
@@ -165,10 +171,14 @@ def monthly_account_summary(request, month: int, year: int):
|
|||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def monthly_currency_summary(request, month: int, year: int):
|
def monthly_currency_summary(request, month: int, year: int):
|
||||||
# Base queryset with all required filters
|
# Base queryset with all required filters
|
||||||
base_queryset = Transaction.objects.filter(
|
base_queryset = (
|
||||||
|
Transaction.objects.filter(
|
||||||
reference_date__year=year,
|
reference_date__year=year,
|
||||||
reference_date__month=month,
|
reference_date__month=month,
|
||||||
).exclude(Q(category__mute=True) & ~Q(category=None))
|
)
|
||||||
|
.exclude(Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True))
|
||||||
|
.exclude(account__in=request.user.untracked_accounts.all())
|
||||||
|
)
|
||||||
|
|
||||||
currency_data = calculate_currency_totals(base_queryset.all(), ignore_empty=True)
|
currency_data = calculate_currency_totals(base_queryset.all(), ignore_empty=True)
|
||||||
currency_percentages = calculate_percentage_distribution(currency_data)
|
currency_percentages = calculate_percentage_distribution(currency_data)
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ from . import views
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("net-worth/current/", views.net_worth_current, name="net_worth_current"),
|
path("net-worth/current/", views.net_worth_current, name="net_worth_current"),
|
||||||
path("net-worth/projected/", views.net_worth_projected, name="net_worth_projected"),
|
path("net-worth/projected/", views.net_worth_projected, name="net_worth_projected"),
|
||||||
|
path("net-worth/", views.net_worth, name="net_worth"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,25 +2,39 @@ from collections import OrderedDict, defaultdict
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
from django.db.models import Sum, Min, Max, Case, When, F, Value, DecimalField
|
from django.db.models import Sum, Min, Max, Case, When, F, Value, DecimalField, Q
|
||||||
from django.db.models.functions import TruncMonth
|
from django.db.models.functions import TruncMonth
|
||||||
from django.template.defaultfilters import date as date_filter
|
from django.template.defaultfilters import date as date_filter
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from apps.accounts.models import Account
|
from apps.accounts.models import Account
|
||||||
|
from apps.common.middleware.thread_local import get_current_user
|
||||||
from apps.currencies.models import Currency
|
from apps.currencies.models import Currency
|
||||||
from apps.transactions.models import Transaction
|
from apps.transactions.models import Transaction
|
||||||
|
|
||||||
|
|
||||||
def calculate_historical_currency_net_worth(is_paid=True):
|
def calculate_historical_currency_net_worth(queryset):
|
||||||
transactions_params = {**{k: v for k, v in [("is_paid", True)] if is_paid}}
|
|
||||||
|
|
||||||
# Get all currencies and date range in a single query
|
# Get all currencies and date range in a single query
|
||||||
aggregates = Transaction.objects.aggregate(
|
aggregates = queryset.aggregate(
|
||||||
min_date=Min("reference_date"),
|
min_date=Min("reference_date"),
|
||||||
max_date=Max("reference_date"),
|
max_date=Max("reference_date"),
|
||||||
)
|
)
|
||||||
currencies = list(Currency.objects.values_list("name", flat=True))
|
|
||||||
|
user = get_current_user()
|
||||||
|
|
||||||
|
currencies = list(
|
||||||
|
Currency.objects.filter(
|
||||||
|
Q(accounts__visibility="public")
|
||||||
|
| Q(accounts__owner=user)
|
||||||
|
| Q(accounts__shared_with=user)
|
||||||
|
| Q(accounts__visibility="private", accounts__owner=None),
|
||||||
|
accounts__is_archived=False,
|
||||||
|
accounts__isnull=False,
|
||||||
|
is_archived=False,
|
||||||
|
)
|
||||||
|
.values_list("name", flat=True)
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
|
||||||
if not aggregates.get("min_date"):
|
if not aggregates.get("min_date"):
|
||||||
start_date = timezone.localdate(timezone.now())
|
start_date = timezone.localdate(timezone.now())
|
||||||
@@ -34,8 +48,7 @@ def calculate_historical_currency_net_worth(is_paid=True):
|
|||||||
|
|
||||||
# Calculate cumulative balances for each account, currency, and month
|
# Calculate cumulative balances for each account, currency, and month
|
||||||
cumulative_balances = (
|
cumulative_balances = (
|
||||||
Transaction.objects.filter(**transactions_params)
|
queryset.annotate(month=TruncMonth("reference_date"))
|
||||||
.annotate(month=TruncMonth("reference_date"))
|
|
||||||
.values("account__currency__name", "month")
|
.values("account__currency__name", "month")
|
||||||
.annotate(
|
.annotate(
|
||||||
balance=Sum(
|
balance=Sum(
|
||||||
@@ -94,15 +107,14 @@ def calculate_historical_currency_net_worth(is_paid=True):
|
|||||||
return historical_net_worth
|
return historical_net_worth
|
||||||
|
|
||||||
|
|
||||||
def calculate_historical_account_balance(is_paid=True):
|
def calculate_historical_account_balance(queryset):
|
||||||
transactions_params = {**{k: v for k, v in [("is_paid", True)] if is_paid}}
|
|
||||||
# Get all accounts
|
# Get all accounts
|
||||||
accounts = Account.objects.filter(
|
accounts = Account.objects.filter(
|
||||||
is_archived=False,
|
is_archived=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get the date range
|
# Get the date range
|
||||||
date_range = Transaction.objects.filter(**transactions_params).aggregate(
|
date_range = queryset.aggregate(
|
||||||
min_date=Min("reference_date"), max_date=Max("reference_date")
|
min_date=Min("reference_date"), max_date=Max("reference_date")
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -118,8 +130,7 @@ def calculate_historical_account_balance(is_paid=True):
|
|||||||
|
|
||||||
# Calculate balances for each account and month
|
# Calculate balances for each account and month
|
||||||
balances = (
|
balances = (
|
||||||
Transaction.objects.filter(**transactions_params)
|
queryset.annotate(month=TruncMonth("reference_date"))
|
||||||
.annotate(month=TruncMonth("reference_date"))
|
|
||||||
.values("account", "month")
|
.values("account", "month")
|
||||||
.annotate(
|
.annotate(
|
||||||
balance=Sum(
|
balance=Sum(
|
||||||
|
|||||||
+42
-102
@@ -2,7 +2,7 @@ import json
|
|||||||
|
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.core.serializers.json import DjangoJSONEncoder
|
from django.core.serializers.json import DjangoJSONEncoder
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render, redirect
|
||||||
from django.views.decorators.http import require_http_methods
|
from django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
from apps.net_worth.utils.calculate_net_worth import (
|
from apps.net_worth.utils.calculate_net_worth import (
|
||||||
@@ -18,113 +18,35 @@ from apps.transactions.utils.calculations import (
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def net_worth_current(request):
|
def net_worth(request):
|
||||||
transactions_currency_queryset = Transaction.objects.filter(
|
if "view_type" in request.GET:
|
||||||
is_paid=True, account__is_archived=False
|
view_type = request.GET["view_type"]
|
||||||
).order_by(
|
request.session["networth_view_type"] = view_type
|
||||||
|
else:
|
||||||
|
view_type = request.session.get("networth_view_type", "current")
|
||||||
|
|
||||||
|
if view_type == "current":
|
||||||
|
transactions_currency_queryset = (
|
||||||
|
Transaction.objects.filter(is_paid=True, account__is_archived=False)
|
||||||
|
.order_by(
|
||||||
"account__currency__name",
|
"account__currency__name",
|
||||||
)
|
)
|
||||||
|
.exclude(account__in=request.user.untracked_accounts.all())
|
||||||
|
)
|
||||||
transactions_account_queryset = Transaction.objects.filter(
|
transactions_account_queryset = Transaction.objects.filter(
|
||||||
is_paid=True, account__is_archived=False
|
is_paid=True, account__is_archived=False
|
||||||
).order_by(
|
).order_by(
|
||||||
"account__group__name",
|
"account__group__name",
|
||||||
"account__name",
|
"account__name",
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
currency_net_worth = calculate_currency_totals(
|
transactions_currency_queryset = (
|
||||||
transactions_queryset=transactions_currency_queryset
|
Transaction.objects.filter(account__is_archived=False)
|
||||||
)
|
.order_by(
|
||||||
account_net_worth = calculate_account_totals(
|
|
||||||
transactions_queryset=transactions_account_queryset
|
|
||||||
)
|
|
||||||
|
|
||||||
historical_currency_net_worth = calculate_historical_currency_net_worth()
|
|
||||||
|
|
||||||
labels = (
|
|
||||||
list(historical_currency_net_worth.keys())
|
|
||||||
if historical_currency_net_worth
|
|
||||||
else []
|
|
||||||
)
|
|
||||||
currencies = (
|
|
||||||
list(historical_currency_net_worth[labels[0]].keys())
|
|
||||||
if historical_currency_net_worth
|
|
||||||
else []
|
|
||||||
)
|
|
||||||
|
|
||||||
datasets = []
|
|
||||||
for i, currency in enumerate(currencies):
|
|
||||||
data = [
|
|
||||||
float(month_data[currency])
|
|
||||||
for month_data in historical_currency_net_worth.values()
|
|
||||||
]
|
|
||||||
datasets.append(
|
|
||||||
{
|
|
||||||
"label": currency,
|
|
||||||
"data": data,
|
|
||||||
"yAxisID": f"y{i}",
|
|
||||||
"fill": False,
|
|
||||||
"tension": 0.1,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
chart_data_currency = {"labels": labels, "datasets": datasets}
|
|
||||||
|
|
||||||
chart_data_currency_json = json.dumps(chart_data_currency, cls=DjangoJSONEncoder)
|
|
||||||
|
|
||||||
historical_account_balance = calculate_historical_account_balance()
|
|
||||||
|
|
||||||
labels = (
|
|
||||||
list(historical_account_balance.keys()) if historical_account_balance else []
|
|
||||||
)
|
|
||||||
accounts = (
|
|
||||||
list(historical_account_balance[labels[0]].keys())
|
|
||||||
if historical_account_balance
|
|
||||||
else []
|
|
||||||
)
|
|
||||||
|
|
||||||
datasets = []
|
|
||||||
for i, account in enumerate(accounts):
|
|
||||||
data = [
|
|
||||||
float(month_data[account])
|
|
||||||
for month_data in historical_account_balance.values()
|
|
||||||
]
|
|
||||||
datasets.append(
|
|
||||||
{
|
|
||||||
"label": account,
|
|
||||||
"data": data,
|
|
||||||
"fill": False,
|
|
||||||
"tension": 0.1,
|
|
||||||
"yAxisID": f"y-axis-{i}", # Assign each dataset to its own Y-axis
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
chart_data_accounts = {"labels": labels, "datasets": datasets}
|
|
||||||
|
|
||||||
chart_data_accounts_json = json.dumps(chart_data_accounts, cls=DjangoJSONEncoder)
|
|
||||||
|
|
||||||
return render(
|
|
||||||
request,
|
|
||||||
"net_worth/net_worth.html",
|
|
||||||
{
|
|
||||||
"currency_net_worth": currency_net_worth,
|
|
||||||
"account_net_worth": account_net_worth,
|
|
||||||
"chart_data_currency_json": chart_data_currency_json,
|
|
||||||
"currencies": currencies,
|
|
||||||
"chart_data_accounts_json": chart_data_accounts_json,
|
|
||||||
"accounts": accounts,
|
|
||||||
"type": "current",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
@require_http_methods(["GET"])
|
|
||||||
def net_worth_projected(request):
|
|
||||||
transactions_currency_queryset = Transaction.objects.filter(
|
|
||||||
account__is_archived=False
|
|
||||||
).order_by(
|
|
||||||
"account__currency__name",
|
"account__currency__name",
|
||||||
)
|
)
|
||||||
|
.exclude(account__in=request.user.untracked_accounts.all())
|
||||||
|
)
|
||||||
transactions_account_queryset = Transaction.objects.filter(
|
transactions_account_queryset = Transaction.objects.filter(
|
||||||
account__is_archived=False
|
account__is_archived=False
|
||||||
).order_by(
|
).order_by(
|
||||||
@@ -133,14 +55,14 @@ def net_worth_projected(request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
currency_net_worth = calculate_currency_totals(
|
currency_net_worth = calculate_currency_totals(
|
||||||
transactions_queryset=transactions_currency_queryset
|
transactions_queryset=transactions_currency_queryset, deep_search=True
|
||||||
)
|
)
|
||||||
account_net_worth = calculate_account_totals(
|
account_net_worth = calculate_account_totals(
|
||||||
transactions_queryset=transactions_account_queryset
|
transactions_queryset=transactions_account_queryset
|
||||||
)
|
)
|
||||||
|
|
||||||
historical_currency_net_worth = calculate_historical_currency_net_worth(
|
historical_currency_net_worth = calculate_historical_currency_net_worth(
|
||||||
is_paid=False
|
queryset=transactions_currency_queryset
|
||||||
)
|
)
|
||||||
|
|
||||||
labels = (
|
labels = (
|
||||||
@@ -174,7 +96,9 @@ def net_worth_projected(request):
|
|||||||
|
|
||||||
chart_data_currency_json = json.dumps(chart_data_currency, cls=DjangoJSONEncoder)
|
chart_data_currency_json = json.dumps(chart_data_currency, cls=DjangoJSONEncoder)
|
||||||
|
|
||||||
historical_account_balance = calculate_historical_account_balance(is_paid=False)
|
historical_account_balance = calculate_historical_account_balance(
|
||||||
|
queryset=transactions_account_queryset
|
||||||
|
)
|
||||||
|
|
||||||
labels = (
|
labels = (
|
||||||
list(historical_account_balance.keys()) if historical_account_balance else []
|
list(historical_account_balance.keys()) if historical_account_balance else []
|
||||||
@@ -215,6 +139,22 @@ def net_worth_projected(request):
|
|||||||
"currencies": currencies,
|
"currencies": currencies,
|
||||||
"chart_data_accounts_json": chart_data_accounts_json,
|
"chart_data_accounts_json": chart_data_accounts_json,
|
||||||
"accounts": accounts,
|
"accounts": accounts,
|
||||||
"type": "projected",
|
"type": view_type,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def net_worth_current(request):
|
||||||
|
request.session["networth_view_type"] = "current"
|
||||||
|
|
||||||
|
return redirect("net_worth")
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def net_worth_projected(request):
|
||||||
|
request.session["networth_view_type"] = "projected"
|
||||||
|
|
||||||
|
return redirect("net_worth")
|
||||||
|
|||||||
+144
-4
@@ -1,24 +1,30 @@
|
|||||||
from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion
|
from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion
|
||||||
from crispy_forms.bootstrap import FormActions, AccordionGroup
|
from crispy_forms.bootstrap import FormActions, AccordionGroup
|
||||||
from crispy_forms.helper import FormHelper
|
from crispy_forms.helper import FormHelper
|
||||||
from crispy_forms.layout import Layout, Field, Row, Column
|
from crispy_forms.layout import Layout, Field, Row, Column, HTML
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
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.tom_select import TomSelect
|
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||||
|
from apps.common.widgets.tom_select import TomSelect, TransactionSelect
|
||||||
from apps.rules.models import TransactionRule, UpdateOrCreateTransactionRuleAction
|
from apps.rules.models import TransactionRule, UpdateOrCreateTransactionRuleAction
|
||||||
from apps.rules.models import TransactionRuleAction
|
from apps.rules.models import TransactionRuleAction
|
||||||
|
from apps.common.fields.forms.dynamic_select import DynamicModelChoiceField
|
||||||
|
from apps.transactions.forms import BulkEditTransactionForm
|
||||||
|
from apps.transactions.models import Transaction
|
||||||
|
|
||||||
|
|
||||||
class TransactionRuleForm(forms.ModelForm):
|
class TransactionRuleForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = TransactionRule
|
model = TransactionRule
|
||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
|
exclude = ("owner", "shared_with", "visibility")
|
||||||
labels = {
|
labels = {
|
||||||
"on_create": _("Run on creation"),
|
"on_create": _("Run on creation"),
|
||||||
"on_update": _("Run on update"),
|
"on_update": _("Run on update"),
|
||||||
|
"on_delete": _("Run on delete"),
|
||||||
"trigger": _("If..."),
|
"trigger": _("If..."),
|
||||||
}
|
}
|
||||||
widgets = {"description": forms.widgets.TextInput}
|
widgets = {"description": forms.widgets.TextInput}
|
||||||
@@ -33,7 +39,13 @@ class TransactionRuleForm(forms.ModelForm):
|
|||||||
self.helper.layout = Layout(
|
self.helper.layout = Layout(
|
||||||
Switch("active"),
|
Switch("active"),
|
||||||
"name",
|
"name",
|
||||||
Row(Column(Switch("on_update")), Column(Switch("on_create"))),
|
Row(
|
||||||
|
Column(Switch("on_update")),
|
||||||
|
Column(Switch("on_create")),
|
||||||
|
Column(Switch("on_delete")),
|
||||||
|
),
|
||||||
|
"order",
|
||||||
|
Switch("sequenced"),
|
||||||
"description",
|
"description",
|
||||||
"trigger",
|
"trigger",
|
||||||
)
|
)
|
||||||
@@ -59,10 +71,11 @@ class TransactionRuleForm(forms.ModelForm):
|
|||||||
class TransactionRuleActionForm(forms.ModelForm):
|
class TransactionRuleActionForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = TransactionRuleAction
|
model = TransactionRuleAction
|
||||||
fields = ("value", "field")
|
fields = ("value", "field", "order")
|
||||||
labels = {
|
labels = {
|
||||||
"field": _("Set field"),
|
"field": _("Set field"),
|
||||||
"value": _("To"),
|
"value": _("To"),
|
||||||
|
"order": _("Order"),
|
||||||
}
|
}
|
||||||
widgets = {"field": TomSelect(clear_button=False)}
|
widgets = {"field": TomSelect(clear_button=False)}
|
||||||
|
|
||||||
@@ -76,6 +89,7 @@ class TransactionRuleActionForm(forms.ModelForm):
|
|||||||
self.helper.form_method = "post"
|
self.helper.form_method = "post"
|
||||||
# TO-DO: Add helper with available commands
|
# TO-DO: Add helper with available commands
|
||||||
self.helper.layout = Layout(
|
self.helper.layout = Layout(
|
||||||
|
"order",
|
||||||
"field",
|
"field",
|
||||||
"value",
|
"value",
|
||||||
)
|
)
|
||||||
@@ -141,9 +155,11 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
|||||||
"search_category_operator": TomSelect(clear_button=False),
|
"search_category_operator": TomSelect(clear_button=False),
|
||||||
"search_internal_note_operator": TomSelect(clear_button=False),
|
"search_internal_note_operator": TomSelect(clear_button=False),
|
||||||
"search_internal_id_operator": TomSelect(clear_button=False),
|
"search_internal_id_operator": TomSelect(clear_button=False),
|
||||||
|
"search_mute_operator": TomSelect(clear_button=False),
|
||||||
}
|
}
|
||||||
|
|
||||||
labels = {
|
labels = {
|
||||||
|
"order": _("Order"),
|
||||||
"search_account_operator": _("Operator"),
|
"search_account_operator": _("Operator"),
|
||||||
"search_type_operator": _("Operator"),
|
"search_type_operator": _("Operator"),
|
||||||
"search_is_paid_operator": _("Operator"),
|
"search_is_paid_operator": _("Operator"),
|
||||||
@@ -157,6 +173,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
|||||||
"search_internal_id_operator": _("Operator"),
|
"search_internal_id_operator": _("Operator"),
|
||||||
"search_tags_operator": _("Operator"),
|
"search_tags_operator": _("Operator"),
|
||||||
"search_entities_operator": _("Operator"),
|
"search_entities_operator": _("Operator"),
|
||||||
|
"search_mute_operator": _("Operator"),
|
||||||
"search_account": _("Account"),
|
"search_account": _("Account"),
|
||||||
"search_type": _("Type"),
|
"search_type": _("Type"),
|
||||||
"search_is_paid": _("Paid"),
|
"search_is_paid": _("Paid"),
|
||||||
@@ -170,6 +187,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
|||||||
"search_internal_id": _("Internal ID"),
|
"search_internal_id": _("Internal ID"),
|
||||||
"search_tags": _("Tags"),
|
"search_tags": _("Tags"),
|
||||||
"search_entities": _("Entities"),
|
"search_entities": _("Entities"),
|
||||||
|
"search_mute": _("Mute"),
|
||||||
"set_account": _("Account"),
|
"set_account": _("Account"),
|
||||||
"set_type": _("Type"),
|
"set_type": _("Type"),
|
||||||
"set_is_paid": _("Paid"),
|
"set_is_paid": _("Paid"),
|
||||||
@@ -183,6 +201,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
|||||||
"set_category": _("Category"),
|
"set_category": _("Category"),
|
||||||
"set_internal_note": _("Internal Note"),
|
"set_internal_note": _("Internal Note"),
|
||||||
"set_internal_id": _("Internal ID"),
|
"set_internal_id": _("Internal ID"),
|
||||||
|
"set_mute": _("Mute"),
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@@ -194,6 +213,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
|||||||
self.helper.form_method = "post"
|
self.helper.form_method = "post"
|
||||||
|
|
||||||
self.helper.layout = Layout(
|
self.helper.layout = Layout(
|
||||||
|
"order",
|
||||||
BS5Accordion(
|
BS5Accordion(
|
||||||
AccordionGroup(
|
AccordionGroup(
|
||||||
_("Search Criteria"),
|
_("Search Criteria"),
|
||||||
@@ -218,6 +238,16 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
|||||||
css_class="form-group col-md-8",
|
css_class="form-group col-md-8",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Row(
|
||||||
|
Column(
|
||||||
|
Field("search_mute_operator"),
|
||||||
|
css_class="form-group col-md-4",
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
Field("search_mute", rows=1),
|
||||||
|
css_class="form-group col-md-8",
|
||||||
|
),
|
||||||
|
),
|
||||||
Row(
|
Row(
|
||||||
Column(
|
Column(
|
||||||
Field("search_account_operator"),
|
Field("search_account_operator"),
|
||||||
@@ -334,6 +364,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
|||||||
_("Set Values"),
|
_("Set Values"),
|
||||||
Field("set_type", rows=1),
|
Field("set_type", rows=1),
|
||||||
Field("set_is_paid", rows=1),
|
Field("set_is_paid", rows=1),
|
||||||
|
Field("set_mute", rows=1),
|
||||||
Field("set_account", rows=1),
|
Field("set_account", rows=1),
|
||||||
Field("set_entities", rows=1),
|
Field("set_entities", rows=1),
|
||||||
Field("set_date", rows=1),
|
Field("set_date", rows=1),
|
||||||
@@ -375,3 +406,112 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
|||||||
if commit:
|
if commit:
|
||||||
instance.save()
|
instance.save()
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
class DryRunCreatedTransacion(forms.Form):
|
||||||
|
transaction = DynamicModelChoiceField(
|
||||||
|
model=Transaction,
|
||||||
|
to_field_name="id",
|
||||||
|
label=_("Transaction"),
|
||||||
|
required=True,
|
||||||
|
queryset=Transaction.objects.none(),
|
||||||
|
widget=TransactionSelect(clear_button=False, income=True, expense=True),
|
||||||
|
help_text=_("Type to search for a transaction"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.helper = FormHelper()
|
||||||
|
self.helper.form_tag = False
|
||||||
|
self.helper.layout = Layout(
|
||||||
|
"transaction",
|
||||||
|
FormActions(
|
||||||
|
NoClassSubmit(
|
||||||
|
"submit", _("Test"), css_class="btn btn-outline-primary w-100"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.data.get("transaction"):
|
||||||
|
try:
|
||||||
|
transaction = Transaction.objects.get(id=self.data.get("transaction"))
|
||||||
|
except Transaction.DoesNotExist:
|
||||||
|
transaction = None
|
||||||
|
|
||||||
|
if transaction:
|
||||||
|
self.fields["transaction"].queryset = Transaction.objects.filter(
|
||||||
|
id=transaction.id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DryRunDeletedTransacion(forms.Form):
|
||||||
|
transaction = DynamicModelChoiceField(
|
||||||
|
model=Transaction,
|
||||||
|
to_field_name="id",
|
||||||
|
label=_("Transaction"),
|
||||||
|
required=True,
|
||||||
|
queryset=Transaction.objects.none(),
|
||||||
|
widget=TransactionSelect(clear_button=False, income=True, expense=True),
|
||||||
|
help_text=_("Type to search for a transaction"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.helper = FormHelper()
|
||||||
|
self.helper.form_tag = False
|
||||||
|
self.helper.layout = Layout(
|
||||||
|
"transaction",
|
||||||
|
FormActions(
|
||||||
|
NoClassSubmit(
|
||||||
|
"submit", _("Test"), css_class="btn btn-outline-primary w-100"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.data.get("transaction"):
|
||||||
|
try:
|
||||||
|
transaction = Transaction.objects.get(id=self.data.get("transaction"))
|
||||||
|
except Transaction.DoesNotExist:
|
||||||
|
transaction = None
|
||||||
|
|
||||||
|
if transaction:
|
||||||
|
self.fields["transaction"].queryset = Transaction.objects.filter(
|
||||||
|
id=transaction.id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DryRunUpdatedTransactionForm(BulkEditTransactionForm):
|
||||||
|
transaction = DynamicModelChoiceField(
|
||||||
|
model=Transaction,
|
||||||
|
to_field_name="id",
|
||||||
|
label=_("Transaction"),
|
||||||
|
required=True,
|
||||||
|
queryset=Transaction.objects.none(),
|
||||||
|
widget=TransactionSelect(clear_button=False, income=True, expense=True),
|
||||||
|
help_text=_("Type to search for a transaction"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.helper.layout.insert(0, "transaction")
|
||||||
|
self.helper.layout.insert(1, HTML("<hr/>"))
|
||||||
|
|
||||||
|
# Change submit button
|
||||||
|
self.helper.layout[-1] = FormActions(
|
||||||
|
NoClassSubmit(
|
||||||
|
"submit", _("Test"), css_class="btn btn-outline-primary w-100"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.data.get("transaction"):
|
||||||
|
try:
|
||||||
|
transaction = Transaction.objects.get(id=self.data.get("transaction"))
|
||||||
|
except Transaction.DoesNotExist:
|
||||||
|
transaction = None
|
||||||
|
|
||||||
|
if transaction:
|
||||||
|
self.fields["transaction"].queryset = Transaction.objects.filter(
|
||||||
|
id=transaction.id
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.1.7 on 2025-03-09 03:45
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('rules', '0012_transactionrule_owner_transactionrule_shared_with_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='transactionrule',
|
||||||
|
name='on_delete',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Generated by Django 5.2.4 on 2025-07-28 02:15
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('rules', '0013_transactionrule_on_delete'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='transactionrule',
|
||||||
|
name='owner',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='transactionrule',
|
||||||
|
name='shared_with',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL, verbose_name='Shared with users'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='transactionrule',
|
||||||
|
name='visibility',
|
||||||
|
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10, verbose_name='Visibility'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Generated by Django 5.2 on 2025-08-30 18:48
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("rules", "0014_alter_transactionrule_owner_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="transactionruleaction",
|
||||||
|
options={
|
||||||
|
"ordering": ["order"],
|
||||||
|
"verbose_name": "Edit transaction action",
|
||||||
|
"verbose_name_plural": "Edit transaction actions",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="updateorcreatetransactionruleaction",
|
||||||
|
options={
|
||||||
|
"ordering": ["order"],
|
||||||
|
"verbose_name": "Update or create transaction action",
|
||||||
|
"verbose_name_plural": "Update or create transaction actions",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="transactionruleaction",
|
||||||
|
name="order",
|
||||||
|
field=models.PositiveIntegerField(default=0, verbose_name="Order"),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="updateorcreatetransactionruleaction",
|
||||||
|
name="order",
|
||||||
|
field=models.PositiveIntegerField(default=0, verbose_name="Order"),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-08-31 18:42
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('rules', '0015_alter_transactionruleaction_options_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='transactionrule',
|
||||||
|
name='sequenced',
|
||||||
|
field=models.BooleanField(default=False, verbose_name='Sequenced'),
|
||||||
|
),
|
||||||
|
]
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-08-31 19:09
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('rules', '0016_transactionrule_sequenced'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='updateorcreatetransactionruleaction',
|
||||||
|
name='search_mute',
|
||||||
|
field=models.TextField(blank=True, verbose_name='Search Mute'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='updateorcreatetransactionruleaction',
|
||||||
|
name='search_mute_operator',
|
||||||
|
field=models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='exact', max_length=10, verbose_name='Mute Operator'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='updateorcreatetransactionruleaction',
|
||||||
|
name='set_mute',
|
||||||
|
field=models.TextField(blank=True, verbose_name='Mute'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='transactionruleaction',
|
||||||
|
name='field',
|
||||||
|
field=models.CharField(choices=[('account', 'Account'), ('type', 'Type'), ('is_paid', 'Paid'), ('date', 'Date'), ('reference_date', 'Reference Date'), ('mute', 'Mute'), ('amount', 'Amount'), ('description', 'Description'), ('notes', 'Notes'), ('category', 'Category'), ('tags', 'Tags'), ('entities', 'Entities'), ('internal_nome', 'Internal Note'), ('internal_id', 'Internal ID')], max_length=50, verbose_name='Field'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-09-02 14:52
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('rules', '0017_updateorcreatetransactionruleaction_search_mute_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='transactionrule',
|
||||||
|
name='order',
|
||||||
|
field=models.PositiveIntegerField(default=0, verbose_name='Order'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -9,9 +9,15 @@ class TransactionRule(SharedObject):
|
|||||||
active = models.BooleanField(default=True)
|
active = models.BooleanField(default=True)
|
||||||
on_update = models.BooleanField(default=False)
|
on_update = models.BooleanField(default=False)
|
||||||
on_create = models.BooleanField(default=True)
|
on_create = models.BooleanField(default=True)
|
||||||
|
on_delete = models.BooleanField(default=False)
|
||||||
name = models.CharField(max_length=100, verbose_name=_("Name"))
|
name = models.CharField(max_length=100, verbose_name=_("Name"))
|
||||||
description = models.TextField(blank=True, null=True, verbose_name=_("Description"))
|
description = models.TextField(blank=True, null=True, verbose_name=_("Description"))
|
||||||
trigger = models.TextField(verbose_name=_("Trigger"))
|
trigger = models.TextField(verbose_name=_("Trigger"))
|
||||||
|
sequenced = models.BooleanField(
|
||||||
|
verbose_name=_("Sequenced"),
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
order = models.PositiveIntegerField(default=0, verbose_name=_("Order"))
|
||||||
|
|
||||||
objects = SharedObjectManager()
|
objects = SharedObjectManager()
|
||||||
all_objects = models.Manager() # Unfiltered manager
|
all_objects = models.Manager() # Unfiltered manager
|
||||||
@@ -31,12 +37,15 @@ class TransactionRuleAction(models.Model):
|
|||||||
is_paid = "is_paid", _("Paid")
|
is_paid = "is_paid", _("Paid")
|
||||||
date = "date", _("Date")
|
date = "date", _("Date")
|
||||||
reference_date = "reference_date", _("Reference Date")
|
reference_date = "reference_date", _("Reference Date")
|
||||||
|
mute = "mute", _("Mute")
|
||||||
amount = "amount", _("Amount")
|
amount = "amount", _("Amount")
|
||||||
description = "description", _("Description")
|
description = "description", _("Description")
|
||||||
notes = "notes", _("Notes")
|
notes = "notes", _("Notes")
|
||||||
category = "category", _("Category")
|
category = "category", _("Category")
|
||||||
tags = "tags", _("Tags")
|
tags = "tags", _("Tags")
|
||||||
entities = "entities", _("Entities")
|
entities = "entities", _("Entities")
|
||||||
|
internal_note = "internal_nome", _("Internal Note")
|
||||||
|
internal_id = "internal_id", _("Internal ID")
|
||||||
|
|
||||||
rule = models.ForeignKey(
|
rule = models.ForeignKey(
|
||||||
TransactionRule,
|
TransactionRule,
|
||||||
@@ -50,6 +59,7 @@ class TransactionRuleAction(models.Model):
|
|||||||
verbose_name=_("Field"),
|
verbose_name=_("Field"),
|
||||||
)
|
)
|
||||||
value = models.TextField(verbose_name=_("Value"))
|
value = models.TextField(verbose_name=_("Value"))
|
||||||
|
order = models.PositiveIntegerField(default=0, verbose_name=_("Order"))
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.rule} - {self.field} - {self.value}"
|
return f"{self.rule} - {self.field} - {self.value}"
|
||||||
@@ -58,6 +68,11 @@ class TransactionRuleAction(models.Model):
|
|||||||
verbose_name = _("Edit transaction action")
|
verbose_name = _("Edit transaction action")
|
||||||
verbose_name_plural = _("Edit transaction actions")
|
verbose_name_plural = _("Edit transaction actions")
|
||||||
unique_together = (("rule", "field"),)
|
unique_together = (("rule", "field"),)
|
||||||
|
ordering = ["order"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def action_type(self):
|
||||||
|
return "edit_transaction"
|
||||||
|
|
||||||
|
|
||||||
class UpdateOrCreateTransactionRuleAction(models.Model):
|
class UpdateOrCreateTransactionRuleAction(models.Model):
|
||||||
@@ -236,6 +251,17 @@ class UpdateOrCreateTransactionRuleAction(models.Model):
|
|||||||
verbose_name="Internal ID Operator",
|
verbose_name="Internal ID Operator",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
search_mute = models.TextField(
|
||||||
|
verbose_name="Search Mute",
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
search_mute_operator = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
choices=SearchOperator.choices,
|
||||||
|
default=SearchOperator.EXACT,
|
||||||
|
verbose_name="Mute Operator",
|
||||||
|
)
|
||||||
|
|
||||||
# Set fields
|
# Set fields
|
||||||
set_account = models.TextField(
|
set_account = models.TextField(
|
||||||
verbose_name=_("Account"),
|
verbose_name=_("Account"),
|
||||||
@@ -289,10 +315,21 @@ class UpdateOrCreateTransactionRuleAction(models.Model):
|
|||||||
verbose_name=_("Tags"),
|
verbose_name=_("Tags"),
|
||||||
blank=True,
|
blank=True,
|
||||||
)
|
)
|
||||||
|
set_mute = models.TextField(
|
||||||
|
verbose_name=_("Mute"),
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
order = models.PositiveIntegerField(default=0, verbose_name=_("Order"))
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Update or create transaction action")
|
verbose_name = _("Update or create transaction action")
|
||||||
verbose_name_plural = _("Update or create transaction actions")
|
verbose_name_plural = _("Update or create transaction actions")
|
||||||
|
ordering = ["order"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def action_type(self):
|
||||||
|
return "update_or_create_transaction"
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Update or create transaction action for {self.rule}"
|
return f"Update or create transaction action for {self.rule}"
|
||||||
@@ -324,6 +361,10 @@ class UpdateOrCreateTransactionRuleAction(models.Model):
|
|||||||
value = simple.eval(self.search_is_paid)
|
value = simple.eval(self.search_is_paid)
|
||||||
search_query &= add_to_query("is_paid", value, self.search_is_paid_operator)
|
search_query &= add_to_query("is_paid", value, self.search_is_paid_operator)
|
||||||
|
|
||||||
|
if self.search_mute:
|
||||||
|
value = simple.eval(self.search_mute)
|
||||||
|
search_query &= add_to_query("mute", value, self.search_mute_operator)
|
||||||
|
|
||||||
if self.search_date:
|
if self.search_date:
|
||||||
value = simple.eval(self.search_date)
|
value = simple.eval(self.search_date)
|
||||||
search_query &= add_to_query("date", value, self.search_date_operator)
|
search_query &= add_to_query("date", value, self.search_date_operator)
|
||||||
|
|||||||
@@ -1,17 +1,34 @@
|
|||||||
|
from django.conf import settings
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
|
|
||||||
from apps.transactions.models import (
|
from apps.transactions.models import (
|
||||||
Transaction,
|
Transaction,
|
||||||
transaction_created,
|
transaction_created,
|
||||||
transaction_updated,
|
transaction_updated,
|
||||||
|
transaction_deleted,
|
||||||
)
|
)
|
||||||
from apps.rules.tasks import check_for_transaction_rules
|
from apps.rules.tasks import check_for_transaction_rules
|
||||||
from apps.common.middleware.thread_local import get_current_user
|
from apps.common.middleware.thread_local import get_current_user
|
||||||
|
from apps.rules.utils.transactions import serialize_transaction
|
||||||
|
|
||||||
|
|
||||||
@receiver(transaction_created)
|
@receiver(transaction_created)
|
||||||
@receiver(transaction_updated)
|
@receiver(transaction_updated)
|
||||||
|
@receiver(transaction_deleted)
|
||||||
def transaction_changed_receiver(sender: Transaction, signal, **kwargs):
|
def transaction_changed_receiver(sender: Transaction, signal, **kwargs):
|
||||||
|
old_data = kwargs.get("old_data")
|
||||||
|
if signal is transaction_deleted:
|
||||||
|
# Serialize transaction data for processing
|
||||||
|
transaction_data = serialize_transaction(sender, deleted=True)
|
||||||
|
|
||||||
|
check_for_transaction_rules.defer(
|
||||||
|
transaction_data=transaction_data,
|
||||||
|
user_id=get_current_user().id,
|
||||||
|
signal="transaction_deleted",
|
||||||
|
is_hard_deleted=kwargs.get("hard_delete", not settings.ENABLE_SOFT_DELETE),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
for dca_entry in sender.dca_expense_entries.all():
|
for dca_entry in sender.dca_expense_entries.all():
|
||||||
dca_entry.amount_paid = sender.amount
|
dca_entry.amount_paid = sender.amount
|
||||||
dca_entry.save()
|
dca_entry.save()
|
||||||
@@ -19,6 +36,9 @@ def transaction_changed_receiver(sender: Transaction, signal, **kwargs):
|
|||||||
dca_entry.amount_received = sender.amount
|
dca_entry.amount_received = sender.amount
|
||||||
dca_entry.save()
|
dca_entry.save()
|
||||||
|
|
||||||
|
if signal is transaction_updated and old_data:
|
||||||
|
old_data = serialize_transaction(old_data, deleted=False)
|
||||||
|
|
||||||
check_for_transaction_rules.defer(
|
check_for_transaction_rules.defer(
|
||||||
instance_id=sender.id,
|
instance_id=sender.id,
|
||||||
user_id=get_current_user().id,
|
user_id=get_current_user().id,
|
||||||
@@ -27,4 +47,5 @@ def transaction_changed_receiver(sender: Transaction, signal, **kwargs):
|
|||||||
if signal is transaction_created
|
if signal is transaction_created
|
||||||
else "transaction_updated"
|
else "transaction_updated"
|
||||||
),
|
),
|
||||||
|
old_data=old_data,
|
||||||
)
|
)
|
||||||
|
|||||||
+652
-300
File diff suppressed because it is too large
Load Diff
@@ -42,6 +42,21 @@ urlpatterns = [
|
|||||||
views.transaction_rule_take_ownership,
|
views.transaction_rule_take_ownership,
|
||||||
name="transaction_rule_take_ownership",
|
name="transaction_rule_take_ownership",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"rules/transaction/<int:pk>/dry-run/created/",
|
||||||
|
views.dry_run_rule_created,
|
||||||
|
name="transaction_rule_dry_run_created",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"rules/transaction/<int:pk>/dry-run/deleted/",
|
||||||
|
views.dry_run_rule_deleted,
|
||||||
|
name="transaction_rule_dry_run_deleted",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"rules/transaction/<int:pk>/dry-run/updated/",
|
||||||
|
views.dry_run_rule_updated,
|
||||||
|
name="transaction_rule_dry_run_updated",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"rules/transaction/<int:pk>/share/",
|
"rules/transaction/<int:pk>/share/",
|
||||||
views.transaction_rule_share,
|
views.transaction_rule_share,
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import logging
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.db.models import Sum, Value, DecimalField, Case, When, F
|
||||||
|
from django.db.models.functions import Coalesce
|
||||||
|
|
||||||
|
from apps.transactions.models import (
|
||||||
|
Transaction,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionsGetter:
|
||||||
|
def __init__(self, **filters):
|
||||||
|
self.__queryset = Transaction.objects.filter(**filters)
|
||||||
|
|
||||||
|
def exclude(self, **exclude_filters):
|
||||||
|
self.__queryset = self.__queryset.exclude(**exclude_filters)
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sum(self):
|
||||||
|
return self.__queryset.aggregate(
|
||||||
|
total=Coalesce(
|
||||||
|
Sum("amount"), Value(Decimal("0")), output_field=DecimalField()
|
||||||
|
)
|
||||||
|
)["total"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def balance(self):
|
||||||
|
return abs(
|
||||||
|
self.__queryset.aggregate(
|
||||||
|
balance=Coalesce(
|
||||||
|
Sum(
|
||||||
|
Case(
|
||||||
|
When(type=Transaction.Type.EXPENSE, then=-F("amount")),
|
||||||
|
default=F("amount"),
|
||||||
|
output_field=DecimalField(),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Value(Decimal("0")),
|
||||||
|
output_field=DecimalField(),
|
||||||
|
)
|
||||||
|
)["balance"]
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def raw_balance(self):
|
||||||
|
return self.__queryset.aggregate(
|
||||||
|
balance=Coalesce(
|
||||||
|
Sum(
|
||||||
|
Case(
|
||||||
|
When(type=Transaction.Type.EXPENSE, then=-F("amount")),
|
||||||
|
default=F("amount"),
|
||||||
|
output_field=DecimalField(),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Value(Decimal("0")),
|
||||||
|
output_field=DecimalField(),
|
||||||
|
)
|
||||||
|
)["balance"]
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_transaction(sender: Transaction, deleted: bool):
|
||||||
|
return {
|
||||||
|
"id": sender.id,
|
||||||
|
"account": (sender.account.id, sender.account.name),
|
||||||
|
"account_group": (
|
||||||
|
sender.account.group.id if sender.account.group else None,
|
||||||
|
sender.account.group.name if sender.account.group else None,
|
||||||
|
),
|
||||||
|
"type": str(sender.type),
|
||||||
|
"is_paid": sender.is_paid,
|
||||||
|
"is_asset": sender.account.is_asset,
|
||||||
|
"is_archived": sender.account.is_archived,
|
||||||
|
"category": (
|
||||||
|
sender.category.id if sender.category else None,
|
||||||
|
sender.category.name if sender.category else None,
|
||||||
|
),
|
||||||
|
"date": sender.date.isoformat(),
|
||||||
|
"reference_date": sender.reference_date.isoformat(),
|
||||||
|
"amount": str(sender.amount),
|
||||||
|
"description": sender.description,
|
||||||
|
"notes": sender.notes,
|
||||||
|
"tags": list(sender.tags.values_list("id", "name")),
|
||||||
|
"entities": list(sender.entities.values_list("id", "name")),
|
||||||
|
"deleted": deleted,
|
||||||
|
"internal_note": sender.internal_note,
|
||||||
|
"internal_id": sender.internal_id,
|
||||||
|
"mute": sender.mute,
|
||||||
|
}
|
||||||
+194
-2
@@ -1,5 +1,10 @@
|
|||||||
|
from itertools import chain
|
||||||
|
|
||||||
|
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
|
||||||
|
from django.db import transaction
|
||||||
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 _
|
||||||
@@ -10,6 +15,9 @@ from apps.rules.forms import (
|
|||||||
TransactionRuleForm,
|
TransactionRuleForm,
|
||||||
TransactionRuleActionForm,
|
TransactionRuleActionForm,
|
||||||
UpdateOrCreateTransactionRuleActionForm,
|
UpdateOrCreateTransactionRuleActionForm,
|
||||||
|
DryRunCreatedTransacion,
|
||||||
|
DryRunDeletedTransacion,
|
||||||
|
DryRunUpdatedTransactionForm,
|
||||||
)
|
)
|
||||||
from apps.rules.models import (
|
from apps.rules.models import (
|
||||||
TransactionRule,
|
TransactionRule,
|
||||||
@@ -18,9 +26,16 @@ from apps.rules.models import (
|
|||||||
)
|
)
|
||||||
from apps.common.models import SharedObject
|
from apps.common.models import SharedObject
|
||||||
from apps.common.forms import SharedObjectForm
|
from apps.common.forms import SharedObjectForm
|
||||||
|
from apps.common.decorators.demo import disabled_on_demo
|
||||||
|
from apps.rules.tasks import check_for_transaction_rules
|
||||||
|
from apps.common.middleware.thread_local import get_current_user
|
||||||
|
from apps.rules.signals import transaction_created, transaction_updated
|
||||||
|
from apps.rules.utils.transactions import serialize_transaction
|
||||||
|
from apps.transactions.models import Transaction
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def rules_index(request):
|
def rules_index(request):
|
||||||
return render(
|
return render(
|
||||||
@@ -31,9 +46,10 @@ def rules_index(request):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def rules_list(request):
|
def rules_list(request):
|
||||||
transaction_rules = TransactionRule.objects.all().order_by("id")
|
transaction_rules = TransactionRule.objects.all().order_by("order", "id")
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"rules/fragments/list.html",
|
"rules/fragments/list.html",
|
||||||
@@ -43,6 +59,7 @@ def rules_list(request):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET", "POST"])
|
||||||
def transaction_rule_toggle_activity(request, transaction_rule_id, **kwargs):
|
def transaction_rule_toggle_activity(request, transaction_rule_id, **kwargs):
|
||||||
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
|
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
|
||||||
@@ -65,6 +82,7 @@ def transaction_rule_toggle_activity(request, transaction_rule_id, **kwargs):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET", "POST"])
|
||||||
def transaction_rule_add(request, **kwargs):
|
def transaction_rule_add(request, **kwargs):
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
@@ -91,6 +109,7 @@ def transaction_rule_add(request, **kwargs):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET", "POST"])
|
||||||
def transaction_rule_edit(request, transaction_rule_id):
|
def transaction_rule_edit(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)
|
||||||
@@ -129,19 +148,31 @@ def transaction_rule_edit(request, transaction_rule_id):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET", "POST"])
|
||||||
def transaction_rule_view(request, transaction_rule_id):
|
def transaction_rule_view(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)
|
||||||
|
|
||||||
|
edit_actions = transaction_rule.transaction_actions.all()
|
||||||
|
update_or_create_actions = (
|
||||||
|
transaction_rule.update_or_create_transaction_actions.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
all_actions = sorted(
|
||||||
|
chain(edit_actions, update_or_create_actions),
|
||||||
|
key=lambda a: a.order,
|
||||||
|
)
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"rules/fragments/transaction_rule/view.html",
|
"rules/fragments/transaction_rule/view.html",
|
||||||
{"transaction_rule": transaction_rule},
|
{"transaction_rule": transaction_rule, "all_actions": all_actions},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
@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)
|
||||||
@@ -166,6 +197,7 @@ def transaction_rule_delete(request, transaction_rule_id):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def transaction_rule_take_ownership(request, transaction_rule_id):
|
def transaction_rule_take_ownership(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)
|
||||||
@@ -187,6 +219,7 @@ def transaction_rule_take_ownership(request, transaction_rule_id):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET", "POST"])
|
||||||
def transaction_rule_share(request, pk):
|
def transaction_rule_share(request, pk):
|
||||||
obj = get_object_or_404(TransactionRule, id=pk)
|
obj = get_object_or_404(TransactionRule, id=pk)
|
||||||
@@ -225,6 +258,7 @@ def transaction_rule_share(request, pk):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET", "POST"])
|
||||||
def transaction_rule_action_add(request, transaction_rule_id):
|
def transaction_rule_action_add(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)
|
||||||
@@ -252,6 +286,7 @@ def transaction_rule_action_add(request, transaction_rule_id):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET", "POST"])
|
||||||
def transaction_rule_action_edit(request, transaction_rule_action_id):
|
def transaction_rule_action_edit(request, transaction_rule_action_id):
|
||||||
transaction_rule_action = get_object_or_404(
|
transaction_rule_action = get_object_or_404(
|
||||||
@@ -289,6 +324,7 @@ def transaction_rule_action_edit(request, transaction_rule_action_id):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
@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(
|
||||||
@@ -309,6 +345,7 @@ def transaction_rule_action_delete(request, transaction_rule_action_id):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET", "POST"])
|
||||||
def update_or_create_transaction_rule_action_add(request, transaction_rule_id):
|
def update_or_create_transaction_rule_action_add(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)
|
||||||
@@ -340,6 +377,7 @@ def update_or_create_transaction_rule_action_add(request, transaction_rule_id):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET", "POST"])
|
||||||
def update_or_create_transaction_rule_action_edit(request, pk):
|
def update_or_create_transaction_rule_action_edit(request, pk):
|
||||||
linked_action = get_object_or_404(UpdateOrCreateTransactionRuleAction, id=pk)
|
linked_action = get_object_or_404(UpdateOrCreateTransactionRuleAction, id=pk)
|
||||||
@@ -374,6 +412,7 @@ def update_or_create_transaction_rule_action_edit(request, pk):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
@require_http_methods(["DELETE"])
|
@require_http_methods(["DELETE"])
|
||||||
def update_or_create_transaction_rule_action_delete(request, pk):
|
def update_or_create_transaction_rule_action_delete(request, pk):
|
||||||
linked_action = get_object_or_404(UpdateOrCreateTransactionRuleAction, id=pk)
|
linked_action = get_object_or_404(UpdateOrCreateTransactionRuleAction, id=pk)
|
||||||
@@ -390,3 +429,156 @@ def update_or_create_transaction_rule_action_delete(request, pk):
|
|||||||
"HX-Trigger": "updated, hide_offcanvas",
|
"HX-Trigger": "updated, hide_offcanvas",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def dry_run_rule_created(request, pk):
|
||||||
|
rule = get_object_or_404(TransactionRule, id=pk)
|
||||||
|
logs = None
|
||||||
|
results = None
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
form = DryRunCreatedTransacion(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
logs, results = check_for_transaction_rules(
|
||||||
|
instance_id=form.cleaned_data["transaction"].id,
|
||||||
|
signal="transaction_created",
|
||||||
|
dry_run=True,
|
||||||
|
rule_id=rule.id,
|
||||||
|
user_id=get_current_user().id,
|
||||||
|
)
|
||||||
|
logs = "\n".join(logs)
|
||||||
|
|
||||||
|
response = render(
|
||||||
|
request,
|
||||||
|
"rules/fragments/transaction_rule/dry_run/created.html",
|
||||||
|
{"form": form, "rule": rule, "logs": logs, "results": results},
|
||||||
|
)
|
||||||
|
|
||||||
|
raise Exception("ROLLBACK")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
else:
|
||||||
|
form = DryRunCreatedTransacion()
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"rules/fragments/transaction_rule/dry_run/created.html",
|
||||||
|
{"form": form, "rule": rule, "logs": logs, "results": results},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def dry_run_rule_deleted(request, pk):
|
||||||
|
rule = get_object_or_404(TransactionRule, id=pk)
|
||||||
|
logs = None
|
||||||
|
results = None
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
form = DryRunDeletedTransacion(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
logs, results = check_for_transaction_rules(
|
||||||
|
instance_id=form.cleaned_data["transaction"].id,
|
||||||
|
signal="transaction_deleted",
|
||||||
|
dry_run=True,
|
||||||
|
rule_id=rule.id,
|
||||||
|
user_id=get_current_user().id,
|
||||||
|
)
|
||||||
|
logs = "\n".join(logs)
|
||||||
|
|
||||||
|
response = render(
|
||||||
|
request,
|
||||||
|
"rules/fragments/transaction_rule/dry_run/created.html",
|
||||||
|
{"form": form, "rule": rule, "logs": logs, "results": results},
|
||||||
|
)
|
||||||
|
|
||||||
|
raise Exception("ROLLBACK")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
else:
|
||||||
|
form = DryRunDeletedTransacion()
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"rules/fragments/transaction_rule/dry_run/deleted.html",
|
||||||
|
{"form": form, "rule": rule, "logs": logs, "results": results},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def dry_run_rule_updated(request, pk):
|
||||||
|
rule = get_object_or_404(TransactionRule, id=pk)
|
||||||
|
logs = None
|
||||||
|
results = None
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
form = DryRunUpdatedTransactionForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
base_transaction = Transaction.objects.get(
|
||||||
|
id=request.POST.get("transaction")
|
||||||
|
)
|
||||||
|
old_data = deepcopy(base_transaction)
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
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":
|
||||||
|
base_transaction.tags.set(value)
|
||||||
|
elif field_name == "entities":
|
||||||
|
base_transaction.entities.set(value)
|
||||||
|
else:
|
||||||
|
setattr(base_transaction, field_name, value)
|
||||||
|
|
||||||
|
base_transaction.save()
|
||||||
|
|
||||||
|
logs, results = check_for_transaction_rules(
|
||||||
|
instance_id=base_transaction.id,
|
||||||
|
signal="transaction_updated",
|
||||||
|
dry_run=True,
|
||||||
|
rule_id=rule.id,
|
||||||
|
user_id=get_current_user().id,
|
||||||
|
old_data=old_data,
|
||||||
|
)
|
||||||
|
logs = "\n".join(logs) if logs else ""
|
||||||
|
|
||||||
|
response = render(
|
||||||
|
request,
|
||||||
|
"rules/fragments/transaction_rule/dry_run/created.html",
|
||||||
|
{"form": form, "rule": rule, "logs": logs, "results": results},
|
||||||
|
)
|
||||||
|
|
||||||
|
# This will rollback the transaction
|
||||||
|
raise Exception("ROLLBACK")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return response
|
||||||
|
else:
|
||||||
|
form = DryRunUpdatedTransactionForm(initial={"is_paid": None, "type": None})
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"rules/fragments/transaction_rule/dry_run/updated.html",
|
||||||
|
{"form": form, "rule": rule, "logs": logs, "results": results},
|
||||||
|
)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from apps.transactions.models import (
|
|||||||
InstallmentPlan,
|
InstallmentPlan,
|
||||||
RecurringTransaction,
|
RecurringTransaction,
|
||||||
TransactionEntity,
|
TransactionEntity,
|
||||||
|
QuickTransaction,
|
||||||
)
|
)
|
||||||
from apps.common.admin import SharedObjectModelAdmin
|
from apps.common.admin import SharedObjectModelAdmin
|
||||||
|
|
||||||
@@ -49,19 +50,22 @@ class TransactionInline(admin.TabularInline):
|
|||||||
|
|
||||||
|
|
||||||
@admin.register(InstallmentPlan)
|
@admin.register(InstallmentPlan)
|
||||||
class InstallmentPlanAdmin(SharedObjectModelAdmin):
|
class InstallmentPlanAdmin(admin.ModelAdmin):
|
||||||
inlines = [
|
inlines = [
|
||||||
TransactionInline,
|
TransactionInline,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@admin.register(RecurringTransaction)
|
@admin.register(RecurringTransaction)
|
||||||
class RecurringTransactionAdmin(SharedObjectModelAdmin):
|
class RecurringTransactionAdmin(admin.ModelAdmin):
|
||||||
inlines = [
|
inlines = [
|
||||||
TransactionInline,
|
TransactionInline,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.register(QuickTransaction)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(TransactionCategory)
|
@admin.register(TransactionCategory)
|
||||||
class TransactionCategoryModelAdmin(SharedObjectModelAdmin):
|
class TransactionCategoryModelAdmin(SharedObjectModelAdmin):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -60,26 +60,20 @@ class TransactionsFilter(django_filters.FilterSet):
|
|||||||
label=_("Currencies"),
|
label=_("Currencies"),
|
||||||
widget=TomSelectMultiple(checkboxes=True, remove_button=True),
|
widget=TomSelectMultiple(checkboxes=True, remove_button=True),
|
||||||
)
|
)
|
||||||
category = django_filters.ModelMultipleChoiceFilter(
|
category = django_filters.MultipleChoiceFilter(
|
||||||
field_name="category__name",
|
|
||||||
queryset=TransactionCategory.objects.all(),
|
|
||||||
to_field_name="name",
|
|
||||||
label=_("Categories"),
|
label=_("Categories"),
|
||||||
widget=TomSelectMultiple(checkboxes=True, remove_button=True),
|
widget=TomSelectMultiple(checkboxes=True, remove_button=True),
|
||||||
|
method="filter_category",
|
||||||
)
|
)
|
||||||
tags = django_filters.ModelMultipleChoiceFilter(
|
tags = django_filters.MultipleChoiceFilter(
|
||||||
field_name="tags__name",
|
|
||||||
queryset=TransactionTag.objects.all(),
|
|
||||||
to_field_name="name",
|
|
||||||
label=_("Tags"),
|
label=_("Tags"),
|
||||||
widget=TomSelectMultiple(checkboxes=True, remove_button=True),
|
widget=TomSelectMultiple(checkboxes=True, remove_button=True),
|
||||||
|
method="filter_tags",
|
||||||
)
|
)
|
||||||
entities = django_filters.ModelMultipleChoiceFilter(
|
entities = django_filters.MultipleChoiceFilter(
|
||||||
field_name="entities__name",
|
|
||||||
queryset=TransactionEntity.objects.all(),
|
|
||||||
to_field_name="name",
|
|
||||||
label=_("Entities"),
|
label=_("Entities"),
|
||||||
widget=TomSelectMultiple(checkboxes=True, remove_button=True),
|
widget=TomSelectMultiple(checkboxes=True, remove_button=True),
|
||||||
|
method="filter_entities",
|
||||||
)
|
)
|
||||||
is_paid = django_filters.MultipleChoiceFilter(
|
is_paid = django_filters.MultipleChoiceFilter(
|
||||||
choices=SITUACAO_CHOICES,
|
choices=SITUACAO_CHOICES,
|
||||||
@@ -125,6 +119,7 @@ class TransactionsFilter(django_filters.FilterSet):
|
|||||||
"is_paid",
|
"is_paid",
|
||||||
"category",
|
"category",
|
||||||
"tags",
|
"tags",
|
||||||
|
"entities",
|
||||||
"date_start",
|
"date_start",
|
||||||
"date_end",
|
"date_end",
|
||||||
"reference_date_start",
|
"reference_date_start",
|
||||||
@@ -186,6 +181,93 @@ class TransactionsFilter(django_filters.FilterSet):
|
|||||||
self.form.fields["date_end"].widget = AirDatePickerInput()
|
self.form.fields["date_end"].widget = AirDatePickerInput()
|
||||||
|
|
||||||
self.form.fields["account"].queryset = Account.objects.all()
|
self.form.fields["account"].queryset = Account.objects.all()
|
||||||
self.form.fields["category"].queryset = TransactionCategory.objects.all()
|
category_choices = list(
|
||||||
self.form.fields["tags"].queryset = TransactionTag.objects.all()
|
TransactionCategory.objects.values_list("name", "name").order_by("name")
|
||||||
self.form.fields["entities"].queryset = TransactionEntity.objects.all()
|
)
|
||||||
|
custom_choices = [
|
||||||
|
("any", _("Categorized")),
|
||||||
|
("uncategorized", _("Uncategorized")),
|
||||||
|
]
|
||||||
|
self.form.fields["category"].choices = custom_choices + category_choices
|
||||||
|
tag_choices = list(
|
||||||
|
TransactionTag.objects.values_list("name", "name").order_by("name")
|
||||||
|
)
|
||||||
|
custom_tag_choices = [("any", _("Tagged")), ("untagged", _("Untagged"))]
|
||||||
|
self.form.fields["tags"].choices = custom_tag_choices + tag_choices
|
||||||
|
entity_choices = list(
|
||||||
|
TransactionEntity.objects.values_list("name", "name").order_by("name")
|
||||||
|
)
|
||||||
|
custom_entity_choices = [
|
||||||
|
("any", _("Any entity")),
|
||||||
|
("no_entity", _("No entity")),
|
||||||
|
]
|
||||||
|
self.form.fields["entities"].choices = custom_entity_choices + entity_choices
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def filter_category(queryset, name, value):
|
||||||
|
if not value:
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
value = list(value)
|
||||||
|
|
||||||
|
if "any" in value:
|
||||||
|
return queryset.filter(category__isnull=False)
|
||||||
|
|
||||||
|
q = Q()
|
||||||
|
if "uncategorized" in value:
|
||||||
|
q |= Q(category__isnull=True)
|
||||||
|
value.remove("uncategorized")
|
||||||
|
|
||||||
|
if value:
|
||||||
|
q |= Q(category__name__in=value)
|
||||||
|
|
||||||
|
if q.children:
|
||||||
|
return queryset.filter(q)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def filter_tags(queryset, name, value):
|
||||||
|
if not value:
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
value = list(value)
|
||||||
|
|
||||||
|
if "any" in value:
|
||||||
|
return queryset.filter(tags__isnull=False).distinct()
|
||||||
|
|
||||||
|
q = Q()
|
||||||
|
if "untagged" in value:
|
||||||
|
q |= Q(tags__isnull=True)
|
||||||
|
value.remove("untagged")
|
||||||
|
|
||||||
|
if value:
|
||||||
|
q |= Q(tags__name__in=value)
|
||||||
|
|
||||||
|
if q.children:
|
||||||
|
return queryset.filter(q).distinct()
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def filter_entities(queryset, name, value):
|
||||||
|
if not value:
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
value = list(value)
|
||||||
|
|
||||||
|
if "any" in value:
|
||||||
|
return queryset.filter(entities__isnull=False).distinct()
|
||||||
|
|
||||||
|
q = Q()
|
||||||
|
if "no_entity" in value:
|
||||||
|
q |= Q(entities__isnull=True)
|
||||||
|
value.remove("no_entity")
|
||||||
|
|
||||||
|
if value:
|
||||||
|
q |= Q(entities__name__in=value)
|
||||||
|
|
||||||
|
if q.children:
|
||||||
|
return queryset.filter(q).distinct()
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|||||||
+286
-24
@@ -1,11 +1,15 @@
|
|||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion
|
from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion
|
||||||
from crispy_forms.bootstrap import FormActions, AccordionGroup
|
from crispy_forms.bootstrap import FormActions, AccordionGroup, AppendedText
|
||||||
from crispy_forms.helper import FormHelper
|
from crispy_forms.helper import FormHelper
|
||||||
from crispy_forms.layout import (
|
from crispy_forms.layout import (
|
||||||
Layout,
|
Layout,
|
||||||
Row,
|
Row,
|
||||||
Column,
|
Column,
|
||||||
Field,
|
Field,
|
||||||
|
Div,
|
||||||
|
HTML,
|
||||||
)
|
)
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
@@ -28,8 +32,8 @@ from apps.transactions.models import (
|
|||||||
InstallmentPlan,
|
InstallmentPlan,
|
||||||
RecurringTransaction,
|
RecurringTransaction,
|
||||||
TransactionEntity,
|
TransactionEntity,
|
||||||
|
QuickTransaction,
|
||||||
)
|
)
|
||||||
from apps.common.middleware.thread_local import get_current_user
|
|
||||||
|
|
||||||
|
|
||||||
class TransactionForm(forms.ModelForm):
|
class TransactionForm(forms.ModelForm):
|
||||||
@@ -206,10 +210,21 @@ class TransactionForm(forms.ModelForm):
|
|||||||
else:
|
else:
|
||||||
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||||
self.helper.layout.append(
|
self.helper.layout.append(
|
||||||
FormActions(
|
Div(
|
||||||
NoClassSubmit(
|
NoClassSubmit(
|
||||||
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
|
"submit", _("Add"), css_class="btn btn-outline-primary"
|
||||||
),
|
),
|
||||||
|
NoClassSubmit(
|
||||||
|
"submit_and_similar",
|
||||||
|
_("Save and add similar"),
|
||||||
|
css_class="btn btn-outline-primary",
|
||||||
|
),
|
||||||
|
NoClassSubmit(
|
||||||
|
"submit_and_another",
|
||||||
|
_("Save and add another"),
|
||||||
|
css_class="btn btn-outline-primary",
|
||||||
|
),
|
||||||
|
css_class="d-grid gap-2",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -226,43 +241,135 @@ class TransactionForm(forms.ModelForm):
|
|||||||
def save(self, **kwargs):
|
def save(self, **kwargs):
|
||||||
is_new = not self.instance.id
|
is_new = not self.instance.id
|
||||||
|
|
||||||
|
if not is_new:
|
||||||
|
old_data = deepcopy(Transaction.objects.get(pk=self.instance.id))
|
||||||
|
else:
|
||||||
|
old_data = None
|
||||||
|
|
||||||
instance = super().save(**kwargs)
|
instance = super().save(**kwargs)
|
||||||
if is_new:
|
if is_new:
|
||||||
transaction_created.send(sender=instance)
|
transaction_created.send(sender=instance)
|
||||||
else:
|
else:
|
||||||
transaction_updated.send(sender=instance)
|
transaction_updated.send(sender=instance, old_data=old_data)
|
||||||
|
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
|
|
||||||
class BulkEditTransactionForm(TransactionForm):
|
class QuickTransactionForm(forms.ModelForm):
|
||||||
is_paid = forms.NullBooleanField(required=False)
|
category = DynamicModelChoiceField(
|
||||||
|
create_field="name",
|
||||||
|
model=TransactionCategory,
|
||||||
|
required=False,
|
||||||
|
label=_("Category"),
|
||||||
|
queryset=TransactionCategory.objects.filter(active=True),
|
||||||
|
)
|
||||||
|
tags = DynamicModelMultipleChoiceField(
|
||||||
|
model=TransactionTag,
|
||||||
|
to_field_name="name",
|
||||||
|
create_field="name",
|
||||||
|
required=False,
|
||||||
|
label=_("Tags"),
|
||||||
|
queryset=TransactionTag.objects.filter(active=True),
|
||||||
|
)
|
||||||
|
entities = DynamicModelMultipleChoiceField(
|
||||||
|
model=TransactionEntity,
|
||||||
|
to_field_name="name",
|
||||||
|
create_field="name",
|
||||||
|
required=False,
|
||||||
|
label=_("Entities"),
|
||||||
|
)
|
||||||
|
account = forms.ModelChoiceField(
|
||||||
|
queryset=Account.objects.filter(is_archived=False),
|
||||||
|
label=_("Account"),
|
||||||
|
widget=TomSelect(clear_button=False, group_by="group"),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = QuickTransaction
|
||||||
|
fields = [
|
||||||
|
"name",
|
||||||
|
"account",
|
||||||
|
"type",
|
||||||
|
"is_paid",
|
||||||
|
"amount",
|
||||||
|
"description",
|
||||||
|
"notes",
|
||||||
|
"category",
|
||||||
|
"tags",
|
||||||
|
"entities",
|
||||||
|
"mute",
|
||||||
|
]
|
||||||
|
widgets = {
|
||||||
|
"notes": forms.Textarea(attrs={"rows": 3}),
|
||||||
|
"account": TomSelect(clear_button=False, group_by="group"),
|
||||||
|
}
|
||||||
|
help_texts = {
|
||||||
|
"mute": _("Muted transactions won't be displayed on monthly summaries")
|
||||||
|
}
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*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
|
# if editing a transaction display non-archived items and it's own item even if it's archived
|
||||||
del self.helper.layout[0:2] # Remove type, is_paid field
|
if self.instance.id:
|
||||||
|
self.fields["account"].queryset = Account.objects.filter(
|
||||||
|
Q(is_archived=False) | Q(transactions=self.instance.id),
|
||||||
|
)
|
||||||
|
|
||||||
self.helper.layout.insert(
|
self.fields["category"].queryset = TransactionCategory.objects.filter(
|
||||||
0,
|
Q(active=True) | Q(transaction=self.instance.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.fields["tags"].queryset = TransactionTag.objects.filter(
|
||||||
|
Q(active=True) | Q(transaction=self.instance.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.fields["entities"].queryset = TransactionEntity.objects.filter(
|
||||||
|
Q(active=True) | Q(transactions=self.instance.id)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.fields["account"].queryset = Account.objects.filter(
|
||||||
|
is_archived=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.fields["category"].queryset = TransactionCategory.objects.filter(
|
||||||
|
active=True
|
||||||
|
)
|
||||||
|
self.fields["tags"].queryset = TransactionTag.objects.filter(active=True)
|
||||||
|
self.fields["entities"].queryset = TransactionEntity.objects.all()
|
||||||
|
|
||||||
|
self.helper = FormHelper()
|
||||||
|
self.helper.form_tag = False
|
||||||
|
self.helper.form_method = "post"
|
||||||
|
self.helper.layout = Layout(
|
||||||
Field(
|
Field(
|
||||||
"type",
|
"type",
|
||||||
template="transactions/widgets/unselectable_income_expense_toggle_buttons.html",
|
template="transactions/widgets/income_expense_toggle_buttons.html",
|
||||||
),
|
),
|
||||||
|
Field("is_paid", template="transactions/widgets/paid_toggle_button.html"),
|
||||||
|
"name",
|
||||||
|
HTML("<hr />"),
|
||||||
|
Row(
|
||||||
|
Column("account", css_class="form-group col-md-6 mb-0"),
|
||||||
|
Column("entities", css_class="form-group col-md-6 mb-0"),
|
||||||
|
css_class="form-row",
|
||||||
|
),
|
||||||
|
"description",
|
||||||
|
Field("amount", inputmode="decimal"),
|
||||||
|
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",
|
||||||
|
Switch("mute"),
|
||||||
)
|
)
|
||||||
|
|
||||||
self.helper.layout.insert(
|
if self.instance and self.instance.pk:
|
||||||
1,
|
decimal_places = self.instance.account.currency.decimal_places
|
||||||
Field(
|
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput(
|
||||||
"is_paid",
|
decimal_places=decimal_places
|
||||||
template="transactions/widgets/unselectable_paid_toggle_button.html",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.helper.layout.append(
|
self.helper.layout.append(
|
||||||
FormActions(
|
FormActions(
|
||||||
NoClassSubmit(
|
NoClassSubmit(
|
||||||
@@ -270,6 +377,136 @@ class BulkEditTransactionForm(TransactionForm):
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||||
|
self.helper.layout.append(
|
||||||
|
Div(
|
||||||
|
NoClassSubmit(
|
||||||
|
"submit", _("Add"), css_class="btn btn-outline-primary"
|
||||||
|
),
|
||||||
|
css_class="d-grid gap-2",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BulkEditTransactionForm(forms.Form):
|
||||||
|
type = forms.ChoiceField(
|
||||||
|
choices=(Transaction.Type.choices),
|
||||||
|
required=False,
|
||||||
|
label=_("Type"),
|
||||||
|
)
|
||||||
|
is_paid = forms.NullBooleanField(
|
||||||
|
required=False,
|
||||||
|
label=_("Paid"),
|
||||||
|
)
|
||||||
|
account = DynamicModelChoiceField(
|
||||||
|
model=Account,
|
||||||
|
required=False,
|
||||||
|
label=_("Account"),
|
||||||
|
queryset=Account.objects.filter(is_archived=False),
|
||||||
|
widget=TomSelect(clear_button=False, group_by="group"),
|
||||||
|
)
|
||||||
|
date = forms.DateField(
|
||||||
|
label=_("Date"),
|
||||||
|
required=False,
|
||||||
|
widget=AirDatePickerInput(clear_button=False),
|
||||||
|
)
|
||||||
|
reference_date = forms.DateField(
|
||||||
|
widget=AirMonthYearPickerInput(),
|
||||||
|
label=_("Reference Date"),
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
amount = forms.DecimalField(
|
||||||
|
max_digits=42,
|
||||||
|
decimal_places=30,
|
||||||
|
required=False,
|
||||||
|
label=_("Amount"),
|
||||||
|
widget=ArbitraryDecimalDisplayNumberInput(),
|
||||||
|
)
|
||||||
|
description = forms.CharField(
|
||||||
|
max_length=500, required=False, label=_("Description")
|
||||||
|
)
|
||||||
|
notes = forms.CharField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.Textarea(attrs={"rows": 3}),
|
||||||
|
label=_("Notes"),
|
||||||
|
)
|
||||||
|
category = DynamicModelChoiceField(
|
||||||
|
create_field="name",
|
||||||
|
model=TransactionCategory,
|
||||||
|
required=False,
|
||||||
|
label=_("Category"),
|
||||||
|
queryset=TransactionCategory.objects.filter(active=True),
|
||||||
|
)
|
||||||
|
tags = DynamicModelMultipleChoiceField(
|
||||||
|
model=TransactionTag,
|
||||||
|
to_field_name="name",
|
||||||
|
create_field="name",
|
||||||
|
required=False,
|
||||||
|
label=_("Tags"),
|
||||||
|
queryset=TransactionTag.objects.filter(active=True),
|
||||||
|
)
|
||||||
|
entities = DynamicModelMultipleChoiceField(
|
||||||
|
model=TransactionEntity,
|
||||||
|
to_field_name="name",
|
||||||
|
create_field="name",
|
||||||
|
required=False,
|
||||||
|
label=_("Entities"),
|
||||||
|
queryset=TransactionEntity.objects.all(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.fields["account"].queryset = Account.objects.filter(
|
||||||
|
is_archived=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.fields["category"].queryset = TransactionCategory.objects.filter(
|
||||||
|
active=True
|
||||||
|
)
|
||||||
|
self.fields["tags"].queryset = TransactionTag.objects.filter(active=True)
|
||||||
|
self.fields["entities"].queryset = TransactionEntity.objects.all()
|
||||||
|
|
||||||
|
self.helper = FormHelper()
|
||||||
|
self.helper.form_tag = False
|
||||||
|
self.helper.form_method = "post"
|
||||||
|
self.helper.layout = Layout(
|
||||||
|
Field(
|
||||||
|
"type",
|
||||||
|
template="transactions/widgets/unselectable_income_expense_toggle_buttons.html",
|
||||||
|
),
|
||||||
|
Field(
|
||||||
|
"is_paid",
|
||||||
|
template="transactions/widgets/unselectable_paid_toggle_button.html",
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
Column("account", css_class="form-group col-md-6 mb-0"),
|
||||||
|
Column("entities", css_class="form-group col-md-6 mb-0"),
|
||||||
|
css_class="form-row",
|
||||||
|
),
|
||||||
|
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"),
|
||||||
|
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",
|
||||||
|
FormActions(
|
||||||
|
NoClassSubmit(
|
||||||
|
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||||
|
self.fields["date"].widget = AirDatePickerInput(clear_button=False)
|
||||||
|
|
||||||
|
|
||||||
class TransferForm(forms.Form):
|
class TransferForm(forms.Form):
|
||||||
@@ -334,7 +571,9 @@ class TransferForm(forms.Form):
|
|||||||
widget=AirMonthYearPickerInput(), label=_("Reference Date"), required=False
|
widget=AirMonthYearPickerInput(), label=_("Reference Date"), required=False
|
||||||
)
|
)
|
||||||
|
|
||||||
description = forms.CharField(max_length=500, label=_("Description"))
|
description = forms.CharField(
|
||||||
|
max_length=500, label=_("Description"), required=False
|
||||||
|
)
|
||||||
notes = forms.CharField(
|
notes = forms.CharField(
|
||||||
required=False,
|
required=False,
|
||||||
widget=forms.Textarea(
|
widget=forms.Textarea(
|
||||||
@@ -345,6 +584,13 @@ class TransferForm(forms.Form):
|
|||||||
label=_("Notes"),
|
label=_("Notes"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
mute = forms.BooleanField(
|
||||||
|
label=_("Mute"),
|
||||||
|
initial=True,
|
||||||
|
required=False,
|
||||||
|
help_text=_("Muted transactions won't be displayed on monthly summaries"),
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
@@ -363,6 +609,7 @@ class TransferForm(forms.Form):
|
|||||||
),
|
),
|
||||||
Field("description"),
|
Field("description"),
|
||||||
Field("notes"),
|
Field("notes"),
|
||||||
|
Switch("mute"),
|
||||||
Row(
|
Row(
|
||||||
Column(
|
Column(
|
||||||
Row(
|
Row(
|
||||||
@@ -445,6 +692,8 @@ class TransferForm(forms.Form):
|
|||||||
return cleaned_data
|
return cleaned_data
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
|
mute = self.cleaned_data["mute"]
|
||||||
|
|
||||||
from_account = self.cleaned_data["from_account"]
|
from_account = self.cleaned_data["from_account"]
|
||||||
to_account = self.cleaned_data["to_account"]
|
to_account = self.cleaned_data["to_account"]
|
||||||
from_amount = self.cleaned_data["from_amount"]
|
from_amount = self.cleaned_data["from_amount"]
|
||||||
@@ -467,6 +716,7 @@ class TransferForm(forms.Form):
|
|||||||
description=description,
|
description=description,
|
||||||
category=from_category,
|
category=from_category,
|
||||||
notes=notes,
|
notes=notes,
|
||||||
|
mute=mute,
|
||||||
)
|
)
|
||||||
from_transaction.tags.set(self.cleaned_data.get("from_tags", []))
|
from_transaction.tags.set(self.cleaned_data.get("from_tags", []))
|
||||||
|
|
||||||
@@ -481,6 +731,7 @@ class TransferForm(forms.Form):
|
|||||||
description=description,
|
description=description,
|
||||||
category=to_category,
|
category=to_category,
|
||||||
notes=notes,
|
notes=notes,
|
||||||
|
mute=mute,
|
||||||
)
|
)
|
||||||
to_transaction.tags.set(self.cleaned_data.get("to_tags", []))
|
to_transaction.tags.set(self.cleaned_data.get("to_tags", []))
|
||||||
|
|
||||||
@@ -538,6 +789,8 @@ class InstallmentPlanForm(forms.ModelForm):
|
|||||||
"notes",
|
"notes",
|
||||||
"installment_start",
|
"installment_start",
|
||||||
"entities",
|
"entities",
|
||||||
|
"add_description_to_transaction",
|
||||||
|
"add_notes_to_transaction",
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
"account": TomSelect(),
|
"account": TomSelect(),
|
||||||
@@ -593,7 +846,9 @@ class InstallmentPlanForm(forms.ModelForm):
|
|||||||
css_class="form-row",
|
css_class="form-row",
|
||||||
),
|
),
|
||||||
"description",
|
"description",
|
||||||
|
Switch("add_description_to_transaction"),
|
||||||
"notes",
|
"notes",
|
||||||
|
Switch("add_notes_to_transaction"),
|
||||||
Row(
|
Row(
|
||||||
Column("number_of_installments", css_class="form-group col-md-6 mb-0"),
|
Column("number_of_installments", css_class="form-group col-md-6 mb-0"),
|
||||||
Column("installment_start", css_class="form-group col-md-6 mb-0"),
|
Column("installment_start", css_class="form-group col-md-6 mb-0"),
|
||||||
@@ -715,7 +970,7 @@ class TransactionCategoryForm(forms.ModelForm):
|
|||||||
fields = ["name", "mute", "active"]
|
fields = ["name", "mute", "active"]
|
||||||
labels = {"name": _("Category name")}
|
labels = {"name": _("Category name")}
|
||||||
help_texts = {
|
help_texts = {
|
||||||
"mute": _("Muted categories won't count towards your monthly total")
|
"mute": _("Muted categories won't be displayed on monthly summaries")
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@@ -782,6 +1037,7 @@ class RecurringTransactionForm(forms.ModelForm):
|
|||||||
"type",
|
"type",
|
||||||
"amount",
|
"amount",
|
||||||
"description",
|
"description",
|
||||||
|
"add_description_to_transaction",
|
||||||
"category",
|
"category",
|
||||||
"tags",
|
"tags",
|
||||||
"start_date",
|
"start_date",
|
||||||
@@ -790,7 +1046,9 @@ class RecurringTransactionForm(forms.ModelForm):
|
|||||||
"recurrence_type",
|
"recurrence_type",
|
||||||
"recurrence_interval",
|
"recurrence_interval",
|
||||||
"notes",
|
"notes",
|
||||||
|
"add_notes_to_transaction",
|
||||||
"entities",
|
"entities",
|
||||||
|
"keep_at_most",
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
"reference_date": AirMonthYearPickerInput(),
|
"reference_date": AirMonthYearPickerInput(),
|
||||||
@@ -850,6 +1108,7 @@ class RecurringTransactionForm(forms.ModelForm):
|
|||||||
css_class="form-row",
|
css_class="form-row",
|
||||||
),
|
),
|
||||||
"description",
|
"description",
|
||||||
|
Switch("add_description_to_transaction"),
|
||||||
"amount",
|
"amount",
|
||||||
Row(
|
Row(
|
||||||
Column("category", css_class="form-group col-md-6 mb-0"),
|
Column("category", css_class="form-group col-md-6 mb-0"),
|
||||||
@@ -857,6 +1116,7 @@ class RecurringTransactionForm(forms.ModelForm):
|
|||||||
css_class="form-row",
|
css_class="form-row",
|
||||||
),
|
),
|
||||||
"notes",
|
"notes",
|
||||||
|
Switch("add_notes_to_transaction"),
|
||||||
Row(
|
Row(
|
||||||
Column("start_date", css_class="form-group col-md-6 mb-0"),
|
Column("start_date", css_class="form-group col-md-6 mb-0"),
|
||||||
Column("reference_date", css_class="form-group col-md-6 mb-0"),
|
Column("reference_date", css_class="form-group col-md-6 mb-0"),
|
||||||
@@ -868,6 +1128,7 @@ class RecurringTransactionForm(forms.ModelForm):
|
|||||||
Column("end_date", css_class="form-group col-md-4 mb-0"),
|
Column("end_date", css_class="form-group col-md-4 mb-0"),
|
||||||
css_class="form-row",
|
css_class="form-row",
|
||||||
),
|
),
|
||||||
|
AppendedText("keep_at_most", _("future transactions")),
|
||||||
)
|
)
|
||||||
|
|
||||||
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||||
@@ -909,5 +1170,6 @@ class RecurringTransactionForm(forms.ModelForm):
|
|||||||
instance.create_upcoming_transactions()
|
instance.create_upcoming_transactions()
|
||||||
else:
|
else:
|
||||||
instance.update_unpaid_transactions()
|
instance.update_unpaid_transactions()
|
||||||
|
instance.generate_upcoming_transactions()
|
||||||
|
|
||||||
return instance
|
return instance
|
||||||
|
|||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 5.1.7 on 2025-03-09 20:22
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('transactions', '0040_alter_transaction_unique_together_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='installmentplan',
|
||||||
|
name='add_description_to_transaction',
|
||||||
|
field=models.BooleanField(default=True, verbose_name='Add description to transactions'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='installmentplan',
|
||||||
|
name='add_notes_to_transaction',
|
||||||
|
field=models.BooleanField(default=True, verbose_name='Add notes to transactions'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='recurringtransaction',
|
||||||
|
name='add_description_to_transaction',
|
||||||
|
field=models.BooleanField(default=True, verbose_name='Add description to transactions'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='recurringtransaction',
|
||||||
|
name='add_notes_to_transaction',
|
||||||
|
field=models.BooleanField(default=True, verbose_name='Add notes to transactions'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Generated by Django 5.1.7 on 2025-03-09 21:54
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('transactions', '0041_installmentplan_add_description_to_transaction_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='transactioncategory',
|
||||||
|
options={'ordering': ['name', 'id'], 'verbose_name': 'Transaction Category', 'verbose_name_plural': 'Transaction Categories'},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='transactionentity',
|
||||||
|
options={'ordering': ['name', 'id'], 'verbose_name': 'Entity', 'verbose_name_plural': 'Entities'},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='transactiontag',
|
||||||
|
options={'ordering': ['name', 'id'], 'verbose_name': 'Transaction Tags', 'verbose_name_plural': 'Transaction Tags'},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Generated by Django 5.1.11 on 2025-06-20 03:57
|
||||||
|
|
||||||
|
import apps.transactions.validators
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0014_alter_account_options_alter_accountgroup_options'),
|
||||||
|
('transactions', '0042_alter_transactioncategory_options_and_more'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='QuickTransaction',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=100, verbose_name='Name')),
|
||||||
|
('type', models.CharField(choices=[('IN', 'Income'), ('EX', 'Expense')], default='EX', max_length=2, verbose_name='Type')),
|
||||||
|
('is_paid', models.BooleanField(default=True, verbose_name='Paid')),
|
||||||
|
('amount', models.DecimalField(decimal_places=30, max_digits=42, validators=[apps.transactions.validators.validate_non_negative, apps.transactions.validators.validate_decimal_places], verbose_name='Amount')),
|
||||||
|
('description', models.CharField(blank=True, max_length=500, verbose_name='Description')),
|
||||||
|
('notes', models.TextField(blank=True, verbose_name='Notes')),
|
||||||
|
('internal_note', models.TextField(blank=True, verbose_name='Internal Note')),
|
||||||
|
('internal_id', models.TextField(blank=True, null=True, unique=True, verbose_name='Internal ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quick_transactions', to='accounts.account', verbose_name='Account')),
|
||||||
|
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='transactions.transactioncategory', verbose_name='Category')),
|
||||||
|
('entities', models.ManyToManyField(blank=True, related_name='quick_transactions', to='transactions.transactionentity', verbose_name='Entities')),
|
||||||
|
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('tags', models.ManyToManyField(blank=True, to='transactions.transactiontag', verbose_name='Tags')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Quick Transaction',
|
||||||
|
'verbose_name_plural': 'Quick Transactions',
|
||||||
|
'db_table': 'quick_transactions',
|
||||||
|
'default_manager_name': 'objects',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 5.1.11 on 2025-06-20 04:02
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('transactions', '0043_quicktransaction'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='quicktransaction',
|
||||||
|
unique_together={('name', 'owner')},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.1.11 on 2025-07-19 18:12
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('transactions', '0044_alter_quicktransaction_unique_together'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='transaction',
|
||||||
|
name='mute',
|
||||||
|
field=models.BooleanField(default=False, verbose_name='Mute'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.1.11 on 2025-07-19 18:59
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('transactions', '0045_transaction_mute'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='quicktransaction',
|
||||||
|
name='mute',
|
||||||
|
field=models.BooleanField(default=False, verbose_name='Mute'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Generated by Django 5.2.4 on 2025-07-28 02:15
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('transactions', '0046_quicktransaction_mute'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='transactioncategory',
|
||||||
|
name='owner',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='transactioncategory',
|
||||||
|
name='shared_with',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL, verbose_name='Shared with users'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='transactioncategory',
|
||||||
|
name='visibility',
|
||||||
|
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10, verbose_name='Visibility'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='transactionentity',
|
||||||
|
name='owner',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='transactionentity',
|
||||||
|
name='shared_with',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL, verbose_name='Shared with users'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='transactionentity',
|
||||||
|
name='visibility',
|
||||||
|
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10, verbose_name='Visibility'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='transactiontag',
|
||||||
|
name='owner',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='transactiontag',
|
||||||
|
name='shared_with',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL, verbose_name='Shared with users'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='transactiontag',
|
||||||
|
name='visibility',
|
||||||
|
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10, verbose_name='Visibility'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 5.2.4 on 2025-08-06 14:51
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('transactions', '0047_alter_transactioncategory_owner_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='recurringtransaction',
|
||||||
|
name='keep_at_most',
|
||||||
|
field=models.PositiveIntegerField(default=6, validators=[django.core.validators.MinValueValidator(1)], verbose_name='Keep at most'),
|
||||||
|
),
|
||||||
|
]
|
||||||
+263
-29
@@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -16,24 +17,30 @@ from apps.common.templatetags.decimal import localize_number, drop_trailing_zero
|
|||||||
from apps.currencies.utils.convert import convert
|
from apps.currencies.utils.convert import convert
|
||||||
from apps.transactions.validators import validate_decimal_places, validate_non_negative
|
from apps.transactions.validators import validate_decimal_places, validate_non_negative
|
||||||
from apps.common.middleware.thread_local import get_current_user
|
from apps.common.middleware.thread_local import get_current_user
|
||||||
from apps.common.models import SharedObject, SharedObjectManager, OwnedObject
|
from apps.common.models import (
|
||||||
|
SharedObject,
|
||||||
|
SharedObjectManager,
|
||||||
|
OwnedObject,
|
||||||
|
OwnedObjectManager,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger()
|
logger = logging.getLogger()
|
||||||
|
|
||||||
|
|
||||||
transaction_created = Signal()
|
transaction_created = Signal()
|
||||||
transaction_updated = Signal()
|
transaction_updated = Signal()
|
||||||
|
transaction_deleted = Signal()
|
||||||
|
|
||||||
|
|
||||||
class SoftDeleteQuerySet(models.QuerySet):
|
class SoftDeleteQuerySet(models.QuerySet):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _emit_signals(instances, created=False):
|
def _emit_signals(instances, created=False, old_data=None):
|
||||||
"""Helper to emit signals for multiple instances"""
|
"""Helper to emit signals for multiple instances"""
|
||||||
for instance in instances:
|
for i, instance in enumerate(instances):
|
||||||
if created:
|
if created:
|
||||||
transaction_created.send(sender=instance)
|
transaction_created.send(sender=instance)
|
||||||
else:
|
else:
|
||||||
transaction_updated.send(sender=instance)
|
transaction_updated.send(sender=instance, old_data=old_data[i])
|
||||||
|
|
||||||
def bulk_create(self, objs, emit_signal=True, **kwargs):
|
def bulk_create(self, objs, emit_signal=True, **kwargs):
|
||||||
instances = super().bulk_create(objs, **kwargs)
|
instances = super().bulk_create(objs, **kwargs)
|
||||||
@@ -44,29 +51,38 @@ class SoftDeleteQuerySet(models.QuerySet):
|
|||||||
return instances
|
return instances
|
||||||
|
|
||||||
def bulk_update(self, objs, fields, emit_signal=True, **kwargs):
|
def bulk_update(self, objs, fields, emit_signal=True, **kwargs):
|
||||||
|
old_data = deepcopy(objs)
|
||||||
result = super().bulk_update(objs, fields, **kwargs)
|
result = super().bulk_update(objs, fields, **kwargs)
|
||||||
|
|
||||||
if emit_signal:
|
if emit_signal:
|
||||||
self._emit_signals(objs, created=False)
|
self._emit_signals(objs, created=False, old_data=old_data)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def update(self, emit_signal=True, **kwargs):
|
def update(self, emit_signal=True, **kwargs):
|
||||||
# Get instances before update
|
# Get instances before update
|
||||||
instances = list(self)
|
instances = list(self)
|
||||||
|
old_data = deepcopy(instances)
|
||||||
|
|
||||||
result = super().update(**kwargs)
|
result = super().update(**kwargs)
|
||||||
|
|
||||||
if emit_signal:
|
if emit_signal:
|
||||||
# Refresh instances to get new values
|
# Refresh instances to get new values
|
||||||
refreshed = self.model.objects.filter(pk__in=[obj.pk for obj in instances])
|
refreshed = self.model.objects.filter(pk__in=[obj.pk for obj in instances])
|
||||||
self._emit_signals(refreshed, created=False)
|
self._emit_signals(refreshed, created=False, old_data=old_data)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def delete(self):
|
def delete(self):
|
||||||
if not settings.ENABLE_SOFT_DELETE:
|
if not settings.ENABLE_SOFT_DELETE:
|
||||||
# If soft deletion is disabled, perform a normal delete
|
# Get instances before hard delete
|
||||||
return super().delete()
|
instances = list(self)
|
||||||
|
# Send signals for each instance before deletion
|
||||||
|
for instance in instances:
|
||||||
|
transaction_deleted.send(sender=instance)
|
||||||
|
# Perform hard delete
|
||||||
|
result = super().delete()
|
||||||
|
return result
|
||||||
|
|
||||||
# Separate the queryset into already deleted and not deleted objects
|
# Separate the queryset into already deleted and not deleted objects
|
||||||
already_deleted = self.filter(deleted=True)
|
already_deleted = self.filter(deleted=True)
|
||||||
@@ -74,14 +90,28 @@ class SoftDeleteQuerySet(models.QuerySet):
|
|||||||
|
|
||||||
# Use a transaction to ensure atomicity
|
# Use a transaction to ensure atomicity
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
|
# Get instances for hard delete before they're gone
|
||||||
|
already_deleted_instances = list(already_deleted)
|
||||||
|
for instance in already_deleted_instances:
|
||||||
|
transaction_deleted.send(sender=instance)
|
||||||
|
|
||||||
# Perform hard delete on already deleted objects
|
# Perform hard delete on already deleted objects
|
||||||
hard_deleted_count = already_deleted._raw_delete(already_deleted.db)
|
hard_deleted_count = already_deleted._raw_delete(already_deleted.db)
|
||||||
|
|
||||||
|
# Get instances for soft delete
|
||||||
|
instances_to_soft_delete = list(not_deleted)
|
||||||
|
|
||||||
# Perform soft delete on not deleted objects
|
# Perform soft delete on not deleted objects
|
||||||
soft_deleted_count = not_deleted.update(
|
soft_deleted_count = not_deleted.update(
|
||||||
deleted=True, deleted_at=timezone.now()
|
deleted=True, deleted_at=timezone.now()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Send signals for soft deleted instances
|
||||||
|
for instance in instances_to_soft_delete:
|
||||||
|
instance.deleted = True
|
||||||
|
instance.deleted_at = timezone.now()
|
||||||
|
transaction_deleted.send(sender=instance)
|
||||||
|
|
||||||
# Return a tuple of counts as expected by Django's delete method
|
# Return a tuple of counts as expected by Django's delete method
|
||||||
return (
|
return (
|
||||||
hard_deleted_count + soft_deleted_count,
|
hard_deleted_count + soft_deleted_count,
|
||||||
@@ -97,13 +127,20 @@ class SoftDeleteManager(models.Manager):
|
|||||||
qs = SoftDeleteQuerySet(self.model, using=self._db)
|
qs = SoftDeleteQuerySet(self.model, using=self._db)
|
||||||
user = get_current_user()
|
user = get_current_user()
|
||||||
if user and not user.is_anonymous:
|
if user and not user.is_anonymous:
|
||||||
return qs.filter(
|
account_ids = (
|
||||||
|
qs.filter(
|
||||||
Q(account__visibility="public")
|
Q(account__visibility="public")
|
||||||
| Q(account__owner=user)
|
| Q(account__owner=user)
|
||||||
| Q(account__shared_with=user)
|
| Q(account__shared_with=user)
|
||||||
| Q(account__visibility="private", account__owner=None),
|
| Q(account__visibility="private", account__owner=None),
|
||||||
deleted=False,
|
deleted=False,
|
||||||
).distinct()
|
)
|
||||||
|
.values_list("account__id", flat=True)
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
return qs.filter(account_id__in=account_ids, deleted=False)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
return qs.filter(
|
return qs.filter(
|
||||||
deleted=False,
|
deleted=False,
|
||||||
@@ -192,6 +229,7 @@ class TransactionCategory(SharedObject):
|
|||||||
verbose_name_plural = _("Transaction Categories")
|
verbose_name_plural = _("Transaction Categories")
|
||||||
db_table = "t_categories"
|
db_table = "t_categories"
|
||||||
unique_together = (("owner", "name"),)
|
unique_together = (("owner", "name"),)
|
||||||
|
ordering = ["name", "id"]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
@@ -215,6 +253,7 @@ class TransactionTag(SharedObject):
|
|||||||
verbose_name_plural = _("Transaction Tags")
|
verbose_name_plural = _("Transaction Tags")
|
||||||
db_table = "tags"
|
db_table = "tags"
|
||||||
unique_together = (("owner", "name"),)
|
unique_together = (("owner", "name"),)
|
||||||
|
ordering = ["name", "id"]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
@@ -238,6 +277,7 @@ class TransactionEntity(SharedObject):
|
|||||||
verbose_name_plural = _("Entities")
|
verbose_name_plural = _("Entities")
|
||||||
db_table = "entities"
|
db_table = "entities"
|
||||||
unique_together = (("owner", "name"),)
|
unique_together = (("owner", "name"),)
|
||||||
|
ordering = ["name", "id"]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
@@ -263,6 +303,7 @@ class Transaction(OwnedObject):
|
|||||||
is_paid = models.BooleanField(default=True, verbose_name=_("Paid"))
|
is_paid = models.BooleanField(default=True, verbose_name=_("Paid"))
|
||||||
date = models.DateField(verbose_name=_("Date"))
|
date = models.DateField(verbose_name=_("Date"))
|
||||||
reference_date = MonthYearModelField(verbose_name=_("Reference Date"))
|
reference_date = MonthYearModelField(verbose_name=_("Reference Date"))
|
||||||
|
mute = models.BooleanField(default=False, verbose_name=_("Mute"))
|
||||||
|
|
||||||
amount = models.DecimalField(
|
amount = models.DecimalField(
|
||||||
max_digits=42,
|
max_digits=42,
|
||||||
@@ -339,7 +380,7 @@ class Transaction(OwnedObject):
|
|||||||
db_table = "transactions"
|
db_table = "transactions"
|
||||||
default_manager_name = "objects"
|
default_manager_name = "objects"
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def clean_fields(self, *args, **kwargs):
|
||||||
self.amount = truncate_decimal(
|
self.amount = truncate_decimal(
|
||||||
value=self.amount, decimal_places=self.account.currency.decimal_places
|
value=self.amount, decimal_places=self.account.currency.decimal_places
|
||||||
)
|
)
|
||||||
@@ -349,6 +390,11 @@ class Transaction(OwnedObject):
|
|||||||
elif not self.reference_date and self.date:
|
elif not self.reference_date and self.date:
|
||||||
self.reference_date = self.date.replace(day=1)
|
self.reference_date = self.date.replace(day=1)
|
||||||
|
|
||||||
|
super().clean_fields(*args, **kwargs)
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
# This is not recommended as it will run twice on some cases like form and API saves.
|
||||||
|
# We only do this here because we forgot to independently call it on multiple places.
|
||||||
self.full_clean()
|
self.full_clean()
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
@@ -358,10 +404,14 @@ class Transaction(OwnedObject):
|
|||||||
self.deleted = True
|
self.deleted = True
|
||||||
self.deleted_at = timezone.now()
|
self.deleted_at = timezone.now()
|
||||||
self.save()
|
self.save()
|
||||||
|
transaction_deleted.send(sender=self) # Emit signal for soft delete
|
||||||
else:
|
else:
|
||||||
super().delete(*args, **kwargs)
|
result = super().delete(*args, **kwargs)
|
||||||
|
return result
|
||||||
else:
|
else:
|
||||||
super().delete(*args, **kwargs)
|
# For hard delete mode
|
||||||
|
transaction_deleted.send(sender=self) # Emit signal before hard delete
|
||||||
|
return super().delete(*args, **kwargs)
|
||||||
|
|
||||||
def hard_delete(self, *args, **kwargs):
|
def hard_delete(self, *args, **kwargs):
|
||||||
super().delete(*args, **kwargs)
|
super().delete(*args, **kwargs)
|
||||||
@@ -402,12 +452,58 @@ class Transaction(OwnedObject):
|
|||||||
type_display = self.get_type_display()
|
type_display = self.get_type_display()
|
||||||
frmt_date = date(self.date, "SHORT_DATE_FORMAT")
|
frmt_date = date(self.date, "SHORT_DATE_FORMAT")
|
||||||
account = self.account
|
account = self.account
|
||||||
tags = ", ".join([x.name for x in self.tags.all()]) or _("No tags")
|
tags = (
|
||||||
|
", ".join([x.name for x in self.tags.all()])
|
||||||
|
if self.id
|
||||||
|
else None or _("No tags")
|
||||||
|
)
|
||||||
category = self.category or _("No category")
|
category = self.category or _("No category")
|
||||||
amount = localize_number(drop_trailing_zeros(self.amount))
|
amount = localize_number(drop_trailing_zeros(self.amount))
|
||||||
description = self.description or _("No description")
|
description = self.description or _("No description")
|
||||||
return f"[{frmt_date}][{type_display}][{account}] {description} • {category} • {tags} • {amount}"
|
return f"[{frmt_date}][{type_display}][{account}] {description} • {category} • {tags} • {amount}"
|
||||||
|
|
||||||
|
def deepcopy(self, memo=None):
|
||||||
|
"""
|
||||||
|
Creates a deep copy of the transaction instance.
|
||||||
|
|
||||||
|
This method returns a new, unsaved Transaction instance with the same
|
||||||
|
values as the original, including its many-to-many relationships.
|
||||||
|
The primary key and any other unique fields are reset to avoid
|
||||||
|
database integrity errors upon saving.
|
||||||
|
"""
|
||||||
|
if memo is None:
|
||||||
|
memo = {}
|
||||||
|
|
||||||
|
# Create a new instance of the class
|
||||||
|
new_obj = self.__class__()
|
||||||
|
memo[id(self)] = new_obj
|
||||||
|
|
||||||
|
# Copy all concrete fields from the original to the new object
|
||||||
|
for field in self._meta.concrete_fields:
|
||||||
|
# Skip the primary key to allow the database to generate a new one
|
||||||
|
if field.primary_key:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Reset any unique fields to None to avoid constraint violations
|
||||||
|
if field.unique and field.name == "internal_id":
|
||||||
|
setattr(new_obj, field.name, None)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Copy the value of the field
|
||||||
|
setattr(new_obj, field.name, getattr(self, field.name))
|
||||||
|
|
||||||
|
# Save the new object to the database to get a primary key
|
||||||
|
new_obj.save()
|
||||||
|
|
||||||
|
# Copy the many-to-many relationships
|
||||||
|
for field in self._meta.many_to_many:
|
||||||
|
source_manager = getattr(self, field.name)
|
||||||
|
destination_manager = getattr(new_obj, field.name)
|
||||||
|
# Set the M2M relationships for the new object
|
||||||
|
destination_manager.set(source_manager.all())
|
||||||
|
|
||||||
|
return new_obj
|
||||||
|
|
||||||
|
|
||||||
class InstallmentPlan(models.Model):
|
class InstallmentPlan(models.Model):
|
||||||
class Recurrence(models.TextChoices):
|
class Recurrence(models.TextChoices):
|
||||||
@@ -468,6 +564,13 @@ class InstallmentPlan(models.Model):
|
|||||||
|
|
||||||
notes = models.TextField(blank=True, verbose_name=_("Notes"))
|
notes = models.TextField(blank=True, verbose_name=_("Notes"))
|
||||||
|
|
||||||
|
add_description_to_transaction = models.BooleanField(
|
||||||
|
default=True, verbose_name=_("Add description to transactions")
|
||||||
|
)
|
||||||
|
add_notes_to_transaction = models.BooleanField(
|
||||||
|
default=True, verbose_name=_("Add notes to transactions")
|
||||||
|
)
|
||||||
|
|
||||||
all_objects = models.Manager() # Unfiltered manager
|
all_objects = models.Manager() # Unfiltered manager
|
||||||
objects = GenericAccountOwnerManager() # Default filtered manager
|
objects = GenericAccountOwnerManager() # Default filtered manager
|
||||||
|
|
||||||
@@ -532,11 +635,14 @@ class InstallmentPlan(models.Model):
|
|||||||
is_paid=False,
|
is_paid=False,
|
||||||
reference_date=transaction_reference_date,
|
reference_date=transaction_reference_date,
|
||||||
amount=self.installment_amount,
|
amount=self.installment_amount,
|
||||||
description=self.description,
|
description=(
|
||||||
|
self.description if self.add_description_to_transaction else ""
|
||||||
|
),
|
||||||
category=self.category,
|
category=self.category,
|
||||||
installment_plan=self,
|
installment_plan=self,
|
||||||
installment_id=i,
|
installment_id=i,
|
||||||
notes=self.notes,
|
notes=self.notes if self.add_notes_to_transaction else "",
|
||||||
|
owner=self.account.owner,
|
||||||
)
|
)
|
||||||
new_transaction.tags.set(self.tags.all())
|
new_transaction.tags.set(self.tags.all())
|
||||||
new_transaction.entities.set(self.entities.all())
|
new_transaction.entities.set(self.entities.all())
|
||||||
@@ -569,9 +675,13 @@ 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.description = self.description
|
existing_transaction.description = (
|
||||||
|
self.description if self.add_description_to_transaction else ""
|
||||||
|
)
|
||||||
existing_transaction.category = self.category
|
existing_transaction.category = self.category
|
||||||
existing_transaction.notes = self.notes
|
existing_transaction.notes = (
|
||||||
|
self.notes if self.add_notes_to_transaction else ""
|
||||||
|
)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
not existing_transaction.is_paid
|
not existing_transaction.is_paid
|
||||||
@@ -592,11 +702,14 @@ class InstallmentPlan(models.Model):
|
|||||||
is_paid=False,
|
is_paid=False,
|
||||||
reference_date=transaction_reference_date,
|
reference_date=transaction_reference_date,
|
||||||
amount=self.installment_amount,
|
amount=self.installment_amount,
|
||||||
description=self.description,
|
description=(
|
||||||
|
self.description if self.add_description_to_transaction else ""
|
||||||
|
),
|
||||||
category=self.category,
|
category=self.category,
|
||||||
installment_plan=self,
|
installment_plan=self,
|
||||||
installment_id=i,
|
installment_id=i,
|
||||||
notes=self.notes,
|
notes=self.notes if self.add_notes_to_transaction else "",
|
||||||
|
owner=self.account.owner,
|
||||||
)
|
)
|
||||||
new_transaction.tags.set(self.tags.all())
|
new_transaction.tags.set(self.tags.all())
|
||||||
new_transaction.entities.set(self.entities.all())
|
new_transaction.entities.set(self.entities.all())
|
||||||
@@ -664,6 +777,9 @@ class RecurringTransaction(models.Model):
|
|||||||
recurrence_interval = models.PositiveIntegerField(
|
recurrence_interval = models.PositiveIntegerField(
|
||||||
verbose_name=_("Recurrence Interval"),
|
verbose_name=_("Recurrence Interval"),
|
||||||
)
|
)
|
||||||
|
keep_at_most = models.PositiveIntegerField(
|
||||||
|
verbose_name=_("Keep at most"), default=6, validators=[MinValueValidator(1)]
|
||||||
|
)
|
||||||
|
|
||||||
last_generated_date = models.DateField(
|
last_generated_date = models.DateField(
|
||||||
verbose_name=_("Last Generated Date"), null=True, blank=True
|
verbose_name=_("Last Generated Date"), null=True, blank=True
|
||||||
@@ -672,6 +788,13 @@ class RecurringTransaction(models.Model):
|
|||||||
verbose_name=_("Last Generated Reference Date"), null=True, blank=True
|
verbose_name=_("Last Generated Reference Date"), null=True, blank=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
add_description_to_transaction = models.BooleanField(
|
||||||
|
default=True, verbose_name=_("Add description to transactions")
|
||||||
|
)
|
||||||
|
add_notes_to_transaction = models.BooleanField(
|
||||||
|
default=True, verbose_name=_("Add notes to transactions")
|
||||||
|
)
|
||||||
|
|
||||||
all_objects = models.Manager() # Unfiltered manager
|
all_objects = models.Manager() # Unfiltered manager
|
||||||
objects = GenericAccountOwnerManager() # Default filtered manager
|
objects = GenericAccountOwnerManager() # Default filtered manager
|
||||||
|
|
||||||
@@ -694,8 +817,10 @@ class RecurringTransaction(models.Model):
|
|||||||
current_date = self.start_date
|
current_date = self.start_date
|
||||||
reference_date = self.reference_date
|
reference_date = self.reference_date
|
||||||
end_date = min(
|
end_date = min(
|
||||||
self.end_date or timezone.now().date() + (self.get_recurrence_delta() * 5),
|
self.end_date
|
||||||
timezone.now().date() + (self.get_recurrence_delta() * 5),
|
or timezone.now().date()
|
||||||
|
+ (self.get_recurrence_delta() * self.keep_at_most),
|
||||||
|
timezone.now().date() + (self.get_recurrence_delta() * self.keep_at_most),
|
||||||
)
|
)
|
||||||
|
|
||||||
while current_date <= end_date:
|
while current_date <= end_date:
|
||||||
@@ -718,11 +843,14 @@ class RecurringTransaction(models.Model):
|
|||||||
date=date,
|
date=date,
|
||||||
reference_date=reference_date.replace(day=1),
|
reference_date=reference_date.replace(day=1),
|
||||||
amount=self.amount,
|
amount=self.amount,
|
||||||
description=self.description,
|
description=(
|
||||||
|
self.description if self.add_description_to_transaction else ""
|
||||||
|
),
|
||||||
category=self.category,
|
category=self.category,
|
||||||
is_paid=False,
|
is_paid=False,
|
||||||
recurring_transaction=self,
|
recurring_transaction=self,
|
||||||
notes=self.notes,
|
notes=self.notes if self.add_notes_to_transaction else "",
|
||||||
|
owner=self.account.owner,
|
||||||
)
|
)
|
||||||
if self.tags.exists():
|
if self.tags.exists():
|
||||||
created_transaction.tags.set(self.tags.all())
|
created_transaction.tags.set(self.tags.all())
|
||||||
@@ -745,12 +873,16 @@ class RecurringTransaction(models.Model):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def generate_upcoming_transactions(cls):
|
def generate_upcoming_transactions(cls):
|
||||||
today = timezone.now().date()
|
today = timezone.now().date()
|
||||||
recurring_transactions = cls.objects.filter(
|
recurring_transactions = cls.all_objects.filter(
|
||||||
Q(models.Q(end_date__isnull=True) | Q(end_date__gte=today))
|
Q(models.Q(end_date__isnull=True) | Q(end_date__gte=today))
|
||||||
& Q(is_paused=False)
|
& Q(is_paused=False)
|
||||||
)
|
)
|
||||||
|
|
||||||
for recurring_transaction in recurring_transactions:
|
for recurring_transaction in recurring_transactions:
|
||||||
|
logger.info(
|
||||||
|
f"Processing recurring transaction: {recurring_transaction.description}..."
|
||||||
|
)
|
||||||
|
|
||||||
if recurring_transaction.last_generated_date:
|
if recurring_transaction.last_generated_date:
|
||||||
start_date = recurring_transaction.get_next_date(
|
start_date = recurring_transaction.get_next_date(
|
||||||
recurring_transaction.last_generated_date
|
recurring_transaction.last_generated_date
|
||||||
@@ -765,11 +897,22 @@ class RecurringTransaction(models.Model):
|
|||||||
current_date = start_date
|
current_date = start_date
|
||||||
end_date = min(
|
end_date = min(
|
||||||
recurring_transaction.end_date
|
recurring_transaction.end_date
|
||||||
or today + (recurring_transaction.get_recurrence_delta() * 6),
|
or today
|
||||||
today + (recurring_transaction.get_recurrence_delta() * 6),
|
+ (
|
||||||
|
recurring_transaction.get_recurrence_delta()
|
||||||
|
* recurring_transaction.keep_at_most
|
||||||
|
),
|
||||||
|
today
|
||||||
|
+ (
|
||||||
|
recurring_transaction.get_recurrence_delta()
|
||||||
|
* recurring_transaction.keep_at_most
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.info(f"End date: {end_date}")
|
||||||
|
|
||||||
while current_date <= end_date:
|
while current_date <= end_date:
|
||||||
|
logger.info(f"Creating transaction for date: {current_date}")
|
||||||
recurring_transaction.create_transaction(current_date, reference_date)
|
recurring_transaction.create_transaction(current_date, reference_date)
|
||||||
current_date = recurring_transaction.get_next_date(current_date)
|
current_date = recurring_transaction.get_next_date(current_date)
|
||||||
reference_date = recurring_transaction.get_next_date(reference_date)
|
reference_date = recurring_transaction.get_next_date(reference_date)
|
||||||
@@ -796,9 +939,13 @@ class RecurringTransaction(models.Model):
|
|||||||
for existing_transaction in unpaid_transactions:
|
for existing_transaction in unpaid_transactions:
|
||||||
# Update fields based on RecurringTransaction
|
# Update fields based on RecurringTransaction
|
||||||
existing_transaction.amount = self.amount
|
existing_transaction.amount = self.amount
|
||||||
existing_transaction.description = self.description
|
existing_transaction.description = (
|
||||||
|
self.description if self.add_description_to_transaction else ""
|
||||||
|
)
|
||||||
existing_transaction.category = self.category
|
existing_transaction.category = self.category
|
||||||
existing_transaction.notes = self.notes
|
existing_transaction.notes = (
|
||||||
|
self.notes if self.add_notes_to_transaction else ""
|
||||||
|
)
|
||||||
|
|
||||||
# Update many-to-many relationships
|
# Update many-to-many relationships
|
||||||
existing_transaction.tags.set(self.tags.all())
|
existing_transaction.tags.set(self.tags.all())
|
||||||
@@ -813,3 +960,90 @@ class RecurringTransaction(models.Model):
|
|||||||
"""
|
"""
|
||||||
today = timezone.localdate(timezone.now())
|
today = timezone.localdate(timezone.now())
|
||||||
self.transactions.filter(is_paid=False, date__gt=today).delete()
|
self.transactions.filter(is_paid=False, date__gt=today).delete()
|
||||||
|
|
||||||
|
|
||||||
|
class QuickTransaction(OwnedObject):
|
||||||
|
class Type(models.TextChoices):
|
||||||
|
INCOME = "IN", _("Income")
|
||||||
|
EXPENSE = "EX", _("Expense")
|
||||||
|
|
||||||
|
name = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
null=False,
|
||||||
|
blank=False,
|
||||||
|
verbose_name=_("Name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
account = models.ForeignKey(
|
||||||
|
"accounts.Account",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
verbose_name=_("Account"),
|
||||||
|
related_name="quick_transactions",
|
||||||
|
)
|
||||||
|
type = models.CharField(
|
||||||
|
max_length=2,
|
||||||
|
choices=Type,
|
||||||
|
default=Type.EXPENSE,
|
||||||
|
verbose_name=_("Type"),
|
||||||
|
)
|
||||||
|
is_paid = models.BooleanField(default=True, verbose_name=_("Paid"))
|
||||||
|
mute = models.BooleanField(default=False, verbose_name=_("Mute"))
|
||||||
|
|
||||||
|
amount = models.DecimalField(
|
||||||
|
max_digits=42,
|
||||||
|
decimal_places=30,
|
||||||
|
verbose_name=_("Amount"),
|
||||||
|
validators=[validate_non_negative, validate_decimal_places],
|
||||||
|
)
|
||||||
|
|
||||||
|
description = models.CharField(
|
||||||
|
max_length=500, verbose_name=_("Description"), blank=True
|
||||||
|
)
|
||||||
|
notes = models.TextField(blank=True, verbose_name=_("Notes"))
|
||||||
|
category = models.ForeignKey(
|
||||||
|
TransactionCategory,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
verbose_name=_("Category"),
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
)
|
||||||
|
tags = models.ManyToManyField(
|
||||||
|
TransactionTag,
|
||||||
|
verbose_name=_("Tags"),
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
entities = models.ManyToManyField(
|
||||||
|
TransactionEntity,
|
||||||
|
verbose_name=_("Entities"),
|
||||||
|
blank=True,
|
||||||
|
related_name="quick_transactions",
|
||||||
|
)
|
||||||
|
|
||||||
|
internal_note = models.TextField(blank=True, verbose_name=_("Internal Note"))
|
||||||
|
internal_id = models.TextField(
|
||||||
|
blank=True, null=True, unique=True, verbose_name=_("Internal ID")
|
||||||
|
)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
objects = OwnedObjectManager()
|
||||||
|
all_objects = models.Manager() # Unfiltered manager
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("Quick Transaction")
|
||||||
|
verbose_name_plural = _("Quick Transactions")
|
||||||
|
unique_together = ("name", "owner")
|
||||||
|
db_table = "quick_transactions"
|
||||||
|
default_manager_name = "objects"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
self.amount = truncate_decimal(
|
||||||
|
value=self.amount, decimal_places=self.account.currency.decimal_places
|
||||||
|
)
|
||||||
|
|
||||||
|
self.full_clean()
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|||||||
@@ -28,24 +28,19 @@ def generate_recurring_transactions(timestamp=None):
|
|||||||
@app.periodic(cron="10 1 * * *")
|
@app.periodic(cron="10 1 * * *")
|
||||||
@app.task(name="cleanup_deleted_transactions")
|
@app.task(name="cleanup_deleted_transactions")
|
||||||
def cleanup_deleted_transactions(timestamp=None):
|
def cleanup_deleted_transactions(timestamp=None):
|
||||||
with cachalot_disabled():
|
|
||||||
if settings.ENABLE_SOFT_DELETE and settings.KEEP_DELETED_TRANSACTIONS_FOR == 0:
|
if settings.ENABLE_SOFT_DELETE and settings.KEEP_DELETED_TRANSACTIONS_FOR == 0:
|
||||||
return "KEEP_DELETED_TRANSACTIONS_FOR is 0, no cleanup performed."
|
return "KEEP_DELETED_TRANSACTIONS_FOR is 0, no cleanup performed."
|
||||||
|
|
||||||
if not settings.ENABLE_SOFT_DELETE:
|
if not settings.ENABLE_SOFT_DELETE:
|
||||||
# Hard delete all soft-deleted transactions
|
# Hard delete all soft-deleted transactions
|
||||||
deleted_count, _ = Transaction.userless_deleted_objects.all().hard_delete()
|
deleted_count, _ = Transaction.userless_deleted_objects.all().hard_delete()
|
||||||
return (
|
return f"Hard deleted {deleted_count} transactions (soft deletion disabled)."
|
||||||
f"Hard deleted {deleted_count} transactions (soft deletion disabled)."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Calculate the cutoff date
|
# Calculate the cutoff date
|
||||||
cutoff_date = timezone.now() - timedelta(
|
cutoff_date = timezone.now() - timedelta(
|
||||||
days=settings.KEEP_DELETED_TRANSACTIONS_FOR
|
days=settings.KEEP_DELETED_TRANSACTIONS_FOR
|
||||||
)
|
)
|
||||||
|
|
||||||
invalidate()
|
|
||||||
|
|
||||||
# Hard delete soft-deleted transactions older than the cutoff date
|
# Hard delete soft-deleted transactions older than the cutoff date
|
||||||
old_transactions = Transaction.userless_deleted_objects.filter(
|
old_transactions = Transaction.userless_deleted_objects.filter(
|
||||||
deleted_at__lt=cutoff_date
|
deleted_at__lt=cutoff_date
|
||||||
|
|||||||
@@ -175,6 +175,6 @@ class RecurringTransactionTests(TestCase):
|
|||||||
recurrence_type=RecurringTransaction.RecurrenceType.MONTH,
|
recurrence_type=RecurringTransaction.RecurrenceType.MONTH,
|
||||||
recurrence_interval=1,
|
recurrence_interval=1,
|
||||||
)
|
)
|
||||||
self.assertFalse(recurring.paused)
|
self.assertFalse(recurring.is_paused)
|
||||||
self.assertEqual(recurring.recurrence_interval, 1)
|
self.assertEqual(recurring.recurrence_interval, 1)
|
||||||
self.assertEqual(recurring.account.currency.code, "USD")
|
self.assertEqual(recurring.account.currency.code, "USD")
|
||||||
|
|||||||
@@ -66,6 +66,21 @@ urlpatterns = [
|
|||||||
views.transaction_pay,
|
views.transaction_pay,
|
||||||
name="transaction_pay",
|
name="transaction_pay",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"transaction/<int:transaction_id>/mute/",
|
||||||
|
views.transaction_mute,
|
||||||
|
name="transaction_mute",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"transaction/<int:transaction_id>/change-month/<str:change_type>/",
|
||||||
|
views.transaction_change_month,
|
||||||
|
name="transaction_change_month",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"transaction/<int:transaction_id>/move-to-today/",
|
||||||
|
views.transaction_move_to_today,
|
||||||
|
name="transaction_move_to_today",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"transaction/<int:transaction_id>/delete/",
|
"transaction/<int:transaction_id>/delete/",
|
||||||
views.transaction_delete,
|
views.transaction_delete,
|
||||||
@@ -307,4 +322,44 @@ urlpatterns = [
|
|||||||
views.recurring_transaction_finish,
|
views.recurring_transaction_finish,
|
||||||
name="recurring_transaction_finish",
|
name="recurring_transaction_finish",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"quick-transactions/",
|
||||||
|
views.quick_transactions_index,
|
||||||
|
name="quick_transactions_index",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"quick-transactions/list/",
|
||||||
|
views.quick_transactions_list,
|
||||||
|
name="quick_transactions_list",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"quick-transactions/add/",
|
||||||
|
views.quick_transaction_add,
|
||||||
|
name="quick_transaction_add",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"quick-transactions/<int:quick_transaction_id>/edit/",
|
||||||
|
views.quick_transaction_edit,
|
||||||
|
name="quick_transaction_edit",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"quick-transactions/<int:quick_transaction_id>/delete/",
|
||||||
|
views.quick_transaction_delete,
|
||||||
|
name="quick_transaction_delete",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"quick-transactions/create-menu/",
|
||||||
|
views.quick_transactions_create_menu,
|
||||||
|
name="quick_transactions_create_menu",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"quick-transactions/<int:quick_transaction_id>/create/",
|
||||||
|
views.quick_transaction_add_as_transaction,
|
||||||
|
name="quick_transaction_add_as_transaction",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"transactions/<int:transaction_id>/add-as-quick-transaction/",
|
||||||
|
views.quick_transaction_add_as_quick_transaction,
|
||||||
|
name="quick_transaction_add_as_quick_transaction",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ from apps.currencies.utils.convert import convert
|
|||||||
from apps.currencies.models import Currency
|
from apps.currencies.models import Currency
|
||||||
|
|
||||||
|
|
||||||
def calculate_currency_totals(transactions_queryset, ignore_empty=False):
|
def calculate_currency_totals(
|
||||||
|
transactions_queryset, ignore_empty=False, deep_search=False
|
||||||
|
):
|
||||||
# Prepare the aggregation expressions
|
# Prepare the aggregation expressions
|
||||||
currency_totals = (
|
currency_totals_from_transactions = (
|
||||||
transactions_queryset.values(
|
transactions_queryset.values(
|
||||||
"account__currency",
|
"account__currency",
|
||||||
"account__currency__code",
|
"account__currency__code",
|
||||||
@@ -19,7 +21,14 @@ def calculate_currency_totals(transactions_queryset, ignore_empty=False):
|
|||||||
"account__currency__decimal_places",
|
"account__currency__decimal_places",
|
||||||
"account__currency__prefix",
|
"account__currency__prefix",
|
||||||
"account__currency__suffix",
|
"account__currency__suffix",
|
||||||
"account__currency__exchange_currency",
|
"account__currency__exchange_currency", # ID of the exchange currency for the account's currency
|
||||||
|
# Fields for the exchange currency itself (if account.currency.exchange_currency is set)
|
||||||
|
# These might be null if not set, so handle appropriately.
|
||||||
|
"account__currency__exchange_currency__code",
|
||||||
|
"account__currency__exchange_currency__name",
|
||||||
|
"account__currency__exchange_currency__decimal_places",
|
||||||
|
"account__currency__exchange_currency__prefix",
|
||||||
|
"account__currency__exchange_currency__suffix",
|
||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
expense_current=Coalesce(
|
expense_current=Coalesce(
|
||||||
@@ -72,15 +81,21 @@ def calculate_currency_totals(transactions_queryset, ignore_empty=False):
|
|||||||
.order_by()
|
.order_by()
|
||||||
)
|
)
|
||||||
|
|
||||||
# First pass: Process basic totals and store all currency data
|
|
||||||
result = {}
|
result = {}
|
||||||
currencies_using_exchange = (
|
# currencies_using_exchange maps:
|
||||||
{}
|
# exchange_currency_id -> list of [
|
||||||
) # Track which currencies use which exchange currencies
|
# { "currency_id": original_currency_id, (the currency that was exchanged FROM)
|
||||||
|
# "exchanged": { field: amount_in_exchange_currency, ... } (the values of original_currency_id converted TO exchange_currency_id)
|
||||||
|
# }
|
||||||
|
# ]
|
||||||
|
currencies_using_exchange = {}
|
||||||
|
|
||||||
for total in currency_totals:
|
# --- First Pass: Process transactions from the queryset ---
|
||||||
# Skip empty currencies if ignore_empty is True
|
for total in currency_totals_from_transactions:
|
||||||
if ignore_empty and all(
|
if (
|
||||||
|
ignore_empty
|
||||||
|
and not deep_search
|
||||||
|
and all(
|
||||||
total[field] == Decimal("0")
|
total[field] == Decimal("0")
|
||||||
for field in [
|
for field in [
|
||||||
"expense_current",
|
"expense_current",
|
||||||
@@ -88,20 +103,33 @@ def calculate_currency_totals(transactions_queryset, ignore_empty=False):
|
|||||||
"income_current",
|
"income_current",
|
||||||
"income_projected",
|
"income_projected",
|
||||||
]
|
]
|
||||||
|
)
|
||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Calculate derived totals
|
currency_id = total["account__currency"]
|
||||||
|
try:
|
||||||
|
from_currency_obj = Currency.objects.get(id=currency_id)
|
||||||
|
except Currency.DoesNotExist:
|
||||||
|
# This should ideally not happen if database is consistent
|
||||||
|
continue
|
||||||
|
|
||||||
|
exchange_currency_for_this_total_id = total[
|
||||||
|
"account__currency__exchange_currency"
|
||||||
|
]
|
||||||
|
exchange_currency_obj_for_this_total = None
|
||||||
|
if exchange_currency_for_this_total_id:
|
||||||
|
try:
|
||||||
|
# Use pre-fetched values if available, otherwise query
|
||||||
|
exchange_currency_obj_for_this_total = Currency.objects.get(
|
||||||
|
id=exchange_currency_for_this_total_id
|
||||||
|
)
|
||||||
|
except Currency.DoesNotExist:
|
||||||
|
pass # Exchange currency might not exist or be set
|
||||||
|
|
||||||
total_current = total["income_current"] - total["expense_current"]
|
total_current = total["income_current"] - total["expense_current"]
|
||||||
total_projected = total["income_projected"] - total["expense_projected"]
|
total_projected = total["income_projected"] - total["expense_projected"]
|
||||||
total_final = total_current + total_projected
|
total_final = total_current + total_projected
|
||||||
currency_id = total["account__currency"]
|
|
||||||
from_currency = Currency.objects.get(id=currency_id)
|
|
||||||
exchange_currency = (
|
|
||||||
Currency.objects.get(id=total["account__currency__exchange_currency"])
|
|
||||||
if total["account__currency__exchange_currency"]
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
currency_data = {
|
currency_data = {
|
||||||
"currency": {
|
"currency": {
|
||||||
@@ -120,9 +148,16 @@ def calculate_currency_totals(transactions_queryset, ignore_empty=False):
|
|||||||
"total_final": total_final,
|
"total_final": total_final,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add exchanged values if exchange_currency exists
|
if exchange_currency_obj_for_this_total:
|
||||||
if exchange_currency:
|
exchanged_details = {
|
||||||
exchanged = {}
|
"currency": {
|
||||||
|
"code": exchange_currency_obj_for_this_total.code,
|
||||||
|
"name": exchange_currency_obj_for_this_total.name,
|
||||||
|
"decimal_places": exchange_currency_obj_for_this_total.decimal_places,
|
||||||
|
"prefix": exchange_currency_obj_for_this_total.prefix,
|
||||||
|
"suffix": exchange_currency_obj_for_this_total.suffix,
|
||||||
|
}
|
||||||
|
}
|
||||||
for field in [
|
for field in [
|
||||||
"expense_current",
|
"expense_current",
|
||||||
"expense_projected",
|
"expense_projected",
|
||||||
@@ -132,50 +167,78 @@ def calculate_currency_totals(transactions_queryset, ignore_empty=False):
|
|||||||
"total_projected",
|
"total_projected",
|
||||||
"total_final",
|
"total_final",
|
||||||
]:
|
]:
|
||||||
amount, prefix, suffix, decimal_places = convert(
|
amount_to_convert = currency_data[field]
|
||||||
amount=currency_data[field],
|
converted_val, _, _, _ = convert(
|
||||||
from_currency=from_currency,
|
amount=amount_to_convert,
|
||||||
to_currency=exchange_currency,
|
from_currency=from_currency_obj,
|
||||||
|
to_currency=exchange_currency_obj_for_this_total,
|
||||||
|
)
|
||||||
|
exchanged_details[field] = (
|
||||||
|
converted_val if converted_val is not None else Decimal("0")
|
||||||
)
|
)
|
||||||
if amount is not None:
|
|
||||||
exchanged[field] = amount
|
|
||||||
if "currency" not in exchanged:
|
|
||||||
exchanged["currency"] = {
|
|
||||||
"prefix": prefix,
|
|
||||||
"suffix": suffix,
|
|
||||||
"decimal_places": decimal_places,
|
|
||||||
"code": exchange_currency.code,
|
|
||||||
"name": exchange_currency.name,
|
|
||||||
}
|
|
||||||
|
|
||||||
if exchanged:
|
currency_data["exchanged"] = exchanged_details
|
||||||
currency_data["exchanged"] = exchanged
|
|
||||||
# Track which currencies are using which exchange currencies
|
if exchange_currency_obj_for_this_total.id not in currencies_using_exchange:
|
||||||
if exchange_currency.id not in currencies_using_exchange:
|
currencies_using_exchange[exchange_currency_obj_for_this_total.id] = []
|
||||||
currencies_using_exchange[exchange_currency.id] = []
|
currencies_using_exchange[exchange_currency_obj_for_this_total.id].append(
|
||||||
currencies_using_exchange[exchange_currency.id].append(
|
{"currency_id": currency_id, "exchanged": exchanged_details}
|
||||||
{"currency_id": currency_id, "exchanged": exchanged}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
result[currency_id] = currency_data
|
result[currency_id] = currency_data
|
||||||
|
|
||||||
# Second pass: Add consolidated totals for currencies that are used as exchange currencies
|
# --- Deep Search: Add transaction-less currencies that are exchange targets ---
|
||||||
for currency_id, currency_data in result.items():
|
if deep_search:
|
||||||
if currency_id in currencies_using_exchange:
|
# Iteratively add exchange targets that might not have had direct transactions
|
||||||
consolidated = {
|
# Start with known exchange targets from the first pass
|
||||||
"currency": currency_data["currency"].copy(),
|
queue = list(currencies_using_exchange.keys())
|
||||||
"expense_current": currency_data["expense_current"],
|
processed_for_deep_add = set(
|
||||||
"expense_projected": currency_data["expense_projected"],
|
result.keys()
|
||||||
"income_current": currency_data["income_current"],
|
) # Track currencies already in result or added by this deep search step
|
||||||
"income_projected": currency_data["income_projected"],
|
|
||||||
"total_current": currency_data["total_current"],
|
while queue:
|
||||||
"total_projected": currency_data["total_projected"],
|
target_id = queue.pop(0)
|
||||||
"total_final": currency_data["total_final"],
|
if target_id in processed_for_deep_add:
|
||||||
|
continue
|
||||||
|
processed_for_deep_add.add(target_id)
|
||||||
|
|
||||||
|
if (
|
||||||
|
target_id not in result
|
||||||
|
): # If this exchange target had no direct transactions
|
||||||
|
try:
|
||||||
|
db_currency = Currency.objects.get(id=target_id)
|
||||||
|
except Currency.DoesNotExist:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Initialize data for this transaction-less exchange target currency
|
||||||
|
currency_data_for_db_currency = {
|
||||||
|
"currency": {
|
||||||
|
"code": db_currency.code,
|
||||||
|
"name": db_currency.name,
|
||||||
|
"decimal_places": db_currency.decimal_places,
|
||||||
|
"prefix": db_currency.prefix,
|
||||||
|
"suffix": db_currency.suffix,
|
||||||
|
},
|
||||||
|
"expense_current": Decimal("0"),
|
||||||
|
"expense_projected": Decimal("0"),
|
||||||
|
"income_current": Decimal("0"),
|
||||||
|
"income_projected": Decimal("0"),
|
||||||
|
"total_current": Decimal("0"),
|
||||||
|
"total_projected": Decimal("0"),
|
||||||
|
"total_final": Decimal("0"),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add exchanged values from all currencies using this as exchange currency
|
# If this newly added transaction-less currency ALSO has an exchange_currency set for itself
|
||||||
for using_currency in currencies_using_exchange[currency_id]:
|
if db_currency.exchange_currency:
|
||||||
exchanged = using_currency["exchanged"]
|
exchanged_details_for_db_currency = {
|
||||||
|
"currency": {
|
||||||
|
"code": db_currency.exchange_currency.code,
|
||||||
|
"name": db_currency.exchange_currency.name,
|
||||||
|
"decimal_places": db_currency.exchange_currency.decimal_places,
|
||||||
|
"prefix": db_currency.exchange_currency.prefix,
|
||||||
|
"suffix": db_currency.exchange_currency.suffix,
|
||||||
|
}
|
||||||
|
}
|
||||||
for field in [
|
for field in [
|
||||||
"expense_current",
|
"expense_current",
|
||||||
"expense_projected",
|
"expense_projected",
|
||||||
@@ -185,10 +248,89 @@ def calculate_currency_totals(transactions_queryset, ignore_empty=False):
|
|||||||
"total_projected",
|
"total_projected",
|
||||||
"total_final",
|
"total_final",
|
||||||
]:
|
]:
|
||||||
if field in exchanged:
|
converted_val, _, _, _ = convert(
|
||||||
consolidated[field] += exchanged[field]
|
Decimal("0"), db_currency, db_currency.exchange_currency
|
||||||
|
)
|
||||||
|
exchanged_details_for_db_currency[field] = (
|
||||||
|
converted_val if converted_val is not None else Decimal("0")
|
||||||
|
)
|
||||||
|
|
||||||
result[currency_id]["consolidated"] = consolidated
|
currency_data_for_db_currency["exchanged"] = (
|
||||||
|
exchanged_details_for_db_currency
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure its own exchange_currency is registered in currencies_using_exchange
|
||||||
|
# and add it to the queue if it hasn't been processed yet for deep add.
|
||||||
|
target_id_for_this_db_curr = db_currency.exchange_currency.id
|
||||||
|
if target_id_for_this_db_curr not in currencies_using_exchange:
|
||||||
|
currencies_using_exchange[target_id_for_this_db_curr] = []
|
||||||
|
|
||||||
|
# Avoid adding duplicate entries
|
||||||
|
already_present_in_cue = any(
|
||||||
|
entry["currency_id"] == db_currency.id
|
||||||
|
for entry in currencies_using_exchange[
|
||||||
|
target_id_for_this_db_curr
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if not already_present_in_cue:
|
||||||
|
currencies_using_exchange[target_id_for_this_db_curr].append(
|
||||||
|
{
|
||||||
|
"currency_id": db_currency.id,
|
||||||
|
"exchanged": exchanged_details_for_db_currency,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if target_id_for_this_db_curr not in processed_for_deep_add:
|
||||||
|
queue.append(target_id_for_this_db_curr)
|
||||||
|
|
||||||
|
result[db_currency.id] = currency_data_for_db_currency
|
||||||
|
|
||||||
|
# --- Second Pass: Calculate consolidated totals for all currencies in result ---
|
||||||
|
for currency_id_consolidated, data_consolidated_currency in result.items():
|
||||||
|
consolidated_data = {
|
||||||
|
"currency": data_consolidated_currency["currency"].copy(),
|
||||||
|
"expense_current": data_consolidated_currency["expense_current"],
|
||||||
|
"expense_projected": data_consolidated_currency["expense_projected"],
|
||||||
|
"income_current": data_consolidated_currency["income_current"],
|
||||||
|
"income_projected": data_consolidated_currency["income_projected"],
|
||||||
|
"total_current": data_consolidated_currency["total_current"],
|
||||||
|
"total_projected": data_consolidated_currency["total_projected"],
|
||||||
|
"total_final": data_consolidated_currency["total_final"],
|
||||||
|
}
|
||||||
|
|
||||||
|
if currency_id_consolidated in currencies_using_exchange:
|
||||||
|
for original_currency_info in currencies_using_exchange[
|
||||||
|
currency_id_consolidated
|
||||||
|
]:
|
||||||
|
exchanged_values_from_original = original_currency_info["exchanged"]
|
||||||
|
for field in [
|
||||||
|
"expense_current",
|
||||||
|
"expense_projected",
|
||||||
|
"income_current",
|
||||||
|
"income_projected",
|
||||||
|
"total_current",
|
||||||
|
"total_projected",
|
||||||
|
"total_final",
|
||||||
|
]:
|
||||||
|
if field in exchanged_values_from_original:
|
||||||
|
consolidated_data[field] += exchanged_values_from_original[
|
||||||
|
field
|
||||||
|
]
|
||||||
|
|
||||||
|
result[currency_id_consolidated]["consolidated"] = consolidated_data
|
||||||
|
|
||||||
|
# Sort currencies by their final_total or consolidated final_total, descending
|
||||||
|
result = {
|
||||||
|
k: v
|
||||||
|
for k, v in sorted(
|
||||||
|
result.items(),
|
||||||
|
reverse=True,
|
||||||
|
key=lambda item: max(
|
||||||
|
item[1].get("total_final", Decimal("0")),
|
||||||
|
item[1].get("consolidated", {}).get("total_final", Decimal("0")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ from .categories import *
|
|||||||
from .actions import *
|
from .actions import *
|
||||||
from .installment_plans import *
|
from .installment_plans import *
|
||||||
from .recurring_transactions import *
|
from .recurring_transactions import *
|
||||||
|
from .quick_transactions import *
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ 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", [])
|
||||||
transactions = Transaction.all_objects.filter(id__in=selected_transactions)
|
transactions = Transaction.objects.filter(id__in=selected_transactions)
|
||||||
count = transactions.count()
|
count = transactions.count()
|
||||||
transactions.delete()
|
transactions.delete()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.forms import model_to_dict
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.shortcuts import render, get_object_or_404
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
|
from apps.common.decorators.htmx import only_htmx
|
||||||
|
from apps.transactions.forms import QuickTransactionForm
|
||||||
|
from apps.transactions.models import QuickTransaction, transaction_created
|
||||||
|
from apps.transactions.models import Transaction
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def quick_transactions_index(request):
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"quick_transactions/pages/index.html",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def quick_transactions_list(request):
|
||||||
|
quick_transactions = QuickTransaction.objects.all().order_by("name")
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"quick_transactions/fragments/list.html",
|
||||||
|
context={"quick_transactions": quick_transactions},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def quick_transaction_add(request):
|
||||||
|
if request.method == "POST":
|
||||||
|
form = QuickTransactionForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
messages.success(request, _("Item added successfully"))
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
headers={
|
||||||
|
"HX-Trigger": "updated, hide_offcanvas",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
form = QuickTransactionForm()
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"quick_transactions/fragments/add.html",
|
||||||
|
{"form": form},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def quick_transaction_edit(request, quick_transaction_id):
|
||||||
|
quick_transaction = get_object_or_404(QuickTransaction, id=quick_transaction_id)
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
form = QuickTransactionForm(request.POST, instance=quick_transaction)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
messages.success(request, _("Item updated successfully"))
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
headers={
|
||||||
|
"HX-Trigger": "updated, hide_offcanvas",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
form = QuickTransactionForm(instance=quick_transaction)
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"quick_transactions/fragments/edit.html",
|
||||||
|
{"form": form, "quick_transaction": quick_transaction},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["DELETE"])
|
||||||
|
def quick_transaction_delete(request, quick_transaction_id):
|
||||||
|
quick_transaction = get_object_or_404(QuickTransaction, id=quick_transaction_id)
|
||||||
|
|
||||||
|
quick_transaction.delete()
|
||||||
|
|
||||||
|
messages.success(request, _("Item deleted successfully"))
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
headers={
|
||||||
|
"HX-Trigger": "updated, hide_offcanvas",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def quick_transactions_create_menu(request):
|
||||||
|
quick_transactions = QuickTransaction.objects.all().order_by("name")
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"quick_transactions/fragments/create_menu.html",
|
||||||
|
context={"quick_transactions": quick_transactions},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def quick_transaction_add_as_transaction(request, quick_transaction_id):
|
||||||
|
quick_transaction: QuickTransaction = get_object_or_404(
|
||||||
|
QuickTransaction, id=quick_transaction_id
|
||||||
|
)
|
||||||
|
today = timezone.localdate(timezone.now())
|
||||||
|
|
||||||
|
quick_transaction_data = model_to_dict(
|
||||||
|
quick_transaction,
|
||||||
|
exclude=[
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"owner",
|
||||||
|
"account",
|
||||||
|
"category",
|
||||||
|
"tags",
|
||||||
|
"entities",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
new_transaction = Transaction(**quick_transaction_data)
|
||||||
|
new_transaction.account = quick_transaction.account
|
||||||
|
new_transaction.category = quick_transaction.category
|
||||||
|
|
||||||
|
new_transaction.date = today
|
||||||
|
new_transaction.reference_date = today.replace(day=1)
|
||||||
|
new_transaction.save()
|
||||||
|
new_transaction.tags.set(quick_transaction.tags.all())
|
||||||
|
new_transaction.entities.set(quick_transaction.entities.all())
|
||||||
|
|
||||||
|
transaction_created.send(sender=new_transaction)
|
||||||
|
|
||||||
|
messages.success(request, _("Transaction added successfully"))
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
headers={
|
||||||
|
"HX-Trigger": "updated, hide_offcanvas",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def quick_transaction_add_as_quick_transaction(request, transaction_id):
|
||||||
|
transaction: Transaction = get_object_or_404(Transaction, pk=transaction_id)
|
||||||
|
|
||||||
|
if (
|
||||||
|
transaction.description
|
||||||
|
and QuickTransaction.objects.filter(
|
||||||
|
name__startswith=transaction.description
|
||||||
|
).exists()
|
||||||
|
) or QuickTransaction.objects.filter(
|
||||||
|
name__startswith=_("Quick Transaction")
|
||||||
|
).exists():
|
||||||
|
if transaction.description:
|
||||||
|
count = QuickTransaction.objects.filter(
|
||||||
|
name__startswith=transaction.description
|
||||||
|
).count()
|
||||||
|
qt_name = transaction.description + f" ({count + 1})"
|
||||||
|
else:
|
||||||
|
count = QuickTransaction.objects.filter(
|
||||||
|
name__startswith=_("Quick Transaction")
|
||||||
|
).count()
|
||||||
|
qt_name = _("Quick Transaction") + f" ({count + 1})"
|
||||||
|
else:
|
||||||
|
qt_name = transaction.description or _("Quick Transaction")
|
||||||
|
|
||||||
|
transaction_data = model_to_dict(
|
||||||
|
transaction,
|
||||||
|
exclude=[
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"owner",
|
||||||
|
"account",
|
||||||
|
"category",
|
||||||
|
"tags",
|
||||||
|
"entities",
|
||||||
|
"date",
|
||||||
|
"reference_date",
|
||||||
|
"installment_plan",
|
||||||
|
"installment_id",
|
||||||
|
"recurring_transaction",
|
||||||
|
"deleted",
|
||||||
|
"deleted_at",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
new_quick_transaction = QuickTransaction(**transaction_data)
|
||||||
|
new_quick_transaction.account = transaction.account
|
||||||
|
new_quick_transaction.category = transaction.category
|
||||||
|
|
||||||
|
new_quick_transaction.name = qt_name
|
||||||
|
|
||||||
|
new_quick_transaction.save()
|
||||||
|
new_quick_transaction.tags.set(transaction.tags.all())
|
||||||
|
new_quick_transaction.entities.set(transaction.entities.all())
|
||||||
|
|
||||||
|
messages.success(request, _("Item added successfully"))
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
headers={
|
||||||
|
"HX-Trigger": "toasts",
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import datetime
|
import datetime
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
|
|
||||||
|
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.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from django.db.models import Q
|
from django.db.models import Q, When, Case, Value, IntegerField
|
||||||
from django.http import HttpResponse, JsonResponse
|
from django.http import HttpResponse, JsonResponse
|
||||||
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
|
||||||
@@ -43,16 +44,71 @@ def transaction_add(request):
|
|||||||
year=year,
|
year=year,
|
||||||
).date()
|
).date()
|
||||||
|
|
||||||
|
update = False
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = TransactionForm(request.POST)
|
form = TransactionForm(request.POST)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
form.save()
|
saved_instance = form.save()
|
||||||
messages.success(request, _("Transaction added successfully"))
|
messages.success(request, _("Transaction added successfully"))
|
||||||
|
|
||||||
|
if "submit" in request.POST:
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
status=204,
|
status=204,
|
||||||
headers={"HX-Trigger": "updated, hide_offcanvas"},
|
headers={"HX-Trigger": "updated, hide_offcanvas"},
|
||||||
)
|
)
|
||||||
|
elif "submit_and_another" in request.POST:
|
||||||
|
form = TransactionForm(
|
||||||
|
initial={
|
||||||
|
"date": expected_date,
|
||||||
|
"type": transaction_type,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
update = True
|
||||||
|
elif "submit_and_similar" in request.POST:
|
||||||
|
initial_data = {}
|
||||||
|
|
||||||
|
# Define fields to copy from the SAVED instance
|
||||||
|
direct_fields_to_copy = [
|
||||||
|
"account", # ForeignKey -> will copy the ID
|
||||||
|
"type", # ChoiceField -> will copy the value
|
||||||
|
"is_paid", # BooleanField -> will copy True/False
|
||||||
|
"date", # DateField -> will copy the date object
|
||||||
|
"reference_date", # DateField -> will copy the date object
|
||||||
|
"amount", # DecimalField -> will copy the decimal
|
||||||
|
"description", # CharField -> will copy the string
|
||||||
|
"notes", # TextField -> will copy the string
|
||||||
|
"category", # ForeignKey -> will copy the ID
|
||||||
|
]
|
||||||
|
m2m_fields_to_copy = [
|
||||||
|
"tags", # ManyToManyField -> will copy list of IDs
|
||||||
|
"entities", # ManyToManyField -> will copy list of IDs
|
||||||
|
]
|
||||||
|
|
||||||
|
# Copy direct fields from the saved instance
|
||||||
|
for field_name in direct_fields_to_copy:
|
||||||
|
value = getattr(saved_instance, field_name, None)
|
||||||
|
if value is not None:
|
||||||
|
# Handle ForeignKey: use the pk
|
||||||
|
if hasattr(value, "pk"):
|
||||||
|
initial_data[field_name] = value.pk
|
||||||
|
# Handle Date/DateTime/Decimal/Boolean/etc.: use the Python object directly
|
||||||
|
else:
|
||||||
|
initial_data[field_name] = (
|
||||||
|
value # This correctly handles date objects!
|
||||||
|
)
|
||||||
|
|
||||||
|
# Copy M2M fields: provide a list of related object pks
|
||||||
|
for field_name in m2m_fields_to_copy:
|
||||||
|
m2m_manager = getattr(saved_instance, field_name)
|
||||||
|
initial_data[field_name] = list(
|
||||||
|
m2m_manager.values_list("name", flat=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a new form instance pre-filled with the correctly typed initial data
|
||||||
|
form = TransactionForm(initial=initial_data)
|
||||||
|
update = True # Signal HTMX to update the form area
|
||||||
|
|
||||||
else:
|
else:
|
||||||
form = TransactionForm(
|
form = TransactionForm(
|
||||||
initial={
|
initial={
|
||||||
@@ -61,11 +117,15 @@ def transaction_add(request):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
return render(
|
response = render(
|
||||||
request,
|
request,
|
||||||
"transactions/fragments/add.html",
|
"transactions/fragments/add.html",
|
||||||
{"form": form},
|
{"form": form},
|
||||||
)
|
)
|
||||||
|
if update:
|
||||||
|
response["HX-Trigger"] = "updated"
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -153,6 +213,7 @@ def transactions_bulk_edit(request):
|
|||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
# Apply changes from the form to all selected transactions
|
# Apply changes from the form to all selected transactions
|
||||||
for transaction in transactions:
|
for transaction in transactions:
|
||||||
|
old_data = deepcopy(transaction)
|
||||||
for field_name, value in form.cleaned_data.items():
|
for field_name, value in form.cleaned_data.items():
|
||||||
if value or isinstance(
|
if value or isinstance(
|
||||||
value, bool
|
value, bool
|
||||||
@@ -165,7 +226,7 @@ def transactions_bulk_edit(request):
|
|||||||
setattr(transaction, field_name, value)
|
setattr(transaction, field_name, value)
|
||||||
|
|
||||||
transaction.save()
|
transaction.save()
|
||||||
transaction_updated.send(sender=transaction)
|
transaction_updated.send(sender=transaction, old_data=old_data)
|
||||||
|
|
||||||
messages.success(
|
messages.success(
|
||||||
request,
|
request,
|
||||||
@@ -313,10 +374,13 @@ def transactions_transfer(request):
|
|||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def transaction_pay(request, transaction_id):
|
def transaction_pay(request, transaction_id):
|
||||||
transaction = get_object_or_404(Transaction, pk=transaction_id)
|
transaction = get_object_or_404(Transaction, pk=transaction_id)
|
||||||
|
old_data = deepcopy(transaction)
|
||||||
|
|
||||||
new_is_paid = False if transaction.is_paid else True
|
new_is_paid = False if transaction.is_paid else True
|
||||||
transaction.is_paid = new_is_paid
|
transaction.is_paid = new_is_paid
|
||||||
transaction.save()
|
transaction.save()
|
||||||
transaction_updated.send(sender=transaction)
|
|
||||||
|
transaction_updated.send(sender=transaction, old_data=old_data)
|
||||||
|
|
||||||
response = render(
|
response = render(
|
||||||
request,
|
request,
|
||||||
@@ -329,6 +393,71 @@ def transaction_pay(request, transaction_id):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def transaction_mute(request, transaction_id):
|
||||||
|
transaction = get_object_or_404(Transaction, pk=transaction_id)
|
||||||
|
old_data = deepcopy(transaction)
|
||||||
|
|
||||||
|
new_mute = False if transaction.mute else True
|
||||||
|
transaction.mute = new_mute
|
||||||
|
transaction.save()
|
||||||
|
transaction_updated.send(sender=transaction, old_data=old_data)
|
||||||
|
|
||||||
|
response = render(
|
||||||
|
request,
|
||||||
|
"transactions/fragments/item.html",
|
||||||
|
context={"transaction": transaction, **request.GET},
|
||||||
|
)
|
||||||
|
response.headers["HX-Trigger"] = "selective_update"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def transaction_change_month(request, transaction_id, change_type):
|
||||||
|
transaction: Transaction = get_object_or_404(Transaction, pk=transaction_id)
|
||||||
|
old_data = deepcopy(transaction)
|
||||||
|
|
||||||
|
if change_type == "next":
|
||||||
|
transaction.reference_date = transaction.reference_date + relativedelta(
|
||||||
|
months=1
|
||||||
|
)
|
||||||
|
transaction.save()
|
||||||
|
transaction_updated.send(sender=transaction, old_data=old_data)
|
||||||
|
elif change_type == "previous":
|
||||||
|
transaction.reference_date = transaction.reference_date - relativedelta(
|
||||||
|
months=1
|
||||||
|
)
|
||||||
|
transaction.save()
|
||||||
|
transaction_updated.send(sender=transaction, old_data=old_data)
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
headers={"HX-Trigger": "updated"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def transaction_move_to_today(request, transaction_id):
|
||||||
|
transaction: Transaction = get_object_or_404(Transaction, pk=transaction_id)
|
||||||
|
|
||||||
|
old_data = deepcopy(transaction)
|
||||||
|
|
||||||
|
transaction.date = timezone.localdate(timezone.now())
|
||||||
|
transaction.save()
|
||||||
|
transaction_updated.send(sender=transaction, old_data=old_data)
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
headers={"HX-Trigger": "updated"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def transaction_all_index(request):
|
def transaction_all_index(request):
|
||||||
@@ -468,7 +597,10 @@ def transaction_all_currency_summary(request):
|
|||||||
|
|
||||||
f = TransactionsFilter(request.GET, queryset=transactions)
|
f = TransactionsFilter(request.GET, queryset=transactions)
|
||||||
|
|
||||||
currency_data = calculate_currency_totals(f.qs.all(), ignore_empty=True)
|
currency_data = calculate_currency_totals(
|
||||||
|
f.qs.exclude(account__in=request.user.untracked_accounts.all()),
|
||||||
|
ignore_empty=True,
|
||||||
|
)
|
||||||
currency_percentages = calculate_percentage_distribution(currency_data)
|
currency_percentages = calculate_percentage_distribution(currency_data)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
@@ -527,11 +659,26 @@ def get_recent_transactions(request, filter_type=None):
|
|||||||
# Get search term from query params
|
# Get search term from query params
|
||||||
search_term = request.GET.get("q", "").strip()
|
search_term = request.GET.get("q", "").strip()
|
||||||
|
|
||||||
|
today = timezone.localdate(timezone.now())
|
||||||
|
yesterday = today - timezone.timedelta(days=1)
|
||||||
|
tomorrow = today + timezone.timedelta(days=1)
|
||||||
|
|
||||||
# Base queryset with selected fields
|
# Base queryset with selected fields
|
||||||
queryset = (
|
queryset = (
|
||||||
Transaction.objects.filter(deleted=False)
|
Transaction.objects.filter(deleted=False)
|
||||||
|
.annotate(
|
||||||
|
date_order=Case(
|
||||||
|
When(date=today, then=Value(0)),
|
||||||
|
When(date=tomorrow, then=Value(1)),
|
||||||
|
When(date=yesterday, then=Value(2)),
|
||||||
|
When(date__gt=tomorrow, then=Value(3)),
|
||||||
|
When(date__lt=yesterday, then=Value(4)),
|
||||||
|
default=Value(5),
|
||||||
|
output_field=IntegerField(),
|
||||||
|
)
|
||||||
|
)
|
||||||
.select_related("account", "category")
|
.select_related("account", "category")
|
||||||
.order_by("-created_at")
|
.order_by("date_order", "date", "id")
|
||||||
)
|
)
|
||||||
|
|
||||||
if filter_type:
|
if filter_type:
|
||||||
|
|||||||
+279
-2
@@ -2,16 +2,20 @@ from crispy_forms.bootstrap import (
|
|||||||
FormActions,
|
FormActions,
|
||||||
)
|
)
|
||||||
from crispy_forms.helper import FormHelper
|
from crispy_forms.helper import FormHelper
|
||||||
from crispy_forms.layout import Layout, Submit
|
from crispy_forms.layout import Layout, Submit, Row, Column, Field, Div, HTML
|
||||||
from django import forms
|
from django import forms
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.forms import (
|
from django.contrib.auth.forms import (
|
||||||
UsernameField,
|
UsernameField,
|
||||||
AuthenticationForm,
|
AuthenticationForm,
|
||||||
|
UserCreationForm,
|
||||||
)
|
)
|
||||||
|
from django.db import transaction
|
||||||
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.users.models import UserSettings
|
from apps.users.models import UserSettings
|
||||||
|
from apps.common.middleware.thread_local import get_current_user
|
||||||
|
|
||||||
|
|
||||||
class LoginForm(AuthenticationForm):
|
class LoginForm(AuthenticationForm):
|
||||||
@@ -41,7 +45,7 @@ class LoginForm(AuthenticationForm):
|
|||||||
self.helper.layout = Layout(
|
self.helper.layout = Layout(
|
||||||
"username",
|
"username",
|
||||||
"password",
|
"password",
|
||||||
Submit("Submit", "Login"),
|
Submit("Submit", "Login", css_class="btn btn-primary w-100"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -85,6 +89,8 @@ class UserSettingsForm(forms.ModelForm):
|
|||||||
("AA", _("Default")),
|
("AA", _("Default")),
|
||||||
("DC", "1.234,50"),
|
("DC", "1.234,50"),
|
||||||
("CD", "1,234.50"),
|
("CD", "1,234.50"),
|
||||||
|
("SD", "1 234.50"),
|
||||||
|
("SC", "1 234,50"),
|
||||||
]
|
]
|
||||||
|
|
||||||
date_format = forms.ChoiceField(
|
date_format = forms.ChoiceField(
|
||||||
@@ -111,6 +117,7 @@ class UserSettingsForm(forms.ModelForm):
|
|||||||
"date_format",
|
"date_format",
|
||||||
"datetime_format",
|
"datetime_format",
|
||||||
"number_format",
|
"number_format",
|
||||||
|
"volume",
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@@ -122,13 +129,283 @@ class UserSettingsForm(forms.ModelForm):
|
|||||||
self.helper.layout = Layout(
|
self.helper.layout = Layout(
|
||||||
"language",
|
"language",
|
||||||
"timezone",
|
"timezone",
|
||||||
|
HTML("<hr />"),
|
||||||
"date_format",
|
"date_format",
|
||||||
"datetime_format",
|
"datetime_format",
|
||||||
"number_format",
|
"number_format",
|
||||||
|
HTML("<hr />"),
|
||||||
"start_page",
|
"start_page",
|
||||||
|
HTML("<hr />"),
|
||||||
|
"volume",
|
||||||
FormActions(
|
FormActions(
|
||||||
NoClassSubmit(
|
NoClassSubmit(
|
||||||
"submit", _("Save"), css_class="btn btn-outline-primary w-100"
|
"submit", _("Save"), css_class="btn btn-outline-primary w-100"
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.fields["language"].help_text = _(
|
||||||
|
"This changes the language (if available) and how numbers and dates are displayed\n"
|
||||||
|
"Consider helping translate WYGIWYH to your language at %(translation_link)s"
|
||||||
|
) % {
|
||||||
|
"translation_link": '<a href="https://translations.herculino.com" target="_blank">translations.herculino.com</a>'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class UserUpdateForm(forms.ModelForm):
|
||||||
|
new_password1 = forms.CharField(
|
||||||
|
label=_("New Password"),
|
||||||
|
widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}),
|
||||||
|
required=False,
|
||||||
|
help_text=_("Leave blank to keep the current password."),
|
||||||
|
)
|
||||||
|
new_password2 = forms.CharField(
|
||||||
|
label=_("Confirm New Password"),
|
||||||
|
widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}),
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = get_user_model()
|
||||||
|
# Add the administrative fields
|
||||||
|
fields = ["first_name", "last_name", "email", "is_active", "is_superuser"]
|
||||||
|
# Help texts can be defined here or directly in the layout/field definition
|
||||||
|
help_texts = {
|
||||||
|
"is_active": _(
|
||||||
|
"Designates whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||||
|
),
|
||||||
|
"is_superuser": _(
|
||||||
|
"Designates that this user has all permissions without explicitly assigning them."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.instance = kwargs.get("instance") # Store instance for validation/checks
|
||||||
|
self.requesting_user = get_current_user()
|
||||||
|
|
||||||
|
self.helper = FormHelper()
|
||||||
|
self.helper.form_tag = False
|
||||||
|
self.helper.form_method = "post"
|
||||||
|
|
||||||
|
# Define the layout using Crispy Forms, including the new fields
|
||||||
|
self.helper.layout = Layout(
|
||||||
|
Row(
|
||||||
|
Column("first_name", css_class="form-group col-md-6"),
|
||||||
|
Column("last_name", css_class="form-group col-md-6"),
|
||||||
|
css_class="row",
|
||||||
|
),
|
||||||
|
Field("email"),
|
||||||
|
# Group password fields (optional visual grouping)
|
||||||
|
Div(
|
||||||
|
Field("new_password1"),
|
||||||
|
Field("new_password2"),
|
||||||
|
css_class="border p-3 rounded mb-3",
|
||||||
|
),
|
||||||
|
# Group administrative status fields
|
||||||
|
Div(
|
||||||
|
Field("is_active"),
|
||||||
|
Field("is_superuser"),
|
||||||
|
css_class="border p-3 rounded mb-3 text-bg-secondary", # Example visual separation
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
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"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
self.requesting_user == self.instance
|
||||||
|
or not self.requesting_user.is_superuser
|
||||||
|
):
|
||||||
|
self.fields["is_superuser"].disabled = True
|
||||||
|
self.fields["is_active"].disabled = True
|
||||||
|
|
||||||
|
# Keep existing clean methods
|
||||||
|
def clean_email(self):
|
||||||
|
email = self.cleaned_data.get("email")
|
||||||
|
# Use case-insensitive comparison for email uniqueness check
|
||||||
|
if (
|
||||||
|
self.instance
|
||||||
|
and get_user_model()
|
||||||
|
.objects.filter(email__iexact=email)
|
||||||
|
.exclude(pk=self.instance.pk)
|
||||||
|
.exists()
|
||||||
|
):
|
||||||
|
raise forms.ValidationError(
|
||||||
|
_("This email address is already in use by another account.")
|
||||||
|
)
|
||||||
|
return email
|
||||||
|
|
||||||
|
def clean_new_password2(self):
|
||||||
|
new_password1 = self.cleaned_data.get("new_password1")
|
||||||
|
new_password2 = self.cleaned_data.get("new_password2")
|
||||||
|
if new_password1 and new_password1 != new_password2:
|
||||||
|
raise forms.ValidationError(_("The two password fields didn't match."))
|
||||||
|
if new_password1 and not new_password2:
|
||||||
|
raise forms.ValidationError(_("Please confirm your new password."))
|
||||||
|
if new_password2 and not new_password1:
|
||||||
|
raise forms.ValidationError(_("Please enter the new password first."))
|
||||||
|
return new_password2
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
is_active_val = cleaned_data.get("is_active")
|
||||||
|
is_superuser_val = cleaned_data.get("is_superuser")
|
||||||
|
|
||||||
|
# --- Crucial Security Check Example ---
|
||||||
|
# Prevent the requesting user from deactivating or removing superuser status
|
||||||
|
# from their *own* account via this form.
|
||||||
|
if (
|
||||||
|
self.requesting_user
|
||||||
|
and self.instance
|
||||||
|
and self.requesting_user.pk == self.instance.pk
|
||||||
|
):
|
||||||
|
# Check if 'is_active' field exists and user is trying to set it to False
|
||||||
|
if "is_active" in self.fields and is_active_val is False:
|
||||||
|
self.add_error(
|
||||||
|
"is_active",
|
||||||
|
_("You cannot deactivate your own account using this form."),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if 'is_superuser' field exists, the user *is* currently a superuser,
|
||||||
|
# and they are trying to set it to False
|
||||||
|
if (
|
||||||
|
"is_superuser" in self.fields
|
||||||
|
and self.instance.is_superuser
|
||||||
|
and is_superuser_val is False
|
||||||
|
):
|
||||||
|
if get_user_model().objects.filter(is_superuser=True).count() <= 1:
|
||||||
|
self.add_error(
|
||||||
|
"is_superuser",
|
||||||
|
_("Cannot remove status from the last superuser."),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.add_error(
|
||||||
|
"is_superuser",
|
||||||
|
_(
|
||||||
|
"You cannot remove your own superuser status using this form."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
# Save method remains the same, ModelForm handles boolean fields correctly
|
||||||
|
def save(self, commit=True):
|
||||||
|
user = super().save(commit=False)
|
||||||
|
new_password = self.cleaned_data.get("new_password1")
|
||||||
|
if new_password:
|
||||||
|
user.set_password(new_password)
|
||||||
|
|
||||||
|
if commit:
|
||||||
|
user.save()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
class UserAddForm(UserCreationForm):
|
||||||
|
"""
|
||||||
|
A form for administrators to create new users.
|
||||||
|
Includes fields for first name, last name, email, active status,
|
||||||
|
and superuser status. Uses email as the username field.
|
||||||
|
Inherits password handling from UserCreationForm.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta(UserCreationForm.Meta):
|
||||||
|
model = get_user_model()
|
||||||
|
# Specify the fields to include. UserCreationForm automatically handles
|
||||||
|
# 'password1' and 'password2'. We replace 'username' with 'email'.
|
||||||
|
fields = ("email", "first_name", "last_name", "is_active", "is_superuser")
|
||||||
|
field_classes = {
|
||||||
|
"email": forms.EmailField
|
||||||
|
} # Ensure email field uses EmailField validation
|
||||||
|
help_texts = {
|
||||||
|
"is_active": _(
|
||||||
|
"Designates whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||||
|
),
|
||||||
|
"is_superuser": _(
|
||||||
|
"Designates that this user has all permissions without explicitly assigning them."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
# Set is_active to True by default for new users, can be overridden by admin
|
||||||
|
self.fields["is_active"].initial = True
|
||||||
|
|
||||||
|
self.helper = FormHelper()
|
||||||
|
self.helper.form_tag = False
|
||||||
|
self.helper.form_method = "post"
|
||||||
|
|
||||||
|
# Define the layout, including password fields from UserCreationForm
|
||||||
|
self.helper.layout = Layout(
|
||||||
|
Field("email"),
|
||||||
|
Row(
|
||||||
|
Column("first_name", css_class="form-group col-md-6"),
|
||||||
|
Column("last_name", css_class="form-group col-md-6"),
|
||||||
|
css_class="row",
|
||||||
|
),
|
||||||
|
# UserCreationForm provides 'password1' and 'password2' fields
|
||||||
|
Div(
|
||||||
|
Field("password1", autocomplete="new-password"),
|
||||||
|
Field("password2", autocomplete="new-password"),
|
||||||
|
css_class="border p-3 rounded mb-3",
|
||||||
|
),
|
||||||
|
# Administrative status fields
|
||||||
|
Div(
|
||||||
|
Field("is_active"),
|
||||||
|
Field("is_superuser"),
|
||||||
|
css_class="border p-3 rounded mb-3 text-bg-secondary",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.instance and self.instance.pk:
|
||||||
|
self.helper.layout.append(
|
||||||
|
FormActions(
|
||||||
|
NoClassSubmit(
|
||||||
|
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.helper.layout.append(
|
||||||
|
FormActions(
|
||||||
|
NoClassSubmit(
|
||||||
|
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean_email(self):
|
||||||
|
"""Ensure email uniqueness (case-insensitive)."""
|
||||||
|
email = self.cleaned_data.get("email")
|
||||||
|
if email and get_user_model().objects.filter(email__iexact=email).exists():
|
||||||
|
raise forms.ValidationError(
|
||||||
|
_("A user with this email address already exists.")
|
||||||
|
)
|
||||||
|
return email
|
||||||
|
|
||||||
|
@transaction.atomic # Ensure user creation is atomic
|
||||||
|
def save(self, commit=True):
|
||||||
|
"""
|
||||||
|
Save the user instance. UserCreationForm's save handles password hashing.
|
||||||
|
Our Meta class ensures other fields are included.
|
||||||
|
"""
|
||||||
|
user = super().save(commit=False)
|
||||||
|
|
||||||
|
if commit:
|
||||||
|
user.save()
|
||||||
|
return user
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user