mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-04-19 15:21:31 +02:00
Compare commits
368 Commits
0.9.0
...
fix/amount
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0832ec75ca | ||
|
|
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 | ||
|
|
621799f445 | ||
|
|
124d29e965 | ||
|
|
bf4d23f15e | ||
|
|
020dd74f80 | ||
|
|
c7d70a1748 | ||
|
|
1025b80dda | ||
|
|
1ae245fe01 | ||
|
|
46c5efb8a9 | ||
|
|
abb0993435 | ||
|
|
a9e7692f99 | ||
|
|
531571798a | ||
|
|
7282aa20ee | ||
|
|
13f9950afa | ||
|
|
672cc5ebc7 | ||
|
|
8045e2c73a | ||
|
|
7c042d9299 | ||
|
|
aba47f0eed | ||
|
|
2010ccc92d | ||
|
|
d73d6cbf22 | ||
|
|
e5a9b6e921 | ||
|
|
dbd9774681 | ||
|
|
5a93a907e1 | ||
|
|
e0e159166b | ||
|
|
6c7594ad14 | ||
|
|
d3ea0e43da | ||
|
|
dde75416ca | ||
|
|
c9b346b791 | ||
|
|
9896044a15 | ||
|
|
eb65eb4590 | ||
|
|
017c70e8b2 | ||
|
|
64b0830909 | ||
|
|
25d99cbece | ||
|
|
033f0e1b0d | ||
|
|
35027ee0ae | ||
|
|
91904e959b | ||
|
|
a6a85ae3a2 | ||
|
|
b0f53f45f9 | ||
|
|
0f60f8d486 | ||
|
|
efb207a109 | ||
|
|
95b1481dd5 | ||
|
|
8de340b68b | ||
|
|
ef15b85386 | ||
|
|
45d939237d | ||
|
|
6bf262e514 | ||
|
|
f9d9137336 | ||
|
|
b532521f27 | ||
|
|
1e06e2d34d | ||
|
|
a33fa5e184 | ||
|
|
a2453695d8 | ||
|
|
3e929d0433 | ||
|
|
185fc464a5 | ||
|
|
647c009525 | ||
|
|
ba75492dcc | ||
|
|
8312baaf45 | ||
|
|
4d346dc278 | ||
|
|
70ff7fab38 | ||
|
|
6947c6affd | ||
|
|
dcab83f936 | ||
|
|
b228e4ec26 | ||
|
|
4071a1301f | ||
|
|
5c9db10710 | ||
|
|
19c92e0014 | ||
|
|
6459f2eb46 | ||
|
|
7926e081ef | ||
|
|
ceefe7075f | ||
|
|
ad3230fd83 | ||
|
|
c89b07ed93 | ||
|
|
201ccea842 | ||
|
|
32ada488b4 | ||
|
|
794d11a355 | ||
|
|
67f8f5fe89 | ||
|
|
9ac69fd92a | ||
|
|
069f1b450c | ||
|
|
2f388af928 | ||
|
|
beeb0579ce | ||
|
|
a8666da57b | ||
|
|
835316d0f3 | ||
|
|
f5feeb9617 | ||
|
|
09e380a480 | ||
|
|
3080df9b66 | ||
|
|
ebc41a8049 | ||
|
|
635628e30e | ||
|
|
819a58ac06 | ||
|
|
d433375522 | ||
|
|
c0150f71a8 | ||
|
|
6119698d38 | ||
|
|
f5ae231601 | ||
|
|
972d23abbd | ||
|
|
9a514a8a69 | ||
|
|
7325231548 | ||
|
|
570657371a | ||
|
|
67da60b5b0 | ||
|
|
84c047c5ab | ||
|
|
23f5d09bec | ||
|
|
2a19075e23 | ||
|
|
7f231175b2 | ||
|
|
062e84f864 | ||
|
|
5521eb20bf | ||
|
|
627b5d250b | ||
|
|
195a8a68d6 | ||
|
|
daf1f68b82 | ||
|
|
dd24fd56d3 | ||
|
|
7a2acb6497 | ||
|
|
9c339faa72 | ||
|
|
02376ad02b | ||
|
|
b53a4a0286 | ||
|
|
a1f618434b | ||
|
|
7b5be29f0d | ||
|
|
56a73b181a | ||
|
|
865618e054 | ||
|
|
9e912b2736 | ||
|
|
da7680e70f | ||
|
|
ab594eb511 | ||
|
|
cffaaa369a | ||
|
|
5f414e82ee | ||
|
|
f3bcef534e | ||
|
|
d140ff5b70 | ||
|
|
7eceacfe68 | ||
|
|
038438fba7 | ||
|
|
ee98a5ef12 | ||
|
|
28b12faaf0 | ||
|
|
d0f2742637 | ||
|
|
9c55dac866 | ||
|
|
e6d8b548b7 | ||
|
|
4f8c2215c1 | ||
|
|
851b34f07a | ||
|
|
546ed5c6af | ||
|
|
04ae7337f5 | ||
|
|
a3a8791e96 | ||
|
|
63069f0ec9 | ||
|
|
32b522dad2 | ||
|
|
0c20a079e3 | ||
|
|
7c9697f683 | ||
|
|
15d04230ae | ||
|
|
ecc09ca6a6 | ||
|
|
cd753c5dd5 | ||
|
|
a3b9952f80 | ||
|
|
e93969c035 | ||
|
|
6ec5b5df1e | ||
|
|
93e7adeea8 | ||
|
|
37b5a43c1f | ||
|
|
87a07c25d1 | ||
|
|
9e27fef5e5 | ||
|
|
2cbba53e06 | ||
|
|
d9e8be7efb | ||
|
|
7dc9ef9950 | ||
|
|
00e83cf6a2 | ||
|
|
039242b48a | ||
|
|
94e2bdf93d | ||
|
|
79b387ce60 | ||
|
|
43eb87d3ba | ||
|
|
0110220b72 | ||
|
|
f5c86f3d97 | ||
|
|
7b7f58d34d | ||
|
|
86112931d9 | ||
|
|
e6e0e4caea | ||
|
|
942154480e | ||
|
|
467131d9f1 | ||
|
|
fee1db8660 | ||
|
|
4f7fc1c9c8 |
12
.env.example
12
.env.example
@@ -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
|
||||||
|
|||||||
4
.github/FUNDING.yml
vendored
Normal file
4
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
github: eitchtee
|
||||||
|
custom: ["https://www.paypal.com/donate/?hosted_button_id=FFWM4W9NQDMM6"]
|
||||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -20,6 +20,10 @@ on:
|
|||||||
env:
|
env:
|
||||||
IMAGE_NAME: wygiwyh
|
IMAGE_NAME: wygiwyh
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
72
.github/workflows/translations.yml
vendored
Normal file
72
.github/workflows/translations.yml
vendored
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
name: Django Translation Update
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
# Add manual trigger
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
reason:
|
||||||
|
description: 'Reason for running'
|
||||||
|
required: false
|
||||||
|
default: 'Manual update of translation files'
|
||||||
|
|
||||||
|
# Ensure only one translation job runs at a time
|
||||||
|
concurrency:
|
||||||
|
group: django-translations
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update-translations:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
# Skip on PRs from forks (which don't have write permissions)
|
||||||
|
# Allow manual runs and pushes to main
|
||||||
|
if: github.event_name == 'workflow_dispatch' || github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.PAT }}
|
||||||
|
ref: ${{ github.head_ref }}
|
||||||
|
|
||||||
|
- name: Set up Python 3.11
|
||||||
|
uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
- name: Install gettext
|
||||||
|
run: sudo apt-get install -y gettext
|
||||||
|
|
||||||
|
- name: Run makemessages
|
||||||
|
run: |
|
||||||
|
cd app
|
||||||
|
python manage.py makemessages -a
|
||||||
|
|
||||||
|
- name: Check for changes
|
||||||
|
id: check_changes
|
||||||
|
run: |
|
||||||
|
if git diff --exit-code --quiet app/locale/; then
|
||||||
|
echo "No translation changes detected"
|
||||||
|
else
|
||||||
|
echo "changes_detected=true" >> $GITHUB_OUTPUT
|
||||||
|
echo "Translation changes detected"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Commit translation files
|
||||||
|
if: steps.check_changes.outputs.changes_detected == 'true'
|
||||||
|
uses: stefanzweifel/git-auto-commit-action@v5
|
||||||
|
with:
|
||||||
|
push_options: --force
|
||||||
|
commit_message: |
|
||||||
|
chore(locale): update translation files
|
||||||
|
|
||||||
|
[skip ci] Automatically generated by Django makemessages workflow
|
||||||
|
file_pattern: "app/locale/**/*.po"
|
||||||
56
README.md
56
README.md
@@ -13,6 +13,7 @@
|
|||||||
<a href="#key-features">Features</a> •
|
<a href="#key-features">Features</a> •
|
||||||
<a href="#how-to-use">Usage</a> •
|
<a href="#how-to-use">Usage</a> •
|
||||||
<a href="#how-it-works">How</a> •
|
<a href="#how-it-works">How</a> •
|
||||||
|
<a href="#help-us-translate-wygiwyh">Translate</a> •
|
||||||
<a href="#caveats-and-warnings">Caveats and Warnings</a> •
|
<a href="#caveats-and-warnings">Caveats and Warnings</a> •
|
||||||
<a href="#built-with">Built with</a>
|
<a href="#built-with">Built with</a>
|
||||||
</p>
|
</p>
|
||||||
@@ -50,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/).
|
||||||
@@ -75,12 +87,12 @@ $ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> If you're using Unraid, you don't need to follow these steps, use the app on the store. Make sure to read the [Unraid section](#unraid) and [Enviroment Variables](#enviroment-variables) for an explanation of all available variables
|
> If you're using Unraid, you don't need to follow these steps, use the app on the store. Make sure to read the [Unraid section](#unraid) and [Environment Variables](#environment-variables) for an explanation of all available variables
|
||||||
|
|
||||||
## Running locally
|
## Running locally
|
||||||
|
|
||||||
@@ -110,7 +122,7 @@ WYGIWYH is available on the Unraid Store. You'll need to provision your own post
|
|||||||
|
|
||||||
To create the first user, open the container's console using Unraid's UI, by clicking on WYGIWYH icon on the Docker page and selecting `Console`, then type `python manage.py createsuperuser`, you'll them be prompted to input your e-mail and password.
|
To create the first user, open the container's console using Unraid's UI, by clicking on WYGIWYH icon on the Docker page and selecting `Console`, then type `python manage.py createsuperuser`, you'll them be prompted to input your e-mail and password.
|
||||||
|
|
||||||
## Enviroment Variables
|
## Environment Variables
|
||||||
|
|
||||||
| variable | type | default | explanation |
|
| variable | type | default | explanation |
|
||||||
|-------------------------------|-------------|-----------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|-------------------------------|-------------|-----------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
@@ -122,17 +134,53 @@ To create the first user, open the container's console using Unraid's UI, by cli
|
|||||||
| SQL_DATABASE | string | None *required | The name of your postgres database |
|
| SQL_DATABASE | string | None *required | The name of your postgres database |
|
||||||
| SQL_USER | string | user | The username used to connect to your postgres database |
|
| SQL_USER | string | user | The username used to connect to your postgres database |
|
||||||
| SQL_PASSWORD | string | password | The password used to connect to your postgres database |
|
| SQL_PASSWORD | string | password | The password used to connect to your postgres database |
|
||||||
| SQL_HOST | string | localhost | The adress used to connect to your postgres database |
|
| SQL_HOST | string | localhost | The address used to connect to your postgres database |
|
||||||
| SQL_PORT | string | 5432 | The port used to connect to your postgres database |
|
| SQL_PORT | string | 5432 | The port used to connect to your postgres database |
|
||||||
| SESSION_EXPIRY_TIME | int | 2678400 (31 days) | The age of session cookies, in seconds. E.g. how long you will stay logged in |
|
| SESSION_EXPIRY_TIME | int | 2678400 (31 days) | The age of session cookies, in seconds. E.g. how long you will stay logged in |
|
||||||
| ENABLE_SOFT_DELETE | true\|false | false | Whether to enable transactions soft delete, if enabled, deleted transactions will remain in the database. Useful for imports and avoiding duplicate entries. |
|
| 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. |
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
Check out our [Wiki](https://github.com/eitchtee/WYGIWYH/wiki) for more information.
|
Check out our [Wiki](https://github.com/eitchtee/WYGIWYH/wiki) for more information.
|
||||||
|
|
||||||
|
# Help us translate WYGIWYH!
|
||||||
|
<a href="https://translations.herculino.com/engage/wygiwyh/">
|
||||||
|
<img src="https://translations.herculino.com/widget/wygiwyh/open-graph.png" alt="Translation status" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Login with your github account
|
||||||
|
|
||||||
# Caveats and Warnings
|
# Caveats and Warnings
|
||||||
|
|
||||||
- I'm not an accountant, some terms and even calculations might be wrong. Make sure to open an issue if you see anything that could be improved.
|
- I'm not an accountant, some terms and even calculations might be wrong. Make sure to open an issue if you see anything that could be improved.
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import logging
|
|
||||||
|
|
||||||
|
|
||||||
class ProcrastinateFilter(logging.Filter):
|
|
||||||
# from https://github.com/madzak/python-json-logger/blob/master/src/pythonjsonlogger/jsonlogger.py#L19
|
|
||||||
_reserved_log_keys = frozenset(
|
|
||||||
"""args asctime created exc_info exc_text filename
|
|
||||||
funcName levelname levelno lineno module msecs message msg name pathname
|
|
||||||
process processName relativeCreated stack_info thread threadName""".split()
|
|
||||||
)
|
|
||||||
|
|
||||||
def filter(self, record: logging.LogRecord):
|
|
||||||
record.procrastinate = {}
|
|
||||||
for key, value in vars(record).items():
|
|
||||||
if not key.startswith("_") and key not in self._reserved_log_keys | {
|
|
||||||
"procrastinate"
|
|
||||||
}:
|
|
||||||
record.procrastinate[key] = value # type: ignore
|
|
||||||
return True
|
|
||||||
@@ -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",
|
||||||
@@ -55,14 +57,15 @@ INSTALLED_APPS = [
|
|||||||
"hijack",
|
"hijack",
|
||||||
"hijack.contrib.admin",
|
"hijack.contrib.admin",
|
||||||
"django_filters",
|
"django_filters",
|
||||||
|
"import_export",
|
||||||
"apps.users.apps.UsersConfig",
|
"apps.users.apps.UsersConfig",
|
||||||
"procrastinate.contrib.django",
|
"procrastinate.contrib.django",
|
||||||
"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.api.apps.ApiConfig",
|
"apps.api.apps.ApiConfig",
|
||||||
"cachalot",
|
"cachalot",
|
||||||
"rest_framework",
|
"rest_framework",
|
||||||
@@ -72,9 +75,17 @@ 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",
|
||||||
"apps.common.middleware.thread_local.ThreadLocalMiddleware",
|
"apps.common.middleware.thread_local.ThreadLocalMiddleware",
|
||||||
"debug_toolbar.middleware.DebugToolbarMiddleware",
|
"debug_toolbar.middleware.DebugToolbarMiddleware",
|
||||||
"django.middleware.security.SecurityMiddleware",
|
"django.middleware.security.SecurityMiddleware",
|
||||||
@@ -87,8 +98,8 @@ MIDDLEWARE = [
|
|||||||
"apps.common.middleware.localization.LocalizationMiddleware",
|
"apps.common.middleware.localization.LocalizationMiddleware",
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
"django_browser_reload.middleware.BrowserReloadMiddleware",
|
|
||||||
"hijack.middleware.HijackUserMiddleware",
|
"hijack.middleware.HijackUserMiddleware",
|
||||||
|
"allauth.account.middleware.AccountMiddleware",
|
||||||
]
|
]
|
||||||
|
|
||||||
ROOT_URLCONF = "WYGIWYH.urls"
|
ROOT_URLCONF = "WYGIWYH.urls"
|
||||||
@@ -161,9 +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"),
|
||||||
|
("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")
|
||||||
@@ -209,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"]
|
||||||
@@ -258,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",
|
||||||
@@ -277,27 +423,16 @@ if "procrastinate" in sys.argv:
|
|||||||
"version": 1,
|
"version": 1,
|
||||||
"disable_existing_loggers": False,
|
"disable_existing_loggers": False,
|
||||||
"formatters": {
|
"formatters": {
|
||||||
"procrastinate": {
|
|
||||||
"format": "[%(asctime)s] - %(levelname)s - %(name)s - %(message)s -> %(procrastinate)s",
|
|
||||||
"datefmt": "%Y-%m-%d %H:%M:%S",
|
|
||||||
},
|
|
||||||
"standard": {
|
"standard": {
|
||||||
"format": "[%(asctime)s] - %(levelname)s - %(name)s - %(message)s",
|
"format": "[%(asctime)s] - %(levelname)s - %(name)s - %(message)s",
|
||||||
"datefmt": "%Y-%m-%d %H:%M:%S",
|
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"filters": {
|
|
||||||
"procrastinate": {
|
|
||||||
"()": "WYGIWYH.logs.ProcrastinateFilter.ProcrastinateFilter",
|
|
||||||
"name": "procrastinate",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"handlers": {
|
"handlers": {
|
||||||
"procrastinate": {
|
"procrastinate": {
|
||||||
"level": "INFO",
|
"level": "INFO",
|
||||||
"class": "logging.StreamHandler",
|
"class": "logging.StreamHandler",
|
||||||
"formatter": "procrastinate",
|
"formatter": "standard",
|
||||||
"filters": ["procrastinate"],
|
|
||||||
},
|
},
|
||||||
"console": {
|
"console": {
|
||||||
"class": "logging.StreamHandler",
|
"class": "logging.StreamHandler",
|
||||||
@@ -308,10 +443,10 @@ if "procrastinate" in sys.argv:
|
|||||||
"loggers": {
|
"loggers": {
|
||||||
"procrastinate": {
|
"procrastinate": {
|
||||||
"handlers": ["procrastinate"],
|
"handlers": ["procrastinate"],
|
||||||
"propagate": True,
|
"propagate": False,
|
||||||
},
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"handlers": None,
|
"handlers": ["console"],
|
||||||
"level": "INFO",
|
"level": "INFO",
|
||||||
"propagate": False,
|
"propagate": False,
|
||||||
},
|
},
|
||||||
@@ -402,3 +537,4 @@ 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"
|
||||||
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")),
|
||||||
@@ -49,4 +58,6 @@ urlpatterns = [
|
|||||||
path("", include("apps.dca.urls")),
|
path("", include("apps.dca.urls")),
|
||||||
path("", include("apps.mini_tools.urls")),
|
path("", include("apps.mini_tools.urls")),
|
||||||
path("", include("apps.import_app.urls")),
|
path("", include("apps.import_app.urls")),
|
||||||
|
path("", include("apps.export_app.urls")),
|
||||||
|
path("", include("apps.insights.urls")),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from apps.accounts.models import Account
|
from apps.accounts.models import Account, AccountGroup
|
||||||
|
from apps.common.admin import SharedObjectModelAdmin
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(Account)
|
@admin.register(Account)
|
||||||
|
class AccountModelAdmin(SharedObjectModelAdmin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(AccountGroup)
|
||||||
|
class AccountGroupModelAdmin(SharedObjectModelAdmin):
|
||||||
|
pass
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ class AccountForm(forms.ModelForm):
|
|||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.fields["group"].queryset = AccountGroup.objects.all()
|
||||||
|
|
||||||
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"
|
||||||
@@ -151,5 +153,11 @@ class AccountBalanceForm(forms.Form):
|
|||||||
decimal_places=self.currency_decimal_places
|
decimal_places=self.currency_decimal_places
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.fields["category"].queryset = TransactionCategory.objects.filter(
|
||||||
|
active=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self.fields["tags"].queryset = TransactionTag.objects.filter(active=True)
|
||||||
|
|
||||||
|
|
||||||
AccountBalanceFormSet = forms.formset_factory(AccountBalanceForm, extra=0)
|
AccountBalanceFormSet = forms.formset_factory(AccountBalanceForm, extra=0)
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Generated by Django 5.1.6 on 2025-03-04 15:07
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0008_alter_account_name'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='account',
|
||||||
|
name='owner',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_accounts', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='account',
|
||||||
|
name='shared_with',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='shared_accounts', to=settings.AUTH_USER_MODEL, verbose_name='Shared With'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='accountgroup',
|
||||||
|
name='owner',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_account_groups', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Generated by Django 5.1.6 on 2025-03-05 02:42
|
||||||
|
|
||||||
|
import django.db.models.manager
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0009_account_owner_account_shared_with_accountgroup_owner'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelManagers(
|
||||||
|
name='account',
|
||||||
|
managers=[
|
||||||
|
('all_objects', django.db.models.manager.Manager()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AlterModelManagers(
|
||||||
|
name='accountgroup',
|
||||||
|
managers=[
|
||||||
|
('all_objects', django.db.models.manager.Manager()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='account',
|
||||||
|
name='name',
|
||||||
|
field=models.CharField(max_length=255, verbose_name='Name'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='accountgroup',
|
||||||
|
name='name',
|
||||||
|
field=models.CharField(max_length=255, verbose_name='Name'),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='account',
|
||||||
|
unique_together={('owner', 'name')},
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='accountgroup',
|
||||||
|
unique_together={('owner', 'name')},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Generated by Django 5.1.6 on 2025-03-05 04:19
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0010_alter_account_managers_alter_accountgroup_managers_and_more'),
|
||||||
|
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='owned_accounts', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='accountgroup',
|
||||||
|
name='owner',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='owned_account_groups', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Generated by Django 5.1.6 on 2025-03-05 23:46
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0011_alter_account_owner_alter_accountgroup_owner'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelManagers(
|
||||||
|
name='account',
|
||||||
|
managers=[
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AlterModelManagers(
|
||||||
|
name='accountgroup',
|
||||||
|
managers=[
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='account',
|
||||||
|
name='visibility',
|
||||||
|
field=models.CharField(choices=[('private', 'Private'), ('shared', 'Shared'), ('public', 'Public')], default='private', max_length=10),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='accountgroup',
|
||||||
|
name='shared_with',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='accountgroup',
|
||||||
|
name='visibility',
|
||||||
|
field=models.CharField(choices=[('private', 'Private'), ('shared', 'Shared'), ('public', 'Public')], default='private', max_length=10),
|
||||||
|
),
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='account',
|
||||||
|
name='shared_with',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.1.6 on 2025-03-06 01:47
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0012_alter_account_managers_alter_accountgroup_managers_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='account',
|
||||||
|
name='visibility',
|
||||||
|
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='accountgroup',
|
||||||
|
name='visibility',
|
||||||
|
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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'},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,24 +1,32 @@
|
|||||||
|
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.transactions.models import Transaction
|
||||||
|
from apps.common.models import SharedObject, SharedObjectManager
|
||||||
|
|
||||||
|
|
||||||
class AccountGroup(models.Model):
|
class AccountGroup(SharedObject):
|
||||||
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True)
|
name = models.CharField(max_length=255, verbose_name=_("Name"))
|
||||||
|
|
||||||
|
objects = SharedObjectManager()
|
||||||
|
all_objects = models.Manager() # Unfiltered manager
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Account Group")
|
verbose_name = _("Account Group")
|
||||||
verbose_name_plural = _("Account Groups")
|
verbose_name_plural = _("Account Groups")
|
||||||
db_table = "account_groups"
|
db_table = "account_groups"
|
||||||
|
unique_together = (("owner", "name"),)
|
||||||
|
ordering = ["name", "id"]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
class Account(models.Model):
|
class Account(SharedObject):
|
||||||
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True)
|
name = models.CharField(max_length=255, verbose_name=_("Name"))
|
||||||
group = models.ForeignKey(
|
group = models.ForeignKey(
|
||||||
AccountGroup,
|
AccountGroup,
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
@@ -55,9 +63,14 @@ class Account(models.Model):
|
|||||||
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"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
objects = SharedObjectManager()
|
||||||
|
all_objects = models.Manager() # Unfiltered manager
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Account")
|
verbose_name = _("Account")
|
||||||
verbose_name_plural = _("Accounts")
|
verbose_name_plural = _("Accounts")
|
||||||
|
unique_together = (("owner", "name"),)
|
||||||
|
ordering = ["name", "id"]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|||||||
@@ -16,11 +16,21 @@ urlpatterns = [
|
|||||||
views.account_edit,
|
views.account_edit,
|
||||||
name="account_edit",
|
name="account_edit",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"account/<int:pk>/share/",
|
||||||
|
views.account_share,
|
||||||
|
name="account_share_settings",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"account/<int:pk>/delete/",
|
"account/<int:pk>/delete/",
|
||||||
views.account_delete,
|
views.account_delete,
|
||||||
name="account_delete",
|
name="account_delete",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"account/<int:pk>/take-ownership/",
|
||||||
|
views.account_take_ownership,
|
||||||
|
name="account_take_ownership",
|
||||||
|
),
|
||||||
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"),
|
||||||
@@ -34,4 +44,14 @@ urlpatterns = [
|
|||||||
views.account_group_delete,
|
views.account_group_delete,
|
||||||
name="account_group_delete",
|
name="account_group_delete",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"account-groups/<int:pk>/take-ownership/",
|
||||||
|
views.account_group_take_ownership,
|
||||||
|
name="account_group_take_ownership",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"account-groups/<int:pk>/share/",
|
||||||
|
views.account_group_share,
|
||||||
|
name="account_group_share_settings",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ from django.views.decorators.http import require_http_methods
|
|||||||
from apps.accounts.forms import AccountGroupForm
|
from apps.accounts.forms import AccountGroupForm
|
||||||
from apps.accounts.models import AccountGroup
|
from apps.accounts.models import AccountGroup
|
||||||
from apps.common.decorators.htmx import only_htmx
|
from apps.common.decorators.htmx import only_htmx
|
||||||
|
from apps.common.models import SharedObject
|
||||||
|
from apps.common.forms import SharedObjectForm
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -63,6 +65,16 @@ def account_group_add(request, **kwargs):
|
|||||||
def account_group_edit(request, pk):
|
def account_group_edit(request, pk):
|
||||||
account_group = get_object_or_404(AccountGroup, id=pk)
|
account_group = get_object_or_404(AccountGroup, id=pk)
|
||||||
|
|
||||||
|
if account_group.owner and account_group.owner != request.user:
|
||||||
|
messages.error(request, _("Only the owner can edit this"))
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
headers={
|
||||||
|
"HX-Trigger": "updated, hide_offcanvas",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = AccountGroupForm(request.POST, instance=account_group)
|
form = AccountGroupForm(request.POST, instance=account_group)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
@@ -91,8 +103,14 @@ def account_group_edit(request, pk):
|
|||||||
def account_group_delete(request, pk):
|
def account_group_delete(request, pk):
|
||||||
account_group = get_object_or_404(AccountGroup, id=pk)
|
account_group = get_object_or_404(AccountGroup, id=pk)
|
||||||
|
|
||||||
|
if (
|
||||||
|
account_group.owner != request.user
|
||||||
|
and request.user in account_group.shared_with.all()
|
||||||
|
):
|
||||||
|
account_group.shared_with.remove(request.user)
|
||||||
|
messages.success(request, _("Item no longer shared with you"))
|
||||||
|
else:
|
||||||
account_group.delete()
|
account_group.delete()
|
||||||
|
|
||||||
messages.success(request, _("Account Group deleted successfully"))
|
messages.success(request, _("Account Group deleted successfully"))
|
||||||
|
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
@@ -101,3 +119,62 @@ def account_group_delete(request, pk):
|
|||||||
"HX-Trigger": "updated, hide_offcanvas",
|
"HX-Trigger": "updated, hide_offcanvas",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def account_group_take_ownership(request, pk):
|
||||||
|
account_group = get_object_or_404(AccountGroup, id=pk)
|
||||||
|
|
||||||
|
if not account_group.owner:
|
||||||
|
account_group.owner = request.user
|
||||||
|
account_group.visibility = SharedObject.Visibility.private
|
||||||
|
account_group.save()
|
||||||
|
|
||||||
|
messages.success(request, _("Ownership taken successfully"))
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
headers={
|
||||||
|
"HX-Trigger": "updated, hide_offcanvas",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def account_group_share(request, pk):
|
||||||
|
obj = get_object_or_404(AccountGroup, id=pk)
|
||||||
|
|
||||||
|
if obj.owner and obj.owner != request.user:
|
||||||
|
messages.error(request, _("Only the owner can edit this"))
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
headers={
|
||||||
|
"HX-Trigger": "updated, hide_offcanvas",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
form = SharedObjectForm(request.POST, instance=obj, user=request.user)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
messages.success(request, _("Configuration saved successfully"))
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
headers={
|
||||||
|
"HX-Trigger": "updated, hide_offcanvas",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
form = SharedObjectForm(instance=obj, user=request.user)
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"accounts/fragments/share.html",
|
||||||
|
{"form": form, "object": obj},
|
||||||
|
)
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ from django.views.decorators.http import require_http_methods
|
|||||||
from apps.accounts.forms import AccountForm
|
from apps.accounts.forms import AccountForm
|
||||||
from apps.accounts.models import Account
|
from apps.accounts.models import Account
|
||||||
from apps.common.decorators.htmx import only_htmx
|
from apps.common.decorators.htmx import only_htmx
|
||||||
|
from apps.common.models import SharedObject
|
||||||
|
from apps.common.forms import SharedObjectForm
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -62,6 +64,15 @@ def account_add(request, **kwargs):
|
|||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET", "POST"])
|
||||||
def account_edit(request, pk):
|
def account_edit(request, pk):
|
||||||
account = get_object_or_404(Account, id=pk)
|
account = get_object_or_404(Account, id=pk)
|
||||||
|
if account.owner and account.owner != request.user:
|
||||||
|
messages.error(request, _("Only the owner can edit this"))
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
headers={
|
||||||
|
"HX-Trigger": "updated, hide_offcanvas",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = AccountForm(request.POST, instance=account)
|
form = AccountForm(request.POST, instance=account)
|
||||||
@@ -85,14 +96,55 @@ def account_edit(request, pk):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def account_share(request, pk):
|
||||||
|
obj = get_object_or_404(Account, id=pk)
|
||||||
|
|
||||||
|
if obj.owner and obj.owner != request.user:
|
||||||
|
messages.error(request, _("Only the owner can edit this"))
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
headers={
|
||||||
|
"HX-Trigger": "updated, hide_offcanvas",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
form = SharedObjectForm(request.POST, instance=obj, user=request.user)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
messages.success(request, _("Configuration saved successfully"))
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
headers={
|
||||||
|
"HX-Trigger": "updated, hide_offcanvas",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
form = SharedObjectForm(instance=obj, user=request.user)
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"accounts/fragments/share.html",
|
||||||
|
{"form": form, "object": obj},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
@require_http_methods(["DELETE"])
|
@require_http_methods(["DELETE"])
|
||||||
def account_delete(request, pk):
|
def account_delete(request, pk):
|
||||||
account = get_object_or_404(Account, id=pk)
|
account = get_object_or_404(Account, id=pk)
|
||||||
|
|
||||||
|
if account.owner != request.user and request.user in account.shared_with.all():
|
||||||
|
account.shared_with.remove(request.user)
|
||||||
|
messages.success(request, _("Item no longer shared with you"))
|
||||||
|
else:
|
||||||
account.delete()
|
account.delete()
|
||||||
|
|
||||||
messages.success(request, _("Account deleted successfully"))
|
messages.success(request, _("Account deleted successfully"))
|
||||||
|
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
@@ -101,3 +153,24 @@ def account_delete(request, pk):
|
|||||||
"HX-Trigger": "updated, hide_offcanvas",
|
"HX-Trigger": "updated, hide_offcanvas",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def account_take_ownership(request, pk):
|
||||||
|
account = get_object_or_404(Account, id=pk)
|
||||||
|
|
||||||
|
if not account.owner:
|
||||||
|
account.owner = request.user
|
||||||
|
account.visibility = SharedObject.Visibility.private
|
||||||
|
account.save()
|
||||||
|
|
||||||
|
messages.success(request, _("Ownership taken successfully"))
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
headers={
|
||||||
|
"HX-Trigger": "updated, hide_offcanvas",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -38,9 +38,9 @@ def account_reconciliation(request):
|
|||||||
"prefix": account.currency.prefix,
|
"prefix": account.currency.prefix,
|
||||||
"current_balance": get_account_balance(account),
|
"current_balance": get_account_balance(account),
|
||||||
}
|
}
|
||||||
for account in Account.objects.filter(is_archived=False).select_related(
|
for account in Account.objects.filter(is_archived=False)
|
||||||
"currency", "group"
|
.select_related("currency", "group")
|
||||||
)
|
.order_by("group", "name")
|
||||||
]
|
]
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
|
|||||||
6
app/apps/api/custom/pagination.py
Normal file
6
app/apps/api/custom/pagination.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from rest_framework.pagination import PageNumberPagination
|
||||||
|
|
||||||
|
|
||||||
|
class CustomPageNumberPagination(PageNumberPagination):
|
||||||
|
page_size = 100
|
||||||
|
page_size_query_param = "page_size"
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
from drf_spectacular.types import OpenApiTypes
|
|
||||||
from drf_spectacular.utils import extend_schema_field
|
from drf_spectacular.utils import extend_schema_field
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from apps.transactions.models import (
|
from apps.transactions.models import (
|
||||||
TransactionCategory,
|
TransactionCategory,
|
||||||
TransactionTag,
|
TransactionTag,
|
||||||
@@ -29,7 +27,11 @@ class TransactionCategoryField(serializers.Field):
|
|||||||
_("Category with this ID does not exist.")
|
_("Category with this ID does not exist.")
|
||||||
)
|
)
|
||||||
elif isinstance(data, str):
|
elif isinstance(data, str):
|
||||||
category, created = TransactionCategory.objects.get_or_create(name=data)
|
try:
|
||||||
|
category = TransactionCategory.objects.get(name=data)
|
||||||
|
except TransactionCategory.DoesNotExist:
|
||||||
|
category = TransactionCategory(name=data)
|
||||||
|
category.save()
|
||||||
return category
|
return category
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
_("Invalid category data. Provide an ID or name.")
|
_("Invalid category data. Provide an ID or name.")
|
||||||
@@ -39,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",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -65,7 +70,11 @@ class TransactionTagField(serializers.Field):
|
|||||||
_("Tag with this ID does not exist.")
|
_("Tag with this ID does not exist.")
|
||||||
)
|
)
|
||||||
elif isinstance(item, str):
|
elif isinstance(item, str):
|
||||||
tag, created = TransactionTag.objects.get_or_create(name=item)
|
try:
|
||||||
|
tag = TransactionTag.objects.get(name=item)
|
||||||
|
except TransactionTag.DoesNotExist:
|
||||||
|
tag = TransactionTag(name=item)
|
||||||
|
tag.save()
|
||||||
else:
|
else:
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
_("Invalid tag data. Provide an ID or name.")
|
_("Invalid tag data. Provide an ID or name.")
|
||||||
@@ -74,6 +83,13 @@ class TransactionTagField(serializers.Field):
|
|||||||
return tags
|
return tags
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema_field(
|
||||||
|
{
|
||||||
|
"type": "array",
|
||||||
|
"items": {"oneOf": [{"type": "string"}, {"type": "integer"}]},
|
||||||
|
"description": "TransactionEntity ID or name. If the name doesn't exist, a new one will be created",
|
||||||
|
}
|
||||||
|
)
|
||||||
class TransactionEntityField(serializers.Field):
|
class TransactionEntityField(serializers.Field):
|
||||||
def to_representation(self, value):
|
def to_representation(self, value):
|
||||||
return [{"id": entity.id, "name": entity.name} for entity in value.all()]
|
return [{"id": entity.id, "name": entity.name} for entity in value.all()]
|
||||||
@@ -84,12 +100,16 @@ class TransactionEntityField(serializers.Field):
|
|||||||
if isinstance(item, int):
|
if isinstance(item, int):
|
||||||
try:
|
try:
|
||||||
entity = TransactionEntity.objects.get(pk=item)
|
entity = TransactionEntity.objects.get(pk=item)
|
||||||
except TransactionTag.DoesNotExist:
|
except TransactionEntity.DoesNotExist:
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
_("Entity with this ID does not exist.")
|
_("Entity with this ID does not exist.")
|
||||||
)
|
)
|
||||||
elif isinstance(item, str):
|
elif isinstance(item, str):
|
||||||
entity, created = TransactionEntity.objects.get_or_create(name=item)
|
try:
|
||||||
|
entity = TransactionEntity.objects.get(name=item)
|
||||||
|
except TransactionEntity.DoesNotExist:
|
||||||
|
entity = TransactionEntity(name=item)
|
||||||
|
entity.save()
|
||||||
else:
|
else:
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
_("Invalid entity data. Provide an ID or name.")
|
_("Invalid entity data. Provide an ID or name.")
|
||||||
|
|||||||
10
app/apps/api/permissions.py
Normal file
10
app/apps/api/permissions.py
Normal file
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from drf_spectacular import openapi
|
from drf_spectacular import openapi
|
||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
@@ -21,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):
|
||||||
@@ -29,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):
|
||||||
@@ -37,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):
|
||||||
@@ -45,12 +56,16 @@ 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):
|
||||||
category = TransactionCategoryField(required=False)
|
category: str | int = TransactionCategoryField(required=False)
|
||||||
tags = TransactionTagField(required=False)
|
tags: str | int = TransactionTagField(required=False)
|
||||||
entities = TransactionEntityField(required=False)
|
entities: str | int = TransactionEntityField(required=False)
|
||||||
|
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
@@ -88,9 +103,9 @@ class InstallmentPlanSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class RecurringTransactionSerializer(serializers.ModelSerializer):
|
class RecurringTransactionSerializer(serializers.ModelSerializer):
|
||||||
category = TransactionCategoryField(required=False)
|
category: str | int = TransactionCategoryField(required=False)
|
||||||
tags = TransactionTagField(required=False)
|
tags: str | int = TransactionTagField(required=False)
|
||||||
entities = TransactionEntityField(required=False)
|
entities: str | int = TransactionEntityField(required=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = RecurringTransaction
|
model = RecurringTransaction
|
||||||
@@ -127,9 +142,9 @@ class RecurringTransactionSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class TransactionSerializer(serializers.ModelSerializer):
|
class TransactionSerializer(serializers.ModelSerializer):
|
||||||
category = TransactionCategoryField(required=False)
|
category: str | int = TransactionCategoryField(required=False)
|
||||||
tags = TransactionTagField(required=False)
|
tags: str | int = TransactionTagField(required=False)
|
||||||
entities = TransactionEntityField(required=False)
|
entities: str | int = TransactionEntityField(required=False)
|
||||||
|
|
||||||
exchanged_amount = serializers.SerializerMethodField()
|
exchanged_amount = serializers.SerializerMethodField()
|
||||||
|
|
||||||
@@ -155,8 +170,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:
|
||||||
@@ -192,5 +215,5 @@ class TransactionSerializer(serializers.ModelSerializer):
|
|||||||
return instance
|
return instance
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_exchanged_amount(obj):
|
def get_exchanged_amount(obj) -> Decimal:
|
||||||
return obj.exchanged_amount()
|
return obj.exchanged_amount()
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
from rest_framework import viewsets
|
from rest_framework import viewsets
|
||||||
|
|
||||||
|
from apps.api.custom.pagination import CustomPageNumberPagination
|
||||||
from apps.accounts.models import AccountGroup, Account
|
from apps.accounts.models import AccountGroup, Account
|
||||||
from apps.api.serializers import AccountGroupSerializer, AccountSerializer
|
from apps.api.serializers import AccountGroupSerializer, AccountSerializer
|
||||||
|
|
||||||
@@ -6,12 +8,20 @@ from apps.api.serializers import AccountGroupSerializer, AccountSerializer
|
|||||||
class AccountGroupViewSet(viewsets.ModelViewSet):
|
class AccountGroupViewSet(viewsets.ModelViewSet):
|
||||||
queryset = AccountGroup.objects.all()
|
queryset = AccountGroup.objects.all()
|
||||||
serializer_class = AccountGroupSerializer
|
serializer_class = AccountGroupSerializer
|
||||||
|
pagination_class = CustomPageNumberPagination
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return AccountGroup.objects.all().order_by("id")
|
||||||
|
|
||||||
|
|
||||||
class AccountViewSet(viewsets.ModelViewSet):
|
class AccountViewSet(viewsets.ModelViewSet):
|
||||||
queryset = Account.objects.all()
|
queryset = Account.objects.all()
|
||||||
serializer_class = AccountSerializer
|
serializer_class = AccountSerializer
|
||||||
|
pagination_class = CustomPageNumberPagination
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
queryset = super().get_queryset()
|
return (
|
||||||
return queryset.select_related("group", "currency", "exchange_currency")
|
Account.objects.all()
|
||||||
|
.order_by("id")
|
||||||
|
.select_related("group", "currency", "exchange_currency")
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from rest_framework import viewsets
|
from rest_framework import viewsets
|
||||||
|
|
||||||
|
from apps.api.custom.pagination import CustomPageNumberPagination
|
||||||
from apps.api.serializers import (
|
from apps.api.serializers import (
|
||||||
TransactionSerializer,
|
TransactionSerializer,
|
||||||
TransactionCategorySerializer,
|
TransactionCategorySerializer,
|
||||||
@@ -22,6 +23,7 @@ from apps.rules.signals import transaction_updated, transaction_created
|
|||||||
class TransactionViewSet(viewsets.ModelViewSet):
|
class TransactionViewSet(viewsets.ModelViewSet):
|
||||||
queryset = Transaction.objects.all()
|
queryset = Transaction.objects.all()
|
||||||
serializer_class = TransactionSerializer
|
serializer_class = TransactionSerializer
|
||||||
|
pagination_class = CustomPageNumberPagination
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
instance = serializer.save()
|
instance = serializer.save()
|
||||||
@@ -35,27 +37,50 @@ class TransactionViewSet(viewsets.ModelViewSet):
|
|||||||
kwargs["partial"] = True
|
kwargs["partial"] = True
|
||||||
return self.update(request, *args, **kwargs)
|
return self.update(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Transaction.objects.all().order_by("-id")
|
||||||
|
|
||||||
|
|
||||||
class TransactionCategoryViewSet(viewsets.ModelViewSet):
|
class TransactionCategoryViewSet(viewsets.ModelViewSet):
|
||||||
queryset = TransactionCategory.objects.all()
|
queryset = TransactionCategory.objects.all()
|
||||||
serializer_class = TransactionCategorySerializer
|
serializer_class = TransactionCategorySerializer
|
||||||
|
pagination_class = CustomPageNumberPagination
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return TransactionCategory.objects.all().order_by("id")
|
||||||
|
|
||||||
|
|
||||||
class TransactionTagViewSet(viewsets.ModelViewSet):
|
class TransactionTagViewSet(viewsets.ModelViewSet):
|
||||||
queryset = TransactionTag.objects.all()
|
queryset = TransactionTag.objects.all()
|
||||||
serializer_class = TransactionTagSerializer
|
serializer_class = TransactionTagSerializer
|
||||||
|
pagination_class = CustomPageNumberPagination
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return TransactionTag.objects.all().order_by("id")
|
||||||
|
|
||||||
|
|
||||||
class TransactionEntityViewSet(viewsets.ModelViewSet):
|
class TransactionEntityViewSet(viewsets.ModelViewSet):
|
||||||
queryset = TransactionEntity.objects.all()
|
queryset = TransactionEntity.objects.all()
|
||||||
serializer_class = TransactionEntitySerializer
|
serializer_class = TransactionEntitySerializer
|
||||||
|
pagination_class = CustomPageNumberPagination
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return TransactionEntity.objects.all().order_by("id")
|
||||||
|
|
||||||
|
|
||||||
class InstallmentPlanViewSet(viewsets.ModelViewSet):
|
class InstallmentPlanViewSet(viewsets.ModelViewSet):
|
||||||
queryset = InstallmentPlan.objects.all()
|
queryset = InstallmentPlan.objects.all()
|
||||||
serializer_class = InstallmentPlanSerializer
|
serializer_class = InstallmentPlanSerializer
|
||||||
|
pagination_class = CustomPageNumberPagination
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return InstallmentPlan.objects.all().order_by("-id")
|
||||||
|
|
||||||
|
|
||||||
class RecurringTransactionViewSet(viewsets.ModelViewSet):
|
class RecurringTransactionViewSet(viewsets.ModelViewSet):
|
||||||
queryset = RecurringTransaction.objects.all()
|
queryset = RecurringTransaction.objects.all()
|
||||||
serializer_class = RecurringTransactionSerializer
|
serializer_class = RecurringTransactionSerializer
|
||||||
|
pagination_class = CustomPageNumberPagination
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return RecurringTransaction.objects.all().order_by("-id")
|
||||||
|
|||||||
7
app/apps/common/admin.py
Normal file
7
app/apps/common/admin.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
|
||||||
|
class SharedObjectModelAdmin(admin.ModelAdmin):
|
||||||
|
def get_queryset(self, request):
|
||||||
|
# Use the all_objects manager to show all transactions, including deleted ones
|
||||||
|
return self.model.all_objects.all()
|
||||||
@@ -4,3 +4,17 @@ from django.apps import AppConfig
|
|||||||
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)
|
||||||
|
|||||||
15
app/apps/common/decorators/demo.py
Normal file
15
app/apps/common/decorators/demo.py
Normal file
@@ -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
|
||||||
78
app/apps/common/decorators/user.py
Normal file
78
app/apps/common/decorators/user.py
Normal file
@@ -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
|
||||||
@@ -4,6 +4,7 @@ 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.tom_select import TomSelect, TomSelectMultiple
|
from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple
|
||||||
|
from apps.common.middleware.thread_local import get_current_user
|
||||||
|
|
||||||
|
|
||||||
class DynamicModelChoiceField(forms.ModelChoiceField):
|
class DynamicModelChoiceField(forms.ModelChoiceField):
|
||||||
@@ -12,15 +13,14 @@ class DynamicModelChoiceField(forms.ModelChoiceField):
|
|||||||
self.to_field_name = kwargs.pop("to_field_name", "pk")
|
self.to_field_name = kwargs.pop("to_field_name", "pk")
|
||||||
|
|
||||||
self.create_field = kwargs.pop("create_field", None)
|
self.create_field = kwargs.pop("create_field", None)
|
||||||
if not self.create_field:
|
|
||||||
raise ValueError("The 'create_field' parameter is required.")
|
|
||||||
|
|
||||||
self.queryset = kwargs.pop("queryset", model.objects.all())
|
self.queryset = kwargs.pop("queryset", model.objects.all())
|
||||||
super().__init__(queryset=self.queryset, *args, **kwargs)
|
|
||||||
self._created_instance = None
|
|
||||||
|
|
||||||
self.widget = TomSelect(clear_button=True, create=True)
|
self.widget = TomSelect(clear_button=True, create=True)
|
||||||
|
|
||||||
|
super().__init__(queryset=self.queryset, *args, **kwargs)
|
||||||
|
self._created_instance = None
|
||||||
|
|
||||||
def to_python(self, value):
|
def to_python(self, value):
|
||||||
if value in self.empty_values:
|
if value in self.empty_values:
|
||||||
return None
|
return None
|
||||||
@@ -53,17 +53,27 @@ class DynamicModelChoiceField(forms.ModelChoiceField):
|
|||||||
else:
|
else:
|
||||||
raise self.model.DoesNotExist
|
raise self.model.DoesNotExist
|
||||||
except self.model.DoesNotExist:
|
except self.model.DoesNotExist:
|
||||||
|
if self.create_field:
|
||||||
try:
|
try:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
instance, _ = self.model.objects.update_or_create(
|
# First try to get the object
|
||||||
**{self.create_field: value}
|
lookup = {self.create_field: value}
|
||||||
)
|
try:
|
||||||
|
instance = self.model.objects.get(**lookup)
|
||||||
|
except self.model.DoesNotExist:
|
||||||
|
# Create a new instance directly
|
||||||
|
instance = self.model(**lookup)
|
||||||
|
instance.save()
|
||||||
|
|
||||||
self._created_instance = instance
|
self._created_instance = instance
|
||||||
return instance
|
return instance
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
raise ValidationError(_("Error creating new instance"))
|
||||||
|
else:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
self.error_messages["invalid_choice"], code="invalid_choice"
|
self.error_messages["invalid_choice"], code="invalid_choice"
|
||||||
)
|
)
|
||||||
|
|
||||||
return super().clean(value)
|
return super().clean(value)
|
||||||
|
|
||||||
def bound_data(self, data, initial):
|
def bound_data(self, data, initial):
|
||||||
@@ -86,8 +96,6 @@ class DynamicModelMultipleChoiceField(forms.ModelMultipleChoiceField):
|
|||||||
|
|
||||||
def __init__(self, model, **kwargs):
|
def __init__(self, model, **kwargs):
|
||||||
"""
|
"""
|
||||||
Initialize the CreateIfNotExistsModelMultipleChoiceField.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
create_field (str): The name of the field to use when creating new instances.
|
create_field (str): The name of the field to use when creating new instances.
|
||||||
*args: Variable length argument list.
|
*args: Variable length argument list.
|
||||||
@@ -119,33 +127,28 @@ class DynamicModelMultipleChoiceField(forms.ModelMultipleChoiceField):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
instance, _ = self.model.objects.update_or_create(
|
# Check if exists first without using update_or_create
|
||||||
**{self.create_field: value}
|
lookup = {self.create_field: value}
|
||||||
)
|
try:
|
||||||
|
# Use base manager to bypass distinct filters
|
||||||
|
instance = self.model.objects.get(**lookup)
|
||||||
|
return instance
|
||||||
|
except self.model.DoesNotExist:
|
||||||
|
# Create a new instance directly
|
||||||
|
instance = self.model(**lookup)
|
||||||
|
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):
|
||||||
"""
|
|
||||||
Clean and validate the field value.
|
|
||||||
|
|
||||||
This method checks if each selected choice exists in the database.
|
|
||||||
If a choice doesn't exist, it creates a new instance of the model.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
value (list): List of selected values.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list: A list containing all selected and newly created model instances.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValidationError: If there's an error during the cleaning process.
|
|
||||||
"""
|
|
||||||
if not value:
|
if not value:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
string_values = set(str(v) for v in value)
|
string_values = set(str(v) for v in value)
|
||||||
|
|
||||||
|
# Get existing objects first
|
||||||
existing_objects = list(
|
existing_objects = list(
|
||||||
self.queryset.filter(**{f"{self.create_field}__in": string_values})
|
self.queryset.filter(**{f"{self.create_field}__in": string_values})
|
||||||
)
|
)
|
||||||
@@ -153,13 +156,11 @@ class DynamicModelMultipleChoiceField(forms.ModelMultipleChoiceField):
|
|||||||
str(getattr(obj, self.create_field)) for obj in existing_objects
|
str(getattr(obj, self.create_field)) for obj in existing_objects
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Create new objects for missing values
|
||||||
new_values = string_values - existing_values
|
new_values = string_values - existing_values
|
||||||
new_objects = []
|
new_objects = []
|
||||||
|
|
||||||
for new_value in new_values:
|
for new_value in new_values:
|
||||||
try:
|
|
||||||
new_objects.append(self._create_new_instance(new_value))
|
new_objects.append(self._create_new_instance(new_value))
|
||||||
except ValidationError as e:
|
|
||||||
raise ValidationError(_("Error creating new instance"))
|
|
||||||
|
|
||||||
return existing_objects + new_objects
|
return existing_objects + new_objects
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
113
app/apps/common/forms.py
Normal file
113
app/apps/common/forms.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
from crispy_forms.bootstrap import FormActions
|
||||||
|
from django import forms
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from crispy_forms.helper import FormHelper
|
||||||
|
from crispy_forms.layout import Layout, Field, Submit, Div, HTML
|
||||||
|
|
||||||
|
from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple
|
||||||
|
from apps.common.models import SharedObject
|
||||||
|
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class SharedObjectForm(forms.Form):
|
||||||
|
"""
|
||||||
|
Generic form for editing visibility and sharing settings
|
||||||
|
for models inheriting from SharedObject.
|
||||||
|
"""
|
||||||
|
|
||||||
|
owner = forms.ModelChoiceField(
|
||||||
|
queryset=User.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label=_("Owner"),
|
||||||
|
widget=TomSelect(clear_button=False),
|
||||||
|
help_text=_(
|
||||||
|
"The owner of this object, if empty all users can see, edit and take ownership."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
shared_with_users = forms.ModelMultipleChoiceField(
|
||||||
|
queryset=User.objects.all(),
|
||||||
|
required=False,
|
||||||
|
widget=TomSelectMultiple(clear_button=True),
|
||||||
|
label=_("Shared with users"),
|
||||||
|
help_text=_("Select users to share this object with"),
|
||||||
|
)
|
||||||
|
visibility = forms.ChoiceField(
|
||||||
|
choices=SharedObject.Visibility.choices,
|
||||||
|
required=True,
|
||||||
|
label=_("Visibility"),
|
||||||
|
help_text=_(
|
||||||
|
"Private: Only shown for the owner and shared users. Only editable by the owner."
|
||||||
|
"<br/>"
|
||||||
|
"Public: Shown for all users. Only editable by the owner."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
fields = ["visibility", "shared_with_users"]
|
||||||
|
widgets = {
|
||||||
|
"visibility": TomSelect(clear_button=False),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
# Get the current user to filter available sharing options
|
||||||
|
self.user = kwargs.pop("user", None)
|
||||||
|
self.instance = kwargs.pop("instance", None)
|
||||||
|
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
# Pre-populate shared users if instance exists
|
||||||
|
if self.instance:
|
||||||
|
self.fields["shared_with_users"].initial = self.instance.shared_with.all()
|
||||||
|
self.fields["visibility"].initial = self.instance.visibility
|
||||||
|
self.fields["owner"].initial = self.instance.owner
|
||||||
|
|
||||||
|
# Set up crispy form helper
|
||||||
|
self.helper = FormHelper()
|
||||||
|
self.helper.form_method = "post"
|
||||||
|
self.helper.form_tag = False
|
||||||
|
|
||||||
|
self.helper.layout = Layout(
|
||||||
|
Field("owner"),
|
||||||
|
Field("visibility"),
|
||||||
|
HTML("<hr>"),
|
||||||
|
Field("shared_with_users"),
|
||||||
|
FormActions(
|
||||||
|
NoClassSubmit(
|
||||||
|
"submit", _("Save"), css_class="btn btn-outline-primary w-100"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
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):
|
||||||
|
instance = self.instance
|
||||||
|
|
||||||
|
instance.visibility = self.cleaned_data["visibility"]
|
||||||
|
instance.owner = self.cleaned_data["owner"]
|
||||||
|
|
||||||
|
instance.save()
|
||||||
|
|
||||||
|
# Clear and set shared_with users
|
||||||
|
instance.shared_with.set(self.cleaned_data.get("shared_with_users", []))
|
||||||
|
|
||||||
|
return instance
|
||||||
@@ -5,7 +5,7 @@ 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:
|
||||||
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)
|
||||||
|
|||||||
0
app/apps/common/management/__init__.py
Normal file
0
app/apps/common/management/__init__.py
Normal file
0
app/apps/common/management/commands/__init__.py
Normal file
0
app/apps/common/management/commands/__init__.py
Normal file
137
app/apps/common/management/commands/setup_users.py
Normal file
137
app/apps/common/management/commands/setup_users.py
Normal file
@@ -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."))
|
||||||
@@ -56,6 +56,16 @@ def get_current_user():
|
|||||||
if request:
|
if request:
|
||||||
return getattr(request, "user", None)
|
return getattr(request, "user", None)
|
||||||
|
|
||||||
|
return getattr(_thread_locals, "user", None)
|
||||||
|
|
||||||
|
|
||||||
|
def write_current_user(user):
|
||||||
|
_thread_locals.user = user
|
||||||
|
|
||||||
|
|
||||||
|
def delete_current_user():
|
||||||
|
del _thread_locals.user
|
||||||
|
|
||||||
|
|
||||||
class ThreadLocalMiddleware(MiddlewareMixin):
|
class ThreadLocalMiddleware(MiddlewareMixin):
|
||||||
"""Simple middleware that adds the request object in thread local storage."""
|
"""Simple middleware that adds the request object in thread local storage."""
|
||||||
|
|||||||
96
app/apps/common/models.py
Normal file
96
app/apps/common/models.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from apps.common.middleware.thread_local import get_current_user
|
||||||
|
|
||||||
|
|
||||||
|
class SharedObjectManager(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(visibility="public")
|
||||||
|
| Q(owner=user)
|
||||||
|
| Q(shared_with=user)
|
||||||
|
| Q(visibility="private", owner=None)
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
return base_qs.filter(visibility="public")
|
||||||
|
|
||||||
|
|
||||||
|
class SharedObject(models.Model):
|
||||||
|
# Access control enum
|
||||||
|
class Visibility(models.TextChoices):
|
||||||
|
private = "private", _("Private")
|
||||||
|
is_paid = "public", _("Public")
|
||||||
|
|
||||||
|
# Core sharing fields
|
||||||
|
owner = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="%(class)s_owned",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
visibility = models.CharField(
|
||||||
|
max_length=10, choices=Visibility.choices, default=Visibility.private
|
||||||
|
)
|
||||||
|
shared_with = models.ManyToManyField(
|
||||||
|
settings.AUTH_USER_MODEL, related_name="%(class)s_shared", blank=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use as abstract base class
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["visibility"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def is_accessible_by(self, user):
|
||||||
|
"""Check if a user can access this object"""
|
||||||
|
return (
|
||||||
|
self.visibility == "public"
|
||||||
|
or self.owner == user
|
||||||
|
or (self.visibility == "shared" and user in self.shared_with.all())
|
||||||
|
)
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if not self.pk and not self.owner:
|
||||||
|
self.owner = get_current_user()
|
||||||
|
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):
|
||||||
|
owner = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="%(class)s_owned",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use as abstract base class
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if not self.pk and not self.owner:
|
||||||
|
self.owner = get_current_user()
|
||||||
|
super().save(*args, **kwargs)
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
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 procrastinate import builtin_tasks
|
from procrastinate import builtin_tasks
|
||||||
from procrastinate.contrib.django import app
|
from procrastinate.contrib.django import app
|
||||||
@@ -40,3 +42,40 @@ 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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ class AirDatePickerInput(widgets.DateInput):
|
|||||||
format=None,
|
format=None,
|
||||||
clear_button=True,
|
clear_button=True,
|
||||||
auto_close=True,
|
auto_close=True,
|
||||||
|
read_only=True,
|
||||||
|
toggle_selected=None,
|
||||||
*args,
|
*args,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
@@ -26,12 +28,18 @@ class AirDatePickerInput(widgets.DateInput):
|
|||||||
super().__init__(attrs=attrs, format=format, *args, **kwargs)
|
super().__init__(attrs=attrs, format=format, *args, **kwargs)
|
||||||
self.clear_button = clear_button
|
self.clear_button = clear_button
|
||||||
self.auto_close = auto_close
|
self.auto_close = auto_close
|
||||||
|
self.read_only = read_only
|
||||||
|
self.toggle_selected = (
|
||||||
|
toggle_selected if isinstance(toggle_selected, bool) else self.clear_button
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
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):
|
||||||
@@ -47,9 +55,13 @@ class AirDatePickerInput(widgets.DateInput):
|
|||||||
attrs["data-now-button-txt"] = _("Today")
|
attrs["data-now-button-txt"] = _("Today")
|
||||||
attrs["data-auto-close"] = str(self.auto_close).lower()
|
attrs["data-auto-close"] = str(self.auto_close).lower()
|
||||||
attrs["data-clear-button"] = str(self.clear_button).lower()
|
attrs["data-clear-button"] = str(self.clear_button).lower()
|
||||||
|
attrs["data-toggle-selected"] = str(self.toggle_selected).lower()
|
||||||
attrs["data-language"] = self._get_current_language()
|
attrs["data-language"] = self._get_current_language()
|
||||||
attrs["data-date-format"] = django_to_airdatepicker_datetime(self._get_format())
|
attrs["data-date-format"] = django_to_airdatepicker_datetime(self._get_format())
|
||||||
|
|
||||||
|
if self.read_only:
|
||||||
|
attrs["readonly"] = True
|
||||||
|
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
def format_value(self, value):
|
def format_value(self, value):
|
||||||
@@ -89,6 +101,8 @@ class AirDateTimePickerInput(widgets.DateTimeInput):
|
|||||||
timepicker=True,
|
timepicker=True,
|
||||||
clear_button=True,
|
clear_button=True,
|
||||||
auto_close=True,
|
auto_close=True,
|
||||||
|
read_only=True,
|
||||||
|
toggle_selected=None,
|
||||||
*args,
|
*args,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
@@ -97,6 +111,10 @@ class AirDateTimePickerInput(widgets.DateTimeInput):
|
|||||||
self.timepicker = timepicker
|
self.timepicker = timepicker
|
||||||
self.clear_button = clear_button
|
self.clear_button = clear_button
|
||||||
self.auto_close = auto_close
|
self.auto_close = auto_close
|
||||||
|
self.read_only = read_only
|
||||||
|
self.toggle_selected = (
|
||||||
|
toggle_selected if isinstance(toggle_selected, bool) else self.clear_button
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_current_language():
|
def _get_current_language():
|
||||||
@@ -123,11 +141,15 @@ class AirDateTimePickerInput(widgets.DateTimeInput):
|
|||||||
attrs["data-now-button-txt"] = _("Now")
|
attrs["data-now-button-txt"] = _("Now")
|
||||||
attrs["data-timepicker"] = str(self.timepicker).lower()
|
attrs["data-timepicker"] = str(self.timepicker).lower()
|
||||||
attrs["data-auto-close"] = str(self.auto_close).lower()
|
attrs["data-auto-close"] = str(self.auto_close).lower()
|
||||||
|
attrs["data-toggle-selected"] = str(self.toggle_selected).lower()
|
||||||
attrs["data-clear-button"] = str(self.clear_button).lower()
|
attrs["data-clear-button"] = str(self.clear_button).lower()
|
||||||
attrs["data-language"] = self._get_current_language()
|
attrs["data-language"] = self._get_current_language()
|
||||||
attrs["data-date-format"] = date_format
|
attrs["data-date-format"] = date_format
|
||||||
attrs["data-time-format"] = time_format
|
attrs["data-time-format"] = time_format
|
||||||
|
|
||||||
|
if self.read_only:
|
||||||
|
attrs["readonly"] = True
|
||||||
|
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
def format_value(self, value):
|
def format_value(self, value):
|
||||||
@@ -227,3 +249,56 @@ class AirMonthYearPickerInput(AirDatePickerInput):
|
|||||||
except (ValueError, KeyError):
|
except (ValueError, KeyError):
|
||||||
return None
|
return None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class AirYearPickerInput(AirDatePickerInput):
|
||||||
|
def __init__(self, attrs=None, format=None, *args, **kwargs):
|
||||||
|
super().__init__(attrs=attrs, format=format, *args, **kwargs)
|
||||||
|
# Store the display format for AirDatepicker
|
||||||
|
self.display_format = "yyyy"
|
||||||
|
# Store the Python format for internal use
|
||||||
|
self.python_format = "%Y"
|
||||||
|
|
||||||
|
def build_attrs(self, base_attrs, extra_attrs=None):
|
||||||
|
attrs = super().build_attrs(base_attrs, extra_attrs)
|
||||||
|
|
||||||
|
# Add data attributes for AirDatepicker configuration
|
||||||
|
attrs["data-now-button-txt"] = _("Today")
|
||||||
|
attrs["data-date-format"] = "yyyy"
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
def format_value(self, value):
|
||||||
|
"""Format the value for display in the widget."""
|
||||||
|
if value:
|
||||||
|
self.attrs["data-value"] = (
|
||||||
|
value # We use this to dynamically select the initial date on AirDatePicker
|
||||||
|
)
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
value = datetime.datetime.strptime(value, "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
return value
|
||||||
|
if isinstance(value, (datetime.datetime, datetime.date)):
|
||||||
|
# Use Django's date translation
|
||||||
|
return f"{value.year}"
|
||||||
|
return value
|
||||||
|
|
||||||
|
def value_from_datadict(self, data, files, name):
|
||||||
|
"""Convert the value from the widget format back to a format Django can handle."""
|
||||||
|
value = super().value_from_datadict(data, files, name)
|
||||||
|
if value:
|
||||||
|
try:
|
||||||
|
# Split the value into month name and year
|
||||||
|
year_str = value
|
||||||
|
year = int(year_str)
|
||||||
|
|
||||||
|
if year:
|
||||||
|
# Return the first day of the month in Django's expected format
|
||||||
|
return datetime.date(year, 1, 1).strftime("%Y-%m-%d")
|
||||||
|
except (ValueError, KeyError):
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class ArbitraryDecimalDisplayNumberInput(forms.TextInput):
|
|||||||
"x-data": "",
|
"x-data": "",
|
||||||
"x-mask:dynamic": f"$money($input, '{get_format('DECIMAL_SEPARATOR')}', "
|
"x-mask:dynamic": f"$money($input, '{get_format('DECIMAL_SEPARATOR')}', "
|
||||||
f"'{get_format('THOUSAND_SEPARATOR')}', '30')",
|
f"'{get_format('THOUSAND_SEPARATOR')}', '30')",
|
||||||
|
"x-on:keyup": "$el.dispatchEvent(new Event('input'))",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from django.forms import widgets, SelectMultiple
|
from django.forms import widgets, SelectMultiple
|
||||||
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
@@ -129,3 +130,28 @@ class TomSelect(widgets.Select):
|
|||||||
|
|
||||||
class TomSelectMultiple(SelectMultiple, TomSelect):
|
class TomSelectMultiple(SelectMultiple, TomSelect):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionSelect(TomSelect):
|
||||||
|
def __init__(self, income: bool = True, expense: bool = True, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.load_income = income
|
||||||
|
self.load_expense = expense
|
||||||
|
self.create = False
|
||||||
|
|
||||||
|
def build_attrs(self, base_attrs, extra_attrs=None):
|
||||||
|
attrs = super().build_attrs(base_attrs, extra_attrs)
|
||||||
|
|
||||||
|
if self.load_income and self.load_expense:
|
||||||
|
attrs["data-load"] = reverse("transactions_search")
|
||||||
|
elif self.load_income and not self.load_expense:
|
||||||
|
attrs["data-load"] = reverse(
|
||||||
|
"transactions_search", kwargs={"filter_type": "income"}
|
||||||
|
)
|
||||||
|
elif self.load_expense and not self.load_income:
|
||||||
|
attrs["data-load"] = reverse(
|
||||||
|
"transactions_search", kwargs={"filter_type": "expenses"}
|
||||||
|
)
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ from django.utils import timezone
|
|||||||
|
|
||||||
from apps.currencies.exchange_rates.providers import (
|
from apps.currencies.exchange_rates.providers import (
|
||||||
SynthFinanceProvider,
|
SynthFinanceProvider,
|
||||||
|
SynthFinanceStockProvider,
|
||||||
CoinGeckoFreeProvider,
|
CoinGeckoFreeProvider,
|
||||||
CoinGeckoProProvider,
|
CoinGeckoProProvider,
|
||||||
|
TransitiveRateProvider,
|
||||||
)
|
)
|
||||||
from apps.currencies.models import ExchangeRateService, ExchangeRate, Currency
|
from apps.currencies.models import ExchangeRateService, ExchangeRate, Currency
|
||||||
|
|
||||||
@@ -17,8 +19,10 @@ logger = logging.getLogger(__name__)
|
|||||||
# Map service types to provider classes
|
# Map service types to provider classes
|
||||||
PROVIDER_MAPPING = {
|
PROVIDER_MAPPING = {
|
||||||
"synth_finance": SynthFinanceProvider,
|
"synth_finance": SynthFinanceProvider,
|
||||||
|
"synth_finance_stock": SynthFinanceStockProvider,
|
||||||
"coingecko_free": CoinGeckoFreeProvider,
|
"coingecko_free": CoinGeckoFreeProvider,
|
||||||
"coingecko_pro": CoinGeckoProProvider,
|
"coingecko_pro": CoinGeckoProProvider,
|
||||||
|
"transitive": TransitiveRateProvider,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -31,7 +35,7 @@ class ExchangeRateFetcher:
|
|||||||
service.fetch_interval
|
service.fetch_interval
|
||||||
)
|
)
|
||||||
should_fetch = current_hour not in blocked_hours
|
should_fetch = current_hour not in blocked_hours
|
||||||
logger.debug(
|
logger.info(
|
||||||
f"NOT_ON check for {service.name}: "
|
f"NOT_ON check for {service.name}: "
|
||||||
f"current_hour={current_hour}, "
|
f"current_hour={current_hour}, "
|
||||||
f"blocked_hours={blocked_hours}, "
|
f"blocked_hours={blocked_hours}, "
|
||||||
@@ -43,18 +47,35 @@ class ExchangeRateFetcher:
|
|||||||
allowed_hours = ExchangeRateService._parse_hour_ranges(
|
allowed_hours = ExchangeRateService._parse_hour_ranges(
|
||||||
service.fetch_interval
|
service.fetch_interval
|
||||||
)
|
)
|
||||||
return current_hour in allowed_hours
|
|
||||||
|
should_fetch = current_hour in allowed_hours
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"ON check for {service.name}: "
|
||||||
|
f"current_hour={current_hour}, "
|
||||||
|
f"allowed_hours={allowed_hours}, "
|
||||||
|
f"should_fetch={should_fetch}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return should_fetch
|
||||||
|
|
||||||
if service.interval_type == ExchangeRateService.IntervalType.EVERY:
|
if service.interval_type == ExchangeRateService.IntervalType.EVERY:
|
||||||
try:
|
try:
|
||||||
interval_hours = int(service.fetch_interval)
|
interval_hours = int(service.fetch_interval)
|
||||||
|
|
||||||
if service.last_fetch is None:
|
if service.last_fetch is None:
|
||||||
return True
|
return True
|
||||||
hours_since_last = (
|
|
||||||
timezone.now() - service.last_fetch
|
# Round down to nearest hour
|
||||||
).total_seconds() / 3600
|
now = timezone.now().replace(minute=0, second=0, microsecond=0)
|
||||||
|
last_fetch = service.last_fetch.replace(
|
||||||
|
minute=0, second=0, microsecond=0
|
||||||
|
)
|
||||||
|
|
||||||
|
hours_since_last = (now - last_fetch).total_seconds() / 3600
|
||||||
should_fetch = hours_since_last >= interval_hours
|
should_fetch = hours_since_last >= interval_hours
|
||||||
logger.debug(
|
|
||||||
|
logger.info(
|
||||||
f"EVERY check for {service.name}: "
|
f"EVERY check for {service.name}: "
|
||||||
f"hours_since_last={hours_since_last:.1f}, "
|
f"hours_since_last={hours_since_last:.1f}, "
|
||||||
f"interval={interval_hours}, "
|
f"interval={interval_hours}, "
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import time
|
|||||||
|
|
||||||
import requests
|
import requests
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Tuple, List
|
from typing import Tuple, List, Optional, Dict
|
||||||
|
|
||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
|
|
||||||
from apps.currencies.models import Currency
|
from apps.currencies.models import Currency, ExchangeRate
|
||||||
from apps.currencies.exchange_rates.base import ExchangeRateProvider
|
from apps.currencies.exchange_rates.base import ExchangeRateProvider
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -150,3 +150,159 @@ class CoinGeckoProProvider(CoinGeckoFreeProvider):
|
|||||||
super().__init__(api_key)
|
super().__init__(api_key)
|
||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
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):
|
||||||
|
"""Calculates exchange rates through paths of existing rates"""
|
||||||
|
|
||||||
|
rates_inverted = True
|
||||||
|
|
||||||
|
def __init__(self, api_key: str = None):
|
||||||
|
super().__init__(api_key) # API key not needed but maintaining interface
|
||||||
|
|
||||||
|
@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 = []
|
||||||
|
|
||||||
|
# Get recent rates for building the graph
|
||||||
|
recent_rates = ExchangeRate.objects.all()
|
||||||
|
|
||||||
|
# Build currency graph
|
||||||
|
currency_graph = self._build_currency_graph(recent_rates)
|
||||||
|
|
||||||
|
for target in target_currencies:
|
||||||
|
if (
|
||||||
|
not target.exchange_currency
|
||||||
|
or target.exchange_currency not in exchange_currencies
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Find path and calculate rate
|
||||||
|
from_id = target.exchange_currency.id
|
||||||
|
to_id = target.id
|
||||||
|
|
||||||
|
path, rate = self._find_conversion_path(currency_graph, from_id, to_id)
|
||||||
|
|
||||||
|
if path and rate:
|
||||||
|
path_codes = [Currency.objects.get(id=cid).code for cid in path]
|
||||||
|
logger.info(
|
||||||
|
f"Found conversion path: {' -> '.join(path_codes)}, rate: {rate}"
|
||||||
|
)
|
||||||
|
results.append((target.exchange_currency, target, rate))
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
f"No conversion path found for {target.exchange_currency.code}->{target.code}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_currency_graph(rates) -> Dict[int, Dict[int, Decimal]]:
|
||||||
|
"""Build a graph representation of currency relationships"""
|
||||||
|
graph = {}
|
||||||
|
|
||||||
|
for rate in rates:
|
||||||
|
# Add both directions to make the graph bidirectional
|
||||||
|
if rate.from_currency_id not in graph:
|
||||||
|
graph[rate.from_currency_id] = {}
|
||||||
|
graph[rate.from_currency_id][rate.to_currency_id] = rate.rate
|
||||||
|
|
||||||
|
if rate.to_currency_id not in graph:
|
||||||
|
graph[rate.to_currency_id] = {}
|
||||||
|
graph[rate.to_currency_id][rate.from_currency_id] = Decimal("1") / rate.rate
|
||||||
|
|
||||||
|
return graph
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _find_conversion_path(
|
||||||
|
graph, from_id, to_id
|
||||||
|
) -> Tuple[Optional[list], Optional[Decimal]]:
|
||||||
|
"""Find the shortest path between currencies using breadth-first search"""
|
||||||
|
if from_id not in graph or to_id not in graph:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
queue = [(from_id, [from_id], Decimal("1"))]
|
||||||
|
visited = {from_id}
|
||||||
|
|
||||||
|
while queue:
|
||||||
|
current, path, current_rate = queue.pop(0)
|
||||||
|
|
||||||
|
if current == to_id:
|
||||||
|
return path, current_rate
|
||||||
|
|
||||||
|
for neighbor, rate in graph.get(current, {}).items():
|
||||||
|
if neighbor not in visited:
|
||||||
|
visited.add(neighbor)
|
||||||
|
queue.append((neighbor, path + [neighbor], current_rate * rate))
|
||||||
|
|
||||||
|
return None, None
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.1.6 on 2025-03-02 01:28
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('currencies', '0011_remove_exchangerateservice_fetch_interval_hours_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
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)')], max_length=255, verbose_name='Service Type'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.1.6 on 2025-03-02 01:58
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('currencies', '0012_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)')], max_length=255, verbose_name='Service Type'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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'},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -38,6 +38,7 @@ class Currency(models.Model):
|
|||||||
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()
|
||||||
@@ -92,8 +93,10 @@ class ExchangeRateService(models.Model):
|
|||||||
|
|
||||||
class ServiceType(models.TextChoices):
|
class ServiceType(models.TextChoices):
|
||||||
SYNTH_FINANCE = "synth_finance", "Synth Finance"
|
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)"
|
||||||
|
|
||||||
class IntervalType(models.TextChoices):
|
class IntervalType(models.TextChoices):
|
||||||
ON = "on", _("On")
|
ON = "on", _("On")
|
||||||
@@ -204,11 +207,11 @@ class ExchangeRateService(models.Model):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
hours = int(self.fetch_interval)
|
hours = int(self.fetch_interval)
|
||||||
if hours < 0 or hours > 23:
|
if hours < 1 or hours > 24:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
{
|
{
|
||||||
"fetch_interval": _(
|
"fetch_interval": _(
|
||||||
"'Every X hours' interval must be between 0 and 23."
|
"'Every X hours' interval must be between 1 and 24."
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from apps.dca.models import DCAStrategy, DCAEntry
|
from apps.dca.models import DCAStrategy, DCAEntry
|
||||||
|
from apps.common.admin import SharedObjectModelAdmin
|
||||||
|
|
||||||
|
|
||||||
# Register your models here.
|
|
||||||
admin.site.register(DCAStrategy)
|
|
||||||
admin.site.register(DCAEntry)
|
admin.site.register(DCAEntry)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(DCAStrategy)
|
||||||
|
class TransactionEntityModelAdmin(SharedObjectModelAdmin):
|
||||||
|
def get_queryset(self, request):
|
||||||
|
return DCAStrategy.all_objects.all()
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
from crispy_forms.bootstrap import FormActions
|
from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion
|
||||||
|
from crispy_forms.bootstrap import FormActions, AccordionGroup
|
||||||
from crispy_forms.helper import FormHelper
|
from crispy_forms.helper import FormHelper
|
||||||
from crispy_forms.layout import Layout, Row, Column
|
from crispy_forms.layout import Layout, Row, Column, HTML
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from apps.accounts.models import Account
|
||||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||||
from apps.common.widgets.datepicker import AirDatePickerInput
|
from apps.common.widgets.datepicker import AirDatePickerInput
|
||||||
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
|
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
|
||||||
from apps.common.widgets.tom_select import TomSelect
|
from apps.common.widgets.tom_select import TomSelect
|
||||||
from apps.dca.models import DCAStrategy, DCAEntry
|
from apps.dca.models import DCAStrategy, DCAEntry
|
||||||
|
from apps.common.widgets.tom_select import TransactionSelect
|
||||||
|
from apps.transactions.models import Transaction, TransactionTag, TransactionCategory
|
||||||
|
from apps.common.fields.forms.dynamic_select import (
|
||||||
|
DynamicModelChoiceField,
|
||||||
|
DynamicModelMultipleChoiceField,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DCAStrategyForm(forms.ModelForm):
|
class DCAStrategyForm(forms.ModelForm):
|
||||||
@@ -53,6 +61,75 @@ class DCAStrategyForm(forms.ModelForm):
|
|||||||
|
|
||||||
|
|
||||||
class DCAEntryForm(forms.ModelForm):
|
class DCAEntryForm(forms.ModelForm):
|
||||||
|
create_transaction = forms.BooleanField(
|
||||||
|
label=_("Create transaction"), initial=False, required=False
|
||||||
|
)
|
||||||
|
|
||||||
|
from_account = forms.ModelChoiceField(
|
||||||
|
queryset=Account.objects.filter(is_archived=False),
|
||||||
|
label=_("From Account"),
|
||||||
|
widget=TomSelect(clear_button=False, group_by="group"),
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
to_account = forms.ModelChoiceField(
|
||||||
|
queryset=Account.objects.filter(is_archived=False),
|
||||||
|
label=_("To Account"),
|
||||||
|
widget=TomSelect(clear_button=False, group_by="group"),
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
from_category = DynamicModelChoiceField(
|
||||||
|
create_field="name",
|
||||||
|
model=TransactionCategory,
|
||||||
|
required=False,
|
||||||
|
label=_("Category"),
|
||||||
|
queryset=TransactionCategory.objects.filter(active=True),
|
||||||
|
)
|
||||||
|
to_category = DynamicModelChoiceField(
|
||||||
|
create_field="name",
|
||||||
|
model=TransactionCategory,
|
||||||
|
required=False,
|
||||||
|
label=_("Category"),
|
||||||
|
queryset=TransactionCategory.objects.filter(active=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
from_tags = DynamicModelMultipleChoiceField(
|
||||||
|
model=TransactionTag,
|
||||||
|
to_field_name="name",
|
||||||
|
create_field="name",
|
||||||
|
required=False,
|
||||||
|
label=_("Tags"),
|
||||||
|
queryset=TransactionTag.objects.filter(active=True),
|
||||||
|
)
|
||||||
|
to_tags = DynamicModelMultipleChoiceField(
|
||||||
|
model=TransactionTag,
|
||||||
|
to_field_name="name",
|
||||||
|
create_field="name",
|
||||||
|
required=False,
|
||||||
|
label=_("Tags"),
|
||||||
|
queryset=TransactionTag.objects.filter(active=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
expense_transaction = DynamicModelChoiceField(
|
||||||
|
model=Transaction,
|
||||||
|
to_field_name="id",
|
||||||
|
label=_("Expense Transaction"),
|
||||||
|
required=False,
|
||||||
|
queryset=Transaction.objects.none(),
|
||||||
|
widget=TransactionSelect(clear_button=True, income=False, expense=True),
|
||||||
|
help_text=_("Type to search for a transaction to link to this entry"),
|
||||||
|
)
|
||||||
|
|
||||||
|
income_transaction = DynamicModelChoiceField(
|
||||||
|
model=Transaction,
|
||||||
|
to_field_name="id",
|
||||||
|
label=_("Income Transaction"),
|
||||||
|
required=False,
|
||||||
|
queryset=Transaction.objects.none(),
|
||||||
|
widget=TransactionSelect(clear_button=True, income=True, expense=False),
|
||||||
|
help_text=_("Type to search for a transaction to link to this entry"),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = DCAEntry
|
model = DCAEntry
|
||||||
fields = [
|
fields = [
|
||||||
@@ -60,13 +137,19 @@ class DCAEntryForm(forms.ModelForm):
|
|||||||
"amount_paid",
|
"amount_paid",
|
||||||
"amount_received",
|
"amount_received",
|
||||||
"notes",
|
"notes",
|
||||||
|
"expense_transaction",
|
||||||
|
"income_transaction",
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
"notes": forms.Textarea(attrs={"rows": 3}),
|
"notes": forms.Textarea(attrs={"rows": 3}),
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
strategy = kwargs.pop("strategy", None)
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.strategy = strategy if strategy else self.instance.strategy
|
||||||
|
|
||||||
self.helper = FormHelper()
|
self.helper = FormHelper()
|
||||||
self.helper.form_tag = False
|
self.helper.form_tag = False
|
||||||
self.helper.layout = Layout(
|
self.helper.layout = Layout(
|
||||||
@@ -75,18 +158,66 @@ class DCAEntryForm(forms.ModelForm):
|
|||||||
Column("amount_paid", css_class="form-group col-md-6"),
|
Column("amount_paid", css_class="form-group col-md-6"),
|
||||||
Column("amount_received", css_class="form-group col-md-6"),
|
Column("amount_received", css_class="form-group col-md-6"),
|
||||||
),
|
),
|
||||||
Row(
|
|
||||||
Column("expense_transaction", css_class="form-group col-md-6"),
|
|
||||||
Column("income_transaction", css_class="form-group col-md-6"),
|
|
||||||
),
|
|
||||||
"notes",
|
"notes",
|
||||||
|
BS5Accordion(
|
||||||
|
AccordionGroup(
|
||||||
|
_("Create transaction"),
|
||||||
|
Switch("create_transaction"),
|
||||||
|
Row(
|
||||||
|
Column(
|
||||||
|
Row(
|
||||||
|
Column(
|
||||||
|
"from_account",
|
||||||
|
css_class="form-group",
|
||||||
|
),
|
||||||
|
css_class="form-row",
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
Column(
|
||||||
|
"from_category",
|
||||||
|
css_class="form-group col-md-6 mb-0",
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
"from_tags", css_class="form-group col-md-6 mb-0"
|
||||||
|
),
|
||||||
|
css_class="form-row",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
css_class="p-1 mx-1 my-3 border rounded-3",
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
Column(
|
||||||
|
Row(
|
||||||
|
Column(
|
||||||
|
"to_account",
|
||||||
|
css_class="form-group",
|
||||||
|
),
|
||||||
|
css_class="form-row",
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
Column(
|
||||||
|
"to_category", css_class="form-group col-md-6 mb-0"
|
||||||
|
),
|
||||||
|
Column("to_tags", css_class="form-group col-md-6 mb-0"),
|
||||||
|
css_class="form-row",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
css_class="p-1 mx-1 my-3 border rounded-3",
|
||||||
|
),
|
||||||
|
active=False,
|
||||||
|
),
|
||||||
|
AccordionGroup(
|
||||||
|
_("Link transaction"),
|
||||||
|
"income_transaction",
|
||||||
|
"expense_transaction",
|
||||||
|
),
|
||||||
|
flush=False,
|
||||||
|
always_open=False,
|
||||||
|
css_class="mb-3",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.instance and self.instance.pk:
|
if self.instance and self.instance.pk:
|
||||||
# decimal_places = self.instance.account.currency.decimal_places
|
|
||||||
# self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput(
|
|
||||||
# decimal_places=decimal_places
|
|
||||||
# )
|
|
||||||
self.helper.layout.append(
|
self.helper.layout.append(
|
||||||
FormActions(
|
FormActions(
|
||||||
NoClassSubmit(
|
NoClassSubmit(
|
||||||
@@ -95,7 +226,6 @@ class DCAEntryForm(forms.ModelForm):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
|
||||||
self.helper.layout.append(
|
self.helper.layout.append(
|
||||||
FormActions(
|
FormActions(
|
||||||
NoClassSubmit(
|
NoClassSubmit(
|
||||||
@@ -107,3 +237,136 @@ class DCAEntryForm(forms.ModelForm):
|
|||||||
self.fields["amount_paid"].widget = ArbitraryDecimalDisplayNumberInput()
|
self.fields["amount_paid"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||||
self.fields["amount_received"].widget = ArbitraryDecimalDisplayNumberInput()
|
self.fields["amount_received"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||||
self.fields["date"].widget = AirDatePickerInput(clear_button=False)
|
self.fields["date"].widget = AirDatePickerInput(clear_button=False)
|
||||||
|
|
||||||
|
expense_transaction = None
|
||||||
|
income_transaction = None
|
||||||
|
if self.instance and self.instance.pk:
|
||||||
|
# Edit mode - get from instance
|
||||||
|
expense_transaction = self.instance.expense_transaction
|
||||||
|
income_transaction = self.instance.income_transaction
|
||||||
|
elif self.data.get("expense_transaction"):
|
||||||
|
# Form validation - get from submitted data
|
||||||
|
try:
|
||||||
|
expense_transaction = Transaction.objects.get(
|
||||||
|
id=self.data["expense_transaction"]
|
||||||
|
)
|
||||||
|
income_transaction = Transaction.objects.get(
|
||||||
|
id=self.data["income_transaction"]
|
||||||
|
)
|
||||||
|
except Transaction.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# If we have a current transaction, ensure it's in the queryset
|
||||||
|
if income_transaction:
|
||||||
|
self.fields["income_transaction"].queryset = Transaction.objects.filter(
|
||||||
|
id=income_transaction.id
|
||||||
|
)
|
||||||
|
if expense_transaction:
|
||||||
|
self.fields["expense_transaction"].queryset = Transaction.objects.filter(
|
||||||
|
id=expense_transaction.id
|
||||||
|
)
|
||||||
|
|
||||||
|
self.fields["from_account"].queryset = Account.objects.filter(
|
||||||
|
is_archived=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.fields["from_category"].queryset = TransactionCategory.objects.filter(
|
||||||
|
active=True
|
||||||
|
)
|
||||||
|
self.fields["from_tags"].queryset = TransactionTag.objects.filter(active=True)
|
||||||
|
|
||||||
|
self.fields["to_account"].queryset = Account.objects.filter(
|
||||||
|
is_archived=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.fields["to_category"].queryset = TransactionCategory.objects.filter(
|
||||||
|
active=True
|
||||||
|
)
|
||||||
|
self.fields["to_tags"].queryset = TransactionTag.objects.filter(active=True)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
|
||||||
|
if cleaned_data.get("create_transaction"):
|
||||||
|
from_account = cleaned_data.get("from_account")
|
||||||
|
to_account = cleaned_data.get("to_account")
|
||||||
|
|
||||||
|
if not from_account and not to_account:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
{
|
||||||
|
"from_account": _("You must provide an account."),
|
||||||
|
"to_account": _("You must provide an account."),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif not from_account and to_account:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
{"from_account": _("You must provide an account.")}
|
||||||
|
)
|
||||||
|
elif not to_account and from_account:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
{"to_account": _("You must provide an account.")}
|
||||||
|
)
|
||||||
|
|
||||||
|
if from_account == to_account:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
_("From and To accounts must be different.")
|
||||||
|
)
|
||||||
|
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
def save(self, **kwargs):
|
||||||
|
instance = super().save(commit=False)
|
||||||
|
|
||||||
|
if self.cleaned_data.get("create_transaction"):
|
||||||
|
from_account = self.cleaned_data["from_account"]
|
||||||
|
to_account = self.cleaned_data["to_account"]
|
||||||
|
from_amount = instance.amount_paid
|
||||||
|
to_amount = instance.amount_received
|
||||||
|
date = instance.date
|
||||||
|
description = _("DCA for %(strategy_name)s") % {
|
||||||
|
"strategy_name": self.strategy.name
|
||||||
|
}
|
||||||
|
from_category = self.cleaned_data.get("from_category")
|
||||||
|
to_category = self.cleaned_data.get("to_category")
|
||||||
|
notes = self.cleaned_data.get("notes")
|
||||||
|
|
||||||
|
# Create "From" transaction
|
||||||
|
from_transaction = Transaction.objects.create(
|
||||||
|
account=from_account,
|
||||||
|
type=Transaction.Type.EXPENSE,
|
||||||
|
is_paid=True,
|
||||||
|
date=date,
|
||||||
|
amount=from_amount,
|
||||||
|
description=description,
|
||||||
|
category=from_category,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
from_transaction.tags.set(self.cleaned_data.get("from_tags", []))
|
||||||
|
|
||||||
|
# Create "To" transaction
|
||||||
|
to_transaction = Transaction.objects.create(
|
||||||
|
account=to_account,
|
||||||
|
type=Transaction.Type.INCOME,
|
||||||
|
is_paid=True,
|
||||||
|
date=date,
|
||||||
|
amount=to_amount,
|
||||||
|
description=description,
|
||||||
|
category=to_category,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
to_transaction.tags.set(self.cleaned_data.get("to_tags", []))
|
||||||
|
|
||||||
|
instance.expense_transaction = from_transaction
|
||||||
|
instance.income_transaction = to_transaction
|
||||||
|
else:
|
||||||
|
if instance.expense_transaction:
|
||||||
|
instance.expense_transaction.amount = instance.amount_paid
|
||||||
|
instance.expense_transaction.save()
|
||||||
|
if instance.income_transaction:
|
||||||
|
instance.income_transaction.amount = instance.amount_received
|
||||||
|
instance.income_transaction.save()
|
||||||
|
|
||||||
|
instance.strategy = self.strategy
|
||||||
|
instance.save()
|
||||||
|
|
||||||
|
return instance
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Generated by Django 5.1.6 on 2025-03-07 18:20
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('dca', '0002_alter_dcaentry_amount_paid_and_more'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='dcastrategy',
|
||||||
|
name='shared_with',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='dcastrategy',
|
||||||
|
name='visibility',
|
||||||
|
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,16 +1,15 @@
|
|||||||
from datetime import timedelta
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from statistics import mean, stdev
|
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.template.defaultfilters import date
|
from django.template.defaultfilters import date
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from apps.common.models import SharedObject, SharedObjectManager
|
||||||
from apps.currencies.utils.convert import convert, get_exchange_rate
|
from apps.currencies.utils.convert import convert, get_exchange_rate
|
||||||
|
|
||||||
|
|
||||||
class DCAStrategy(models.Model):
|
class DCAStrategy(SharedObject):
|
||||||
name = models.CharField(max_length=255, verbose_name=_("Name"))
|
name = models.CharField(max_length=255, verbose_name=_("Name"))
|
||||||
target_currency = models.ForeignKey(
|
target_currency = models.ForeignKey(
|
||||||
"currencies.Currency",
|
"currencies.Currency",
|
||||||
@@ -28,6 +27,9 @@ class DCAStrategy(models.Model):
|
|||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
objects = SharedObjectManager()
|
||||||
|
all_objects = models.Manager() # Unfiltered manager
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("DCA Strategy")
|
verbose_name = _("DCA Strategy")
|
||||||
verbose_name_plural = _("DCA Strategies")
|
verbose_name_plural = _("DCA Strategies")
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ urlpatterns = [
|
|||||||
views.strategy_delete,
|
views.strategy_delete,
|
||||||
name="dca_strategy_delete",
|
name="dca_strategy_delete",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"dca/<int:strategy_id>/take-ownership/",
|
||||||
|
views.strategy_take_ownership,
|
||||||
|
name="dca_strategy_take_ownership",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"dca/<int:pk>/share/",
|
||||||
|
views.strategy_share,
|
||||||
|
name="dca_strategy_share_settings",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"dca/<int:strategy_id>/",
|
"dca/<int:strategy_id>/",
|
||||||
views.strategy_detail_index,
|
views.strategy_detail_index,
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ from django.views.decorators.http import require_http_methods
|
|||||||
from apps.common.decorators.htmx import only_htmx
|
from apps.common.decorators.htmx import only_htmx
|
||||||
from apps.dca.forms import DCAEntryForm, DCAStrategyForm
|
from apps.dca.forms import DCAEntryForm, DCAStrategyForm
|
||||||
from apps.dca.models import DCAStrategy, DCAEntry
|
from apps.dca.models import DCAStrategy, DCAEntry
|
||||||
|
from apps.common.models import SharedObject
|
||||||
|
from apps.common.forms import SharedObjectForm
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -57,6 +59,16 @@ def strategy_add(request):
|
|||||||
def strategy_edit(request, strategy_id):
|
def strategy_edit(request, strategy_id):
|
||||||
dca_strategy = get_object_or_404(DCAStrategy, id=strategy_id)
|
dca_strategy = get_object_or_404(DCAStrategy, id=strategy_id)
|
||||||
|
|
||||||
|
if dca_strategy.owner and dca_strategy.owner != request.user:
|
||||||
|
messages.error(request, _("Only the owner can edit this"))
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
headers={
|
||||||
|
"HX-Trigger": "updated, hide_offcanvas",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = DCAStrategyForm(request.POST, instance=dca_strategy)
|
form = DCAStrategyForm(request.POST, instance=dca_strategy)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
@@ -85,8 +97,14 @@ def strategy_edit(request, strategy_id):
|
|||||||
def strategy_delete(request, strategy_id):
|
def strategy_delete(request, strategy_id):
|
||||||
dca_strategy = get_object_or_404(DCAStrategy, id=strategy_id)
|
dca_strategy = get_object_or_404(DCAStrategy, id=strategy_id)
|
||||||
|
|
||||||
|
if (
|
||||||
|
dca_strategy.owner != request.user
|
||||||
|
and request.user in dca_strategy.shared_with.all()
|
||||||
|
):
|
||||||
|
dca_strategy.shared_with.remove(request.user)
|
||||||
|
messages.success(request, _("Item no longer shared with you"))
|
||||||
|
else:
|
||||||
dca_strategy.delete()
|
dca_strategy.delete()
|
||||||
|
|
||||||
messages.success(request, _("DCA strategy deleted successfully"))
|
messages.success(request, _("DCA strategy deleted successfully"))
|
||||||
|
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
@@ -97,6 +115,65 @@ def strategy_delete(request, strategy_id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def strategy_take_ownership(request, strategy_id):
|
||||||
|
dca_strategy = get_object_or_404(DCAStrategy, id=strategy_id)
|
||||||
|
|
||||||
|
if not dca_strategy.owner:
|
||||||
|
dca_strategy.owner = request.user
|
||||||
|
dca_strategy.visibility = SharedObject.Visibility.private
|
||||||
|
dca_strategy.save()
|
||||||
|
|
||||||
|
messages.success(request, _("Ownership taken successfully"))
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
headers={
|
||||||
|
"HX-Trigger": "updated, hide_offcanvas",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def strategy_share(request, pk):
|
||||||
|
obj = get_object_or_404(DCAStrategy, id=pk)
|
||||||
|
|
||||||
|
if obj.owner and obj.owner != request.user:
|
||||||
|
messages.error(request, _("Only the owner can edit this"))
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
headers={
|
||||||
|
"HX-Trigger": "updated, hide_offcanvas",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
form = SharedObjectForm(request.POST, instance=obj, user=request.user)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
messages.success(request, _("Configuration saved successfully"))
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
headers={
|
||||||
|
"HX-Trigger": "updated, hide_offcanvas",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
form = SharedObjectForm(instance=obj, user=request.user)
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"dca/fragments/strategy/share.html",
|
||||||
|
{"form": form, "object": obj},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def strategy_detail_index(request, strategy_id):
|
def strategy_detail_index(request, strategy_id):
|
||||||
strategy = get_object_or_404(DCAStrategy, id=strategy_id)
|
strategy = get_object_or_404(DCAStrategy, id=strategy_id)
|
||||||
@@ -155,11 +232,9 @@ def strategy_detail(request, strategy_id):
|
|||||||
def strategy_entry_add(request, strategy_id):
|
def strategy_entry_add(request, strategy_id):
|
||||||
strategy = get_object_or_404(DCAStrategy, id=strategy_id)
|
strategy = get_object_or_404(DCAStrategy, id=strategy_id)
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = DCAEntryForm(request.POST)
|
form = DCAEntryForm(request.POST, strategy=strategy)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
entry = form.save(commit=False)
|
entry = form.save()
|
||||||
entry.strategy = strategy
|
|
||||||
entry.save()
|
|
||||||
messages.success(request, _("Entry added successfully"))
|
messages.success(request, _("Entry added successfully"))
|
||||||
|
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
@@ -169,7 +244,7 @@ def strategy_entry_add(request, strategy_id):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
form = DCAEntryForm()
|
form = DCAEntryForm(strategy=strategy)
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
|
|||||||
0
app/apps/export_app/__init__.py
Normal file
0
app/apps/export_app/__init__.py
Normal file
3
app/apps/export_app/admin.py
Normal file
3
app/apps/export_app/admin.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
6
app/apps/export_app/apps.py
Normal file
6
app/apps/export_app/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ExportConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.export_app"
|
||||||
198
app/apps/export_app/forms.py
Normal file
198
app/apps/export_app/forms.py
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
from crispy_forms.bootstrap import FormActions
|
||||||
|
from crispy_forms.helper import FormHelper
|
||||||
|
from crispy_forms.layout import Layout, HTML
|
||||||
|
from django import forms
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||||
|
|
||||||
|
|
||||||
|
class ExportForm(forms.Form):
|
||||||
|
users = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.CheckboxInput(),
|
||||||
|
label=_("Users"),
|
||||||
|
initial=True,
|
||||||
|
)
|
||||||
|
accounts = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.CheckboxInput(),
|
||||||
|
label=_("Accounts"),
|
||||||
|
initial=True,
|
||||||
|
)
|
||||||
|
currencies = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.CheckboxInput(),
|
||||||
|
label=_("Currencies"),
|
||||||
|
initial=True,
|
||||||
|
)
|
||||||
|
transactions = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.CheckboxInput(),
|
||||||
|
label=_("Transactions"),
|
||||||
|
initial=True,
|
||||||
|
)
|
||||||
|
categories = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.CheckboxInput(),
|
||||||
|
label=_("Categories"),
|
||||||
|
initial=True,
|
||||||
|
)
|
||||||
|
tags = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.CheckboxInput(),
|
||||||
|
label=_("Tags"),
|
||||||
|
initial=False,
|
||||||
|
)
|
||||||
|
entities = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.CheckboxInput(),
|
||||||
|
label=_("Entities"),
|
||||||
|
initial=False,
|
||||||
|
)
|
||||||
|
recurring_transactions = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.CheckboxInput(),
|
||||||
|
label=_("Recurring Transactions"),
|
||||||
|
initial=True,
|
||||||
|
)
|
||||||
|
installment_plans = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.CheckboxInput(),
|
||||||
|
label=_("Installment Plans"),
|
||||||
|
initial=True,
|
||||||
|
)
|
||||||
|
exchange_rates = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.CheckboxInput(),
|
||||||
|
label=_("Exchange Rates"),
|
||||||
|
initial=False,
|
||||||
|
)
|
||||||
|
exchange_rates_services = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.CheckboxInput(),
|
||||||
|
label=_("Automatic Exchange Rates"),
|
||||||
|
initial=False,
|
||||||
|
)
|
||||||
|
rules = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.CheckboxInput(),
|
||||||
|
label=_("Rules"),
|
||||||
|
initial=True,
|
||||||
|
)
|
||||||
|
dca = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.CheckboxInput(),
|
||||||
|
label=_("DCA"),
|
||||||
|
initial=False,
|
||||||
|
)
|
||||||
|
import_profiles = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.CheckboxInput(),
|
||||||
|
label=_("Import Profiles"),
|
||||||
|
initial=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.helper = FormHelper()
|
||||||
|
self.helper.form_tag = False
|
||||||
|
self.helper.form_method = "post"
|
||||||
|
self.helper.layout = Layout(
|
||||||
|
"users",
|
||||||
|
"accounts",
|
||||||
|
"currencies",
|
||||||
|
"transactions",
|
||||||
|
"categories",
|
||||||
|
"entities",
|
||||||
|
"tags",
|
||||||
|
"installment_plans",
|
||||||
|
"recurring_transactions",
|
||||||
|
"exchange_rates_services",
|
||||||
|
"exchange_rates",
|
||||||
|
"rules",
|
||||||
|
"dca",
|
||||||
|
"import_profiles",
|
||||||
|
FormActions(
|
||||||
|
NoClassSubmit(
|
||||||
|
"submit", _("Export"), css_class="btn btn-outline-primary w-100"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RestoreForm(forms.Form):
|
||||||
|
zip_file = forms.FileField(
|
||||||
|
required=False,
|
||||||
|
help_text=_("Import a ZIP file exported from WYGIWYH"),
|
||||||
|
label=_("ZIP File"),
|
||||||
|
)
|
||||||
|
users = forms.FileField(required=False, label=_("Users"))
|
||||||
|
accounts = forms.FileField(required=False, label=_("Accounts"))
|
||||||
|
currencies = forms.FileField(required=False, label=_("Currencies"))
|
||||||
|
transactions_categories = forms.FileField(required=False, label=_("Categories"))
|
||||||
|
transactions_tags = forms.FileField(required=False, label=_("Tags"))
|
||||||
|
transactions_entities = forms.FileField(required=False, label=_("Entities"))
|
||||||
|
transactions = forms.FileField(required=False, label=_("Transactions"))
|
||||||
|
installment_plans = forms.FileField(required=False, label=_("Installment Plans"))
|
||||||
|
recurring_transactions = forms.FileField(
|
||||||
|
required=False, label=_("Recurring Transactions")
|
||||||
|
)
|
||||||
|
automatic_exchange_rates = forms.FileField(
|
||||||
|
required=False, label=_("Automatic Exchange Rates")
|
||||||
|
)
|
||||||
|
exchange_rates = forms.FileField(required=False, label=_("Exchange Rates"))
|
||||||
|
transaction_rules = forms.FileField(required=False, label=_("Transaction rules"))
|
||||||
|
transaction_rules_actions = forms.FileField(
|
||||||
|
required=False, label=_("Edit transaction action")
|
||||||
|
)
|
||||||
|
transaction_rules_update_or_create = forms.FileField(
|
||||||
|
required=False, label=_("Update or create transaction actions")
|
||||||
|
)
|
||||||
|
dca_strategies = forms.FileField(required=False, label=_("DCA Strategies"))
|
||||||
|
dca_entries = forms.FileField(required=False, label=_("DCA Entries"))
|
||||||
|
import_profiles = forms.FileField(required=False, label=_("Import Profiles"))
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.helper = FormHelper()
|
||||||
|
self.helper.form_tag = False
|
||||||
|
self.helper.form_method = "post"
|
||||||
|
self.helper.layout = Layout(
|
||||||
|
"zip_file",
|
||||||
|
HTML("<hr />"),
|
||||||
|
"users",
|
||||||
|
"accounts",
|
||||||
|
"currencies",
|
||||||
|
"transactions",
|
||||||
|
"transactions_categories",
|
||||||
|
"transactions_entities",
|
||||||
|
"transactions_tags",
|
||||||
|
"installment_plans",
|
||||||
|
"recurring_transactions",
|
||||||
|
"automatic_exchange_rates",
|
||||||
|
"exchange_rates",
|
||||||
|
"transaction_rules",
|
||||||
|
"transaction_rules_actions",
|
||||||
|
"transaction_rules_update_or_create",
|
||||||
|
"dca_strategies",
|
||||||
|
"dca_entries",
|
||||||
|
"import_profiles",
|
||||||
|
FormActions(
|
||||||
|
NoClassSubmit(
|
||||||
|
"submit", _("Restore"), css_class="btn btn-outline-primary w-100"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
if not cleaned_data.get("zip_file") and not any(
|
||||||
|
cleaned_data.get(field) for field in self.fields if field != "zip_file"
|
||||||
|
):
|
||||||
|
raise forms.ValidationError(
|
||||||
|
_("Please upload either a ZIP file or at least one CSV file")
|
||||||
|
)
|
||||||
|
return cleaned_data
|
||||||
0
app/apps/export_app/migrations/__init__.py
Normal file
0
app/apps/export_app/migrations/__init__.py
Normal file
3
app/apps/export_app/models.py
Normal file
3
app/apps/export_app/models.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
# Create your models here.
|
||||||
0
app/apps/export_app/resources/__init__.py
Normal file
0
app/apps/export_app/resources/__init__.py
Normal file
29
app/apps/export_app/resources/accounts.py
Normal file
29
app/apps/export_app/resources/accounts.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from import_export import fields, resources, widgets
|
||||||
|
|
||||||
|
from apps.accounts.models import Account, AccountGroup
|
||||||
|
from apps.export_app.widgets.foreign_key import AutoCreateForeignKeyWidget
|
||||||
|
from apps.currencies.models import Currency
|
||||||
|
|
||||||
|
|
||||||
|
class AccountResource(resources.ModelResource):
|
||||||
|
group = fields.Field(
|
||||||
|
attribute="group",
|
||||||
|
column_name="group",
|
||||||
|
widget=AutoCreateForeignKeyWidget(AccountGroup, "name"),
|
||||||
|
)
|
||||||
|
currency = fields.Field(
|
||||||
|
attribute="currency",
|
||||||
|
column_name="currency",
|
||||||
|
widget=widgets.ForeignKeyWidget(Currency, "name"),
|
||||||
|
)
|
||||||
|
exchange_currency = fields.Field(
|
||||||
|
attribute="exchange_currency",
|
||||||
|
column_name="exchange_currency",
|
||||||
|
widget=widgets.ForeignKeyWidget(Currency, "name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Account
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Account.all_objects.all()
|
||||||
52
app/apps/export_app/resources/currencies.py
Normal file
52
app/apps/export_app/resources/currencies.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
from import_export import fields, resources, widgets
|
||||||
|
|
||||||
|
from apps.accounts.models import Account
|
||||||
|
from apps.currencies.models import Currency, ExchangeRate, ExchangeRateService
|
||||||
|
from apps.export_app.widgets.foreign_key import SkipMissingForeignKeyWidget
|
||||||
|
from apps.export_app.widgets.numbers import UniversalDecimalWidget
|
||||||
|
|
||||||
|
|
||||||
|
class CurrencyResource(resources.ModelResource):
|
||||||
|
exchange_currency = fields.Field(
|
||||||
|
attribute="exchange_currency",
|
||||||
|
column_name="exchange_currency",
|
||||||
|
widget=SkipMissingForeignKeyWidget(Currency, "name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Currency
|
||||||
|
|
||||||
|
|
||||||
|
class ExchangeRateResource(resources.ModelResource):
|
||||||
|
from_currency = fields.Field(
|
||||||
|
attribute="from_currency",
|
||||||
|
column_name="from_currency",
|
||||||
|
widget=widgets.ForeignKeyWidget(Currency, "name"),
|
||||||
|
)
|
||||||
|
to_currency = fields.Field(
|
||||||
|
attribute="to_currency",
|
||||||
|
column_name="to_currency",
|
||||||
|
widget=widgets.ForeignKeyWidget(Currency, "name"),
|
||||||
|
)
|
||||||
|
rate = fields.Field(
|
||||||
|
attribute="rate", column_name="rate", widget=UniversalDecimalWidget()
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ExchangeRate
|
||||||
|
|
||||||
|
|
||||||
|
class ExchangeRateServiceResource(resources.ModelResource):
|
||||||
|
target_currencies = fields.Field(
|
||||||
|
attribute="target_currencies",
|
||||||
|
column_name="target_currencies",
|
||||||
|
widget=widgets.ManyToManyWidget(Currency, field="name"),
|
||||||
|
)
|
||||||
|
target_accounts = fields.Field(
|
||||||
|
attribute="target_accounts",
|
||||||
|
column_name="target_accounts",
|
||||||
|
widget=widgets.ManyToManyWidget(Account, field="name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ExchangeRateService
|
||||||
38
app/apps/export_app/resources/dca.py
Normal file
38
app/apps/export_app/resources/dca.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
from import_export import fields, resources
|
||||||
|
from import_export.widgets import ForeignKeyWidget
|
||||||
|
|
||||||
|
from apps.dca.models import DCAStrategy, DCAEntry
|
||||||
|
from apps.currencies.models import Currency
|
||||||
|
from apps.export_app.widgets.numbers import UniversalDecimalWidget
|
||||||
|
|
||||||
|
|
||||||
|
class DCAStrategyResource(resources.ModelResource):
|
||||||
|
target_currency = fields.Field(
|
||||||
|
attribute="target_currency",
|
||||||
|
column_name="target_currency",
|
||||||
|
widget=ForeignKeyWidget(Currency, "name"),
|
||||||
|
)
|
||||||
|
payment_currency = fields.Field(
|
||||||
|
attribute="payment_currency",
|
||||||
|
column_name="payment_currency",
|
||||||
|
widget=ForeignKeyWidget(Currency, "name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = DCAStrategy
|
||||||
|
|
||||||
|
|
||||||
|
class DCAEntryResource(resources.ModelResource):
|
||||||
|
amount_paid = fields.Field(
|
||||||
|
attribute="amount_paid",
|
||||||
|
column_name="amount_paid",
|
||||||
|
widget=UniversalDecimalWidget(),
|
||||||
|
)
|
||||||
|
amount_received = fields.Field(
|
||||||
|
attribute="amount_received",
|
||||||
|
column_name="amount_received",
|
||||||
|
widget=UniversalDecimalWidget(),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = DCAEntry
|
||||||
8
app/apps/export_app/resources/import_app.py
Normal file
8
app/apps/export_app/resources/import_app.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from import_export import resources
|
||||||
|
|
||||||
|
from apps.import_app.models import ImportProfile
|
||||||
|
|
||||||
|
|
||||||
|
class ImportProfileResource(resources.ModelResource):
|
||||||
|
class Meta:
|
||||||
|
model = ImportProfile
|
||||||
25
app/apps/export_app/resources/rules.py
Normal file
25
app/apps/export_app/resources/rules.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from import_export import fields, resources
|
||||||
|
from import_export.widgets import ForeignKeyWidget
|
||||||
|
|
||||||
|
from apps.export_app.widgets.foreign_key import AutoCreateForeignKeyWidget
|
||||||
|
from apps.export_app.widgets.many_to_many import AutoCreateManyToManyWidget
|
||||||
|
from apps.rules.models import (
|
||||||
|
TransactionRule,
|
||||||
|
TransactionRuleAction,
|
||||||
|
UpdateOrCreateTransactionRuleAction,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionRuleResource(resources.ModelResource):
|
||||||
|
class Meta:
|
||||||
|
model = TransactionRule
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionRuleActionResource(resources.ModelResource):
|
||||||
|
class Meta:
|
||||||
|
model = TransactionRuleAction
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateOrCreateTransactionRuleResource(resources.ModelResource):
|
||||||
|
class Meta:
|
||||||
|
model = UpdateOrCreateTransactionRuleAction
|
||||||
158
app/apps/export_app/resources/transactions.py
Normal file
158
app/apps/export_app/resources/transactions.py
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
from import_export import fields, resources
|
||||||
|
from import_export.widgets import ForeignKeyWidget
|
||||||
|
|
||||||
|
from apps.accounts.models import Account
|
||||||
|
from apps.export_app.widgets.foreign_key import AutoCreateForeignKeyWidget
|
||||||
|
from apps.export_app.widgets.many_to_many import AutoCreateManyToManyWidget
|
||||||
|
from apps.export_app.widgets.string import EmptyStringToNoneField
|
||||||
|
from apps.transactions.models import (
|
||||||
|
Transaction,
|
||||||
|
TransactionCategory,
|
||||||
|
TransactionTag,
|
||||||
|
TransactionEntity,
|
||||||
|
RecurringTransaction,
|
||||||
|
InstallmentPlan,
|
||||||
|
)
|
||||||
|
from apps.export_app.widgets.numbers import UniversalDecimalWidget
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionResource(resources.ModelResource):
|
||||||
|
account = fields.Field(
|
||||||
|
attribute="account",
|
||||||
|
column_name="account",
|
||||||
|
widget=ForeignKeyWidget(Account, "name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
category = fields.Field(
|
||||||
|
attribute="category",
|
||||||
|
column_name="category",
|
||||||
|
widget=AutoCreateForeignKeyWidget(TransactionCategory, "name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
tags = fields.Field(
|
||||||
|
attribute="tags",
|
||||||
|
column_name="tags",
|
||||||
|
widget=AutoCreateManyToManyWidget(TransactionTag, field="name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
entities = fields.Field(
|
||||||
|
attribute="entities",
|
||||||
|
column_name="entities",
|
||||||
|
widget=AutoCreateManyToManyWidget(TransactionEntity, field="name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
internal_id = EmptyStringToNoneField(
|
||||||
|
column_name="internal_id", attribute="internal_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
amount = fields.Field(
|
||||||
|
attribute="amount",
|
||||||
|
column_name="amount",
|
||||||
|
widget=UniversalDecimalWidget(),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Transaction
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Transaction.userless_all_objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionTagResource(resources.ModelResource):
|
||||||
|
class Meta:
|
||||||
|
model = TransactionTag
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return TransactionTag.all_objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionEntityResource(resources.ModelResource):
|
||||||
|
class Meta:
|
||||||
|
model = TransactionEntity
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return TransactionEntity.all_objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionCategoyResource(resources.ModelResource):
|
||||||
|
class Meta:
|
||||||
|
model = TransactionCategory
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return TransactionCategory.all_objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
class RecurringTransactionResource(resources.ModelResource):
|
||||||
|
account = fields.Field(
|
||||||
|
attribute="account",
|
||||||
|
column_name="account",
|
||||||
|
widget=ForeignKeyWidget(Account, "name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
category = fields.Field(
|
||||||
|
attribute="category",
|
||||||
|
column_name="category",
|
||||||
|
widget=AutoCreateForeignKeyWidget(TransactionCategory, "name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
tags = fields.Field(
|
||||||
|
attribute="tags",
|
||||||
|
column_name="tags",
|
||||||
|
widget=AutoCreateManyToManyWidget(TransactionTag, field="name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
entities = fields.Field(
|
||||||
|
attribute="entities",
|
||||||
|
column_name="entities",
|
||||||
|
widget=AutoCreateManyToManyWidget(TransactionEntity, field="name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
amount = fields.Field(
|
||||||
|
attribute="amount",
|
||||||
|
column_name="amount",
|
||||||
|
widget=UniversalDecimalWidget(),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = RecurringTransaction
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return RecurringTransaction.all_objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
class InstallmentPlanResource(resources.ModelResource):
|
||||||
|
account = fields.Field(
|
||||||
|
attribute="account",
|
||||||
|
column_name="account",
|
||||||
|
widget=ForeignKeyWidget(Account, "name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
category = fields.Field(
|
||||||
|
attribute="category",
|
||||||
|
column_name="category",
|
||||||
|
widget=AutoCreateForeignKeyWidget(TransactionCategory, "name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
tags = fields.Field(
|
||||||
|
attribute="tags",
|
||||||
|
column_name="tags",
|
||||||
|
widget=AutoCreateManyToManyWidget(TransactionTag, field="name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
entities = fields.Field(
|
||||||
|
attribute="entities",
|
||||||
|
column_name="entities",
|
||||||
|
widget=AutoCreateManyToManyWidget(TransactionEntity, field="name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
installment_amount = fields.Field(
|
||||||
|
attribute="installment_amount",
|
||||||
|
column_name="installment_amount",
|
||||||
|
widget=UniversalDecimalWidget(),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = InstallmentPlan
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return InstallmentPlan.all_objects.all()
|
||||||
161
app/apps/export_app/resources/users.py
Normal file
161
app/apps/export_app/resources/users.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
from import_export import resources, fields
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
from apps.users.models import UserSettings
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class UserResource(resources.ModelResource):
|
||||||
|
# User fields
|
||||||
|
email = fields.Field(attribute="email", column_name="Email")
|
||||||
|
|
||||||
|
# UserSettings fields - for export only
|
||||||
|
hide_amounts = fields.Field(
|
||||||
|
attribute="settings__hide_amounts", column_name="Hide Amounts", readonly=True
|
||||||
|
)
|
||||||
|
mute_sounds = fields.Field(
|
||||||
|
attribute="settings__mute_sounds", column_name="Mute Sounds", readonly=True
|
||||||
|
)
|
||||||
|
date_format = fields.Field(
|
||||||
|
attribute="settings__date_format", column_name="Date Format", readonly=True
|
||||||
|
)
|
||||||
|
datetime_format = fields.Field(
|
||||||
|
attribute="settings__datetime_format",
|
||||||
|
column_name="Datetime Format",
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
number_format = fields.Field(
|
||||||
|
attribute="settings__number_format", column_name="Number Format", readonly=True
|
||||||
|
)
|
||||||
|
language = fields.Field(
|
||||||
|
attribute="settings__language", column_name="Language", readonly=True
|
||||||
|
)
|
||||||
|
timezone = fields.Field(
|
||||||
|
attribute="settings__timezone", column_name="Timezone", readonly=True
|
||||||
|
)
|
||||||
|
start_page = fields.Field(
|
||||||
|
attribute="settings__start_page", column_name="Start Page", readonly=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Human-readable fields for choice values
|
||||||
|
start_page_display = fields.Field(column_name="Start Page Display", readonly=True)
|
||||||
|
language_display = fields.Field(column_name="Language Display", readonly=True)
|
||||||
|
timezone_display = fields.Field(column_name="Timezone Display", readonly=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def dehydrate_start_page_display(user):
|
||||||
|
if hasattr(user, "settings"):
|
||||||
|
return dict(UserSettings.StartPage.choices).get(
|
||||||
|
user.settings.start_page, ""
|
||||||
|
)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def dehydrate_language_display(user):
|
||||||
|
if hasattr(user, "settings"):
|
||||||
|
languages = dict([("auto", "Auto")] + list(settings.LANGUAGES))
|
||||||
|
return languages.get(user.settings.language, user.settings.language)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def dehydrate_timezone_display(user):
|
||||||
|
if hasattr(user, "settings"):
|
||||||
|
if user.settings.timezone == "auto":
|
||||||
|
return "Auto"
|
||||||
|
return user.settings.timezone
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def after_init_instance(self, instance, new, row, **kwargs):
|
||||||
|
"""
|
||||||
|
Store settings data on the instance to be used after save
|
||||||
|
"""
|
||||||
|
# Process boolean fields properly
|
||||||
|
hide_amounts = row.get("Hide Amounts", "").lower() == "true"
|
||||||
|
mute_sounds = row.get("Mute Sounds", "").lower() == "true"
|
||||||
|
|
||||||
|
# Store settings data on the instance for later use
|
||||||
|
instance._settings_data = {
|
||||||
|
"hide_amounts": hide_amounts,
|
||||||
|
"mute_sounds": mute_sounds,
|
||||||
|
"date_format": row.get("Date Format", "SHORT_DATE_FORMAT"),
|
||||||
|
"datetime_format": row.get("Datetime Format", "SHORT_DATETIME_FORMAT"),
|
||||||
|
"number_format": row.get("Number Format", "AA"),
|
||||||
|
"language": row.get("Language", "auto"),
|
||||||
|
"timezone": row.get("Timezone", "auto"),
|
||||||
|
"start_page": row.get("Start Page", UserSettings.StartPage.MONTHLY),
|
||||||
|
}
|
||||||
|
|
||||||
|
return instance
|
||||||
|
|
||||||
|
def after_save_instance(self, instance, row, **kwargs):
|
||||||
|
"""
|
||||||
|
Create or update UserSettings after User is saved
|
||||||
|
"""
|
||||||
|
if not hasattr(instance, "_settings_data"):
|
||||||
|
return
|
||||||
|
|
||||||
|
settings_data = instance._settings_data
|
||||||
|
|
||||||
|
# Create or update UserSettings
|
||||||
|
try:
|
||||||
|
user_settings = UserSettings.objects.get(user=instance)
|
||||||
|
# Update existing settings
|
||||||
|
for key, value in settings_data.items():
|
||||||
|
setattr(user_settings, key, value)
|
||||||
|
user_settings.save()
|
||||||
|
except UserSettings.DoesNotExist:
|
||||||
|
# Create new settings
|
||||||
|
UserSettings.objects.create(user=instance, **settings_data)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""
|
||||||
|
Ensure settings are prefetched when exporting users
|
||||||
|
"""
|
||||||
|
return super().get_queryset().select_related("settings")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
import_id_fields = ["id"]
|
||||||
|
fields = (
|
||||||
|
"id",
|
||||||
|
"email",
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"is_active",
|
||||||
|
"date_joined",
|
||||||
|
"password",
|
||||||
|
"hide_amounts",
|
||||||
|
"mute_sounds",
|
||||||
|
"date_format",
|
||||||
|
"datetime_format",
|
||||||
|
"number_format",
|
||||||
|
"language",
|
||||||
|
"language_display",
|
||||||
|
"timezone",
|
||||||
|
"timezone_display",
|
||||||
|
"start_page",
|
||||||
|
"start_page_display",
|
||||||
|
)
|
||||||
|
export_order = (
|
||||||
|
"id",
|
||||||
|
"email",
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"is_active",
|
||||||
|
"date_joined",
|
||||||
|
"password",
|
||||||
|
"hide_amounts",
|
||||||
|
"mute_sounds",
|
||||||
|
"date_format",
|
||||||
|
"datetime_format",
|
||||||
|
"number_format",
|
||||||
|
"language",
|
||||||
|
"language_display",
|
||||||
|
"timezone",
|
||||||
|
"timezone_display",
|
||||||
|
"start_page",
|
||||||
|
"start_page_display",
|
||||||
|
)
|
||||||
3
app/apps/export_app/tests.py
Normal file
3
app/apps/export_app/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
8
app/apps/export_app/urls.py
Normal file
8
app/apps/export_app/urls.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from django.urls import path
|
||||||
|
import apps.export_app.views as views
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("export/", views.export_index, name="export_index"),
|
||||||
|
path("export/form/", views.export_form, name="export_form"),
|
||||||
|
path("export/restore/", views.import_form, name="restore_form"),
|
||||||
|
]
|
||||||
296
app/apps/export_app/views.py
Normal file
296
app/apps/export_app/views.py
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
import logging
|
||||||
|
import zipfile
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth.decorators import login_required, user_passes_test
|
||||||
|
from django.db import transaction
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.shortcuts import render
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.views.decorators.http import require_http_methods
|
||||||
|
from tablib import Dataset
|
||||||
|
|
||||||
|
from apps.common.decorators.htmx import only_htmx
|
||||||
|
from apps.export_app.forms import ExportForm, RestoreForm
|
||||||
|
from apps.export_app.resources.accounts import AccountResource
|
||||||
|
from apps.export_app.resources.currencies import (
|
||||||
|
CurrencyResource,
|
||||||
|
ExchangeRateResource,
|
||||||
|
ExchangeRateServiceResource,
|
||||||
|
)
|
||||||
|
from apps.export_app.resources.dca import (
|
||||||
|
DCAStrategyResource,
|
||||||
|
DCAEntryResource,
|
||||||
|
)
|
||||||
|
from apps.export_app.resources.import_app import (
|
||||||
|
ImportProfileResource,
|
||||||
|
)
|
||||||
|
from apps.export_app.resources.rules import (
|
||||||
|
TransactionRuleResource,
|
||||||
|
TransactionRuleActionResource,
|
||||||
|
UpdateOrCreateTransactionRuleResource,
|
||||||
|
)
|
||||||
|
from apps.export_app.resources.transactions import (
|
||||||
|
TransactionResource,
|
||||||
|
TransactionTagResource,
|
||||||
|
TransactionEntityResource,
|
||||||
|
TransactionCategoyResource,
|
||||||
|
InstallmentPlanResource,
|
||||||
|
RecurringTransactionResource,
|
||||||
|
)
|
||||||
|
from apps.export_app.resources.users import UserResource
|
||||||
|
from apps.common.decorators.demo import disabled_on_demo
|
||||||
|
|
||||||
|
logger = logging.getLogger()
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
|
@user_passes_test(lambda u: u.is_superuser)
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def export_index(request):
|
||||||
|
return render(request, "export_app/pages/index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
|
@user_passes_test(lambda u: u.is_superuser)
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def export_form(request):
|
||||||
|
timestamp = timezone.localtime(timezone.now()).strftime("%Y-%m-%dT%H-%M-%S")
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
form = ExportForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
zip_buffer = BytesIO()
|
||||||
|
|
||||||
|
export_users = form.cleaned_data.get("users", False)
|
||||||
|
export_accounts = form.cleaned_data.get("accounts", False)
|
||||||
|
export_currencies = form.cleaned_data.get("currencies", False)
|
||||||
|
export_transactions = form.cleaned_data.get("transactions", False)
|
||||||
|
export_categories = form.cleaned_data.get("categories", False)
|
||||||
|
export_tags = form.cleaned_data.get("tags", False)
|
||||||
|
export_entities = form.cleaned_data.get("entities", False)
|
||||||
|
export_installment_plans = form.cleaned_data.get("installment_plans", False)
|
||||||
|
export_recurring_transactions = form.cleaned_data.get(
|
||||||
|
"recurring_transactions", False
|
||||||
|
)
|
||||||
|
|
||||||
|
export_exchange_rates_services = form.cleaned_data.get(
|
||||||
|
"exchange_rates_services", False
|
||||||
|
)
|
||||||
|
export_exchange_rates = form.cleaned_data.get("exchange_rates", False)
|
||||||
|
export_rules = form.cleaned_data.get("rules", False)
|
||||||
|
export_dca = form.cleaned_data.get("dca", False)
|
||||||
|
export_import_profiles = form.cleaned_data.get("import_profiles", False)
|
||||||
|
|
||||||
|
exports = []
|
||||||
|
if export_users:
|
||||||
|
exports.append((UserResource().export(), "users"))
|
||||||
|
if export_accounts:
|
||||||
|
exports.append((AccountResource().export(), "accounts"))
|
||||||
|
if export_currencies:
|
||||||
|
exports.append((CurrencyResource().export(), "currencies"))
|
||||||
|
if export_transactions:
|
||||||
|
exports.append((TransactionResource().export(), "transactions"))
|
||||||
|
if export_categories:
|
||||||
|
exports.append(
|
||||||
|
(TransactionCategoyResource().export(), "transactions_categories")
|
||||||
|
)
|
||||||
|
if export_tags:
|
||||||
|
exports.append((TransactionTagResource().export(), "transactions_tags"))
|
||||||
|
if export_entities:
|
||||||
|
exports.append(
|
||||||
|
(TransactionEntityResource().export(), "transactions_entities")
|
||||||
|
)
|
||||||
|
if export_installment_plans:
|
||||||
|
exports.append(
|
||||||
|
(InstallmentPlanResource().export(), "installment_plans")
|
||||||
|
)
|
||||||
|
if export_recurring_transactions:
|
||||||
|
exports.append(
|
||||||
|
(RecurringTransactionResource().export(), "recurring_transactions")
|
||||||
|
)
|
||||||
|
if export_exchange_rates_services:
|
||||||
|
exports.append(
|
||||||
|
(ExchangeRateServiceResource().export(), "automatic_exchange_rates")
|
||||||
|
)
|
||||||
|
if export_exchange_rates:
|
||||||
|
exports.append((ExchangeRateResource().export(), "exchange_rates"))
|
||||||
|
if export_rules:
|
||||||
|
exports.append(
|
||||||
|
(TransactionRuleResource().export(), "transaction_rules")
|
||||||
|
)
|
||||||
|
exports.append(
|
||||||
|
(
|
||||||
|
TransactionRuleActionResource().export(),
|
||||||
|
"transaction_rules_actions",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
exports.append(
|
||||||
|
(
|
||||||
|
UpdateOrCreateTransactionRuleResource().export(),
|
||||||
|
"transaction_rules_update_or_create",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if export_dca:
|
||||||
|
exports.append((DCAStrategyResource().export(), "dca_strategies"))
|
||||||
|
exports.append(
|
||||||
|
(
|
||||||
|
DCAEntryResource().export(),
|
||||||
|
"dca_entries",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if export_import_profiles:
|
||||||
|
exports.append((ImportProfileResource().export(), "import_profiles"))
|
||||||
|
|
||||||
|
if len(exports) >= 2:
|
||||||
|
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
|
||||||
|
for dataset, name in exports:
|
||||||
|
zip_file.writestr(f"{name}.csv", dataset.csv)
|
||||||
|
|
||||||
|
response = HttpResponse(
|
||||||
|
zip_buffer.getvalue(),
|
||||||
|
content_type="application/zip",
|
||||||
|
headers={
|
||||||
|
"HX-Trigger": "hide_offcanvas, updated",
|
||||||
|
"Content-Disposition": f'attachment; filename="{timestamp}_WYGIWYH_export.zip"',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
elif len(exports) == 1:
|
||||||
|
dataset, name = exports[0]
|
||||||
|
|
||||||
|
response = HttpResponse(
|
||||||
|
dataset.csv,
|
||||||
|
content_type="text/csv",
|
||||||
|
headers={
|
||||||
|
"HX-Trigger": "hide_offcanvas, updated",
|
||||||
|
"Content-Disposition": f'attachment; filename="{timestamp}_WYGIWYH_export_{name}.csv"',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
else:
|
||||||
|
return HttpResponse(
|
||||||
|
_("You have to select at least one export"),
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
form = ExportForm()
|
||||||
|
|
||||||
|
return render(request, "export_app/fragments/export.html", context={"form": form})
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
|
@user_passes_test(lambda u: u.is_superuser)
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def import_form(request):
|
||||||
|
if request.method == "POST":
|
||||||
|
form = RestoreForm(request.POST, request.FILES)
|
||||||
|
if form.is_valid():
|
||||||
|
try:
|
||||||
|
process_imports(request, form.cleaned_data)
|
||||||
|
messages.success(request, _("Data restored successfully"))
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
headers={
|
||||||
|
"HX-Trigger": "hide_offcanvas, updated",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error importing", exc_info=e)
|
||||||
|
messages.error(
|
||||||
|
request,
|
||||||
|
_(
|
||||||
|
"There was an error restoring your data. Check the logs for more details."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
form = RestoreForm()
|
||||||
|
|
||||||
|
response = render(request, "export_app/fragments/restore.html", {"form": form})
|
||||||
|
response["HX-Trigger"] = "updated"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def process_imports(request, cleaned_data):
|
||||||
|
# Define import order to handle dependencies
|
||||||
|
import_order = [
|
||||||
|
("users", UserResource),
|
||||||
|
("currencies", CurrencyResource),
|
||||||
|
(
|
||||||
|
"currencies",
|
||||||
|
CurrencyResource,
|
||||||
|
), # We do a double pass because exchange_currency may not exist when currency is initially created
|
||||||
|
("accounts", AccountResource),
|
||||||
|
("transactions_categories", TransactionCategoyResource),
|
||||||
|
("transactions_tags", TransactionTagResource),
|
||||||
|
("transactions_entities", TransactionEntityResource),
|
||||||
|
("automatic_exchange_rates", ExchangeRateServiceResource),
|
||||||
|
("exchange_rates", ExchangeRateResource),
|
||||||
|
("installment_plans", InstallmentPlanResource),
|
||||||
|
("recurring_transactions", RecurringTransactionResource),
|
||||||
|
("transactions", TransactionResource),
|
||||||
|
("dca_strategies", DCAStrategyResource),
|
||||||
|
("dca_entries", DCAEntryResource),
|
||||||
|
("import_profiles", ImportProfileResource),
|
||||||
|
("transaction_rules", TransactionRuleResource),
|
||||||
|
("transaction_rules_actions", TransactionRuleActionResource),
|
||||||
|
("transaction_rules_update_or_create", UpdateOrCreateTransactionRuleResource),
|
||||||
|
]
|
||||||
|
|
||||||
|
def import_dataset(content, resource_class, field_name):
|
||||||
|
try:
|
||||||
|
# Create a new resource instance
|
||||||
|
resource = resource_class()
|
||||||
|
|
||||||
|
# Create dataset from CSV content
|
||||||
|
dataset = Dataset()
|
||||||
|
dataset.load(content, format="csv")
|
||||||
|
|
||||||
|
# Perform the import
|
||||||
|
result = resource.import_data(
|
||||||
|
dataset,
|
||||||
|
dry_run=False,
|
||||||
|
raise_errors=True,
|
||||||
|
collect_failed_rows=True,
|
||||||
|
use_transactions=False,
|
||||||
|
skip_unchanged=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.has_errors():
|
||||||
|
raise ImportError(f"Failed rows: {result.failed_dataset}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error importing {field_name}: {str(e)}")
|
||||||
|
raise ImportError(f"Error importing {field_name}: {str(e)}")
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
files = {}
|
||||||
|
|
||||||
|
if zip_file := cleaned_data.get("zip_file"):
|
||||||
|
# Process ZIP file
|
||||||
|
with zipfile.ZipFile(zip_file) as z:
|
||||||
|
for filename in z.namelist():
|
||||||
|
name = filename.replace(".csv", "")
|
||||||
|
with z.open(filename) as f:
|
||||||
|
content = f.read().decode("utf-8")
|
||||||
|
|
||||||
|
files[name] = content
|
||||||
|
|
||||||
|
for field_name, resource_class in import_order:
|
||||||
|
if field_name in files.keys():
|
||||||
|
content = files[field_name]
|
||||||
|
import_dataset(content, resource_class, field_name)
|
||||||
|
else:
|
||||||
|
# Process individual files
|
||||||
|
for field_name, resource_class in import_order:
|
||||||
|
if csv_file := cleaned_data.get(field_name):
|
||||||
|
content = csv_file.read().decode("utf-8")
|
||||||
|
import_dataset(content, resource_class, field_name)
|
||||||
0
app/apps/export_app/widgets/__init__.py
Normal file
0
app/apps/export_app/widgets/__init__.py
Normal file
22
app/apps/export_app/widgets/foreign_key.py
Normal file
22
app/apps/export_app/widgets/foreign_key.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from import_export.widgets import ForeignKeyWidget
|
||||||
|
|
||||||
|
|
||||||
|
class AutoCreateForeignKeyWidget(ForeignKeyWidget):
|
||||||
|
def clean(self, value, row=None, *args, **kwargs):
|
||||||
|
if value:
|
||||||
|
try:
|
||||||
|
return super().clean(value, row, **kwargs)
|
||||||
|
except self.model.DoesNotExist:
|
||||||
|
return self.model.objects.create(name=value)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class SkipMissingForeignKeyWidget(ForeignKeyWidget):
|
||||||
|
def clean(self, value, row=None, *args, **kwargs):
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return super().clean(value, row, *args, **kwargs)
|
||||||
|
except self.model.DoesNotExist:
|
||||||
|
return None
|
||||||
21
app/apps/export_app/widgets/many_to_many.py
Normal file
21
app/apps/export_app/widgets/many_to_many.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from import_export.widgets import ManyToManyWidget
|
||||||
|
|
||||||
|
|
||||||
|
class AutoCreateManyToManyWidget(ManyToManyWidget):
|
||||||
|
def clean(self, value, row=None, *args, **kwargs):
|
||||||
|
if not value:
|
||||||
|
return []
|
||||||
|
|
||||||
|
values = value.split(self.separator)
|
||||||
|
cleaned_values = []
|
||||||
|
|
||||||
|
for val in values:
|
||||||
|
val = val.strip()
|
||||||
|
if val:
|
||||||
|
try:
|
||||||
|
obj = self.model.objects.get(**{self.field: val})
|
||||||
|
except self.model.DoesNotExist:
|
||||||
|
obj = self.model.objects.create(name=val)
|
||||||
|
cleaned_values.append(obj)
|
||||||
|
|
||||||
|
return cleaned_values
|
||||||
18
app/apps/export_app/widgets/numbers.py
Normal file
18
app/apps/export_app/widgets/numbers.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from import_export.widgets import NumberWidget
|
||||||
|
|
||||||
|
|
||||||
|
class UniversalDecimalWidget(NumberWidget):
|
||||||
|
def clean(self, value, row=None, *args, **kwargs):
|
||||||
|
if self.is_empty(value):
|
||||||
|
return None
|
||||||
|
# Replace comma with dot if present
|
||||||
|
if isinstance(value, str):
|
||||||
|
value = value.replace(",", ".")
|
||||||
|
return Decimal(str(value))
|
||||||
|
|
||||||
|
def render(self, value, obj=None, **kwargs):
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
return str(value).replace(",", ".")
|
||||||
7
app/apps/export_app/widgets/string.py
Normal file
7
app/apps/export_app/widgets/string.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from import_export import fields
|
||||||
|
|
||||||
|
|
||||||
|
class EmptyStringToNoneField(fields.Field):
|
||||||
|
def clean(self, data, **kwargs):
|
||||||
|
value = super().clean(data)
|
||||||
|
return None if value == "" else value
|
||||||
@@ -47,6 +47,34 @@ class SplitTransformationRule(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AddTransformationRule(BaseModel):
|
||||||
|
type: Literal["add"]
|
||||||
|
field: str = Field(..., description="Field to add to the source value")
|
||||||
|
absolute_values: bool = Field(
|
||||||
|
default=False, description="Use absolute values for addition"
|
||||||
|
)
|
||||||
|
thousand_separator: str = Field(
|
||||||
|
default="", description="Thousand separator character"
|
||||||
|
)
|
||||||
|
decimal_separator: str = Field(
|
||||||
|
default=".", description="Decimal separator character"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SubtractTransformationRule(BaseModel):
|
||||||
|
type: Literal["subtract"]
|
||||||
|
field: str = Field(..., description="Field to subtract from the source value")
|
||||||
|
absolute_values: bool = Field(
|
||||||
|
default=False, description="Use absolute values for subtraction"
|
||||||
|
)
|
||||||
|
thousand_separator: str = Field(
|
||||||
|
default="", description="Thousand separator character"
|
||||||
|
)
|
||||||
|
decimal_separator: str = Field(
|
||||||
|
default=".", description="Decimal separator character"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CSVImportSettings(BaseModel):
|
class CSVImportSettings(BaseModel):
|
||||||
skip_errors: bool = Field(
|
skip_errors: bool = Field(
|
||||||
default=False,
|
default=False,
|
||||||
@@ -64,6 +92,20 @@ class CSVImportSettings(BaseModel):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ExcelImportSettings(BaseModel):
|
||||||
|
skip_errors: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="If True, errors during import will be logged and skipped",
|
||||||
|
)
|
||||||
|
file_type: Literal["xls", "xlsx"]
|
||||||
|
trigger_transaction_rules: bool = True
|
||||||
|
importing: Literal[
|
||||||
|
"transactions", "accounts", "currencies", "categories", "tags", "entities"
|
||||||
|
]
|
||||||
|
start_row: int = Field(default=1, description="Where your header is located")
|
||||||
|
sheets: list[str] | str = "*"
|
||||||
|
|
||||||
|
|
||||||
class ColumnMapping(BaseModel):
|
class ColumnMapping(BaseModel):
|
||||||
source: Optional[str] | Optional[list[str]] = Field(
|
source: Optional[str] | Optional[list[str]] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
@@ -78,6 +120,8 @@ class ColumnMapping(BaseModel):
|
|||||||
| HashTransformationRule
|
| HashTransformationRule
|
||||||
| MergeTransformationRule
|
| MergeTransformationRule
|
||||||
| SplitTransformationRule
|
| SplitTransformationRule
|
||||||
|
| AddTransformationRule
|
||||||
|
| SubtractTransformationRule
|
||||||
]
|
]
|
||||||
] = Field(default_factory=list)
|
] = Field(default_factory=list)
|
||||||
|
|
||||||
@@ -86,7 +130,6 @@ class TransactionAccountMapping(ColumnMapping):
|
|||||||
target: Literal["account"] = Field(..., description="Transaction field to map to")
|
target: Literal["account"] = Field(..., description="Transaction field to map to")
|
||||||
type: Literal["id", "name"] = "name"
|
type: Literal["id", "name"] = "name"
|
||||||
coerce_to: Literal["str|int"] = Field("str|int", frozen=True)
|
coerce_to: Literal["str|int"] = Field("str|int", frozen=True)
|
||||||
required: bool = Field(True, frozen=True)
|
|
||||||
|
|
||||||
|
|
||||||
class TransactionTypeMapping(ColumnMapping):
|
class TransactionTypeMapping(ColumnMapping):
|
||||||
@@ -105,7 +148,6 @@ class TransactionDateMapping(ColumnMapping):
|
|||||||
target: Literal["date"] = Field(..., description="Transaction field to map to")
|
target: Literal["date"] = Field(..., description="Transaction field to map to")
|
||||||
format: List[str] | str
|
format: List[str] | str
|
||||||
coerce_to: Literal["date"] = Field("date", frozen=True)
|
coerce_to: Literal["date"] = Field("date", frozen=True)
|
||||||
required: bool = Field(True, frozen=True)
|
|
||||||
|
|
||||||
|
|
||||||
class TransactionReferenceDateMapping(ColumnMapping):
|
class TransactionReferenceDateMapping(ColumnMapping):
|
||||||
@@ -119,7 +161,6 @@ class TransactionReferenceDateMapping(ColumnMapping):
|
|||||||
class TransactionAmountMapping(ColumnMapping):
|
class TransactionAmountMapping(ColumnMapping):
|
||||||
target: Literal["amount"] = Field(..., description="Transaction field to map to")
|
target: Literal["amount"] = Field(..., description="Transaction field to map to")
|
||||||
coerce_to: Literal["positive_decimal"] = Field("positive_decimal", frozen=True)
|
coerce_to: Literal["positive_decimal"] = Field("positive_decimal", frozen=True)
|
||||||
required: bool = Field(True, frozen=True)
|
|
||||||
|
|
||||||
|
|
||||||
class TransactionDescriptionMapping(ColumnMapping):
|
class TransactionDescriptionMapping(ColumnMapping):
|
||||||
@@ -301,7 +342,7 @@ class CurrencyExchangeMapping(ColumnMapping):
|
|||||||
|
|
||||||
|
|
||||||
class ImportProfileSchema(BaseModel):
|
class ImportProfileSchema(BaseModel):
|
||||||
settings: CSVImportSettings
|
settings: CSVImportSettings | ExcelImportSettings
|
||||||
mapping: Dict[
|
mapping: Dict[
|
||||||
str,
|
str,
|
||||||
TransactionAccountMapping
|
TransactionAccountMapping
|
||||||
|
|||||||
@@ -3,14 +3,16 @@ import hashlib
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime, date
|
||||||
from decimal import Decimal
|
from decimal import Decimal, InvalidOperation
|
||||||
from typing import Dict, Any, Literal, Union
|
from typing import Dict, Any, Literal, Union
|
||||||
|
|
||||||
import cachalot.api
|
import openpyxl
|
||||||
|
import xlrd
|
||||||
import yaml
|
import yaml
|
||||||
from cachalot.api import cachalot_disabled
|
from cachalot.api import cachalot_disabled
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from openpyxl.utils.exceptions import InvalidFileException
|
||||||
|
|
||||||
from apps.accounts.models import Account, AccountGroup
|
from apps.accounts.models import Account, AccountGroup
|
||||||
from apps.currencies.models import Currency
|
from apps.currencies.models import Currency
|
||||||
@@ -40,7 +42,9 @@ class ImportService:
|
|||||||
self.import_run: ImportRun = import_run
|
self.import_run: ImportRun = import_run
|
||||||
self.profile: ImportProfile = import_run.profile
|
self.profile: ImportProfile = import_run.profile
|
||||||
self.config: version_1.ImportProfileSchema = self._load_config()
|
self.config: version_1.ImportProfileSchema = self._load_config()
|
||||||
self.settings: version_1.CSVImportSettings = self.config.settings
|
self.settings: version_1.CSVImportSettings | version_1.ExcelImportSettings = (
|
||||||
|
self.config.settings
|
||||||
|
)
|
||||||
self.deduplication: list[version_1.CompareDeduplicationRule] = (
|
self.deduplication: list[version_1.CompareDeduplicationRule] = (
|
||||||
self.config.deduplication
|
self.config.deduplication
|
||||||
)
|
)
|
||||||
@@ -75,6 +79,13 @@ class ImportService:
|
|||||||
self.import_run.logs += log_line
|
self.import_run.logs += log_line
|
||||||
self.import_run.save(update_fields=["logs"])
|
self.import_run.save(update_fields=["logs"])
|
||||||
|
|
||||||
|
if level == "info":
|
||||||
|
logger.info(log_line)
|
||||||
|
elif level == "warning":
|
||||||
|
logger.warning(log_line)
|
||||||
|
elif level == "error":
|
||||||
|
logger.error(log_line, exc_info=True)
|
||||||
|
|
||||||
def _update_totals(
|
def _update_totals(
|
||||||
self,
|
self,
|
||||||
field: Literal["total", "processed", "successful", "skipped", "failed"],
|
field: Literal["total", "processed", "successful", "skipped", "failed"],
|
||||||
@@ -129,9 +140,12 @@ class ImportService:
|
|||||||
|
|
||||||
self.import_run.save(update_fields=["status"])
|
self.import_run.save(update_fields=["status"])
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _transform_value(
|
def _transform_value(
|
||||||
value: str, mapping: version_1.ColumnMapping, row: Dict[str, str] = None
|
self,
|
||||||
|
value: str,
|
||||||
|
mapping: version_1.ColumnMapping,
|
||||||
|
row: Dict[str, str] = None,
|
||||||
|
mapped_data: Dict[str, Any] = None,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
transformed = value
|
transformed = value
|
||||||
|
|
||||||
@@ -142,8 +156,12 @@ class ImportService:
|
|||||||
for field in transform.fields:
|
for field in transform.fields:
|
||||||
if field in row:
|
if field in row:
|
||||||
values_to_hash.append(str(row[field]))
|
values_to_hash.append(str(row[field]))
|
||||||
|
elif (
|
||||||
# Create hash from concatenated values
|
field.startswith("__")
|
||||||
|
and mapped_data
|
||||||
|
and field[2:] in mapped_data
|
||||||
|
):
|
||||||
|
values_to_hash.append(str(mapped_data[field[2:]]))
|
||||||
if values_to_hash:
|
if values_to_hash:
|
||||||
concatenated = "|".join(values_to_hash)
|
concatenated = "|".join(values_to_hash)
|
||||||
transformed = hashlib.sha256(concatenated.encode()).hexdigest()
|
transformed = hashlib.sha256(concatenated.encode()).hexdigest()
|
||||||
@@ -157,6 +175,7 @@ class ImportService:
|
|||||||
transformed = transformed.replace(
|
transformed = transformed.replace(
|
||||||
transform.pattern, transform.replacement
|
transform.pattern, transform.replacement
|
||||||
)
|
)
|
||||||
|
|
||||||
elif transform.type == "regex":
|
elif transform.type == "regex":
|
||||||
if transform.exclusive:
|
if transform.exclusive:
|
||||||
transformed = re.sub(
|
transformed = re.sub(
|
||||||
@@ -166,16 +185,25 @@ class ImportService:
|
|||||||
transformed = re.sub(
|
transformed = re.sub(
|
||||||
transform.pattern, transform.replacement, transformed
|
transform.pattern, transform.replacement, transformed
|
||||||
)
|
)
|
||||||
|
|
||||||
elif transform.type == "date_format":
|
elif transform.type == "date_format":
|
||||||
transformed = datetime.strptime(
|
transformed = datetime.strptime(
|
||||||
transformed, transform.original_format
|
transformed, transform.original_format
|
||||||
).strftime(transform.new_format)
|
).strftime(transform.new_format)
|
||||||
|
|
||||||
elif transform.type == "merge":
|
elif transform.type == "merge":
|
||||||
values_to_merge = []
|
values_to_merge = []
|
||||||
for field in transform.fields:
|
for field in transform.fields:
|
||||||
if field in row:
|
if field in row:
|
||||||
values_to_merge.append(str(row[field]))
|
values_to_merge.append(str(row[field]))
|
||||||
|
elif (
|
||||||
|
field.startswith("__")
|
||||||
|
and mapped_data
|
||||||
|
and field[2:] in mapped_data
|
||||||
|
):
|
||||||
|
values_to_merge.append(str(mapped_data[field[2:]]))
|
||||||
transformed = transform.separator.join(values_to_merge)
|
transformed = transform.separator.join(values_to_merge)
|
||||||
|
|
||||||
elif transform.type == "split":
|
elif transform.type == "split":
|
||||||
parts = transformed.split(transform.separator)
|
parts = transformed.split(transform.separator)
|
||||||
if transform.index is not None:
|
if transform.index is not None:
|
||||||
@@ -183,6 +211,38 @@ class ImportService:
|
|||||||
else:
|
else:
|
||||||
transformed = parts
|
transformed = parts
|
||||||
|
|
||||||
|
elif transform.type in ["add", "subtract"]:
|
||||||
|
try:
|
||||||
|
source_value = Decimal(transformed)
|
||||||
|
|
||||||
|
# First check row data, then mapped data if not found
|
||||||
|
field_value = row.get(transform.field)
|
||||||
|
if field_value is None and transform.field.startswith("__"):
|
||||||
|
field_value = mapped_data.get(transform.field[2:])
|
||||||
|
|
||||||
|
if field_value is None:
|
||||||
|
raise KeyError(
|
||||||
|
f"Field '{transform.field}' not found in row or mapped data"
|
||||||
|
)
|
||||||
|
|
||||||
|
field_value = self._prepare_numeric_value(
|
||||||
|
str(field_value),
|
||||||
|
transform.thousand_separator,
|
||||||
|
transform.decimal_separator,
|
||||||
|
)
|
||||||
|
|
||||||
|
if transform.absolute_values:
|
||||||
|
source_value = abs(source_value)
|
||||||
|
field_value = abs(field_value)
|
||||||
|
|
||||||
|
if transform.type == "add":
|
||||||
|
transformed = str(source_value + field_value)
|
||||||
|
else: # subtract
|
||||||
|
transformed = str(source_value - field_value)
|
||||||
|
except (InvalidOperation, KeyError, AttributeError) as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Error in {transform.type} transformation: {e}. Values: {transformed}, {transform.field}"
|
||||||
|
)
|
||||||
return transformed
|
return transformed
|
||||||
|
|
||||||
def _create_transaction(self, data: Dict[str, Any]) -> Transaction:
|
def _create_transaction(self, data: Dict[str, Any]) -> Transaction:
|
||||||
@@ -208,14 +268,17 @@ class ImportService:
|
|||||||
category = TransactionCategory.objects.get(id=category_name)
|
category = TransactionCategory.objects.get(id=category_name)
|
||||||
else: # name
|
else: # name
|
||||||
if getattr(category_mapping, "create", False):
|
if getattr(category_mapping, "create", False):
|
||||||
category, _ = TransactionCategory.objects.get_or_create(
|
try:
|
||||||
|
category = TransactionCategory.objects.get(
|
||||||
name=category_name
|
name=category_name
|
||||||
)
|
)
|
||||||
|
except TransactionCategory.DoesNotExist:
|
||||||
|
category = TransactionCategory(name=category_name)
|
||||||
|
category.save()
|
||||||
else:
|
else:
|
||||||
category = TransactionCategory.objects.filter(
|
category = TransactionCategory.objects.filter(
|
||||||
name=category_name
|
name=category_name
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if category:
|
if category:
|
||||||
data["category"] = category
|
data["category"] = category
|
||||||
self.import_run.categories.add(category)
|
self.import_run.categories.add(category)
|
||||||
@@ -265,9 +328,13 @@ class ImportService:
|
|||||||
tag = TransactionTag.objects.filter(id=tag_name).first()
|
tag = TransactionTag.objects.filter(id=tag_name).first()
|
||||||
else: # name
|
else: # name
|
||||||
if getattr(tags_mapping, "create", False):
|
if getattr(tags_mapping, "create", False):
|
||||||
tag, _ = TransactionTag.objects.get_or_create(
|
try:
|
||||||
|
tag = TransactionTag.objects.get(
|
||||||
name=tag_name.strip()
|
name=tag_name.strip()
|
||||||
)
|
)
|
||||||
|
except TransactionTag.DoesNotExist:
|
||||||
|
tag = TransactionTag(name=tag_name.strip())
|
||||||
|
tag.save()
|
||||||
else:
|
else:
|
||||||
tag = TransactionTag.objects.filter(
|
tag = TransactionTag.objects.filter(
|
||||||
name=tag_name.strip()
|
name=tag_name.strip()
|
||||||
@@ -301,9 +368,13 @@ class ImportService:
|
|||||||
).first()
|
).first()
|
||||||
else: # name
|
else: # name
|
||||||
if getattr(entities_mapping, "create", False):
|
if getattr(entities_mapping, "create", False):
|
||||||
entity, _ = TransactionEntity.objects.get_or_create(
|
try:
|
||||||
|
entity = TransactionEntity.objects.get(
|
||||||
name=entity_name.strip()
|
name=entity_name.strip()
|
||||||
)
|
)
|
||||||
|
except TransactionEntity.DoesNotExist:
|
||||||
|
entity = TransactionEntity(name=entity_name.strip())
|
||||||
|
entity.save()
|
||||||
else:
|
else:
|
||||||
entity = TransactionEntity.objects.filter(
|
entity = TransactionEntity.objects.filter(
|
||||||
name=entity_name.strip()
|
name=entity_name.strip()
|
||||||
@@ -334,7 +405,11 @@ class ImportService:
|
|||||||
def _create_account(self, data: Dict[str, Any]) -> Account:
|
def _create_account(self, data: Dict[str, Any]) -> Account:
|
||||||
if "group" in data:
|
if "group" in data:
|
||||||
group_name = data.pop("group")
|
group_name = data.pop("group")
|
||||||
group, _ = AccountGroup.objects.get_or_create(name=group_name)
|
try:
|
||||||
|
group = AccountGroup.objects.get(name=group_name)
|
||||||
|
except AccountGroup.DoesNotExist:
|
||||||
|
group = AccountGroup(name=group_name)
|
||||||
|
group.save()
|
||||||
data["group"] = group
|
data["group"] = group
|
||||||
|
|
||||||
# Handle currency references
|
# Handle currency references
|
||||||
@@ -399,7 +474,7 @@ class ImportService:
|
|||||||
|
|
||||||
def _coerce_type(
|
def _coerce_type(
|
||||||
self, value: str, mapping: version_1.ColumnMapping
|
self, value: str, mapping: version_1.ColumnMapping
|
||||||
) -> Union[str, int, bool, Decimal, datetime, list]:
|
) -> Union[str, int, bool, Decimal, datetime, list, None]:
|
||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -434,6 +509,11 @@ class ImportService:
|
|||||||
version_1.TransactionReferenceDateMapping,
|
version_1.TransactionReferenceDateMapping,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value.date()
|
||||||
|
elif isinstance(value, date):
|
||||||
|
return value
|
||||||
|
|
||||||
formats = (
|
formats = (
|
||||||
mapping.format
|
mapping.format
|
||||||
if isinstance(mapping.format, list)
|
if isinstance(mapping.format, list)
|
||||||
@@ -484,28 +564,30 @@ class ImportService:
|
|||||||
|
|
||||||
def _map_row(self, row: Dict[str, str]) -> Dict[str, Any]:
|
def _map_row(self, row: Dict[str, str]) -> Dict[str, Any]:
|
||||||
mapped_data = {}
|
mapped_data = {}
|
||||||
|
|
||||||
for field, mapping in self.mapping.items():
|
for field, mapping in self.mapping.items():
|
||||||
value = None
|
value = None
|
||||||
|
|
||||||
if isinstance(mapping.source, str):
|
if isinstance(mapping.source, str):
|
||||||
value = row.get(mapping.source, None)
|
if mapping.source in row:
|
||||||
|
value = row[mapping.source]
|
||||||
|
elif (
|
||||||
|
mapping.source.startswith("__")
|
||||||
|
and mapping.source[2:] in mapped_data
|
||||||
|
):
|
||||||
|
value = mapped_data[mapping.source[2:]]
|
||||||
elif isinstance(mapping.source, list):
|
elif isinstance(mapping.source, list):
|
||||||
for source in mapping.source:
|
for source in mapping.source:
|
||||||
value = row.get(source, None)
|
if source in row:
|
||||||
if value:
|
value = row[source]
|
||||||
|
break
|
||||||
|
elif source.startswith("__") and source[2:] in mapped_data:
|
||||||
|
value = mapped_data[source[2:]]
|
||||||
break
|
break
|
||||||
else:
|
|
||||||
# If source is None, use None as the initial value
|
|
||||||
value = None
|
|
||||||
|
|
||||||
# Use default_value if value is None
|
if value is None:
|
||||||
if not value:
|
|
||||||
value = mapping.default
|
value = mapping.default
|
||||||
|
|
||||||
# Apply transformations
|
|
||||||
if mapping.transformations:
|
if mapping.transformations:
|
||||||
value = self._transform_value(value, mapping, row)
|
value = self._transform_value(value, mapping, row, mapped_data)
|
||||||
|
|
||||||
value = self._coerce_type(value, mapping)
|
value = self._coerce_type(value, mapping)
|
||||||
|
|
||||||
@@ -513,17 +595,29 @@ class ImportService:
|
|||||||
raise ValueError(f"Required field {field} is missing")
|
raise ValueError(f"Required field {field} is missing")
|
||||||
|
|
||||||
if value is not None:
|
if value is not None:
|
||||||
# Remove the prefix from the target field
|
|
||||||
target = mapping.target
|
target = mapping.target
|
||||||
if self.settings.importing == "transactions":
|
if self.settings.importing == "transactions":
|
||||||
mapped_data[target] = value
|
mapped_data[target] = value
|
||||||
else:
|
else:
|
||||||
# Remove the model prefix (e.g., "account_" from "account_name")
|
|
||||||
field_name = target.split("_", 1)[1]
|
field_name = target.split("_", 1)[1]
|
||||||
mapped_data[field_name] = value
|
mapped_data[field_name] = value
|
||||||
|
|
||||||
return mapped_data
|
return mapped_data
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _prepare_numeric_value(
|
||||||
|
value: str, thousand_separator: str, decimal_separator: str
|
||||||
|
) -> Decimal:
|
||||||
|
# Remove thousand separators
|
||||||
|
if thousand_separator:
|
||||||
|
value = value.replace(thousand_separator, "")
|
||||||
|
|
||||||
|
# Replace decimal separator with dot
|
||||||
|
if decimal_separator != ".":
|
||||||
|
value = value.replace(decimal_separator, ".")
|
||||||
|
|
||||||
|
return Decimal(value)
|
||||||
|
|
||||||
def _process_row(self, row: Dict[str, str], row_number: int) -> None:
|
def _process_row(self, row: Dict[str, str], row_number: int) -> None:
|
||||||
try:
|
try:
|
||||||
mapped_data = self._map_row(row)
|
mapped_data = self._map_row(row)
|
||||||
@@ -589,6 +683,151 @@ class ImportService:
|
|||||||
for row_number, row in enumerate(reader, start=1):
|
for row_number, row in enumerate(reader, start=1):
|
||||||
self._process_row(row, row_number)
|
self._process_row(row, row_number)
|
||||||
|
|
||||||
|
def _process_excel(self, file_path):
|
||||||
|
try:
|
||||||
|
if self.settings.file_type == "xlsx":
|
||||||
|
workbook = openpyxl.load_workbook(
|
||||||
|
file_path, read_only=True, data_only=True
|
||||||
|
)
|
||||||
|
sheets_to_process = (
|
||||||
|
workbook.sheetnames
|
||||||
|
if self.settings.sheets == "*"
|
||||||
|
else (
|
||||||
|
self.settings.sheets
|
||||||
|
if isinstance(self.settings.sheets, list)
|
||||||
|
else [self.settings.sheets]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate total rows
|
||||||
|
total_rows = sum(
|
||||||
|
max(0, workbook[sheet_name].max_row - self.settings.start_row)
|
||||||
|
for sheet_name in sheets_to_process
|
||||||
|
if sheet_name in workbook.sheetnames
|
||||||
|
)
|
||||||
|
self._update_totals("total", value=total_rows)
|
||||||
|
|
||||||
|
# Process sheets
|
||||||
|
for sheet_name in sheets_to_process:
|
||||||
|
if sheet_name not in workbook.sheetnames:
|
||||||
|
self._log(
|
||||||
|
"warning",
|
||||||
|
f"Sheet '{sheet_name}' not found in the Excel file. Skipping.",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
sheet = workbook[sheet_name]
|
||||||
|
self._log("info", f"Processing sheet: {sheet_name}")
|
||||||
|
headers = [
|
||||||
|
str(cell.value or "") for cell in sheet[self.settings.start_row]
|
||||||
|
]
|
||||||
|
|
||||||
|
for row_number, row in enumerate(
|
||||||
|
sheet.iter_rows(
|
||||||
|
min_row=self.settings.start_row + 1, values_only=True
|
||||||
|
),
|
||||||
|
start=1,
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
row_data = {
|
||||||
|
key: str(value) if value is not None else None
|
||||||
|
for key, value in zip(headers, row)
|
||||||
|
}
|
||||||
|
self._process_row(row_data, row_number)
|
||||||
|
except Exception as e:
|
||||||
|
if self.settings.skip_errors:
|
||||||
|
self._log(
|
||||||
|
"warning",
|
||||||
|
f"Error processing row {row_number} in sheet '{sheet_name}': {str(e)}",
|
||||||
|
)
|
||||||
|
self._increment_totals("failed", value=1)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
workbook.close()
|
||||||
|
|
||||||
|
else: # xls
|
||||||
|
workbook = xlrd.open_workbook(file_path)
|
||||||
|
sheets_to_process = (
|
||||||
|
workbook.sheet_names()
|
||||||
|
if self.settings.sheets == "*"
|
||||||
|
else (
|
||||||
|
self.settings.sheets
|
||||||
|
if isinstance(self.settings.sheets, list)
|
||||||
|
else [self.settings.sheets]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Calculate total rows
|
||||||
|
total_rows = sum(
|
||||||
|
max(
|
||||||
|
0,
|
||||||
|
workbook.sheet_by_name(sheet_name).nrows
|
||||||
|
- self.settings.start_row,
|
||||||
|
)
|
||||||
|
for sheet_name in sheets_to_process
|
||||||
|
if sheet_name in workbook.sheet_names()
|
||||||
|
)
|
||||||
|
self._update_totals("total", value=total_rows)
|
||||||
|
# Process sheets
|
||||||
|
for sheet_name in sheets_to_process:
|
||||||
|
if sheet_name not in workbook.sheet_names():
|
||||||
|
self._log(
|
||||||
|
"warning",
|
||||||
|
f"Sheet '{sheet_name}' not found in the Excel file. Skipping.",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
sheet = workbook.sheet_by_name(sheet_name)
|
||||||
|
self._log("info", f"Processing sheet: {sheet_name}")
|
||||||
|
headers = [
|
||||||
|
str(sheet.cell_value(self.settings.start_row - 1, col) or "")
|
||||||
|
for col in range(sheet.ncols)
|
||||||
|
]
|
||||||
|
for row_number in range(self.settings.start_row, sheet.nrows):
|
||||||
|
try:
|
||||||
|
row_data = {}
|
||||||
|
for col, key in enumerate(headers):
|
||||||
|
cell_type = sheet.cell_type(row_number, col)
|
||||||
|
cell_value = sheet.cell_value(row_number, col)
|
||||||
|
|
||||||
|
if cell_type == xlrd.XL_CELL_DATE:
|
||||||
|
# Convert Excel date to Python datetime
|
||||||
|
try:
|
||||||
|
python_date = datetime(
|
||||||
|
*xlrd.xldate_as_tuple(
|
||||||
|
cell_value, workbook.datemode
|
||||||
|
)
|
||||||
|
)
|
||||||
|
row_data[key] = python_date
|
||||||
|
except Exception:
|
||||||
|
# If date conversion fails, use the original value
|
||||||
|
row_data[key] = (
|
||||||
|
str(cell_value)
|
||||||
|
if cell_value is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
elif cell_value is None:
|
||||||
|
row_data[key] = None
|
||||||
|
else:
|
||||||
|
row_data[key] = str(cell_value)
|
||||||
|
|
||||||
|
self._process_row(
|
||||||
|
row_data, row_number - self.settings.start_row + 1
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
if self.settings.skip_errors:
|
||||||
|
self._log(
|
||||||
|
"warning",
|
||||||
|
f"Error processing row {row_number} in sheet '{sheet_name}': {str(e)}",
|
||||||
|
)
|
||||||
|
self._increment_totals("failed", value=1)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
except (InvalidFileException, xlrd.XLRDError) as e:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid {self.settings.file_type.upper()} file format: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
def _validate_file_path(self, file_path: str) -> str:
|
def _validate_file_path(self, file_path: str) -> str:
|
||||||
"""
|
"""
|
||||||
Validates that the file path is within the allowed temporary directory.
|
Validates that the file path is within the allowed temporary directory.
|
||||||
@@ -611,8 +850,10 @@ class ImportService:
|
|||||||
self._log("info", "Starting import process")
|
self._log("info", "Starting import process")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if self.settings.file_type == "csv":
|
if isinstance(self.settings, version_1.CSVImportSettings):
|
||||||
self._process_csv(file_path)
|
self._process_csv(file_path)
|
||||||
|
elif isinstance(self.settings, version_1.ExcelImportSettings):
|
||||||
|
self._process_excel(file_path)
|
||||||
|
|
||||||
self._update_status("FINISHED")
|
self._update_status("FINISHED")
|
||||||
self._log(
|
self._log(
|
||||||
@@ -639,4 +880,3 @@ class ImportService:
|
|||||||
|
|
||||||
self.import_run.finished_at = timezone.now()
|
self.import_run.finished_at = timezone.now()
|
||||||
self.import_run.save(update_fields=["finished_at"])
|
self.import_run.save(update_fields=["finished_at"])
|
||||||
cachalot.api.invalidate()
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
import cachalot.api
|
from django.contrib.auth import get_user_model
|
||||||
from procrastinate.contrib.django import app
|
from procrastinate.contrib.django import app
|
||||||
|
|
||||||
|
from apps.common.middleware.thread_local import write_current_user, delete_current_user
|
||||||
from apps.import_app.models import ImportRun
|
from apps.import_app.models import ImportRun
|
||||||
from apps.import_app.services import ImportServiceV1
|
from apps.import_app.services import ImportServiceV1
|
||||||
|
|
||||||
@@ -10,12 +11,15 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
@app.task(name="process_import")
|
@app.task(name="process_import")
|
||||||
def process_import(import_run_id: int, file_path: str):
|
def process_import(import_run_id: int, file_path: str, user_id: int):
|
||||||
|
user = get_user_model().objects.get(id=user_id)
|
||||||
|
write_current_user(user)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import_run = ImportRun.objects.get(id=import_run_id)
|
import_run = ImportRun.objects.get(id=import_run_id)
|
||||||
import_service = ImportServiceV1(import_run)
|
import_service = ImportServiceV1(import_run)
|
||||||
import_service.process_file(file_path)
|
import_service.process_file(file_path)
|
||||||
cachalot.api.invalidate()
|
delete_current_user()
|
||||||
except ImportRun.DoesNotExist:
|
except ImportRun.DoesNotExist:
|
||||||
cachalot.api.invalidate()
|
delete_current_user()
|
||||||
raise ValueError(f"ImportRun with id {import_run_id} not found")
|
raise ValueError(f"ImportRun with id {import_run_id} not found")
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ from django.urls import path
|
|||||||
import apps.import_app.views as views
|
import apps.import_app.views as views
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("import/", views.import_view, name="import"),
|
|
||||||
path(
|
path(
|
||||||
"import/presets/",
|
"import/presets/",
|
||||||
views.import_presets_list,
|
views.import_presets_list,
|
||||||
|
|||||||
@@ -13,22 +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
|
||||||
|
|
||||||
def import_view(request):
|
|
||||||
import_profile = ImportProfile.objects.get(id=2)
|
|
||||||
shutil.copyfile(
|
|
||||||
"/usr/src/app/apps/import_app/teste2.csv", "/usr/src/app/temp/teste2.csv"
|
|
||||||
)
|
|
||||||
ir = ImportRun.objects.create(profile=import_profile, file_name="teste.csv")
|
|
||||||
process_import.defer(
|
|
||||||
import_run_id=ir.id,
|
|
||||||
file_path="/usr/src/app/temp/teste2.csv",
|
|
||||||
)
|
|
||||||
return HttpResponse("Hello, world. You're at the polls page.")
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@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()
|
||||||
@@ -40,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(
|
||||||
@@ -50,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()
|
||||||
@@ -63,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)
|
||||||
@@ -98,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)
|
||||||
@@ -127,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)
|
||||||
@@ -145,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)
|
||||||
@@ -160,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)
|
||||||
@@ -173,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)
|
||||||
@@ -189,7 +186,11 @@ def import_run_add(request, profile_id):
|
|||||||
import_run = ImportRun.objects.create(profile=profile, file_name=filename)
|
import_run = ImportRun.objects.create(profile=profile, file_name=filename)
|
||||||
|
|
||||||
# Defer the procrastinate task
|
# Defer the procrastinate task
|
||||||
process_import.defer(import_run_id=import_run.id, file_path=file_path)
|
process_import.defer(
|
||||||
|
import_run_id=import_run.id,
|
||||||
|
file_path=file_path,
|
||||||
|
user_id=request.user.id,
|
||||||
|
)
|
||||||
|
|
||||||
messages.success(request, _("Import Run queued successfully"))
|
messages.success(request, _("Import Run queued successfully"))
|
||||||
|
|
||||||
@@ -211,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)
|
||||||
|
|||||||
0
app/apps/insights/__init__.py
Normal file
0
app/apps/insights/__init__.py
Normal file
3
app/apps/insights/admin.py
Normal file
3
app/apps/insights/admin.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
6
app/apps/insights/apps.py
Normal file
6
app/apps/insights/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class InsightsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.insights"
|
||||||
133
app/apps/insights/forms.py
Normal file
133
app/apps/insights/forms.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
from crispy_forms.helper import FormHelper
|
||||||
|
from crispy_forms.layout import Layout, Field, Row, Column
|
||||||
|
from django import forms
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from apps.common.widgets.datepicker import (
|
||||||
|
AirMonthYearPickerInput,
|
||||||
|
AirYearPickerInput,
|
||||||
|
AirDatePickerInput,
|
||||||
|
)
|
||||||
|
from apps.transactions.models import TransactionCategory
|
||||||
|
from apps.common.widgets.tom_select import TomSelect
|
||||||
|
|
||||||
|
|
||||||
|
class SingleMonthForm(forms.Form):
|
||||||
|
month = forms.DateField(
|
||||||
|
widget=AirMonthYearPickerInput(clear_button=False), label="", required=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.helper = FormHelper()
|
||||||
|
self.helper.form_tag = False
|
||||||
|
self.helper.disable_csrf = True
|
||||||
|
|
||||||
|
self.helper.layout = Layout(Field("month"))
|
||||||
|
|
||||||
|
|
||||||
|
class SingleYearForm(forms.Form):
|
||||||
|
year = forms.DateField(
|
||||||
|
widget=AirYearPickerInput(clear_button=False), label="", required=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.helper = FormHelper()
|
||||||
|
self.helper.form_tag = False
|
||||||
|
self.helper.disable_csrf = True
|
||||||
|
|
||||||
|
self.helper.layout = Layout(Field("year"))
|
||||||
|
|
||||||
|
|
||||||
|
class MonthRangeForm(forms.Form):
|
||||||
|
month_from = forms.DateField(
|
||||||
|
widget=AirMonthYearPickerInput(clear_button=False), label="", required=True
|
||||||
|
)
|
||||||
|
month_to = forms.DateField(
|
||||||
|
widget=AirMonthYearPickerInput(clear_button=False), label="", required=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.helper = FormHelper()
|
||||||
|
self.helper.form_tag = False
|
||||||
|
self.helper.disable_csrf = True
|
||||||
|
|
||||||
|
self.helper.layout = Layout(
|
||||||
|
Row(
|
||||||
|
Column("month_from", css_class="form-group col-md-6"),
|
||||||
|
Column("month_to", css_class="form-group col-md-6"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class YearRangeForm(forms.Form):
|
||||||
|
year_from = forms.DateField(
|
||||||
|
widget=AirYearPickerInput(clear_button=False), label="", required=True
|
||||||
|
)
|
||||||
|
year_to = forms.DateField(
|
||||||
|
widget=AirYearPickerInput(clear_button=False), label="", required=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.helper = FormHelper()
|
||||||
|
self.helper.form_tag = False
|
||||||
|
self.helper.disable_csrf = True
|
||||||
|
|
||||||
|
self.helper.layout = Layout(
|
||||||
|
Row(
|
||||||
|
Column("year_from", css_class="form-group col-md-6"),
|
||||||
|
Column("year_to", css_class="form-group col-md-6"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DateRangeForm(forms.Form):
|
||||||
|
date_from = forms.DateField(
|
||||||
|
widget=AirDatePickerInput(clear_button=False), label="", required=True
|
||||||
|
)
|
||||||
|
date_to = forms.DateField(
|
||||||
|
widget=AirDatePickerInput(clear_button=False), label="", required=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.helper = FormHelper()
|
||||||
|
self.helper.form_tag = False
|
||||||
|
self.helper.disable_csrf = True
|
||||||
|
|
||||||
|
self.helper.layout = Layout(
|
||||||
|
Row(
|
||||||
|
Column("date_from", css_class="form-group col-md-6"),
|
||||||
|
Column("date_to", css_class="form-group col-md-6"),
|
||||||
|
css_class="mb-0",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryForm(forms.Form):
|
||||||
|
category = forms.ModelChoiceField(
|
||||||
|
required=False,
|
||||||
|
label=_("Category"),
|
||||||
|
empty_label=_("Uncategorized"),
|
||||||
|
queryset=TransactionCategory.objects.all(),
|
||||||
|
widget=TomSelect(clear_button=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.fields["category"].queryset = TransactionCategory.objects.all()
|
||||||
|
|
||||||
|
self.helper = FormHelper()
|
||||||
|
self.helper.form_tag = False
|
||||||
|
self.helper.disable_csrf = True
|
||||||
|
|
||||||
|
self.helper.layout = Layout("category")
|
||||||
0
app/apps/insights/migrations/__init__.py
Normal file
0
app/apps/insights/migrations/__init__.py
Normal file
3
app/apps/insights/models.py
Normal file
3
app/apps/insights/models.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
# Create your models here.
|
||||||
3
app/apps/insights/tests.py
Normal file
3
app/apps/insights/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
52
app/apps/insights/urls.py
Normal file
52
app/apps/insights/urls.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("insights/", views.index, name="insights_index"),
|
||||||
|
path(
|
||||||
|
"insights/sankey/account/",
|
||||||
|
views.sankey_by_account,
|
||||||
|
name="insights_sankey_by_account",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"insights/sankey/currency/",
|
||||||
|
views.sankey_by_currency,
|
||||||
|
name="insights_sankey_by_currency",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"insights/category-explorer/",
|
||||||
|
views.category_explorer_index,
|
||||||
|
name="category_explorer_index",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"insights/category-explorer/account/",
|
||||||
|
views.category_sum_by_account,
|
||||||
|
name="category_sum_by_account",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"insights/category-explorer/currency/",
|
||||||
|
views.category_sum_by_currency,
|
||||||
|
name="category_sum_by_currency",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"insights/category-overview/",
|
||||||
|
views.category_overview,
|
||||||
|
name="category_overview",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"insights/late-transactions/",
|
||||||
|
views.late_transactions,
|
||||||
|
name="insights_late_transactions",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"insights/latest-transactions/",
|
||||||
|
views.latest_transactions,
|
||||||
|
name="insights_latest_transactions",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"insights/emergency-fund/",
|
||||||
|
views.emergency_fund,
|
||||||
|
name="insights_emergency_fund",
|
||||||
|
),
|
||||||
|
]
|
||||||
0
app/apps/insights/utils/__init__.py
Normal file
0
app/apps/insights/utils/__init__.py
Normal file
161
app/apps/insights/utils/category_explorer.py
Normal file
161
app/apps/insights/utils/category_explorer.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
from django.db.models import Sum, Case, When, F, DecimalField, Value
|
||||||
|
from django.db.models.functions import Coalesce
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
def get_category_sums_by_account(queryset, category=None):
|
||||||
|
"""
|
||||||
|
Returns income/expense sums per account for a specific category.
|
||||||
|
"""
|
||||||
|
sums = (
|
||||||
|
queryset.filter(category=category)
|
||||||
|
.values("account__name")
|
||||||
|
.annotate(
|
||||||
|
current_income=Coalesce(
|
||||||
|
Sum(
|
||||||
|
Case(
|
||||||
|
When(type="IN", is_paid=True, then="amount"),
|
||||||
|
default=Value(0),
|
||||||
|
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Value(0),
|
||||||
|
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||||
|
),
|
||||||
|
current_expense=Coalesce(
|
||||||
|
Sum(
|
||||||
|
Case(
|
||||||
|
When(type="EX", is_paid=True, then=-F("amount")),
|
||||||
|
default=Value(0),
|
||||||
|
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Value(0),
|
||||||
|
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||||
|
),
|
||||||
|
projected_income=Coalesce(
|
||||||
|
Sum(
|
||||||
|
Case(
|
||||||
|
When(type="IN", is_paid=False, then="amount"),
|
||||||
|
default=Value(0),
|
||||||
|
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Value(0),
|
||||||
|
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||||
|
),
|
||||||
|
projected_expense=Coalesce(
|
||||||
|
Sum(
|
||||||
|
Case(
|
||||||
|
When(type="EX", is_paid=False, then=-F("amount")),
|
||||||
|
default=Value(0),
|
||||||
|
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Value(0),
|
||||||
|
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.order_by("account__name")
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"labels": [item["account__name"] for item in sums],
|
||||||
|
"datasets": [
|
||||||
|
{
|
||||||
|
"label": _("Current Income"),
|
||||||
|
"data": [float(item["current_income"]) for item in sums],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": _("Current Expenses"),
|
||||||
|
"data": [float(item["current_expense"]) for item in sums],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": _("Projected Income"),
|
||||||
|
"data": [float(item["projected_income"]) for item in sums],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": _("Projected Expenses"),
|
||||||
|
"data": [float(item["projected_expense"]) for item in sums],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_category_sums_by_currency(queryset, category=None):
|
||||||
|
"""
|
||||||
|
Returns income/expense sums per currency for a specific category.
|
||||||
|
"""
|
||||||
|
sums = (
|
||||||
|
queryset.filter(category=category)
|
||||||
|
.values("account__currency__name")
|
||||||
|
.annotate(
|
||||||
|
current_income=Coalesce(
|
||||||
|
Sum(
|
||||||
|
Case(
|
||||||
|
When(type="IN", is_paid=True, then="amount"),
|
||||||
|
default=Value(0),
|
||||||
|
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Value(0),
|
||||||
|
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||||
|
),
|
||||||
|
current_expense=Coalesce(
|
||||||
|
Sum(
|
||||||
|
Case(
|
||||||
|
When(type="EX", is_paid=True, then=-F("amount")),
|
||||||
|
default=Value(0),
|
||||||
|
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Value(0),
|
||||||
|
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||||
|
),
|
||||||
|
projected_income=Coalesce(
|
||||||
|
Sum(
|
||||||
|
Case(
|
||||||
|
When(type="IN", is_paid=False, then="amount"),
|
||||||
|
default=Value(0),
|
||||||
|
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Value(0),
|
||||||
|
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||||
|
),
|
||||||
|
projected_expense=Coalesce(
|
||||||
|
Sum(
|
||||||
|
Case(
|
||||||
|
When(type="EX", is_paid=False, then=-F("amount")),
|
||||||
|
default=Value(0),
|
||||||
|
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Value(0),
|
||||||
|
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.order_by("account__currency__name")
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"labels": [item["account__currency__name"] for item in sums],
|
||||||
|
"datasets": [
|
||||||
|
{
|
||||||
|
"label": _("Current Income"),
|
||||||
|
"data": [float(item["current_income"]) for item in sums],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": _("Current Expenses"),
|
||||||
|
"data": [float(item["current_expense"]) for item in sums],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": _("Projected Income"),
|
||||||
|
"data": [float(item["projected_income"]) for item in sums],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": _("Projected Expenses"),
|
||||||
|
"data": [float(item["projected_expense"]) for item in sums],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
322
app/apps/insights/utils/category_overview.py
Normal file
322
app/apps/insights/utils/category_overview.py
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
from django.db.models import Sum, Case, When, Value, DecimalField
|
||||||
|
from django.db.models.functions import Coalesce
|
||||||
|
|
||||||
|
from apps.transactions.models import Transaction
|
||||||
|
from apps.currencies.models import Currency
|
||||||
|
from apps.currencies.utils.convert import convert
|
||||||
|
|
||||||
|
|
||||||
|
def get_categories_totals(transactions_queryset, ignore_empty=False):
|
||||||
|
# First get the category totals as before
|
||||||
|
category_currency_metrics = (
|
||||||
|
transactions_queryset.values(
|
||||||
|
"category",
|
||||||
|
"category__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"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.order_by("category__name")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get tag totals within each category with currency details
|
||||||
|
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"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process the results to structure by category
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
# Process category totals first
|
||||||
|
for metric in category_currency_metrics:
|
||||||
|
# Skip empty categories if ignore_empty is True
|
||||||
|
if ignore_empty and all(
|
||||||
|
metric[field] == Decimal("0")
|
||||||
|
for field in [
|
||||||
|
"expense_current",
|
||||||
|
"expense_projected",
|
||||||
|
"income_current",
|
||||||
|
"income_projected",
|
||||||
|
]
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Calculate derived totals
|
||||||
|
total_current = metric["income_current"] - metric["expense_current"]
|
||||||
|
total_projected = metric["income_projected"] - metric["expense_projected"]
|
||||||
|
total_income = metric["income_current"] + metric["income_projected"]
|
||||||
|
total_expense = metric["expense_current"] + metric["expense_projected"]
|
||||||
|
total_final = total_current + total_projected
|
||||||
|
|
||||||
|
category_id = metric["category"]
|
||||||
|
currency_id = metric["account__currency"]
|
||||||
|
|
||||||
|
if category_id not in result:
|
||||||
|
result[category_id] = {
|
||||||
|
"name": metric["category__name"],
|
||||||
|
"currencies": {},
|
||||||
|
"tags": {}, # Add tags container
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add currency data
|
||||||
|
currency_data = {
|
||||||
|
"currency": {
|
||||||
|
"code": metric["account__currency__code"],
|
||||||
|
"name": metric["account__currency__name"],
|
||||||
|
"decimal_places": metric["account__currency__decimal_places"],
|
||||||
|
"prefix": metric["account__currency__prefix"],
|
||||||
|
"suffix": metric["account__currency__suffix"],
|
||||||
|
},
|
||||||
|
"expense_current": metric["expense_current"],
|
||||||
|
"expense_projected": metric["expense_projected"],
|
||||||
|
"total_expense": total_expense,
|
||||||
|
"income_current": metric["income_current"],
|
||||||
|
"income_projected": metric["income_projected"],
|
||||||
|
"total_income": total_income,
|
||||||
|
"total_current": total_current,
|
||||||
|
"total_projected": total_projected,
|
||||||
|
"total_final": total_final,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add exchanged values if exchange_currency exists
|
||||||
|
if metric["account__currency__exchange_currency"]:
|
||||||
|
from_currency = Currency.objects.get(id=currency_id)
|
||||||
|
exchange_currency = Currency.objects.get(
|
||||||
|
id=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=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:
|
||||||
|
currency_data["exchanged"] = exchanged
|
||||||
|
|
||||||
|
result[category_id]["currencies"][currency_id] = currency_data
|
||||||
|
|
||||||
|
# Process tag totals and add them to the result, including untagged
|
||||||
|
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": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add exchange currency support for tags
|
||||||
|
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
|
||||||
|
|
||||||
|
return result
|
||||||
280
app/apps/insights/utils/sankey.py
Normal file
280
app/apps/insights/utils/sankey.py
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Dict, List, TypedDict
|
||||||
|
|
||||||
|
|
||||||
|
class SankeyNode(TypedDict):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class SankeyFlow(TypedDict):
|
||||||
|
from_node: str
|
||||||
|
to_node: str
|
||||||
|
flow: float
|
||||||
|
currency: Dict
|
||||||
|
original_amount: float
|
||||||
|
percentage: float
|
||||||
|
|
||||||
|
|
||||||
|
def generate_sankey_data_by_account(transactions_queryset):
|
||||||
|
"""
|
||||||
|
Generates Sankey diagram data from transaction queryset using account as intermediary.
|
||||||
|
"""
|
||||||
|
nodes: Dict[str, Dict] = {}
|
||||||
|
flows: List[SankeyFlow] = []
|
||||||
|
|
||||||
|
# Aggregate transactions
|
||||||
|
income_data = {} # {(category, currency, account) -> amount}
|
||||||
|
expense_data = {} # {(category, currency, account) -> amount}
|
||||||
|
total_income_by_currency = {} # {currency -> amount}
|
||||||
|
total_expense_by_currency = {} # {currency -> amount}
|
||||||
|
total_volume_by_currency = {} # {currency -> amount}
|
||||||
|
|
||||||
|
for transaction in transactions_queryset:
|
||||||
|
currency = transaction.account.currency
|
||||||
|
account = transaction.account
|
||||||
|
category = transaction.category or _("Uncategorized")
|
||||||
|
key = (category, currency, account)
|
||||||
|
amount = transaction.amount
|
||||||
|
|
||||||
|
if transaction.type == "IN":
|
||||||
|
income_data[key] = income_data.get(key, Decimal("0")) + amount
|
||||||
|
total_income_by_currency[currency] = (
|
||||||
|
total_income_by_currency.get(currency, Decimal("0")) + amount
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
expense_data[key] = expense_data.get(key, Decimal("0")) + amount
|
||||||
|
total_expense_by_currency[currency] = (
|
||||||
|
total_expense_by_currency.get(currency, Decimal("0")) + amount
|
||||||
|
)
|
||||||
|
|
||||||
|
total_volume_by_currency[currency] = (
|
||||||
|
total_volume_by_currency.get(currency, Decimal("0")) + amount
|
||||||
|
)
|
||||||
|
|
||||||
|
unique_accounts = {
|
||||||
|
account_id: idx
|
||||||
|
for idx, account_id in enumerate(
|
||||||
|
transactions_queryset.values_list("account", flat=True).distinct()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_node_priority(node_id: str) -> int:
|
||||||
|
"""Get priority based on the account ID embedded in the node ID."""
|
||||||
|
account_id = int(node_id.split("_")[-1])
|
||||||
|
return unique_accounts[account_id]
|
||||||
|
|
||||||
|
def get_node_id(node_type: str, name: str, account_id: int) -> str:
|
||||||
|
"""Generate unique node ID."""
|
||||||
|
return f"{node_type}_{name}_{account_id}".lower().replace(" ", "_")
|
||||||
|
|
||||||
|
def add_node(node_id: str, display_name: str) -> None:
|
||||||
|
"""Add node with ID, display name and priority."""
|
||||||
|
nodes[node_id] = {
|
||||||
|
"id": node_id,
|
||||||
|
"name": display_name,
|
||||||
|
"priority": get_node_priority(node_id),
|
||||||
|
}
|
||||||
|
|
||||||
|
def add_flow(
|
||||||
|
from_node_id: str, to_node_id: str, amount: Decimal, currency, is_income: bool
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Add flow with percentage based on total transaction volume for the specific currency.
|
||||||
|
"""
|
||||||
|
total_volume = total_volume_by_currency.get(currency, Decimal("0"))
|
||||||
|
percentage = (amount / total_volume) * 100 if total_volume else 0
|
||||||
|
scaled_flow = percentage / 100
|
||||||
|
|
||||||
|
flows.append(
|
||||||
|
{
|
||||||
|
"from_node": from_node_id,
|
||||||
|
"to_node": to_node_id,
|
||||||
|
"flow": float(scaled_flow),
|
||||||
|
"currency": {
|
||||||
|
"code": currency.code,
|
||||||
|
"prefix": currency.prefix,
|
||||||
|
"suffix": currency.suffix,
|
||||||
|
"decimal_places": currency.decimal_places,
|
||||||
|
},
|
||||||
|
"original_amount": float(amount),
|
||||||
|
"percentage": float(percentage),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process income
|
||||||
|
for (category, currency, account), amount in income_data.items():
|
||||||
|
category_node_id = get_node_id("income", category, account.id)
|
||||||
|
account_node_id = get_node_id("account", account.name, account.id)
|
||||||
|
add_node(category_node_id, str(category))
|
||||||
|
add_node(account_node_id, account.name)
|
||||||
|
add_flow(category_node_id, account_node_id, amount, currency, is_income=True)
|
||||||
|
|
||||||
|
# Process expenses
|
||||||
|
for (category, currency, account), amount in expense_data.items():
|
||||||
|
category_node_id = get_node_id("expense", category, account.id)
|
||||||
|
account_node_id = get_node_id("account", account.name, account.id)
|
||||||
|
add_node(category_node_id, str(category))
|
||||||
|
add_node(account_node_id, account.name)
|
||||||
|
add_flow(account_node_id, category_node_id, amount, currency, is_income=False)
|
||||||
|
|
||||||
|
# Calculate and add savings flows
|
||||||
|
savings_data = {} # {(account, currency) -> amount}
|
||||||
|
for (category, currency, account), amount in income_data.items():
|
||||||
|
key = (account, currency)
|
||||||
|
savings_data[key] = savings_data.get(key, Decimal("0")) + amount
|
||||||
|
for (category, currency, account), amount in expense_data.items():
|
||||||
|
key = (account, currency)
|
||||||
|
savings_data[key] = savings_data.get(key, Decimal("0")) - amount
|
||||||
|
|
||||||
|
for (account, currency), amount in savings_data.items():
|
||||||
|
if amount > 0:
|
||||||
|
account_node_id = get_node_id("account", account.name, account.id)
|
||||||
|
savings_node_id = get_node_id("savings", _("Saved"), account.id)
|
||||||
|
add_node(savings_node_id, str(_("Saved")))
|
||||||
|
add_flow(account_node_id, savings_node_id, amount, currency, is_income=True)
|
||||||
|
|
||||||
|
# Calculate total across all currencies (for reference only)
|
||||||
|
total_amount = sum(float(amount) for amount in total_income_by_currency.values())
|
||||||
|
|
||||||
|
return {
|
||||||
|
"nodes": list(nodes.values()),
|
||||||
|
"flows": flows,
|
||||||
|
"total_amount": total_amount,
|
||||||
|
"total_by_currency": {
|
||||||
|
curr.code: float(amount)
|
||||||
|
for curr, amount in total_income_by_currency.items()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_sankey_data_by_currency(transactions_queryset):
|
||||||
|
"""
|
||||||
|
Generates Sankey diagram data from transaction queryset, using currency as intermediary.
|
||||||
|
"""
|
||||||
|
nodes: Dict[str, Dict] = {}
|
||||||
|
flows: List[SankeyFlow] = []
|
||||||
|
|
||||||
|
# Aggregate transactions
|
||||||
|
income_data = {} # {(category, currency) -> amount}
|
||||||
|
expense_data = {} # {(category, currency) -> amount}
|
||||||
|
total_income_by_currency = {} # {currency -> amount}
|
||||||
|
total_expense_by_currency = {} # {currency -> amount}
|
||||||
|
total_volume_by_currency = {} # {currency -> amount}
|
||||||
|
|
||||||
|
for transaction in transactions_queryset:
|
||||||
|
currency = transaction.account.currency
|
||||||
|
category = transaction.category or _("Uncategorized")
|
||||||
|
key = (category, currency)
|
||||||
|
amount = transaction.amount
|
||||||
|
|
||||||
|
if transaction.type == "IN":
|
||||||
|
income_data[key] = income_data.get(key, Decimal("0")) + amount
|
||||||
|
total_income_by_currency[currency] = (
|
||||||
|
total_income_by_currency.get(currency, Decimal("0")) + amount
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
expense_data[key] = expense_data.get(key, Decimal("0")) + amount
|
||||||
|
total_expense_by_currency[currency] = (
|
||||||
|
total_expense_by_currency.get(currency, Decimal("0")) + amount
|
||||||
|
)
|
||||||
|
|
||||||
|
total_volume_by_currency[currency] = (
|
||||||
|
total_volume_by_currency.get(currency, Decimal("0")) + amount
|
||||||
|
)
|
||||||
|
|
||||||
|
unique_currencies = {
|
||||||
|
currency_id: idx
|
||||||
|
for idx, currency_id in enumerate(
|
||||||
|
transactions_queryset.values_list("account__currency", flat=True).distinct()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_node_priority(node_id: str) -> int:
|
||||||
|
"""Get priority based on the currency ID embedded in the node ID."""
|
||||||
|
currency_id = int(node_id.split("_")[-1])
|
||||||
|
return unique_currencies[currency_id]
|
||||||
|
|
||||||
|
def get_node_id(node_type: str, name: str, currency_id: int) -> str:
|
||||||
|
"""Generate unique node ID including currency information."""
|
||||||
|
return f"{node_type}_{name}_{currency_id}".lower().replace(" ", "_")
|
||||||
|
|
||||||
|
def add_node(node_id: str, display_name: str) -> None:
|
||||||
|
"""Add node with ID, display name and priority."""
|
||||||
|
nodes[node_id] = {
|
||||||
|
"id": node_id,
|
||||||
|
"name": display_name,
|
||||||
|
"priority": get_node_priority(node_id),
|
||||||
|
}
|
||||||
|
|
||||||
|
def add_flow(
|
||||||
|
from_node_id: str, to_node_id: str, amount: Decimal, currency, is_income: bool
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Add flow with percentage based on total transaction volume for the specific currency.
|
||||||
|
"""
|
||||||
|
total_volume = total_volume_by_currency.get(currency, Decimal("0"))
|
||||||
|
percentage = (amount / total_volume) * 100 if total_volume else 0
|
||||||
|
scaled_flow = percentage / 100
|
||||||
|
|
||||||
|
flows.append(
|
||||||
|
{
|
||||||
|
"from_node": from_node_id,
|
||||||
|
"to_node": to_node_id,
|
||||||
|
"flow": float(scaled_flow),
|
||||||
|
"currency": {
|
||||||
|
"code": currency.code,
|
||||||
|
"name": currency.name,
|
||||||
|
"prefix": currency.prefix,
|
||||||
|
"suffix": currency.suffix,
|
||||||
|
"decimal_places": currency.decimal_places,
|
||||||
|
},
|
||||||
|
"original_amount": float(amount),
|
||||||
|
"percentage": float(percentage),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process income
|
||||||
|
for (category, currency), amount in income_data.items():
|
||||||
|
category_node_id = get_node_id("income", category, currency.id)
|
||||||
|
currency_node_id = get_node_id("currency", currency.name, currency.id)
|
||||||
|
add_node(category_node_id, str(category))
|
||||||
|
add_node(currency_node_id, currency.name)
|
||||||
|
add_flow(category_node_id, currency_node_id, amount, currency, is_income=True)
|
||||||
|
|
||||||
|
# Process expenses
|
||||||
|
for (category, currency), amount in expense_data.items():
|
||||||
|
category_node_id = get_node_id("expense", category, currency.id)
|
||||||
|
currency_node_id = get_node_id("currency", currency.name, currency.id)
|
||||||
|
add_node(category_node_id, str(category))
|
||||||
|
add_node(currency_node_id, currency.name)
|
||||||
|
add_flow(currency_node_id, category_node_id, amount, currency, is_income=False)
|
||||||
|
|
||||||
|
# Calculate and add savings flows
|
||||||
|
savings_data = {} # {currency -> amount}
|
||||||
|
for (category, currency), amount in income_data.items():
|
||||||
|
savings_data[currency] = savings_data.get(currency, Decimal("0")) + amount
|
||||||
|
for (category, currency), amount in expense_data.items():
|
||||||
|
savings_data[currency] = savings_data.get(currency, Decimal("0")) - amount
|
||||||
|
|
||||||
|
for currency, amount in savings_data.items():
|
||||||
|
if amount > 0:
|
||||||
|
currency_node_id = get_node_id("currency", currency.name, currency.id)
|
||||||
|
savings_node_id = get_node_id("savings", _("Saved"), currency.id)
|
||||||
|
add_node(savings_node_id, str(_("Saved")))
|
||||||
|
add_flow(
|
||||||
|
currency_node_id, savings_node_id, amount, currency, is_income=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate total across all currencies (for reference only)
|
||||||
|
total_amount = sum(float(amount) for amount in total_income_by_currency.values())
|
||||||
|
|
||||||
|
return {
|
||||||
|
"nodes": list(nodes.values()),
|
||||||
|
"flows": flows,
|
||||||
|
"total_amount": total_amount,
|
||||||
|
"total_by_currency": {
|
||||||
|
curr.name: float(amount)
|
||||||
|
for curr, amount in total_income_by_currency.items()
|
||||||
|
},
|
||||||
|
}
|
||||||
96
app/apps/insights/utils/transactions.py
Normal file
96
app/apps/insights/utils/transactions.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
from django.db.models import Q
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
|
from apps.transactions.models import Transaction
|
||||||
|
from apps.insights.forms import (
|
||||||
|
SingleMonthForm,
|
||||||
|
SingleYearForm,
|
||||||
|
MonthRangeForm,
|
||||||
|
YearRangeForm,
|
||||||
|
DateRangeForm,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_transactions(request, include_unpaid=True, include_silent=False):
|
||||||
|
transactions = Transaction.objects.all()
|
||||||
|
|
||||||
|
filter_type = request.GET.get("type", None)
|
||||||
|
|
||||||
|
if filter_type is not None:
|
||||||
|
if filter_type == "month":
|
||||||
|
form = SingleMonthForm(request.GET)
|
||||||
|
|
||||||
|
if form.is_valid():
|
||||||
|
month = form.cleaned_data["month"].replace(day=1)
|
||||||
|
else:
|
||||||
|
month = timezone.localdate(timezone.now()).replace(day=1)
|
||||||
|
|
||||||
|
transactions = transactions.filter(
|
||||||
|
reference_date__month=month.month, reference_date__year=month.year
|
||||||
|
)
|
||||||
|
elif filter_type == "year":
|
||||||
|
form = SingleYearForm(request.GET)
|
||||||
|
if form.is_valid():
|
||||||
|
year = form.cleaned_data["year"].replace(day=1, month=1)
|
||||||
|
else:
|
||||||
|
year = timezone.localdate(timezone.now()).replace(day=1, month=1)
|
||||||
|
|
||||||
|
transactions = transactions.filter(reference_date__year=year.year)
|
||||||
|
elif filter_type == "month-range":
|
||||||
|
form = MonthRangeForm(request.GET)
|
||||||
|
if form.is_valid():
|
||||||
|
month_from = form.cleaned_data["month_from"].replace(day=1)
|
||||||
|
month_to = form.cleaned_data["month_to"].replace(day=1)
|
||||||
|
else:
|
||||||
|
month_from = timezone.localdate(timezone.now()).replace(day=1)
|
||||||
|
month_to = (
|
||||||
|
timezone.localdate(timezone.now()) + relativedelta(months=1)
|
||||||
|
).replace(day=1)
|
||||||
|
|
||||||
|
transactions = transactions.filter(
|
||||||
|
reference_date__gte=month_from,
|
||||||
|
reference_date__lte=month_to,
|
||||||
|
)
|
||||||
|
elif filter_type == "year-range":
|
||||||
|
form = YearRangeForm(request.GET)
|
||||||
|
if form.is_valid():
|
||||||
|
year_from = form.cleaned_data["year_from"].replace(day=1, month=1)
|
||||||
|
year_to = form.cleaned_data["year_to"].replace(day=31, month=12)
|
||||||
|
else:
|
||||||
|
year_from = timezone.localdate(timezone.now()).replace(day=1, month=1)
|
||||||
|
year_to = (
|
||||||
|
timezone.localdate(timezone.now()) + relativedelta(years=1)
|
||||||
|
).replace(day=31, month=12)
|
||||||
|
|
||||||
|
transactions = transactions.filter(
|
||||||
|
reference_date__gte=year_from,
|
||||||
|
reference_date__lte=year_to,
|
||||||
|
)
|
||||||
|
elif filter_type == "date-range":
|
||||||
|
form = DateRangeForm(request.GET)
|
||||||
|
if form.is_valid():
|
||||||
|
date_from = form.cleaned_data["date_from"]
|
||||||
|
date_to = form.cleaned_data["date_to"]
|
||||||
|
else:
|
||||||
|
date_from = timezone.localdate(timezone.now())
|
||||||
|
date_to = timezone.localdate(timezone.now()) + relativedelta(months=1)
|
||||||
|
|
||||||
|
transactions = transactions.filter(
|
||||||
|
date__gte=date_from,
|
||||||
|
date__lte=date_to,
|
||||||
|
)
|
||||||
|
else: # Default to current month
|
||||||
|
month = timezone.localdate(timezone.now())
|
||||||
|
transactions = transactions.filter(
|
||||||
|
reference_date__month=month.month, reference_date__year=month.year
|
||||||
|
)
|
||||||
|
|
||||||
|
if not include_unpaid:
|
||||||
|
transactions = transactions.filter(is_paid=True)
|
||||||
|
|
||||||
|
if not include_silent:
|
||||||
|
transactions = transactions.exclude(Q(category__mute=True) & ~Q(category=None))
|
||||||
|
|
||||||
|
return transactions
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user