mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-02-24 16:34:53 +01:00
Compare commits
439 Commits
0.12.6
...
add-mcp-se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9acf1dd8f | ||
|
|
63898aeef0 | ||
|
|
4fdf00d098 | ||
|
|
025cc585d5 | ||
|
|
17018d87cd | ||
|
|
1e5f4f6583 | ||
|
|
a99851cf9b | ||
|
|
9fb1ad4861 | ||
|
|
66c3abfe37 | ||
|
|
8ca64f5820 | ||
|
|
e743821570 | ||
|
|
5c698d8735 | ||
|
|
3e5aa90df0 | ||
|
|
b2add14238 | ||
|
|
a052c00aa8 | ||
|
|
7f343708e0 | ||
|
|
22e95c7f4a | ||
|
|
7645153f77 | ||
|
|
1abfed9abf | ||
|
|
eea0ab009d | ||
|
|
29446def22 | ||
|
|
9dce5e9efe | ||
|
|
695e2cb322 | ||
|
|
b135ec3b15 | ||
|
|
bb3cc5da6c | ||
|
|
ca7fe24a8a | ||
|
|
483ba74010 | ||
|
|
f2abeff31a | ||
|
|
666eaff167 | ||
|
|
d72454f854 | ||
|
|
333aa81923 | ||
|
|
41b8cfd1e7 | ||
|
|
1fa7985b01 | ||
|
|
38392a6322 | ||
|
|
637c62319b | ||
|
|
f91fe67629 | ||
|
|
9eb1818a20 | ||
|
|
50ac679e33 | ||
|
|
2a463c63b8 | ||
|
|
dce65f2faf | ||
|
|
a053cb3947 | ||
|
|
2d43072120 | ||
|
|
70bdee065e | ||
|
|
95db27a32f | ||
|
|
d6d4e6a102 | ||
|
|
bc0f30fead | ||
|
|
a9a86fc491 | ||
|
|
c3b5f2bf39 | ||
|
|
19128e5aed | ||
|
|
9b5c6d3413 | ||
|
|
73c873a2ad | ||
|
|
9d2be22a77 | ||
|
|
6a3d31f37d | ||
|
|
3be3a3c14b | ||
|
|
a5b0f4efb7 | ||
|
|
6da50db417 | ||
|
|
a6c1daf902 | ||
|
|
6a271fb3d7 | ||
|
|
2cf9a9dd0f | ||
|
|
64b32316ca | ||
|
|
0deaabe719 | ||
|
|
b14342af2e | ||
|
|
efe020efb3 | ||
|
|
2c14ce6366 | ||
|
|
8c133f92ce | ||
|
|
2dd887b0d9 | ||
|
|
f3c9d8faea | ||
|
|
8be7758dc0 | ||
|
|
8f5204a17b | ||
|
|
05dd782df5 | ||
|
|
187fe43283 | ||
|
|
cae73376db | ||
|
|
7225454a6e | ||
|
|
70c8c1e07c | ||
|
|
2235bdeabb | ||
|
|
d724300513 | ||
|
|
eacafa1def | ||
|
|
c738f5ee29 | ||
|
|
c392a2c988 | ||
|
|
17ea859fd2 | ||
|
|
8aae6f928f | ||
|
|
7c43b06b9f | ||
|
|
72904266bf | ||
|
|
e16e279911 | ||
|
|
670bee4325 | ||
|
|
3e2c1184ce | ||
|
|
731f351eef | ||
|
|
b7056e7aa1 | ||
|
|
accceed630 | ||
|
|
76346cb503 | ||
|
|
3df8952ea2 | ||
|
|
9bd067da96 | ||
|
|
1abe9e9f62 | ||
|
|
1a86b5dea4 | ||
|
|
8f2f5a16c2 | ||
|
|
4565dc770b | ||
|
|
23673def09 | ||
|
|
dd2b9ead7e | ||
|
|
2078e9f3e4 | ||
|
|
e6bab57ab4 | ||
|
|
38d50a78f4 | ||
|
|
0d947f9ba6 | ||
|
|
99c85a56bb | ||
|
|
ab1c074f27 | ||
|
|
abf3a148cc | ||
|
|
2733c92da5 | ||
|
|
9bfbe54ed5 | ||
|
|
5b27dea07c | ||
|
|
791e1000a3 | ||
|
|
7301d9f475 | ||
|
|
47a44e96f8 | ||
|
|
7d247eb737 | ||
|
|
373616e7bb | ||
|
|
bf3c11d582 | ||
|
|
b4b1c10db9 | ||
|
|
5ca531f47d | ||
|
|
07673cb528 | ||
|
|
67c6d81897 | ||
|
|
3c85da46b0 | ||
|
|
d263936be7 | ||
|
|
1524063d5a | ||
|
|
c3a403b8f0 | ||
|
|
1c1018adae | ||
|
|
350d5adf25 | ||
|
|
7e4defb9cc | ||
|
|
7121e4bc09 | ||
|
|
4540e48fe5 | ||
|
|
d06b51421f | ||
|
|
e096912e41 | ||
|
|
f0ad6e16fe | ||
|
|
734a302fa7 | ||
|
|
89b1b7bcb7 | ||
|
|
37b40f89bb | ||
|
|
0c63552d1b | ||
|
|
7db517e848 | ||
|
|
7e3ed6cf94 | ||
|
|
e10a88c00e | ||
|
|
b912a33b93 | ||
|
|
d9fb3627cc | ||
|
|
78ffa68ba4 | ||
|
|
37f4ead058 | ||
|
|
61630fca5b | ||
|
|
910d4c84a3 | ||
|
|
be1f29d8c1 | ||
|
|
9784d840cc | ||
|
|
db5ce13ff3 | ||
|
|
a2b943d949 | ||
|
|
d8098b4486 | ||
|
|
f8cff6623f | ||
|
|
65c61f76ff | ||
|
|
74899f63ab | ||
|
|
66a5e6d613 | ||
|
|
e0ab32ec03 | ||
|
|
a912e4a511 | ||
|
|
57ba672c91 | ||
|
|
20c6989ffb | ||
|
|
c6cd525c49 | ||
|
|
55c4b920ee | ||
|
|
7f8261b9cc | ||
|
|
9102654eab | ||
|
|
1ff49a8a04 | ||
|
|
846dd1fd73 | ||
|
|
9eed3b6692 | ||
|
|
b7c53a3c2d | ||
|
|
b378c8f6f7 | ||
|
|
ccc4deb1d8 | ||
|
|
d3ecf55375 | ||
|
|
580f3e7345 | ||
|
|
0e5843094b | ||
|
|
ed65945d19 | ||
|
|
18d8837c64 | ||
|
|
067d819077 | ||
|
|
bbaae4746a | ||
|
|
d2e5c1d6b3 | ||
|
|
ffef61d514 | ||
|
|
9020f6f972 | ||
|
|
540235c1b0 | ||
|
|
9070bc5705 | ||
|
|
ba5a6c9772 | ||
|
|
2f33b5989f | ||
|
|
5f24d05540 | ||
|
|
31cf62e277 | ||
|
|
15d990007e | ||
|
|
3d5bc9cd3f | ||
|
|
a544dc4943 | ||
|
|
b1178198e9 | ||
|
|
02a488bfff | ||
|
|
b05285947b | ||
|
|
d7b7dd28c7 | ||
|
|
9353d498ef | ||
|
|
4f6903e8e4 | ||
|
|
7d3d6ea2fc | ||
|
|
cce9c7a7a5 | ||
|
|
f80f74a01a | ||
|
|
df47ffc49c | ||
|
|
4f35647a22 | ||
|
|
368342853f | ||
|
|
5a675f674d | ||
|
|
9ef8fdec49 | ||
|
|
f29a8d8bc0 | ||
|
|
8c43365ec0 | ||
|
|
2cdcc4ee26 | ||
|
|
84852012f9 | ||
|
|
edf0e2c66f | ||
|
|
f90a31f2b9 | ||
|
|
dd1f6a6ef2 | ||
|
|
57f98ba171 | ||
|
|
f2e93f7df9 | ||
|
|
26cfa493b3 | ||
|
|
c6e003ed86 | ||
|
|
c09ad0e49d | ||
|
|
9250131396 | ||
|
|
5f503149ce | ||
|
|
d45b4f2942 | ||
|
|
4a8493c7d9 | ||
|
|
c39c3ccacb | ||
|
|
4bb8ef6582 | ||
|
|
d711ccca69 | ||
|
|
76d59f1038 | ||
|
|
5b6c123fa1 | ||
|
|
782ab11ae4 | ||
|
|
8db885f47d | ||
|
|
01bd8710d8 | ||
|
|
569d08711c | ||
|
|
a285f055e4 | ||
|
|
6aae9b1207 | ||
|
|
9d2206f8a4 | ||
|
|
d7e3c50c2c | ||
|
|
789fd4eb80 | ||
|
|
586b3a5d44 | ||
|
|
9248e8bd77 | ||
|
|
c44247f6a5 | ||
|
|
8ba89434f8 | ||
|
|
f2f41981a3 | ||
|
|
1153fd6b0a | ||
|
|
76822224a0 | ||
|
|
31b2b98eb9 | ||
|
|
d7a4e79321 | ||
|
|
985f07e792 | ||
|
|
5465bb1eeb | ||
|
|
451a85a998 | ||
|
|
54c74e7c07 | ||
|
|
d6e9e123b7 | ||
|
|
80c9c43a02 | ||
|
|
3e34f088fc | ||
|
|
5b9e5c6003 | ||
|
|
c266b8809f | ||
|
|
8cda4116bc | ||
|
|
c2510b2261 | ||
|
|
dcdaf756f9 | ||
|
|
50ca08165a | ||
|
|
f85618fa01 | ||
|
|
635f87a8ad | ||
|
|
1a073ba53d | ||
|
|
5412e5b12c | ||
|
|
2103ba1b38 | ||
|
|
04fb15224c | ||
|
|
2fc526beac | ||
|
|
cc3ca4f4a3 | ||
|
|
8d3844c431 | ||
|
|
5e7e918085 | ||
|
|
c3f02320b5 | ||
|
|
da8bbbfb0b | ||
|
|
e3f74538d2 | ||
|
|
d8234950c6 | ||
|
|
58f19ce1ca | ||
|
|
ef5f3580a0 | ||
|
|
efe0f99cb4 | ||
|
|
dccb5079ad | ||
|
|
6c90150661 | ||
|
|
c33d6fab69 | ||
|
|
c0c57a6d77 | ||
|
|
f19d58a2bd | ||
|
|
dfe99093e9 | ||
|
|
d737e573cc | ||
|
|
805d3f419e | ||
|
|
9777aac746 | ||
|
|
61b782104d | ||
|
|
79dec2b515 | ||
|
|
db23e162c4 | ||
|
|
d81d89d9f6 | ||
|
|
6826cfe79a | ||
|
|
0832ec75ca | ||
|
|
3090f632de | ||
|
|
6b4fbee7a6 | ||
|
|
e7fe6622cd | ||
|
|
3017593ed5 | ||
|
|
ceb8e9ea97 | ||
|
|
9b5b7683dd | ||
|
|
514600e34a | ||
|
|
07dd805b07 | ||
|
|
905e9b4c54 | ||
|
|
60d367dec5 | ||
|
|
6e0842a697 | ||
|
|
858934b7c5 | ||
|
|
47d9e4078c | ||
|
|
fa6f3e87c0 | ||
|
|
5f101af879 | ||
|
|
b27633a28e | ||
|
|
7716eee0b3 | ||
|
|
37c447ae0a | ||
|
|
e544d7068b | ||
|
|
8d93da99c1 | ||
|
|
cc87477a2e | ||
|
|
e86e0b8c08 | ||
|
|
eb0c872c50 | ||
|
|
b4578df242 | ||
|
|
756de12835 | ||
|
|
d573d02657 | ||
|
|
250b352217 | ||
|
|
b4e9446cf6 | ||
|
|
90944f0179 | ||
|
|
008d34b1d0 | ||
|
|
46dfc7dad4 | ||
|
|
22900b5d9e | ||
|
|
0c48e9fe3d | ||
|
|
b2e100d1b0 | ||
|
|
e49b38a442 | ||
|
|
1f2902eea9 | ||
|
|
7d60db8716 | ||
|
|
873b0baed7 | ||
|
|
2313c97761 | ||
|
|
9cd7337153 | ||
|
|
d3b354e2b8 | ||
|
|
e137666e99 | ||
|
|
4291a5b97d | ||
|
|
c8d316857f | ||
|
|
3395a96949 | ||
|
|
8ab9624619 | ||
|
|
f9056c3a45 | ||
|
|
a9df684ee2 | ||
|
|
e4d07c94d4 | ||
|
|
5d5d172b3b | ||
|
|
99f746b6be | ||
|
|
a461a33dc2 | ||
|
|
1213ffebeb | ||
|
|
c5a352cf4d | ||
|
|
cfcca54aa6 | ||
|
|
234f8cd669 | ||
|
|
43184140f0 | ||
|
|
acc325c150 | ||
|
|
46eb471a34 | ||
|
|
6dc14c73d6 | ||
|
|
f942924e7c | ||
|
|
aa6019e0a9 | ||
|
|
9dfbd346bc | ||
|
|
73b1d36dfd | ||
|
|
3662fb030a | ||
|
|
a423ee1032 | ||
|
|
72eb59d24f | ||
|
|
1a0247e028 | ||
|
|
281a0fccda | ||
|
|
59ce50299a | ||
|
|
be89509beb | ||
|
|
80cded234d | ||
|
|
030bb63586 | ||
|
|
66e8fc5884 | ||
|
|
363047337d | ||
|
|
c7e32d1576 | ||
|
|
157e59a1d1 | ||
|
|
d9c505ac79 | ||
|
|
7274a13f3c | ||
|
|
5d64665ddd | ||
|
|
e0d92d15c8 | ||
|
|
48dd658627 | ||
|
|
80dbbd02f0 | ||
|
|
4b7ca61c29 | ||
|
|
b2f04ae1f9 | ||
|
|
f34d4b5e28 | ||
|
|
d2ebfbd615 | ||
|
|
812abbe488 | ||
|
|
9602a4affc | ||
|
|
bf548c0747 | ||
|
|
55ad2be08b | ||
|
|
2cd58c2464 | ||
|
|
4675ba9d56 | ||
|
|
a25c992d5c | ||
|
|
2eadfe99a5 | ||
|
|
11086a726f | ||
|
|
cd99b40b0a | ||
|
|
63aa51dc0d | ||
|
|
4708c5bc7e | ||
|
|
5a8462c050 | ||
|
|
6cac02e01f | ||
|
|
8d12ceeebb | ||
|
|
4681d3ca1d | ||
|
|
60ded03ea9 | ||
|
|
b20d137dc3 | ||
|
|
29ca6eed6c | ||
|
|
fa85303f36 | ||
|
|
a5f4f43678 | ||
|
|
d807bd5da3 | ||
|
|
85314fb749 | ||
|
|
c4d5e93a41 | ||
|
|
86f0c4365e | ||
|
|
202592b940 | ||
|
|
aea149bd13 | ||
|
|
411365f101 | ||
|
|
2008476021 | ||
|
|
53afe5b8eb | ||
|
|
6193c7a048 | ||
|
|
41f81d90d7 | ||
|
|
bf623cf16b | ||
|
|
ec213330cd | ||
|
|
7aedf524c6 | ||
|
|
04602b1964 | ||
|
|
15cfc4f300 | ||
|
|
3463c7c62c | ||
|
|
7b76c10093 | ||
|
|
7ad26a2e7b | ||
|
|
7706ca2d5f | ||
|
|
56198e93ce | ||
|
|
a74323f739 | ||
|
|
e4efde177b | ||
|
|
5871a03ee2 | ||
|
|
67af4430e1 | ||
|
|
696dcdf951 | ||
|
|
e35bad0e08 | ||
|
|
904f7cac22 | ||
|
|
ccd73963ca | ||
|
|
b5469b0413 | ||
|
|
dae848d951 | ||
|
|
45a33ad0c0 | ||
|
|
89e50b17bd | ||
|
|
ac54ba3da1 | ||
|
|
2da610f15e | ||
|
|
4ab6c4c6b6 | ||
|
|
68dbedd938 | ||
|
|
2800c53346 | ||
|
|
132547a074 | ||
|
|
61ed87dc45 | ||
|
|
96c1227c4f | ||
|
|
33f1ac1785 | ||
|
|
e9e94a8343 | ||
|
|
ba24a53853 | ||
|
|
4955fbde33 | ||
|
|
d04067a91d | ||
|
|
01333a439b | ||
|
|
d26907ea94 |
@@ -31,3 +31,10 @@ ENABLE_SOFT_DELETE=false
|
||||
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.
|
||||
|
||||
# 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"]
|
||||
43
README.md
43
README.md
@@ -13,6 +13,7 @@
|
||||
<a href="#key-features">Features</a> •
|
||||
<a href="#how-to-use">Usage</a> •
|
||||
<a href="#how-it-works">How</a> •
|
||||
<a href="#mcp-server">MCP Server</a> •
|
||||
<a href="#help-us-translate-wygiwyh">Translate</a> •
|
||||
<a href="#caveats-and-warnings">Caveats and Warnings</a> •
|
||||
<a href="#built-with">Built with</a>
|
||||
@@ -29,15 +30,15 @@ Managing money can feel unnecessarily complex, but it doesn’t have to be. WYGI
|
||||
|
||||
By sticking to this straightforward approach, you avoid dipping into your savings while still keeping tabs on where your money goes.
|
||||
|
||||
While this philosophy is simple, finding tools to make it work wasn’t. I initially used a spreadsheet, which served me well for years—until it became unwieldy as I started managing multiple currencies, accounts, and investments. I tried various financial management apps, but none met my key requirements:
|
||||
While this philosophy is simple, finding tools to make it work wasn’t. I initially used a spreadsheet, which served me well for years, until it became unwieldy as I started managing multiple currencies, accounts, and investments. I tried various financial management apps, but none met my key requirements:
|
||||
|
||||
1. **Multi-currency support** to track income and expenses in different currencies.
|
||||
2. **Not a budgeting app** — as I dislike budgeting constraints.
|
||||
2. **Not a budgeting app** as I dislike budgeting constraints.
|
||||
3. **Web app usability** (ideally with mobile support, though optional).
|
||||
4. **Automation-ready API** to integrate with other tools and services.
|
||||
5. **Custom transaction rules** for credit card billing cycles or similar quirks.
|
||||
|
||||
Frustrated by the lack of comprehensive options, I set out to build **WYGIWYH** — an opinionated yet powerful tool that I believe will resonate with like-minded users.
|
||||
Frustrated by the lack of comprehensive options, I set out to build **WYGIWYH**, an opinionated yet powerful tool that I believe will resonate with like-minded users.
|
||||
|
||||
# Key Features
|
||||
|
||||
@@ -140,9 +141,35 @@ To create the first user, open the container's console using Unraid's UI, by cli
|
||||
| ENABLE_SOFT_DELETE | true\|false | false | Whether to enable transactions soft delete, if enabled, deleted transactions will remain in the database. Useful for imports and avoiding duplicate entries. |
|
||||
| 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 |
|
||||
| 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. |
|
||||
| DEMO | true\|false | false | If demo mode is enabled. |
|
||||
| ADMIN_EMAIL | string | None | Automatically creates an admin account with this email. Must have `ADMIN_PASSWORD` also set. |
|
||||
| ADMIN_PASSWORD | string | None | Automatically creates an admin account with this password. Must have `ADMIN_EMAIL` also set. |
|
||||
| CHECK_FOR_UPDATES | bool | true | Check and notify users about new versions. The check is done by doing a single query to Github's API every 12 hours. |
|
||||
|
||||
## OIDC Configuration
|
||||
|
||||
WYGIWYH supports login via OpenID Connect (OIDC) through `django-allauth`. This allows users to authenticate using an external OIDC provider.
|
||||
|
||||
> [!NOTE]
|
||||
> Currently only OpenID Connect is supported as a provider, open an issue if you need something else.
|
||||
|
||||
To configure OIDC, you need to set the following environment variables:
|
||||
|
||||
| Variable | Description |
|
||||
|----------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `OIDC_CLIENT_NAME` | The name of the provider. will be displayed in the login page. Defaults to `OpenID Connect` |
|
||||
| `OIDC_CLIENT_ID` | The Client ID provided by your OIDC provider. |
|
||||
| `OIDC_CLIENT_SECRET` | The Client Secret provided by your OIDC provider. |
|
||||
| `OIDC_SERVER_URL` | The base URL of your OIDC provider's discovery document or authorization server (e.g., `https://your-provider.com/auth/realms/your-realm`). `django-allauth` will use this to discover the necessary endpoints (authorization, token, userinfo, etc.). |
|
||||
| `OIDC_ALLOW_SIGNUP` | Allow the automatic creation of inexistent accounts on a successfull authentication. Defaults to `true`. |
|
||||
|
||||
**Callback URL (Redirect URI):**
|
||||
|
||||
When configuring your OIDC provider, you will need to provide a callback URL (also known as a Redirect URI). For WYGIWYH, the default callback URL is:
|
||||
|
||||
`https://your.wygiwyh.domain/auth/oidc/<OIDC_CLIENT_NAME>/login/callback/`
|
||||
|
||||
Replace `https://your.wygiwyh.domain` with the actual URL where your WYGIWYH instance is accessible. And `<OIDC_CLIENT_NAME>` with the slugfied value set in OIDC_CLIENT_NAME or the default `openid-connect` if you haven't set this variable.
|
||||
|
||||
# How it works
|
||||
|
||||
@@ -156,6 +183,10 @@ Check out our [Wiki](https://github.com/eitchtee/WYGIWYH/wiki) for more informat
|
||||
> [!NOTE]
|
||||
> Login with your github account
|
||||
|
||||
# MCP Server
|
||||
|
||||
[IZIme07](https://github.com/IZIme07) has kindly created an MCP Server for WYGIWYH that you can self-host. [Check it out at MCP-WYGIWYH](https://github.com/ReNewator/MCP-WYGIWYH)!
|
||||
|
||||
# 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.
|
||||
|
||||
@@ -14,6 +14,7 @@ import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from django.utils.text import slugify
|
||||
|
||||
SITE_TITLE = "WYGIWYH"
|
||||
TITLE_SEPARATOR = "::"
|
||||
@@ -42,6 +43,7 @@ INSTALLED_APPS = [
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.sites",
|
||||
"whitenoise.runserver_nostatic",
|
||||
"django.contrib.staticfiles",
|
||||
"webpack_boilerplate",
|
||||
@@ -61,7 +63,6 @@ INSTALLED_APPS = [
|
||||
"apps.transactions.apps.TransactionsConfig",
|
||||
"apps.currencies.apps.CurrenciesConfig",
|
||||
"apps.accounts.apps.AccountsConfig",
|
||||
"apps.common.apps.CommonConfig",
|
||||
"apps.net_worth.apps.NetWorthConfig",
|
||||
"apps.import_app.apps.ImportConfig",
|
||||
"apps.export_app.apps.ExportConfig",
|
||||
@@ -74,8 +75,15 @@ INSTALLED_APPS = [
|
||||
"apps.calendar_view.apps.CalendarViewConfig",
|
||||
"apps.dca.apps.DcaConfig",
|
||||
"pwa",
|
||||
"allauth",
|
||||
"allauth.account",
|
||||
"allauth.socialaccount",
|
||||
"allauth.socialaccount.providers.openid_connect",
|
||||
"apps.common.apps.CommonConfig",
|
||||
]
|
||||
|
||||
SITE_ID = 1
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django_browser_reload.middleware.BrowserReloadMiddleware",
|
||||
"apps.common.middleware.thread_local.ThreadLocalMiddleware",
|
||||
@@ -91,6 +99,7 @@ MIDDLEWARE = [
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"hijack.middleware.HijackUserMiddleware",
|
||||
"allauth.account.middleware.AccountMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "WYGIWYH.urls"
|
||||
@@ -163,10 +172,105 @@ AUTH_USER_MODEL = "users.User"
|
||||
|
||||
LANGUAGE_CODE = "en"
|
||||
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-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"),
|
||||
("nn", "Norsk (Nynorsk)"),
|
||||
("os", "Ирон"), # Ossetic
|
||||
("pa", "ਪੰਜਾਬੀ"),
|
||||
("pl", "Polski"),
|
||||
("pt", "Português"),
|
||||
("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")
|
||||
@@ -212,6 +316,42 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
LOGIN_REDIRECT_URL = "/"
|
||||
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_ALLOWED_TEMPLATE_PACKS = ["bootstrap5", "crispy_forms/pure_text"]
|
||||
@@ -239,7 +379,7 @@ DEBUG_TOOLBAR_PANELS = [
|
||||
"debug_toolbar.panels.signals.SignalsPanel",
|
||||
"debug_toolbar.panels.redirects.RedirectsPanel",
|
||||
"debug_toolbar.panels.profiling.ProfilingPanel",
|
||||
"cachalot.panels.CachalotPanel",
|
||||
# "cachalot.panels.CachalotPanel",
|
||||
]
|
||||
INTERNAL_IPS = [
|
||||
"127.0.0.1",
|
||||
@@ -347,6 +487,8 @@ else:
|
||||
|
||||
CACHALOT_UNCACHABLE_TABLES = ("django_migrations", "procrastinate_jobs")
|
||||
|
||||
# Procrastinate
|
||||
PROCRASTINATE_ON_APP_READY = "apps.common.procrastinate.on_app_ready"
|
||||
|
||||
# PWA
|
||||
PWA_APP_NAME = SITE_TITLE
|
||||
@@ -395,6 +537,7 @@ PWA_APP_SCREENSHOTS = [
|
||||
PWA_SERVICE_WORKER_PATH = BASE_DIR / "templates" / "pwa" / "serviceworker.js"
|
||||
|
||||
ENABLE_SOFT_DELETE = os.getenv("ENABLE_SOFT_DELETE", "false").lower() == "true"
|
||||
CHECK_FOR_UPDATES = os.getenv("CHECK_FOR_UPDATES", "true").lower() == "true"
|
||||
KEEP_DELETED_TRANSACTIONS_FOR = int(os.getenv("KEEP_DELETED_ENTRIES_FOR", "365"))
|
||||
APP_VERSION = os.getenv("APP_VERSION", "unknown")
|
||||
DEMO = os.getenv("DEMO", "false").lower() == "true"
|
||||
|
||||
@@ -21,6 +21,8 @@ from drf_spectacular.views import (
|
||||
SpectacularAPIView,
|
||||
SpectacularSwaggerView,
|
||||
)
|
||||
from allauth.socialaccount.providers.openid_connect.views import login, callback
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
@@ -36,6 +38,13 @@ urlpatterns = [
|
||||
SpectacularSwaggerView.as_view(url_name="schema"),
|
||||
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.common.urls")),
|
||||
path("", include("apps.users.urls")),
|
||||
|
||||
@@ -3,6 +3,7 @@ from crispy_forms.bootstrap import FormActions
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, Field, Column, Row
|
||||
from django import forms
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.accounts.models import Account
|
||||
@@ -15,6 +16,7 @@ from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
from apps.common.widgets.tom_select import TomSelect
|
||||
from apps.transactions.models import TransactionCategory, TransactionTag
|
||||
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
|
||||
from apps.currencies.models import Currency
|
||||
|
||||
|
||||
class AccountGroupForm(forms.ModelForm):
|
||||
@@ -79,6 +81,18 @@ class AccountForm(forms.ModelForm):
|
||||
|
||||
self.fields["group"].queryset = AccountGroup.objects.all()
|
||||
|
||||
if self.instance.id:
|
||||
qs = Currency.objects.filter(
|
||||
Q(is_archived=False) | Q(accounts=self.instance.id)
|
||||
).distinct()
|
||||
self.fields["currency"].queryset = qs
|
||||
self.fields["exchange_currency"].queryset = qs
|
||||
|
||||
else:
|
||||
qs = Currency.objects.filter(Q(is_archived=False))
|
||||
self.fields["currency"].queryset = qs
|
||||
self.fields["exchange_currency"].queryset = qs
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.form_method = "post"
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Generated by Django 5.2.4 on 2025-07-28 02:15
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0014_alter_account_options_alter_accountgroup_options'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='account',
|
||||
name='owner',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='account',
|
||||
name='shared_with',
|
||||
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL, verbose_name='Shared with users'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='account',
|
||||
name='visibility',
|
||||
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10, verbose_name='Visibility'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='accountgroup',
|
||||
name='owner',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='accountgroup',
|
||||
name='shared_with',
|
||||
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL, verbose_name='Shared with users'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='accountgroup',
|
||||
name='visibility',
|
||||
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10, verbose_name='Visibility'),
|
||||
),
|
||||
]
|
||||
20
app/apps/accounts/migrations/0016_account_untracked_by.py
Normal file
20
app/apps/accounts/migrations/0016_account_untracked_by.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 5.2.4 on 2025-08-09 05:52
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0015_alter_account_owner_alter_account_shared_with_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='account',
|
||||
name='untracked_by',
|
||||
field=models.ManyToManyField(blank=True, related_name='untracked_accounts', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
@@ -1,11 +1,11 @@
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.transactions.models import Transaction
|
||||
from apps.common.middleware.thread_local import get_current_user
|
||||
from apps.common.models import SharedObject, SharedObjectManager
|
||||
from apps.transactions.models import Transaction
|
||||
|
||||
|
||||
class AccountGroup(SharedObject):
|
||||
@@ -62,6 +62,11 @@ class Account(SharedObject):
|
||||
verbose_name=_("Archived"),
|
||||
help_text=_("Archived accounts don't show up nor count towards your net worth"),
|
||||
)
|
||||
untracked_by = models.ManyToManyField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
blank=True,
|
||||
related_name="untracked_accounts",
|
||||
)
|
||||
|
||||
objects = SharedObjectManager()
|
||||
all_objects = models.Manager() # Unfiltered manager
|
||||
@@ -75,6 +80,10 @@ class Account(SharedObject):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def is_untracked_by(self):
|
||||
user = get_current_user()
|
||||
return self.untracked_by.filter(pk=user.pk).exists()
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if self.exchange_currency == self.currency:
|
||||
|
||||
@@ -31,6 +31,11 @@ urlpatterns = [
|
||||
views.account_take_ownership,
|
||||
name="account_take_ownership",
|
||||
),
|
||||
path(
|
||||
"account/<int:pk>/toggle-untracked/",
|
||||
views.account_toggle_untracked,
|
||||
name="account_toggle_untracked",
|
||||
),
|
||||
path("account-groups/", views.account_groups_index, name="account_groups_index"),
|
||||
path("account-groups/list/", views.account_groups_list, name="account_groups_list"),
|
||||
path("account-groups/add/", views.account_group_add, name="account_group_add"),
|
||||
@@ -51,7 +56,7 @@ urlpatterns = [
|
||||
),
|
||||
path(
|
||||
"account-groups/<int:pk>/share/",
|
||||
views.account_share,
|
||||
views.account_group_share,
|
||||
name="account_group_share_settings",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -145,7 +145,7 @@ def account_group_take_ownership(request, pk):
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def account_share(request, pk):
|
||||
def account_group_share(request, pk):
|
||||
obj = get_object_or_404(AccountGroup, id=pk)
|
||||
|
||||
if obj.owner and obj.owner != request.user:
|
||||
|
||||
@@ -155,6 +155,26 @@ def account_delete(request, pk):
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def account_toggle_untracked(request, pk):
|
||||
account = get_object_or_404(Account, id=pk)
|
||||
if account.is_untracked_by():
|
||||
account.untracked_by.remove(request.user)
|
||||
messages.success(request, _("Account is now tracked"))
|
||||
else:
|
||||
account.untracked_by.add(request.user)
|
||||
messages.success(request, _("Account is now untracked"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
|
||||
@@ -41,7 +41,10 @@ class TransactionCategoryField(serializers.Field):
|
||||
def get_schema():
|
||||
return {
|
||||
"type": "array",
|
||||
"items": {"type": "string", "description": "TransactionTag ID or name"},
|
||||
"items": {
|
||||
"type": "string",
|
||||
"description": "TransactionCategory ID or name",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from django.db.models import Q
|
||||
from rest_framework import serializers
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
|
||||
@@ -22,6 +23,7 @@ class AccountSerializer(serializers.ModelSerializer):
|
||||
write_only=True,
|
||||
allow_null=True,
|
||||
)
|
||||
|
||||
currency = CurrencySerializer(read_only=True)
|
||||
currency_id = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Currency.objects.all(), source="currency", write_only=True
|
||||
@@ -50,6 +52,13 @@ class AccountSerializer(serializers.ModelSerializer):
|
||||
"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):
|
||||
return Account.objects.create(**validated_data)
|
||||
|
||||
|
||||
@@ -138,6 +138,7 @@ class RecurringTransactionSerializer(serializers.ModelSerializer):
|
||||
def update(self, instance, validated_data):
|
||||
instance = super().update(instance, validated_data)
|
||||
instance.update_unpaid_transactions()
|
||||
instance.generate_upcoming_transactions()
|
||||
return instance
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from copy import deepcopy
|
||||
|
||||
from rest_framework import viewsets
|
||||
|
||||
from apps.api.custom.pagination import CustomPageNumberPagination
|
||||
@@ -30,8 +32,9 @@ class TransactionViewSet(viewsets.ModelViewSet):
|
||||
transaction_created.send(sender=instance)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
old_data = deepcopy(Transaction.objects.get(pk=serializer.data["pk"]))
|
||||
instance = serializer.save()
|
||||
transaction_updated.send(sender=instance)
|
||||
transaction_updated.send(sender=instance, old_data=old_data)
|
||||
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
kwargs["partial"] = True
|
||||
|
||||
@@ -1,7 +1,26 @@
|
||||
from django.contrib import admin
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
@admin.action(description=_("Make public"))
|
||||
def make_public(modeladmin, request, queryset):
|
||||
queryset.update(visibility="public")
|
||||
|
||||
|
||||
@admin.action(description=_("Make private"))
|
||||
def make_private(modeladmin, request, queryset):
|
||||
queryset.update(visibility="private")
|
||||
|
||||
|
||||
class SharedObjectModelAdmin(admin.ModelAdmin):
|
||||
actions = [make_public, make_private]
|
||||
|
||||
list_display = ("__str__", "visibility", "owner", "get_shared_with")
|
||||
|
||||
@admin.display(description=_("Shared with users"))
|
||||
def get_shared_with(self, obj):
|
||||
return ", ".join([p.email for p in obj.shared_with.all()])
|
||||
|
||||
def get_queryset(self, request):
|
||||
# Use the all_objects manager to show all transactions, including deleted ones
|
||||
return self.model.all_objects.all()
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
from django.apps import AppConfig
|
||||
from django.core.cache import cache
|
||||
|
||||
|
||||
class CommonConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.common"
|
||||
|
||||
def ready(self):
|
||||
from django.contrib import admin
|
||||
from django.contrib.sites.models import Site
|
||||
from allauth.socialaccount.models import (
|
||||
SocialAccount,
|
||||
SocialApp,
|
||||
SocialToken,
|
||||
)
|
||||
|
||||
admin.site.unregister(Site)
|
||||
admin.site.unregister(SocialAccount)
|
||||
admin.site.unregister(SocialApp)
|
||||
admin.site.unregister(SocialToken)
|
||||
|
||||
# Delete the cache for update checks to prevent false-positives when the app is restarted
|
||||
# this will be recreated by the check_for_updates task
|
||||
cache.delete("update_check")
|
||||
|
||||
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
|
||||
@@ -139,7 +139,6 @@ class DynamicModelMultipleChoiceField(forms.ModelMultipleChoiceField):
|
||||
instance.save()
|
||||
return instance
|
||||
except Exception as e:
|
||||
print(e)
|
||||
raise ValidationError(_("Error creating new instance"))
|
||||
|
||||
def clean(self, value):
|
||||
|
||||
@@ -20,7 +20,15 @@ class MonthYearModelField(models.DateField):
|
||||
# Set the day to 1
|
||||
return date.replace(day=1).date()
|
||||
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):
|
||||
kwargs["widget"] = MonthYearWidget
|
||||
|
||||
@@ -2,6 +2,7 @@ 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
|
||||
|
||||
@@ -81,6 +82,23 @@ class SharedObjectForm(forms.Form):
|
||||
),
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
owner = cleaned_data.get("owner")
|
||||
shared_with_users = cleaned_data.get("shared_with_users", [])
|
||||
|
||||
# Raise validation error if owner is in shared_with_users
|
||||
if owner and owner in shared_with_users:
|
||||
self.add_error(
|
||||
"shared_with_users",
|
||||
ValidationError(
|
||||
_("You cannot share this item with its owner."),
|
||||
code="invalid_share",
|
||||
),
|
||||
)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
def save(self):
|
||||
instance = self.instance
|
||||
|
||||
|
||||
@@ -9,5 +9,8 @@ def truncate_decimal(value, decimal_places):
|
||||
:param decimal_places: The number of decimal places to keep
|
||||
:return: Truncated Decimal value
|
||||
"""
|
||||
if isinstance(value, (int, float)):
|
||||
value = Decimal(str(value))
|
||||
|
||||
multiplier = Decimal(10**decimal_places)
|
||||
return (value * multiplier).to_integral_value(rounding=ROUND_DOWN) / multiplier
|
||||
|
||||
@@ -5,7 +5,12 @@ from django.utils.formats import get_format as original_get_format
|
||||
def get_format(format_type=None, lang=None, use_l10n=None):
|
||||
user = get_current_user()
|
||||
|
||||
if user and user.is_authenticated and hasattr(user, "settings"):
|
||||
if (
|
||||
user
|
||||
and user.is_authenticated
|
||||
and hasattr(user, "settings")
|
||||
and use_l10n is not False
|
||||
):
|
||||
user_settings = user.settings
|
||||
if format_type == "THOUSAND_SEPARATOR":
|
||||
number_format = getattr(user_settings, "number_format", None)
|
||||
@@ -13,11 +18,13 @@ def get_format(format_type=None, lang=None, use_l10n=None):
|
||||
return "."
|
||||
elif number_format == "CD":
|
||||
return ","
|
||||
elif number_format == "SD" or number_format == "SC":
|
||||
return " "
|
||||
elif format_type == "DECIMAL_SEPARATOR":
|
||||
number_format = getattr(user_settings, "number_format", None)
|
||||
if number_format == "DC":
|
||||
if number_format == "DC" or number_format == "SC":
|
||||
return ","
|
||||
elif number_format == "CD":
|
||||
elif number_format == "CD" or number_format == "SD":
|
||||
return "."
|
||||
elif format_type == "SHORT_DATE_FORMAT":
|
||||
date_format = getattr(user_settings, "date_format", None)
|
||||
|
||||
@@ -36,12 +36,19 @@ class SharedObject(models.Model):
|
||||
related_name="%(class)s_owned",
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_("Owner"),
|
||||
)
|
||||
visibility = models.CharField(
|
||||
max_length=10, choices=Visibility.choices, default=Visibility.private
|
||||
max_length=10,
|
||||
choices=Visibility.choices,
|
||||
default=Visibility.private,
|
||||
verbose_name=_("Visibility"),
|
||||
)
|
||||
shared_with = models.ManyToManyField(
|
||||
settings.AUTH_USER_MODEL, related_name="%(class)s_shared", blank=True
|
||||
settings.AUTH_USER_MODEL,
|
||||
related_name="%(class)s_shared",
|
||||
blank=True,
|
||||
verbose_name=_("Shared with users"),
|
||||
)
|
||||
|
||||
# Use as abstract base class
|
||||
@@ -65,6 +72,18 @@ class SharedObject(models.Model):
|
||||
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,
|
||||
|
||||
6
app/apps/common/procrastinate.py
Normal file
6
app/apps/common/procrastinate.py
Normal file
@@ -0,0 +1,6 @@
|
||||
import procrastinate
|
||||
|
||||
|
||||
def on_app_ready(app: procrastinate.App):
|
||||
"""This function is ran upon procrastinate initialization."""
|
||||
...
|
||||
@@ -1,13 +1,17 @@
|
||||
import logging
|
||||
from packaging.version import parse as parse_version, InvalidVersion
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
from django.conf import settings
|
||||
from django.core import management
|
||||
from django.db import DEFAULT_DB_ALIAS
|
||||
from django.core.cache import cache
|
||||
|
||||
from procrastinate import builtin_tasks
|
||||
from procrastinate.contrib.django import app
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -19,7 +23,7 @@ async def remove_old_jobs(context, timestamp):
|
||||
return await builtin_tasks.remove_old_jobs(
|
||||
context,
|
||||
max_hours=744,
|
||||
remove_error=True,
|
||||
remove_failed=True,
|
||||
remove_cancelled=True,
|
||||
remove_aborted=True,
|
||||
)
|
||||
@@ -49,7 +53,7 @@ async def remove_expired_sessions(timestamp=None):
|
||||
def reset_demo_data(timestamp=None):
|
||||
"""
|
||||
Wipes the database and loads fresh demo data if DEMO mode is active.
|
||||
Runs daily at 6:00 AM.
|
||||
Runs daily at 8:00 AM.
|
||||
"""
|
||||
if not settings.DEMO:
|
||||
return # Exit if not in demo mode
|
||||
@@ -59,13 +63,14 @@ def reset_demo_data(timestamp=None):
|
||||
try:
|
||||
# 1. Flush the database (wipe all data)
|
||||
logger.info("Flushing the database...")
|
||||
# Using --noinput prevents prompts. Specify database if not default.
|
||||
|
||||
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(
|
||||
@@ -78,3 +83,49 @@ def reset_demo_data(timestamp=None):
|
||||
except Exception as e:
|
||||
logger.exception(f"Error during daily demo data reset: {e}")
|
||||
raise
|
||||
|
||||
|
||||
@app.periodic(cron="0 */12 * * *") # Every 12 hours
|
||||
@app.task(
|
||||
name="check_for_updates",
|
||||
)
|
||||
def check_for_updates(timestamp=None):
|
||||
if not settings.CHECK_FOR_UPDATES:
|
||||
return "CHECK_FOR_UPDATES is disabled"
|
||||
|
||||
url = "https://api.github.com/repos/eitchtee/WYGIWYH/releases/latest"
|
||||
|
||||
try:
|
||||
response = requests.get(url, timeout=60)
|
||||
response.raise_for_status() # Raise an exception for bad status codes (4xx or 5xx)
|
||||
|
||||
data = response.json()
|
||||
latest_version = data.get("tag_name")
|
||||
|
||||
if latest_version:
|
||||
try:
|
||||
current_v = parse_version(settings.APP_VERSION)
|
||||
except InvalidVersion:
|
||||
current_v = parse_version("0.0.0")
|
||||
try:
|
||||
latest_v = parse_version(latest_version)
|
||||
except InvalidVersion:
|
||||
latest_v = parse_version("0.0.0")
|
||||
|
||||
update_info = {
|
||||
"update_available": False,
|
||||
"current_version": str(current_v),
|
||||
"latest_version": str(latest_v),
|
||||
}
|
||||
|
||||
if latest_v > current_v:
|
||||
update_info["update_available"] = True
|
||||
|
||||
# Cache the entire dictionary
|
||||
cache.set("update_check", update_info, 60 * 60 * 25)
|
||||
logger.info(f"Update check complete. Result: {update_info}")
|
||||
else:
|
||||
logger.warning("Could not find 'tag_name' in GitHub API response.")
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Failed to fetch updates from GitHub: {e}")
|
||||
|
||||
17
app/apps/common/templatetags/cache_access.py
Normal file
17
app/apps/common/templatetags/cache_access.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# core/templatetags/update_tags.py
|
||||
from django import template
|
||||
from django.core.cache import cache
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def get_update_check():
|
||||
"""
|
||||
Retrieves the update status dictionary from the cache.
|
||||
Returns a default dictionary if nothing is found.
|
||||
"""
|
||||
return cache.get("update_check") or {
|
||||
"update_available": False,
|
||||
"latest_version": "N/A",
|
||||
}
|
||||
@@ -15,10 +15,11 @@ from cachalot.api import invalidate
|
||||
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
from apps.transactions.models import Transaction
|
||||
from apps.common.decorators.user import htmx_login_required
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@htmx_login_required
|
||||
@require_http_methods(["GET"])
|
||||
def toasts(request):
|
||||
return render(request, "common/fragments/toasts.html")
|
||||
@@ -90,6 +91,12 @@ def month_year_picker(request):
|
||||
for date in all_months
|
||||
]
|
||||
|
||||
today_url = (
|
||||
reverse(url, kwargs={"month": current_date.month, "year": current_date.year})
|
||||
if url
|
||||
else ""
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"common/fragments/month_year_picker.html",
|
||||
@@ -97,6 +104,7 @@ def month_year_picker(request):
|
||||
"month_year_data": result,
|
||||
"current_month": current_month,
|
||||
"current_year": current_year,
|
||||
"today_url": today_url,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -37,7 +37,9 @@ class AirDatePickerInput(widgets.DateInput):
|
||||
def _get_current_language():
|
||||
"""Get current language code in format compatible with AirDatepicker"""
|
||||
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]
|
||||
|
||||
def _get_format(self):
|
||||
|
||||
@@ -35,8 +35,8 @@ class ArbitraryDecimalDisplayNumberInput(forms.TextInput):
|
||||
self.attrs.update(
|
||||
{
|
||||
"x-data": "",
|
||||
"x-mask:dynamic": f"$money($input, '{get_format('DECIMAL_SEPARATOR')}', "
|
||||
f"'{get_format('THOUSAND_SEPARATOR')}', '30')",
|
||||
"x-mask:dynamic": f"$money($input, '{get_format('DECIMAL_SEPARATOR')}', '{get_format('THOUSAND_SEPARATOR')}', '30')",
|
||||
"x-on:keyup": "if (!['Control', 'Shift', 'Alt', 'Meta'].includes($event.key) && !(($event.ctrlKey || $event.metaKey) && $event.key.toLowerCase() === 'a')) $el.dispatchEvent(new Event('input'))",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -4,13 +4,7 @@ from datetime import timedelta
|
||||
from django.db.models import QuerySet
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.currencies.exchange_rates.providers import (
|
||||
SynthFinanceProvider,
|
||||
SynthFinanceStockProvider,
|
||||
CoinGeckoFreeProvider,
|
||||
CoinGeckoProProvider,
|
||||
TransitiveRateProvider,
|
||||
)
|
||||
import apps.currencies.exchange_rates.providers as providers
|
||||
from apps.currencies.models import ExchangeRateService, ExchangeRate, Currency
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -18,11 +12,12 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# Map service types to provider classes
|
||||
PROVIDER_MAPPING = {
|
||||
"synth_finance": SynthFinanceProvider,
|
||||
"synth_finance_stock": SynthFinanceStockProvider,
|
||||
"coingecko_free": CoinGeckoFreeProvider,
|
||||
"coingecko_pro": CoinGeckoProProvider,
|
||||
"transitive": TransitiveRateProvider,
|
||||
"coingecko_free": providers.CoinGeckoFreeProvider,
|
||||
"coingecko_pro": providers.CoinGeckoProProvider,
|
||||
"transitive": providers.TransitiveRateProvider,
|
||||
"frankfurter": providers.FrankfurterProvider,
|
||||
"twelvedata": providers.TwelveDataProvider,
|
||||
"twelvedatamarkets": providers.TwelveDataMarketsProvider,
|
||||
}
|
||||
|
||||
|
||||
@@ -203,21 +198,63 @@ class ExchangeRateFetcher:
|
||||
|
||||
if provider.rates_inverted:
|
||||
# If rates are inverted, we need to swap currencies
|
||||
ExchangeRate.objects.create(
|
||||
from_currency=to_currency,
|
||||
to_currency=from_currency,
|
||||
rate=rate,
|
||||
date=timezone.now(),
|
||||
)
|
||||
if service.singleton:
|
||||
# Try to get the last automatically created exchange rate
|
||||
exchange_rate = (
|
||||
ExchangeRate.objects.filter(
|
||||
automatic=True,
|
||||
from_currency=to_currency,
|
||||
to_currency=from_currency,
|
||||
)
|
||||
.order_by("-date")
|
||||
.first()
|
||||
)
|
||||
else:
|
||||
exchange_rate = None
|
||||
|
||||
if not exchange_rate:
|
||||
ExchangeRate.objects.create(
|
||||
automatic=True,
|
||||
from_currency=to_currency,
|
||||
to_currency=from_currency,
|
||||
rate=rate,
|
||||
date=timezone.now(),
|
||||
)
|
||||
else:
|
||||
exchange_rate.rate = rate
|
||||
exchange_rate.date = timezone.now()
|
||||
exchange_rate.save()
|
||||
|
||||
processed_pairs.add((to_currency.id, from_currency.id))
|
||||
else:
|
||||
# If rates are not inverted, we can use them as is
|
||||
ExchangeRate.objects.create(
|
||||
from_currency=from_currency,
|
||||
to_currency=to_currency,
|
||||
rate=rate,
|
||||
date=timezone.now(),
|
||||
)
|
||||
if service.singleton:
|
||||
# Try to get the last automatically created exchange rate
|
||||
exchange_rate = (
|
||||
ExchangeRate.objects.filter(
|
||||
automatic=True,
|
||||
from_currency=from_currency,
|
||||
to_currency=to_currency,
|
||||
)
|
||||
.order_by("-date")
|
||||
.first()
|
||||
)
|
||||
else:
|
||||
exchange_rate = None
|
||||
|
||||
if not exchange_rate:
|
||||
ExchangeRate.objects.create(
|
||||
automatic=True,
|
||||
from_currency=from_currency,
|
||||
to_currency=to_currency,
|
||||
rate=rate,
|
||||
date=timezone.now(),
|
||||
)
|
||||
else:
|
||||
exchange_rate.rate = rate
|
||||
exchange_rate.date = timezone.now()
|
||||
exchange_rate.save()
|
||||
|
||||
processed_pairs.add((from_currency.id, to_currency.id))
|
||||
|
||||
service.last_fetch = timezone.now()
|
||||
|
||||
@@ -13,70 +13,6 @@ from apps.currencies.exchange_rates.base import ExchangeRateProvider
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SynthFinanceProvider(ExchangeRateProvider):
|
||||
"""Implementation for Synth Finance API (synthfinance.com)"""
|
||||
|
||||
BASE_URL = "https://api.synthfinance.com/rates/live"
|
||||
rates_inverted = False # SynthFinance returns non-inverted rates
|
||||
|
||||
def __init__(self, api_key: str = None):
|
||||
super().__init__(api_key)
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({"Authorization": f"Bearer {self.api_key}"})
|
||||
|
||||
def get_rates(
|
||||
self, target_currencies: QuerySet, exchange_currencies: set
|
||||
) -> List[Tuple[Currency, Currency, Decimal]]:
|
||||
results = []
|
||||
currency_groups = {}
|
||||
for currency in target_currencies:
|
||||
if currency.exchange_currency in exchange_currencies:
|
||||
group = currency_groups.setdefault(currency.exchange_currency.code, [])
|
||||
group.append(currency)
|
||||
|
||||
for base_currency, currencies in currency_groups.items():
|
||||
try:
|
||||
to_currencies = ",".join(
|
||||
currency.code
|
||||
for currency in currencies
|
||||
if currency.code != base_currency
|
||||
)
|
||||
response = self.session.get(
|
||||
f"{self.BASE_URL}",
|
||||
params={"from": base_currency, "to": to_currencies},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
rates = data["data"]["rates"]
|
||||
|
||||
for currency in currencies:
|
||||
if currency.code == base_currency:
|
||||
rate = Decimal("1")
|
||||
else:
|
||||
rate = Decimal(str(rates[currency.code]))
|
||||
# Return the rate as is, without inversion
|
||||
results.append((currency.exchange_currency, currency, rate))
|
||||
|
||||
credits_used = data["meta"]["credits_used"]
|
||||
credits_remaining = data["meta"]["credits_remaining"]
|
||||
logger.info(
|
||||
f"Synth Finance API call: {credits_used} credits used, {credits_remaining} remaining"
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
logger.error(
|
||||
f"Error fetching rates from Synth Finance API for base {base_currency}: {e}"
|
||||
)
|
||||
except KeyError as e:
|
||||
logger.error(
|
||||
f"Unexpected response structure from Synth Finance API for base {base_currency}: {e}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error processing Synth Finance data for base {base_currency}: {e}"
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
class CoinGeckoFreeProvider(ExchangeRateProvider):
|
||||
"""Implementation for CoinGecko Free API"""
|
||||
|
||||
@@ -152,71 +88,6 @@ class CoinGeckoProProvider(CoinGeckoFreeProvider):
|
||||
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"""
|
||||
|
||||
@@ -306,3 +177,329 @@ class TransitiveRateProvider(ExchangeRateProvider):
|
||||
queue.append((neighbor, path + [neighbor], current_rate * rate))
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
class FrankfurterProvider(ExchangeRateProvider):
|
||||
"""Implementation for the Frankfurter API (frankfurter.dev)"""
|
||||
|
||||
BASE_URL = "https://api.frankfurter.dev/v1/latest"
|
||||
rates_inverted = (
|
||||
False # Frankfurter returns non-inverted rates (e.g., 1 EUR = 1.1 USD)
|
||||
)
|
||||
|
||||
def __init__(self, api_key: str = None):
|
||||
"""
|
||||
Initializes the provider. The Frankfurter API does not require an API key,
|
||||
so the api_key parameter is ignored.
|
||||
"""
|
||||
super().__init__(api_key)
|
||||
self.session = requests.Session()
|
||||
|
||||
@classmethod
|
||||
def requires_api_key(cls) -> bool:
|
||||
return False
|
||||
|
||||
def get_rates(
|
||||
self, target_currencies: QuerySet, exchange_currencies: set
|
||||
) -> List[Tuple[Currency, Currency, Decimal]]:
|
||||
results = []
|
||||
currency_groups = {}
|
||||
# Group target currencies by their exchange (base) currency to minimize API calls
|
||||
for currency in target_currencies:
|
||||
if currency.exchange_currency in exchange_currencies:
|
||||
group = currency_groups.setdefault(currency.exchange_currency.code, [])
|
||||
group.append(currency)
|
||||
|
||||
# Make one API call for each base currency
|
||||
for base_currency, currencies in currency_groups.items():
|
||||
try:
|
||||
# Create a comma-separated list of target currency codes
|
||||
to_currencies = ",".join(
|
||||
currency.code
|
||||
for currency in currencies
|
||||
if currency.code != base_currency
|
||||
)
|
||||
|
||||
# If there are no target currencies other than the base, skip the API call
|
||||
if not to_currencies:
|
||||
# Handle the case where the only request is for the base rate (e.g., USD to USD)
|
||||
for currency in currencies:
|
||||
if currency.code == base_currency:
|
||||
results.append(
|
||||
(currency.exchange_currency, currency, Decimal("1"))
|
||||
)
|
||||
continue
|
||||
|
||||
response = self.session.get(
|
||||
self.BASE_URL,
|
||||
params={"base": base_currency, "symbols": to_currencies},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
rates = data["rates"]
|
||||
|
||||
# Process the returned rates
|
||||
for currency in currencies:
|
||||
if currency.code == base_currency:
|
||||
# The rate for the base currency to itself is always 1
|
||||
rate = Decimal("1")
|
||||
else:
|
||||
rate = Decimal(str(rates[currency.code]))
|
||||
|
||||
results.append((currency.exchange_currency, currency, rate))
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(
|
||||
f"Error fetching rates from Frankfurter API for base {base_currency}: {e}"
|
||||
)
|
||||
except KeyError as e:
|
||||
logger.error(
|
||||
f"Unexpected response structure from Frankfurter API for base {base_currency}: {e}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error processing Frankfurter data for base {base_currency}: {e}"
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
class TwelveDataProvider(ExchangeRateProvider):
|
||||
"""Implementation for the Twelve Data API (twelvedata.com)"""
|
||||
|
||||
BASE_URL = "https://api.twelvedata.com/exchange_rate"
|
||||
rates_inverted = (
|
||||
False # The API returns direct rates, e.g., for EUR/USD it's 1 EUR = X USD
|
||||
)
|
||||
|
||||
def __init__(self, api_key: str):
|
||||
"""
|
||||
Initializes the provider with an API key and a requests session.
|
||||
"""
|
||||
super().__init__(api_key)
|
||||
self.session = requests.Session()
|
||||
|
||||
@classmethod
|
||||
def requires_api_key(cls) -> bool:
|
||||
"""This provider requires an API key."""
|
||||
return True
|
||||
|
||||
def get_rates(
|
||||
self, target_currencies: QuerySet, exchange_currencies: set
|
||||
) -> List[Tuple[Currency, Currency, Decimal]]:
|
||||
"""
|
||||
Fetches exchange rates from the Twelve Data API for the given currency pairs.
|
||||
|
||||
This provider makes one API call for each requested currency pair.
|
||||
"""
|
||||
results = []
|
||||
|
||||
for target_currency in target_currencies:
|
||||
# Ensure the target currency's exchange currency is one we're interested in
|
||||
if target_currency.exchange_currency not in exchange_currencies:
|
||||
continue
|
||||
|
||||
base_currency = target_currency.exchange_currency
|
||||
|
||||
# The exchange rate for the same currency is always 1
|
||||
if base_currency.code == target_currency.code:
|
||||
rate = Decimal("1")
|
||||
results.append((base_currency, target_currency, rate))
|
||||
continue
|
||||
|
||||
# Construct the symbol in the format "BASE/TARGET", e.g., "EUR/USD"
|
||||
symbol = f"{base_currency.code}/{target_currency.code}"
|
||||
|
||||
try:
|
||||
params = {
|
||||
"symbol": symbol,
|
||||
"apikey": self.api_key,
|
||||
}
|
||||
|
||||
response = self.session.get(self.BASE_URL, params=params)
|
||||
response.raise_for_status() # Raise an HTTPError for bad responses (4xx or 5xx)
|
||||
|
||||
data = response.json()
|
||||
|
||||
# The API may return an error message in a JSON object
|
||||
if "rate" not in data:
|
||||
error_message = data.get("message", "Rate not found in response.")
|
||||
logger.error(
|
||||
f"Could not fetch rate for {symbol} from Twelve Data: {error_message}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Convert the rate to a Decimal for precision
|
||||
rate = Decimal(str(data["rate"]))
|
||||
results.append((base_currency, target_currency, rate))
|
||||
|
||||
logger.info(f"Successfully fetched rate for {symbol} from Twelve Data.")
|
||||
|
||||
time.sleep(
|
||||
60
|
||||
) # We sleep every pair as to not step over TwelveData's minute limit
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(
|
||||
f"Error fetching rate from Twelve Data API for symbol {symbol}: {e}"
|
||||
)
|
||||
except KeyError as e:
|
||||
logger.error(
|
||||
f"Unexpected response structure from Twelve Data API for symbol {symbol}: Missing key {e}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"An unexpected error occurred while processing Twelve Data for {symbol}: {e}"
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
class TwelveDataMarketsProvider(ExchangeRateProvider):
|
||||
"""
|
||||
Provides prices for market instruments (stocks, ETFs, etc.) using the Twelve Data API.
|
||||
|
||||
This provider performs a multi-step process:
|
||||
1. Parses instrument codes which can be symbols, FIGI, CUSIP, or ISIN.
|
||||
2. For CUSIPs, it defaults the currency to USD. For all others, it searches
|
||||
for the instrument to determine its native trading currency.
|
||||
3. Fetches the latest price for the instrument in its native currency.
|
||||
4. Converts the price to the requested target exchange currency.
|
||||
"""
|
||||
|
||||
SYMBOL_SEARCH_URL = "https://api.twelvedata.com/symbol_search"
|
||||
PRICE_URL = "https://api.twelvedata.com/price"
|
||||
EXCHANGE_RATE_URL = "https://api.twelvedata.com/exchange_rate"
|
||||
|
||||
rates_inverted = True
|
||||
|
||||
def __init__(self, api_key: str):
|
||||
super().__init__(api_key)
|
||||
self.session = requests.Session()
|
||||
|
||||
@classmethod
|
||||
def requires_api_key(cls) -> bool:
|
||||
return True
|
||||
|
||||
def _parse_code(self, raw_code: str) -> Tuple[str, str]:
|
||||
"""Parses the raw code to determine its type and value."""
|
||||
if raw_code.startswith("figi:"):
|
||||
return "figi", raw_code.removeprefix("figi:")
|
||||
if raw_code.startswith("cusip:"):
|
||||
return "cusip", raw_code.removeprefix("cusip:")
|
||||
if raw_code.startswith("isin:"):
|
||||
return "isin", raw_code.removeprefix("isin:")
|
||||
return "symbol", raw_code
|
||||
|
||||
def get_rates(
|
||||
self, target_currencies: QuerySet, exchange_currencies: set
|
||||
) -> List[Tuple[Currency, Currency, Decimal]]:
|
||||
results = []
|
||||
|
||||
for asset in target_currencies:
|
||||
if asset.exchange_currency not in exchange_currencies:
|
||||
continue
|
||||
|
||||
code_type, code_value = self._parse_code(asset.code)
|
||||
original_currency_code = None
|
||||
|
||||
try:
|
||||
# Determine the instrument's native currency
|
||||
if code_type == "cusip":
|
||||
# CUSIP codes always default to USD
|
||||
original_currency_code = "USD"
|
||||
logger.info(f"Defaulting CUSIP {code_value} to USD currency.")
|
||||
else:
|
||||
# For all other types, find currency via symbol search
|
||||
search_params = {"symbol": code_value, "apikey": "demo"}
|
||||
search_res = self.session.get(
|
||||
self.SYMBOL_SEARCH_URL, params=search_params
|
||||
)
|
||||
search_res.raise_for_status()
|
||||
search_data = search_res.json()
|
||||
|
||||
if not search_data.get("data"):
|
||||
logger.warning(
|
||||
f"TwelveDataMarkets: Symbol search for '{code_value}' returned no results."
|
||||
)
|
||||
continue
|
||||
|
||||
instrument_data = search_data["data"][0]
|
||||
original_currency_code = instrument_data.get("currency")
|
||||
|
||||
if not original_currency_code:
|
||||
logger.error(
|
||||
f"TwelveDataMarkets: Could not determine original currency for '{code_value}'."
|
||||
)
|
||||
continue
|
||||
|
||||
# Get the instrument's price in its native currency
|
||||
price_params = {code_type: code_value, "apikey": self.api_key}
|
||||
price_res = self.session.get(self.PRICE_URL, params=price_params)
|
||||
price_res.raise_for_status()
|
||||
price_data = price_res.json()
|
||||
|
||||
if "price" not in price_data:
|
||||
error_message = price_data.get(
|
||||
"message", "Price key not found in response"
|
||||
)
|
||||
logger.error(
|
||||
f"TwelveDataMarkets: Could not get price for {code_type} '{code_value}': {error_message}"
|
||||
)
|
||||
continue
|
||||
|
||||
price_in_original_currency = Decimal(price_data["price"])
|
||||
|
||||
# Convert price to the target exchange currency
|
||||
target_exchange_currency = asset.exchange_currency
|
||||
|
||||
if (
|
||||
original_currency_code.upper()
|
||||
== target_exchange_currency.code.upper()
|
||||
):
|
||||
final_price = price_in_original_currency
|
||||
else:
|
||||
rate_symbol = (
|
||||
f"{original_currency_code}/{target_exchange_currency.code}"
|
||||
)
|
||||
rate_params = {"symbol": rate_symbol, "apikey": self.api_key}
|
||||
rate_res = self.session.get(
|
||||
self.EXCHANGE_RATE_URL, params=rate_params
|
||||
)
|
||||
rate_res.raise_for_status()
|
||||
rate_data = rate_res.json()
|
||||
|
||||
if "rate" not in rate_data:
|
||||
error_message = rate_data.get(
|
||||
"message", "Rate key not found in response"
|
||||
)
|
||||
logger.error(
|
||||
f"TwelveDataMarkets: Could not get conversion rate for '{rate_symbol}': {error_message}"
|
||||
)
|
||||
continue
|
||||
|
||||
conversion_rate = Decimal(str(rate_data["rate"]))
|
||||
final_price = price_in_original_currency * conversion_rate
|
||||
|
||||
results.append((target_exchange_currency, asset, final_price))
|
||||
logger.info(
|
||||
f"Successfully processed price for {asset.code} as {final_price} {target_exchange_currency.code}"
|
||||
)
|
||||
|
||||
time.sleep(
|
||||
60
|
||||
) # We sleep every pair as to not step over TwelveData's minute limit
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(
|
||||
f"TwelveDataMarkets: API request failed for {code_value}: {e}"
|
||||
)
|
||||
except (KeyError, IndexError) as e:
|
||||
logger.error(
|
||||
f"TwelveDataMarkets: Error processing API response for {code_value}: {e}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"TwelveDataMarkets: An unexpected error occurred for {code_value}: {e}"
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
@@ -26,6 +26,7 @@ class CurrencyForm(forms.ModelForm):
|
||||
"suffix",
|
||||
"code",
|
||||
"exchange_currency",
|
||||
"is_archived",
|
||||
]
|
||||
widgets = {
|
||||
"exchange_currency": TomSelect(),
|
||||
@@ -40,6 +41,7 @@ class CurrencyForm(forms.ModelForm):
|
||||
self.helper.layout = Layout(
|
||||
"code",
|
||||
"name",
|
||||
Switch("is_archived"),
|
||||
"decimal_places",
|
||||
"prefix",
|
||||
"suffix",
|
||||
@@ -114,6 +116,7 @@ class ExchangeRateServiceForm(forms.ModelForm):
|
||||
"fetch_interval",
|
||||
"target_currencies",
|
||||
"target_accounts",
|
||||
"singleton",
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -126,6 +129,7 @@ class ExchangeRateServiceForm(forms.ModelForm):
|
||||
"name",
|
||||
"service_type",
|
||||
Switch("is_active"),
|
||||
Switch("singleton"),
|
||||
"api_key",
|
||||
Row(
|
||||
Column("interval_type", css_class="form-group col-md-6"),
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.4 on 2025-08-08 02:18
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('currencies', '0014_alter_currency_options'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='exchangerate',
|
||||
name='automatic',
|
||||
field=models.BooleanField(default=False, verbose_name='Automatic'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='exchangerateservice',
|
||||
name='singleton',
|
||||
field=models.BooleanField(default=False, help_text='Create one exchange rate and keep updating it. Avoids database clutter.', verbose_name='Single exchange rate'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.4 on 2025-08-08 02:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('currencies', '0015_exchangerate_automatic_exchangerateservice_singleton'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='exchangerate',
|
||||
name='automatic',
|
||||
field=models.BooleanField(default=False, verbose_name='Auto'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-16 22:18
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('currencies', '0016_alter_exchangerate_automatic'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='exchangerateservice',
|
||||
name='service_type',
|
||||
field=models.CharField(choices=[('synth_finance', 'Synth Finance'), ('synth_finance_stock', 'Synth Finance Stock'), ('coingecko_free', 'CoinGecko (Demo/Free)'), ('coingecko_pro', 'CoinGecko (Pro)'), ('transitive', 'Transitive (Calculated from Existing Rates)'), ('frankfurter', 'Frankfurter')], max_length=255, verbose_name='Service Type'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-17 03:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('currencies', '0017_alter_exchangerateservice_service_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='exchangerateservice',
|
||||
name='service_type',
|
||||
field=models.CharField(choices=[('synth_finance', 'Synth Finance'), ('synth_finance_stock', 'Synth Finance Stock'), ('coingecko_free', 'CoinGecko (Demo/Free)'), ('coingecko_pro', 'CoinGecko (Pro)'), ('transitive', 'Transitive (Calculated from Existing Rates)'), ('frankfurter', 'Frankfurter'), ('twelvedata', 'TwelveData')], max_length=255, verbose_name='Service Type'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-17 06:01
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('currencies', '0018_alter_exchangerateservice_service_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='exchangerateservice',
|
||||
name='service_type',
|
||||
field=models.CharField(choices=[('synth_finance', 'Synth Finance'), ('synth_finance_stock', 'Synth Finance Stock'), ('coingecko_free', 'CoinGecko (Demo/Free)'), ('coingecko_pro', 'CoinGecko (Pro)'), ('transitive', 'Transitive (Calculated from Existing Rates)'), ('frankfurter', 'Frankfurter'), ('twelvedata', 'TwelveData'), ('twelvedatamarkets', 'TwelveData Markets')], max_length=255, verbose_name='Service Type'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,51 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-17 06:25
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
# The new value we are migrating to
|
||||
NEW_SERVICE_TYPE = "frankfurter"
|
||||
# The old values we are deprecating
|
||||
OLD_SERVICE_TYPE_TO_UPDATE = "synth_finance"
|
||||
OLD_SERVICE_TYPE_TO_DELETE = "synth_finance_stock"
|
||||
|
||||
|
||||
def forwards_func(apps, schema_editor):
|
||||
"""
|
||||
Forward migration:
|
||||
- Deletes all ExchangeRateService instances with service_type 'synth_finance_stock'.
|
||||
- Updates all ExchangeRateService instances with service_type 'synth_finance' to 'frankfurter'.
|
||||
"""
|
||||
ExchangeRateService = apps.get_model("currencies", "ExchangeRateService")
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
# 1. Delete the SYNTH_FINANCE_STOCK entries
|
||||
ExchangeRateService.objects.using(db_alias).filter(
|
||||
service_type=OLD_SERVICE_TYPE_TO_DELETE
|
||||
).delete()
|
||||
|
||||
# 2. Update the SYNTH_FINANCE entries to FRANKFURTER
|
||||
ExchangeRateService.objects.using(db_alias).filter(
|
||||
service_type=OLD_SERVICE_TYPE_TO_UPDATE
|
||||
).update(service_type=NEW_SERVICE_TYPE, api_key=None)
|
||||
|
||||
|
||||
def backwards_func(apps, schema_editor):
|
||||
"""
|
||||
Backward migration: This operation is not safely reversible.
|
||||
- We cannot know which 'frankfurter' services were originally 'synth_finance'.
|
||||
- The deleted 'synth_finance_stock' services cannot be recovered.
|
||||
We will leave this function empty to allow migrating backwards without doing anything.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
# Add the previous migration file here
|
||||
("currencies", "0019_alter_exchangerateservice_service_type"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forwards_func, reverse_code=backwards_func),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-17 06:29
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('currencies', '0020_migrate_synth_finance_services'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='exchangerateservice',
|
||||
name='service_type',
|
||||
field=models.CharField(choices=[('coingecko_free', 'CoinGecko (Demo/Free)'), ('coingecko_pro', 'CoinGecko (Pro)'), ('transitive', 'Transitive (Calculated from Existing Rates)'), ('frankfurter', 'Frankfurter'), ('twelvedata', 'TwelveData'), ('twelvedatamarkets', 'TwelveData Markets')], max_length=255, verbose_name='Service Type'),
|
||||
),
|
||||
]
|
||||
18
app/apps/currencies/migrations/0022_currency_is_archived.py
Normal file
18
app/apps/currencies/migrations/0022_currency_is_archived.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-30 00:47
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('currencies', '0021_alter_exchangerateservice_service_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='currency',
|
||||
name='is_archived',
|
||||
field=models.BooleanField(default=False, verbose_name='Archived'),
|
||||
),
|
||||
]
|
||||
@@ -32,6 +32,11 @@ class Currency(models.Model):
|
||||
help_text=_("Default currency for exchange calculations"),
|
||||
)
|
||||
|
||||
is_archived = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("Archived"),
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@@ -70,6 +75,8 @@ class ExchangeRate(models.Model):
|
||||
)
|
||||
date = models.DateTimeField(verbose_name=_("Date and Time"))
|
||||
|
||||
automatic = models.BooleanField(verbose_name=_("Auto"), default=False)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Exchange Rate")
|
||||
verbose_name_plural = _("Exchange Rates")
|
||||
@@ -92,11 +99,12 @@ class ExchangeRateService(models.Model):
|
||||
"""Configuration for exchange rate services"""
|
||||
|
||||
class ServiceType(models.TextChoices):
|
||||
SYNTH_FINANCE = "synth_finance", "Synth Finance"
|
||||
SYNTH_FINANCE_STOCK = "synth_finance_stock", "Synth Finance Stock"
|
||||
COINGECKO_FREE = "coingecko_free", "CoinGecko (Demo/Free)"
|
||||
COINGECKO_PRO = "coingecko_pro", "CoinGecko (Pro)"
|
||||
TRANSITIVE = "transitive", "Transitive (Calculated from Existing Rates)"
|
||||
FRANKFURTER = "frankfurter", "Frankfurter"
|
||||
TWELVEDATA = "twelvedata", "TwelveData"
|
||||
TWELVEDATA_MARKETS = "twelvedatamarkets", "TwelveData Markets"
|
||||
|
||||
class IntervalType(models.TextChoices):
|
||||
ON = "on", _("On")
|
||||
@@ -148,6 +156,14 @@ class ExchangeRateService(models.Model):
|
||||
blank=True,
|
||||
)
|
||||
|
||||
singleton = models.BooleanField(
|
||||
verbose_name=_("Single exchange rate"),
|
||||
default=False,
|
||||
help_text=_(
|
||||
"Create one exchange rate and keep updating it. Avoids database clutter."
|
||||
),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Exchange Rate Service")
|
||||
verbose_name_plural = _("Exchange Rate Services")
|
||||
|
||||
@@ -40,12 +40,6 @@ class CurrencyTests(TestCase):
|
||||
with self.assertRaises(ValidationError):
|
||||
currency.full_clean()
|
||||
|
||||
def test_currency_unique_code(self):
|
||||
"""Test that currency codes must be unique"""
|
||||
Currency.objects.create(code="USD", name="US Dollar", decimal_places=2)
|
||||
with self.assertRaises(IntegrityError):
|
||||
Currency.objects.create(code="USD", name="Another Dollar", decimal_places=2)
|
||||
|
||||
def test_currency_unique_name(self):
|
||||
"""Test that currency names must be unique"""
|
||||
Currency.objects.create(code="USD", name="US Dollar", decimal_places=2)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 5.2.4 on 2025-07-28 02:15
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dca', '0003_dcastrategy_owner_dcastrategy_shared_with_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='dcastrategy',
|
||||
name='owner',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='dcastrategy',
|
||||
name='shared_with',
|
||||
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL, verbose_name='Shared with users'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='dcastrategy',
|
||||
name='visibility',
|
||||
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10, verbose_name='Visibility'),
|
||||
),
|
||||
]
|
||||
@@ -9,8 +9,13 @@ from apps.currencies.models import Currency
|
||||
from apps.currencies.utils.convert import convert
|
||||
|
||||
|
||||
def get_categories_totals(transactions_queryset, ignore_empty=False):
|
||||
# Get metrics for each category and currency in a single query
|
||||
def get_categories_totals(
|
||||
transactions_queryset, ignore_empty=False, show_entities=False
|
||||
):
|
||||
# Step 1: Aggregate transaction data by category and currency.
|
||||
# This query calculates the total current and projected income/expense for each
|
||||
# category by grouping transactions and summing up their amounts based on their
|
||||
# type (income/expense) and payment status (paid/unpaid).
|
||||
category_currency_metrics = (
|
||||
transactions_queryset.values(
|
||||
"category",
|
||||
@@ -74,9 +79,70 @@ def get_categories_totals(transactions_queryset, ignore_empty=False):
|
||||
.order_by("category__name")
|
||||
)
|
||||
|
||||
# Process the results to structure by category
|
||||
# Step 2: Aggregate transaction data by tag, category, and currency.
|
||||
# This is similar to the category metrics but adds tags to the grouping,
|
||||
# allowing for a breakdown of totals by tag within each category. It also
|
||||
# handles untagged transactions, where the 'tags' field is None.
|
||||
tag_metrics = transactions_queryset.values(
|
||||
"category",
|
||||
"tags",
|
||||
"tags__name",
|
||||
"account__currency",
|
||||
"account__currency__code",
|
||||
"account__currency__name",
|
||||
"account__currency__decimal_places",
|
||||
"account__currency__prefix",
|
||||
"account__currency__suffix",
|
||||
"account__currency__exchange_currency",
|
||||
).annotate(
|
||||
expense_current=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(type=Transaction.Type.EXPENSE, is_paid=True, then="amount"),
|
||||
default=Value(0),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
),
|
||||
Decimal("0"),
|
||||
),
|
||||
expense_projected=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(type=Transaction.Type.EXPENSE, is_paid=False, then="amount"),
|
||||
default=Value(0),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
),
|
||||
Decimal("0"),
|
||||
),
|
||||
income_current=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(type=Transaction.Type.INCOME, is_paid=True, then="amount"),
|
||||
default=Value(0),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
),
|
||||
Decimal("0"),
|
||||
),
|
||||
income_projected=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(type=Transaction.Type.INCOME, is_paid=False, then="amount"),
|
||||
default=Value(0),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
),
|
||||
Decimal("0"),
|
||||
),
|
||||
)
|
||||
|
||||
# Step 3: Initialize the main dictionary to structure the final results.
|
||||
# The data will be organized hierarchically: category -> currency -> tags -> entities.
|
||||
result = {}
|
||||
|
||||
# Step 4: Process the aggregated category metrics to build the initial result structure.
|
||||
# This loop iterates through each category's metrics and populates the `result` dict.
|
||||
for metric in category_currency_metrics:
|
||||
# Skip empty categories if ignore_empty is True
|
||||
if ignore_empty and all(
|
||||
@@ -101,7 +167,11 @@ def get_categories_totals(transactions_queryset, ignore_empty=False):
|
||||
currency_id = metric["account__currency"]
|
||||
|
||||
if category_id not in result:
|
||||
result[category_id] = {"name": metric["category__name"], "currencies": {}}
|
||||
result[category_id] = {
|
||||
"name": metric["category__name"],
|
||||
"currencies": {},
|
||||
"tags": {}, # Add tags container
|
||||
}
|
||||
|
||||
# Add currency data
|
||||
currency_data = {
|
||||
@@ -123,7 +193,7 @@ def get_categories_totals(transactions_queryset, ignore_empty=False):
|
||||
"total_final": total_final,
|
||||
}
|
||||
|
||||
# Add exchanged values if exchange_currency exists
|
||||
# Step 4a: Handle currency conversion for category totals if an exchange currency is defined.
|
||||
if metric["account__currency__exchange_currency"]:
|
||||
from_currency = Currency.objects.get(id=currency_id)
|
||||
exchange_currency = Currency.objects.get(
|
||||
@@ -162,4 +232,273 @@ def get_categories_totals(transactions_queryset, ignore_empty=False):
|
||||
|
||||
result[category_id]["currencies"][currency_id] = currency_data
|
||||
|
||||
# Step 5: Process the aggregated tag metrics and integrate them into the result structure.
|
||||
for tag_metric in tag_metrics:
|
||||
category_id = tag_metric["category"]
|
||||
tag_id = tag_metric["tags"] # Will be None for untagged transactions
|
||||
|
||||
if category_id in result:
|
||||
# Initialize the tag container if not exists
|
||||
if "tags" not in result[category_id]:
|
||||
result[category_id]["tags"] = {}
|
||||
|
||||
# Determine if this is a tagged or untagged transaction
|
||||
tag_key = tag_id if tag_id is not None else "untagged"
|
||||
tag_name = tag_metric["tags__name"] if tag_id is not None else None
|
||||
|
||||
if tag_key not in result[category_id]["tags"]:
|
||||
result[category_id]["tags"][tag_key] = {
|
||||
"name": tag_name,
|
||||
"currencies": {},
|
||||
"entities": {},
|
||||
}
|
||||
|
||||
currency_id = tag_metric["account__currency"]
|
||||
|
||||
# Calculate tag totals
|
||||
tag_total_current = (
|
||||
tag_metric["income_current"] - tag_metric["expense_current"]
|
||||
)
|
||||
tag_total_projected = (
|
||||
tag_metric["income_projected"] - tag_metric["expense_projected"]
|
||||
)
|
||||
tag_total_income = (
|
||||
tag_metric["income_current"] + tag_metric["income_projected"]
|
||||
)
|
||||
tag_total_expense = (
|
||||
tag_metric["expense_current"] + tag_metric["expense_projected"]
|
||||
)
|
||||
tag_total_final = tag_total_current + tag_total_projected
|
||||
|
||||
tag_currency_data = {
|
||||
"currency": {
|
||||
"code": tag_metric["account__currency__code"],
|
||||
"name": tag_metric["account__currency__name"],
|
||||
"decimal_places": tag_metric["account__currency__decimal_places"],
|
||||
"prefix": tag_metric["account__currency__prefix"],
|
||||
"suffix": tag_metric["account__currency__suffix"],
|
||||
},
|
||||
"expense_current": tag_metric["expense_current"],
|
||||
"expense_projected": tag_metric["expense_projected"],
|
||||
"total_expense": tag_total_expense,
|
||||
"income_current": tag_metric["income_current"],
|
||||
"income_projected": tag_metric["income_projected"],
|
||||
"total_income": tag_total_income,
|
||||
"total_current": tag_total_current,
|
||||
"total_projected": tag_total_projected,
|
||||
"total_final": tag_total_final,
|
||||
}
|
||||
|
||||
# Step 5a: Handle currency conversion for tag totals.
|
||||
if tag_metric["account__currency__exchange_currency"]:
|
||||
from_currency = Currency.objects.get(id=currency_id)
|
||||
exchange_currency = Currency.objects.get(
|
||||
id=tag_metric["account__currency__exchange_currency"]
|
||||
)
|
||||
|
||||
exchanged = {}
|
||||
for field in [
|
||||
"expense_current",
|
||||
"expense_projected",
|
||||
"income_current",
|
||||
"income_projected",
|
||||
"total_income",
|
||||
"total_expense",
|
||||
"total_current",
|
||||
"total_projected",
|
||||
"total_final",
|
||||
]:
|
||||
amount, prefix, suffix, decimal_places = convert(
|
||||
amount=tag_currency_data[field],
|
||||
from_currency=from_currency,
|
||||
to_currency=exchange_currency,
|
||||
)
|
||||
if amount is not None:
|
||||
exchanged[field] = amount
|
||||
if "currency" not in exchanged:
|
||||
exchanged["currency"] = {
|
||||
"prefix": prefix,
|
||||
"suffix": suffix,
|
||||
"decimal_places": decimal_places,
|
||||
"code": exchange_currency.code,
|
||||
"name": exchange_currency.name,
|
||||
}
|
||||
if exchanged:
|
||||
tag_currency_data["exchanged"] = exchanged
|
||||
|
||||
result[category_id]["tags"][tag_key]["currencies"][
|
||||
currency_id
|
||||
] = tag_currency_data
|
||||
|
||||
# Step 6: If requested, aggregate and process entity-level data.
|
||||
if show_entities:
|
||||
entity_metrics = transactions_queryset.values(
|
||||
"category",
|
||||
"tags",
|
||||
"entities",
|
||||
"entities__name",
|
||||
"account__currency",
|
||||
"account__currency__code",
|
||||
"account__currency__name",
|
||||
"account__currency__decimal_places",
|
||||
"account__currency__prefix",
|
||||
"account__currency__suffix",
|
||||
"account__currency__exchange_currency",
|
||||
).annotate(
|
||||
expense_current=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(
|
||||
type=Transaction.Type.EXPENSE, is_paid=True, then="amount"
|
||||
),
|
||||
default=Value(0),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
),
|
||||
Decimal("0"),
|
||||
),
|
||||
expense_projected=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(
|
||||
type=Transaction.Type.EXPENSE, is_paid=False, then="amount"
|
||||
),
|
||||
default=Value(0),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
),
|
||||
Decimal("0"),
|
||||
),
|
||||
income_current=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(type=Transaction.Type.INCOME, is_paid=True, then="amount"),
|
||||
default=Value(0),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
),
|
||||
Decimal("0"),
|
||||
),
|
||||
income_projected=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(
|
||||
type=Transaction.Type.INCOME, is_paid=False, then="amount"
|
||||
),
|
||||
default=Value(0),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
),
|
||||
Decimal("0"),
|
||||
),
|
||||
)
|
||||
|
||||
for entity_metric in entity_metrics:
|
||||
category_id = entity_metric["category"]
|
||||
tag_id = entity_metric["tags"]
|
||||
entity_id = entity_metric["entities"]
|
||||
|
||||
if category_id in result:
|
||||
tag_key = tag_id if tag_id is not None else "untagged"
|
||||
if tag_key in result[category_id]["tags"]:
|
||||
entity_key = entity_id if entity_id is not None else "no_entity"
|
||||
entity_name = (
|
||||
entity_metric["entities__name"]
|
||||
if entity_id is not None
|
||||
else None
|
||||
)
|
||||
|
||||
if "entities" not in result[category_id]["tags"][tag_key]:
|
||||
result[category_id]["tags"][tag_key]["entities"] = {}
|
||||
|
||||
if (
|
||||
entity_key
|
||||
not in result[category_id]["tags"][tag_key]["entities"]
|
||||
):
|
||||
result[category_id]["tags"][tag_key]["entities"][entity_key] = {
|
||||
"name": entity_name,
|
||||
"currencies": {},
|
||||
}
|
||||
|
||||
currency_id = entity_metric["account__currency"]
|
||||
|
||||
entity_total_current = (
|
||||
entity_metric["income_current"]
|
||||
- entity_metric["expense_current"]
|
||||
)
|
||||
entity_total_projected = (
|
||||
entity_metric["income_projected"]
|
||||
- entity_metric["expense_projected"]
|
||||
)
|
||||
entity_total_income = (
|
||||
entity_metric["income_current"]
|
||||
+ entity_metric["income_projected"]
|
||||
)
|
||||
entity_total_expense = (
|
||||
entity_metric["expense_current"]
|
||||
+ entity_metric["expense_projected"]
|
||||
)
|
||||
entity_total_final = entity_total_current + entity_total_projected
|
||||
|
||||
entity_currency_data = {
|
||||
"currency": {
|
||||
"code": entity_metric["account__currency__code"],
|
||||
"name": entity_metric["account__currency__name"],
|
||||
"decimal_places": entity_metric[
|
||||
"account__currency__decimal_places"
|
||||
],
|
||||
"prefix": entity_metric["account__currency__prefix"],
|
||||
"suffix": entity_metric["account__currency__suffix"],
|
||||
},
|
||||
"expense_current": entity_metric["expense_current"],
|
||||
"expense_projected": entity_metric["expense_projected"],
|
||||
"total_expense": entity_total_expense,
|
||||
"income_current": entity_metric["income_current"],
|
||||
"income_projected": entity_metric["income_projected"],
|
||||
"total_income": entity_total_income,
|
||||
"total_current": entity_total_current,
|
||||
"total_projected": entity_total_projected,
|
||||
"total_final": entity_total_final,
|
||||
}
|
||||
|
||||
if entity_metric["account__currency__exchange_currency"]:
|
||||
from_currency = Currency.objects.get(id=currency_id)
|
||||
exchange_currency = Currency.objects.get(
|
||||
id=entity_metric["account__currency__exchange_currency"]
|
||||
)
|
||||
|
||||
exchanged = {}
|
||||
for field in [
|
||||
"expense_current",
|
||||
"expense_projected",
|
||||
"income_current",
|
||||
"income_projected",
|
||||
"total_income",
|
||||
"total_expense",
|
||||
"total_current",
|
||||
"total_projected",
|
||||
"total_final",
|
||||
]:
|
||||
amount, prefix, suffix, decimal_places = convert(
|
||||
amount=entity_currency_data[field],
|
||||
from_currency=from_currency,
|
||||
to_currency=exchange_currency,
|
||||
)
|
||||
if amount is not None:
|
||||
exchanged[field] = amount
|
||||
if "currency" not in exchanged:
|
||||
exchanged["currency"] = {
|
||||
"prefix": prefix,
|
||||
"suffix": suffix,
|
||||
"decimal_places": decimal_places,
|
||||
"code": exchange_currency.code,
|
||||
"name": exchange_currency.name,
|
||||
}
|
||||
if exchanged:
|
||||
entity_currency_data["exchanged"] = exchanged
|
||||
|
||||
result[category_id]["tags"][tag_key]["entities"][entity_key][
|
||||
"currencies"
|
||||
][currency_id] = entity_currency_data
|
||||
|
||||
return result
|
||||
|
||||
@@ -13,7 +13,9 @@ from apps.insights.forms import (
|
||||
)
|
||||
|
||||
|
||||
def get_transactions(request, include_unpaid=True, include_silent=False):
|
||||
def get_transactions(
|
||||
request, include_unpaid=True, include_silent=False, include_untracked_accounts=False
|
||||
):
|
||||
transactions = Transaction.objects.all()
|
||||
|
||||
filter_type = request.GET.get("type", None)
|
||||
@@ -91,6 +93,15 @@ def get_transactions(request, include_unpaid=True, include_silent=False):
|
||||
transactions = transactions.filter(is_paid=True)
|
||||
|
||||
if not include_silent:
|
||||
transactions = transactions.exclude(Q(category__mute=True) & ~Q(category=None))
|
||||
transactions = transactions.exclude(
|
||||
Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True)
|
||||
)
|
||||
|
||||
if not include_untracked_accounts:
|
||||
transactions = transactions.exclude(
|
||||
account__in=request.user.untracked_accounts.all()
|
||||
)
|
||||
|
||||
transactions = transactions.exclude(account__currency__is_archived=True)
|
||||
|
||||
return transactions
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import decimal
|
||||
import json
|
||||
from collections import defaultdict
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Sum, Avg, F
|
||||
from django.db.models import Sum
|
||||
from django.shortcuts import render
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.http import require_http_methods
|
||||
@@ -22,13 +20,13 @@ from apps.insights.utils.category_explorer import (
|
||||
get_category_sums_by_account,
|
||||
get_category_sums_by_currency,
|
||||
)
|
||||
from apps.insights.utils.category_overview import get_categories_totals
|
||||
from apps.insights.utils.sankey import (
|
||||
generate_sankey_data_by_account,
|
||||
generate_sankey_data_by_currency,
|
||||
)
|
||||
from apps.insights.utils.transactions import get_transactions
|
||||
from apps.transactions.models import TransactionCategory, Transaction
|
||||
from apps.insights.utils.category_overview import get_categories_totals
|
||||
from apps.transactions.utils.calculations import calculate_currency_totals
|
||||
|
||||
|
||||
@@ -76,7 +74,7 @@ def index(request):
|
||||
def sankey_by_account(request):
|
||||
# Get filtered transactions
|
||||
|
||||
transactions = get_transactions(request)
|
||||
transactions = get_transactions(request, include_untracked_accounts=True)
|
||||
|
||||
# Generate Sankey data
|
||||
sankey_data = generate_sankey_data_by_account(transactions)
|
||||
@@ -170,17 +168,51 @@ def category_sum_by_currency(request):
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def category_overview(request):
|
||||
if "view_type" in request.GET:
|
||||
view_type = request.GET["view_type"]
|
||||
request.session["insights_category_explorer_view_type"] = view_type
|
||||
else:
|
||||
view_type = request.session.get("insights_category_explorer_view_type", "table")
|
||||
|
||||
if "show_tags" in request.GET:
|
||||
show_tags = request.GET["show_tags"] == "on"
|
||||
request.session["insights_category_explorer_show_tags"] = show_tags
|
||||
else:
|
||||
show_tags = request.session.get("insights_category_explorer_show_tags", True)
|
||||
|
||||
if "show_entities" in request.GET:
|
||||
show_entities = request.GET["show_entities"] == "on"
|
||||
request.session["insights_category_explorer_show_entities"] = show_entities
|
||||
else:
|
||||
show_entities = request.session.get(
|
||||
"insights_category_explorer_show_entities", False
|
||||
)
|
||||
|
||||
if "showing" in request.GET:
|
||||
showing = request.GET["showing"]
|
||||
request.session["insights_category_explorer_showing"] = showing
|
||||
else:
|
||||
showing = request.session.get("insights_category_explorer_showing", "final")
|
||||
|
||||
# Get filtered transactions
|
||||
transactions = get_transactions(request, include_silent=True)
|
||||
|
||||
total_table = get_categories_totals(
|
||||
transactions_queryset=transactions, ignore_empty=False
|
||||
transactions_queryset=transactions,
|
||||
ignore_empty=False,
|
||||
show_entities=show_entities,
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"insights/fragments/category_overview/index.html",
|
||||
{"total_table": total_table},
|
||||
{
|
||||
"total_table": total_table,
|
||||
"view_type": view_type,
|
||||
"show_tags": show_tags,
|
||||
"show_entities": show_entities,
|
||||
"showing": showing,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -218,10 +250,14 @@ def late_transactions(request):
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def emergency_fund(request):
|
||||
transactions_currency_queryset = Transaction.objects.filter(
|
||||
is_paid=True, account__is_archived=False, account__is_asset=False
|
||||
).order_by(
|
||||
"account__currency__name",
|
||||
transactions_currency_queryset = (
|
||||
Transaction.objects.filter(
|
||||
is_paid=True, account__is_archived=False, account__is_asset=False
|
||||
)
|
||||
.exclude(account__in=request.user.untracked_accounts.all())
|
||||
.order_by(
|
||||
"account__currency__name",
|
||||
)
|
||||
)
|
||||
currency_net_worth = calculate_currency_totals(
|
||||
transactions_queryset=transactions_currency_queryset, ignore_empty=False
|
||||
@@ -239,7 +275,9 @@ def emergency_fund(request):
|
||||
reference_date__gte=start_date,
|
||||
reference_date__lte=end_date,
|
||||
category__mute=False,
|
||||
mute=False,
|
||||
)
|
||||
.exclude(account__in=request.user.untracked_accounts.all())
|
||||
.values("reference_date", "account__currency")
|
||||
.annotate(monthly_total=Sum("amount"))
|
||||
)
|
||||
|
||||
@@ -107,9 +107,15 @@ def transactions_list(request, month: int, year: int):
|
||||
@require_http_methods(["GET"])
|
||||
def monthly_summary(request, month: int, year: int):
|
||||
# Base queryset with all required filters
|
||||
base_queryset = Transaction.objects.filter(
|
||||
reference_date__year=year, reference_date__month=month, account__is_asset=False
|
||||
).exclude(Q(category__mute=True) & ~Q(category=None))
|
||||
base_queryset = (
|
||||
Transaction.objects.filter(
|
||||
reference_date__year=year,
|
||||
reference_date__month=month,
|
||||
account__is_asset=False,
|
||||
)
|
||||
.exclude(Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True))
|
||||
.exclude(account__in=request.user.untracked_accounts.all())
|
||||
)
|
||||
|
||||
data = calculate_currency_totals(base_queryset, ignore_empty=True)
|
||||
percentages = calculate_percentage_distribution(data)
|
||||
@@ -143,7 +149,7 @@ def monthly_account_summary(request, month: int, year: int):
|
||||
base_queryset = Transaction.objects.filter(
|
||||
reference_date__year=year,
|
||||
reference_date__month=month,
|
||||
).exclude(Q(category__mute=True) & ~Q(category=None))
|
||||
).exclude(Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True))
|
||||
|
||||
account_data = calculate_account_totals(transactions_queryset=base_queryset.all())
|
||||
account_percentages = calculate_percentage_distribution(account_data)
|
||||
@@ -165,10 +171,14 @@ def monthly_account_summary(request, month: int, year: int):
|
||||
@require_http_methods(["GET"])
|
||||
def monthly_currency_summary(request, month: int, year: int):
|
||||
# Base queryset with all required filters
|
||||
base_queryset = Transaction.objects.filter(
|
||||
reference_date__year=year,
|
||||
reference_date__month=month,
|
||||
).exclude(Q(category__mute=True) & ~Q(category=None))
|
||||
base_queryset = (
|
||||
Transaction.objects.filter(
|
||||
reference_date__year=year,
|
||||
reference_date__month=month,
|
||||
)
|
||||
.exclude(Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True))
|
||||
.exclude(account__in=request.user.untracked_accounts.all())
|
||||
)
|
||||
|
||||
currency_data = calculate_currency_totals(base_queryset.all(), ignore_empty=True)
|
||||
currency_percentages = calculate_percentage_distribution(currency_data)
|
||||
|
||||
@@ -5,4 +5,5 @@ from . import views
|
||||
urlpatterns = [
|
||||
path("net-worth/current/", views.net_worth_current, name="net_worth_current"),
|
||||
path("net-worth/projected/", views.net_worth_projected, name="net_worth_projected"),
|
||||
path("net-worth/", views.net_worth, name="net_worth"),
|
||||
]
|
||||
|
||||
@@ -30,6 +30,7 @@ def calculate_historical_currency_net_worth(queryset):
|
||||
| Q(accounts__visibility="private", accounts__owner=None),
|
||||
accounts__is_archived=False,
|
||||
accounts__isnull=False,
|
||||
is_archived=False,
|
||||
)
|
||||
.values_list("name", flat=True)
|
||||
.distinct()
|
||||
@@ -181,3 +182,29 @@ def calculate_historical_account_balance(queryset):
|
||||
historical_account_balance[date_filter(end_date, "b Y")] = month_data
|
||||
|
||||
return historical_account_balance
|
||||
|
||||
|
||||
def calculate_monthly_net_worth_difference(historical_net_worth):
|
||||
diff_dict = OrderedDict()
|
||||
if not historical_net_worth:
|
||||
return diff_dict
|
||||
|
||||
# Get all currencies
|
||||
currencies = set()
|
||||
for data in historical_net_worth.values():
|
||||
currencies.update(data.keys())
|
||||
|
||||
# Initialize prev_values for all currencies
|
||||
prev_values = {currency: Decimal("0.00") for currency in currencies}
|
||||
|
||||
for month, values in historical_net_worth.items():
|
||||
diff_values = {}
|
||||
for currency in sorted(list(currencies)):
|
||||
current_val = values.get(currency, Decimal("0.00"))
|
||||
prev_val = prev_values.get(currency, Decimal("0.00"))
|
||||
diff_values[currency] = current_val - prev_val
|
||||
|
||||
diff_dict[month] = diff_values
|
||||
prev_values = values.copy()
|
||||
|
||||
return diff_dict
|
||||
|
||||
@@ -2,12 +2,13 @@ import json
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.shortcuts import render
|
||||
from django.shortcuts import render, redirect
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from apps.net_worth.utils.calculate_net_worth import (
|
||||
calculate_historical_currency_net_worth,
|
||||
calculate_historical_account_balance,
|
||||
calculate_monthly_net_worth_difference,
|
||||
)
|
||||
from apps.transactions.models import Transaction
|
||||
from apps.transactions.utils.calculations import (
|
||||
@@ -18,21 +19,44 @@ from apps.transactions.utils.calculations import (
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def net_worth_current(request):
|
||||
transactions_currency_queryset = Transaction.objects.filter(
|
||||
is_paid=True, account__is_archived=False
|
||||
).order_by(
|
||||
"account__currency__name",
|
||||
)
|
||||
transactions_account_queryset = Transaction.objects.filter(
|
||||
is_paid=True, account__is_archived=False
|
||||
).order_by(
|
||||
"account__group__name",
|
||||
"account__name",
|
||||
)
|
||||
def net_worth(request):
|
||||
if "view_type" in request.GET:
|
||||
view_type = request.GET["view_type"]
|
||||
request.session["networth_view_type"] = view_type
|
||||
else:
|
||||
view_type = request.session.get("networth_view_type", "current")
|
||||
|
||||
if view_type == "current":
|
||||
transactions_currency_queryset = (
|
||||
Transaction.objects.filter(is_paid=True, account__is_archived=False)
|
||||
.order_by(
|
||||
"account__currency__name",
|
||||
)
|
||||
.exclude(account__in=request.user.untracked_accounts.all())
|
||||
)
|
||||
transactions_account_queryset = Transaction.objects.filter(
|
||||
is_paid=True, account__is_archived=False
|
||||
).order_by(
|
||||
"account__group__name",
|
||||
"account__name",
|
||||
)
|
||||
else:
|
||||
transactions_currency_queryset = (
|
||||
Transaction.objects.filter(account__is_archived=False)
|
||||
.order_by(
|
||||
"account__currency__name",
|
||||
)
|
||||
.exclude(account__in=request.user.untracked_accounts.all())
|
||||
)
|
||||
transactions_account_queryset = Transaction.objects.filter(
|
||||
account__is_archived=False
|
||||
).order_by(
|
||||
"account__group__name",
|
||||
"account__name",
|
||||
)
|
||||
|
||||
currency_net_worth = calculate_currency_totals(
|
||||
transactions_queryset=transactions_currency_queryset
|
||||
transactions_queryset=transactions_currency_queryset, deep_search=True
|
||||
)
|
||||
account_net_worth = calculate_account_totals(
|
||||
transactions_queryset=transactions_account_queryset
|
||||
@@ -73,6 +97,38 @@ def net_worth_current(request):
|
||||
|
||||
chart_data_currency_json = json.dumps(chart_data_currency, cls=DjangoJSONEncoder)
|
||||
|
||||
monthly_difference_data = calculate_monthly_net_worth_difference(
|
||||
historical_net_worth=historical_currency_net_worth
|
||||
)
|
||||
|
||||
diff_labels = (
|
||||
list(monthly_difference_data.keys()) if monthly_difference_data else []
|
||||
)
|
||||
diff_currencies = (
|
||||
list(monthly_difference_data[diff_labels[0]].keys())
|
||||
if monthly_difference_data and diff_labels
|
||||
else []
|
||||
)
|
||||
|
||||
diff_datasets = []
|
||||
for i, currency in enumerate(diff_currencies):
|
||||
data = [
|
||||
float(month_data.get(currency, 0))
|
||||
for month_data in monthly_difference_data.values()
|
||||
]
|
||||
diff_datasets.append(
|
||||
{
|
||||
"label": currency,
|
||||
"data": data,
|
||||
"borderWidth": 3,
|
||||
}
|
||||
)
|
||||
|
||||
chart_data_monthly_difference = {"labels": diff_labels, "datasets": diff_datasets}
|
||||
chart_data_monthly_difference_json = json.dumps(
|
||||
chart_data_monthly_difference, cls=DjangoJSONEncoder
|
||||
)
|
||||
|
||||
historical_account_balance = calculate_historical_account_balance(
|
||||
queryset=transactions_account_queryset
|
||||
)
|
||||
@@ -116,111 +172,23 @@ def net_worth_current(request):
|
||||
"currencies": currencies,
|
||||
"chart_data_accounts_json": chart_data_accounts_json,
|
||||
"accounts": accounts,
|
||||
"type": "current",
|
||||
"type": view_type,
|
||||
"chart_data_monthly_difference_json": chart_data_monthly_difference_json,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def net_worth_current(request):
|
||||
request.session["networth_view_type"] = "current"
|
||||
|
||||
return redirect("net_worth")
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def net_worth_projected(request):
|
||||
transactions_currency_queryset = Transaction.objects.filter(
|
||||
account__is_archived=False
|
||||
).order_by(
|
||||
"account__currency__name",
|
||||
)
|
||||
transactions_account_queryset = Transaction.objects.filter(
|
||||
account__is_archived=False
|
||||
).order_by(
|
||||
"account__group__name",
|
||||
"account__name",
|
||||
)
|
||||
request.session["networth_view_type"] = "projected"
|
||||
|
||||
currency_net_worth = calculate_currency_totals(
|
||||
transactions_queryset=transactions_currency_queryset
|
||||
)
|
||||
account_net_worth = calculate_account_totals(
|
||||
transactions_queryset=transactions_account_queryset
|
||||
)
|
||||
|
||||
historical_currency_net_worth = calculate_historical_currency_net_worth(
|
||||
queryset=transactions_currency_queryset
|
||||
)
|
||||
|
||||
labels = (
|
||||
list(historical_currency_net_worth.keys())
|
||||
if historical_currency_net_worth
|
||||
else []
|
||||
)
|
||||
currencies = (
|
||||
list(historical_currency_net_worth[labels[0]].keys())
|
||||
if historical_currency_net_worth
|
||||
else []
|
||||
)
|
||||
|
||||
datasets = []
|
||||
for i, currency in enumerate(currencies):
|
||||
data = [
|
||||
float(month_data[currency])
|
||||
for month_data in historical_currency_net_worth.values()
|
||||
]
|
||||
datasets.append(
|
||||
{
|
||||
"label": currency,
|
||||
"data": data,
|
||||
"yAxisID": f"y{i}",
|
||||
"fill": False,
|
||||
"tension": 0.1,
|
||||
}
|
||||
)
|
||||
|
||||
chart_data_currency = {"labels": labels, "datasets": datasets}
|
||||
|
||||
chart_data_currency_json = json.dumps(chart_data_currency, cls=DjangoJSONEncoder)
|
||||
|
||||
historical_account_balance = calculate_historical_account_balance(
|
||||
queryset=transactions_account_queryset
|
||||
)
|
||||
|
||||
labels = (
|
||||
list(historical_account_balance.keys()) if historical_account_balance else []
|
||||
)
|
||||
accounts = (
|
||||
list(historical_account_balance[labels[0]].keys())
|
||||
if historical_account_balance
|
||||
else []
|
||||
)
|
||||
|
||||
datasets = []
|
||||
for i, account in enumerate(accounts):
|
||||
data = [
|
||||
float(month_data[account])
|
||||
for month_data in historical_account_balance.values()
|
||||
]
|
||||
datasets.append(
|
||||
{
|
||||
"label": account,
|
||||
"data": data,
|
||||
"fill": False,
|
||||
"tension": 0.1,
|
||||
"yAxisID": f"y-axis-{i}", # Assign each dataset to its own Y-axis
|
||||
}
|
||||
)
|
||||
|
||||
chart_data_accounts = {"labels": labels, "datasets": datasets}
|
||||
|
||||
chart_data_accounts_json = json.dumps(chart_data_accounts, cls=DjangoJSONEncoder)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"net_worth/net_worth.html",
|
||||
{
|
||||
"currency_net_worth": currency_net_worth,
|
||||
"account_net_worth": account_net_worth,
|
||||
"chart_data_currency_json": chart_data_currency_json,
|
||||
"currencies": currencies,
|
||||
"chart_data_accounts_json": chart_data_accounts_json,
|
||||
"accounts": accounts,
|
||||
"type": "projected",
|
||||
},
|
||||
)
|
||||
return redirect("net_worth")
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion
|
||||
from crispy_forms.bootstrap import FormActions, AccordionGroup
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, Field, Row, Column
|
||||
from crispy_forms.layout import Layout, Field, Row, Column, HTML
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
from apps.common.widgets.tom_select import TomSelect
|
||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
from apps.common.widgets.tom_select import TomSelect, TransactionSelect
|
||||
from apps.rules.models import TransactionRule, UpdateOrCreateTransactionRuleAction
|
||||
from apps.rules.models import TransactionRuleAction
|
||||
from apps.common.fields.forms.dynamic_select import DynamicModelChoiceField
|
||||
from apps.transactions.forms import BulkEditTransactionForm
|
||||
from apps.transactions.models import Transaction
|
||||
|
||||
|
||||
class TransactionRuleForm(forms.ModelForm):
|
||||
@@ -40,6 +44,8 @@ class TransactionRuleForm(forms.ModelForm):
|
||||
Column(Switch("on_create")),
|
||||
Column(Switch("on_delete")),
|
||||
),
|
||||
"order",
|
||||
Switch("sequenced"),
|
||||
"description",
|
||||
"trigger",
|
||||
)
|
||||
@@ -65,10 +71,11 @@ class TransactionRuleForm(forms.ModelForm):
|
||||
class TransactionRuleActionForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = TransactionRuleAction
|
||||
fields = ("value", "field")
|
||||
fields = ("value", "field", "order")
|
||||
labels = {
|
||||
"field": _("Set field"),
|
||||
"value": _("To"),
|
||||
"order": _("Order"),
|
||||
}
|
||||
widgets = {"field": TomSelect(clear_button=False)}
|
||||
|
||||
@@ -82,6 +89,7 @@ class TransactionRuleActionForm(forms.ModelForm):
|
||||
self.helper.form_method = "post"
|
||||
# TO-DO: Add helper with available commands
|
||||
self.helper.layout = Layout(
|
||||
"order",
|
||||
"field",
|
||||
"value",
|
||||
)
|
||||
@@ -147,9 +155,11 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
||||
"search_category_operator": TomSelect(clear_button=False),
|
||||
"search_internal_note_operator": TomSelect(clear_button=False),
|
||||
"search_internal_id_operator": TomSelect(clear_button=False),
|
||||
"search_mute_operator": TomSelect(clear_button=False),
|
||||
}
|
||||
|
||||
labels = {
|
||||
"order": _("Order"),
|
||||
"search_account_operator": _("Operator"),
|
||||
"search_type_operator": _("Operator"),
|
||||
"search_is_paid_operator": _("Operator"),
|
||||
@@ -163,6 +173,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
||||
"search_internal_id_operator": _("Operator"),
|
||||
"search_tags_operator": _("Operator"),
|
||||
"search_entities_operator": _("Operator"),
|
||||
"search_mute_operator": _("Operator"),
|
||||
"search_account": _("Account"),
|
||||
"search_type": _("Type"),
|
||||
"search_is_paid": _("Paid"),
|
||||
@@ -176,6 +187,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
||||
"search_internal_id": _("Internal ID"),
|
||||
"search_tags": _("Tags"),
|
||||
"search_entities": _("Entities"),
|
||||
"search_mute": _("Mute"),
|
||||
"set_account": _("Account"),
|
||||
"set_type": _("Type"),
|
||||
"set_is_paid": _("Paid"),
|
||||
@@ -189,6 +201,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
||||
"set_category": _("Category"),
|
||||
"set_internal_note": _("Internal Note"),
|
||||
"set_internal_id": _("Internal ID"),
|
||||
"set_mute": _("Mute"),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -200,6 +213,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
||||
self.helper.form_method = "post"
|
||||
|
||||
self.helper.layout = Layout(
|
||||
"order",
|
||||
BS5Accordion(
|
||||
AccordionGroup(
|
||||
_("Search Criteria"),
|
||||
@@ -224,6 +238,16 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
||||
css_class="form-group col-md-8",
|
||||
),
|
||||
),
|
||||
Row(
|
||||
Column(
|
||||
Field("search_mute_operator"),
|
||||
css_class="form-group col-md-4",
|
||||
),
|
||||
Column(
|
||||
Field("search_mute", rows=1),
|
||||
css_class="form-group col-md-8",
|
||||
),
|
||||
),
|
||||
Row(
|
||||
Column(
|
||||
Field("search_account_operator"),
|
||||
@@ -340,6 +364,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
||||
_("Set Values"),
|
||||
Field("set_type", rows=1),
|
||||
Field("set_is_paid", rows=1),
|
||||
Field("set_mute", rows=1),
|
||||
Field("set_account", rows=1),
|
||||
Field("set_entities", rows=1),
|
||||
Field("set_date", rows=1),
|
||||
@@ -381,3 +406,112 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
||||
if commit:
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
|
||||
class DryRunCreatedTransacion(forms.Form):
|
||||
transaction = DynamicModelChoiceField(
|
||||
model=Transaction,
|
||||
to_field_name="id",
|
||||
label=_("Transaction"),
|
||||
required=True,
|
||||
queryset=Transaction.objects.none(),
|
||||
widget=TransactionSelect(clear_button=False, income=True, expense=True),
|
||||
help_text=_("Type to search for a transaction"),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.layout = Layout(
|
||||
"transaction",
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Test"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
if self.data.get("transaction"):
|
||||
try:
|
||||
transaction = Transaction.objects.get(id=self.data.get("transaction"))
|
||||
except Transaction.DoesNotExist:
|
||||
transaction = None
|
||||
|
||||
if transaction:
|
||||
self.fields["transaction"].queryset = Transaction.objects.filter(
|
||||
id=transaction.id
|
||||
)
|
||||
|
||||
|
||||
class DryRunDeletedTransacion(forms.Form):
|
||||
transaction = DynamicModelChoiceField(
|
||||
model=Transaction,
|
||||
to_field_name="id",
|
||||
label=_("Transaction"),
|
||||
required=True,
|
||||
queryset=Transaction.objects.none(),
|
||||
widget=TransactionSelect(clear_button=False, income=True, expense=True),
|
||||
help_text=_("Type to search for a transaction"),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.layout = Layout(
|
||||
"transaction",
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Test"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
if self.data.get("transaction"):
|
||||
try:
|
||||
transaction = Transaction.objects.get(id=self.data.get("transaction"))
|
||||
except Transaction.DoesNotExist:
|
||||
transaction = None
|
||||
|
||||
if transaction:
|
||||
self.fields["transaction"].queryset = Transaction.objects.filter(
|
||||
id=transaction.id
|
||||
)
|
||||
|
||||
|
||||
class DryRunUpdatedTransactionForm(BulkEditTransactionForm):
|
||||
transaction = DynamicModelChoiceField(
|
||||
model=Transaction,
|
||||
to_field_name="id",
|
||||
label=_("Transaction"),
|
||||
required=True,
|
||||
queryset=Transaction.objects.none(),
|
||||
widget=TransactionSelect(clear_button=False, income=True, expense=True),
|
||||
help_text=_("Type to search for a transaction"),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.helper.layout.insert(0, "transaction")
|
||||
self.helper.layout.insert(1, HTML("<hr/>"))
|
||||
|
||||
# Change submit button
|
||||
self.helper.layout[-1] = FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Test"), css_class="btn btn-outline-primary w-100"
|
||||
)
|
||||
)
|
||||
|
||||
if self.data.get("transaction"):
|
||||
try:
|
||||
transaction = Transaction.objects.get(id=self.data.get("transaction"))
|
||||
except Transaction.DoesNotExist:
|
||||
transaction = None
|
||||
|
||||
if transaction:
|
||||
self.fields["transaction"].queryset = Transaction.objects.filter(
|
||||
id=transaction.id
|
||||
)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 5.2.4 on 2025-07-28 02:15
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('rules', '0013_transactionrule_on_delete'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='transactionrule',
|
||||
name='owner',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='transactionrule',
|
||||
name='shared_with',
|
||||
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL, verbose_name='Shared with users'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='transactionrule',
|
||||
name='visibility',
|
||||
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10, verbose_name='Visibility'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,39 @@
|
||||
# Generated by Django 5.2 on 2025-08-30 18:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("rules", "0014_alter_transactionrule_owner_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="transactionruleaction",
|
||||
options={
|
||||
"ordering": ["order"],
|
||||
"verbose_name": "Edit transaction action",
|
||||
"verbose_name_plural": "Edit transaction actions",
|
||||
},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="updateorcreatetransactionruleaction",
|
||||
options={
|
||||
"ordering": ["order"],
|
||||
"verbose_name": "Update or create transaction action",
|
||||
"verbose_name_plural": "Update or create transaction actions",
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="transactionruleaction",
|
||||
name="order",
|
||||
field=models.PositiveIntegerField(default=0, verbose_name="Order"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="updateorcreatetransactionruleaction",
|
||||
name="order",
|
||||
field=models.PositiveIntegerField(default=0, verbose_name="Order"),
|
||||
),
|
||||
]
|
||||
18
app/apps/rules/migrations/0016_transactionrule_sequenced.py
Normal file
18
app/apps/rules/migrations/0016_transactionrule_sequenced.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-31 18:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('rules', '0015_alter_transactionruleaction_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='transactionrule',
|
||||
name='sequenced',
|
||||
field=models.BooleanField(default=False, verbose_name='Sequenced'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-31 19:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('rules', '0016_transactionrule_sequenced'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='search_mute',
|
||||
field=models.TextField(blank=True, verbose_name='Search Mute'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='search_mute_operator',
|
||||
field=models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='exact', max_length=10, verbose_name='Mute Operator'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='set_mute',
|
||||
field=models.TextField(blank=True, verbose_name='Mute'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='transactionruleaction',
|
||||
name='field',
|
||||
field=models.CharField(choices=[('account', 'Account'), ('type', 'Type'), ('is_paid', 'Paid'), ('date', 'Date'), ('reference_date', 'Reference Date'), ('mute', 'Mute'), ('amount', 'Amount'), ('description', 'Description'), ('notes', 'Notes'), ('category', 'Category'), ('tags', 'Tags'), ('entities', 'Entities'), ('internal_nome', 'Internal Note'), ('internal_id', 'Internal ID')], max_length=50, verbose_name='Field'),
|
||||
),
|
||||
]
|
||||
18
app/apps/rules/migrations/0018_transactionrule_order.py
Normal file
18
app/apps/rules/migrations/0018_transactionrule_order.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.5 on 2025-09-02 14:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('rules', '0017_updateorcreatetransactionruleaction_search_mute_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='transactionrule',
|
||||
name='order',
|
||||
field=models.PositiveIntegerField(default=0, verbose_name='Order'),
|
||||
),
|
||||
]
|
||||
@@ -13,6 +13,11 @@ class TransactionRule(SharedObject):
|
||||
name = models.CharField(max_length=100, verbose_name=_("Name"))
|
||||
description = models.TextField(blank=True, null=True, verbose_name=_("Description"))
|
||||
trigger = models.TextField(verbose_name=_("Trigger"))
|
||||
sequenced = models.BooleanField(
|
||||
verbose_name=_("Sequenced"),
|
||||
default=False,
|
||||
)
|
||||
order = models.PositiveIntegerField(default=0, verbose_name=_("Order"))
|
||||
|
||||
objects = SharedObjectManager()
|
||||
all_objects = models.Manager() # Unfiltered manager
|
||||
@@ -32,12 +37,15 @@ class TransactionRuleAction(models.Model):
|
||||
is_paid = "is_paid", _("Paid")
|
||||
date = "date", _("Date")
|
||||
reference_date = "reference_date", _("Reference Date")
|
||||
mute = "mute", _("Mute")
|
||||
amount = "amount", _("Amount")
|
||||
description = "description", _("Description")
|
||||
notes = "notes", _("Notes")
|
||||
category = "category", _("Category")
|
||||
tags = "tags", _("Tags")
|
||||
entities = "entities", _("Entities")
|
||||
internal_note = "internal_nome", _("Internal Note")
|
||||
internal_id = "internal_id", _("Internal ID")
|
||||
|
||||
rule = models.ForeignKey(
|
||||
TransactionRule,
|
||||
@@ -51,6 +59,7 @@ class TransactionRuleAction(models.Model):
|
||||
verbose_name=_("Field"),
|
||||
)
|
||||
value = models.TextField(verbose_name=_("Value"))
|
||||
order = models.PositiveIntegerField(default=0, verbose_name=_("Order"))
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.rule} - {self.field} - {self.value}"
|
||||
@@ -59,6 +68,11 @@ class TransactionRuleAction(models.Model):
|
||||
verbose_name = _("Edit transaction action")
|
||||
verbose_name_plural = _("Edit transaction actions")
|
||||
unique_together = (("rule", "field"),)
|
||||
ordering = ["order"]
|
||||
|
||||
@property
|
||||
def action_type(self):
|
||||
return "edit_transaction"
|
||||
|
||||
|
||||
class UpdateOrCreateTransactionRuleAction(models.Model):
|
||||
@@ -237,6 +251,17 @@ class UpdateOrCreateTransactionRuleAction(models.Model):
|
||||
verbose_name="Internal ID Operator",
|
||||
)
|
||||
|
||||
search_mute = models.TextField(
|
||||
verbose_name="Search Mute",
|
||||
blank=True,
|
||||
)
|
||||
search_mute_operator = models.CharField(
|
||||
max_length=10,
|
||||
choices=SearchOperator.choices,
|
||||
default=SearchOperator.EXACT,
|
||||
verbose_name="Mute Operator",
|
||||
)
|
||||
|
||||
# Set fields
|
||||
set_account = models.TextField(
|
||||
verbose_name=_("Account"),
|
||||
@@ -290,10 +315,21 @@ class UpdateOrCreateTransactionRuleAction(models.Model):
|
||||
verbose_name=_("Tags"),
|
||||
blank=True,
|
||||
)
|
||||
set_mute = models.TextField(
|
||||
verbose_name=_("Mute"),
|
||||
blank=True,
|
||||
)
|
||||
|
||||
order = models.PositiveIntegerField(default=0, verbose_name=_("Order"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Update or create transaction action")
|
||||
verbose_name_plural = _("Update or create transaction actions")
|
||||
ordering = ["order"]
|
||||
|
||||
@property
|
||||
def action_type(self):
|
||||
return "update_or_create_transaction"
|
||||
|
||||
def __str__(self):
|
||||
return f"Update or create transaction action for {self.rule}"
|
||||
@@ -325,6 +361,10 @@ class UpdateOrCreateTransactionRuleAction(models.Model):
|
||||
value = simple.eval(self.search_is_paid)
|
||||
search_query &= add_to_query("is_paid", value, self.search_is_paid_operator)
|
||||
|
||||
if self.search_mute:
|
||||
value = simple.eval(self.search_mute)
|
||||
search_query &= add_to_query("mute", value, self.search_mute_operator)
|
||||
|
||||
if self.search_date:
|
||||
value = simple.eval(self.search_date)
|
||||
search_query &= add_to_query("date", value, self.search_date_operator)
|
||||
|
||||
@@ -9,40 +9,17 @@ from apps.transactions.models import (
|
||||
)
|
||||
from apps.rules.tasks import check_for_transaction_rules
|
||||
from apps.common.middleware.thread_local import get_current_user
|
||||
from apps.rules.utils.transactions import serialize_transaction
|
||||
|
||||
|
||||
@receiver(transaction_created)
|
||||
@receiver(transaction_updated)
|
||||
@receiver(transaction_deleted)
|
||||
def transaction_changed_receiver(sender: Transaction, signal, **kwargs):
|
||||
old_data = kwargs.get("old_data")
|
||||
if signal is transaction_deleted:
|
||||
# Serialize transaction data for processing
|
||||
transaction_data = {
|
||||
"id": sender.id,
|
||||
"account": (sender.account.id, sender.account.name),
|
||||
"account_group": (
|
||||
sender.account.group.id if sender.account.group else None,
|
||||
sender.account.group.name if sender.account.group else None,
|
||||
),
|
||||
"type": str(sender.type),
|
||||
"is_paid": sender.is_paid,
|
||||
"is_asset": sender.account.is_asset,
|
||||
"is_archived": sender.account.is_archived,
|
||||
"category": (
|
||||
sender.category.id if sender.category else None,
|
||||
sender.category.name if sender.category else None,
|
||||
),
|
||||
"date": sender.date.isoformat(),
|
||||
"reference_date": sender.reference_date.isoformat(),
|
||||
"amount": str(sender.amount),
|
||||
"description": sender.description,
|
||||
"notes": sender.notes,
|
||||
"tags": list(sender.tags.values_list("id", "name")),
|
||||
"entities": list(sender.entities.values_list("id", "name")),
|
||||
"deleted": True,
|
||||
"internal_note": sender.internal_note,
|
||||
"internal_id": sender.internal_id,
|
||||
}
|
||||
transaction_data = serialize_transaction(sender, deleted=True)
|
||||
|
||||
check_for_transaction_rules.defer(
|
||||
transaction_data=transaction_data,
|
||||
@@ -59,6 +36,9 @@ def transaction_changed_receiver(sender: Transaction, signal, **kwargs):
|
||||
dca_entry.amount_received = sender.amount
|
||||
dca_entry.save()
|
||||
|
||||
if signal is transaction_updated and old_data:
|
||||
old_data = serialize_transaction(old_data, deleted=False)
|
||||
|
||||
check_for_transaction_rules.defer(
|
||||
instance_id=sender.id,
|
||||
user_id=get_current_user().id,
|
||||
@@ -67,4 +47,5 @@ def transaction_changed_receiver(sender: Transaction, signal, **kwargs):
|
||||
if signal is transaction_created
|
||||
else "transaction_updated"
|
||||
),
|
||||
old_data=old_data,
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -42,6 +42,21 @@ urlpatterns = [
|
||||
views.transaction_rule_take_ownership,
|
||||
name="transaction_rule_take_ownership",
|
||||
),
|
||||
path(
|
||||
"rules/transaction/<int:pk>/dry-run/created/",
|
||||
views.dry_run_rule_created,
|
||||
name="transaction_rule_dry_run_created",
|
||||
),
|
||||
path(
|
||||
"rules/transaction/<int:pk>/dry-run/deleted/",
|
||||
views.dry_run_rule_deleted,
|
||||
name="transaction_rule_dry_run_deleted",
|
||||
),
|
||||
path(
|
||||
"rules/transaction/<int:pk>/dry-run/updated/",
|
||||
views.dry_run_rule_updated,
|
||||
name="transaction_rule_dry_run_updated",
|
||||
),
|
||||
path(
|
||||
"rules/transaction/<int:pk>/share/",
|
||||
views.transaction_rule_share,
|
||||
|
||||
0
app/apps/rules/utils/__init__.py
Normal file
0
app/apps/rules/utils/__init__.py
Normal file
101
app/apps/rules/utils/transactions.py
Normal file
101
app/apps/rules/utils/transactions.py
Normal file
@@ -0,0 +1,101 @@
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db.models import Sum, Value, DecimalField, Case, When, F
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
from apps.transactions.models import (
|
||||
Transaction,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TransactionsGetter:
|
||||
def __init__(self, **filters):
|
||||
self.__queryset = Transaction.objects.filter(**filters)
|
||||
|
||||
def exclude(self, **exclude_filters):
|
||||
self.__queryset = self.__queryset.exclude(**exclude_filters)
|
||||
|
||||
return self
|
||||
|
||||
@property
|
||||
def sum(self):
|
||||
return self.__queryset.aggregate(
|
||||
total=Coalesce(
|
||||
Sum("amount"), Value(Decimal("0")), output_field=DecimalField()
|
||||
)
|
||||
)["total"]
|
||||
|
||||
@property
|
||||
def balance(self):
|
||||
return abs(
|
||||
self.__queryset.aggregate(
|
||||
balance=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(type=Transaction.Type.EXPENSE, then=-F("amount")),
|
||||
default=F("amount"),
|
||||
output_field=DecimalField(),
|
||||
)
|
||||
),
|
||||
Value(Decimal("0")),
|
||||
output_field=DecimalField(),
|
||||
)
|
||||
)["balance"]
|
||||
)
|
||||
|
||||
@property
|
||||
def raw_balance(self):
|
||||
return self.__queryset.aggregate(
|
||||
balance=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(type=Transaction.Type.EXPENSE, then=-F("amount")),
|
||||
default=F("amount"),
|
||||
output_field=DecimalField(),
|
||||
)
|
||||
),
|
||||
Value(Decimal("0")),
|
||||
output_field=DecimalField(),
|
||||
)
|
||||
)["balance"]
|
||||
|
||||
|
||||
def serialize_transaction(sender: Transaction, deleted: bool):
|
||||
return {
|
||||
"id": sender.id,
|
||||
"account": (sender.account.id, sender.account.name),
|
||||
"account_group": (
|
||||
sender.account.group.id if sender.account.group else None,
|
||||
sender.account.group.name if sender.account.group else None,
|
||||
),
|
||||
"type": str(sender.type),
|
||||
"is_paid": sender.is_paid,
|
||||
"is_asset": sender.account.is_asset,
|
||||
"is_archived": sender.account.is_archived,
|
||||
"category": (
|
||||
sender.category.id if sender.category else None,
|
||||
sender.category.name if sender.category else None,
|
||||
),
|
||||
"date": sender.date.isoformat(),
|
||||
"reference_date": sender.reference_date.isoformat(),
|
||||
"amount": str(sender.amount),
|
||||
"description": sender.description,
|
||||
"notes": sender.notes,
|
||||
"tags": list(sender.tags.values_list("id", "name")),
|
||||
"entities": list(sender.entities.values_list("id", "name")),
|
||||
"deleted": deleted,
|
||||
"internal_note": sender.internal_note,
|
||||
"internal_id": sender.internal_id,
|
||||
"mute": sender.mute,
|
||||
"installment_id": sender.installment_id if sender.installment_plan else None,
|
||||
"installment_total": (
|
||||
sender.installment_plan.number_of_installments
|
||||
if sender.installment_plan is not None
|
||||
else None
|
||||
),
|
||||
"installment": sender.installment_plan is not None,
|
||||
"recurring_transaction": sender.recurring_transaction is not None,
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
from itertools import chain
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db import transaction
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import render, get_object_or_404, redirect
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -10,6 +15,9 @@ from apps.rules.forms import (
|
||||
TransactionRuleForm,
|
||||
TransactionRuleActionForm,
|
||||
UpdateOrCreateTransactionRuleActionForm,
|
||||
DryRunCreatedTransacion,
|
||||
DryRunDeletedTransacion,
|
||||
DryRunUpdatedTransactionForm,
|
||||
)
|
||||
from apps.rules.models import (
|
||||
TransactionRule,
|
||||
@@ -19,6 +27,11 @@ from apps.rules.models import (
|
||||
from apps.common.models import SharedObject
|
||||
from apps.common.forms import SharedObjectForm
|
||||
from apps.common.decorators.demo import disabled_on_demo
|
||||
from apps.rules.tasks import check_for_transaction_rules
|
||||
from apps.common.middleware.thread_local import get_current_user
|
||||
from apps.rules.signals import transaction_created, transaction_updated
|
||||
from apps.rules.utils.transactions import serialize_transaction
|
||||
from apps.transactions.models import Transaction
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -36,7 +49,7 @@ def rules_index(request):
|
||||
@disabled_on_demo
|
||||
@require_http_methods(["GET"])
|
||||
def rules_list(request):
|
||||
transaction_rules = TransactionRule.objects.all().order_by("id")
|
||||
transaction_rules = TransactionRule.objects.all().order_by("order", "id")
|
||||
return render(
|
||||
request,
|
||||
"rules/fragments/list.html",
|
||||
@@ -140,10 +153,20 @@ def transaction_rule_edit(request, transaction_rule_id):
|
||||
def transaction_rule_view(request, transaction_rule_id):
|
||||
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
|
||||
|
||||
edit_actions = transaction_rule.transaction_actions.all()
|
||||
update_or_create_actions = (
|
||||
transaction_rule.update_or_create_transaction_actions.all()
|
||||
)
|
||||
|
||||
all_actions = sorted(
|
||||
chain(edit_actions, update_or_create_actions),
|
||||
key=lambda a: a.order,
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"rules/fragments/transaction_rule/view.html",
|
||||
{"transaction_rule": transaction_rule},
|
||||
{"transaction_rule": transaction_rule, "all_actions": all_actions},
|
||||
)
|
||||
|
||||
|
||||
@@ -406,3 +429,156 @@ def update_or_create_transaction_rule_action_delete(request, pk):
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@disabled_on_demo
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def dry_run_rule_created(request, pk):
|
||||
rule = get_object_or_404(TransactionRule, id=pk)
|
||||
logs = None
|
||||
results = None
|
||||
|
||||
if request.method == "POST":
|
||||
form = DryRunCreatedTransacion(request.POST)
|
||||
if form.is_valid():
|
||||
try:
|
||||
with transaction.atomic():
|
||||
logs, results = check_for_transaction_rules(
|
||||
instance_id=form.cleaned_data["transaction"].id,
|
||||
signal="transaction_created",
|
||||
dry_run=True,
|
||||
rule_id=rule.id,
|
||||
user_id=get_current_user().id,
|
||||
)
|
||||
logs = "\n".join(logs)
|
||||
|
||||
response = render(
|
||||
request,
|
||||
"rules/fragments/transaction_rule/dry_run/created.html",
|
||||
{"form": form, "rule": rule, "logs": logs, "results": results},
|
||||
)
|
||||
|
||||
raise Exception("ROLLBACK")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return response
|
||||
|
||||
else:
|
||||
form = DryRunCreatedTransacion()
|
||||
|
||||
return render(
|
||||
request,
|
||||
"rules/fragments/transaction_rule/dry_run/created.html",
|
||||
{"form": form, "rule": rule, "logs": logs, "results": results},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@disabled_on_demo
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def dry_run_rule_deleted(request, pk):
|
||||
rule = get_object_or_404(TransactionRule, id=pk)
|
||||
logs = None
|
||||
results = None
|
||||
|
||||
if request.method == "POST":
|
||||
form = DryRunDeletedTransacion(request.POST)
|
||||
if form.is_valid():
|
||||
try:
|
||||
with transaction.atomic():
|
||||
logs, results = check_for_transaction_rules(
|
||||
instance_id=form.cleaned_data["transaction"].id,
|
||||
signal="transaction_deleted",
|
||||
dry_run=True,
|
||||
rule_id=rule.id,
|
||||
user_id=get_current_user().id,
|
||||
)
|
||||
logs = "\n".join(logs)
|
||||
|
||||
response = render(
|
||||
request,
|
||||
"rules/fragments/transaction_rule/dry_run/created.html",
|
||||
{"form": form, "rule": rule, "logs": logs, "results": results},
|
||||
)
|
||||
|
||||
raise Exception("ROLLBACK")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return response
|
||||
|
||||
else:
|
||||
form = DryRunDeletedTransacion()
|
||||
|
||||
return render(
|
||||
request,
|
||||
"rules/fragments/transaction_rule/dry_run/deleted.html",
|
||||
{"form": form, "rule": rule, "logs": logs, "results": results},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@disabled_on_demo
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def dry_run_rule_updated(request, pk):
|
||||
rule = get_object_or_404(TransactionRule, id=pk)
|
||||
logs = None
|
||||
results = None
|
||||
|
||||
if request.method == "POST":
|
||||
form = DryRunUpdatedTransactionForm(request.POST)
|
||||
if form.is_valid():
|
||||
base_transaction = Transaction.objects.get(
|
||||
id=request.POST.get("transaction")
|
||||
)
|
||||
old_data = deepcopy(base_transaction)
|
||||
try:
|
||||
with transaction.atomic():
|
||||
for field_name, value in form.cleaned_data.items():
|
||||
if value or isinstance(
|
||||
value, bool
|
||||
): # Only update fields that have been filled in the form
|
||||
if field_name == "tags":
|
||||
base_transaction.tags.set(value)
|
||||
elif field_name == "entities":
|
||||
base_transaction.entities.set(value)
|
||||
else:
|
||||
setattr(base_transaction, field_name, value)
|
||||
|
||||
base_transaction.save()
|
||||
|
||||
logs, results = check_for_transaction_rules(
|
||||
instance_id=base_transaction.id,
|
||||
signal="transaction_updated",
|
||||
dry_run=True,
|
||||
rule_id=rule.id,
|
||||
user_id=get_current_user().id,
|
||||
old_data=old_data,
|
||||
)
|
||||
logs = "\n".join(logs) if logs else ""
|
||||
|
||||
response = render(
|
||||
request,
|
||||
"rules/fragments/transaction_rule/dry_run/created.html",
|
||||
{"form": form, "rule": rule, "logs": logs, "results": results},
|
||||
)
|
||||
|
||||
# This will rollback the transaction
|
||||
raise Exception("ROLLBACK")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return response
|
||||
else:
|
||||
form = DryRunUpdatedTransactionForm(initial={"is_paid": None, "type": None})
|
||||
|
||||
return render(
|
||||
request,
|
||||
"rules/fragments/transaction_rule/dry_run/updated.html",
|
||||
{"form": form, "rule": rule, "logs": logs, "results": results},
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ from apps.transactions.models import (
|
||||
InstallmentPlan,
|
||||
RecurringTransaction,
|
||||
TransactionEntity,
|
||||
QuickTransaction,
|
||||
)
|
||||
from apps.common.admin import SharedObjectModelAdmin
|
||||
|
||||
@@ -49,19 +50,22 @@ class TransactionInline(admin.TabularInline):
|
||||
|
||||
|
||||
@admin.register(InstallmentPlan)
|
||||
class InstallmentPlanAdmin(SharedObjectModelAdmin):
|
||||
class InstallmentPlanAdmin(admin.ModelAdmin):
|
||||
inlines = [
|
||||
TransactionInline,
|
||||
]
|
||||
|
||||
|
||||
@admin.register(RecurringTransaction)
|
||||
class RecurringTransactionAdmin(SharedObjectModelAdmin):
|
||||
class RecurringTransactionAdmin(admin.ModelAdmin):
|
||||
inlines = [
|
||||
TransactionInline,
|
||||
]
|
||||
|
||||
|
||||
admin.site.register(QuickTransaction)
|
||||
|
||||
|
||||
@admin.register(TransactionCategory)
|
||||
class TransactionCategoryModelAdmin(SharedObjectModelAdmin):
|
||||
pass
|
||||
|
||||
@@ -60,26 +60,20 @@ class TransactionsFilter(django_filters.FilterSet):
|
||||
label=_("Currencies"),
|
||||
widget=TomSelectMultiple(checkboxes=True, remove_button=True),
|
||||
)
|
||||
category = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name="category__name",
|
||||
queryset=TransactionCategory.objects.all(),
|
||||
to_field_name="name",
|
||||
category = django_filters.MultipleChoiceFilter(
|
||||
label=_("Categories"),
|
||||
widget=TomSelectMultiple(checkboxes=True, remove_button=True),
|
||||
method="filter_category",
|
||||
)
|
||||
tags = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name="tags__name",
|
||||
queryset=TransactionTag.objects.all(),
|
||||
to_field_name="name",
|
||||
tags = django_filters.MultipleChoiceFilter(
|
||||
label=_("Tags"),
|
||||
widget=TomSelectMultiple(checkboxes=True, remove_button=True),
|
||||
method="filter_tags",
|
||||
)
|
||||
entities = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name="entities__name",
|
||||
queryset=TransactionEntity.objects.all(),
|
||||
to_field_name="name",
|
||||
entities = django_filters.MultipleChoiceFilter(
|
||||
label=_("Entities"),
|
||||
widget=TomSelectMultiple(checkboxes=True, remove_button=True),
|
||||
method="filter_entities",
|
||||
)
|
||||
is_paid = django_filters.MultipleChoiceFilter(
|
||||
choices=SITUACAO_CHOICES,
|
||||
@@ -125,6 +119,7 @@ class TransactionsFilter(django_filters.FilterSet):
|
||||
"is_paid",
|
||||
"category",
|
||||
"tags",
|
||||
"entities",
|
||||
"date_start",
|
||||
"date_end",
|
||||
"reference_date_start",
|
||||
@@ -186,6 +181,93 @@ class TransactionsFilter(django_filters.FilterSet):
|
||||
self.form.fields["date_end"].widget = AirDatePickerInput()
|
||||
|
||||
self.form.fields["account"].queryset = Account.objects.all()
|
||||
self.form.fields["category"].queryset = TransactionCategory.objects.all()
|
||||
self.form.fields["tags"].queryset = TransactionTag.objects.all()
|
||||
self.form.fields["entities"].queryset = TransactionEntity.objects.all()
|
||||
category_choices = list(
|
||||
TransactionCategory.objects.values_list("name", "name").order_by("name")
|
||||
)
|
||||
custom_choices = [
|
||||
("any", _("Categorized")),
|
||||
("uncategorized", _("Uncategorized")),
|
||||
]
|
||||
self.form.fields["category"].choices = custom_choices + category_choices
|
||||
tag_choices = list(
|
||||
TransactionTag.objects.values_list("name", "name").order_by("name")
|
||||
)
|
||||
custom_tag_choices = [("any", _("Tagged")), ("untagged", _("Untagged"))]
|
||||
self.form.fields["tags"].choices = custom_tag_choices + tag_choices
|
||||
entity_choices = list(
|
||||
TransactionEntity.objects.values_list("name", "name").order_by("name")
|
||||
)
|
||||
custom_entity_choices = [
|
||||
("any", _("Any entity")),
|
||||
("no_entity", _("No entity")),
|
||||
]
|
||||
self.form.fields["entities"].choices = custom_entity_choices + entity_choices
|
||||
|
||||
@staticmethod
|
||||
def filter_category(queryset, name, value):
|
||||
if not value:
|
||||
return queryset
|
||||
|
||||
value = list(value)
|
||||
|
||||
if "any" in value:
|
||||
return queryset.filter(category__isnull=False)
|
||||
|
||||
q = Q()
|
||||
if "uncategorized" in value:
|
||||
q |= Q(category__isnull=True)
|
||||
value.remove("uncategorized")
|
||||
|
||||
if value:
|
||||
q |= Q(category__name__in=value)
|
||||
|
||||
if q.children:
|
||||
return queryset.filter(q)
|
||||
|
||||
return queryset
|
||||
|
||||
@staticmethod
|
||||
def filter_tags(queryset, name, value):
|
||||
if not value:
|
||||
return queryset
|
||||
|
||||
value = list(value)
|
||||
|
||||
if "any" in value:
|
||||
return queryset.filter(tags__isnull=False).distinct()
|
||||
|
||||
q = Q()
|
||||
if "untagged" in value:
|
||||
q |= Q(tags__isnull=True)
|
||||
value.remove("untagged")
|
||||
|
||||
if value:
|
||||
q |= Q(tags__name__in=value)
|
||||
|
||||
if q.children:
|
||||
return queryset.filter(q).distinct()
|
||||
|
||||
return queryset
|
||||
|
||||
@staticmethod
|
||||
def filter_entities(queryset, name, value):
|
||||
if not value:
|
||||
return queryset
|
||||
|
||||
value = list(value)
|
||||
|
||||
if "any" in value:
|
||||
return queryset.filter(entities__isnull=False).distinct()
|
||||
|
||||
q = Q()
|
||||
if "no_entity" in value:
|
||||
q |= Q(entities__isnull=True)
|
||||
value.remove("no_entity")
|
||||
|
||||
if value:
|
||||
q |= Q(entities__name__in=value)
|
||||
|
||||
if q.children:
|
||||
return queryset.filter(q).distinct()
|
||||
|
||||
return queryset
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
from copy import deepcopy
|
||||
|
||||
from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion
|
||||
from crispy_forms.bootstrap import FormActions, AccordionGroup
|
||||
from crispy_forms.bootstrap import FormActions, AccordionGroup, AppendedText
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import (
|
||||
Layout,
|
||||
Row,
|
||||
Column,
|
||||
Field,
|
||||
Div,
|
||||
HTML,
|
||||
)
|
||||
from django import forms
|
||||
from django.db.models import Q
|
||||
@@ -28,8 +32,8 @@ from apps.transactions.models import (
|
||||
InstallmentPlan,
|
||||
RecurringTransaction,
|
||||
TransactionEntity,
|
||||
QuickTransaction,
|
||||
)
|
||||
from apps.common.middleware.thread_local import get_current_user
|
||||
|
||||
|
||||
class TransactionForm(forms.ModelForm):
|
||||
@@ -206,10 +210,21 @@ class TransactionForm(forms.ModelForm):
|
||||
else:
|
||||
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
Div(
|
||||
NoClassSubmit(
|
||||
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
|
||||
"submit", _("Add"), css_class="btn btn-outline-primary"
|
||||
),
|
||||
NoClassSubmit(
|
||||
"submit_and_similar",
|
||||
_("Save and add similar"),
|
||||
css_class="btn btn-outline-primary",
|
||||
),
|
||||
NoClassSubmit(
|
||||
"submit_and_another",
|
||||
_("Save and add another"),
|
||||
css_class="btn btn-outline-primary",
|
||||
),
|
||||
css_class="d-grid gap-2",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -226,44 +241,263 @@ class TransactionForm(forms.ModelForm):
|
||||
def save(self, **kwargs):
|
||||
is_new = not self.instance.id
|
||||
|
||||
if not is_new:
|
||||
old_data = deepcopy(Transaction.objects.get(pk=self.instance.id))
|
||||
else:
|
||||
old_data = None
|
||||
|
||||
instance = super().save(**kwargs)
|
||||
if is_new:
|
||||
transaction_created.send(sender=instance)
|
||||
else:
|
||||
transaction_updated.send(sender=instance)
|
||||
transaction_updated.send(sender=instance, old_data=old_data)
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
class BulkEditTransactionForm(TransactionForm):
|
||||
is_paid = forms.NullBooleanField(required=False)
|
||||
class QuickTransactionForm(forms.ModelForm):
|
||||
category = DynamicModelChoiceField(
|
||||
create_field="name",
|
||||
model=TransactionCategory,
|
||||
required=False,
|
||||
label=_("Category"),
|
||||
queryset=TransactionCategory.objects.filter(active=True),
|
||||
)
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
model=TransactionTag,
|
||||
to_field_name="name",
|
||||
create_field="name",
|
||||
required=False,
|
||||
label=_("Tags"),
|
||||
queryset=TransactionTag.objects.filter(active=True),
|
||||
)
|
||||
entities = DynamicModelMultipleChoiceField(
|
||||
model=TransactionEntity,
|
||||
to_field_name="name",
|
||||
create_field="name",
|
||||
required=False,
|
||||
label=_("Entities"),
|
||||
)
|
||||
account = forms.ModelChoiceField(
|
||||
queryset=Account.objects.filter(is_archived=False),
|
||||
label=_("Account"),
|
||||
widget=TomSelect(clear_button=False, group_by="group"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = QuickTransaction
|
||||
fields = [
|
||||
"name",
|
||||
"account",
|
||||
"type",
|
||||
"is_paid",
|
||||
"amount",
|
||||
"description",
|
||||
"notes",
|
||||
"category",
|
||||
"tags",
|
||||
"entities",
|
||||
"mute",
|
||||
]
|
||||
widgets = {
|
||||
"notes": forms.Textarea(attrs={"rows": 3}),
|
||||
"account": TomSelect(clear_button=False, group_by="group"),
|
||||
}
|
||||
help_texts = {
|
||||
"mute": _("Muted transactions won't be displayed on monthly summaries")
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Make all fields optional
|
||||
for field_name, field in self.fields.items():
|
||||
field.required = False
|
||||
|
||||
del self.helper.layout[-1] # Remove button
|
||||
del self.helper.layout[0:2] # Remove type, is_paid field
|
||||
# if editing a transaction display non-archived items and it's own item even if it's archived
|
||||
if self.instance.id:
|
||||
self.fields["account"].queryset = Account.objects.filter(
|
||||
Q(is_archived=False) | Q(transactions=self.instance.id),
|
||||
)
|
||||
|
||||
self.helper.layout.insert(
|
||||
0,
|
||||
self.fields["category"].queryset = TransactionCategory.objects.filter(
|
||||
Q(active=True) | Q(transaction=self.instance.id)
|
||||
)
|
||||
|
||||
self.fields["tags"].queryset = TransactionTag.objects.filter(
|
||||
Q(active=True) | Q(transaction=self.instance.id)
|
||||
)
|
||||
|
||||
self.fields["entities"].queryset = TransactionEntity.objects.filter(
|
||||
Q(active=True) | Q(transactions=self.instance.id)
|
||||
)
|
||||
else:
|
||||
self.fields["account"].queryset = Account.objects.filter(
|
||||
is_archived=False,
|
||||
)
|
||||
|
||||
self.fields["category"].queryset = TransactionCategory.objects.filter(
|
||||
active=True
|
||||
)
|
||||
self.fields["tags"].queryset = TransactionTag.objects.filter(active=True)
|
||||
self.fields["entities"].queryset = TransactionEntity.objects.all()
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.form_method = "post"
|
||||
self.helper.layout = Layout(
|
||||
Field(
|
||||
"type",
|
||||
template="transactions/widgets/income_expense_toggle_buttons.html",
|
||||
),
|
||||
Field("is_paid", template="transactions/widgets/paid_toggle_button.html"),
|
||||
"name",
|
||||
HTML("<hr />"),
|
||||
Row(
|
||||
Column("account", css_class="form-group col-md-6 mb-0"),
|
||||
Column("entities", css_class="form-group col-md-6 mb-0"),
|
||||
css_class="form-row",
|
||||
),
|
||||
"description",
|
||||
Field("amount", inputmode="decimal"),
|
||||
Row(
|
||||
Column("category", css_class="form-group col-md-6 mb-0"),
|
||||
Column("tags", css_class="form-group col-md-6 mb-0"),
|
||||
css_class="form-row",
|
||||
),
|
||||
"notes",
|
||||
Switch("mute"),
|
||||
)
|
||||
|
||||
if self.instance and self.instance.pk:
|
||||
decimal_places = self.instance.account.currency.decimal_places
|
||||
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput(
|
||||
decimal_places=decimal_places
|
||||
)
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
),
|
||||
)
|
||||
else:
|
||||
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
self.helper.layout.append(
|
||||
Div(
|
||||
NoClassSubmit(
|
||||
"submit", _("Add"), css_class="btn btn-outline-primary"
|
||||
),
|
||||
css_class="d-grid gap-2",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class BulkEditTransactionForm(forms.Form):
|
||||
type = forms.ChoiceField(
|
||||
choices=(Transaction.Type.choices),
|
||||
required=False,
|
||||
label=_("Type"),
|
||||
)
|
||||
is_paid = forms.NullBooleanField(
|
||||
required=False,
|
||||
label=_("Paid"),
|
||||
)
|
||||
account = DynamicModelChoiceField(
|
||||
model=Account,
|
||||
required=False,
|
||||
label=_("Account"),
|
||||
queryset=Account.objects.filter(is_archived=False),
|
||||
widget=TomSelect(clear_button=False, group_by="group"),
|
||||
)
|
||||
date = forms.DateField(
|
||||
label=_("Date"),
|
||||
required=False,
|
||||
widget=AirDatePickerInput(clear_button=False),
|
||||
)
|
||||
reference_date = forms.DateField(
|
||||
widget=AirMonthYearPickerInput(),
|
||||
label=_("Reference Date"),
|
||||
required=False,
|
||||
)
|
||||
amount = forms.DecimalField(
|
||||
max_digits=42,
|
||||
decimal_places=30,
|
||||
required=False,
|
||||
label=_("Amount"),
|
||||
widget=ArbitraryDecimalDisplayNumberInput(),
|
||||
)
|
||||
description = forms.CharField(
|
||||
max_length=500, required=False, label=_("Description")
|
||||
)
|
||||
notes = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.Textarea(attrs={"rows": 3}),
|
||||
label=_("Notes"),
|
||||
)
|
||||
category = DynamicModelChoiceField(
|
||||
create_field="name",
|
||||
model=TransactionCategory,
|
||||
required=False,
|
||||
label=_("Category"),
|
||||
queryset=TransactionCategory.objects.filter(active=True),
|
||||
)
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
model=TransactionTag,
|
||||
to_field_name="name",
|
||||
create_field="name",
|
||||
required=False,
|
||||
label=_("Tags"),
|
||||
queryset=TransactionTag.objects.filter(active=True),
|
||||
)
|
||||
entities = DynamicModelMultipleChoiceField(
|
||||
model=TransactionEntity,
|
||||
to_field_name="name",
|
||||
create_field="name",
|
||||
required=False,
|
||||
label=_("Entities"),
|
||||
queryset=TransactionEntity.objects.all(),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.fields["account"].queryset = Account.objects.filter(
|
||||
is_archived=False,
|
||||
)
|
||||
|
||||
self.fields["category"].queryset = TransactionCategory.objects.filter(
|
||||
active=True
|
||||
)
|
||||
self.fields["tags"].queryset = TransactionTag.objects.filter(active=True)
|
||||
self.fields["entities"].queryset = TransactionEntity.objects.all()
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.form_method = "post"
|
||||
self.helper.layout = Layout(
|
||||
Field(
|
||||
"type",
|
||||
template="transactions/widgets/unselectable_income_expense_toggle_buttons.html",
|
||||
),
|
||||
)
|
||||
|
||||
self.helper.layout.insert(
|
||||
1,
|
||||
Field(
|
||||
"is_paid",
|
||||
template="transactions/widgets/unselectable_paid_toggle_button.html",
|
||||
),
|
||||
)
|
||||
|
||||
self.helper.layout.append(
|
||||
Row(
|
||||
Column("account", css_class="form-group col-md-6 mb-0"),
|
||||
Column("entities", css_class="form-group col-md-6 mb-0"),
|
||||
css_class="form-row",
|
||||
),
|
||||
Row(
|
||||
Column(Field("date"), css_class="form-group col-md-6 mb-0"),
|
||||
Column(Field("reference_date"), css_class="form-group col-md-6 mb-0"),
|
||||
css_class="form-row",
|
||||
),
|
||||
"description",
|
||||
Field("amount", inputmode="decimal"),
|
||||
Row(
|
||||
Column("category", css_class="form-group col-md-6 mb-0"),
|
||||
Column("tags", css_class="form-group col-md-6 mb-0"),
|
||||
css_class="form-row",
|
||||
),
|
||||
"notes",
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
||||
@@ -271,6 +505,9 @@ class BulkEditTransactionForm(TransactionForm):
|
||||
),
|
||||
)
|
||||
|
||||
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
self.fields["date"].widget = AirDatePickerInput(clear_button=False)
|
||||
|
||||
|
||||
class TransferForm(forms.Form):
|
||||
from_account = forms.ModelChoiceField(
|
||||
@@ -347,6 +584,13 @@ class TransferForm(forms.Form):
|
||||
label=_("Notes"),
|
||||
)
|
||||
|
||||
mute = forms.BooleanField(
|
||||
label=_("Mute"),
|
||||
initial=True,
|
||||
required=False,
|
||||
help_text=_("Muted transactions won't be displayed on monthly summaries"),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@@ -365,6 +609,7 @@ class TransferForm(forms.Form):
|
||||
),
|
||||
Field("description"),
|
||||
Field("notes"),
|
||||
Switch("mute"),
|
||||
Row(
|
||||
Column(
|
||||
Row(
|
||||
@@ -447,6 +692,8 @@ class TransferForm(forms.Form):
|
||||
return cleaned_data
|
||||
|
||||
def save(self):
|
||||
mute = self.cleaned_data["mute"]
|
||||
|
||||
from_account = self.cleaned_data["from_account"]
|
||||
to_account = self.cleaned_data["to_account"]
|
||||
from_amount = self.cleaned_data["from_amount"]
|
||||
@@ -469,6 +716,7 @@ class TransferForm(forms.Form):
|
||||
description=description,
|
||||
category=from_category,
|
||||
notes=notes,
|
||||
mute=mute,
|
||||
)
|
||||
from_transaction.tags.set(self.cleaned_data.get("from_tags", []))
|
||||
|
||||
@@ -483,6 +731,7 @@ class TransferForm(forms.Form):
|
||||
description=description,
|
||||
category=to_category,
|
||||
notes=notes,
|
||||
mute=mute,
|
||||
)
|
||||
to_transaction.tags.set(self.cleaned_data.get("to_tags", []))
|
||||
|
||||
@@ -721,7 +970,7 @@ class TransactionCategoryForm(forms.ModelForm):
|
||||
fields = ["name", "mute", "active"]
|
||||
labels = {"name": _("Category name")}
|
||||
help_texts = {
|
||||
"mute": _("Muted categories won't count towards your monthly total")
|
||||
"mute": _("Muted categories won't be displayed on monthly summaries")
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -799,6 +1048,7 @@ class RecurringTransactionForm(forms.ModelForm):
|
||||
"notes",
|
||||
"add_notes_to_transaction",
|
||||
"entities",
|
||||
"keep_at_most",
|
||||
]
|
||||
widgets = {
|
||||
"reference_date": AirMonthYearPickerInput(),
|
||||
@@ -878,6 +1128,7 @@ class RecurringTransactionForm(forms.ModelForm):
|
||||
Column("end_date", css_class="form-group col-md-4 mb-0"),
|
||||
css_class="form-row",
|
||||
),
|
||||
AppendedText("keep_at_most", _("future transactions")),
|
||||
)
|
||||
|
||||
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
@@ -919,5 +1170,6 @@ class RecurringTransactionForm(forms.ModelForm):
|
||||
instance.create_upcoming_transactions()
|
||||
else:
|
||||
instance.update_unpaid_transactions()
|
||||
instance.generate_upcoming_transactions()
|
||||
|
||||
return instance
|
||||
|
||||
45
app/apps/transactions/migrations/0043_quicktransaction.py
Normal file
45
app/apps/transactions/migrations/0043_quicktransaction.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# Generated by Django 5.1.11 on 2025-06-20 03:57
|
||||
|
||||
import apps.transactions.validators
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0014_alter_account_options_alter_accountgroup_options'),
|
||||
('transactions', '0042_alter_transactioncategory_options_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='QuickTransaction',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, verbose_name='Name')),
|
||||
('type', models.CharField(choices=[('IN', 'Income'), ('EX', 'Expense')], default='EX', max_length=2, verbose_name='Type')),
|
||||
('is_paid', models.BooleanField(default=True, verbose_name='Paid')),
|
||||
('amount', models.DecimalField(decimal_places=30, max_digits=42, validators=[apps.transactions.validators.validate_non_negative, apps.transactions.validators.validate_decimal_places], verbose_name='Amount')),
|
||||
('description', models.CharField(blank=True, max_length=500, verbose_name='Description')),
|
||||
('notes', models.TextField(blank=True, verbose_name='Notes')),
|
||||
('internal_note', models.TextField(blank=True, verbose_name='Internal Note')),
|
||||
('internal_id', models.TextField(blank=True, null=True, unique=True, verbose_name='Internal ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quick_transactions', to='accounts.account', verbose_name='Account')),
|
||||
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='transactions.transactioncategory', verbose_name='Category')),
|
||||
('entities', models.ManyToManyField(blank=True, related_name='quick_transactions', to='transactions.transactionentity', verbose_name='Entities')),
|
||||
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL)),
|
||||
('tags', models.ManyToManyField(blank=True, to='transactions.transactiontag', verbose_name='Tags')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Quick Transaction',
|
||||
'verbose_name_plural': 'Quick Transactions',
|
||||
'db_table': 'quick_transactions',
|
||||
'default_manager_name': 'objects',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.1.11 on 2025-06-20 04:02
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('transactions', '0043_quicktransaction'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name='quicktransaction',
|
||||
unique_together={('name', 'owner')},
|
||||
),
|
||||
]
|
||||
18
app/apps/transactions/migrations/0045_transaction_mute.py
Normal file
18
app/apps/transactions/migrations/0045_transaction_mute.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.11 on 2025-07-19 18:12
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('transactions', '0044_alter_quicktransaction_unique_together'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='transaction',
|
||||
name='mute',
|
||||
field=models.BooleanField(default=False, verbose_name='Mute'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.11 on 2025-07-19 18:59
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('transactions', '0045_transaction_mute'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='quicktransaction',
|
||||
name='mute',
|
||||
field=models.BooleanField(default=False, verbose_name='Mute'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,61 @@
|
||||
# Generated by Django 5.2.4 on 2025-07-28 02:15
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('transactions', '0046_quicktransaction_mute'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='transactioncategory',
|
||||
name='owner',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='transactioncategory',
|
||||
name='shared_with',
|
||||
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL, verbose_name='Shared with users'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='transactioncategory',
|
||||
name='visibility',
|
||||
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10, verbose_name='Visibility'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='transactionentity',
|
||||
name='owner',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='transactionentity',
|
||||
name='shared_with',
|
||||
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL, verbose_name='Shared with users'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='transactionentity',
|
||||
name='visibility',
|
||||
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10, verbose_name='Visibility'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='transactiontag',
|
||||
name='owner',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='transactiontag',
|
||||
name='shared_with',
|
||||
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL, verbose_name='Shared with users'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='transactiontag',
|
||||
name='visibility',
|
||||
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10, verbose_name='Visibility'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.2.4 on 2025-08-06 14:51
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('transactions', '0047_alter_transactioncategory_owner_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='recurringtransaction',
|
||||
name='keep_at_most',
|
||||
field=models.PositiveIntegerField(default=6, validators=[django.core.validators.MinValueValidator(1)], verbose_name='Keep at most'),
|
||||
),
|
||||
]
|
||||
@@ -1,4 +1,6 @@
|
||||
import decimal
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.conf import settings
|
||||
@@ -16,7 +18,12 @@ from apps.common.templatetags.decimal import localize_number, drop_trailing_zero
|
||||
from apps.currencies.utils.convert import convert
|
||||
from apps.transactions.validators import validate_decimal_places, validate_non_negative
|
||||
from apps.common.middleware.thread_local import get_current_user
|
||||
from apps.common.models import SharedObject, SharedObjectManager, OwnedObject
|
||||
from apps.common.models import (
|
||||
SharedObject,
|
||||
SharedObjectManager,
|
||||
OwnedObject,
|
||||
OwnedObjectManager,
|
||||
)
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
@@ -28,13 +35,13 @@ transaction_deleted = Signal()
|
||||
|
||||
class SoftDeleteQuerySet(models.QuerySet):
|
||||
@staticmethod
|
||||
def _emit_signals(instances, created=False):
|
||||
def _emit_signals(instances, created=False, old_data=None):
|
||||
"""Helper to emit signals for multiple instances"""
|
||||
for instance in instances:
|
||||
for i, instance in enumerate(instances):
|
||||
if created:
|
||||
transaction_created.send(sender=instance)
|
||||
else:
|
||||
transaction_updated.send(sender=instance)
|
||||
transaction_updated.send(sender=instance, old_data=old_data[i])
|
||||
|
||||
def bulk_create(self, objs, emit_signal=True, **kwargs):
|
||||
instances = super().bulk_create(objs, **kwargs)
|
||||
@@ -45,22 +52,25 @@ class SoftDeleteQuerySet(models.QuerySet):
|
||||
return instances
|
||||
|
||||
def bulk_update(self, objs, fields, emit_signal=True, **kwargs):
|
||||
old_data = deepcopy(objs)
|
||||
result = super().bulk_update(objs, fields, **kwargs)
|
||||
|
||||
if emit_signal:
|
||||
self._emit_signals(objs, created=False)
|
||||
self._emit_signals(objs, created=False, old_data=old_data)
|
||||
|
||||
return result
|
||||
|
||||
def update(self, emit_signal=True, **kwargs):
|
||||
# Get instances before update
|
||||
instances = list(self)
|
||||
old_data = deepcopy(instances)
|
||||
|
||||
result = super().update(**kwargs)
|
||||
|
||||
if emit_signal:
|
||||
# Refresh instances to get new values
|
||||
refreshed = self.model.objects.filter(pk__in=[obj.pk for obj in instances])
|
||||
self._emit_signals(refreshed, created=False)
|
||||
self._emit_signals(refreshed, created=False, old_data=old_data)
|
||||
|
||||
return result
|
||||
|
||||
@@ -118,13 +128,20 @@ class SoftDeleteManager(models.Manager):
|
||||
qs = SoftDeleteQuerySet(self.model, using=self._db)
|
||||
user = get_current_user()
|
||||
if user and not user.is_anonymous:
|
||||
return qs.filter(
|
||||
Q(account__visibility="public")
|
||||
| Q(account__owner=user)
|
||||
| Q(account__shared_with=user)
|
||||
| Q(account__visibility="private", account__owner=None),
|
||||
deleted=False,
|
||||
).distinct()
|
||||
account_ids = (
|
||||
qs.filter(
|
||||
Q(account__visibility="public")
|
||||
| Q(account__owner=user)
|
||||
| Q(account__shared_with=user)
|
||||
| Q(account__visibility="private", account__owner=None),
|
||||
deleted=False,
|
||||
)
|
||||
.values_list("account__id", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
return qs.filter(account_id__in=account_ids, deleted=False)
|
||||
|
||||
else:
|
||||
return qs.filter(
|
||||
deleted=False,
|
||||
@@ -287,6 +304,7 @@ class Transaction(OwnedObject):
|
||||
is_paid = models.BooleanField(default=True, verbose_name=_("Paid"))
|
||||
date = models.DateField(verbose_name=_("Date"))
|
||||
reference_date = MonthYearModelField(verbose_name=_("Reference Date"))
|
||||
mute = models.BooleanField(default=False, verbose_name=_("Mute"))
|
||||
|
||||
amount = models.DecimalField(
|
||||
max_digits=42,
|
||||
@@ -363,7 +381,10 @@ class Transaction(OwnedObject):
|
||||
db_table = "transactions"
|
||||
default_manager_name = "objects"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
def clean_fields(self, *args, **kwargs):
|
||||
if isinstance(self.amount, (str, int, float)):
|
||||
self.amount = decimal.Decimal(str(self.amount))
|
||||
|
||||
self.amount = truncate_decimal(
|
||||
value=self.amount, decimal_places=self.account.currency.decimal_places
|
||||
)
|
||||
@@ -373,6 +394,11 @@ class Transaction(OwnedObject):
|
||||
elif not self.reference_date and self.date:
|
||||
self.reference_date = self.date.replace(day=1)
|
||||
|
||||
super().clean_fields(*args, **kwargs)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# This is not recommended as it will run twice on some cases like form and API saves.
|
||||
# We only do this here because we forgot to independently call it on multiple places.
|
||||
self.full_clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@@ -430,12 +456,58 @@ class Transaction(OwnedObject):
|
||||
type_display = self.get_type_display()
|
||||
frmt_date = date(self.date, "SHORT_DATE_FORMAT")
|
||||
account = self.account
|
||||
tags = ", ".join([x.name for x in self.tags.all()]) or _("No tags")
|
||||
tags = (
|
||||
", ".join([x.name for x in self.tags.all()])
|
||||
if self.id
|
||||
else None or _("No tags")
|
||||
)
|
||||
category = self.category or _("No category")
|
||||
amount = localize_number(drop_trailing_zeros(self.amount))
|
||||
description = self.description or _("No description")
|
||||
return f"[{frmt_date}][{type_display}][{account}] {description} • {category} • {tags} • {amount}"
|
||||
|
||||
def deepcopy(self, memo=None):
|
||||
"""
|
||||
Creates a deep copy of the transaction instance.
|
||||
|
||||
This method returns a new, unsaved Transaction instance with the same
|
||||
values as the original, including its many-to-many relationships.
|
||||
The primary key and any other unique fields are reset to avoid
|
||||
database integrity errors upon saving.
|
||||
"""
|
||||
if memo is None:
|
||||
memo = {}
|
||||
|
||||
# Create a new instance of the class
|
||||
new_obj = self.__class__()
|
||||
memo[id(self)] = new_obj
|
||||
|
||||
# Copy all concrete fields from the original to the new object
|
||||
for field in self._meta.concrete_fields:
|
||||
# Skip the primary key to allow the database to generate a new one
|
||||
if field.primary_key:
|
||||
continue
|
||||
|
||||
# Reset any unique fields to None to avoid constraint violations
|
||||
if field.unique and field.name == "internal_id":
|
||||
setattr(new_obj, field.name, None)
|
||||
continue
|
||||
|
||||
# Copy the value of the field
|
||||
setattr(new_obj, field.name, getattr(self, field.name))
|
||||
|
||||
# Save the new object to the database to get a primary key
|
||||
new_obj.save()
|
||||
|
||||
# Copy the many-to-many relationships
|
||||
for field in self._meta.many_to_many:
|
||||
source_manager = getattr(self, field.name)
|
||||
destination_manager = getattr(new_obj, field.name)
|
||||
# Set the M2M relationships for the new object
|
||||
destination_manager.set(source_manager.all())
|
||||
|
||||
return new_obj
|
||||
|
||||
|
||||
class InstallmentPlan(models.Model):
|
||||
class Recurrence(models.TextChoices):
|
||||
@@ -574,6 +646,7 @@ class InstallmentPlan(models.Model):
|
||||
installment_plan=self,
|
||||
installment_id=i,
|
||||
notes=self.notes if self.add_notes_to_transaction else "",
|
||||
owner=self.account.owner,
|
||||
)
|
||||
new_transaction.tags.set(self.tags.all())
|
||||
new_transaction.entities.set(self.entities.all())
|
||||
@@ -640,6 +713,7 @@ class InstallmentPlan(models.Model):
|
||||
installment_plan=self,
|
||||
installment_id=i,
|
||||
notes=self.notes if self.add_notes_to_transaction else "",
|
||||
owner=self.account.owner,
|
||||
)
|
||||
new_transaction.tags.set(self.tags.all())
|
||||
new_transaction.entities.set(self.entities.all())
|
||||
@@ -707,6 +781,9 @@ class RecurringTransaction(models.Model):
|
||||
recurrence_interval = models.PositiveIntegerField(
|
||||
verbose_name=_("Recurrence Interval"),
|
||||
)
|
||||
keep_at_most = models.PositiveIntegerField(
|
||||
verbose_name=_("Keep at most"), default=6, validators=[MinValueValidator(1)]
|
||||
)
|
||||
|
||||
last_generated_date = models.DateField(
|
||||
verbose_name=_("Last Generated Date"), null=True, blank=True
|
||||
@@ -744,8 +821,10 @@ class RecurringTransaction(models.Model):
|
||||
current_date = self.start_date
|
||||
reference_date = self.reference_date
|
||||
end_date = min(
|
||||
self.end_date or timezone.now().date() + (self.get_recurrence_delta() * 5),
|
||||
timezone.now().date() + (self.get_recurrence_delta() * 5),
|
||||
self.end_date
|
||||
or timezone.now().date()
|
||||
+ (self.get_recurrence_delta() * self.keep_at_most),
|
||||
timezone.now().date() + (self.get_recurrence_delta() * self.keep_at_most),
|
||||
)
|
||||
|
||||
while current_date <= end_date:
|
||||
@@ -775,6 +854,7 @@ class RecurringTransaction(models.Model):
|
||||
is_paid=False,
|
||||
recurring_transaction=self,
|
||||
notes=self.notes if self.add_notes_to_transaction else "",
|
||||
owner=self.account.owner,
|
||||
)
|
||||
if self.tags.exists():
|
||||
created_transaction.tags.set(self.tags.all())
|
||||
@@ -797,12 +877,16 @@ class RecurringTransaction(models.Model):
|
||||
@classmethod
|
||||
def generate_upcoming_transactions(cls):
|
||||
today = timezone.now().date()
|
||||
recurring_transactions = cls.objects.filter(
|
||||
recurring_transactions = cls.all_objects.filter(
|
||||
Q(models.Q(end_date__isnull=True) | Q(end_date__gte=today))
|
||||
& Q(is_paused=False)
|
||||
)
|
||||
|
||||
for recurring_transaction in recurring_transactions:
|
||||
logger.info(
|
||||
f"Processing recurring transaction: {recurring_transaction.description}..."
|
||||
)
|
||||
|
||||
if recurring_transaction.last_generated_date:
|
||||
start_date = recurring_transaction.get_next_date(
|
||||
recurring_transaction.last_generated_date
|
||||
@@ -817,11 +901,22 @@ class RecurringTransaction(models.Model):
|
||||
current_date = start_date
|
||||
end_date = min(
|
||||
recurring_transaction.end_date
|
||||
or today + (recurring_transaction.get_recurrence_delta() * 6),
|
||||
today + (recurring_transaction.get_recurrence_delta() * 6),
|
||||
or today
|
||||
+ (
|
||||
recurring_transaction.get_recurrence_delta()
|
||||
* recurring_transaction.keep_at_most
|
||||
),
|
||||
today
|
||||
+ (
|
||||
recurring_transaction.get_recurrence_delta()
|
||||
* recurring_transaction.keep_at_most
|
||||
),
|
||||
)
|
||||
|
||||
logger.info(f"End date: {end_date}")
|
||||
|
||||
while current_date <= end_date:
|
||||
logger.info(f"Creating transaction for date: {current_date}")
|
||||
recurring_transaction.create_transaction(current_date, reference_date)
|
||||
current_date = recurring_transaction.get_next_date(current_date)
|
||||
reference_date = recurring_transaction.get_next_date(reference_date)
|
||||
@@ -869,3 +964,90 @@ class RecurringTransaction(models.Model):
|
||||
"""
|
||||
today = timezone.localdate(timezone.now())
|
||||
self.transactions.filter(is_paid=False, date__gt=today).delete()
|
||||
|
||||
|
||||
class QuickTransaction(OwnedObject):
|
||||
class Type(models.TextChoices):
|
||||
INCOME = "IN", _("Income")
|
||||
EXPENSE = "EX", _("Expense")
|
||||
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
null=False,
|
||||
blank=False,
|
||||
verbose_name=_("Name"),
|
||||
)
|
||||
|
||||
account = models.ForeignKey(
|
||||
"accounts.Account",
|
||||
on_delete=models.CASCADE,
|
||||
verbose_name=_("Account"),
|
||||
related_name="quick_transactions",
|
||||
)
|
||||
type = models.CharField(
|
||||
max_length=2,
|
||||
choices=Type,
|
||||
default=Type.EXPENSE,
|
||||
verbose_name=_("Type"),
|
||||
)
|
||||
is_paid = models.BooleanField(default=True, verbose_name=_("Paid"))
|
||||
mute = models.BooleanField(default=False, verbose_name=_("Mute"))
|
||||
|
||||
amount = models.DecimalField(
|
||||
max_digits=42,
|
||||
decimal_places=30,
|
||||
verbose_name=_("Amount"),
|
||||
validators=[validate_non_negative, validate_decimal_places],
|
||||
)
|
||||
|
||||
description = models.CharField(
|
||||
max_length=500, verbose_name=_("Description"), blank=True
|
||||
)
|
||||
notes = models.TextField(blank=True, verbose_name=_("Notes"))
|
||||
category = models.ForeignKey(
|
||||
TransactionCategory,
|
||||
on_delete=models.SET_NULL,
|
||||
verbose_name=_("Category"),
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
tags = models.ManyToManyField(
|
||||
TransactionTag,
|
||||
verbose_name=_("Tags"),
|
||||
blank=True,
|
||||
)
|
||||
entities = models.ManyToManyField(
|
||||
TransactionEntity,
|
||||
verbose_name=_("Entities"),
|
||||
blank=True,
|
||||
related_name="quick_transactions",
|
||||
)
|
||||
|
||||
internal_note = models.TextField(blank=True, verbose_name=_("Internal Note"))
|
||||
internal_id = models.TextField(
|
||||
blank=True, null=True, unique=True, verbose_name=_("Internal ID")
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
objects = OwnedObjectManager()
|
||||
all_objects = models.Manager() # Unfiltered manager
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Quick Transaction")
|
||||
verbose_name_plural = _("Quick Transactions")
|
||||
unique_together = ("name", "owner")
|
||||
db_table = "quick_transactions"
|
||||
default_manager_name = "objects"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.amount = truncate_decimal(
|
||||
value=self.amount, decimal_places=self.account.currency.decimal_places
|
||||
)
|
||||
|
||||
self.full_clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@@ -28,23 +28,18 @@ def generate_recurring_transactions(timestamp=None):
|
||||
@app.periodic(cron="10 1 * * *")
|
||||
@app.task(name="cleanup_deleted_transactions")
|
||||
def cleanup_deleted_transactions(timestamp=None):
|
||||
with cachalot_disabled():
|
||||
if settings.ENABLE_SOFT_DELETE and settings.KEEP_DELETED_TRANSACTIONS_FOR == 0:
|
||||
return "KEEP_DELETED_TRANSACTIONS_FOR is 0, no cleanup performed."
|
||||
if settings.ENABLE_SOFT_DELETE and settings.KEEP_DELETED_TRANSACTIONS_FOR == 0:
|
||||
return "KEEP_DELETED_TRANSACTIONS_FOR is 0, no cleanup performed."
|
||||
|
||||
if not settings.ENABLE_SOFT_DELETE:
|
||||
# Hard delete all soft-deleted transactions
|
||||
deleted_count, _ = Transaction.userless_deleted_objects.all().hard_delete()
|
||||
return (
|
||||
f"Hard deleted {deleted_count} transactions (soft deletion disabled)."
|
||||
)
|
||||
if not settings.ENABLE_SOFT_DELETE:
|
||||
# Hard delete all soft-deleted transactions
|
||||
deleted_count, _ = Transaction.userless_deleted_objects.all().hard_delete()
|
||||
return f"Hard deleted {deleted_count} transactions (soft deletion disabled)."
|
||||
|
||||
# Calculate the cutoff date
|
||||
cutoff_date = timezone.now() - timedelta(
|
||||
days=settings.KEEP_DELETED_TRANSACTIONS_FOR
|
||||
)
|
||||
|
||||
invalidate()
|
||||
# Calculate the cutoff date
|
||||
cutoff_date = timezone.now() - timedelta(
|
||||
days=settings.KEEP_DELETED_TRANSACTIONS_FOR
|
||||
)
|
||||
|
||||
# Hard delete soft-deleted transactions older than the cutoff date
|
||||
old_transactions = Transaction.userless_deleted_objects.filter(
|
||||
|
||||
@@ -175,6 +175,6 @@ class RecurringTransactionTests(TestCase):
|
||||
recurrence_type=RecurringTransaction.RecurrenceType.MONTH,
|
||||
recurrence_interval=1,
|
||||
)
|
||||
self.assertFalse(recurring.paused)
|
||||
self.assertFalse(recurring.is_paused)
|
||||
self.assertEqual(recurring.recurrence_interval, 1)
|
||||
self.assertEqual(recurring.account.currency.code, "USD")
|
||||
|
||||
@@ -66,6 +66,21 @@ urlpatterns = [
|
||||
views.transaction_pay,
|
||||
name="transaction_pay",
|
||||
),
|
||||
path(
|
||||
"transaction/<int:transaction_id>/mute/",
|
||||
views.transaction_mute,
|
||||
name="transaction_mute",
|
||||
),
|
||||
path(
|
||||
"transaction/<int:transaction_id>/change-month/<str:change_type>/",
|
||||
views.transaction_change_month,
|
||||
name="transaction_change_month",
|
||||
),
|
||||
path(
|
||||
"transaction/<int:transaction_id>/move-to-today/",
|
||||
views.transaction_move_to_today,
|
||||
name="transaction_move_to_today",
|
||||
),
|
||||
path(
|
||||
"transaction/<int:transaction_id>/delete/",
|
||||
views.transaction_delete,
|
||||
@@ -307,4 +322,44 @@ urlpatterns = [
|
||||
views.recurring_transaction_finish,
|
||||
name="recurring_transaction_finish",
|
||||
),
|
||||
path(
|
||||
"quick-transactions/",
|
||||
views.quick_transactions_index,
|
||||
name="quick_transactions_index",
|
||||
),
|
||||
path(
|
||||
"quick-transactions/list/",
|
||||
views.quick_transactions_list,
|
||||
name="quick_transactions_list",
|
||||
),
|
||||
path(
|
||||
"quick-transactions/add/",
|
||||
views.quick_transaction_add,
|
||||
name="quick_transaction_add",
|
||||
),
|
||||
path(
|
||||
"quick-transactions/<int:quick_transaction_id>/edit/",
|
||||
views.quick_transaction_edit,
|
||||
name="quick_transaction_edit",
|
||||
),
|
||||
path(
|
||||
"quick-transactions/<int:quick_transaction_id>/delete/",
|
||||
views.quick_transaction_delete,
|
||||
name="quick_transaction_delete",
|
||||
),
|
||||
path(
|
||||
"quick-transactions/create-menu/",
|
||||
views.quick_transactions_create_menu,
|
||||
name="quick_transactions_create_menu",
|
||||
),
|
||||
path(
|
||||
"quick-transactions/<int:quick_transaction_id>/create/",
|
||||
views.quick_transaction_add_as_transaction,
|
||||
name="quick_transaction_add_as_transaction",
|
||||
),
|
||||
path(
|
||||
"transactions/<int:transaction_id>/add-as-quick-transaction/",
|
||||
views.quick_transaction_add_as_quick_transaction,
|
||||
name="quick_transaction_add_as_quick_transaction",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -9,9 +9,11 @@ from apps.currencies.utils.convert import convert
|
||||
from apps.currencies.models import Currency
|
||||
|
||||
|
||||
def calculate_currency_totals(transactions_queryset, ignore_empty=False):
|
||||
def calculate_currency_totals(
|
||||
transactions_queryset, ignore_empty=False, deep_search=False
|
||||
):
|
||||
# Prepare the aggregation expressions
|
||||
currency_totals = (
|
||||
currency_totals_from_transactions = (
|
||||
transactions_queryset.values(
|
||||
"account__currency",
|
||||
"account__currency__code",
|
||||
@@ -19,7 +21,14 @@ def calculate_currency_totals(transactions_queryset, ignore_empty=False):
|
||||
"account__currency__decimal_places",
|
||||
"account__currency__prefix",
|
||||
"account__currency__suffix",
|
||||
"account__currency__exchange_currency",
|
||||
"account__currency__exchange_currency", # ID of the exchange currency for the account's currency
|
||||
# Fields for the exchange currency itself (if account.currency.exchange_currency is set)
|
||||
# These might be null if not set, so handle appropriately.
|
||||
"account__currency__exchange_currency__code",
|
||||
"account__currency__exchange_currency__name",
|
||||
"account__currency__exchange_currency__decimal_places",
|
||||
"account__currency__exchange_currency__prefix",
|
||||
"account__currency__exchange_currency__suffix",
|
||||
)
|
||||
.annotate(
|
||||
expense_current=Coalesce(
|
||||
@@ -72,36 +81,55 @@ def calculate_currency_totals(transactions_queryset, ignore_empty=False):
|
||||
.order_by()
|
||||
)
|
||||
|
||||
# First pass: Process basic totals and store all currency data
|
||||
result = {}
|
||||
currencies_using_exchange = (
|
||||
{}
|
||||
) # Track which currencies use which exchange currencies
|
||||
# currencies_using_exchange maps:
|
||||
# exchange_currency_id -> list of [
|
||||
# { "currency_id": original_currency_id, (the currency that was exchanged FROM)
|
||||
# "exchanged": { field: amount_in_exchange_currency, ... } (the values of original_currency_id converted TO exchange_currency_id)
|
||||
# }
|
||||
# ]
|
||||
currencies_using_exchange = {}
|
||||
|
||||
for total in currency_totals:
|
||||
# Skip empty currencies if ignore_empty is True
|
||||
if ignore_empty and all(
|
||||
total[field] == Decimal("0")
|
||||
for field in [
|
||||
"expense_current",
|
||||
"expense_projected",
|
||||
"income_current",
|
||||
"income_projected",
|
||||
]
|
||||
# --- First Pass: Process transactions from the queryset ---
|
||||
for total in currency_totals_from_transactions:
|
||||
if (
|
||||
ignore_empty
|
||||
and not deep_search
|
||||
and all(
|
||||
total[field] == Decimal("0")
|
||||
for field in [
|
||||
"expense_current",
|
||||
"expense_projected",
|
||||
"income_current",
|
||||
"income_projected",
|
||||
]
|
||||
)
|
||||
):
|
||||
continue
|
||||
|
||||
# Calculate derived totals
|
||||
currency_id = total["account__currency"]
|
||||
try:
|
||||
from_currency_obj = Currency.objects.get(id=currency_id)
|
||||
except Currency.DoesNotExist:
|
||||
# This should ideally not happen if database is consistent
|
||||
continue
|
||||
|
||||
exchange_currency_for_this_total_id = total[
|
||||
"account__currency__exchange_currency"
|
||||
]
|
||||
exchange_currency_obj_for_this_total = None
|
||||
if exchange_currency_for_this_total_id:
|
||||
try:
|
||||
# Use pre-fetched values if available, otherwise query
|
||||
exchange_currency_obj_for_this_total = Currency.objects.get(
|
||||
id=exchange_currency_for_this_total_id
|
||||
)
|
||||
except Currency.DoesNotExist:
|
||||
pass # Exchange currency might not exist or be set
|
||||
|
||||
total_current = total["income_current"] - total["expense_current"]
|
||||
total_projected = total["income_projected"] - total["expense_projected"]
|
||||
total_final = total_current + total_projected
|
||||
currency_id = total["account__currency"]
|
||||
from_currency = Currency.objects.get(id=currency_id)
|
||||
exchange_currency = (
|
||||
Currency.objects.get(id=total["account__currency__exchange_currency"])
|
||||
if total["account__currency__exchange_currency"]
|
||||
else None
|
||||
)
|
||||
|
||||
currency_data = {
|
||||
"currency": {
|
||||
@@ -120,9 +148,16 @@ def calculate_currency_totals(transactions_queryset, ignore_empty=False):
|
||||
"total_final": total_final,
|
||||
}
|
||||
|
||||
# Add exchanged values if exchange_currency exists
|
||||
if exchange_currency:
|
||||
exchanged = {}
|
||||
if exchange_currency_obj_for_this_total:
|
||||
exchanged_details = {
|
||||
"currency": {
|
||||
"code": exchange_currency_obj_for_this_total.code,
|
||||
"name": exchange_currency_obj_for_this_total.name,
|
||||
"decimal_places": exchange_currency_obj_for_this_total.decimal_places,
|
||||
"prefix": exchange_currency_obj_for_this_total.prefix,
|
||||
"suffix": exchange_currency_obj_for_this_total.suffix,
|
||||
}
|
||||
}
|
||||
for field in [
|
||||
"expense_current",
|
||||
"expense_projected",
|
||||
@@ -132,50 +167,142 @@ def calculate_currency_totals(transactions_queryset, ignore_empty=False):
|
||||
"total_projected",
|
||||
"total_final",
|
||||
]:
|
||||
amount, prefix, suffix, decimal_places = convert(
|
||||
amount=currency_data[field],
|
||||
from_currency=from_currency,
|
||||
to_currency=exchange_currency,
|
||||
amount_to_convert = currency_data[field]
|
||||
converted_val, _, _, _ = convert(
|
||||
amount=amount_to_convert,
|
||||
from_currency=from_currency_obj,
|
||||
to_currency=exchange_currency_obj_for_this_total,
|
||||
)
|
||||
exchanged_details[field] = (
|
||||
converted_val if converted_val is not None else Decimal("0")
|
||||
)
|
||||
if amount is not None:
|
||||
exchanged[field] = amount
|
||||
if "currency" not in exchanged:
|
||||
exchanged["currency"] = {
|
||||
"prefix": prefix,
|
||||
"suffix": suffix,
|
||||
"decimal_places": decimal_places,
|
||||
"code": exchange_currency.code,
|
||||
"name": exchange_currency.name,
|
||||
}
|
||||
|
||||
if exchanged:
|
||||
currency_data["exchanged"] = exchanged
|
||||
# Track which currencies are using which exchange currencies
|
||||
if exchange_currency.id not in currencies_using_exchange:
|
||||
currencies_using_exchange[exchange_currency.id] = []
|
||||
currencies_using_exchange[exchange_currency.id].append(
|
||||
{"currency_id": currency_id, "exchanged": exchanged}
|
||||
)
|
||||
currency_data["exchanged"] = exchanged_details
|
||||
|
||||
if exchange_currency_obj_for_this_total.id not in currencies_using_exchange:
|
||||
currencies_using_exchange[exchange_currency_obj_for_this_total.id] = []
|
||||
currencies_using_exchange[exchange_currency_obj_for_this_total.id].append(
|
||||
{"currency_id": currency_id, "exchanged": exchanged_details}
|
||||
)
|
||||
|
||||
result[currency_id] = currency_data
|
||||
|
||||
# Second pass: Add consolidated totals for currencies that are used as exchange currencies
|
||||
for currency_id, currency_data in result.items():
|
||||
if currency_id in currencies_using_exchange:
|
||||
consolidated = {
|
||||
"currency": currency_data["currency"].copy(),
|
||||
"expense_current": currency_data["expense_current"],
|
||||
"expense_projected": currency_data["expense_projected"],
|
||||
"income_current": currency_data["income_current"],
|
||||
"income_projected": currency_data["income_projected"],
|
||||
"total_current": currency_data["total_current"],
|
||||
"total_projected": currency_data["total_projected"],
|
||||
"total_final": currency_data["total_final"],
|
||||
}
|
||||
# --- Deep Search: Add transaction-less currencies that are exchange targets ---
|
||||
if deep_search:
|
||||
# Iteratively add exchange targets that might not have had direct transactions
|
||||
# Start with known exchange targets from the first pass
|
||||
queue = list(currencies_using_exchange.keys())
|
||||
processed_for_deep_add = set(
|
||||
result.keys()
|
||||
) # Track currencies already in result or added by this deep search step
|
||||
|
||||
# Add exchanged values from all currencies using this as exchange currency
|
||||
for using_currency in currencies_using_exchange[currency_id]:
|
||||
exchanged = using_currency["exchanged"]
|
||||
while queue:
|
||||
target_id = queue.pop(0)
|
||||
if target_id in processed_for_deep_add:
|
||||
continue
|
||||
processed_for_deep_add.add(target_id)
|
||||
|
||||
if (
|
||||
target_id not in result
|
||||
): # If this exchange target had no direct transactions
|
||||
try:
|
||||
db_currency = Currency.objects.get(id=target_id)
|
||||
except Currency.DoesNotExist:
|
||||
continue
|
||||
|
||||
# Initialize data for this transaction-less exchange target currency
|
||||
currency_data_for_db_currency = {
|
||||
"currency": {
|
||||
"code": db_currency.code,
|
||||
"name": db_currency.name,
|
||||
"decimal_places": db_currency.decimal_places,
|
||||
"prefix": db_currency.prefix,
|
||||
"suffix": db_currency.suffix,
|
||||
},
|
||||
"expense_current": Decimal("0"),
|
||||
"expense_projected": Decimal("0"),
|
||||
"income_current": Decimal("0"),
|
||||
"income_projected": Decimal("0"),
|
||||
"total_current": Decimal("0"),
|
||||
"total_projected": Decimal("0"),
|
||||
"total_final": Decimal("0"),
|
||||
}
|
||||
|
||||
# If this newly added transaction-less currency ALSO has an exchange_currency set for itself
|
||||
if db_currency.exchange_currency:
|
||||
exchanged_details_for_db_currency = {
|
||||
"currency": {
|
||||
"code": db_currency.exchange_currency.code,
|
||||
"name": db_currency.exchange_currency.name,
|
||||
"decimal_places": db_currency.exchange_currency.decimal_places,
|
||||
"prefix": db_currency.exchange_currency.prefix,
|
||||
"suffix": db_currency.exchange_currency.suffix,
|
||||
}
|
||||
}
|
||||
for field in [
|
||||
"expense_current",
|
||||
"expense_projected",
|
||||
"income_current",
|
||||
"income_projected",
|
||||
"total_current",
|
||||
"total_projected",
|
||||
"total_final",
|
||||
]:
|
||||
converted_val, _, _, _ = convert(
|
||||
Decimal("0"), db_currency, db_currency.exchange_currency
|
||||
)
|
||||
exchanged_details_for_db_currency[field] = (
|
||||
converted_val if converted_val is not None else Decimal("0")
|
||||
)
|
||||
|
||||
currency_data_for_db_currency["exchanged"] = (
|
||||
exchanged_details_for_db_currency
|
||||
)
|
||||
|
||||
# Ensure its own exchange_currency is registered in currencies_using_exchange
|
||||
# and add it to the queue if it hasn't been processed yet for deep add.
|
||||
target_id_for_this_db_curr = db_currency.exchange_currency.id
|
||||
if target_id_for_this_db_curr not in currencies_using_exchange:
|
||||
currencies_using_exchange[target_id_for_this_db_curr] = []
|
||||
|
||||
# Avoid adding duplicate entries
|
||||
already_present_in_cue = any(
|
||||
entry["currency_id"] == db_currency.id
|
||||
for entry in currencies_using_exchange[
|
||||
target_id_for_this_db_curr
|
||||
]
|
||||
)
|
||||
if not already_present_in_cue:
|
||||
currencies_using_exchange[target_id_for_this_db_curr].append(
|
||||
{
|
||||
"currency_id": db_currency.id,
|
||||
"exchanged": exchanged_details_for_db_currency,
|
||||
}
|
||||
)
|
||||
|
||||
if target_id_for_this_db_curr not in processed_for_deep_add:
|
||||
queue.append(target_id_for_this_db_curr)
|
||||
|
||||
result[db_currency.id] = currency_data_for_db_currency
|
||||
|
||||
# --- Second Pass: Calculate consolidated totals for all currencies in result ---
|
||||
for currency_id_consolidated, data_consolidated_currency in result.items():
|
||||
consolidated_data = {
|
||||
"currency": data_consolidated_currency["currency"].copy(),
|
||||
"expense_current": data_consolidated_currency["expense_current"],
|
||||
"expense_projected": data_consolidated_currency["expense_projected"],
|
||||
"income_current": data_consolidated_currency["income_current"],
|
||||
"income_projected": data_consolidated_currency["income_projected"],
|
||||
"total_current": data_consolidated_currency["total_current"],
|
||||
"total_projected": data_consolidated_currency["total_projected"],
|
||||
"total_final": data_consolidated_currency["total_final"],
|
||||
}
|
||||
|
||||
if currency_id_consolidated in currencies_using_exchange:
|
||||
for original_currency_info in currencies_using_exchange[
|
||||
currency_id_consolidated
|
||||
]:
|
||||
exchanged_values_from_original = original_currency_info["exchanged"]
|
||||
for field in [
|
||||
"expense_current",
|
||||
"expense_projected",
|
||||
@@ -185,10 +312,25 @@ def calculate_currency_totals(transactions_queryset, ignore_empty=False):
|
||||
"total_projected",
|
||||
"total_final",
|
||||
]:
|
||||
if field in exchanged:
|
||||
consolidated[field] += exchanged[field]
|
||||
if field in exchanged_values_from_original:
|
||||
consolidated_data[field] += exchanged_values_from_original[
|
||||
field
|
||||
]
|
||||
|
||||
result[currency_id]["consolidated"] = consolidated
|
||||
result[currency_id_consolidated]["consolidated"] = consolidated_data
|
||||
|
||||
# Sort currencies by their final_total or consolidated final_total, descending
|
||||
result = {
|
||||
k: v
|
||||
for k, v in sorted(
|
||||
result.items(),
|
||||
reverse=True,
|
||||
key=lambda item: max(
|
||||
item[1].get("total_final", Decimal("0")),
|
||||
item[1].get("consolidated", {}).get("total_final", Decimal("0")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -5,3 +5,4 @@ from .categories import *
|
||||
from .actions import *
|
||||
from .installment_plans import *
|
||||
from .recurring_transactions import *
|
||||
from .quick_transactions import *
|
||||
|
||||
@@ -62,7 +62,7 @@ def bulk_unpay_transactions(request):
|
||||
@login_required
|
||||
def bulk_delete_transactions(request):
|
||||
selected_transactions = request.GET.getlist("transactions", [])
|
||||
transactions = Transaction.all_objects.filter(id__in=selected_transactions)
|
||||
transactions = Transaction.objects.filter(id__in=selected_transactions)
|
||||
count = transactions.count()
|
||||
transactions.delete()
|
||||
|
||||
|
||||
229
app/apps/transactions/views/quick_transactions.py
Normal file
229
app/apps/transactions/views/quick_transactions.py
Normal file
@@ -0,0 +1,229 @@
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.forms import model_to_dict
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import render, get_object_or_404
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
from apps.transactions.forms import QuickTransactionForm
|
||||
from apps.transactions.models import QuickTransaction, transaction_created
|
||||
from apps.transactions.models import Transaction
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def quick_transactions_index(request):
|
||||
return render(
|
||||
request,
|
||||
"quick_transactions/pages/index.html",
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def quick_transactions_list(request):
|
||||
quick_transactions = QuickTransaction.objects.all().order_by("name")
|
||||
return render(
|
||||
request,
|
||||
"quick_transactions/fragments/list.html",
|
||||
context={"quick_transactions": quick_transactions},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def quick_transaction_add(request):
|
||||
if request.method == "POST":
|
||||
form = QuickTransactionForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Item added successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = QuickTransactionForm()
|
||||
|
||||
return render(
|
||||
request,
|
||||
"quick_transactions/fragments/add.html",
|
||||
{"form": form},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def quick_transaction_edit(request, quick_transaction_id):
|
||||
quick_transaction = get_object_or_404(QuickTransaction, id=quick_transaction_id)
|
||||
|
||||
if request.method == "POST":
|
||||
form = QuickTransactionForm(request.POST, instance=quick_transaction)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Item updated successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = QuickTransactionForm(instance=quick_transaction)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"quick_transactions/fragments/edit.html",
|
||||
{"form": form, "quick_transaction": quick_transaction},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["DELETE"])
|
||||
def quick_transaction_delete(request, quick_transaction_id):
|
||||
quick_transaction = get_object_or_404(QuickTransaction, id=quick_transaction_id)
|
||||
|
||||
quick_transaction.delete()
|
||||
|
||||
messages.success(request, _("Item deleted successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def quick_transactions_create_menu(request):
|
||||
quick_transactions = QuickTransaction.objects.all().order_by("name")
|
||||
return render(
|
||||
request,
|
||||
"quick_transactions/fragments/create_menu.html",
|
||||
context={"quick_transactions": quick_transactions},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def quick_transaction_add_as_transaction(request, quick_transaction_id):
|
||||
quick_transaction: QuickTransaction = get_object_or_404(
|
||||
QuickTransaction, id=quick_transaction_id
|
||||
)
|
||||
today = timezone.localdate(timezone.now())
|
||||
|
||||
quick_transaction_data = model_to_dict(
|
||||
quick_transaction,
|
||||
exclude=[
|
||||
"id",
|
||||
"name",
|
||||
"owner",
|
||||
"account",
|
||||
"category",
|
||||
"tags",
|
||||
"entities",
|
||||
],
|
||||
)
|
||||
|
||||
new_transaction = Transaction(**quick_transaction_data)
|
||||
new_transaction.account = quick_transaction.account
|
||||
new_transaction.category = quick_transaction.category
|
||||
|
||||
new_transaction.date = today
|
||||
new_transaction.reference_date = today.replace(day=1)
|
||||
new_transaction.save()
|
||||
new_transaction.tags.set(quick_transaction.tags.all())
|
||||
new_transaction.entities.set(quick_transaction.entities.all())
|
||||
|
||||
transaction_created.send(sender=new_transaction)
|
||||
|
||||
messages.success(request, _("Transaction added successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def quick_transaction_add_as_quick_transaction(request, transaction_id):
|
||||
transaction: Transaction = get_object_or_404(Transaction, pk=transaction_id)
|
||||
|
||||
if (
|
||||
transaction.description
|
||||
and QuickTransaction.objects.filter(
|
||||
name__startswith=transaction.description
|
||||
).exists()
|
||||
) or QuickTransaction.objects.filter(
|
||||
name__startswith=_("Quick Transaction")
|
||||
).exists():
|
||||
if transaction.description:
|
||||
count = QuickTransaction.objects.filter(
|
||||
name__startswith=transaction.description
|
||||
).count()
|
||||
qt_name = transaction.description + f" ({count + 1})"
|
||||
else:
|
||||
count = QuickTransaction.objects.filter(
|
||||
name__startswith=_("Quick Transaction")
|
||||
).count()
|
||||
qt_name = _("Quick Transaction") + f" ({count + 1})"
|
||||
else:
|
||||
qt_name = transaction.description or _("Quick Transaction")
|
||||
|
||||
transaction_data = model_to_dict(
|
||||
transaction,
|
||||
exclude=[
|
||||
"id",
|
||||
"name",
|
||||
"owner",
|
||||
"account",
|
||||
"category",
|
||||
"tags",
|
||||
"entities",
|
||||
"date",
|
||||
"reference_date",
|
||||
"installment_plan",
|
||||
"installment_id",
|
||||
"recurring_transaction",
|
||||
"deleted",
|
||||
"deleted_at",
|
||||
],
|
||||
)
|
||||
|
||||
new_quick_transaction = QuickTransaction(**transaction_data)
|
||||
new_quick_transaction.account = transaction.account
|
||||
new_quick_transaction.category = transaction.category
|
||||
|
||||
new_quick_transaction.name = qt_name
|
||||
|
||||
new_quick_transaction.save()
|
||||
new_quick_transaction.tags.set(transaction.tags.all())
|
||||
new_quick_transaction.entities.set(transaction.entities.all())
|
||||
|
||||
messages.success(request, _("Item added successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "toasts",
|
||||
},
|
||||
)
|
||||
@@ -1,10 +1,11 @@
|
||||
import datetime
|
||||
from copy import deepcopy
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.paginator import Paginator
|
||||
from django.db.models import Q
|
||||
from django.db.models import Q, When, Case, Value, IntegerField
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from django.shortcuts import render, get_object_or_404
|
||||
from django.utils import timezone
|
||||
@@ -43,16 +44,71 @@ def transaction_add(request):
|
||||
year=year,
|
||||
).date()
|
||||
|
||||
update = False
|
||||
|
||||
if request.method == "POST":
|
||||
form = TransactionForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
saved_instance = form.save()
|
||||
messages.success(request, _("Transaction added successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={"HX-Trigger": "updated, hide_offcanvas"},
|
||||
)
|
||||
if "submit" in request.POST:
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={"HX-Trigger": "updated, hide_offcanvas"},
|
||||
)
|
||||
elif "submit_and_another" in request.POST:
|
||||
form = TransactionForm(
|
||||
initial={
|
||||
"date": expected_date,
|
||||
"type": transaction_type,
|
||||
},
|
||||
)
|
||||
update = True
|
||||
elif "submit_and_similar" in request.POST:
|
||||
initial_data = {}
|
||||
|
||||
# Define fields to copy from the SAVED instance
|
||||
direct_fields_to_copy = [
|
||||
"account", # ForeignKey -> will copy the ID
|
||||
"type", # ChoiceField -> will copy the value
|
||||
"is_paid", # BooleanField -> will copy True/False
|
||||
"date", # DateField -> will copy the date object
|
||||
"reference_date", # DateField -> will copy the date object
|
||||
"amount", # DecimalField -> will copy the decimal
|
||||
"description", # CharField -> will copy the string
|
||||
"notes", # TextField -> will copy the string
|
||||
"category", # ForeignKey -> will copy the ID
|
||||
]
|
||||
m2m_fields_to_copy = [
|
||||
"tags", # ManyToManyField -> will copy list of IDs
|
||||
"entities", # ManyToManyField -> will copy list of IDs
|
||||
]
|
||||
|
||||
# Copy direct fields from the saved instance
|
||||
for field_name in direct_fields_to_copy:
|
||||
value = getattr(saved_instance, field_name, None)
|
||||
if value is not None:
|
||||
# Handle ForeignKey: use the pk
|
||||
if hasattr(value, "pk"):
|
||||
initial_data[field_name] = value.pk
|
||||
# Handle Date/DateTime/Decimal/Boolean/etc.: use the Python object directly
|
||||
else:
|
||||
initial_data[field_name] = (
|
||||
value # This correctly handles date objects!
|
||||
)
|
||||
|
||||
# Copy M2M fields: provide a list of related object pks
|
||||
for field_name in m2m_fields_to_copy:
|
||||
m2m_manager = getattr(saved_instance, field_name)
|
||||
initial_data[field_name] = list(
|
||||
m2m_manager.values_list("name", flat=True)
|
||||
)
|
||||
|
||||
# Create a new form instance pre-filled with the correctly typed initial data
|
||||
form = TransactionForm(initial=initial_data)
|
||||
update = True # Signal HTMX to update the form area
|
||||
|
||||
else:
|
||||
form = TransactionForm(
|
||||
initial={
|
||||
@@ -61,11 +117,15 @@ def transaction_add(request):
|
||||
},
|
||||
)
|
||||
|
||||
return render(
|
||||
response = render(
|
||||
request,
|
||||
"transactions/fragments/add.html",
|
||||
{"form": form},
|
||||
)
|
||||
if update:
|
||||
response["HX-Trigger"] = "updated"
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -153,6 +213,7 @@ def transactions_bulk_edit(request):
|
||||
if form.is_valid():
|
||||
# Apply changes from the form to all selected transactions
|
||||
for transaction in transactions:
|
||||
old_data = deepcopy(transaction)
|
||||
for field_name, value in form.cleaned_data.items():
|
||||
if value or isinstance(
|
||||
value, bool
|
||||
@@ -165,7 +226,7 @@ def transactions_bulk_edit(request):
|
||||
setattr(transaction, field_name, value)
|
||||
|
||||
transaction.save()
|
||||
transaction_updated.send(sender=transaction)
|
||||
transaction_updated.send(sender=transaction, old_data=old_data)
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
@@ -313,10 +374,13 @@ def transactions_transfer(request):
|
||||
@require_http_methods(["GET"])
|
||||
def transaction_pay(request, transaction_id):
|
||||
transaction = get_object_or_404(Transaction, pk=transaction_id)
|
||||
old_data = deepcopy(transaction)
|
||||
|
||||
new_is_paid = False if transaction.is_paid else True
|
||||
transaction.is_paid = new_is_paid
|
||||
transaction.save()
|
||||
transaction_updated.send(sender=transaction)
|
||||
|
||||
transaction_updated.send(sender=transaction, old_data=old_data)
|
||||
|
||||
response = render(
|
||||
request,
|
||||
@@ -329,6 +393,71 @@ def transaction_pay(request, transaction_id):
|
||||
return response
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def transaction_mute(request, transaction_id):
|
||||
transaction = get_object_or_404(Transaction, pk=transaction_id)
|
||||
old_data = deepcopy(transaction)
|
||||
|
||||
new_mute = False if transaction.mute else True
|
||||
transaction.mute = new_mute
|
||||
transaction.save()
|
||||
transaction_updated.send(sender=transaction, old_data=old_data)
|
||||
|
||||
response = render(
|
||||
request,
|
||||
"transactions/fragments/item.html",
|
||||
context={"transaction": transaction, **request.GET},
|
||||
)
|
||||
response.headers["HX-Trigger"] = "selective_update"
|
||||
return response
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def transaction_change_month(request, transaction_id, change_type):
|
||||
transaction: Transaction = get_object_or_404(Transaction, pk=transaction_id)
|
||||
old_data = deepcopy(transaction)
|
||||
|
||||
if change_type == "next":
|
||||
transaction.reference_date = transaction.reference_date + relativedelta(
|
||||
months=1
|
||||
)
|
||||
transaction.save()
|
||||
transaction_updated.send(sender=transaction, old_data=old_data)
|
||||
elif change_type == "previous":
|
||||
transaction.reference_date = transaction.reference_date - relativedelta(
|
||||
months=1
|
||||
)
|
||||
transaction.save()
|
||||
transaction_updated.send(sender=transaction, old_data=old_data)
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={"HX-Trigger": "updated"},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def transaction_move_to_today(request, transaction_id):
|
||||
transaction: Transaction = get_object_or_404(Transaction, pk=transaction_id)
|
||||
|
||||
old_data = deepcopy(transaction)
|
||||
|
||||
transaction.date = timezone.localdate(timezone.now())
|
||||
transaction.save()
|
||||
transaction_updated.send(sender=transaction, old_data=old_data)
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={"HX-Trigger": "updated"},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def transaction_all_index(request):
|
||||
@@ -468,7 +597,10 @@ def transaction_all_currency_summary(request):
|
||||
|
||||
f = TransactionsFilter(request.GET, queryset=transactions)
|
||||
|
||||
currency_data = calculate_currency_totals(f.qs.all(), ignore_empty=True)
|
||||
currency_data = calculate_currency_totals(
|
||||
f.qs.exclude(account__in=request.user.untracked_accounts.all()),
|
||||
ignore_empty=True,
|
||||
)
|
||||
currency_percentages = calculate_percentage_distribution(currency_data)
|
||||
|
||||
context = {
|
||||
@@ -527,11 +659,26 @@ def get_recent_transactions(request, filter_type=None):
|
||||
# Get search term from query params
|
||||
search_term = request.GET.get("q", "").strip()
|
||||
|
||||
today = timezone.localdate(timezone.now())
|
||||
yesterday = today - timezone.timedelta(days=1)
|
||||
tomorrow = today + timezone.timedelta(days=1)
|
||||
|
||||
# Base queryset with selected fields
|
||||
queryset = (
|
||||
Transaction.objects.filter(deleted=False)
|
||||
.annotate(
|
||||
date_order=Case(
|
||||
When(date=today, then=Value(0)),
|
||||
When(date=tomorrow, then=Value(1)),
|
||||
When(date=yesterday, then=Value(2)),
|
||||
When(date__gt=tomorrow, then=Value(3)),
|
||||
When(date__lt=yesterday, then=Value(4)),
|
||||
default=Value(5),
|
||||
output_field=IntegerField(),
|
||||
)
|
||||
)
|
||||
.select_related("account", "category")
|
||||
.order_by("-created_at")
|
||||
.order_by("date_order", "date", "id")
|
||||
)
|
||||
|
||||
if filter_type:
|
||||
|
||||
@@ -2,16 +2,20 @@ from crispy_forms.bootstrap import (
|
||||
FormActions,
|
||||
)
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, Submit
|
||||
from crispy_forms.layout import Layout, Submit, Row, Column, Field, Div, HTML
|
||||
from django import forms
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.forms import (
|
||||
UsernameField,
|
||||
AuthenticationForm,
|
||||
UserCreationForm,
|
||||
)
|
||||
from django.db import transaction
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
from apps.users.models import UserSettings
|
||||
from apps.common.middleware.thread_local import get_current_user
|
||||
|
||||
|
||||
class LoginForm(AuthenticationForm):
|
||||
@@ -41,7 +45,7 @@ class LoginForm(AuthenticationForm):
|
||||
self.helper.layout = Layout(
|
||||
"username",
|
||||
"password",
|
||||
Submit("Submit", "Login"),
|
||||
Submit("Submit", "Login", css_class="btn btn-primary w-100"),
|
||||
)
|
||||
|
||||
|
||||
@@ -85,6 +89,8 @@ class UserSettingsForm(forms.ModelForm):
|
||||
("AA", _("Default")),
|
||||
("DC", "1.234,50"),
|
||||
("CD", "1,234.50"),
|
||||
("SD", "1 234.50"),
|
||||
("SC", "1 234,50"),
|
||||
]
|
||||
|
||||
date_format = forms.ChoiceField(
|
||||
@@ -111,6 +117,7 @@ class UserSettingsForm(forms.ModelForm):
|
||||
"date_format",
|
||||
"datetime_format",
|
||||
"number_format",
|
||||
"volume",
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -122,13 +129,283 @@ class UserSettingsForm(forms.ModelForm):
|
||||
self.helper.layout = Layout(
|
||||
"language",
|
||||
"timezone",
|
||||
HTML("<hr />"),
|
||||
"date_format",
|
||||
"datetime_format",
|
||||
"number_format",
|
||||
HTML("<hr />"),
|
||||
"start_page",
|
||||
HTML("<hr />"),
|
||||
"volume",
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Save"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
self.fields["language"].help_text = _(
|
||||
"This changes the language (if available) and how numbers and dates are displayed\n"
|
||||
"Consider helping translate WYGIWYH to your language at %(translation_link)s"
|
||||
) % {
|
||||
"translation_link": '<a href="https://translations.herculino.com" target="_blank">translations.herculino.com</a>'
|
||||
}
|
||||
|
||||
|
||||
class UserUpdateForm(forms.ModelForm):
|
||||
new_password1 = forms.CharField(
|
||||
label=_("New Password"),
|
||||
widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}),
|
||||
required=False,
|
||||
help_text=_("Leave blank to keep the current password."),
|
||||
)
|
||||
new_password2 = forms.CharField(
|
||||
label=_("Confirm New Password"),
|
||||
widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}),
|
||||
required=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = get_user_model()
|
||||
# Add the administrative fields
|
||||
fields = ["first_name", "last_name", "email", "is_active", "is_superuser"]
|
||||
# Help texts can be defined here or directly in the layout/field definition
|
||||
help_texts = {
|
||||
"is_active": _(
|
||||
"Designates whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
),
|
||||
"is_superuser": _(
|
||||
"Designates that this user has all permissions without explicitly assigning them."
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.instance = kwargs.get("instance") # Store instance for validation/checks
|
||||
self.requesting_user = get_current_user()
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.form_method = "post"
|
||||
|
||||
# Define the layout using Crispy Forms, including the new fields
|
||||
self.helper.layout = Layout(
|
||||
Row(
|
||||
Column("first_name", css_class="form-group col-md-6"),
|
||||
Column("last_name", css_class="form-group col-md-6"),
|
||||
css_class="row",
|
||||
),
|
||||
Field("email"),
|
||||
# Group password fields (optional visual grouping)
|
||||
Div(
|
||||
Field("new_password1"),
|
||||
Field("new_password2"),
|
||||
css_class="border p-3 rounded mb-3",
|
||||
),
|
||||
# Group administrative status fields
|
||||
Div(
|
||||
Field("is_active"),
|
||||
Field("is_superuser"),
|
||||
css_class="border p-3 rounded mb-3 text-bg-secondary", # Example visual separation
|
||||
),
|
||||
)
|
||||
|
||||
if self.instance and self.instance.pk:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
),
|
||||
)
|
||||
else:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
if (
|
||||
self.requesting_user == self.instance
|
||||
or not self.requesting_user.is_superuser
|
||||
):
|
||||
self.fields["is_superuser"].disabled = True
|
||||
self.fields["is_active"].disabled = True
|
||||
|
||||
# Keep existing clean methods
|
||||
def clean_email(self):
|
||||
email = self.cleaned_data.get("email")
|
||||
# Use case-insensitive comparison for email uniqueness check
|
||||
if (
|
||||
self.instance
|
||||
and get_user_model()
|
||||
.objects.filter(email__iexact=email)
|
||||
.exclude(pk=self.instance.pk)
|
||||
.exists()
|
||||
):
|
||||
raise forms.ValidationError(
|
||||
_("This email address is already in use by another account.")
|
||||
)
|
||||
return email
|
||||
|
||||
def clean_new_password2(self):
|
||||
new_password1 = self.cleaned_data.get("new_password1")
|
||||
new_password2 = self.cleaned_data.get("new_password2")
|
||||
if new_password1 and new_password1 != new_password2:
|
||||
raise forms.ValidationError(_("The two password fields didn't match."))
|
||||
if new_password1 and not new_password2:
|
||||
raise forms.ValidationError(_("Please confirm your new password."))
|
||||
if new_password2 and not new_password1:
|
||||
raise forms.ValidationError(_("Please enter the new password first."))
|
||||
return new_password2
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
is_active_val = cleaned_data.get("is_active")
|
||||
is_superuser_val = cleaned_data.get("is_superuser")
|
||||
|
||||
# --- Crucial Security Check Example ---
|
||||
# Prevent the requesting user from deactivating or removing superuser status
|
||||
# from their *own* account via this form.
|
||||
if (
|
||||
self.requesting_user
|
||||
and self.instance
|
||||
and self.requesting_user.pk == self.instance.pk
|
||||
):
|
||||
# Check if 'is_active' field exists and user is trying to set it to False
|
||||
if "is_active" in self.fields and is_active_val is False:
|
||||
self.add_error(
|
||||
"is_active",
|
||||
_("You cannot deactivate your own account using this form."),
|
||||
)
|
||||
|
||||
# Check if 'is_superuser' field exists, the user *is* currently a superuser,
|
||||
# and they are trying to set it to False
|
||||
if (
|
||||
"is_superuser" in self.fields
|
||||
and self.instance.is_superuser
|
||||
and is_superuser_val is False
|
||||
):
|
||||
if get_user_model().objects.filter(is_superuser=True).count() <= 1:
|
||||
self.add_error(
|
||||
"is_superuser",
|
||||
_("Cannot remove status from the last superuser."),
|
||||
)
|
||||
else:
|
||||
self.add_error(
|
||||
"is_superuser",
|
||||
_(
|
||||
"You cannot remove your own superuser status using this form."
|
||||
),
|
||||
)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
# Save method remains the same, ModelForm handles boolean fields correctly
|
||||
def save(self, commit=True):
|
||||
user = super().save(commit=False)
|
||||
new_password = self.cleaned_data.get("new_password1")
|
||||
if new_password:
|
||||
user.set_password(new_password)
|
||||
|
||||
if commit:
|
||||
user.save()
|
||||
return user
|
||||
|
||||
|
||||
class UserAddForm(UserCreationForm):
|
||||
"""
|
||||
A form for administrators to create new users.
|
||||
Includes fields for first name, last name, email, active status,
|
||||
and superuser status. Uses email as the username field.
|
||||
Inherits password handling from UserCreationForm.
|
||||
"""
|
||||
|
||||
class Meta(UserCreationForm.Meta):
|
||||
model = get_user_model()
|
||||
# Specify the fields to include. UserCreationForm automatically handles
|
||||
# 'password1' and 'password2'. We replace 'username' with 'email'.
|
||||
fields = ("email", "first_name", "last_name", "is_active", "is_superuser")
|
||||
field_classes = {
|
||||
"email": forms.EmailField
|
||||
} # Ensure email field uses EmailField validation
|
||||
help_texts = {
|
||||
"is_active": _(
|
||||
"Designates whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
),
|
||||
"is_superuser": _(
|
||||
"Designates that this user has all permissions without explicitly assigning them."
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Set is_active to True by default for new users, can be overridden by admin
|
||||
self.fields["is_active"].initial = True
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.form_method = "post"
|
||||
|
||||
# Define the layout, including password fields from UserCreationForm
|
||||
self.helper.layout = Layout(
|
||||
Field("email"),
|
||||
Row(
|
||||
Column("first_name", css_class="form-group col-md-6"),
|
||||
Column("last_name", css_class="form-group col-md-6"),
|
||||
css_class="row",
|
||||
),
|
||||
# UserCreationForm provides 'password1' and 'password2' fields
|
||||
Div(
|
||||
Field("password1", autocomplete="new-password"),
|
||||
Field("password2", autocomplete="new-password"),
|
||||
css_class="border p-3 rounded mb-3",
|
||||
),
|
||||
# Administrative status fields
|
||||
Div(
|
||||
Field("is_active"),
|
||||
Field("is_superuser"),
|
||||
css_class="border p-3 rounded mb-3 text-bg-secondary",
|
||||
),
|
||||
)
|
||||
|
||||
if self.instance and self.instance.pk:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
),
|
||||
)
|
||||
else:
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def clean_email(self):
|
||||
"""Ensure email uniqueness (case-insensitive)."""
|
||||
email = self.cleaned_data.get("email")
|
||||
if email and get_user_model().objects.filter(email__iexact=email).exists():
|
||||
raise forms.ValidationError(
|
||||
_("A user with this email address already exists.")
|
||||
)
|
||||
return email
|
||||
|
||||
@transaction.atomic # Ensure user creation is atomic
|
||||
def save(self, commit=True):
|
||||
"""
|
||||
Save the user instance. UserCreationForm's save handles password hashing.
|
||||
Our Meta class ensures other fields are included.
|
||||
"""
|
||||
user = super().save(commit=False)
|
||||
|
||||
if commit:
|
||||
user.save()
|
||||
return user
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.8 on 2025-04-13 03:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0019_alter_usersettings_language'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='usersettings',
|
||||
name='language',
|
||||
field=models.CharField(choices=[('auto', 'Auto'), ('af', 'Afrikaans'), ('ar', 'العربية'), ('ar-dz', 'العربية (الجزائر)'), ('ast', 'Asturianu'), ('az', 'Azərbaycan'), ('bg', 'Български'), ('be', 'Беларуская'), ('bn', 'বাংলা'), ('br', 'Brezhoneg'), ('bs', 'Bosanski'), ('ca', 'Català'), ('ckb', 'کوردیی ناوەندی'), ('cs', 'Čeština'), ('cy', 'Cymraeg'), ('da', 'Dansk'), ('de', 'Deutsch'), ('dsb', 'Dolnoserbšćina'), ('el', 'Ελληνικά'), ('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'), ('nn', 'Norsk (Nynorsk)'), ('os', 'Ирон'), ('pa', 'ਪੰਜਾਬੀ'), ('pl', 'Polski'), ('pt', 'Português'), ('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', '繁體中文')], default='auto', max_length=10, verbose_name='Language'),
|
||||
),
|
||||
]
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,479 @@
|
||||
# Generated by Django 5.1.1 on 2025-06-29 00:48
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("users", "0021_alter_usersettings_timezone"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="usersettings",
|
||||
name="volume",
|
||||
field=models.PositiveIntegerField(
|
||||
default=10,
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(1),
|
||||
django.core.validators.MaxValueValidator(10),
|
||||
],
|
||||
verbose_name="Volume",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="usersettings",
|
||||
name="timezone",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("auto", "Auto"),
|
||||
("Africa/Abidjan", "Africa/Abidjan"),
|
||||
("Africa/Accra", "Africa/Accra"),
|
||||
("Africa/Addis_Ababa", "Africa/Addis_Ababa"),
|
||||
("Africa/Algiers", "Africa/Algiers"),
|
||||
("Africa/Asmara", "Africa/Asmara"),
|
||||
("Africa/Bamako", "Africa/Bamako"),
|
||||
("Africa/Bangui", "Africa/Bangui"),
|
||||
("Africa/Banjul", "Africa/Banjul"),
|
||||
("Africa/Bissau", "Africa/Bissau"),
|
||||
("Africa/Blantyre", "Africa/Blantyre"),
|
||||
("Africa/Brazzaville", "Africa/Brazzaville"),
|
||||
("Africa/Bujumbura", "Africa/Bujumbura"),
|
||||
("Africa/Cairo", "Africa/Cairo"),
|
||||
("Africa/Casablanca", "Africa/Casablanca"),
|
||||
("Africa/Ceuta", "Africa/Ceuta"),
|
||||
("Africa/Conakry", "Africa/Conakry"),
|
||||
("Africa/Dakar", "Africa/Dakar"),
|
||||
("Africa/Dar_es_Salaam", "Africa/Dar_es_Salaam"),
|
||||
("Africa/Djibouti", "Africa/Djibouti"),
|
||||
("Africa/Douala", "Africa/Douala"),
|
||||
("Africa/El_Aaiun", "Africa/El_Aaiun"),
|
||||
("Africa/Freetown", "Africa/Freetown"),
|
||||
("Africa/Gaborone", "Africa/Gaborone"),
|
||||
("Africa/Harare", "Africa/Harare"),
|
||||
("Africa/Johannesburg", "Africa/Johannesburg"),
|
||||
("Africa/Juba", "Africa/Juba"),
|
||||
("Africa/Kampala", "Africa/Kampala"),
|
||||
("Africa/Khartoum", "Africa/Khartoum"),
|
||||
("Africa/Kigali", "Africa/Kigali"),
|
||||
("Africa/Kinshasa", "Africa/Kinshasa"),
|
||||
("Africa/Lagos", "Africa/Lagos"),
|
||||
("Africa/Libreville", "Africa/Libreville"),
|
||||
("Africa/Lome", "Africa/Lome"),
|
||||
("Africa/Luanda", "Africa/Luanda"),
|
||||
("Africa/Lubumbashi", "Africa/Lubumbashi"),
|
||||
("Africa/Lusaka", "Africa/Lusaka"),
|
||||
("Africa/Malabo", "Africa/Malabo"),
|
||||
("Africa/Maputo", "Africa/Maputo"),
|
||||
("Africa/Maseru", "Africa/Maseru"),
|
||||
("Africa/Mbabane", "Africa/Mbabane"),
|
||||
("Africa/Mogadishu", "Africa/Mogadishu"),
|
||||
("Africa/Monrovia", "Africa/Monrovia"),
|
||||
("Africa/Nairobi", "Africa/Nairobi"),
|
||||
("Africa/Ndjamena", "Africa/Ndjamena"),
|
||||
("Africa/Niamey", "Africa/Niamey"),
|
||||
("Africa/Nouakchott", "Africa/Nouakchott"),
|
||||
("Africa/Ouagadougou", "Africa/Ouagadougou"),
|
||||
("Africa/Porto-Novo", "Africa/Porto-Novo"),
|
||||
("Africa/Sao_Tome", "Africa/Sao_Tome"),
|
||||
("Africa/Tripoli", "Africa/Tripoli"),
|
||||
("Africa/Tunis", "Africa/Tunis"),
|
||||
("Africa/Windhoek", "Africa/Windhoek"),
|
||||
("America/Adak", "America/Adak"),
|
||||
("America/Anchorage", "America/Anchorage"),
|
||||
("America/Anguilla", "America/Anguilla"),
|
||||
("America/Antigua", "America/Antigua"),
|
||||
("America/Araguaina", "America/Araguaina"),
|
||||
(
|
||||
"America/Argentina/Buenos_Aires",
|
||||
"America/Argentina/Buenos_Aires",
|
||||
),
|
||||
("America/Argentina/Catamarca", "America/Argentina/Catamarca"),
|
||||
("America/Argentina/Cordoba", "America/Argentina/Cordoba"),
|
||||
("America/Argentina/Jujuy", "America/Argentina/Jujuy"),
|
||||
("America/Argentina/La_Rioja", "America/Argentina/La_Rioja"),
|
||||
("America/Argentina/Mendoza", "America/Argentina/Mendoza"),
|
||||
(
|
||||
"America/Argentina/Rio_Gallegos",
|
||||
"America/Argentina/Rio_Gallegos",
|
||||
),
|
||||
("America/Argentina/Salta", "America/Argentina/Salta"),
|
||||
("America/Argentina/San_Juan", "America/Argentina/San_Juan"),
|
||||
("America/Argentina/San_Luis", "America/Argentina/San_Luis"),
|
||||
("America/Argentina/Tucuman", "America/Argentina/Tucuman"),
|
||||
("America/Argentina/Ushuaia", "America/Argentina/Ushuaia"),
|
||||
("America/Aruba", "America/Aruba"),
|
||||
("America/Asuncion", "America/Asuncion"),
|
||||
("America/Atikokan", "America/Atikokan"),
|
||||
("America/Bahia", "America/Bahia"),
|
||||
("America/Bahia_Banderas", "America/Bahia_Banderas"),
|
||||
("America/Barbados", "America/Barbados"),
|
||||
("America/Belem", "America/Belem"),
|
||||
("America/Belize", "America/Belize"),
|
||||
("America/Blanc-Sablon", "America/Blanc-Sablon"),
|
||||
("America/Boa_Vista", "America/Boa_Vista"),
|
||||
("America/Bogota", "America/Bogota"),
|
||||
("America/Boise", "America/Boise"),
|
||||
("America/Cambridge_Bay", "America/Cambridge_Bay"),
|
||||
("America/Campo_Grande", "America/Campo_Grande"),
|
||||
("America/Cancun", "America/Cancun"),
|
||||
("America/Caracas", "America/Caracas"),
|
||||
("America/Cayenne", "America/Cayenne"),
|
||||
("America/Cayman", "America/Cayman"),
|
||||
("America/Chicago", "America/Chicago"),
|
||||
("America/Chihuahua", "America/Chihuahua"),
|
||||
("America/Ciudad_Juarez", "America/Ciudad_Juarez"),
|
||||
("America/Costa_Rica", "America/Costa_Rica"),
|
||||
("America/Creston", "America/Creston"),
|
||||
("America/Cuiaba", "America/Cuiaba"),
|
||||
("America/Curacao", "America/Curacao"),
|
||||
("America/Danmarkshavn", "America/Danmarkshavn"),
|
||||
("America/Dawson", "America/Dawson"),
|
||||
("America/Dawson_Creek", "America/Dawson_Creek"),
|
||||
("America/Denver", "America/Denver"),
|
||||
("America/Detroit", "America/Detroit"),
|
||||
("America/Dominica", "America/Dominica"),
|
||||
("America/Edmonton", "America/Edmonton"),
|
||||
("America/Eirunepe", "America/Eirunepe"),
|
||||
("America/El_Salvador", "America/El_Salvador"),
|
||||
("America/Fort_Nelson", "America/Fort_Nelson"),
|
||||
("America/Fortaleza", "America/Fortaleza"),
|
||||
("America/Glace_Bay", "America/Glace_Bay"),
|
||||
("America/Goose_Bay", "America/Goose_Bay"),
|
||||
("America/Grand_Turk", "America/Grand_Turk"),
|
||||
("America/Grenada", "America/Grenada"),
|
||||
("America/Guadeloupe", "America/Guadeloupe"),
|
||||
("America/Guatemala", "America/Guatemala"),
|
||||
("America/Guayaquil", "America/Guayaquil"),
|
||||
("America/Guyana", "America/Guyana"),
|
||||
("America/Halifax", "America/Halifax"),
|
||||
("America/Havana", "America/Havana"),
|
||||
("America/Hermosillo", "America/Hermosillo"),
|
||||
("America/Indiana/Indianapolis", "America/Indiana/Indianapolis"),
|
||||
("America/Indiana/Knox", "America/Indiana/Knox"),
|
||||
("America/Indiana/Marengo", "America/Indiana/Marengo"),
|
||||
("America/Indiana/Petersburg", "America/Indiana/Petersburg"),
|
||||
("America/Indiana/Tell_City", "America/Indiana/Tell_City"),
|
||||
("America/Indiana/Vevay", "America/Indiana/Vevay"),
|
||||
("America/Indiana/Vincennes", "America/Indiana/Vincennes"),
|
||||
("America/Indiana/Winamac", "America/Indiana/Winamac"),
|
||||
("America/Inuvik", "America/Inuvik"),
|
||||
("America/Iqaluit", "America/Iqaluit"),
|
||||
("America/Jamaica", "America/Jamaica"),
|
||||
("America/Juneau", "America/Juneau"),
|
||||
("America/Kentucky/Louisville", "America/Kentucky/Louisville"),
|
||||
("America/Kentucky/Monticello", "America/Kentucky/Monticello"),
|
||||
("America/Kralendijk", "America/Kralendijk"),
|
||||
("America/La_Paz", "America/La_Paz"),
|
||||
("America/Lima", "America/Lima"),
|
||||
("America/Los_Angeles", "America/Los_Angeles"),
|
||||
("America/Lower_Princes", "America/Lower_Princes"),
|
||||
("America/Maceio", "America/Maceio"),
|
||||
("America/Managua", "America/Managua"),
|
||||
("America/Manaus", "America/Manaus"),
|
||||
("America/Marigot", "America/Marigot"),
|
||||
("America/Martinique", "America/Martinique"),
|
||||
("America/Matamoros", "America/Matamoros"),
|
||||
("America/Mazatlan", "America/Mazatlan"),
|
||||
("America/Menominee", "America/Menominee"),
|
||||
("America/Merida", "America/Merida"),
|
||||
("America/Metlakatla", "America/Metlakatla"),
|
||||
("America/Mexico_City", "America/Mexico_City"),
|
||||
("America/Miquelon", "America/Miquelon"),
|
||||
("America/Moncton", "America/Moncton"),
|
||||
("America/Monterrey", "America/Monterrey"),
|
||||
("America/Montevideo", "America/Montevideo"),
|
||||
("America/Montserrat", "America/Montserrat"),
|
||||
("America/Nassau", "America/Nassau"),
|
||||
("America/New_York", "America/New_York"),
|
||||
("America/Nome", "America/Nome"),
|
||||
("America/Noronha", "America/Noronha"),
|
||||
("America/North_Dakota/Beulah", "America/North_Dakota/Beulah"),
|
||||
("America/North_Dakota/Center", "America/North_Dakota/Center"),
|
||||
(
|
||||
"America/North_Dakota/New_Salem",
|
||||
"America/North_Dakota/New_Salem",
|
||||
),
|
||||
("America/Nuuk", "America/Nuuk"),
|
||||
("America/Ojinaga", "America/Ojinaga"),
|
||||
("America/Panama", "America/Panama"),
|
||||
("America/Paramaribo", "America/Paramaribo"),
|
||||
("America/Phoenix", "America/Phoenix"),
|
||||
("America/Port-au-Prince", "America/Port-au-Prince"),
|
||||
("America/Port_of_Spain", "America/Port_of_Spain"),
|
||||
("America/Porto_Velho", "America/Porto_Velho"),
|
||||
("America/Puerto_Rico", "America/Puerto_Rico"),
|
||||
("America/Punta_Arenas", "America/Punta_Arenas"),
|
||||
("America/Rankin_Inlet", "America/Rankin_Inlet"),
|
||||
("America/Recife", "America/Recife"),
|
||||
("America/Regina", "America/Regina"),
|
||||
("America/Resolute", "America/Resolute"),
|
||||
("America/Rio_Branco", "America/Rio_Branco"),
|
||||
("America/Santarem", "America/Santarem"),
|
||||
("America/Santiago", "America/Santiago"),
|
||||
("America/Santo_Domingo", "America/Santo_Domingo"),
|
||||
("America/Sao_Paulo", "America/Sao_Paulo"),
|
||||
("America/Scoresbysund", "America/Scoresbysund"),
|
||||
("America/Sitka", "America/Sitka"),
|
||||
("America/St_Barthelemy", "America/St_Barthelemy"),
|
||||
("America/St_Johns", "America/St_Johns"),
|
||||
("America/St_Kitts", "America/St_Kitts"),
|
||||
("America/St_Lucia", "America/St_Lucia"),
|
||||
("America/St_Thomas", "America/St_Thomas"),
|
||||
("America/St_Vincent", "America/St_Vincent"),
|
||||
("America/Swift_Current", "America/Swift_Current"),
|
||||
("America/Tegucigalpa", "America/Tegucigalpa"),
|
||||
("America/Thule", "America/Thule"),
|
||||
("America/Tijuana", "America/Tijuana"),
|
||||
("America/Toronto", "America/Toronto"),
|
||||
("America/Tortola", "America/Tortola"),
|
||||
("America/Vancouver", "America/Vancouver"),
|
||||
("America/Whitehorse", "America/Whitehorse"),
|
||||
("America/Winnipeg", "America/Winnipeg"),
|
||||
("America/Yakutat", "America/Yakutat"),
|
||||
("Antarctica/Casey", "Antarctica/Casey"),
|
||||
("Antarctica/Davis", "Antarctica/Davis"),
|
||||
("Antarctica/DumontDUrville", "Antarctica/DumontDUrville"),
|
||||
("Antarctica/Macquarie", "Antarctica/Macquarie"),
|
||||
("Antarctica/Mawson", "Antarctica/Mawson"),
|
||||
("Antarctica/McMurdo", "Antarctica/McMurdo"),
|
||||
("Antarctica/Palmer", "Antarctica/Palmer"),
|
||||
("Antarctica/Rothera", "Antarctica/Rothera"),
|
||||
("Antarctica/Syowa", "Antarctica/Syowa"),
|
||||
("Antarctica/Troll", "Antarctica/Troll"),
|
||||
("Antarctica/Vostok", "Antarctica/Vostok"),
|
||||
("Arctic/Longyearbyen", "Arctic/Longyearbyen"),
|
||||
("Asia/Aden", "Asia/Aden"),
|
||||
("Asia/Almaty", "Asia/Almaty"),
|
||||
("Asia/Amman", "Asia/Amman"),
|
||||
("Asia/Anadyr", "Asia/Anadyr"),
|
||||
("Asia/Aqtau", "Asia/Aqtau"),
|
||||
("Asia/Aqtobe", "Asia/Aqtobe"),
|
||||
("Asia/Ashgabat", "Asia/Ashgabat"),
|
||||
("Asia/Atyrau", "Asia/Atyrau"),
|
||||
("Asia/Baghdad", "Asia/Baghdad"),
|
||||
("Asia/Bahrain", "Asia/Bahrain"),
|
||||
("Asia/Baku", "Asia/Baku"),
|
||||
("Asia/Bangkok", "Asia/Bangkok"),
|
||||
("Asia/Barnaul", "Asia/Barnaul"),
|
||||
("Asia/Beirut", "Asia/Beirut"),
|
||||
("Asia/Bishkek", "Asia/Bishkek"),
|
||||
("Asia/Brunei", "Asia/Brunei"),
|
||||
("Asia/Chita", "Asia/Chita"),
|
||||
("Asia/Colombo", "Asia/Colombo"),
|
||||
("Asia/Damascus", "Asia/Damascus"),
|
||||
("Asia/Dhaka", "Asia/Dhaka"),
|
||||
("Asia/Dili", "Asia/Dili"),
|
||||
("Asia/Dubai", "Asia/Dubai"),
|
||||
("Asia/Dushanbe", "Asia/Dushanbe"),
|
||||
("Asia/Famagusta", "Asia/Famagusta"),
|
||||
("Asia/Gaza", "Asia/Gaza"),
|
||||
("Asia/Hebron", "Asia/Hebron"),
|
||||
("Asia/Ho_Chi_Minh", "Asia/Ho_Chi_Minh"),
|
||||
("Asia/Hong_Kong", "Asia/Hong_Kong"),
|
||||
("Asia/Hovd", "Asia/Hovd"),
|
||||
("Asia/Irkutsk", "Asia/Irkutsk"),
|
||||
("Asia/Jakarta", "Asia/Jakarta"),
|
||||
("Asia/Jayapura", "Asia/Jayapura"),
|
||||
("Asia/Jerusalem", "Asia/Jerusalem"),
|
||||
("Asia/Kabul", "Asia/Kabul"),
|
||||
("Asia/Kamchatka", "Asia/Kamchatka"),
|
||||
("Asia/Karachi", "Asia/Karachi"),
|
||||
("Asia/Kathmandu", "Asia/Kathmandu"),
|
||||
("Asia/Khandyga", "Asia/Khandyga"),
|
||||
("Asia/Kolkata", "Asia/Kolkata"),
|
||||
("Asia/Krasnoyarsk", "Asia/Krasnoyarsk"),
|
||||
("Asia/Kuala_Lumpur", "Asia/Kuala_Lumpur"),
|
||||
("Asia/Kuching", "Asia/Kuching"),
|
||||
("Asia/Kuwait", "Asia/Kuwait"),
|
||||
("Asia/Macau", "Asia/Macau"),
|
||||
("Asia/Magadan", "Asia/Magadan"),
|
||||
("Asia/Makassar", "Asia/Makassar"),
|
||||
("Asia/Manila", "Asia/Manila"),
|
||||
("Asia/Muscat", "Asia/Muscat"),
|
||||
("Asia/Nicosia", "Asia/Nicosia"),
|
||||
("Asia/Novokuznetsk", "Asia/Novokuznetsk"),
|
||||
("Asia/Novosibirsk", "Asia/Novosibirsk"),
|
||||
("Asia/Omsk", "Asia/Omsk"),
|
||||
("Asia/Oral", "Asia/Oral"),
|
||||
("Asia/Phnom_Penh", "Asia/Phnom_Penh"),
|
||||
("Asia/Pontianak", "Asia/Pontianak"),
|
||||
("Asia/Pyongyang", "Asia/Pyongyang"),
|
||||
("Asia/Qatar", "Asia/Qatar"),
|
||||
("Asia/Qostanay", "Asia/Qostanay"),
|
||||
("Asia/Qyzylorda", "Asia/Qyzylorda"),
|
||||
("Asia/Riyadh", "Asia/Riyadh"),
|
||||
("Asia/Sakhalin", "Asia/Sakhalin"),
|
||||
("Asia/Samarkand", "Asia/Samarkand"),
|
||||
("Asia/Seoul", "Asia/Seoul"),
|
||||
("Asia/Shanghai", "Asia/Shanghai"),
|
||||
("Asia/Singapore", "Asia/Singapore"),
|
||||
("Asia/Srednekolymsk", "Asia/Srednekolymsk"),
|
||||
("Asia/Taipei", "Asia/Taipei"),
|
||||
("Asia/Tashkent", "Asia/Tashkent"),
|
||||
("Asia/Tbilisi", "Asia/Tbilisi"),
|
||||
("Asia/Tehran", "Asia/Tehran"),
|
||||
("Asia/Thimphu", "Asia/Thimphu"),
|
||||
("Asia/Tokyo", "Asia/Tokyo"),
|
||||
("Asia/Tomsk", "Asia/Tomsk"),
|
||||
("Asia/Ulaanbaatar", "Asia/Ulaanbaatar"),
|
||||
("Asia/Urumqi", "Asia/Urumqi"),
|
||||
("Asia/Ust-Nera", "Asia/Ust-Nera"),
|
||||
("Asia/Vientiane", "Asia/Vientiane"),
|
||||
("Asia/Vladivostok", "Asia/Vladivostok"),
|
||||
("Asia/Yakutsk", "Asia/Yakutsk"),
|
||||
("Asia/Yangon", "Asia/Yangon"),
|
||||
("Asia/Yekaterinburg", "Asia/Yekaterinburg"),
|
||||
("Asia/Yerevan", "Asia/Yerevan"),
|
||||
("Atlantic/Azores", "Atlantic/Azores"),
|
||||
("Atlantic/Bermuda", "Atlantic/Bermuda"),
|
||||
("Atlantic/Canary", "Atlantic/Canary"),
|
||||
("Atlantic/Cape_Verde", "Atlantic/Cape_Verde"),
|
||||
("Atlantic/Faroe", "Atlantic/Faroe"),
|
||||
("Atlantic/Madeira", "Atlantic/Madeira"),
|
||||
("Atlantic/Reykjavik", "Atlantic/Reykjavik"),
|
||||
("Atlantic/South_Georgia", "Atlantic/South_Georgia"),
|
||||
("Atlantic/St_Helena", "Atlantic/St_Helena"),
|
||||
("Atlantic/Stanley", "Atlantic/Stanley"),
|
||||
("Australia/Adelaide", "Australia/Adelaide"),
|
||||
("Australia/Brisbane", "Australia/Brisbane"),
|
||||
("Australia/Broken_Hill", "Australia/Broken_Hill"),
|
||||
("Australia/Darwin", "Australia/Darwin"),
|
||||
("Australia/Eucla", "Australia/Eucla"),
|
||||
("Australia/Hobart", "Australia/Hobart"),
|
||||
("Australia/Lindeman", "Australia/Lindeman"),
|
||||
("Australia/Lord_Howe", "Australia/Lord_Howe"),
|
||||
("Australia/Melbourne", "Australia/Melbourne"),
|
||||
("Australia/Perth", "Australia/Perth"),
|
||||
("Australia/Sydney", "Australia/Sydney"),
|
||||
("Canada/Atlantic", "Canada/Atlantic"),
|
||||
("Canada/Central", "Canada/Central"),
|
||||
("Canada/Eastern", "Canada/Eastern"),
|
||||
("Canada/Mountain", "Canada/Mountain"),
|
||||
("Canada/Newfoundland", "Canada/Newfoundland"),
|
||||
("Canada/Pacific", "Canada/Pacific"),
|
||||
("Europe/Amsterdam", "Europe/Amsterdam"),
|
||||
("Europe/Andorra", "Europe/Andorra"),
|
||||
("Europe/Astrakhan", "Europe/Astrakhan"),
|
||||
("Europe/Athens", "Europe/Athens"),
|
||||
("Europe/Belgrade", "Europe/Belgrade"),
|
||||
("Europe/Berlin", "Europe/Berlin"),
|
||||
("Europe/Bratislava", "Europe/Bratislava"),
|
||||
("Europe/Brussels", "Europe/Brussels"),
|
||||
("Europe/Bucharest", "Europe/Bucharest"),
|
||||
("Europe/Budapest", "Europe/Budapest"),
|
||||
("Europe/Busingen", "Europe/Busingen"),
|
||||
("Europe/Chisinau", "Europe/Chisinau"),
|
||||
("Europe/Copenhagen", "Europe/Copenhagen"),
|
||||
("Europe/Dublin", "Europe/Dublin"),
|
||||
("Europe/Gibraltar", "Europe/Gibraltar"),
|
||||
("Europe/Guernsey", "Europe/Guernsey"),
|
||||
("Europe/Helsinki", "Europe/Helsinki"),
|
||||
("Europe/Isle_of_Man", "Europe/Isle_of_Man"),
|
||||
("Europe/Istanbul", "Europe/Istanbul"),
|
||||
("Europe/Jersey", "Europe/Jersey"),
|
||||
("Europe/Kaliningrad", "Europe/Kaliningrad"),
|
||||
("Europe/Kirov", "Europe/Kirov"),
|
||||
("Europe/Kyiv", "Europe/Kyiv"),
|
||||
("Europe/Lisbon", "Europe/Lisbon"),
|
||||
("Europe/Ljubljana", "Europe/Ljubljana"),
|
||||
("Europe/London", "Europe/London"),
|
||||
("Europe/Luxembourg", "Europe/Luxembourg"),
|
||||
("Europe/Madrid", "Europe/Madrid"),
|
||||
("Europe/Malta", "Europe/Malta"),
|
||||
("Europe/Mariehamn", "Europe/Mariehamn"),
|
||||
("Europe/Minsk", "Europe/Minsk"),
|
||||
("Europe/Monaco", "Europe/Monaco"),
|
||||
("Europe/Moscow", "Europe/Moscow"),
|
||||
("Europe/Oslo", "Europe/Oslo"),
|
||||
("Europe/Paris", "Europe/Paris"),
|
||||
("Europe/Podgorica", "Europe/Podgorica"),
|
||||
("Europe/Prague", "Europe/Prague"),
|
||||
("Europe/Riga", "Europe/Riga"),
|
||||
("Europe/Rome", "Europe/Rome"),
|
||||
("Europe/Samara", "Europe/Samara"),
|
||||
("Europe/San_Marino", "Europe/San_Marino"),
|
||||
("Europe/Sarajevo", "Europe/Sarajevo"),
|
||||
("Europe/Saratov", "Europe/Saratov"),
|
||||
("Europe/Simferopol", "Europe/Simferopol"),
|
||||
("Europe/Skopje", "Europe/Skopje"),
|
||||
("Europe/Sofia", "Europe/Sofia"),
|
||||
("Europe/Stockholm", "Europe/Stockholm"),
|
||||
("Europe/Tallinn", "Europe/Tallinn"),
|
||||
("Europe/Tirane", "Europe/Tirane"),
|
||||
("Europe/Ulyanovsk", "Europe/Ulyanovsk"),
|
||||
("Europe/Vaduz", "Europe/Vaduz"),
|
||||
("Europe/Vatican", "Europe/Vatican"),
|
||||
("Europe/Vienna", "Europe/Vienna"),
|
||||
("Europe/Vilnius", "Europe/Vilnius"),
|
||||
("Europe/Volgograd", "Europe/Volgograd"),
|
||||
("Europe/Warsaw", "Europe/Warsaw"),
|
||||
("Europe/Zagreb", "Europe/Zagreb"),
|
||||
("Europe/Zurich", "Europe/Zurich"),
|
||||
("GMT", "GMT"),
|
||||
("Indian/Antananarivo", "Indian/Antananarivo"),
|
||||
("Indian/Chagos", "Indian/Chagos"),
|
||||
("Indian/Christmas", "Indian/Christmas"),
|
||||
("Indian/Cocos", "Indian/Cocos"),
|
||||
("Indian/Comoro", "Indian/Comoro"),
|
||||
("Indian/Kerguelen", "Indian/Kerguelen"),
|
||||
("Indian/Mahe", "Indian/Mahe"),
|
||||
("Indian/Maldives", "Indian/Maldives"),
|
||||
("Indian/Mauritius", "Indian/Mauritius"),
|
||||
("Indian/Mayotte", "Indian/Mayotte"),
|
||||
("Indian/Reunion", "Indian/Reunion"),
|
||||
("Pacific/Apia", "Pacific/Apia"),
|
||||
("Pacific/Auckland", "Pacific/Auckland"),
|
||||
("Pacific/Bougainville", "Pacific/Bougainville"),
|
||||
("Pacific/Chatham", "Pacific/Chatham"),
|
||||
("Pacific/Chuuk", "Pacific/Chuuk"),
|
||||
("Pacific/Easter", "Pacific/Easter"),
|
||||
("Pacific/Efate", "Pacific/Efate"),
|
||||
("Pacific/Fakaofo", "Pacific/Fakaofo"),
|
||||
("Pacific/Fiji", "Pacific/Fiji"),
|
||||
("Pacific/Funafuti", "Pacific/Funafuti"),
|
||||
("Pacific/Galapagos", "Pacific/Galapagos"),
|
||||
("Pacific/Gambier", "Pacific/Gambier"),
|
||||
("Pacific/Guadalcanal", "Pacific/Guadalcanal"),
|
||||
("Pacific/Guam", "Pacific/Guam"),
|
||||
("Pacific/Honolulu", "Pacific/Honolulu"),
|
||||
("Pacific/Kanton", "Pacific/Kanton"),
|
||||
("Pacific/Kiritimati", "Pacific/Kiritimati"),
|
||||
("Pacific/Kosrae", "Pacific/Kosrae"),
|
||||
("Pacific/Kwajalein", "Pacific/Kwajalein"),
|
||||
("Pacific/Majuro", "Pacific/Majuro"),
|
||||
("Pacific/Marquesas", "Pacific/Marquesas"),
|
||||
("Pacific/Midway", "Pacific/Midway"),
|
||||
("Pacific/Nauru", "Pacific/Nauru"),
|
||||
("Pacific/Niue", "Pacific/Niue"),
|
||||
("Pacific/Norfolk", "Pacific/Norfolk"),
|
||||
("Pacific/Noumea", "Pacific/Noumea"),
|
||||
("Pacific/Pago_Pago", "Pacific/Pago_Pago"),
|
||||
("Pacific/Palau", "Pacific/Palau"),
|
||||
("Pacific/Pitcairn", "Pacific/Pitcairn"),
|
||||
("Pacific/Pohnpei", "Pacific/Pohnpei"),
|
||||
("Pacific/Port_Moresby", "Pacific/Port_Moresby"),
|
||||
("Pacific/Rarotonga", "Pacific/Rarotonga"),
|
||||
("Pacific/Saipan", "Pacific/Saipan"),
|
||||
("Pacific/Tahiti", "Pacific/Tahiti"),
|
||||
("Pacific/Tarawa", "Pacific/Tarawa"),
|
||||
("Pacific/Tongatapu", "Pacific/Tongatapu"),
|
||||
("Pacific/Wake", "Pacific/Wake"),
|
||||
("Pacific/Wallis", "Pacific/Wallis"),
|
||||
("US/Alaska", "US/Alaska"),
|
||||
("US/Arizona", "US/Arizona"),
|
||||
("US/Central", "US/Central"),
|
||||
("US/Eastern", "US/Eastern"),
|
||||
("US/Hawaii", "US/Hawaii"),
|
||||
("US/Mountain", "US/Mountain"),
|
||||
("US/Pacific", "US/Pacific"),
|
||||
("UTC", "UTC"),
|
||||
],
|
||||
default="auto",
|
||||
max_length=50,
|
||||
verbose_name="Time Zone",
|
||||
),
|
||||
),
|
||||
]
|
||||
File diff suppressed because one or more lines are too long
@@ -2,11 +2,449 @@ import pytz
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import AbstractUser, Group
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.users.managers import UserManager
|
||||
|
||||
timezones = [
|
||||
("auto", _("Auto")),
|
||||
("Africa/Abidjan", "Africa/Abidjan"),
|
||||
("Africa/Accra", "Africa/Accra"),
|
||||
("Africa/Addis_Ababa", "Africa/Addis_Ababa"),
|
||||
("Africa/Algiers", "Africa/Algiers"),
|
||||
("Africa/Asmara", "Africa/Asmara"),
|
||||
("Africa/Bamako", "Africa/Bamako"),
|
||||
("Africa/Bangui", "Africa/Bangui"),
|
||||
("Africa/Banjul", "Africa/Banjul"),
|
||||
("Africa/Bissau", "Africa/Bissau"),
|
||||
("Africa/Blantyre", "Africa/Blantyre"),
|
||||
("Africa/Brazzaville", "Africa/Brazzaville"),
|
||||
("Africa/Bujumbura", "Africa/Bujumbura"),
|
||||
("Africa/Cairo", "Africa/Cairo"),
|
||||
("Africa/Casablanca", "Africa/Casablanca"),
|
||||
("Africa/Ceuta", "Africa/Ceuta"),
|
||||
("Africa/Conakry", "Africa/Conakry"),
|
||||
("Africa/Dakar", "Africa/Dakar"),
|
||||
("Africa/Dar_es_Salaam", "Africa/Dar_es_Salaam"),
|
||||
("Africa/Djibouti", "Africa/Djibouti"),
|
||||
("Africa/Douala", "Africa/Douala"),
|
||||
("Africa/El_Aaiun", "Africa/El_Aaiun"),
|
||||
("Africa/Freetown", "Africa/Freetown"),
|
||||
("Africa/Gaborone", "Africa/Gaborone"),
|
||||
("Africa/Harare", "Africa/Harare"),
|
||||
("Africa/Johannesburg", "Africa/Johannesburg"),
|
||||
("Africa/Juba", "Africa/Juba"),
|
||||
("Africa/Kampala", "Africa/Kampala"),
|
||||
("Africa/Khartoum", "Africa/Khartoum"),
|
||||
("Africa/Kigali", "Africa/Kigali"),
|
||||
("Africa/Kinshasa", "Africa/Kinshasa"),
|
||||
("Africa/Lagos", "Africa/Lagos"),
|
||||
("Africa/Libreville", "Africa/Libreville"),
|
||||
("Africa/Lome", "Africa/Lome"),
|
||||
("Africa/Luanda", "Africa/Luanda"),
|
||||
("Africa/Lubumbashi", "Africa/Lubumbashi"),
|
||||
("Africa/Lusaka", "Africa/Lusaka"),
|
||||
("Africa/Malabo", "Africa/Malabo"),
|
||||
("Africa/Maputo", "Africa/Maputo"),
|
||||
("Africa/Maseru", "Africa/Maseru"),
|
||||
("Africa/Mbabane", "Africa/Mbabane"),
|
||||
("Africa/Mogadishu", "Africa/Mogadishu"),
|
||||
("Africa/Monrovia", "Africa/Monrovia"),
|
||||
("Africa/Nairobi", "Africa/Nairobi"),
|
||||
("Africa/Ndjamena", "Africa/Ndjamena"),
|
||||
("Africa/Niamey", "Africa/Niamey"),
|
||||
("Africa/Nouakchott", "Africa/Nouakchott"),
|
||||
("Africa/Ouagadougou", "Africa/Ouagadougou"),
|
||||
("Africa/Porto-Novo", "Africa/Porto-Novo"),
|
||||
("Africa/Sao_Tome", "Africa/Sao_Tome"),
|
||||
("Africa/Tripoli", "Africa/Tripoli"),
|
||||
("Africa/Tunis", "Africa/Tunis"),
|
||||
("Africa/Windhoek", "Africa/Windhoek"),
|
||||
("America/Adak", "America/Adak"),
|
||||
("America/Anchorage", "America/Anchorage"),
|
||||
("America/Anguilla", "America/Anguilla"),
|
||||
("America/Antigua", "America/Antigua"),
|
||||
("America/Araguaina", "America/Araguaina"),
|
||||
("America/Argentina/Buenos_Aires", "America/Argentina/Buenos_Aires"),
|
||||
("America/Argentina/Catamarca", "America/Argentina/Catamarca"),
|
||||
("America/Argentina/Cordoba", "America/Argentina/Cordoba"),
|
||||
("America/Argentina/Jujuy", "America/Argentina/Jujuy"),
|
||||
("America/Argentina/La_Rioja", "America/Argentina/La_Rioja"),
|
||||
("America/Argentina/Mendoza", "America/Argentina/Mendoza"),
|
||||
("America/Argentina/Rio_Gallegos", "America/Argentina/Rio_Gallegos"),
|
||||
("America/Argentina/Salta", "America/Argentina/Salta"),
|
||||
("America/Argentina/San_Juan", "America/Argentina/San_Juan"),
|
||||
("America/Argentina/San_Luis", "America/Argentina/San_Luis"),
|
||||
("America/Argentina/Tucuman", "America/Argentina/Tucuman"),
|
||||
("America/Argentina/Ushuaia", "America/Argentina/Ushuaia"),
|
||||
("America/Aruba", "America/Aruba"),
|
||||
("America/Asuncion", "America/Asuncion"),
|
||||
("America/Atikokan", "America/Atikokan"),
|
||||
("America/Bahia", "America/Bahia"),
|
||||
("America/Bahia_Banderas", "America/Bahia_Banderas"),
|
||||
("America/Barbados", "America/Barbados"),
|
||||
("America/Belem", "America/Belem"),
|
||||
("America/Belize", "America/Belize"),
|
||||
("America/Blanc-Sablon", "America/Blanc-Sablon"),
|
||||
("America/Boa_Vista", "America/Boa_Vista"),
|
||||
("America/Bogota", "America/Bogota"),
|
||||
("America/Boise", "America/Boise"),
|
||||
("America/Cambridge_Bay", "America/Cambridge_Bay"),
|
||||
("America/Campo_Grande", "America/Campo_Grande"),
|
||||
("America/Cancun", "America/Cancun"),
|
||||
("America/Caracas", "America/Caracas"),
|
||||
("America/Cayenne", "America/Cayenne"),
|
||||
("America/Cayman", "America/Cayman"),
|
||||
("America/Chicago", "America/Chicago"),
|
||||
("America/Chihuahua", "America/Chihuahua"),
|
||||
("America/Ciudad_Juarez", "America/Ciudad_Juarez"),
|
||||
("America/Costa_Rica", "America/Costa_Rica"),
|
||||
("America/Coyhaique", "America/Coyhaique"),
|
||||
("America/Creston", "America/Creston"),
|
||||
("America/Cuiaba", "America/Cuiaba"),
|
||||
("America/Curacao", "America/Curacao"),
|
||||
("America/Danmarkshavn", "America/Danmarkshavn"),
|
||||
("America/Dawson", "America/Dawson"),
|
||||
("America/Dawson_Creek", "America/Dawson_Creek"),
|
||||
("America/Denver", "America/Denver"),
|
||||
("America/Detroit", "America/Detroit"),
|
||||
("America/Dominica", "America/Dominica"),
|
||||
("America/Edmonton", "America/Edmonton"),
|
||||
("America/Eirunepe", "America/Eirunepe"),
|
||||
("America/El_Salvador", "America/El_Salvador"),
|
||||
("America/Fort_Nelson", "America/Fort_Nelson"),
|
||||
("America/Fortaleza", "America/Fortaleza"),
|
||||
("America/Glace_Bay", "America/Glace_Bay"),
|
||||
("America/Goose_Bay", "America/Goose_Bay"),
|
||||
("America/Grand_Turk", "America/Grand_Turk"),
|
||||
("America/Grenada", "America/Grenada"),
|
||||
("America/Guadeloupe", "America/Guadeloupe"),
|
||||
("America/Guatemala", "America/Guatemala"),
|
||||
("America/Guayaquil", "America/Guayaquil"),
|
||||
("America/Guyana", "America/Guyana"),
|
||||
("America/Halifax", "America/Halifax"),
|
||||
("America/Havana", "America/Havana"),
|
||||
("America/Hermosillo", "America/Hermosillo"),
|
||||
("America/Indiana/Indianapolis", "America/Indiana/Indianapolis"),
|
||||
("America/Indiana/Knox", "America/Indiana/Knox"),
|
||||
("America/Indiana/Marengo", "America/Indiana/Marengo"),
|
||||
("America/Indiana/Petersburg", "America/Indiana/Petersburg"),
|
||||
("America/Indiana/Tell_City", "America/Indiana/Tell_City"),
|
||||
("America/Indiana/Vevay", "America/Indiana/Vevay"),
|
||||
("America/Indiana/Vincennes", "America/Indiana/Vincennes"),
|
||||
("America/Indiana/Winamac", "America/Indiana/Winamac"),
|
||||
("America/Inuvik", "America/Inuvik"),
|
||||
("America/Iqaluit", "America/Iqaluit"),
|
||||
("America/Jamaica", "America/Jamaica"),
|
||||
("America/Juneau", "America/Juneau"),
|
||||
("America/Kentucky/Louisville", "America/Kentucky/Louisville"),
|
||||
("America/Kentucky/Monticello", "America/Kentucky/Monticello"),
|
||||
("America/Kralendijk", "America/Kralendijk"),
|
||||
("America/La_Paz", "America/La_Paz"),
|
||||
("America/Lima", "America/Lima"),
|
||||
("America/Los_Angeles", "America/Los_Angeles"),
|
||||
("America/Lower_Princes", "America/Lower_Princes"),
|
||||
("America/Maceio", "America/Maceio"),
|
||||
("America/Managua", "America/Managua"),
|
||||
("America/Manaus", "America/Manaus"),
|
||||
("America/Marigot", "America/Marigot"),
|
||||
("America/Martinique", "America/Martinique"),
|
||||
("America/Matamoros", "America/Matamoros"),
|
||||
("America/Mazatlan", "America/Mazatlan"),
|
||||
("America/Menominee", "America/Menominee"),
|
||||
("America/Merida", "America/Merida"),
|
||||
("America/Metlakatla", "America/Metlakatla"),
|
||||
("America/Mexico_City", "America/Mexico_City"),
|
||||
("America/Miquelon", "America/Miquelon"),
|
||||
("America/Moncton", "America/Moncton"),
|
||||
("America/Monterrey", "America/Monterrey"),
|
||||
("America/Montevideo", "America/Montevideo"),
|
||||
("America/Montserrat", "America/Montserrat"),
|
||||
("America/Nassau", "America/Nassau"),
|
||||
("America/New_York", "America/New_York"),
|
||||
("America/Nome", "America/Nome"),
|
||||
("America/Noronha", "America/Noronha"),
|
||||
("America/North_Dakota/Beulah", "America/North_Dakota/Beulah"),
|
||||
("America/North_Dakota/Center", "America/North_Dakota/Center"),
|
||||
("America/North_Dakota/New_Salem", "America/North_Dakota/New_Salem"),
|
||||
("America/Nuuk", "America/Nuuk"),
|
||||
("America/Ojinaga", "America/Ojinaga"),
|
||||
("America/Panama", "America/Panama"),
|
||||
("America/Paramaribo", "America/Paramaribo"),
|
||||
("America/Phoenix", "America/Phoenix"),
|
||||
("America/Port-au-Prince", "America/Port-au-Prince"),
|
||||
("America/Port_of_Spain", "America/Port_of_Spain"),
|
||||
("America/Porto_Velho", "America/Porto_Velho"),
|
||||
("America/Puerto_Rico", "America/Puerto_Rico"),
|
||||
("America/Punta_Arenas", "America/Punta_Arenas"),
|
||||
("America/Rankin_Inlet", "America/Rankin_Inlet"),
|
||||
("America/Recife", "America/Recife"),
|
||||
("America/Regina", "America/Regina"),
|
||||
("America/Resolute", "America/Resolute"),
|
||||
("America/Rio_Branco", "America/Rio_Branco"),
|
||||
("America/Santarem", "America/Santarem"),
|
||||
("America/Santiago", "America/Santiago"),
|
||||
("America/Santo_Domingo", "America/Santo_Domingo"),
|
||||
("America/Sao_Paulo", "America/Sao_Paulo"),
|
||||
("America/Scoresbysund", "America/Scoresbysund"),
|
||||
("America/Sitka", "America/Sitka"),
|
||||
("America/St_Barthelemy", "America/St_Barthelemy"),
|
||||
("America/St_Johns", "America/St_Johns"),
|
||||
("America/St_Kitts", "America/St_Kitts"),
|
||||
("America/St_Lucia", "America/St_Lucia"),
|
||||
("America/St_Thomas", "America/St_Thomas"),
|
||||
("America/St_Vincent", "America/St_Vincent"),
|
||||
("America/Swift_Current", "America/Swift_Current"),
|
||||
("America/Tegucigalpa", "America/Tegucigalpa"),
|
||||
("America/Thule", "America/Thule"),
|
||||
("America/Tijuana", "America/Tijuana"),
|
||||
("America/Toronto", "America/Toronto"),
|
||||
("America/Tortola", "America/Tortola"),
|
||||
("America/Vancouver", "America/Vancouver"),
|
||||
("America/Whitehorse", "America/Whitehorse"),
|
||||
("America/Winnipeg", "America/Winnipeg"),
|
||||
("America/Yakutat", "America/Yakutat"),
|
||||
("Antarctica/Casey", "Antarctica/Casey"),
|
||||
("Antarctica/Davis", "Antarctica/Davis"),
|
||||
("Antarctica/DumontDUrville", "Antarctica/DumontDUrville"),
|
||||
("Antarctica/Macquarie", "Antarctica/Macquarie"),
|
||||
("Antarctica/Mawson", "Antarctica/Mawson"),
|
||||
("Antarctica/McMurdo", "Antarctica/McMurdo"),
|
||||
("Antarctica/Palmer", "Antarctica/Palmer"),
|
||||
("Antarctica/Rothera", "Antarctica/Rothera"),
|
||||
("Antarctica/Syowa", "Antarctica/Syowa"),
|
||||
("Antarctica/Troll", "Antarctica/Troll"),
|
||||
("Antarctica/Vostok", "Antarctica/Vostok"),
|
||||
("Arctic/Longyearbyen", "Arctic/Longyearbyen"),
|
||||
("Asia/Aden", "Asia/Aden"),
|
||||
("Asia/Almaty", "Asia/Almaty"),
|
||||
("Asia/Amman", "Asia/Amman"),
|
||||
("Asia/Anadyr", "Asia/Anadyr"),
|
||||
("Asia/Aqtau", "Asia/Aqtau"),
|
||||
("Asia/Aqtobe", "Asia/Aqtobe"),
|
||||
("Asia/Ashgabat", "Asia/Ashgabat"),
|
||||
("Asia/Atyrau", "Asia/Atyrau"),
|
||||
("Asia/Baghdad", "Asia/Baghdad"),
|
||||
("Asia/Bahrain", "Asia/Bahrain"),
|
||||
("Asia/Baku", "Asia/Baku"),
|
||||
("Asia/Bangkok", "Asia/Bangkok"),
|
||||
("Asia/Barnaul", "Asia/Barnaul"),
|
||||
("Asia/Beirut", "Asia/Beirut"),
|
||||
("Asia/Bishkek", "Asia/Bishkek"),
|
||||
("Asia/Brunei", "Asia/Brunei"),
|
||||
("Asia/Chita", "Asia/Chita"),
|
||||
("Asia/Colombo", "Asia/Colombo"),
|
||||
("Asia/Damascus", "Asia/Damascus"),
|
||||
("Asia/Dhaka", "Asia/Dhaka"),
|
||||
("Asia/Dili", "Asia/Dili"),
|
||||
("Asia/Dubai", "Asia/Dubai"),
|
||||
("Asia/Dushanbe", "Asia/Dushanbe"),
|
||||
("Asia/Famagusta", "Asia/Famagusta"),
|
||||
("Asia/Gaza", "Asia/Gaza"),
|
||||
("Asia/Hebron", "Asia/Hebron"),
|
||||
("Asia/Ho_Chi_Minh", "Asia/Ho_Chi_Minh"),
|
||||
("Asia/Hong_Kong", "Asia/Hong_Kong"),
|
||||
("Asia/Hovd", "Asia/Hovd"),
|
||||
("Asia/Irkutsk", "Asia/Irkutsk"),
|
||||
("Asia/Jakarta", "Asia/Jakarta"),
|
||||
("Asia/Jayapura", "Asia/Jayapura"),
|
||||
("Asia/Jerusalem", "Asia/Jerusalem"),
|
||||
("Asia/Kabul", "Asia/Kabul"),
|
||||
("Asia/Kamchatka", "Asia/Kamchatka"),
|
||||
("Asia/Karachi", "Asia/Karachi"),
|
||||
("Asia/Kathmandu", "Asia/Kathmandu"),
|
||||
("Asia/Khandyga", "Asia/Khandyga"),
|
||||
("Asia/Kolkata", "Asia/Kolkata"),
|
||||
("Asia/Krasnoyarsk", "Asia/Krasnoyarsk"),
|
||||
("Asia/Kuala_Lumpur", "Asia/Kuala_Lumpur"),
|
||||
("Asia/Kuching", "Asia/Kuching"),
|
||||
("Asia/Kuwait", "Asia/Kuwait"),
|
||||
("Asia/Macau", "Asia/Macau"),
|
||||
("Asia/Magadan", "Asia/Magadan"),
|
||||
("Asia/Makassar", "Asia/Makassar"),
|
||||
("Asia/Manila", "Asia/Manila"),
|
||||
("Asia/Muscat", "Asia/Muscat"),
|
||||
("Asia/Nicosia", "Asia/Nicosia"),
|
||||
("Asia/Novokuznetsk", "Asia/Novokuznetsk"),
|
||||
("Asia/Novosibirsk", "Asia/Novosibirsk"),
|
||||
("Asia/Omsk", "Asia/Omsk"),
|
||||
("Asia/Oral", "Asia/Oral"),
|
||||
("Asia/Phnom_Penh", "Asia/Phnom_Penh"),
|
||||
("Asia/Pontianak", "Asia/Pontianak"),
|
||||
("Asia/Pyongyang", "Asia/Pyongyang"),
|
||||
("Asia/Qatar", "Asia/Qatar"),
|
||||
("Asia/Qostanay", "Asia/Qostanay"),
|
||||
("Asia/Qyzylorda", "Asia/Qyzylorda"),
|
||||
("Asia/Riyadh", "Asia/Riyadh"),
|
||||
("Asia/Sakhalin", "Asia/Sakhalin"),
|
||||
("Asia/Samarkand", "Asia/Samarkand"),
|
||||
("Asia/Seoul", "Asia/Seoul"),
|
||||
("Asia/Shanghai", "Asia/Shanghai"),
|
||||
("Asia/Singapore", "Asia/Singapore"),
|
||||
("Asia/Srednekolymsk", "Asia/Srednekolymsk"),
|
||||
("Asia/Taipei", "Asia/Taipei"),
|
||||
("Asia/Tashkent", "Asia/Tashkent"),
|
||||
("Asia/Tbilisi", "Asia/Tbilisi"),
|
||||
("Asia/Tehran", "Asia/Tehran"),
|
||||
("Asia/Thimphu", "Asia/Thimphu"),
|
||||
("Asia/Tokyo", "Asia/Tokyo"),
|
||||
("Asia/Tomsk", "Asia/Tomsk"),
|
||||
("Asia/Ulaanbaatar", "Asia/Ulaanbaatar"),
|
||||
("Asia/Urumqi", "Asia/Urumqi"),
|
||||
("Asia/Ust-Nera", "Asia/Ust-Nera"),
|
||||
("Asia/Vientiane", "Asia/Vientiane"),
|
||||
("Asia/Vladivostok", "Asia/Vladivostok"),
|
||||
("Asia/Yakutsk", "Asia/Yakutsk"),
|
||||
("Asia/Yangon", "Asia/Yangon"),
|
||||
("Asia/Yekaterinburg", "Asia/Yekaterinburg"),
|
||||
("Asia/Yerevan", "Asia/Yerevan"),
|
||||
("Atlantic/Azores", "Atlantic/Azores"),
|
||||
("Atlantic/Bermuda", "Atlantic/Bermuda"),
|
||||
("Atlantic/Canary", "Atlantic/Canary"),
|
||||
("Atlantic/Cape_Verde", "Atlantic/Cape_Verde"),
|
||||
("Atlantic/Faroe", "Atlantic/Faroe"),
|
||||
("Atlantic/Madeira", "Atlantic/Madeira"),
|
||||
("Atlantic/Reykjavik", "Atlantic/Reykjavik"),
|
||||
("Atlantic/South_Georgia", "Atlantic/South_Georgia"),
|
||||
("Atlantic/St_Helena", "Atlantic/St_Helena"),
|
||||
("Atlantic/Stanley", "Atlantic/Stanley"),
|
||||
("Australia/Adelaide", "Australia/Adelaide"),
|
||||
("Australia/Brisbane", "Australia/Brisbane"),
|
||||
("Australia/Broken_Hill", "Australia/Broken_Hill"),
|
||||
("Australia/Darwin", "Australia/Darwin"),
|
||||
("Australia/Eucla", "Australia/Eucla"),
|
||||
("Australia/Hobart", "Australia/Hobart"),
|
||||
("Australia/Lindeman", "Australia/Lindeman"),
|
||||
("Australia/Lord_Howe", "Australia/Lord_Howe"),
|
||||
("Australia/Melbourne", "Australia/Melbourne"),
|
||||
("Australia/Perth", "Australia/Perth"),
|
||||
("Australia/Sydney", "Australia/Sydney"),
|
||||
("Canada/Atlantic", "Canada/Atlantic"),
|
||||
("Canada/Central", "Canada/Central"),
|
||||
("Canada/Eastern", "Canada/Eastern"),
|
||||
("Canada/Mountain", "Canada/Mountain"),
|
||||
("Canada/Newfoundland", "Canada/Newfoundland"),
|
||||
("Canada/Pacific", "Canada/Pacific"),
|
||||
("Europe/Amsterdam", "Europe/Amsterdam"),
|
||||
("Europe/Andorra", "Europe/Andorra"),
|
||||
("Europe/Astrakhan", "Europe/Astrakhan"),
|
||||
("Europe/Athens", "Europe/Athens"),
|
||||
("Europe/Belgrade", "Europe/Belgrade"),
|
||||
("Europe/Berlin", "Europe/Berlin"),
|
||||
("Europe/Bratislava", "Europe/Bratislava"),
|
||||
("Europe/Brussels", "Europe/Brussels"),
|
||||
("Europe/Bucharest", "Europe/Bucharest"),
|
||||
("Europe/Budapest", "Europe/Budapest"),
|
||||
("Europe/Busingen", "Europe/Busingen"),
|
||||
("Europe/Chisinau", "Europe/Chisinau"),
|
||||
("Europe/Copenhagen", "Europe/Copenhagen"),
|
||||
("Europe/Dublin", "Europe/Dublin"),
|
||||
("Europe/Gibraltar", "Europe/Gibraltar"),
|
||||
("Europe/Guernsey", "Europe/Guernsey"),
|
||||
("Europe/Helsinki", "Europe/Helsinki"),
|
||||
("Europe/Isle_of_Man", "Europe/Isle_of_Man"),
|
||||
("Europe/Istanbul", "Europe/Istanbul"),
|
||||
("Europe/Jersey", "Europe/Jersey"),
|
||||
("Europe/Kaliningrad", "Europe/Kaliningrad"),
|
||||
("Europe/Kirov", "Europe/Kirov"),
|
||||
("Europe/Kyiv", "Europe/Kyiv"),
|
||||
("Europe/Lisbon", "Europe/Lisbon"),
|
||||
("Europe/Ljubljana", "Europe/Ljubljana"),
|
||||
("Europe/London", "Europe/London"),
|
||||
("Europe/Luxembourg", "Europe/Luxembourg"),
|
||||
("Europe/Madrid", "Europe/Madrid"),
|
||||
("Europe/Malta", "Europe/Malta"),
|
||||
("Europe/Mariehamn", "Europe/Mariehamn"),
|
||||
("Europe/Minsk", "Europe/Minsk"),
|
||||
("Europe/Monaco", "Europe/Monaco"),
|
||||
("Europe/Moscow", "Europe/Moscow"),
|
||||
("Europe/Oslo", "Europe/Oslo"),
|
||||
("Europe/Paris", "Europe/Paris"),
|
||||
("Europe/Podgorica", "Europe/Podgorica"),
|
||||
("Europe/Prague", "Europe/Prague"),
|
||||
("Europe/Riga", "Europe/Riga"),
|
||||
("Europe/Rome", "Europe/Rome"),
|
||||
("Europe/Samara", "Europe/Samara"),
|
||||
("Europe/San_Marino", "Europe/San_Marino"),
|
||||
("Europe/Sarajevo", "Europe/Sarajevo"),
|
||||
("Europe/Saratov", "Europe/Saratov"),
|
||||
("Europe/Simferopol", "Europe/Simferopol"),
|
||||
("Europe/Skopje", "Europe/Skopje"),
|
||||
("Europe/Sofia", "Europe/Sofia"),
|
||||
("Europe/Stockholm", "Europe/Stockholm"),
|
||||
("Europe/Tallinn", "Europe/Tallinn"),
|
||||
("Europe/Tirane", "Europe/Tirane"),
|
||||
("Europe/Ulyanovsk", "Europe/Ulyanovsk"),
|
||||
("Europe/Vaduz", "Europe/Vaduz"),
|
||||
("Europe/Vatican", "Europe/Vatican"),
|
||||
("Europe/Vienna", "Europe/Vienna"),
|
||||
("Europe/Vilnius", "Europe/Vilnius"),
|
||||
("Europe/Volgograd", "Europe/Volgograd"),
|
||||
("Europe/Warsaw", "Europe/Warsaw"),
|
||||
("Europe/Zagreb", "Europe/Zagreb"),
|
||||
("Europe/Zurich", "Europe/Zurich"),
|
||||
("GMT", "GMT"),
|
||||
("Indian/Antananarivo", "Indian/Antananarivo"),
|
||||
("Indian/Chagos", "Indian/Chagos"),
|
||||
("Indian/Christmas", "Indian/Christmas"),
|
||||
("Indian/Cocos", "Indian/Cocos"),
|
||||
("Indian/Comoro", "Indian/Comoro"),
|
||||
("Indian/Kerguelen", "Indian/Kerguelen"),
|
||||
("Indian/Mahe", "Indian/Mahe"),
|
||||
("Indian/Maldives", "Indian/Maldives"),
|
||||
("Indian/Mauritius", "Indian/Mauritius"),
|
||||
("Indian/Mayotte", "Indian/Mayotte"),
|
||||
("Indian/Reunion", "Indian/Reunion"),
|
||||
("Pacific/Apia", "Pacific/Apia"),
|
||||
("Pacific/Auckland", "Pacific/Auckland"),
|
||||
("Pacific/Bougainville", "Pacific/Bougainville"),
|
||||
("Pacific/Chatham", "Pacific/Chatham"),
|
||||
("Pacific/Chuuk", "Pacific/Chuuk"),
|
||||
("Pacific/Easter", "Pacific/Easter"),
|
||||
("Pacific/Efate", "Pacific/Efate"),
|
||||
("Pacific/Fakaofo", "Pacific/Fakaofo"),
|
||||
("Pacific/Fiji", "Pacific/Fiji"),
|
||||
("Pacific/Funafuti", "Pacific/Funafuti"),
|
||||
("Pacific/Galapagos", "Pacific/Galapagos"),
|
||||
("Pacific/Gambier", "Pacific/Gambier"),
|
||||
("Pacific/Guadalcanal", "Pacific/Guadalcanal"),
|
||||
("P2025-06-29T01:43:14.671389745Z acific/Guam", "Pacific/Guam"),
|
||||
("Pacific/Honolulu", "Pacific/Honolulu"),
|
||||
("Pacific/Kanton", "Pacific/Kanton"),
|
||||
("Pacific/Kiritimati", "Pacific/Kiritimati"),
|
||||
("Pacific/Kosrae", "Pacific/Kosrae"),
|
||||
("Pacific/Kwajalein", "Pacific/Kwajalein"),
|
||||
("Pacific/Majuro", "Pacific/Majuro"),
|
||||
("Pacific/Marquesas", "Pacific/Marquesas"),
|
||||
("Pacific/Midway", "Pacific/Midway"),
|
||||
("Pacific/Nauru", "Pacific/Nauru"),
|
||||
("Pacific/Niue", "Pacific/Niue"),
|
||||
("Pacific/Norfolk", "Pacific/Norfolk"),
|
||||
("Pacific/Noumea", "Pacific/Noumea"),
|
||||
("Pacific/Pago_Pago", "Pacific/Pago_Pago"),
|
||||
("Pacific/Palau", "Pacific/Palau"),
|
||||
("Pacific/Pitcairn", "Pacific/Pitcairn"),
|
||||
("Pacific/Pohnpei", "Pacific/Pohnpei"),
|
||||
("Pacific/Port_Moresby", "Pacific/Port_Moresby"),
|
||||
("Pacific/Rarotonga", "Pacific/Rarotonga"),
|
||||
("Pacific/Saipan", "Pacific/Saipan"),
|
||||
("Pacific/Tahiti", "Pacific/Tahiti"),
|
||||
("Pacific/Tarawa", "Pacific/Tarawa"),
|
||||
("Pacific/Tongatapu", "Pacific/Tongatapu"),
|
||||
("Pacific/Wake", "Pacific/Wake"),
|
||||
("Pacific/Wallis", "Pacific/Wallis"),
|
||||
("US/Alaska", "US/Alaska"),
|
||||
("US/Arizona", "US/Arizona"),
|
||||
("US/Central", "US/Central"),
|
||||
("US/Eastern", "US/Eastern"),
|
||||
("US/Hawaii", "US/Hawaii"),
|
||||
("US/Mountain", "US/Mountain"),
|
||||
("US/Pacific", "US/Pacific"),
|
||||
("UTC", "UTC"),
|
||||
]
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
username = None
|
||||
@@ -36,6 +474,11 @@ class UserSettings(models.Model):
|
||||
)
|
||||
hide_amounts = models.BooleanField(default=False)
|
||||
mute_sounds = models.BooleanField(default=False)
|
||||
volume = models.PositiveIntegerField(
|
||||
default=10,
|
||||
validators=[MinValueValidator(1), MaxValueValidator(10)],
|
||||
verbose_name=_("Volume"),
|
||||
)
|
||||
|
||||
date_format = models.CharField(
|
||||
max_length=100, default="SHORT_DATE_FORMAT", verbose_name=_("Date Format")
|
||||
@@ -57,7 +500,7 @@ class UserSettings(models.Model):
|
||||
)
|
||||
timezone = models.CharField(
|
||||
max_length=50,
|
||||
choices=[("auto", _("Auto"))] + [(tz, tz) for tz in pytz.common_timezones],
|
||||
choices=timezones,
|
||||
default="auto",
|
||||
verbose_name=_("Time Zone"),
|
||||
)
|
||||
|
||||
@@ -17,9 +17,34 @@ urlpatterns = [
|
||||
views.toggle_sound_playing,
|
||||
name="toggle_sound_playing",
|
||||
),
|
||||
path(
|
||||
"user/toggle-sidebar/",
|
||||
views.toggle_sidebar_status,
|
||||
name="toggle_sidebar_status",
|
||||
),
|
||||
path(
|
||||
"user/settings/",
|
||||
views.update_settings,
|
||||
name="user_settings",
|
||||
),
|
||||
path(
|
||||
"users/",
|
||||
views.users_index,
|
||||
name="users_index",
|
||||
),
|
||||
path(
|
||||
"users/list/",
|
||||
views.users_list,
|
||||
name="users_list",
|
||||
),
|
||||
path(
|
||||
"user/add/",
|
||||
views.user_add,
|
||||
name="user_add",
|
||||
),
|
||||
path(
|
||||
"user/<int:pk>/edit/",
|
||||
views.user_edit,
|
||||
name="user_edit",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import logout
|
||||
from django.contrib.auth import logout, get_user_model
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.views import (
|
||||
LoginView,
|
||||
)
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import redirect, render
|
||||
from django.shortcuts import redirect, render, get_object_or_404
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
from apps.common.decorators.user import is_superuser, htmx_login_required
|
||||
from apps.users.forms import (
|
||||
LoginForm,
|
||||
UserSettingsForm,
|
||||
UserUpdateForm,
|
||||
UserAddForm,
|
||||
)
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
from apps.users.models import UserSettings
|
||||
from apps.common.decorators.demo import disabled_on_demo
|
||||
|
||||
|
||||
def logout_view(request):
|
||||
@@ -22,7 +28,7 @@ def logout_view(request):
|
||||
return redirect(reverse("login"))
|
||||
|
||||
|
||||
@login_required
|
||||
@htmx_login_required
|
||||
def index(request):
|
||||
if request.user.settings.start_page == UserSettings.StartPage.MONTHLY:
|
||||
return redirect(reverse("monthly_index"))
|
||||
@@ -49,7 +55,7 @@ class UserLoginView(LoginView):
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@htmx_login_required
|
||||
def toggle_amount_visibility(request):
|
||||
user_settings, created = UserSettings.objects.get_or_create(user=request.user)
|
||||
current_hide_amounts = user_settings.hide_amounts
|
||||
@@ -70,7 +76,7 @@ def toggle_amount_visibility(request):
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@htmx_login_required
|
||||
def toggle_sound_playing(request):
|
||||
user_settings, created = UserSettings.objects.get_or_create(user=request.user)
|
||||
current_mute_sounds = user_settings.mute_sounds
|
||||
@@ -91,7 +97,7 @@ def toggle_sound_playing(request):
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@htmx_login_required
|
||||
def update_settings(request):
|
||||
user_settings = request.user.settings
|
||||
|
||||
@@ -108,3 +114,104 @@ def update_settings(request):
|
||||
form = UserSettingsForm(instance=user_settings)
|
||||
|
||||
return render(request, "users/fragments/user_settings.html", {"form": form})
|
||||
|
||||
|
||||
@only_htmx
|
||||
@htmx_login_required
|
||||
def toggle_sidebar_status(request):
|
||||
if not request.session.get("sidebar_status"):
|
||||
request.session["sidebar_status"] = "floating"
|
||||
|
||||
if request.session["sidebar_status"] == "floating":
|
||||
request.session["sidebar_status"] = "fixed"
|
||||
elif request.session["sidebar_status"] == "fixed":
|
||||
request.session["sidebar_status"] = "floating"
|
||||
else:
|
||||
request.session["sidebar_status"] = "fixed"
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
)
|
||||
|
||||
|
||||
@htmx_login_required
|
||||
@is_superuser
|
||||
@require_http_methods(["GET"])
|
||||
def users_index(request):
|
||||
return render(
|
||||
request,
|
||||
"users/pages/index.html",
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@htmx_login_required
|
||||
@is_superuser
|
||||
@require_http_methods(["GET"])
|
||||
def users_list(request):
|
||||
users = get_user_model().objects.all().order_by("id")
|
||||
|
||||
return render(
|
||||
request,
|
||||
"users/fragments/list.html",
|
||||
{"users": users},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@htmx_login_required
|
||||
@is_superuser
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def user_add(request):
|
||||
if request.method == "POST":
|
||||
form = UserAddForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Item added successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = UserAddForm()
|
||||
|
||||
return render(
|
||||
request,
|
||||
"users/fragments/add.html",
|
||||
{"form": form},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@htmx_login_required
|
||||
@disabled_on_demo
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def user_edit(request, pk):
|
||||
user = get_object_or_404(get_user_model(), id=pk)
|
||||
|
||||
if not request.user.is_superuser and user != request.user:
|
||||
raise PermissionDenied
|
||||
|
||||
if request.method == "POST":
|
||||
form = UserUpdateForm(request.POST, instance=user)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Item updated successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = UserUpdateForm(instance=user)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"users/fragments/edit.html",
|
||||
{"form": form, "user": user},
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ from django.urls import path
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path("yearly/", views.index, name="yearly_index"),
|
||||
path("yearly/currency/", views.index_by_currency, name="yearly_index_currency"),
|
||||
path("yearly/account/", views.index_by_account, name="yearly_index_account"),
|
||||
path(
|
||||
|
||||
@@ -16,6 +16,22 @@ from apps.transactions.utils.calculations import (
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def index(request):
|
||||
if "view_type" in request.GET:
|
||||
view_type = request.GET["view_type"]
|
||||
request.session["yearly_view_type"] = view_type
|
||||
else:
|
||||
view_type = request.session.get("yearly_view_type", "currency")
|
||||
|
||||
now = timezone.localdate(timezone.now())
|
||||
|
||||
if view_type == "currency":
|
||||
return redirect(to="yearly_overview_currency", year=now.year)
|
||||
else:
|
||||
return redirect(to="yearly_overview_account", year=now.year)
|
||||
|
||||
|
||||
@login_required
|
||||
def index_by_currency(request):
|
||||
now = timezone.localdate(timezone.now())
|
||||
@@ -32,6 +48,8 @@ def index_by_account(request):
|
||||
|
||||
@login_required
|
||||
def index_yearly_overview_by_currency(request, year: int):
|
||||
request.session["yearly_view_type"] = "currency"
|
||||
|
||||
next_year = year + 1
|
||||
previous_year = year - 1
|
||||
|
||||
@@ -49,6 +67,7 @@ def index_yearly_overview_by_currency(request, year: int):
|
||||
"previous_year": previous_year,
|
||||
"months": month_options,
|
||||
"currencies": currency_options,
|
||||
"type": "currency",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -60,7 +79,7 @@ def yearly_overview_by_currency(request, year: int):
|
||||
currency = request.GET.get("currency")
|
||||
|
||||
# Base query filter
|
||||
filter_params = {"reference_date__year": year, "account__is_archived": False}
|
||||
filter_params = {"reference_date__year": year}
|
||||
|
||||
# Add month filter if provided
|
||||
if month:
|
||||
@@ -75,7 +94,8 @@ def yearly_overview_by_currency(request, year: int):
|
||||
|
||||
transactions = (
|
||||
Transaction.objects.filter(**filter_params)
|
||||
.exclude(Q(category__mute=True) & ~Q(category=None))
|
||||
.exclude(Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True))
|
||||
.exclude(account__in=request.user.untracked_accounts.all())
|
||||
.order_by("account__currency__name")
|
||||
)
|
||||
|
||||
@@ -95,6 +115,7 @@ def yearly_overview_by_currency(request, year: int):
|
||||
|
||||
@login_required
|
||||
def index_yearly_overview_by_account(request, year: int):
|
||||
request.session["yearly_view_type"] = "account"
|
||||
next_year = year + 1
|
||||
previous_year = year - 1
|
||||
|
||||
@@ -115,6 +136,7 @@ def index_yearly_overview_by_account(request, year: int):
|
||||
"previous_year": previous_year,
|
||||
"months": month_options,
|
||||
"accounts": account_options,
|
||||
"type": "account",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -141,7 +163,7 @@ def yearly_overview_by_account(request, year: int):
|
||||
|
||||
transactions = (
|
||||
Transaction.objects.filter(**filter_params)
|
||||
.exclude(Q(category__mute=True) & ~Q(category=None))
|
||||
.exclude(Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True))
|
||||
.order_by(
|
||||
"account__group__name",
|
||||
"account__name",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
4031
app/locale/es/LC_MESSAGES/django.po
Normal file
4031
app/locale/es/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
3456
app/locale/id/LC_MESSAGES/django.po
Normal file
3456
app/locale/id/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
3518
app/locale/it/LC_MESSAGES/django.po
Normal file
3518
app/locale/it/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user