mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-05-17 13:17:05 +02:00
Compare commits
603 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 64b32316ca | |||
| efe020efb3 | |||
| 8c133f92ce | |||
| f3c9d8faea | |||
| 8f5204a17b | |||
| 2235bdeabb | |||
| d724300513 | |||
| eacafa1def | |||
| c738f5ee29 | |||
| 72904266bf | |||
| 670bee4325 | |||
| 3e2c1184ce | |||
| 731f351eef | |||
| b7056e7aa1 | |||
| accceed630 | |||
| 76346cb503 | |||
| 3df8952ea2 | |||
| 9bd067da96 | |||
| 1abe9e9f62 | |||
| 1a86b5dea4 | |||
| 8f2f5a16c2 | |||
| 4565dc770b | |||
| 23673def09 | |||
| dd2b9ead7e | |||
| 2078e9f3e4 | |||
| e6bab57ab4 | |||
| 38d50a78f4 | |||
| 0d947f9ba6 | |||
| 99c85a56bb | |||
| ab1c074f27 | |||
| abf3a148cc | |||
| 2733c92da5 | |||
| 9bfbe54ed5 | |||
| 5b27dea07c | |||
| 791e1000a3 | |||
| 7301d9f475 | |||
| 47a44e96f8 | |||
| 7d247eb737 | |||
| 373616e7bb | |||
| bf3c11d582 | |||
| b4b1c10db9 | |||
| 5ca531f47d | |||
| 07673cb528 | |||
| 67c6d81897 | |||
| 3c85da46b0 | |||
| d263936be7 | |||
| 1524063d5a | |||
| c3a403b8f0 | |||
| 1c1018adae | |||
| 350d5adf25 | |||
| 7e4defb9cc | |||
| 7121e4bc09 | |||
| 4540e48fe5 | |||
| d06b51421f | |||
| e096912e41 | |||
| f0ad6e16fe | |||
| 734a302fa7 | |||
| 89b1b7bcb7 | |||
| 37b40f89bb | |||
| 0c63552d1b | |||
| 7db517e848 | |||
| 7e3ed6cf94 | |||
| e10a88c00e | |||
| b912a33b93 | |||
| d9fb3627cc | |||
| 78ffa68ba4 | |||
| 37f4ead058 | |||
| 61630fca5b | |||
| 910d4c84a3 | |||
| be1f29d8c1 | |||
| 9784d840cc | |||
| db5ce13ff3 | |||
| a2b943d949 | |||
| d8098b4486 | |||
| f8cff6623f | |||
| 65c61f76ff | |||
| 74899f63ab | |||
| 66a5e6d613 | |||
| e0ab32ec03 | |||
| a912e4a511 | |||
| 57ba672c91 | |||
| 20c6989ffb | |||
| c6cd525c49 | |||
| 55c4b920ee | |||
| 7f8261b9cc | |||
| 9102654eab | |||
| 1ff49a8a04 | |||
| 846dd1fd73 | |||
| 9eed3b6692 | |||
| b7c53a3c2d | |||
| b378c8f6f7 | |||
| ccc4deb1d8 | |||
| d3ecf55375 | |||
| 580f3e7345 | |||
| 0e5843094b | |||
| ed65945d19 | |||
| 18d8837c64 | |||
| 067d819077 | |||
| bbaae4746a | |||
| d2e5c1d6b3 | |||
| ffef61d514 | |||
| 9020f6f972 | |||
| 540235c1b0 | |||
| 9070bc5705 | |||
| ba5a6c9772 | |||
| 2f33b5989f | |||
| 5f24d05540 | |||
| 31cf62e277 | |||
| 15d990007e | |||
| 3d5bc9cd3f | |||
| a544dc4943 | |||
| b1178198e9 | |||
| 02a488bfff | |||
| b05285947b | |||
| d7b7dd28c7 | |||
| 9353d498ef | |||
| 4f6903e8e4 | |||
| 7d3d6ea2fc | |||
| cce9c7a7a5 | |||
| f80f74a01a | |||
| df47ffc49c | |||
| 4f35647a22 | |||
| 368342853f | |||
| 5a675f674d | |||
| 9ef8fdec49 | |||
| f29a8d8bc0 | |||
| 8c43365ec0 | |||
| 2cdcc4ee26 | |||
| 84852012f9 | |||
| edf0e2c66f | |||
| f90a31f2b9 | |||
| dd1f6a6ef2 | |||
| 57f98ba171 | |||
| f2e93f7df9 | |||
| 26cfa493b3 | |||
| c6e003ed86 | |||
| c09ad0e49d | |||
| 9250131396 | |||
| 5f503149ce | |||
| d45b4f2942 | |||
| 4a8493c7d9 | |||
| c39c3ccacb | |||
| 4bb8ef6582 | |||
| d711ccca69 | |||
| 76d59f1038 | |||
| 5b6c123fa1 | |||
| 782ab11ae4 | |||
| 8db885f47d | |||
| 01bd8710d8 | |||
| 569d08711c | |||
| a285f055e4 | |||
| 6aae9b1207 | |||
| 9d2206f8a4 | |||
| d7e3c50c2c | |||
| 789fd4eb80 | |||
| 586b3a5d44 | |||
| 9248e8bd77 | |||
| c44247f6a5 | |||
| 8ba89434f8 | |||
| f2f41981a3 | |||
| 1153fd6b0a | |||
| 76822224a0 | |||
| 31b2b98eb9 | |||
| d7a4e79321 | |||
| 985f07e792 | |||
| 5465bb1eeb | |||
| 451a85a998 | |||
| 54c74e7c07 | |||
| d6e9e123b7 | |||
| 80c9c43a02 | |||
| 3e34f088fc | |||
| 5b9e5c6003 | |||
| c266b8809f | |||
| 8cda4116bc | |||
| c2510b2261 | |||
| dcdaf756f9 | |||
| 50ca08165a | |||
| f85618fa01 | |||
| 635f87a8ad | |||
| 1a073ba53d | |||
| 5412e5b12c | |||
| 2103ba1b38 | |||
| 04fb15224c | |||
| 2fc526beac | |||
| cc3ca4f4a3 | |||
| 8d3844c431 | |||
| 5e7e918085 | |||
| c3f02320b5 | |||
| da8bbbfb0b | |||
| e3f74538d2 | |||
| d8234950c6 | |||
| 58f19ce1ca | |||
| ef5f3580a0 | |||
| efe0f99cb4 | |||
| dccb5079ad | |||
| 6c90150661 | |||
| c33d6fab69 | |||
| c0c57a6d77 | |||
| f19d58a2bd | |||
| dfe99093e9 | |||
| d737e573cc | |||
| 805d3f419e | |||
| 9777aac746 | |||
| 61b782104d | |||
| 79dec2b515 | |||
| db23e162c4 | |||
| d81d89d9f6 | |||
| 6826cfe79a | |||
| 0832ec75ca | |||
| 3090f632de | |||
| 6b4fbee7a6 | |||
| e7fe6622cd | |||
| 3017593ed5 | |||
| ceb8e9ea97 | |||
| 9b5b7683dd | |||
| 514600e34a | |||
| 07dd805b07 | |||
| 905e9b4c54 | |||
| 60d367dec5 | |||
| 6e0842a697 | |||
| 858934b7c5 | |||
| 47d9e4078c | |||
| fa6f3e87c0 | |||
| 5f101af879 | |||
| b27633a28e | |||
| 7716eee0b3 | |||
| 37c447ae0a | |||
| e544d7068b | |||
| 8d93da99c1 | |||
| cc87477a2e | |||
| e86e0b8c08 | |||
| eb0c872c50 | |||
| b4578df242 | |||
| 756de12835 | |||
| d573d02657 | |||
| 250b352217 | |||
| b4e9446cf6 | |||
| 90944f0179 | |||
| 008d34b1d0 | |||
| 46dfc7dad4 | |||
| 22900b5d9e | |||
| 0c48e9fe3d | |||
| b2e100d1b0 | |||
| e49b38a442 | |||
| 1f2902eea9 | |||
| 7d60db8716 | |||
| 873b0baed7 | |||
| 2313c97761 | |||
| 9cd7337153 | |||
| d3b354e2b8 | |||
| e137666e99 | |||
| 4291a5b97d | |||
| c8d316857f | |||
| 3395a96949 | |||
| 8ab9624619 | |||
| f9056c3a45 | |||
| a9df684ee2 | |||
| e4d07c94d4 | |||
| 5d5d172b3b | |||
| 99f746b6be | |||
| a461a33dc2 | |||
| 1213ffebeb | |||
| c5a352cf4d | |||
| cfcca54aa6 | |||
| 234f8cd669 | |||
| 43184140f0 | |||
| acc325c150 | |||
| 46eb471a34 | |||
| 6dc14c73d6 | |||
| f942924e7c | |||
| aa6019e0a9 | |||
| 9dfbd346bc | |||
| 73b1d36dfd | |||
| 3662fb030a | |||
| a423ee1032 | |||
| 72eb59d24f | |||
| 1a0247e028 | |||
| 281a0fccda | |||
| 59ce50299a | |||
| be89509beb | |||
| 80cded234d | |||
| 030bb63586 | |||
| 66e8fc5884 | |||
| 363047337d | |||
| c7e32d1576 | |||
| 157e59a1d1 | |||
| d9c505ac79 | |||
| 7274a13f3c | |||
| 5d64665ddd | |||
| e0d92d15c8 | |||
| 48dd658627 | |||
| 80dbbd02f0 | |||
| 4b7ca61c29 | |||
| b2f04ae1f9 | |||
| f34d4b5e28 | |||
| d2ebfbd615 | |||
| 812abbe488 | |||
| 9602a4affc | |||
| bf548c0747 | |||
| 55ad2be08b | |||
| 2cd58c2464 | |||
| 4675ba9d56 | |||
| a25c992d5c | |||
| 2eadfe99a5 | |||
| 11086a726f | |||
| cd99b40b0a | |||
| 63aa51dc0d | |||
| 4708c5bc7e | |||
| 5a8462c050 | |||
| 6cac02e01f | |||
| 8d12ceeebb | |||
| 4681d3ca1d | |||
| 60ded03ea9 | |||
| b20d137dc3 | |||
| 29ca6eed6c | |||
| fa85303f36 | |||
| a5f4f43678 | |||
| d807bd5da3 | |||
| 85314fb749 | |||
| c4d5e93a41 | |||
| 86f0c4365e | |||
| 202592b940 | |||
| aea149bd13 | |||
| 411365f101 | |||
| 2008476021 | |||
| 53afe5b8eb | |||
| 6193c7a048 | |||
| 41f81d90d7 | |||
| bf623cf16b | |||
| ec213330cd | |||
| 7aedf524c6 | |||
| 04602b1964 | |||
| 15cfc4f300 | |||
| 3463c7c62c | |||
| 7b76c10093 | |||
| 7ad26a2e7b | |||
| 7706ca2d5f | |||
| 56198e93ce | |||
| a74323f739 | |||
| e4efde177b | |||
| 5871a03ee2 | |||
| 67af4430e1 | |||
| 696dcdf951 | |||
| e35bad0e08 | |||
| 904f7cac22 | |||
| ccd73963ca | |||
| b5469b0413 | |||
| dae848d951 | |||
| 45a33ad0c0 | |||
| 89e50b17bd | |||
| ac54ba3da1 | |||
| 2da610f15e | |||
| 4ab6c4c6b6 | |||
| 68dbedd938 | |||
| 2800c53346 | |||
| 132547a074 | |||
| 61ed87dc45 | |||
| 96c1227c4f | |||
| 33f1ac1785 | |||
| e9e94a8343 | |||
| ba24a53853 | |||
| 4955fbde33 | |||
| d04067a91d | |||
| 01333a439b | |||
| d26907ea94 | |||
| c98d9d3ce9 | |||
| bfa4d3dea3 | |||
| 90323049eb | |||
| b62122ed23 | |||
| f74946cba7 | |||
| 585652064a | |||
| ea6f61d5e4 | |||
| e986f7d802 | |||
| 26b218ae51 | |||
| 19f0bc1034 | |||
| 47d34f3c27 | |||
| 046e02d506 | |||
| 92c7a29b6a | |||
| d95e5f71cc | |||
| 992c518dab | |||
| 29aa1c9d2b | |||
| 1b3b7a583d | |||
| 2d22f961ad | |||
| 71551d7651 | |||
| 62d58d1be3 | |||
| 21917437f2 | |||
| 59acb14d05 | |||
| 050f794f2b | |||
| a5958c0937 | |||
| ee73ada5ae | |||
| 736a116685 | |||
| 6c03c7b4eb | |||
| 960e537709 | |||
| e32285ce75 | |||
| 73e8fdbf04 | |||
| d4c15da051 | |||
| 187b3174d2 | |||
| c90ea7ef16 | |||
| 54713ecfe2 | |||
| cf693aa0c3 | |||
| 3580f1b132 | |||
| febd9a8ae7 | |||
| 3809f82b60 | |||
| 3c6b52462a | |||
| cc8a4c97a9 | |||
| 99fbb5f7db | |||
| 3d61068ecf | |||
| f6f06f4d65 | |||
| 56346c26ee | |||
| 23b74d73e5 | |||
| 17697dc565 | |||
| e9bc35d9b2 | |||
| d6fbb71f41 | |||
| 9a9cf75bcd | |||
| d6a8658fe1 | |||
| 211963ea7d | |||
| 776068a438 | |||
| 621799f445 | |||
| 124d29e965 | |||
| bf4d23f15e | |||
| 020dd74f80 | |||
| c7d70a1748 | |||
| 1025b80dda | |||
| 1ae245fe01 | |||
| 46c5efb8a9 | |||
| abb0993435 | |||
| a9e7692f99 | |||
| 531571798a | |||
| 7282aa20ee | |||
| 13f9950afa | |||
| 672cc5ebc7 | |||
| 8045e2c73a | |||
| 7c042d9299 | |||
| aba47f0eed | |||
| 2010ccc92d | |||
| d73d6cbf22 | |||
| e5a9b6e921 | |||
| dbd9774681 | |||
| 5a93a907e1 | |||
| e0e159166b | |||
| 6c7594ad14 | |||
| d3ea0e43da | |||
| dde75416ca | |||
| c9b346b791 | |||
| 9896044a15 | |||
| eb65eb4590 | |||
| 017c70e8b2 | |||
| 64b0830909 | |||
| 25d99cbece | |||
| 033f0e1b0d | |||
| 35027ee0ae | |||
| 91904e959b | |||
| a6a85ae3a2 | |||
| b0f53f45f9 | |||
| 0f60f8d486 | |||
| efb207a109 | |||
| 95b1481dd5 | |||
| 8de340b68b | |||
| ef15b85386 | |||
| 45d939237d | |||
| 6bf262e514 | |||
| f9d9137336 | |||
| b532521f27 | |||
| 1e06e2d34d | |||
| a33fa5e184 | |||
| a2453695d8 | |||
| 3e929d0433 | |||
| 185fc464a5 | |||
| 647c009525 | |||
| ba75492dcc | |||
| 8312baaf45 | |||
| 4d346dc278 | |||
| 70ff7fab38 | |||
| 6947c6affd | |||
| dcab83f936 | |||
| b228e4ec26 | |||
| 4071a1301f | |||
| 5c9db10710 | |||
| 19c92e0014 | |||
| 6459f2eb46 | |||
| 7926e081ef | |||
| ceefe7075f | |||
| ad3230fd83 | |||
| c89b07ed93 | |||
| 201ccea842 | |||
| 32ada488b4 | |||
| 794d11a355 | |||
| 67f8f5fe89 | |||
| 9ac69fd92a | |||
| 069f1b450c | |||
| 2f388af928 | |||
| beeb0579ce | |||
| a8666da57b | |||
| 835316d0f3 | |||
| f5feeb9617 | |||
| 09e380a480 | |||
| 3080df9b66 | |||
| ebc41a8049 | |||
| 635628e30e | |||
| 819a58ac06 | |||
| d433375522 | |||
| c0150f71a8 | |||
| 6119698d38 | |||
| f5ae231601 | |||
| 972d23abbd | |||
| 9a514a8a69 | |||
| 7325231548 | |||
| 570657371a | |||
| 67da60b5b0 | |||
| 84c047c5ab | |||
| 23f5d09bec | |||
| 2a19075e23 | |||
| 7f231175b2 | |||
| 062e84f864 | |||
| 5521eb20bf | |||
| 627b5d250b | |||
| 195a8a68d6 | |||
| daf1f68b82 | |||
| dd24fd56d3 | |||
| 7a2acb6497 | |||
| 9c339faa72 | |||
| 02376ad02b | |||
| b53a4a0286 | |||
| a1f618434b | |||
| 7b5be29f0d | |||
| 56a73b181a | |||
| 865618e054 | |||
| 9e912b2736 | |||
| da7680e70f | |||
| ab594eb511 | |||
| cffaaa369a | |||
| 5f414e82ee | |||
| f3bcef534e | |||
| d140ff5b70 | |||
| 7eceacfe68 | |||
| 038438fba7 | |||
| ee98a5ef12 | |||
| 28b12faaf0 | |||
| d0f2742637 | |||
| 9c55dac866 | |||
| e6d8b548b7 | |||
| 4f8c2215c1 | |||
| 851b34f07a | |||
| 546ed5c6af | |||
| 04ae7337f5 | |||
| a3a8791e96 | |||
| 63069f0ec9 | |||
| 32b522dad2 | |||
| 0c20a079e3 | |||
| 7c9697f683 | |||
| 15d04230ae | |||
| ecc09ca6a6 | |||
| cd753c5dd5 | |||
| a3b9952f80 | |||
| e93969c035 | |||
| 6ec5b5df1e | |||
| 93e7adeea8 | |||
| 37b5a43c1f | |||
| 87a07c25d1 | |||
| 9e27fef5e5 | |||
| 2cbba53e06 | |||
| d9e8be7efb | |||
| 7dc9ef9950 | |||
| 00e83cf6a2 | |||
| 039242b48a | |||
| 94e2bdf93d | |||
| 79b387ce60 | |||
| 43eb87d3ba | |||
| 0110220b72 | |||
| f5c86f3d97 | |||
| 7b7f58d34d | |||
| 86112931d9 | |||
| e6e0e4caea | |||
| 942154480e | |||
| 467131d9f1 | |||
| fee1db8660 | |||
| 4f7fc1c9c8 | |||
| f788709f97 | |||
| 1a0de32ef8 | |||
| 8315adeb4a | |||
| 5296820d46 | |||
| d5f5053821 | |||
| 852ffd5634 | |||
| 8cb3f51ea4 | |||
| 62bfaaa62a | |||
| dd1d4292d3 | |||
| 93bb34166e | |||
| 8f311d9924 | |||
| a5a9f838f5 | |||
| 6c17b3babb | |||
| d207760ae9 | |||
| 996e0ee0eb | |||
| 80edf557cb | |||
| 2f3207b1f6 | |||
| 7b95c806fb | |||
| 06e9383689 | |||
| 56862cd025 | |||
| 35782cf14c | |||
| f7768c8658 | |||
| 7f8fe6a516 | |||
| aa8abe0e1c | |||
| 3190f3ae09 | |||
| 757f6647da |
@@ -1,6 +1,8 @@
|
|||||||
SERVER_NAME=wygiwyh_server
|
SERVER_NAME=wygiwyh_server
|
||||||
DB_NAME=wygiwyh_pg
|
DB_NAME=wygiwyh_pg
|
||||||
|
|
||||||
|
TZ=UTC # Change to your timezone. This only affects some async tasks.
|
||||||
|
|
||||||
DEBUG=false
|
DEBUG=false
|
||||||
URL = https://...
|
URL = https://...
|
||||||
HTTPS_ENABLED=true
|
HTTPS_ENABLED=true
|
||||||
@@ -8,6 +10,11 @@ SECRET_KEY=<GENERATE A SAFE SECRET KEY AND PLACE IT HERE>
|
|||||||
DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1]
|
DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1]
|
||||||
OUTBOUND_PORT=9005
|
OUTBOUND_PORT=9005
|
||||||
|
|
||||||
|
# Uncomment these variables to automatically create an admin account using these credentials on startup.
|
||||||
|
# After your first successfull login you can remove these variables from your file for safety reasons.
|
||||||
|
#ADMIN_EMAIL=<ENTER YOUR EMAIL>
|
||||||
|
#ADMIN_PASSWORD=<YOUR SAFE PASSWORD>
|
||||||
|
|
||||||
SQL_DATABASE=wygiwyh
|
SQL_DATABASE=wygiwyh
|
||||||
SQL_USER=wygiwyh
|
SQL_USER=wygiwyh
|
||||||
SQL_PASSWORD=<INSERT A SAFE PASSWORD HERE>
|
SQL_PASSWORD=<INSERT A SAFE PASSWORD HERE>
|
||||||
@@ -24,3 +31,10 @@ ENABLE_SOFT_DELETE=false
|
|||||||
KEEP_DELETED_TRANSACTIONS_FOR=365
|
KEEP_DELETED_TRANSACTIONS_FOR=365
|
||||||
|
|
||||||
TASK_WORKERS=1 # This only work if you're using the single container option. Increase to have more open queues via procrastinate, you probably don't need to increase this.
|
TASK_WORKERS=1 # This only work if you're using the single container option. Increase to have more open queues via procrastinate, you probably don't need to increase this.
|
||||||
|
|
||||||
|
# OIDC Configuration. Uncomment the lines below if you want to add OIDC login to your instance
|
||||||
|
#OIDC_CLIENT_NAME=""
|
||||||
|
#OIDC_CLIENT_ID=""
|
||||||
|
#OIDC_CLIENT_SECRET=""
|
||||||
|
#OIDC_SERVER_URL=""
|
||||||
|
#OIDC_ALLOW_SIGNUP=true
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
github: eitchtee
|
||||||
|
custom: ["https://www.paypal.com/donate/?hosted_button_id=FFWM4W9NQDMM6"]
|
||||||
@@ -20,6 +20,10 @@ on:
|
|||||||
env:
|
env:
|
||||||
IMAGE_NAME: wygiwyh
|
IMAGE_NAME: wygiwyh
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
name: Django Translation Update
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
# Add manual trigger
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
reason:
|
||||||
|
description: 'Reason for running'
|
||||||
|
required: false
|
||||||
|
default: 'Manual update of translation files'
|
||||||
|
|
||||||
|
# Ensure only one translation job runs at a time
|
||||||
|
concurrency:
|
||||||
|
group: django-translations
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update-translations:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
# Skip on PRs from forks (which don't have write permissions)
|
||||||
|
# Allow manual runs and pushes to main
|
||||||
|
if: github.event_name == 'workflow_dispatch' || github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.PAT }}
|
||||||
|
ref: ${{ github.head_ref }}
|
||||||
|
|
||||||
|
- name: Set up Python 3.11
|
||||||
|
uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
- name: Install gettext
|
||||||
|
run: sudo apt-get install -y gettext
|
||||||
|
|
||||||
|
- name: Run makemessages
|
||||||
|
run: |
|
||||||
|
cd app
|
||||||
|
python manage.py makemessages -a
|
||||||
|
|
||||||
|
- name: Check for changes
|
||||||
|
id: check_changes
|
||||||
|
run: |
|
||||||
|
if git diff --exit-code --quiet app/locale/; then
|
||||||
|
echo "No translation changes detected"
|
||||||
|
else
|
||||||
|
echo "changes_detected=true" >> $GITHUB_OUTPUT
|
||||||
|
echo "Translation changes detected"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Commit translation files
|
||||||
|
if: steps.check_changes.outputs.changes_detected == 'true'
|
||||||
|
uses: stefanzweifel/git-auto-commit-action@v5
|
||||||
|
with:
|
||||||
|
push_options: --force
|
||||||
|
commit_message: |
|
||||||
|
chore(locale): update translation files
|
||||||
|
|
||||||
|
[skip ci] Automatically generated by Django makemessages workflow
|
||||||
|
file_pattern: "app/locale/**/*.po"
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
<a href="#key-features">Features</a> •
|
<a href="#key-features">Features</a> •
|
||||||
<a href="#how-to-use">Usage</a> •
|
<a href="#how-to-use">Usage</a> •
|
||||||
<a href="#how-it-works">How</a> •
|
<a href="#how-it-works">How</a> •
|
||||||
|
<a href="#help-us-translate-wygiwyh">Translate</a> •
|
||||||
<a href="#caveats-and-warnings">Caveats and Warnings</a> •
|
<a href="#caveats-and-warnings">Caveats and Warnings</a> •
|
||||||
<a href="#built-with">Built with</a>
|
<a href="#built-with">Built with</a>
|
||||||
</p>
|
</p>
|
||||||
@@ -28,15 +29,15 @@ Managing money can feel unnecessarily complex, but it doesn’t have to be. WYGI
|
|||||||
|
|
||||||
By sticking to this straightforward approach, you avoid dipping into your savings while still keeping tabs on where your money goes.
|
By sticking to this straightforward approach, you avoid dipping into your savings while still keeping tabs on where your money goes.
|
||||||
|
|
||||||
While this philosophy is simple, finding tools to make it work wasn’t. I initially used a spreadsheet, which served me well for years—until it became unwieldy as I started managing multiple currencies, accounts, and investments. I tried various financial management apps, but none met my key requirements:
|
While this philosophy is simple, finding tools to make it work wasn’t. I initially used a spreadsheet, which served me well for years, until it became unwieldy as I started managing multiple currencies, accounts, and investments. I tried various financial management apps, but none met my key requirements:
|
||||||
|
|
||||||
1. **Multi-currency support** to track income and expenses in different currencies.
|
1. **Multi-currency support** to track income and expenses in different currencies.
|
||||||
2. **Not a budgeting app** — as I dislike budgeting constraints.
|
2. **Not a budgeting app** as I dislike budgeting constraints.
|
||||||
3. **Web app usability** (ideally with mobile support, though optional).
|
3. **Web app usability** (ideally with mobile support, though optional).
|
||||||
4. **Automation-ready API** to integrate with other tools and services.
|
4. **Automation-ready API** to integrate with other tools and services.
|
||||||
5. **Custom transaction rules** for credit card billing cycles or similar quirks.
|
5. **Custom transaction rules** for credit card billing cycles or similar quirks.
|
||||||
|
|
||||||
Frustrated by the lack of comprehensive options, I set out to build **WYGIWYH** — an opinionated yet powerful tool that I believe will resonate with like-minded users.
|
Frustrated by the lack of comprehensive options, I set out to build **WYGIWYH**, an opinionated yet powerful tool that I believe will resonate with like-minded users.
|
||||||
|
|
||||||
# Key Features
|
# Key Features
|
||||||
|
|
||||||
@@ -50,6 +51,17 @@ Frustrated by the lack of comprehensive options, I set out to build **WYGIWYH**
|
|||||||
* **Built-in Dollar-Cost Average (DCA) tracker**: Essential for tracking recurring investments, especially for crypto and stocks.
|
* **Built-in Dollar-Cost Average (DCA) tracker**: Essential for tracking recurring investments, especially for crypto and stocks.
|
||||||
* **API support for automation**: Seamlessly integrate with existing services to synchronize transactions.
|
* **API support for automation**: Seamlessly integrate with existing services to synchronize transactions.
|
||||||
|
|
||||||
|
# Demo
|
||||||
|
|
||||||
|
You can try WYGIWYH on [wygiwyh-demo.herculino.com](https://wygiwyh-demo.herculino.com/) with the credentials below:
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> E-mail: `demo@demo.com`
|
||||||
|
>
|
||||||
|
> Password: `wygiwyhdemo`
|
||||||
|
|
||||||
|
Keep in mind that **any data you add will be wiped in 24 hours or less**. And that **most automation features like the API, Rules, Automatic Exchange Rates and Import/Export are disabled**.
|
||||||
|
|
||||||
# How To Use
|
# How To Use
|
||||||
|
|
||||||
To run this application, you'll need [Docker](https://docs.docker.com/engine/install/) with [docker-compose](https://docs.docker.com/compose/install/).
|
To run this application, you'll need [Docker](https://docs.docker.com/engine/install/) with [docker-compose](https://docs.docker.com/compose/install/).
|
||||||
@@ -75,10 +87,13 @@ $ nano .env # or any other editor you want to use
|
|||||||
# Run the app
|
# Run the app
|
||||||
$ docker compose up -d
|
$ docker compose up -d
|
||||||
|
|
||||||
# Create the first admin account
|
# Create the first admin account. This isn't required if you set the enviroment variables: ADMIN_EMAIL and ADMIN_PASSWORD.
|
||||||
$ docker compose exec -it web python manage.py createsuperuser
|
$ docker compose exec -it web python manage.py createsuperuser
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> If you're using Unraid, you don't need to follow these steps, use the app on the store. Make sure to read the [Unraid section](#unraid) and [Environment Variables](#environment-variables) for an explanation of all available variables
|
||||||
|
|
||||||
## Running locally
|
## Running locally
|
||||||
|
|
||||||
If you want to run WYGIWYH locally, on your env file:
|
If you want to run WYGIWYH locally, on your env file:
|
||||||
@@ -105,7 +120,9 @@ All the required Dockerfiles are [here](https://github.com/eitchtee/WYGIWYH/tree
|
|||||||
|
|
||||||
WYGIWYH is available on the Unraid Store. You'll need to provision your own postgres (version 15 or up) database.
|
WYGIWYH is available on the Unraid Store. You'll need to provision your own postgres (version 15 or up) database.
|
||||||
|
|
||||||
## Enviroment Variables
|
To create the first user, open the container's console using Unraid's UI, by clicking on WYGIWYH icon on the Docker page and selecting `Console`, then type `python manage.py createsuperuser`, you'll them be prompted to input your e-mail and password.
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
| variable | type | default | explanation |
|
| variable | type | default | explanation |
|
||||||
|-------------------------------|-------------|-----------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|-------------------------------|-------------|-----------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
@@ -117,17 +134,54 @@ WYGIWYH is available on the Unraid Store. You'll need to provision your own post
|
|||||||
| SQL_DATABASE | string | None *required | The name of your postgres database |
|
| SQL_DATABASE | string | None *required | The name of your postgres database |
|
||||||
| SQL_USER | string | user | The username used to connect to your postgres database |
|
| SQL_USER | string | user | The username used to connect to your postgres database |
|
||||||
| SQL_PASSWORD | string | password | The password used to connect to your postgres database |
|
| SQL_PASSWORD | string | password | The password used to connect to your postgres database |
|
||||||
| SQL_HOST | string | localhost | The adress used to connect to your postgres database |
|
| SQL_HOST | string | localhost | The address used to connect to your postgres database |
|
||||||
| SQL_PORT | string | 5432 | The port used to connect to your postgres database |
|
| SQL_PORT | string | 5432 | The port used to connect to your postgres database |
|
||||||
| SESSION_EXPIRY_TIME | int | 2678400 (31 days) | The age of session cookies, in seconds. E.g. how long you will stay logged in |
|
| SESSION_EXPIRY_TIME | int | 2678400 (31 days) | The age of session cookies, in seconds. E.g. how long you will stay logged in |
|
||||||
| ENABLE_SOFT_DELETE | true\|false | false | Whether to enable transactions soft delete, if enabled, deleted transactions will remain in the database. Useful for imports and avoiding duplicate entries. |
|
| ENABLE_SOFT_DELETE | true\|false | false | Whether to enable transactions soft delete, if enabled, deleted transactions will remain in the database. Useful for imports and avoiding duplicate entries. |
|
||||||
| KEEP_DELETED_TRANSACTIONS_FOR | int | 365 | Time in days to keep soft deleted transactions for. If 0, will keep all transactions indefinitely. Only works if ENABLE_SOFT_DELETE is true. |
|
| KEEP_DELETED_TRANSACTIONS_FOR | int | 365 | Time in days to keep soft deleted transactions for. If 0, will keep all transactions indefinitely. Only works if ENABLE_SOFT_DELETE is true. |
|
||||||
| TASK_WORKERS | int | 1 | How many workers to have for async tasks. One should be enough for most use cases |
|
| TASK_WORKERS | int | 1 | How many workers to have for async tasks. One should be enough for most use cases |
|
||||||
|
| DEMO | true\|false | false | If demo mode is enabled. |
|
||||||
|
| ADMIN_EMAIL | string | None | Automatically creates an admin account with this email. Must have `ADMIN_PASSWORD` also set. |
|
||||||
|
| ADMIN_PASSWORD | string | None | Automatically creates an admin account with this password. Must have `ADMIN_EMAIL` also set. |
|
||||||
|
| CHECK_FOR_UPDATES | bool | true | Check and notify users about new versions. The check is done by doing a single query to Github's API every 12 hours. |
|
||||||
|
|
||||||
|
## OIDC Configuration
|
||||||
|
|
||||||
|
WYGIWYH supports login via OpenID Connect (OIDC) through `django-allauth`. This allows users to authenticate using an external OIDC provider.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Currently only OpenID Connect is supported as a provider, open an issue if you need something else.
|
||||||
|
|
||||||
|
To configure OIDC, you need to set the following environment variables:
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
|
| `OIDC_CLIENT_NAME` | The name of the provider. will be displayed in the login page. Defaults to `OpenID Connect` |
|
||||||
|
| `OIDC_CLIENT_ID` | The Client ID provided by your OIDC provider. |
|
||||||
|
| `OIDC_CLIENT_SECRET` | The Client Secret provided by your OIDC provider. |
|
||||||
|
| `OIDC_SERVER_URL` | The base URL of your OIDC provider's discovery document or authorization server (e.g., `https://your-provider.com/auth/realms/your-realm`). `django-allauth` will use this to discover the necessary endpoints (authorization, token, userinfo, etc.). |
|
||||||
|
| `OIDC_ALLOW_SIGNUP` | Allow the automatic creation of inexistent accounts on a successfull authentication. Defaults to `true`. |
|
||||||
|
|
||||||
|
**Callback URL (Redirect URI):**
|
||||||
|
|
||||||
|
When configuring your OIDC provider, you will need to provide a callback URL (also known as a Redirect URI). For WYGIWYH, the default callback URL is:
|
||||||
|
|
||||||
|
`https://your.wygiwyh.domain/auth/oidc/<OIDC_CLIENT_NAME>/login/callback/`
|
||||||
|
|
||||||
|
Replace `https://your.wygiwyh.domain` with the actual URL where your WYGIWYH instance is accessible. And `<OIDC_CLIENT_NAME>` with the slugfied value set in OIDC_CLIENT_NAME or the default `openid-connect` if you haven't set this variable.
|
||||||
|
|
||||||
# How it works
|
# How it works
|
||||||
|
|
||||||
Check out our [Wiki](https://github.com/eitchtee/WYGIWYH/wiki) for more information.
|
Check out our [Wiki](https://github.com/eitchtee/WYGIWYH/wiki) for more information.
|
||||||
|
|
||||||
|
# Help us translate WYGIWYH!
|
||||||
|
<a href="https://translations.herculino.com/engage/wygiwyh/">
|
||||||
|
<img src="https://translations.herculino.com/widget/wygiwyh/open-graph.png" alt="Translation status" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Login with your github account
|
||||||
|
|
||||||
# Caveats and Warnings
|
# Caveats and Warnings
|
||||||
|
|
||||||
- I'm not an accountant, some terms and even calculations might be wrong. Make sure to open an issue if you see anything that could be improved.
|
- I'm not an accountant, some terms and even calculations might be wrong. Make sure to open an issue if you see anything that could be improved.
|
||||||
|
|||||||
+172
-18
@@ -14,6 +14,7 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.utils.text import slugify
|
||||||
|
|
||||||
SITE_TITLE = "WYGIWYH"
|
SITE_TITLE = "WYGIWYH"
|
||||||
TITLE_SEPARATOR = "::"
|
TITLE_SEPARATOR = "::"
|
||||||
@@ -42,6 +43,7 @@ INSTALLED_APPS = [
|
|||||||
"django.contrib.contenttypes",
|
"django.contrib.contenttypes",
|
||||||
"django.contrib.sessions",
|
"django.contrib.sessions",
|
||||||
"django.contrib.messages",
|
"django.contrib.messages",
|
||||||
|
"django.contrib.sites",
|
||||||
"whitenoise.runserver_nostatic",
|
"whitenoise.runserver_nostatic",
|
||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
"webpack_boilerplate",
|
"webpack_boilerplate",
|
||||||
@@ -55,14 +57,15 @@ INSTALLED_APPS = [
|
|||||||
"hijack",
|
"hijack",
|
||||||
"hijack.contrib.admin",
|
"hijack.contrib.admin",
|
||||||
"django_filters",
|
"django_filters",
|
||||||
|
"import_export",
|
||||||
"apps.users.apps.UsersConfig",
|
"apps.users.apps.UsersConfig",
|
||||||
"procrastinate.contrib.django",
|
"procrastinate.contrib.django",
|
||||||
"apps.transactions.apps.TransactionsConfig",
|
"apps.transactions.apps.TransactionsConfig",
|
||||||
"apps.currencies.apps.CurrenciesConfig",
|
"apps.currencies.apps.CurrenciesConfig",
|
||||||
"apps.accounts.apps.AccountsConfig",
|
"apps.accounts.apps.AccountsConfig",
|
||||||
"apps.common.apps.CommonConfig",
|
|
||||||
"apps.net_worth.apps.NetWorthConfig",
|
"apps.net_worth.apps.NetWorthConfig",
|
||||||
"apps.import_app.apps.ImportConfig",
|
"apps.import_app.apps.ImportConfig",
|
||||||
|
"apps.export_app.apps.ExportConfig",
|
||||||
"apps.api.apps.ApiConfig",
|
"apps.api.apps.ApiConfig",
|
||||||
"cachalot",
|
"cachalot",
|
||||||
"rest_framework",
|
"rest_framework",
|
||||||
@@ -72,9 +75,17 @@ INSTALLED_APPS = [
|
|||||||
"apps.calendar_view.apps.CalendarViewConfig",
|
"apps.calendar_view.apps.CalendarViewConfig",
|
||||||
"apps.dca.apps.DcaConfig",
|
"apps.dca.apps.DcaConfig",
|
||||||
"pwa",
|
"pwa",
|
||||||
|
"allauth",
|
||||||
|
"allauth.account",
|
||||||
|
"allauth.socialaccount",
|
||||||
|
"allauth.socialaccount.providers.openid_connect",
|
||||||
|
"apps.common.apps.CommonConfig",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
SITE_ID = 1
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
"django_browser_reload.middleware.BrowserReloadMiddleware",
|
||||||
"apps.common.middleware.thread_local.ThreadLocalMiddleware",
|
"apps.common.middleware.thread_local.ThreadLocalMiddleware",
|
||||||
"debug_toolbar.middleware.DebugToolbarMiddleware",
|
"debug_toolbar.middleware.DebugToolbarMiddleware",
|
||||||
"django.middleware.security.SecurityMiddleware",
|
"django.middleware.security.SecurityMiddleware",
|
||||||
@@ -87,8 +98,8 @@ MIDDLEWARE = [
|
|||||||
"apps.common.middleware.localization.LocalizationMiddleware",
|
"apps.common.middleware.localization.LocalizationMiddleware",
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
"django_browser_reload.middleware.BrowserReloadMiddleware",
|
|
||||||
"hijack.middleware.HijackUserMiddleware",
|
"hijack.middleware.HijackUserMiddleware",
|
||||||
|
"allauth.account.middleware.AccountMiddleware",
|
||||||
]
|
]
|
||||||
|
|
||||||
ROOT_URLCONF = "WYGIWYH.urls"
|
ROOT_URLCONF = "WYGIWYH.urls"
|
||||||
@@ -161,12 +172,108 @@ AUTH_USER_MODEL = "users.User"
|
|||||||
|
|
||||||
LANGUAGE_CODE = "en"
|
LANGUAGE_CODE = "en"
|
||||||
LANGUAGES = (
|
LANGUAGES = (
|
||||||
|
("af", "Afrikaans"),
|
||||||
|
("ar", "العربية"),
|
||||||
|
("ar-dz", "العربية (الجزائر)"), # Algerian Arabic often uses the base name + region
|
||||||
|
("ast", "Asturianu"),
|
||||||
|
("az", "Azərbaycan"),
|
||||||
|
("bg", "Български"),
|
||||||
|
("be", "Беларуская"),
|
||||||
|
("bn", "বাংলা"),
|
||||||
|
("br", "Brezhoneg"),
|
||||||
|
("bs", "Bosanski"),
|
||||||
|
("ca", "Català"),
|
||||||
|
("ckb", "کوردیی ناوەندی"), # Central Kurdish (Sorani)
|
||||||
|
("cs", "Čeština"),
|
||||||
|
("cy", "Cymraeg"),
|
||||||
|
("da", "Dansk"),
|
||||||
|
("de", "Deutsch"),
|
||||||
|
("dsb", "Dolnoserbšćina"),
|
||||||
|
("el", "Ελληνικά"),
|
||||||
("en", "English"),
|
("en", "English"),
|
||||||
|
("en-au", "English (Australia)"),
|
||||||
|
("en-gb", "English (UK)"),
|
||||||
|
("eo", "Esperanto"),
|
||||||
|
("es", "Español"),
|
||||||
|
("es-ar", "Español (Argentina)"),
|
||||||
|
("es-co", "Español (Colombia)"),
|
||||||
|
("es-mx", "Español (México)"),
|
||||||
|
("es-ni", "Español (Nicaragua)"),
|
||||||
|
("es-ve", "Español (Venezuela)"),
|
||||||
|
("et", "Eesti"),
|
||||||
|
("eu", "Euskara"),
|
||||||
|
("fa", "فارسی"),
|
||||||
|
("fi", "Suomi"),
|
||||||
|
("fr", "Français"),
|
||||||
|
("fy", "Frysk"),
|
||||||
|
("ga", "Gaeilge"),
|
||||||
|
("gd", "Gàidhlig"),
|
||||||
|
("gl", "Galego"),
|
||||||
|
("he", "עברית"),
|
||||||
|
("hi", "हिन्दी"),
|
||||||
|
("hr", "Hrvatski"),
|
||||||
|
("hsb", "Hornjoserbšćina"),
|
||||||
|
("hu", "Magyar"),
|
||||||
|
("hy", "Հայերեն"),
|
||||||
|
("ia", "Interlingua"),
|
||||||
|
("id", "Bahasa Indonesia"),
|
||||||
|
("ig", "Igbo"),
|
||||||
|
("io", "Ido"),
|
||||||
|
("is", "Íslenska"),
|
||||||
|
("it", "Italiano"),
|
||||||
|
("ja", "日本語"),
|
||||||
|
("ka", "ქართული"),
|
||||||
|
("kab", "Taqbaylit"),
|
||||||
|
("kk", "Қазақша"),
|
||||||
|
("km", "ខ្មែរ"),
|
||||||
|
("kn", "ಕನ್ನಡ"),
|
||||||
|
("ko", "한국어"),
|
||||||
|
("ky", "Кыргызча"),
|
||||||
|
("lb", "Lëtzebuergesch"),
|
||||||
|
("lt", "Lietuvių"),
|
||||||
|
("lv", "Latviešu"),
|
||||||
|
("mk", "Македонски"),
|
||||||
|
("ml", "മലയാളം"),
|
||||||
|
("mn", "Монгол"),
|
||||||
|
("mr", "मराठी"),
|
||||||
|
("ms", "Bahasa Melayu"),
|
||||||
|
("my", "မြန်မာဘာသာ"),
|
||||||
|
("nb", "Norsk (Bokmål)"),
|
||||||
|
("ne", "नेपाली"),
|
||||||
("nl", "Nederlands"),
|
("nl", "Nederlands"),
|
||||||
|
("nn", "Norsk (Nynorsk)"),
|
||||||
|
("os", "Ирон"), # Ossetic
|
||||||
|
("pa", "ਪੰਜਾਬੀ"),
|
||||||
|
("pl", "Polski"),
|
||||||
|
("pt", "Português"),
|
||||||
("pt-br", "Português (Brasil)"),
|
("pt-br", "Português (Brasil)"),
|
||||||
|
("ro", "Română"),
|
||||||
|
("ru", "Русский"),
|
||||||
|
("sk", "Slovenčina"),
|
||||||
|
("sl", "Slovenščina"),
|
||||||
|
("sq", "Shqip"),
|
||||||
|
("sr", "Српски"),
|
||||||
|
("sr-latn", "Srpski (Latinica)"),
|
||||||
|
("sv", "Svenska"),
|
||||||
|
("sw", "Kiswahili"),
|
||||||
|
("ta", "தமிழ்"),
|
||||||
|
("te", "తెలుగు"),
|
||||||
|
("tg", "Тоҷикӣ"),
|
||||||
|
("th", "ไทย"),
|
||||||
|
("tk", "Türkmençe"),
|
||||||
|
("tr", "Türkçe"),
|
||||||
|
("tt", "Татарча"),
|
||||||
|
("udm", "Удмурт"),
|
||||||
|
("ug", "ئۇيغۇرچە"),
|
||||||
|
("uk", "Українська"),
|
||||||
|
("ur", "اردو"),
|
||||||
|
("uz", "Oʻzbekcha"),
|
||||||
|
("vi", "Tiếng Việt"),
|
||||||
|
("zh-hans", "简体中文"),
|
||||||
|
("zh-hant", "繁體中文"),
|
||||||
)
|
)
|
||||||
|
|
||||||
TIME_ZONE = "UTC"
|
TIME_ZONE = os.getenv("TZ", "UTC")
|
||||||
|
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
|
|
||||||
@@ -209,6 +316,42 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
|||||||
|
|
||||||
LOGIN_REDIRECT_URL = "/"
|
LOGIN_REDIRECT_URL = "/"
|
||||||
LOGIN_URL = "/login/"
|
LOGIN_URL = "/login/"
|
||||||
|
LOGOUT_REDIRECT_URL = "/login/"
|
||||||
|
|
||||||
|
# Allauth settings
|
||||||
|
AUTHENTICATION_BACKENDS = [
|
||||||
|
"django.contrib.auth.backends.ModelBackend", # Keep default
|
||||||
|
"allauth.account.auth_backends.AuthenticationBackend",
|
||||||
|
]
|
||||||
|
|
||||||
|
SOCIALACCOUNT_PROVIDERS = {"openid_connect": {"APPS": []}}
|
||||||
|
|
||||||
|
if (
|
||||||
|
os.getenv("OIDC_CLIENT_ID")
|
||||||
|
and os.getenv("OIDC_CLIENT_SECRET")
|
||||||
|
and os.getenv("OIDC_SERVER_URL")
|
||||||
|
):
|
||||||
|
SOCIALACCOUNT_PROVIDERS["openid_connect"]["APPS"].append(
|
||||||
|
{
|
||||||
|
"provider_id": slugify(os.getenv("OIDC_CLIENT_NAME", "OpenID Connect")),
|
||||||
|
"name": os.getenv("OIDC_CLIENT_NAME", "OpenID Connect"),
|
||||||
|
"client_id": os.getenv("OIDC_CLIENT_ID"),
|
||||||
|
"secret": os.getenv("OIDC_CLIENT_SECRET"),
|
||||||
|
"settings": {
|
||||||
|
"server_url": os.getenv("OIDC_SERVER_URL"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ACCOUNT_LOGIN_METHODS = {"email"}
|
||||||
|
ACCOUNT_SIGNUP_FIELDS = ["email*", "password1*", "password2*"]
|
||||||
|
ACCOUNT_USER_MODEL_USERNAME_FIELD = None
|
||||||
|
ACCOUNT_EMAIL_VERIFICATION = "none"
|
||||||
|
SOCIALACCOUNT_LOGIN_ON_GET = True
|
||||||
|
SOCIALACCOUNT_ONLY = True
|
||||||
|
SOCIALACCOUNT_AUTO_SIGNUP = os.getenv("OIDC_ALLOW_SIGNUP", "true").lower() == "true"
|
||||||
|
ACCOUNT_ADAPTER = "allauth.account.adapter.DefaultAccountAdapter"
|
||||||
|
SOCIALACCOUNT_ADAPTER = "allauth.socialaccount.adapter.DefaultSocialAccountAdapter"
|
||||||
|
|
||||||
# CRISPY FORMS
|
# CRISPY FORMS
|
||||||
CRISPY_ALLOWED_TEMPLATE_PACKS = ["bootstrap5", "crispy_forms/pure_text"]
|
CRISPY_ALLOWED_TEMPLATE_PACKS = ["bootstrap5", "crispy_forms/pure_text"]
|
||||||
@@ -236,7 +379,7 @@ DEBUG_TOOLBAR_PANELS = [
|
|||||||
"debug_toolbar.panels.signals.SignalsPanel",
|
"debug_toolbar.panels.signals.SignalsPanel",
|
||||||
"debug_toolbar.panels.redirects.RedirectsPanel",
|
"debug_toolbar.panels.redirects.RedirectsPanel",
|
||||||
"debug_toolbar.panels.profiling.ProfilingPanel",
|
"debug_toolbar.panels.profiling.ProfilingPanel",
|
||||||
"cachalot.panels.CachalotPanel",
|
# "cachalot.panels.CachalotPanel",
|
||||||
]
|
]
|
||||||
INTERNAL_IPS = [
|
INTERNAL_IPS = [
|
||||||
"127.0.0.1",
|
"127.0.0.1",
|
||||||
@@ -258,7 +401,10 @@ if DEBUG:
|
|||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
# Use Django's standard `django.contrib.auth` permissions,
|
# Use Django's standard `django.contrib.auth` permissions,
|
||||||
# or allow read-only access for unauthenticated users.
|
# or allow read-only access for unauthenticated users.
|
||||||
"DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.DjangoModelPermissions"],
|
"DEFAULT_PERMISSION_CLASSES": [
|
||||||
|
"apps.api.permissions.NotInDemoMode",
|
||||||
|
"rest_framework.permissions.DjangoModelPermissions",
|
||||||
|
],
|
||||||
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
|
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
|
||||||
"PAGE_SIZE": 10,
|
"PAGE_SIZE": 10,
|
||||||
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
||||||
@@ -277,29 +423,32 @@ if "procrastinate" in sys.argv:
|
|||||||
"version": 1,
|
"version": 1,
|
||||||
"disable_existing_loggers": False,
|
"disable_existing_loggers": False,
|
||||||
"formatters": {
|
"formatters": {
|
||||||
"procrastinate": {
|
"standard": {
|
||||||
"format": "%(asctime)s %(levelname)-7s %(name)s %(message)s"
|
"format": "[%(asctime)s] - %(levelname)s - %(name)s - %(message)s",
|
||||||
|
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"handlers": {
|
"handlers": {
|
||||||
"procrastinate": {
|
"procrastinate": {
|
||||||
"level": "DEBUG",
|
"level": "INFO",
|
||||||
"class": "logging.StreamHandler",
|
"class": "logging.StreamHandler",
|
||||||
"formatter": "procrastinate",
|
"formatter": "standard",
|
||||||
},
|
},
|
||||||
"console": {
|
"console": {
|
||||||
"class": "logging.StreamHandler",
|
"class": "logging.StreamHandler",
|
||||||
|
"formatter": "standard",
|
||||||
|
"level": "INFO",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"loggers": {
|
"loggers": {
|
||||||
"procrastinate": {
|
"procrastinate": {
|
||||||
"handlers": ["procrastinate"],
|
"handlers": ["procrastinate"],
|
||||||
"level": "INFO",
|
|
||||||
"propagate": False,
|
"propagate": False,
|
||||||
},
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"handlers": ["console"],
|
"handlers": ["console"],
|
||||||
"level": "INFO",
|
"level": "INFO",
|
||||||
|
"propagate": False,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -308,24 +457,25 @@ else:
|
|||||||
"version": 1,
|
"version": 1,
|
||||||
"disable_existing_loggers": False,
|
"disable_existing_loggers": False,
|
||||||
"formatters": {
|
"formatters": {
|
||||||
"procrastinate": {
|
"standard": {
|
||||||
"format": "%(asctime)s %(levelname)-7s %(name)s %(message)s"
|
"format": "[%(asctime)s] - %(levelname)s - %(name)s - %(message)s",
|
||||||
|
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"handlers": {
|
"handlers": {
|
||||||
"procrastinate": {
|
|
||||||
"level": "DEBUG",
|
|
||||||
"class": "logging.StreamHandler",
|
|
||||||
"formatter": "procrastinate",
|
|
||||||
},
|
|
||||||
"console": {
|
"console": {
|
||||||
"class": "logging.StreamHandler",
|
"class": "logging.StreamHandler",
|
||||||
|
"formatter": "standard",
|
||||||
|
"level": "INFO",
|
||||||
|
},
|
||||||
|
"procrastinate": {
|
||||||
|
"level": "INFO",
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"loggers": {
|
"loggers": {
|
||||||
"procrastinate": {
|
"procrastinate": {
|
||||||
"handlers": None,
|
"handlers": None,
|
||||||
"level": "INFO",
|
|
||||||
"propagate": False,
|
"propagate": False,
|
||||||
},
|
},
|
||||||
"root": {
|
"root": {
|
||||||
@@ -337,6 +487,8 @@ else:
|
|||||||
|
|
||||||
CACHALOT_UNCACHABLE_TABLES = ("django_migrations", "procrastinate_jobs")
|
CACHALOT_UNCACHABLE_TABLES = ("django_migrations", "procrastinate_jobs")
|
||||||
|
|
||||||
|
# Procrastinate
|
||||||
|
PROCRASTINATE_ON_APP_READY = "apps.common.procrastinate.on_app_ready"
|
||||||
|
|
||||||
# PWA
|
# PWA
|
||||||
PWA_APP_NAME = SITE_TITLE
|
PWA_APP_NAME = SITE_TITLE
|
||||||
@@ -385,5 +537,7 @@ PWA_APP_SCREENSHOTS = [
|
|||||||
PWA_SERVICE_WORKER_PATH = BASE_DIR / "templates" / "pwa" / "serviceworker.js"
|
PWA_SERVICE_WORKER_PATH = BASE_DIR / "templates" / "pwa" / "serviceworker.js"
|
||||||
|
|
||||||
ENABLE_SOFT_DELETE = os.getenv("ENABLE_SOFT_DELETE", "false").lower() == "true"
|
ENABLE_SOFT_DELETE = os.getenv("ENABLE_SOFT_DELETE", "false").lower() == "true"
|
||||||
|
CHECK_FOR_UPDATES = os.getenv("CHECK_FOR_UPDATES", "true").lower() == "true"
|
||||||
KEEP_DELETED_TRANSACTIONS_FOR = int(os.getenv("KEEP_DELETED_ENTRIES_FOR", "365"))
|
KEEP_DELETED_TRANSACTIONS_FOR = int(os.getenv("KEEP_DELETED_ENTRIES_FOR", "365"))
|
||||||
APP_VERSION = os.getenv("APP_VERSION", "unknown")
|
APP_VERSION = os.getenv("APP_VERSION", "unknown")
|
||||||
|
DEMO = os.getenv("DEMO", "false").lower() == "true"
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ from drf_spectacular.views import (
|
|||||||
SpectacularAPIView,
|
SpectacularAPIView,
|
||||||
SpectacularSwaggerView,
|
SpectacularSwaggerView,
|
||||||
)
|
)
|
||||||
|
from allauth.socialaccount.providers.openid_connect.views import login, callback
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
@@ -36,6 +38,13 @@ urlpatterns = [
|
|||||||
SpectacularSwaggerView.as_view(url_name="schema"),
|
SpectacularSwaggerView.as_view(url_name="schema"),
|
||||||
name="swagger-ui",
|
name="swagger-ui",
|
||||||
),
|
),
|
||||||
|
path("auth/", include("allauth.urls")), # allauth urls
|
||||||
|
# path("auth/oidc/<str:provider_id>/login/", login, name="openid_connect_login"),
|
||||||
|
# path(
|
||||||
|
# "auth/oidc/<str:provider_id>/login/callback/",
|
||||||
|
# callback,
|
||||||
|
# name="openid_connect_callback",
|
||||||
|
# ),
|
||||||
path("", include("apps.transactions.urls")),
|
path("", include("apps.transactions.urls")),
|
||||||
path("", include("apps.common.urls")),
|
path("", include("apps.common.urls")),
|
||||||
path("", include("apps.users.urls")),
|
path("", include("apps.users.urls")),
|
||||||
@@ -49,4 +58,6 @@ urlpatterns = [
|
|||||||
path("", include("apps.dca.urls")),
|
path("", include("apps.dca.urls")),
|
||||||
path("", include("apps.mini_tools.urls")),
|
path("", include("apps.mini_tools.urls")),
|
||||||
path("", include("apps.import_app.urls")),
|
path("", include("apps.import_app.urls")),
|
||||||
|
path("", include("apps.export_app.urls")),
|
||||||
|
path("", include("apps.insights.urls")),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from apps.accounts.models import Account
|
from apps.accounts.models import Account, AccountGroup
|
||||||
|
from apps.common.admin import SharedObjectModelAdmin
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(Account)
|
@admin.register(Account)
|
||||||
|
class AccountModelAdmin(SharedObjectModelAdmin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(AccountGroup)
|
||||||
|
class AccountGroupModelAdmin(SharedObjectModelAdmin):
|
||||||
|
pass
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from crispy_forms.bootstrap import FormActions
|
|||||||
from crispy_forms.helper import FormHelper
|
from crispy_forms.helper import FormHelper
|
||||||
from crispy_forms.layout import Layout, Field, Column, Row
|
from crispy_forms.layout import Layout, Field, Column, Row
|
||||||
from django import forms
|
from django import forms
|
||||||
|
from django.db.models import Q
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from apps.accounts.models import Account
|
from apps.accounts.models import Account
|
||||||
@@ -15,6 +16,7 @@ from apps.common.widgets.crispy.submit import NoClassSubmit
|
|||||||
from apps.common.widgets.tom_select import TomSelect
|
from apps.common.widgets.tom_select import TomSelect
|
||||||
from apps.transactions.models import TransactionCategory, TransactionTag
|
from apps.transactions.models import TransactionCategory, TransactionTag
|
||||||
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
|
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
|
||||||
|
from apps.currencies.models import Currency
|
||||||
|
|
||||||
|
|
||||||
class AccountGroupForm(forms.ModelForm):
|
class AccountGroupForm(forms.ModelForm):
|
||||||
@@ -77,6 +79,20 @@ class AccountForm(forms.ModelForm):
|
|||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.fields["group"].queryset = AccountGroup.objects.all()
|
||||||
|
|
||||||
|
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 = FormHelper()
|
||||||
self.helper.form_tag = False
|
self.helper.form_tag = False
|
||||||
self.helper.form_method = "post"
|
self.helper.form_method = "post"
|
||||||
@@ -151,5 +167,11 @@ class AccountBalanceForm(forms.Form):
|
|||||||
decimal_places=self.currency_decimal_places
|
decimal_places=self.currency_decimal_places
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.fields["category"].queryset = TransactionCategory.objects.filter(
|
||||||
|
active=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self.fields["tags"].queryset = TransactionTag.objects.filter(active=True)
|
||||||
|
|
||||||
|
|
||||||
AccountBalanceFormSet = forms.formset_factory(AccountBalanceForm, extra=0)
|
AccountBalanceFormSet = forms.formset_factory(AccountBalanceForm, extra=0)
|
||||||
|
|||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
# Generated by Django 5.1.6 on 2025-03-04 15:07
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0008_alter_account_name'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='account',
|
||||||
|
name='owner',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_accounts', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='account',
|
||||||
|
name='shared_with',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='shared_accounts', to=settings.AUTH_USER_MODEL, verbose_name='Shared With'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='accountgroup',
|
||||||
|
name='owner',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_account_groups', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
|
||||||
|
),
|
||||||
|
]
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
# Generated by Django 5.1.6 on 2025-03-05 02:42
|
||||||
|
|
||||||
|
import django.db.models.manager
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0009_account_owner_account_shared_with_accountgroup_owner'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelManagers(
|
||||||
|
name='account',
|
||||||
|
managers=[
|
||||||
|
('all_objects', django.db.models.manager.Manager()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AlterModelManagers(
|
||||||
|
name='accountgroup',
|
||||||
|
managers=[
|
||||||
|
('all_objects', django.db.models.manager.Manager()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='account',
|
||||||
|
name='name',
|
||||||
|
field=models.CharField(max_length=255, verbose_name='Name'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='accountgroup',
|
||||||
|
name='name',
|
||||||
|
field=models.CharField(max_length=255, verbose_name='Name'),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='account',
|
||||||
|
unique_together={('owner', 'name')},
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='accountgroup',
|
||||||
|
unique_together={('owner', 'name')},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Generated by Django 5.1.6 on 2025-03-05 04:19
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0010_alter_account_managers_alter_accountgroup_managers_and_more'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='account',
|
||||||
|
name='owner',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='owned_accounts', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='accountgroup',
|
||||||
|
name='owner',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='owned_account_groups', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
|
||||||
|
),
|
||||||
|
]
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
# Generated by Django 5.1.6 on 2025-03-05 23:46
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0011_alter_account_owner_alter_accountgroup_owner'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelManagers(
|
||||||
|
name='account',
|
||||||
|
managers=[
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AlterModelManagers(
|
||||||
|
name='accountgroup',
|
||||||
|
managers=[
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='account',
|
||||||
|
name='visibility',
|
||||||
|
field=models.CharField(choices=[('private', 'Private'), ('shared', 'Shared'), ('public', 'Public')], default='private', max_length=10),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='accountgroup',
|
||||||
|
name='shared_with',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='accountgroup',
|
||||||
|
name='visibility',
|
||||||
|
field=models.CharField(choices=[('private', 'Private'), ('shared', 'Shared'), ('public', 'Public')], default='private', max_length=10),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='account',
|
||||||
|
name='owner',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='account',
|
||||||
|
name='shared_with',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='accountgroup',
|
||||||
|
name='owner',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.1.6 on 2025-03-06 01:47
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0012_alter_account_managers_alter_accountgroup_managers_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='account',
|
||||||
|
name='visibility',
|
||||||
|
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='accountgroup',
|
||||||
|
name='visibility',
|
||||||
|
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Generated by Django 5.1.7 on 2025-03-09 21:54
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0013_alter_account_visibility_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='account',
|
||||||
|
options={'ordering': ['name', 'id'], 'verbose_name': 'Account', 'verbose_name_plural': 'Accounts'},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='accountgroup',
|
||||||
|
options={'ordering': ['name', 'id'], 'verbose_name': 'Account Group', 'verbose_name_plural': 'Account Groups'},
|
||||||
|
),
|
||||||
|
]
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
# Generated by Django 5.2.4 on 2025-07-28 02:15
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0014_alter_account_options_alter_accountgroup_options'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='account',
|
||||||
|
name='owner',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='account',
|
||||||
|
name='shared_with',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL, verbose_name='Shared with users'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='account',
|
||||||
|
name='visibility',
|
||||||
|
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10, verbose_name='Visibility'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='accountgroup',
|
||||||
|
name='owner',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='accountgroup',
|
||||||
|
name='shared_with',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL, verbose_name='Shared with users'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='accountgroup',
|
||||||
|
name='visibility',
|
||||||
|
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10, verbose_name='Visibility'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 5.2.4 on 2025-08-09 05:52
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0015_alter_account_owner_alter_account_shared_with_and_more'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='account',
|
||||||
|
name='untracked_by',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='untracked_accounts', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,24 +1,32 @@
|
|||||||
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from apps.common.middleware.thread_local import get_current_user
|
||||||
|
from apps.common.models import SharedObject, SharedObjectManager
|
||||||
from apps.transactions.models import Transaction
|
from apps.transactions.models import Transaction
|
||||||
|
|
||||||
|
|
||||||
class AccountGroup(models.Model):
|
class AccountGroup(SharedObject):
|
||||||
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True)
|
name = models.CharField(max_length=255, verbose_name=_("Name"))
|
||||||
|
|
||||||
|
objects = SharedObjectManager()
|
||||||
|
all_objects = models.Manager() # Unfiltered manager
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Account Group")
|
verbose_name = _("Account Group")
|
||||||
verbose_name_plural = _("Account Groups")
|
verbose_name_plural = _("Account Groups")
|
||||||
db_table = "account_groups"
|
db_table = "account_groups"
|
||||||
|
unique_together = (("owner", "name"),)
|
||||||
|
ordering = ["name", "id"]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
class Account(models.Model):
|
class Account(SharedObject):
|
||||||
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True)
|
name = models.CharField(max_length=255, verbose_name=_("Name"))
|
||||||
group = models.ForeignKey(
|
group = models.ForeignKey(
|
||||||
AccountGroup,
|
AccountGroup,
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
@@ -54,14 +62,28 @@ class Account(models.Model):
|
|||||||
verbose_name=_("Archived"),
|
verbose_name=_("Archived"),
|
||||||
help_text=_("Archived accounts don't show up nor count towards your net worth"),
|
help_text=_("Archived accounts don't show up nor count towards your net worth"),
|
||||||
)
|
)
|
||||||
|
untracked_by = models.ManyToManyField(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
blank=True,
|
||||||
|
related_name="untracked_accounts",
|
||||||
|
)
|
||||||
|
|
||||||
|
objects = SharedObjectManager()
|
||||||
|
all_objects = models.Manager() # Unfiltered manager
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Account")
|
verbose_name = _("Account")
|
||||||
verbose_name_plural = _("Accounts")
|
verbose_name_plural = _("Accounts")
|
||||||
|
unique_together = (("owner", "name"),)
|
||||||
|
ordering = ["name", "id"]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def is_untracked_by(self):
|
||||||
|
user = get_current_user()
|
||||||
|
return self.untracked_by.filter(pk=user.pk).exists()
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
super().clean()
|
super().clean()
|
||||||
if self.exchange_currency == self.currency:
|
if self.exchange_currency == self.currency:
|
||||||
|
|||||||
@@ -16,11 +16,26 @@ urlpatterns = [
|
|||||||
views.account_edit,
|
views.account_edit,
|
||||||
name="account_edit",
|
name="account_edit",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"account/<int:pk>/share/",
|
||||||
|
views.account_share,
|
||||||
|
name="account_share_settings",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"account/<int:pk>/delete/",
|
"account/<int:pk>/delete/",
|
||||||
views.account_delete,
|
views.account_delete,
|
||||||
name="account_delete",
|
name="account_delete",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"account/<int:pk>/take-ownership/",
|
||||||
|
views.account_take_ownership,
|
||||||
|
name="account_take_ownership",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"account/<int:pk>/toggle-untracked/",
|
||||||
|
views.account_toggle_untracked,
|
||||||
|
name="account_toggle_untracked",
|
||||||
|
),
|
||||||
path("account-groups/", views.account_groups_index, name="account_groups_index"),
|
path("account-groups/", views.account_groups_index, name="account_groups_index"),
|
||||||
path("account-groups/list/", views.account_groups_list, name="account_groups_list"),
|
path("account-groups/list/", views.account_groups_list, name="account_groups_list"),
|
||||||
path("account-groups/add/", views.account_group_add, name="account_group_add"),
|
path("account-groups/add/", views.account_group_add, name="account_group_add"),
|
||||||
@@ -34,4 +49,14 @@ urlpatterns = [
|
|||||||
views.account_group_delete,
|
views.account_group_delete,
|
||||||
name="account_group_delete",
|
name="account_group_delete",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"account-groups/<int:pk>/take-ownership/",
|
||||||
|
views.account_group_take_ownership,
|
||||||
|
name="account_group_take_ownership",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"account-groups/<int:pk>/share/",
|
||||||
|
views.account_group_share,
|
||||||
|
name="account_group_share_settings",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ from django.views.decorators.http import require_http_methods
|
|||||||
from apps.accounts.forms import AccountGroupForm
|
from apps.accounts.forms import AccountGroupForm
|
||||||
from apps.accounts.models import AccountGroup
|
from apps.accounts.models import AccountGroup
|
||||||
from apps.common.decorators.htmx import only_htmx
|
from apps.common.decorators.htmx import only_htmx
|
||||||
|
from apps.common.models import SharedObject
|
||||||
|
from apps.common.forms import SharedObjectForm
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -63,6 +65,16 @@ def account_group_add(request, **kwargs):
|
|||||||
def account_group_edit(request, pk):
|
def account_group_edit(request, pk):
|
||||||
account_group = get_object_or_404(AccountGroup, id=pk)
|
account_group = get_object_or_404(AccountGroup, id=pk)
|
||||||
|
|
||||||
|
if account_group.owner and account_group.owner != request.user:
|
||||||
|
messages.error(request, _("Only the owner can edit this"))
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
headers={
|
||||||
|
"HX-Trigger": "updated, hide_offcanvas",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = AccountGroupForm(request.POST, instance=account_group)
|
form = AccountGroupForm(request.POST, instance=account_group)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
@@ -91,8 +103,14 @@ def account_group_edit(request, pk):
|
|||||||
def account_group_delete(request, pk):
|
def account_group_delete(request, pk):
|
||||||
account_group = get_object_or_404(AccountGroup, id=pk)
|
account_group = get_object_or_404(AccountGroup, id=pk)
|
||||||
|
|
||||||
|
if (
|
||||||
|
account_group.owner != request.user
|
||||||
|
and request.user in account_group.shared_with.all()
|
||||||
|
):
|
||||||
|
account_group.shared_with.remove(request.user)
|
||||||
|
messages.success(request, _("Item no longer shared with you"))
|
||||||
|
else:
|
||||||
account_group.delete()
|
account_group.delete()
|
||||||
|
|
||||||
messages.success(request, _("Account Group deleted successfully"))
|
messages.success(request, _("Account Group deleted successfully"))
|
||||||
|
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
@@ -101,3 +119,62 @@ def account_group_delete(request, pk):
|
|||||||
"HX-Trigger": "updated, hide_offcanvas",
|
"HX-Trigger": "updated, hide_offcanvas",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def account_group_take_ownership(request, pk):
|
||||||
|
account_group = get_object_or_404(AccountGroup, id=pk)
|
||||||
|
|
||||||
|
if not account_group.owner:
|
||||||
|
account_group.owner = request.user
|
||||||
|
account_group.visibility = SharedObject.Visibility.private
|
||||||
|
account_group.save()
|
||||||
|
|
||||||
|
messages.success(request, _("Ownership taken successfully"))
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
headers={
|
||||||
|
"HX-Trigger": "updated, hide_offcanvas",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def account_group_share(request, pk):
|
||||||
|
obj = get_object_or_404(AccountGroup, id=pk)
|
||||||
|
|
||||||
|
if obj.owner and obj.owner != request.user:
|
||||||
|
messages.error(request, _("Only the owner can edit this"))
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
headers={
|
||||||
|
"HX-Trigger": "updated, hide_offcanvas",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
form = SharedObjectForm(request.POST, instance=obj, user=request.user)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
messages.success(request, _("Configuration saved successfully"))
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
headers={
|
||||||
|
"HX-Trigger": "updated, hide_offcanvas",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
form = SharedObjectForm(instance=obj, user=request.user)
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"accounts/fragments/share.html",
|
||||||
|
{"form": form, "object": obj},
|
||||||
|
)
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ from django.views.decorators.http import require_http_methods
|
|||||||
from apps.accounts.forms import AccountForm
|
from apps.accounts.forms import AccountForm
|
||||||
from apps.accounts.models import Account
|
from apps.accounts.models import Account
|
||||||
from apps.common.decorators.htmx import only_htmx
|
from apps.common.decorators.htmx import only_htmx
|
||||||
|
from apps.common.models import SharedObject
|
||||||
|
from apps.common.forms import SharedObjectForm
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -62,6 +64,15 @@ def account_add(request, **kwargs):
|
|||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET", "POST"])
|
||||||
def account_edit(request, pk):
|
def account_edit(request, pk):
|
||||||
account = get_object_or_404(Account, id=pk)
|
account = get_object_or_404(Account, id=pk)
|
||||||
|
if account.owner and account.owner != request.user:
|
||||||
|
messages.error(request, _("Only the owner can edit this"))
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
headers={
|
||||||
|
"HX-Trigger": "updated, hide_offcanvas",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = AccountForm(request.POST, instance=account)
|
form = AccountForm(request.POST, instance=account)
|
||||||
@@ -85,14 +96,55 @@ def account_edit(request, pk):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def account_share(request, pk):
|
||||||
|
obj = get_object_or_404(Account, id=pk)
|
||||||
|
|
||||||
|
if obj.owner and obj.owner != request.user:
|
||||||
|
messages.error(request, _("Only the owner can edit this"))
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
headers={
|
||||||
|
"HX-Trigger": "updated, hide_offcanvas",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
form = SharedObjectForm(request.POST, instance=obj, user=request.user)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
messages.success(request, _("Configuration saved successfully"))
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
headers={
|
||||||
|
"HX-Trigger": "updated, hide_offcanvas",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
form = SharedObjectForm(instance=obj, user=request.user)
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"accounts/fragments/share.html",
|
||||||
|
{"form": form, "object": obj},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
@require_http_methods(["DELETE"])
|
@require_http_methods(["DELETE"])
|
||||||
def account_delete(request, pk):
|
def account_delete(request, pk):
|
||||||
account = get_object_or_404(Account, id=pk)
|
account = get_object_or_404(Account, id=pk)
|
||||||
|
|
||||||
|
if account.owner != request.user and request.user in account.shared_with.all():
|
||||||
|
account.shared_with.remove(request.user)
|
||||||
|
messages.success(request, _("Item no longer shared with you"))
|
||||||
|
else:
|
||||||
account.delete()
|
account.delete()
|
||||||
|
|
||||||
messages.success(request, _("Account deleted successfully"))
|
messages.success(request, _("Account deleted successfully"))
|
||||||
|
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
@@ -101,3 +153,44 @@ def account_delete(request, pk):
|
|||||||
"HX-Trigger": "updated, hide_offcanvas",
|
"HX-Trigger": "updated, hide_offcanvas",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def account_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"])
|
||||||
|
def account_take_ownership(request, pk):
|
||||||
|
account = get_object_or_404(Account, id=pk)
|
||||||
|
|
||||||
|
if not account.owner:
|
||||||
|
account.owner = request.user
|
||||||
|
account.visibility = SharedObject.Visibility.private
|
||||||
|
account.save()
|
||||||
|
|
||||||
|
messages.success(request, _("Ownership taken successfully"))
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
headers={
|
||||||
|
"HX-Trigger": "updated, hide_offcanvas",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -38,9 +38,9 @@ def account_reconciliation(request):
|
|||||||
"prefix": account.currency.prefix,
|
"prefix": account.currency.prefix,
|
||||||
"current_balance": get_account_balance(account),
|
"current_balance": get_account_balance(account),
|
||||||
}
|
}
|
||||||
for account in Account.objects.filter(is_archived=False).select_related(
|
for account in Account.objects.filter(is_archived=False)
|
||||||
"currency", "group"
|
.select_related("currency", "group")
|
||||||
)
|
.order_by("group", "name")
|
||||||
]
|
]
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from rest_framework.pagination import PageNumberPagination
|
||||||
|
|
||||||
|
|
||||||
|
class CustomPageNumberPagination(PageNumberPagination):
|
||||||
|
page_size = 100
|
||||||
|
page_size_query_param = "page_size"
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
from drf_spectacular.types import OpenApiTypes
|
|
||||||
from drf_spectacular.utils import extend_schema_field
|
from drf_spectacular.utils import extend_schema_field
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from apps.transactions.models import (
|
from apps.transactions.models import (
|
||||||
TransactionCategory,
|
TransactionCategory,
|
||||||
TransactionTag,
|
TransactionTag,
|
||||||
@@ -29,7 +27,11 @@ class TransactionCategoryField(serializers.Field):
|
|||||||
_("Category with this ID does not exist.")
|
_("Category with this ID does not exist.")
|
||||||
)
|
)
|
||||||
elif isinstance(data, str):
|
elif isinstance(data, str):
|
||||||
category, created = TransactionCategory.objects.get_or_create(name=data)
|
try:
|
||||||
|
category = TransactionCategory.objects.get(name=data)
|
||||||
|
except TransactionCategory.DoesNotExist:
|
||||||
|
category = TransactionCategory(name=data)
|
||||||
|
category.save()
|
||||||
return category
|
return category
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
_("Invalid category data. Provide an ID or name.")
|
_("Invalid category data. Provide an ID or name.")
|
||||||
@@ -39,7 +41,10 @@ class TransactionCategoryField(serializers.Field):
|
|||||||
def get_schema():
|
def get_schema():
|
||||||
return {
|
return {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {"type": "string", "description": "TransactionTag ID or name"},
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "TransactionCategory ID or name",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -65,7 +70,11 @@ class TransactionTagField(serializers.Field):
|
|||||||
_("Tag with this ID does not exist.")
|
_("Tag with this ID does not exist.")
|
||||||
)
|
)
|
||||||
elif isinstance(item, str):
|
elif isinstance(item, str):
|
||||||
tag, created = TransactionTag.objects.get_or_create(name=item)
|
try:
|
||||||
|
tag = TransactionTag.objects.get(name=item)
|
||||||
|
except TransactionTag.DoesNotExist:
|
||||||
|
tag = TransactionTag(name=item)
|
||||||
|
tag.save()
|
||||||
else:
|
else:
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
_("Invalid tag data. Provide an ID or name.")
|
_("Invalid tag data. Provide an ID or name.")
|
||||||
@@ -74,6 +83,13 @@ class TransactionTagField(serializers.Field):
|
|||||||
return tags
|
return tags
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema_field(
|
||||||
|
{
|
||||||
|
"type": "array",
|
||||||
|
"items": {"oneOf": [{"type": "string"}, {"type": "integer"}]},
|
||||||
|
"description": "TransactionEntity ID or name. If the name doesn't exist, a new one will be created",
|
||||||
|
}
|
||||||
|
)
|
||||||
class TransactionEntityField(serializers.Field):
|
class TransactionEntityField(serializers.Field):
|
||||||
def to_representation(self, value):
|
def to_representation(self, value):
|
||||||
return [{"id": entity.id, "name": entity.name} for entity in value.all()]
|
return [{"id": entity.id, "name": entity.name} for entity in value.all()]
|
||||||
@@ -84,12 +100,16 @@ class TransactionEntityField(serializers.Field):
|
|||||||
if isinstance(item, int):
|
if isinstance(item, int):
|
||||||
try:
|
try:
|
||||||
entity = TransactionEntity.objects.get(pk=item)
|
entity = TransactionEntity.objects.get(pk=item)
|
||||||
except TransactionTag.DoesNotExist:
|
except TransactionEntity.DoesNotExist:
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
_("Entity with this ID does not exist.")
|
_("Entity with this ID does not exist.")
|
||||||
)
|
)
|
||||||
elif isinstance(item, str):
|
elif isinstance(item, str):
|
||||||
entity, created = TransactionEntity.objects.get_or_create(name=item)
|
try:
|
||||||
|
entity = TransactionEntity.objects.get(name=item)
|
||||||
|
except TransactionEntity.DoesNotExist:
|
||||||
|
entity = TransactionEntity(name=item)
|
||||||
|
entity.save()
|
||||||
else:
|
else:
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
_("Invalid entity data. Provide an ID or name.")
|
_("Invalid entity data. Provide an ID or name.")
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from rest_framework.permissions import BasePermission
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
class NotInDemoMode(BasePermission):
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
if settings.DEMO and not request.user.is_superuser:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
return True
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from django.db.models import Q
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ class AccountSerializer(serializers.ModelSerializer):
|
|||||||
write_only=True,
|
write_only=True,
|
||||||
allow_null=True,
|
allow_null=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
currency = CurrencySerializer(read_only=True)
|
currency = CurrencySerializer(read_only=True)
|
||||||
currency_id = serializers.PrimaryKeyRelatedField(
|
currency_id = serializers.PrimaryKeyRelatedField(
|
||||||
queryset=Currency.objects.all(), source="currency", write_only=True
|
queryset=Currency.objects.all(), source="currency", write_only=True
|
||||||
@@ -50,6 +52,13 @@ class AccountSerializer(serializers.ModelSerializer):
|
|||||||
"is_asset",
|
"is_asset",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
request = self.context.get("request")
|
||||||
|
if request and request.user.is_authenticated:
|
||||||
|
# Reload the queryset to get an updated version with the requesting user
|
||||||
|
self.fields["group_id"].queryset = AccountGroup.objects.all()
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
return Account.objects.create(**validated_data)
|
return Account.objects.create(**validated_data)
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from drf_spectacular import openapi
|
from drf_spectacular import openapi
|
||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
@@ -21,6 +23,7 @@ from apps.transactions.models import (
|
|||||||
TransactionEntity,
|
TransactionEntity,
|
||||||
RecurringTransaction,
|
RecurringTransaction,
|
||||||
)
|
)
|
||||||
|
from apps.common.middleware.thread_local import get_current_user
|
||||||
|
|
||||||
|
|
||||||
class TransactionCategorySerializer(serializers.ModelSerializer):
|
class TransactionCategorySerializer(serializers.ModelSerializer):
|
||||||
@@ -29,6 +32,10 @@ class TransactionCategorySerializer(serializers.ModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = TransactionCategory
|
model = TransactionCategory
|
||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
|
read_only_fields = [
|
||||||
|
"id",
|
||||||
|
"owner",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class TransactionTagSerializer(serializers.ModelSerializer):
|
class TransactionTagSerializer(serializers.ModelSerializer):
|
||||||
@@ -37,6 +44,10 @@ class TransactionTagSerializer(serializers.ModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = TransactionTag
|
model = TransactionTag
|
||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
|
read_only_fields = [
|
||||||
|
"id",
|
||||||
|
"owner",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class TransactionEntitySerializer(serializers.ModelSerializer):
|
class TransactionEntitySerializer(serializers.ModelSerializer):
|
||||||
@@ -45,12 +56,16 @@ class TransactionEntitySerializer(serializers.ModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = TransactionEntity
|
model = TransactionEntity
|
||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
|
read_only_fields = [
|
||||||
|
"id",
|
||||||
|
"owner",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class InstallmentPlanSerializer(serializers.ModelSerializer):
|
class InstallmentPlanSerializer(serializers.ModelSerializer):
|
||||||
category = TransactionCategoryField(required=False)
|
category: str | int = TransactionCategoryField(required=False)
|
||||||
tags = TransactionTagField(required=False)
|
tags: str | int = TransactionTagField(required=False)
|
||||||
entities = TransactionEntityField(required=False)
|
entities: str | int = TransactionEntityField(required=False)
|
||||||
|
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
@@ -88,9 +103,9 @@ class InstallmentPlanSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class RecurringTransactionSerializer(serializers.ModelSerializer):
|
class RecurringTransactionSerializer(serializers.ModelSerializer):
|
||||||
category = TransactionCategoryField(required=False)
|
category: str | int = TransactionCategoryField(required=False)
|
||||||
tags = TransactionTagField(required=False)
|
tags: str | int = TransactionTagField(required=False)
|
||||||
entities = TransactionEntityField(required=False)
|
entities: str | int = TransactionEntityField(required=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = RecurringTransaction
|
model = RecurringTransaction
|
||||||
@@ -123,13 +138,14 @@ class RecurringTransactionSerializer(serializers.ModelSerializer):
|
|||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
instance = super().update(instance, validated_data)
|
instance = super().update(instance, validated_data)
|
||||||
instance.update_unpaid_transactions()
|
instance.update_unpaid_transactions()
|
||||||
|
instance.generate_upcoming_transactions()
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
|
|
||||||
class TransactionSerializer(serializers.ModelSerializer):
|
class TransactionSerializer(serializers.ModelSerializer):
|
||||||
category = TransactionCategoryField(required=False)
|
category: str | int = TransactionCategoryField(required=False)
|
||||||
tags = TransactionTagField(required=False)
|
tags: str | int = TransactionTagField(required=False)
|
||||||
entities = TransactionEntityField(required=False)
|
entities: str | int = TransactionEntityField(required=False)
|
||||||
|
|
||||||
exchanged_amount = serializers.SerializerMethodField()
|
exchanged_amount = serializers.SerializerMethodField()
|
||||||
|
|
||||||
@@ -155,8 +171,16 @@ class TransactionSerializer(serializers.ModelSerializer):
|
|||||||
"installment_plan",
|
"installment_plan",
|
||||||
"recurring_transaction",
|
"recurring_transaction",
|
||||||
"installment_id",
|
"installment_id",
|
||||||
|
"owner",
|
||||||
|
"deleted_at",
|
||||||
|
"deleted",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.fields["account_id"].queryset = Account.objects.all()
|
||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
if not self.partial:
|
if not self.partial:
|
||||||
if "date" in data and "reference_date" not in data:
|
if "date" in data and "reference_date" not in data:
|
||||||
@@ -192,5 +216,5 @@ class TransactionSerializer(serializers.ModelSerializer):
|
|||||||
return instance
|
return instance
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_exchanged_amount(obj):
|
def get_exchanged_amount(obj) -> Decimal:
|
||||||
return obj.exchanged_amount()
|
return obj.exchanged_amount()
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
from rest_framework import viewsets
|
from rest_framework import viewsets
|
||||||
|
|
||||||
|
from apps.api.custom.pagination import CustomPageNumberPagination
|
||||||
from apps.accounts.models import AccountGroup, Account
|
from apps.accounts.models import AccountGroup, Account
|
||||||
from apps.api.serializers import AccountGroupSerializer, AccountSerializer
|
from apps.api.serializers import AccountGroupSerializer, AccountSerializer
|
||||||
|
|
||||||
@@ -6,12 +8,20 @@ from apps.api.serializers import AccountGroupSerializer, AccountSerializer
|
|||||||
class AccountGroupViewSet(viewsets.ModelViewSet):
|
class AccountGroupViewSet(viewsets.ModelViewSet):
|
||||||
queryset = AccountGroup.objects.all()
|
queryset = AccountGroup.objects.all()
|
||||||
serializer_class = AccountGroupSerializer
|
serializer_class = AccountGroupSerializer
|
||||||
|
pagination_class = CustomPageNumberPagination
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return AccountGroup.objects.all().order_by("id")
|
||||||
|
|
||||||
|
|
||||||
class AccountViewSet(viewsets.ModelViewSet):
|
class AccountViewSet(viewsets.ModelViewSet):
|
||||||
queryset = Account.objects.all()
|
queryset = Account.objects.all()
|
||||||
serializer_class = AccountSerializer
|
serializer_class = AccountSerializer
|
||||||
|
pagination_class = CustomPageNumberPagination
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
queryset = super().get_queryset()
|
return (
|
||||||
return queryset.select_related("group", "currency", "exchange_currency")
|
Account.objects.all()
|
||||||
|
.order_by("id")
|
||||||
|
.select_related("group", "currency", "exchange_currency")
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
from rest_framework import viewsets
|
from rest_framework import viewsets
|
||||||
|
|
||||||
|
from apps.api.custom.pagination import CustomPageNumberPagination
|
||||||
from apps.api.serializers import (
|
from apps.api.serializers import (
|
||||||
TransactionSerializer,
|
TransactionSerializer,
|
||||||
TransactionCategorySerializer,
|
TransactionCategorySerializer,
|
||||||
@@ -22,40 +25,65 @@ from apps.rules.signals import transaction_updated, transaction_created
|
|||||||
class TransactionViewSet(viewsets.ModelViewSet):
|
class TransactionViewSet(viewsets.ModelViewSet):
|
||||||
queryset = Transaction.objects.all()
|
queryset = Transaction.objects.all()
|
||||||
serializer_class = TransactionSerializer
|
serializer_class = TransactionSerializer
|
||||||
|
pagination_class = CustomPageNumberPagination
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
instance = serializer.save()
|
instance = serializer.save()
|
||||||
transaction_created.send(sender=instance)
|
transaction_created.send(sender=instance)
|
||||||
|
|
||||||
def perform_update(self, serializer):
|
def perform_update(self, serializer):
|
||||||
|
old_data = deepcopy(Transaction.objects.get(pk=serializer.data["pk"]))
|
||||||
instance = serializer.save()
|
instance = serializer.save()
|
||||||
transaction_updated.send(sender=instance)
|
transaction_updated.send(sender=instance, old_data=old_data)
|
||||||
|
|
||||||
def partial_update(self, request, *args, **kwargs):
|
def partial_update(self, request, *args, **kwargs):
|
||||||
kwargs["partial"] = True
|
kwargs["partial"] = True
|
||||||
return self.update(request, *args, **kwargs)
|
return self.update(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Transaction.objects.all().order_by("-id")
|
||||||
|
|
||||||
|
|
||||||
class TransactionCategoryViewSet(viewsets.ModelViewSet):
|
class TransactionCategoryViewSet(viewsets.ModelViewSet):
|
||||||
queryset = TransactionCategory.objects.all()
|
queryset = TransactionCategory.objects.all()
|
||||||
serializer_class = TransactionCategorySerializer
|
serializer_class = TransactionCategorySerializer
|
||||||
|
pagination_class = CustomPageNumberPagination
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return TransactionCategory.objects.all().order_by("id")
|
||||||
|
|
||||||
|
|
||||||
class TransactionTagViewSet(viewsets.ModelViewSet):
|
class TransactionTagViewSet(viewsets.ModelViewSet):
|
||||||
queryset = TransactionTag.objects.all()
|
queryset = TransactionTag.objects.all()
|
||||||
serializer_class = TransactionTagSerializer
|
serializer_class = TransactionTagSerializer
|
||||||
|
pagination_class = CustomPageNumberPagination
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return TransactionTag.objects.all().order_by("id")
|
||||||
|
|
||||||
|
|
||||||
class TransactionEntityViewSet(viewsets.ModelViewSet):
|
class TransactionEntityViewSet(viewsets.ModelViewSet):
|
||||||
queryset = TransactionEntity.objects.all()
|
queryset = TransactionEntity.objects.all()
|
||||||
serializer_class = TransactionEntitySerializer
|
serializer_class = TransactionEntitySerializer
|
||||||
|
pagination_class = CustomPageNumberPagination
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return TransactionEntity.objects.all().order_by("id")
|
||||||
|
|
||||||
|
|
||||||
class InstallmentPlanViewSet(viewsets.ModelViewSet):
|
class InstallmentPlanViewSet(viewsets.ModelViewSet):
|
||||||
queryset = InstallmentPlan.objects.all()
|
queryset = InstallmentPlan.objects.all()
|
||||||
serializer_class = InstallmentPlanSerializer
|
serializer_class = InstallmentPlanSerializer
|
||||||
|
pagination_class = CustomPageNumberPagination
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return InstallmentPlan.objects.all().order_by("-id")
|
||||||
|
|
||||||
|
|
||||||
class RecurringTransactionViewSet(viewsets.ModelViewSet):
|
class RecurringTransactionViewSet(viewsets.ModelViewSet):
|
||||||
queryset = RecurringTransaction.objects.all()
|
queryset = RecurringTransaction.objects.all()
|
||||||
serializer_class = RecurringTransactionSerializer
|
serializer_class = RecurringTransactionSerializer
|
||||||
|
pagination_class = CustomPageNumberPagination
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return RecurringTransaction.objects.all().order_by("-id")
|
||||||
|
|||||||
@@ -0,0 +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.apps import AppConfig
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
|
||||||
class CommonConfig(AppConfig):
|
class CommonConfig(AppConfig):
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
name = "apps.common"
|
name = "apps.common"
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.contrib.sites.models import Site
|
||||||
|
from allauth.socialaccount.models import (
|
||||||
|
SocialAccount,
|
||||||
|
SocialApp,
|
||||||
|
SocialToken,
|
||||||
|
)
|
||||||
|
|
||||||
|
admin.site.unregister(Site)
|
||||||
|
admin.site.unregister(SocialAccount)
|
||||||
|
admin.site.unregister(SocialApp)
|
||||||
|
admin.site.unregister(SocialToken)
|
||||||
|
|
||||||
|
# Delete the cache for update checks to prevent false-positives when the app is restarted
|
||||||
|
# this will be recreated by the check_for_updates task
|
||||||
|
cache.delete("update_check")
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
|
|
||||||
|
|
||||||
|
def disabled_on_demo(view):
|
||||||
|
@wraps(view)
|
||||||
|
def _view(request, *args, **kwargs):
|
||||||
|
if settings.DEMO and not request.user.is_superuser:
|
||||||
|
raise PermissionDenied
|
||||||
|
|
||||||
|
return view(request, *args, **kwargs)
|
||||||
|
|
||||||
|
return _view
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
from django.urls import reverse, NoReverseMatch
|
||||||
|
|
||||||
|
|
||||||
|
def is_superuser(view):
|
||||||
|
@wraps(view)
|
||||||
|
def _view(request, *args, **kwargs):
|
||||||
|
if not request.user.is_superuser:
|
||||||
|
raise PermissionDenied
|
||||||
|
|
||||||
|
return view(request, *args, **kwargs)
|
||||||
|
|
||||||
|
return _view
|
||||||
|
|
||||||
|
|
||||||
|
def htmx_login_required(function=None, login_url=None):
|
||||||
|
"""
|
||||||
|
Decorator that checks if the user is logged in.
|
||||||
|
|
||||||
|
Allows overriding the default login URL.
|
||||||
|
|
||||||
|
If the user is not logged in:
|
||||||
|
- If "hx-request" is present in the request header, it returns a 200 response
|
||||||
|
with a "HX-Redirect" header containing the determined login URL (including the "next" parameter).
|
||||||
|
- If "hx-request" is not present, it redirects to the determined login page normally.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
function: The view function to decorate.
|
||||||
|
login_url: Optional. The URL or URL name to redirect to for login.
|
||||||
|
Defaults to settings.LOGIN_URL.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(view_func):
|
||||||
|
# Simplified @wraps usage - it handles necessary attribute assignments by default
|
||||||
|
@wraps(view_func)
|
||||||
|
def wrapped_view(request, *args, **kwargs):
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
return view_func(request, *args, **kwargs)
|
||||||
|
else:
|
||||||
|
# Determine the login URL
|
||||||
|
resolved_login_url = login_url
|
||||||
|
if not resolved_login_url:
|
||||||
|
resolved_login_url = settings.LOGIN_URL
|
||||||
|
|
||||||
|
# Try to reverse the URL name if it's not a path
|
||||||
|
try:
|
||||||
|
# Check if it looks like a URL path already
|
||||||
|
if "/" not in resolved_login_url and "." not in resolved_login_url:
|
||||||
|
login_url_path = reverse(resolved_login_url)
|
||||||
|
else:
|
||||||
|
login_url_path = resolved_login_url
|
||||||
|
except NoReverseMatch:
|
||||||
|
# If reverse fails, assume it's already a URL path
|
||||||
|
login_url_path = resolved_login_url
|
||||||
|
|
||||||
|
# Construct the full redirect path with 'next' parameter
|
||||||
|
# Ensure request.path is URL-encoded if needed, though Django usually handles this
|
||||||
|
redirect_path = f"{login_url_path}?next={request.get_full_path()}" # Use get_full_path() to include query params
|
||||||
|
|
||||||
|
if request.headers.get("hx-request"):
|
||||||
|
# For HTMX requests, return a 200 with the HX-Redirect header.
|
||||||
|
response = HttpResponse()
|
||||||
|
response["HX-Redirect"] = login_url_path
|
||||||
|
return response
|
||||||
|
else:
|
||||||
|
# For regular requests, redirect to the login page.
|
||||||
|
return redirect(redirect_path)
|
||||||
|
|
||||||
|
return wrapped_view
|
||||||
|
|
||||||
|
if function:
|
||||||
|
return decorator(function)
|
||||||
|
return decorator
|
||||||
@@ -4,6 +4,7 @@ from django.db import transaction
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple
|
from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple
|
||||||
|
from apps.common.middleware.thread_local import get_current_user
|
||||||
|
|
||||||
|
|
||||||
class DynamicModelChoiceField(forms.ModelChoiceField):
|
class DynamicModelChoiceField(forms.ModelChoiceField):
|
||||||
@@ -12,15 +13,14 @@ class DynamicModelChoiceField(forms.ModelChoiceField):
|
|||||||
self.to_field_name = kwargs.pop("to_field_name", "pk")
|
self.to_field_name = kwargs.pop("to_field_name", "pk")
|
||||||
|
|
||||||
self.create_field = kwargs.pop("create_field", None)
|
self.create_field = kwargs.pop("create_field", None)
|
||||||
if not self.create_field:
|
|
||||||
raise ValueError("The 'create_field' parameter is required.")
|
|
||||||
|
|
||||||
self.queryset = kwargs.pop("queryset", model.objects.all())
|
self.queryset = kwargs.pop("queryset", model.objects.all())
|
||||||
super().__init__(queryset=self.queryset, *args, **kwargs)
|
|
||||||
self._created_instance = None
|
|
||||||
|
|
||||||
self.widget = TomSelect(clear_button=True, create=True)
|
self.widget = TomSelect(clear_button=True, create=True)
|
||||||
|
|
||||||
|
super().__init__(queryset=self.queryset, *args, **kwargs)
|
||||||
|
self._created_instance = None
|
||||||
|
|
||||||
def to_python(self, value):
|
def to_python(self, value):
|
||||||
if value in self.empty_values:
|
if value in self.empty_values:
|
||||||
return None
|
return None
|
||||||
@@ -53,17 +53,27 @@ class DynamicModelChoiceField(forms.ModelChoiceField):
|
|||||||
else:
|
else:
|
||||||
raise self.model.DoesNotExist
|
raise self.model.DoesNotExist
|
||||||
except self.model.DoesNotExist:
|
except self.model.DoesNotExist:
|
||||||
|
if self.create_field:
|
||||||
try:
|
try:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
instance, _ = self.model.objects.update_or_create(
|
# First try to get the object
|
||||||
**{self.create_field: value}
|
lookup = {self.create_field: value}
|
||||||
)
|
try:
|
||||||
|
instance = self.model.objects.get(**lookup)
|
||||||
|
except self.model.DoesNotExist:
|
||||||
|
# Create a new instance directly
|
||||||
|
instance = self.model(**lookup)
|
||||||
|
instance.save()
|
||||||
|
|
||||||
self._created_instance = instance
|
self._created_instance = instance
|
||||||
return instance
|
return instance
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
raise ValidationError(_("Error creating new instance"))
|
||||||
|
else:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
self.error_messages["invalid_choice"], code="invalid_choice"
|
self.error_messages["invalid_choice"], code="invalid_choice"
|
||||||
)
|
)
|
||||||
|
|
||||||
return super().clean(value)
|
return super().clean(value)
|
||||||
|
|
||||||
def bound_data(self, data, initial):
|
def bound_data(self, data, initial):
|
||||||
@@ -86,8 +96,6 @@ class DynamicModelMultipleChoiceField(forms.ModelMultipleChoiceField):
|
|||||||
|
|
||||||
def __init__(self, model, **kwargs):
|
def __init__(self, model, **kwargs):
|
||||||
"""
|
"""
|
||||||
Initialize the CreateIfNotExistsModelMultipleChoiceField.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
create_field (str): The name of the field to use when creating new instances.
|
create_field (str): The name of the field to use when creating new instances.
|
||||||
*args: Variable length argument list.
|
*args: Variable length argument list.
|
||||||
@@ -119,33 +127,27 @@ class DynamicModelMultipleChoiceField(forms.ModelMultipleChoiceField):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
instance, _ = self.model.objects.update_or_create(
|
# Check if exists first without using update_or_create
|
||||||
**{self.create_field: value}
|
lookup = {self.create_field: value}
|
||||||
)
|
try:
|
||||||
|
# Use base manager to bypass distinct filters
|
||||||
|
instance = self.model.objects.get(**lookup)
|
||||||
|
return instance
|
||||||
|
except self.model.DoesNotExist:
|
||||||
|
# Create a new instance directly
|
||||||
|
instance = self.model(**lookup)
|
||||||
|
instance.save()
|
||||||
return instance
|
return instance
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValidationError(_("Error creating new instance"))
|
raise ValidationError(_("Error creating new instance"))
|
||||||
|
|
||||||
def clean(self, value):
|
def clean(self, value):
|
||||||
"""
|
|
||||||
Clean and validate the field value.
|
|
||||||
|
|
||||||
This method checks if each selected choice exists in the database.
|
|
||||||
If a choice doesn't exist, it creates a new instance of the model.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
value (list): List of selected values.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list: A list containing all selected and newly created model instances.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValidationError: If there's an error during the cleaning process.
|
|
||||||
"""
|
|
||||||
if not value:
|
if not value:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
string_values = set(str(v) for v in value)
|
string_values = set(str(v) for v in value)
|
||||||
|
|
||||||
|
# Get existing objects first
|
||||||
existing_objects = list(
|
existing_objects = list(
|
||||||
self.queryset.filter(**{f"{self.create_field}__in": string_values})
|
self.queryset.filter(**{f"{self.create_field}__in": string_values})
|
||||||
)
|
)
|
||||||
@@ -153,13 +155,11 @@ class DynamicModelMultipleChoiceField(forms.ModelMultipleChoiceField):
|
|||||||
str(getattr(obj, self.create_field)) for obj in existing_objects
|
str(getattr(obj, self.create_field)) for obj in existing_objects
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Create new objects for missing values
|
||||||
new_values = string_values - existing_values
|
new_values = string_values - existing_values
|
||||||
new_objects = []
|
new_objects = []
|
||||||
|
|
||||||
for new_value in new_values:
|
for new_value in new_values:
|
||||||
try:
|
|
||||||
new_objects.append(self._create_new_instance(new_value))
|
new_objects.append(self._create_new_instance(new_value))
|
||||||
except ValidationError as e:
|
|
||||||
raise ValidationError(_("Error creating new instance"))
|
|
||||||
|
|
||||||
return existing_objects + new_objects
|
return existing_objects + new_objects
|
||||||
|
|||||||
@@ -20,7 +20,15 @@ class MonthYearModelField(models.DateField):
|
|||||||
# Set the day to 1
|
# Set the day to 1
|
||||||
return date.replace(day=1).date()
|
return date.replace(day=1).date()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise ValidationError(_("Invalid date format. Use YYYY-MM."))
|
try:
|
||||||
|
# Also accept YYYY-MM-DD format (for loaddata)
|
||||||
|
return (
|
||||||
|
datetime.datetime.strptime(value, "%Y-%m-%d").replace(day=1).date()
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
raise ValidationError(
|
||||||
|
_("Invalid date format. Use YYYY-MM or YYYY-MM-DD.")
|
||||||
|
)
|
||||||
|
|
||||||
def formfield(self, **kwargs):
|
def formfield(self, **kwargs):
|
||||||
kwargs["widget"] = MonthYearWidget
|
kwargs["widget"] = MonthYearWidget
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
from crispy_forms.bootstrap import FormActions
|
||||||
|
from django import forms
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from crispy_forms.helper import FormHelper
|
||||||
|
from crispy_forms.layout import Layout, Field, Submit, Div, HTML
|
||||||
|
|
||||||
|
from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple
|
||||||
|
from apps.common.models import SharedObject
|
||||||
|
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class SharedObjectForm(forms.Form):
|
||||||
|
"""
|
||||||
|
Generic form for editing visibility and sharing settings
|
||||||
|
for models inheriting from SharedObject.
|
||||||
|
"""
|
||||||
|
|
||||||
|
owner = forms.ModelChoiceField(
|
||||||
|
queryset=User.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label=_("Owner"),
|
||||||
|
widget=TomSelect(clear_button=False),
|
||||||
|
help_text=_(
|
||||||
|
"The owner of this object, if empty all users can see, edit and take ownership."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
shared_with_users = forms.ModelMultipleChoiceField(
|
||||||
|
queryset=User.objects.all(),
|
||||||
|
required=False,
|
||||||
|
widget=TomSelectMultiple(clear_button=True),
|
||||||
|
label=_("Shared with users"),
|
||||||
|
help_text=_("Select users to share this object with"),
|
||||||
|
)
|
||||||
|
visibility = forms.ChoiceField(
|
||||||
|
choices=SharedObject.Visibility.choices,
|
||||||
|
required=True,
|
||||||
|
label=_("Visibility"),
|
||||||
|
help_text=_(
|
||||||
|
"Private: Only shown for the owner and shared users. Only editable by the owner."
|
||||||
|
"<br/>"
|
||||||
|
"Public: Shown for all users. Only editable by the owner."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
fields = ["visibility", "shared_with_users"]
|
||||||
|
widgets = {
|
||||||
|
"visibility": TomSelect(clear_button=False),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
# Get the current user to filter available sharing options
|
||||||
|
self.user = kwargs.pop("user", None)
|
||||||
|
self.instance = kwargs.pop("instance", None)
|
||||||
|
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
# Pre-populate shared users if instance exists
|
||||||
|
if self.instance:
|
||||||
|
self.fields["shared_with_users"].initial = self.instance.shared_with.all()
|
||||||
|
self.fields["visibility"].initial = self.instance.visibility
|
||||||
|
self.fields["owner"].initial = self.instance.owner
|
||||||
|
|
||||||
|
# Set up crispy form helper
|
||||||
|
self.helper = FormHelper()
|
||||||
|
self.helper.form_method = "post"
|
||||||
|
self.helper.form_tag = False
|
||||||
|
|
||||||
|
self.helper.layout = Layout(
|
||||||
|
Field("owner"),
|
||||||
|
Field("visibility"),
|
||||||
|
HTML("<hr>"),
|
||||||
|
Field("shared_with_users"),
|
||||||
|
FormActions(
|
||||||
|
NoClassSubmit(
|
||||||
|
"submit", _("Save"), css_class="btn btn-outline-primary w-100"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
owner = cleaned_data.get("owner")
|
||||||
|
shared_with_users = cleaned_data.get("shared_with_users", [])
|
||||||
|
|
||||||
|
# Raise validation error if owner is in shared_with_users
|
||||||
|
if owner and owner in shared_with_users:
|
||||||
|
self.add_error(
|
||||||
|
"shared_with_users",
|
||||||
|
ValidationError(
|
||||||
|
_("You cannot share this item with its owner."),
|
||||||
|
code="invalid_share",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
instance = self.instance
|
||||||
|
|
||||||
|
instance.visibility = self.cleaned_data["visibility"]
|
||||||
|
instance.owner = self.cleaned_data["owner"]
|
||||||
|
|
||||||
|
instance.save()
|
||||||
|
|
||||||
|
# Clear and set shared_with users
|
||||||
|
instance.shared_with.set(self.cleaned_data.get("shared_with_users", []))
|
||||||
|
|
||||||
|
return instance
|
||||||
@@ -9,5 +9,8 @@ def truncate_decimal(value, decimal_places):
|
|||||||
:param decimal_places: The number of decimal places to keep
|
:param decimal_places: The number of decimal places to keep
|
||||||
:return: Truncated Decimal value
|
:return: Truncated Decimal value
|
||||||
"""
|
"""
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
value = Decimal(str(value))
|
||||||
|
|
||||||
multiplier = Decimal(10**decimal_places)
|
multiplier = Decimal(10**decimal_places)
|
||||||
return (value * multiplier).to_integral_value(rounding=ROUND_DOWN) / multiplier
|
return (value * multiplier).to_integral_value(rounding=ROUND_DOWN) / multiplier
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ from django.utils.formats import get_format as original_get_format
|
|||||||
def get_format(format_type=None, lang=None, use_l10n=None):
|
def get_format(format_type=None, lang=None, use_l10n=None):
|
||||||
user = get_current_user()
|
user = get_current_user()
|
||||||
|
|
||||||
if user and user.is_authenticated and hasattr(user, "settings"):
|
if (
|
||||||
|
user
|
||||||
|
and user.is_authenticated
|
||||||
|
and hasattr(user, "settings")
|
||||||
|
and use_l10n is not False
|
||||||
|
):
|
||||||
user_settings = user.settings
|
user_settings = user.settings
|
||||||
if format_type == "THOUSAND_SEPARATOR":
|
if format_type == "THOUSAND_SEPARATOR":
|
||||||
number_format = getattr(user_settings, "number_format", None)
|
number_format = getattr(user_settings, "number_format", None)
|
||||||
@@ -13,11 +18,13 @@ def get_format(format_type=None, lang=None, use_l10n=None):
|
|||||||
return "."
|
return "."
|
||||||
elif number_format == "CD":
|
elif number_format == "CD":
|
||||||
return ","
|
return ","
|
||||||
|
elif number_format == "SD" or number_format == "SC":
|
||||||
|
return " "
|
||||||
elif format_type == "DECIMAL_SEPARATOR":
|
elif format_type == "DECIMAL_SEPARATOR":
|
||||||
number_format = getattr(user_settings, "number_format", None)
|
number_format = getattr(user_settings, "number_format", None)
|
||||||
if number_format == "DC":
|
if number_format == "DC" or number_format == "SC":
|
||||||
return ","
|
return ","
|
||||||
elif number_format == "CD":
|
elif number_format == "CD" or number_format == "SD":
|
||||||
return "."
|
return "."
|
||||||
elif format_type == "SHORT_DATE_FORMAT":
|
elif format_type == "SHORT_DATE_FORMAT":
|
||||||
date_format = getattr(user_settings, "date_format", None)
|
date_format = getattr(user_settings, "date_format", None)
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import os
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import IntegrityError
|
||||||
|
|
||||||
|
# Get the custom User model if defined, otherwise the default User model
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = (
|
||||||
|
"Creates a superuser from environment variables (ADMIN_EMAIL, ADMIN_PASSWORD) "
|
||||||
|
"and optionally creates a demo user (demo@demo.com) if settings.DEMO is True."
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
self.stdout.write("Starting user setup...")
|
||||||
|
|
||||||
|
# --- Create Superuser ---
|
||||||
|
admin_email = os.environ.get("ADMIN_EMAIL")
|
||||||
|
admin_password = os.environ.get("ADMIN_PASSWORD")
|
||||||
|
|
||||||
|
if admin_email and admin_password:
|
||||||
|
self.stdout.write(f"Attempting to create superuser: {admin_email}")
|
||||||
|
# Use email as username for simplicity, requires USERNAME_FIELD='email'
|
||||||
|
# or adapt if your USERNAME_FIELD is different.
|
||||||
|
# If USERNAME_FIELD is 'username', you might need ADMIN_USERNAME env var.
|
||||||
|
username_field = User.USERNAME_FIELD # Get the actual username field name
|
||||||
|
|
||||||
|
# Check if the user already exists by email or username
|
||||||
|
user_exists_kwargs = {"email": admin_email}
|
||||||
|
if username_field != "email":
|
||||||
|
# Assume username should also be the email if not explicitly provided
|
||||||
|
user_exists_kwargs[username_field] = admin_email
|
||||||
|
|
||||||
|
if User.objects.filter(**user_exists_kwargs).exists():
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(
|
||||||
|
f"Superuser with email '{admin_email}' (or corresponding username) already exists. Skipping creation."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
create_kwargs = {
|
||||||
|
username_field: admin_email, # Use email as username by default
|
||||||
|
"email": admin_email,
|
||||||
|
"password": admin_password,
|
||||||
|
}
|
||||||
|
User.objects.create_superuser(**create_kwargs)
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f"Superuser '{admin_email}' created successfully."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except IntegrityError as e:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.ERROR(
|
||||||
|
f"Failed to create superuser '{admin_email}'. IntegrityError: {e}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.ERROR(
|
||||||
|
f"An unexpected error occurred creating superuser '{admin_email}': {e}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.NOTICE(
|
||||||
|
"ADMIN_EMAIL or ADMIN_PASSWORD environment variables not set. Skipping superuser creation."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write("---") # Separator
|
||||||
|
|
||||||
|
# --- Create Demo User ---
|
||||||
|
# Use getattr to safely check for the DEMO setting, default to False if not present
|
||||||
|
create_demo_user = getattr(settings, "DEMO", False)
|
||||||
|
|
||||||
|
if create_demo_user:
|
||||||
|
demo_email = "demo@demo.com"
|
||||||
|
demo_password = (
|
||||||
|
"wygiwyhdemo" # Consider making this an env var too for security
|
||||||
|
)
|
||||||
|
demo_username = demo_email # Using email as username for consistency
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
f"DEMO setting is True. Attempting to create demo user: {demo_email}"
|
||||||
|
)
|
||||||
|
|
||||||
|
username_field = User.USERNAME_FIELD # Get the actual username field name
|
||||||
|
|
||||||
|
# Check if the user already exists by email or username
|
||||||
|
user_exists_kwargs = {"email": demo_email}
|
||||||
|
if username_field != "email":
|
||||||
|
user_exists_kwargs[username_field] = demo_username
|
||||||
|
|
||||||
|
if User.objects.filter(**user_exists_kwargs).exists():
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(
|
||||||
|
f"Demo user with email '{demo_email}' (or corresponding username) already exists. Skipping creation."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
create_kwargs = {
|
||||||
|
username_field: demo_username,
|
||||||
|
"email": demo_email,
|
||||||
|
"password": demo_password,
|
||||||
|
}
|
||||||
|
User.objects.create_user(**create_kwargs)
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f"Demo user '{demo_email}' created successfully."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except IntegrityError as e:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.ERROR(
|
||||||
|
f"Failed to create demo user '{demo_email}'. IntegrityError: {e}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.ERROR(
|
||||||
|
f"An unexpected error occurred creating demo user '{demo_email}': {e}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.NOTICE(
|
||||||
|
"DEMO setting is not True (or not set). Skipping demo user creation."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS("User setup command finished."))
|
||||||
@@ -56,6 +56,16 @@ def get_current_user():
|
|||||||
if request:
|
if request:
|
||||||
return getattr(request, "user", None)
|
return getattr(request, "user", None)
|
||||||
|
|
||||||
|
return getattr(_thread_locals, "user", None)
|
||||||
|
|
||||||
|
|
||||||
|
def write_current_user(user):
|
||||||
|
_thread_locals.user = user
|
||||||
|
|
||||||
|
|
||||||
|
def delete_current_user():
|
||||||
|
del _thread_locals.user
|
||||||
|
|
||||||
|
|
||||||
class ThreadLocalMiddleware(MiddlewareMixin):
|
class ThreadLocalMiddleware(MiddlewareMixin):
|
||||||
"""Simple middleware that adds the request object in thread local storage."""
|
"""Simple middleware that adds the request object in thread local storage."""
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from apps.common.middleware.thread_local import get_current_user
|
||||||
|
|
||||||
|
|
||||||
|
class SharedObjectManager(models.Manager):
|
||||||
|
def get_queryset(self):
|
||||||
|
"""Return only objects the user can access"""
|
||||||
|
user = get_current_user()
|
||||||
|
base_qs = super().get_queryset()
|
||||||
|
|
||||||
|
if user and user.is_authenticated:
|
||||||
|
return base_qs.filter(
|
||||||
|
Q(visibility="public")
|
||||||
|
| Q(owner=user)
|
||||||
|
| Q(shared_with=user)
|
||||||
|
| Q(visibility="private", owner=None)
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
return base_qs.filter(visibility="public")
|
||||||
|
|
||||||
|
|
||||||
|
class SharedObject(models.Model):
|
||||||
|
# Access control enum
|
||||||
|
class Visibility(models.TextChoices):
|
||||||
|
private = "private", _("Private")
|
||||||
|
is_paid = "public", _("Public")
|
||||||
|
|
||||||
|
# Core sharing fields
|
||||||
|
owner = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="%(class)s_owned",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name=_("Owner"),
|
||||||
|
)
|
||||||
|
visibility = models.CharField(
|
||||||
|
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,
|
||||||
|
verbose_name=_("Shared with users"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use as abstract base class
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["visibility"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def is_accessible_by(self, user):
|
||||||
|
"""Check if a user can access this object"""
|
||||||
|
return (
|
||||||
|
self.visibility == "public"
|
||||||
|
or self.owner == user
|
||||||
|
or (self.visibility == "shared" and user in self.shared_with.all())
|
||||||
|
)
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if not self.pk and not self.owner:
|
||||||
|
self.owner = get_current_user()
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class OwnedObjectManager(models.Manager):
|
||||||
|
def get_queryset(self):
|
||||||
|
"""Return only objects the user can access"""
|
||||||
|
user = get_current_user()
|
||||||
|
base_qs = super().get_queryset()
|
||||||
|
|
||||||
|
if user and user.is_authenticated:
|
||||||
|
return base_qs.filter(Q(owner=user) | Q(owner=None)).distinct()
|
||||||
|
|
||||||
|
return base_qs
|
||||||
|
|
||||||
|
|
||||||
|
class OwnedObject(models.Model):
|
||||||
|
owner = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="%(class)s_owned",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use as abstract base class
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if not self.pk and not self.owner:
|
||||||
|
self.owner = get_current_user()
|
||||||
|
super().save(*args, **kwargs)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import procrastinate
|
||||||
|
|
||||||
|
|
||||||
|
def on_app_ready(app: procrastinate.App):
|
||||||
|
"""This function is ran upon procrastinate initialization."""
|
||||||
|
...
|
||||||
@@ -1,23 +1,29 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from packaging.version import parse as parse_version, InvalidVersion
|
||||||
|
|
||||||
from asgiref.sync import sync_to_async
|
from asgiref.sync import sync_to_async
|
||||||
|
from django.conf import settings
|
||||||
from django.core import management
|
from django.core import management
|
||||||
|
from django.db import DEFAULT_DB_ALIAS
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
from procrastinate import builtin_tasks
|
from procrastinate import builtin_tasks
|
||||||
from procrastinate.contrib.django import app
|
from procrastinate.contrib.django import app
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@app.periodic(cron="0 4 * * *")
|
@app.periodic(cron="0 4 * * *")
|
||||||
@app.task(queueing_lock="remove_old_jobs", pass_context=True)
|
@app.task(queueing_lock="remove_old_jobs", pass_context=True, name="remove_old_jobs")
|
||||||
async def remove_old_jobs(context, timestamp):
|
async def remove_old_jobs(context, timestamp):
|
||||||
try:
|
try:
|
||||||
return await builtin_tasks.remove_old_jobs(
|
return await builtin_tasks.remove_old_jobs(
|
||||||
context,
|
context,
|
||||||
max_hours=744,
|
max_hours=744,
|
||||||
remove_error=True,
|
remove_failed=True,
|
||||||
remove_cancelled=True,
|
remove_cancelled=True,
|
||||||
remove_aborted=True,
|
remove_aborted=True,
|
||||||
)
|
)
|
||||||
@@ -30,7 +36,7 @@ async def remove_old_jobs(context, timestamp):
|
|||||||
|
|
||||||
|
|
||||||
@app.periodic(cron="0 6 1 * *")
|
@app.periodic(cron="0 6 1 * *")
|
||||||
@app.task(queueing_lock="remove_expired_sessions")
|
@app.task(queueing_lock="remove_expired_sessions", name="remove_expired_sessions")
|
||||||
async def remove_expired_sessions(timestamp=None):
|
async def remove_expired_sessions(timestamp=None):
|
||||||
"""Cleanup expired sessions by using Django management command."""
|
"""Cleanup expired sessions by using Django management command."""
|
||||||
try:
|
try:
|
||||||
@@ -40,3 +46,86 @@ async def remove_expired_sessions(timestamp=None):
|
|||||||
"Error while executing 'remove_expired_sessions' task",
|
"Error while executing 'remove_expired_sessions' task",
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.periodic(cron="0 8 * * *")
|
||||||
|
@app.task(name="reset_demo_data")
|
||||||
|
def reset_demo_data(timestamp=None):
|
||||||
|
"""
|
||||||
|
Wipes the database and loads fresh demo data if DEMO mode is active.
|
||||||
|
Runs daily at 8:00 AM.
|
||||||
|
"""
|
||||||
|
if not settings.DEMO:
|
||||||
|
return # Exit if not in demo mode
|
||||||
|
|
||||||
|
logger.info("Demo mode active. Starting daily data reset...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. Flush the database (wipe all data)
|
||||||
|
logger.info("Flushing the database...")
|
||||||
|
|
||||||
|
management.call_command(
|
||||||
|
"flush", "--noinput", database=DEFAULT_DB_ALIAS, verbosity=1
|
||||||
|
)
|
||||||
|
logger.info("Database flushed successfully.")
|
||||||
|
|
||||||
|
# 2. Load data from the fixture
|
||||||
|
# TO-DO: Roll dates over based on today's date
|
||||||
|
fixture_name = "fixtures/demo_data.json"
|
||||||
|
logger.info(f"Loading data from fixture: {fixture_name}...")
|
||||||
|
management.call_command(
|
||||||
|
"loaddata", fixture_name, database=DEFAULT_DB_ALIAS, verbosity=1
|
||||||
|
)
|
||||||
|
logger.info(f"Data loaded successfully from {fixture_name}.")
|
||||||
|
|
||||||
|
logger.info("Daily demo data reset completed.")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Error during daily demo data reset: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
@app.periodic(cron="0 */12 * * *") # Every 12 hours
|
||||||
|
@app.task(
|
||||||
|
name="check_for_updates",
|
||||||
|
)
|
||||||
|
def check_for_updates(timestamp=None):
|
||||||
|
if not settings.CHECK_FOR_UPDATES:
|
||||||
|
return "CHECK_FOR_UPDATES is disabled"
|
||||||
|
|
||||||
|
url = "https://api.github.com/repos/eitchtee/WYGIWYH/releases/latest"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(url, timeout=60)
|
||||||
|
response.raise_for_status() # Raise an exception for bad status codes (4xx or 5xx)
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
latest_version = data.get("tag_name")
|
||||||
|
|
||||||
|
if latest_version:
|
||||||
|
try:
|
||||||
|
current_v = parse_version(settings.APP_VERSION)
|
||||||
|
except InvalidVersion:
|
||||||
|
current_v = parse_version("0.0.0")
|
||||||
|
try:
|
||||||
|
latest_v = parse_version(latest_version)
|
||||||
|
except InvalidVersion:
|
||||||
|
latest_v = parse_version("0.0.0")
|
||||||
|
|
||||||
|
update_info = {
|
||||||
|
"update_available": False,
|
||||||
|
"current_version": str(current_v),
|
||||||
|
"latest_version": str(latest_v),
|
||||||
|
}
|
||||||
|
|
||||||
|
if latest_v > current_v:
|
||||||
|
update_info["update_available"] = True
|
||||||
|
|
||||||
|
# Cache the entire dictionary
|
||||||
|
cache.set("update_check", update_info, 60 * 60 * 25)
|
||||||
|
logger.info(f"Update check complete. Result: {update_info}")
|
||||||
|
else:
|
||||||
|
logger.warning("Could not find 'tag_name' in GitHub API response.")
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"Failed to fetch updates from GitHub: {e}")
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# core/templatetags/update_tags.py
|
||||||
|
from django import template
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag
|
||||||
|
def get_update_check():
|
||||||
|
"""
|
||||||
|
Retrieves the update status dictionary from the cache.
|
||||||
|
Returns a default dictionary if nothing is found.
|
||||||
|
"""
|
||||||
|
return cache.get("update_check") or {
|
||||||
|
"update_available": False,
|
||||||
|
"latest_version": "N/A",
|
||||||
|
}
|
||||||
@@ -15,10 +15,11 @@ from cachalot.api import invalidate
|
|||||||
|
|
||||||
from apps.common.decorators.htmx import only_htmx
|
from apps.common.decorators.htmx import only_htmx
|
||||||
from apps.transactions.models import Transaction
|
from apps.transactions.models import Transaction
|
||||||
|
from apps.common.decorators.user import htmx_login_required
|
||||||
|
|
||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@htmx_login_required
|
||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def toasts(request):
|
def toasts(request):
|
||||||
return render(request, "common/fragments/toasts.html")
|
return render(request, "common/fragments/toasts.html")
|
||||||
@@ -90,6 +91,12 @@ def month_year_picker(request):
|
|||||||
for date in all_months
|
for date in all_months
|
||||||
]
|
]
|
||||||
|
|
||||||
|
today_url = (
|
||||||
|
reverse(url, kwargs={"month": current_date.month, "year": current_date.year})
|
||||||
|
if url
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"common/fragments/month_year_picker.html",
|
"common/fragments/month_year_picker.html",
|
||||||
@@ -97,6 +104,7 @@ def month_year_picker(request):
|
|||||||
"month_year_data": result,
|
"month_year_data": result,
|
||||||
"current_month": current_month,
|
"current_month": current_month,
|
||||||
"current_year": current_year,
|
"current_year": current_year,
|
||||||
|
"today_url": today_url,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ class AirDatePickerInput(widgets.DateInput):
|
|||||||
format=None,
|
format=None,
|
||||||
clear_button=True,
|
clear_button=True,
|
||||||
auto_close=True,
|
auto_close=True,
|
||||||
|
read_only=True,
|
||||||
|
toggle_selected=None,
|
||||||
*args,
|
*args,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
@@ -26,12 +28,18 @@ class AirDatePickerInput(widgets.DateInput):
|
|||||||
super().__init__(attrs=attrs, format=format, *args, **kwargs)
|
super().__init__(attrs=attrs, format=format, *args, **kwargs)
|
||||||
self.clear_button = clear_button
|
self.clear_button = clear_button
|
||||||
self.auto_close = auto_close
|
self.auto_close = auto_close
|
||||||
|
self.read_only = read_only
|
||||||
|
self.toggle_selected = (
|
||||||
|
toggle_selected if isinstance(toggle_selected, bool) else self.clear_button
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_current_language():
|
def _get_current_language():
|
||||||
"""Get current language code in format compatible with AirDatepicker"""
|
"""Get current language code in format compatible with AirDatepicker"""
|
||||||
lang_code = translation.get_language()
|
lang_code = translation.get_language()
|
||||||
# AirDatepicker uses simple language codes
|
# AirDatepicker uses simple language codes, except for pt-br
|
||||||
|
if lang_code.lower() == "pt-br":
|
||||||
|
return "pt-BR"
|
||||||
return lang_code.split("-")[0]
|
return lang_code.split("-")[0]
|
||||||
|
|
||||||
def _get_format(self):
|
def _get_format(self):
|
||||||
@@ -47,9 +55,13 @@ class AirDatePickerInput(widgets.DateInput):
|
|||||||
attrs["data-now-button-txt"] = _("Today")
|
attrs["data-now-button-txt"] = _("Today")
|
||||||
attrs["data-auto-close"] = str(self.auto_close).lower()
|
attrs["data-auto-close"] = str(self.auto_close).lower()
|
||||||
attrs["data-clear-button"] = str(self.clear_button).lower()
|
attrs["data-clear-button"] = str(self.clear_button).lower()
|
||||||
|
attrs["data-toggle-selected"] = str(self.toggle_selected).lower()
|
||||||
attrs["data-language"] = self._get_current_language()
|
attrs["data-language"] = self._get_current_language()
|
||||||
attrs["data-date-format"] = django_to_airdatepicker_datetime(self._get_format())
|
attrs["data-date-format"] = django_to_airdatepicker_datetime(self._get_format())
|
||||||
|
|
||||||
|
if self.read_only:
|
||||||
|
attrs["readonly"] = True
|
||||||
|
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
def format_value(self, value):
|
def format_value(self, value):
|
||||||
@@ -89,6 +101,8 @@ class AirDateTimePickerInput(widgets.DateTimeInput):
|
|||||||
timepicker=True,
|
timepicker=True,
|
||||||
clear_button=True,
|
clear_button=True,
|
||||||
auto_close=True,
|
auto_close=True,
|
||||||
|
read_only=True,
|
||||||
|
toggle_selected=None,
|
||||||
*args,
|
*args,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
@@ -97,6 +111,10 @@ class AirDateTimePickerInput(widgets.DateTimeInput):
|
|||||||
self.timepicker = timepicker
|
self.timepicker = timepicker
|
||||||
self.clear_button = clear_button
|
self.clear_button = clear_button
|
||||||
self.auto_close = auto_close
|
self.auto_close = auto_close
|
||||||
|
self.read_only = read_only
|
||||||
|
self.toggle_selected = (
|
||||||
|
toggle_selected if isinstance(toggle_selected, bool) else self.clear_button
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_current_language():
|
def _get_current_language():
|
||||||
@@ -123,11 +141,15 @@ class AirDateTimePickerInput(widgets.DateTimeInput):
|
|||||||
attrs["data-now-button-txt"] = _("Now")
|
attrs["data-now-button-txt"] = _("Now")
|
||||||
attrs["data-timepicker"] = str(self.timepicker).lower()
|
attrs["data-timepicker"] = str(self.timepicker).lower()
|
||||||
attrs["data-auto-close"] = str(self.auto_close).lower()
|
attrs["data-auto-close"] = str(self.auto_close).lower()
|
||||||
|
attrs["data-toggle-selected"] = str(self.toggle_selected).lower()
|
||||||
attrs["data-clear-button"] = str(self.clear_button).lower()
|
attrs["data-clear-button"] = str(self.clear_button).lower()
|
||||||
attrs["data-language"] = self._get_current_language()
|
attrs["data-language"] = self._get_current_language()
|
||||||
attrs["data-date-format"] = date_format
|
attrs["data-date-format"] = date_format
|
||||||
attrs["data-time-format"] = time_format
|
attrs["data-time-format"] = time_format
|
||||||
|
|
||||||
|
if self.read_only:
|
||||||
|
attrs["readonly"] = True
|
||||||
|
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
def format_value(self, value):
|
def format_value(self, value):
|
||||||
@@ -227,3 +249,56 @@ class AirMonthYearPickerInput(AirDatePickerInput):
|
|||||||
except (ValueError, KeyError):
|
except (ValueError, KeyError):
|
||||||
return None
|
return None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class AirYearPickerInput(AirDatePickerInput):
|
||||||
|
def __init__(self, attrs=None, format=None, *args, **kwargs):
|
||||||
|
super().__init__(attrs=attrs, format=format, *args, **kwargs)
|
||||||
|
# Store the display format for AirDatepicker
|
||||||
|
self.display_format = "yyyy"
|
||||||
|
# Store the Python format for internal use
|
||||||
|
self.python_format = "%Y"
|
||||||
|
|
||||||
|
def build_attrs(self, base_attrs, extra_attrs=None):
|
||||||
|
attrs = super().build_attrs(base_attrs, extra_attrs)
|
||||||
|
|
||||||
|
# Add data attributes for AirDatepicker configuration
|
||||||
|
attrs["data-now-button-txt"] = _("Today")
|
||||||
|
attrs["data-date-format"] = "yyyy"
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
def format_value(self, value):
|
||||||
|
"""Format the value for display in the widget."""
|
||||||
|
if value:
|
||||||
|
self.attrs["data-value"] = (
|
||||||
|
value # We use this to dynamically select the initial date on AirDatePicker
|
||||||
|
)
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
value = datetime.datetime.strptime(value, "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
return value
|
||||||
|
if isinstance(value, (datetime.datetime, datetime.date)):
|
||||||
|
# Use Django's date translation
|
||||||
|
return f"{value.year}"
|
||||||
|
return value
|
||||||
|
|
||||||
|
def value_from_datadict(self, data, files, name):
|
||||||
|
"""Convert the value from the widget format back to a format Django can handle."""
|
||||||
|
value = super().value_from_datadict(data, files, name)
|
||||||
|
if value:
|
||||||
|
try:
|
||||||
|
# Split the value into month name and year
|
||||||
|
year_str = value
|
||||||
|
year = int(year_str)
|
||||||
|
|
||||||
|
if year:
|
||||||
|
# Return the first day of the month in Django's expected format
|
||||||
|
return datetime.date(year, 1, 1).strftime("%Y-%m-%d")
|
||||||
|
except (ValueError, KeyError):
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ class ArbitraryDecimalDisplayNumberInput(forms.TextInput):
|
|||||||
self.attrs.update(
|
self.attrs.update(
|
||||||
{
|
{
|
||||||
"x-data": "",
|
"x-data": "",
|
||||||
"x-mask:dynamic": f"$money($input, '{get_format('DECIMAL_SEPARATOR')}', "
|
"x-mask:dynamic": f"$money($input, '{get_format('DECIMAL_SEPARATOR')}', '{get_format('THOUSAND_SEPARATOR')}', '30')",
|
||||||
f"'{get_format('THOUSAND_SEPARATOR')}', '30')",
|
"x-on:keyup": "$el.dispatchEvent(new Event('input'))",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from django.forms import widgets, SelectMultiple
|
from django.forms import widgets, SelectMultiple
|
||||||
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
@@ -129,3 +130,28 @@ class TomSelect(widgets.Select):
|
|||||||
|
|
||||||
class TomSelectMultiple(SelectMultiple, TomSelect):
|
class TomSelectMultiple(SelectMultiple, TomSelect):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionSelect(TomSelect):
|
||||||
|
def __init__(self, income: bool = True, expense: bool = True, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.load_income = income
|
||||||
|
self.load_expense = expense
|
||||||
|
self.create = False
|
||||||
|
|
||||||
|
def build_attrs(self, base_attrs, extra_attrs=None):
|
||||||
|
attrs = super().build_attrs(base_attrs, extra_attrs)
|
||||||
|
|
||||||
|
if self.load_income and self.load_expense:
|
||||||
|
attrs["data-load"] = reverse("transactions_search")
|
||||||
|
elif self.load_income and not self.load_expense:
|
||||||
|
attrs["data-load"] = reverse(
|
||||||
|
"transactions_search", kwargs={"filter_type": "income"}
|
||||||
|
)
|
||||||
|
elif self.load_expense and not self.load_income:
|
||||||
|
attrs["data-load"] = reverse(
|
||||||
|
"transactions_search", kwargs={"filter_type": "expenses"}
|
||||||
|
)
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from apps.currencies.models import Currency, ExchangeRate
|
from apps.currencies.models import Currency, ExchangeRate, ExchangeRateService
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Currency)
|
@admin.register(Currency)
|
||||||
@@ -11,4 +11,19 @@ class CurrencyAdmin(admin.ModelAdmin):
|
|||||||
return super().formfield_for_dbfield(db_field, request, **kwargs)
|
return super().formfield_for_dbfield(db_field, request, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ExchangeRateService)
|
||||||
|
class ExchangeRateServiceAdmin(admin.ModelAdmin):
|
||||||
|
list_display = [
|
||||||
|
"name",
|
||||||
|
"service_type",
|
||||||
|
"is_active",
|
||||||
|
"interval_type",
|
||||||
|
"fetch_interval",
|
||||||
|
"last_fetch",
|
||||||
|
]
|
||||||
|
list_filter = ["is_active", "service_type"]
|
||||||
|
search_fields = ["name"]
|
||||||
|
filter_horizontal = ["target_currencies"]
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(ExchangeRate)
|
admin.site.register(ExchangeRate)
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import List, Tuple, Optional
|
||||||
|
from django.db.models import QuerySet
|
||||||
|
|
||||||
|
from apps.currencies.models import Currency
|
||||||
|
|
||||||
|
|
||||||
|
class ExchangeRateProvider(ABC):
|
||||||
|
rates_inverted = False
|
||||||
|
|
||||||
|
def __init__(self, api_key: Optional[str] = None):
|
||||||
|
self.api_key = api_key
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_rates(
|
||||||
|
self, target_currencies: QuerySet, exchange_currencies: set
|
||||||
|
) -> List[Tuple[Currency, Currency, Decimal]]:
|
||||||
|
"""Fetch exchange rates for multiple currency pairs"""
|
||||||
|
raise NotImplementedError("Subclasses must implement get_rates method")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def requires_api_key(cls) -> bool:
|
||||||
|
"""Return True if the service requires an API key"""
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def invert_rate(rate: Decimal) -> Decimal:
|
||||||
|
"""Invert the given rate."""
|
||||||
|
return Decimal("1") / rate
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.db.models import QuerySet
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
import apps.currencies.exchange_rates.providers as providers
|
||||||
|
from apps.currencies.models import ExchangeRateService, ExchangeRate, Currency
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Map service types to provider classes
|
||||||
|
PROVIDER_MAPPING = {
|
||||||
|
"coingecko_free": providers.CoinGeckoFreeProvider,
|
||||||
|
"coingecko_pro": providers.CoinGeckoProProvider,
|
||||||
|
"transitive": providers.TransitiveRateProvider,
|
||||||
|
"frankfurter": providers.FrankfurterProvider,
|
||||||
|
"twelvedata": providers.TwelveDataProvider,
|
||||||
|
"twelvedatamarkets": providers.TwelveDataMarketsProvider,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ExchangeRateFetcher:
|
||||||
|
def _should_fetch_at_hour(service: ExchangeRateService, current_hour: int) -> bool:
|
||||||
|
"""Check if service should fetch rates at given hour based on interval type."""
|
||||||
|
try:
|
||||||
|
if service.interval_type == ExchangeRateService.IntervalType.NOT_ON:
|
||||||
|
blocked_hours = ExchangeRateService._parse_hour_ranges(
|
||||||
|
service.fetch_interval
|
||||||
|
)
|
||||||
|
should_fetch = current_hour not in blocked_hours
|
||||||
|
logger.info(
|
||||||
|
f"NOT_ON check for {service.name}: "
|
||||||
|
f"current_hour={current_hour}, "
|
||||||
|
f"blocked_hours={blocked_hours}, "
|
||||||
|
f"should_fetch={should_fetch}"
|
||||||
|
)
|
||||||
|
return should_fetch
|
||||||
|
|
||||||
|
if service.interval_type == ExchangeRateService.IntervalType.ON:
|
||||||
|
allowed_hours = ExchangeRateService._parse_hour_ranges(
|
||||||
|
service.fetch_interval
|
||||||
|
)
|
||||||
|
|
||||||
|
should_fetch = current_hour in allowed_hours
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"ON check for {service.name}: "
|
||||||
|
f"current_hour={current_hour}, "
|
||||||
|
f"allowed_hours={allowed_hours}, "
|
||||||
|
f"should_fetch={should_fetch}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return should_fetch
|
||||||
|
|
||||||
|
if service.interval_type == ExchangeRateService.IntervalType.EVERY:
|
||||||
|
try:
|
||||||
|
interval_hours = int(service.fetch_interval)
|
||||||
|
|
||||||
|
if service.last_fetch is None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Round down to nearest hour
|
||||||
|
now = timezone.now().replace(minute=0, second=0, microsecond=0)
|
||||||
|
last_fetch = service.last_fetch.replace(
|
||||||
|
minute=0, second=0, microsecond=0
|
||||||
|
)
|
||||||
|
|
||||||
|
hours_since_last = (now - last_fetch).total_seconds() / 3600
|
||||||
|
should_fetch = hours_since_last >= interval_hours
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"EVERY check for {service.name}: "
|
||||||
|
f"hours_since_last={hours_since_last:.1f}, "
|
||||||
|
f"interval={interval_hours}, "
|
||||||
|
f"should_fetch={should_fetch}"
|
||||||
|
)
|
||||||
|
return should_fetch
|
||||||
|
except ValueError:
|
||||||
|
logger.error(
|
||||||
|
f"Invalid EVERY interval format for {service.name}: "
|
||||||
|
f"expected single number, got '{service.fetch_interval}'"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
logger.error(f"Error parsing fetch_interval for {service.name}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def fetch_due_rates(force: bool = False) -> None:
|
||||||
|
"""
|
||||||
|
Fetch rates for all services that are due for update.
|
||||||
|
Args:
|
||||||
|
force (bool): If True, fetches all active services regardless of their schedule.
|
||||||
|
"""
|
||||||
|
services = ExchangeRateService.objects.filter(is_active=True)
|
||||||
|
current_time = timezone.now().astimezone()
|
||||||
|
current_hour = current_time.hour
|
||||||
|
|
||||||
|
for service in services:
|
||||||
|
try:
|
||||||
|
if force:
|
||||||
|
logger.info(f"Force fetching rates for {service.name}")
|
||||||
|
ExchangeRateFetcher._fetch_service_rates(service)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if service should fetch based on interval type
|
||||||
|
if ExchangeRateFetcher._should_fetch_at_hour(service, current_hour):
|
||||||
|
logger.info(
|
||||||
|
f"Fetching rates for {service.name}. "
|
||||||
|
f"Last fetch: {service.last_fetch}, "
|
||||||
|
f"Interval type: {service.interval_type}, "
|
||||||
|
f"Current hour: {current_hour}"
|
||||||
|
)
|
||||||
|
ExchangeRateFetcher._fetch_service_rates(service)
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
f"Skipping {service.name}. "
|
||||||
|
f"Current hour: {current_hour}, "
|
||||||
|
f"Interval type: {service.interval_type}, "
|
||||||
|
f"Fetch interval: {service.fetch_interval}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error checking fetch schedule for {service.name}: {e}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_unique_currency_pairs(
|
||||||
|
service: ExchangeRateService,
|
||||||
|
) -> tuple[QuerySet, set]:
|
||||||
|
"""
|
||||||
|
Get unique currency pairs from both target_currencies and target_accounts
|
||||||
|
Returns a tuple of (target_currencies QuerySet, exchange_currencies set)
|
||||||
|
"""
|
||||||
|
# Get currencies from target_currencies
|
||||||
|
target_currencies = set(service.target_currencies.all())
|
||||||
|
|
||||||
|
# Add currencies from target_accounts
|
||||||
|
for account in service.target_accounts.all():
|
||||||
|
if account.currency and account.exchange_currency:
|
||||||
|
target_currencies.add(account.currency)
|
||||||
|
|
||||||
|
# Convert back to QuerySet for compatibility with existing code
|
||||||
|
target_currencies_qs = Currency.objects.filter(
|
||||||
|
id__in=[curr.id for curr in target_currencies]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get unique exchange currencies
|
||||||
|
exchange_currencies = set()
|
||||||
|
|
||||||
|
# From target_currencies
|
||||||
|
for currency in target_currencies:
|
||||||
|
if currency.exchange_currency:
|
||||||
|
exchange_currencies.add(currency.exchange_currency)
|
||||||
|
|
||||||
|
# From target_accounts
|
||||||
|
for account in service.target_accounts.all():
|
||||||
|
if account.exchange_currency:
|
||||||
|
exchange_currencies.add(account.exchange_currency)
|
||||||
|
|
||||||
|
return target_currencies_qs, exchange_currencies
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fetch_service_rates(service: ExchangeRateService) -> None:
|
||||||
|
"""Fetch rates for a specific service"""
|
||||||
|
try:
|
||||||
|
provider = service.get_provider()
|
||||||
|
|
||||||
|
# Check if API key is required but missing
|
||||||
|
if provider.requires_api_key() and not service.api_key:
|
||||||
|
logger.error(f"API key required but not provided for {service.name}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get unique currency pairs from both sources
|
||||||
|
target_currencies, exchange_currencies = (
|
||||||
|
ExchangeRateFetcher._get_unique_currency_pairs(service)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Skip if no currencies to process
|
||||||
|
if not target_currencies or not exchange_currencies:
|
||||||
|
logger.info(f"No currency pairs to process for service {service.name}")
|
||||||
|
return
|
||||||
|
|
||||||
|
rates = provider.get_rates(target_currencies, exchange_currencies)
|
||||||
|
|
||||||
|
# Track processed currency pairs to avoid duplicates
|
||||||
|
processed_pairs = set()
|
||||||
|
|
||||||
|
for from_currency, to_currency, rate in rates:
|
||||||
|
# Create a unique identifier for this currency pair
|
||||||
|
pair_key = (from_currency.id, to_currency.id)
|
||||||
|
if pair_key in processed_pairs:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if provider.rates_inverted:
|
||||||
|
# If rates are inverted, we need to swap currencies
|
||||||
|
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
|
||||||
|
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()
|
||||||
|
service.save()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching rates for {service.name}: {e}")
|
||||||
@@ -0,0 +1,505 @@
|
|||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Tuple, List, Optional, Dict
|
||||||
|
|
||||||
|
from django.db.models import QuerySet
|
||||||
|
|
||||||
|
from apps.currencies.models import Currency, ExchangeRate
|
||||||
|
from apps.currencies.exchange_rates.base import ExchangeRateProvider
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CoinGeckoFreeProvider(ExchangeRateProvider):
|
||||||
|
"""Implementation for CoinGecko Free API"""
|
||||||
|
|
||||||
|
BASE_URL = "https://api.coingecko.com/api/v3"
|
||||||
|
rates_inverted = True
|
||||||
|
|
||||||
|
def __init__(self, api_key: str):
|
||||||
|
super().__init__(api_key)
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.session.headers.update({"x-cg-demo-api-key": api_key})
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def requires_api_key(cls) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_rates(
|
||||||
|
self, target_currencies: QuerySet, exchange_currencies: set
|
||||||
|
) -> List[Tuple[Currency, Currency, Decimal]]:
|
||||||
|
results = []
|
||||||
|
all_currencies = set(currency.code.lower() for currency in target_currencies)
|
||||||
|
all_currencies.update(currency.code.lower() for currency in exchange_currencies)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self.session.get(
|
||||||
|
f"{self.BASE_URL}/simple/price",
|
||||||
|
params={
|
||||||
|
"ids": ",".join(all_currencies),
|
||||||
|
"vs_currencies": ",".join(all_currencies),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
rates_data = response.json()
|
||||||
|
|
||||||
|
for target_currency in target_currencies:
|
||||||
|
if target_currency.exchange_currency in exchange_currencies:
|
||||||
|
try:
|
||||||
|
rate = Decimal(
|
||||||
|
str(
|
||||||
|
rates_data[target_currency.code.lower()][
|
||||||
|
target_currency.exchange_currency.code.lower()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# The rate is already inverted, so we don't need to invert it again
|
||||||
|
results.append(
|
||||||
|
(target_currency.exchange_currency, target_currency, rate)
|
||||||
|
)
|
||||||
|
except KeyError:
|
||||||
|
logger.error(
|
||||||
|
f"Rate not found for {target_currency.code} or {target_currency.exchange_currency.code}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error calculating rate for {target_currency.code}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
time.sleep(1) # CoinGecko allows 10-30 calls/minute for free tier
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.error(f"Error fetching rates from CoinGecko API: {e}")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
class CoinGeckoProProvider(CoinGeckoFreeProvider):
|
||||||
|
"""Implementation for CoinGecko Pro API"""
|
||||||
|
|
||||||
|
BASE_URL = "https://pro-api.coingecko.com/api/v3/simple/price"
|
||||||
|
rates_inverted = True
|
||||||
|
|
||||||
|
def __init__(self, api_key: str):
|
||||||
|
super().__init__(api_key)
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.session.headers.update({"x-cg-pro-api-key": api_key})
|
||||||
|
|
||||||
|
|
||||||
|
class TransitiveRateProvider(ExchangeRateProvider):
|
||||||
|
"""Calculates exchange rates through paths of existing rates"""
|
||||||
|
|
||||||
|
rates_inverted = True
|
||||||
|
|
||||||
|
def __init__(self, api_key: str = None):
|
||||||
|
super().__init__(api_key) # API key not needed but maintaining interface
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def requires_api_key(cls) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_rates(
|
||||||
|
self, target_currencies: QuerySet, exchange_currencies: set
|
||||||
|
) -> List[Tuple[Currency, Currency, Decimal]]:
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# Get recent rates for building the graph
|
||||||
|
recent_rates = ExchangeRate.objects.all()
|
||||||
|
|
||||||
|
# Build currency graph
|
||||||
|
currency_graph = self._build_currency_graph(recent_rates)
|
||||||
|
|
||||||
|
for target in target_currencies:
|
||||||
|
if (
|
||||||
|
not target.exchange_currency
|
||||||
|
or target.exchange_currency not in exchange_currencies
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Find path and calculate rate
|
||||||
|
from_id = target.exchange_currency.id
|
||||||
|
to_id = target.id
|
||||||
|
|
||||||
|
path, rate = self._find_conversion_path(currency_graph, from_id, to_id)
|
||||||
|
|
||||||
|
if path and rate:
|
||||||
|
path_codes = [Currency.objects.get(id=cid).code for cid in path]
|
||||||
|
logger.info(
|
||||||
|
f"Found conversion path: {' -> '.join(path_codes)}, rate: {rate}"
|
||||||
|
)
|
||||||
|
results.append((target.exchange_currency, target, rate))
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
f"No conversion path found for {target.exchange_currency.code}->{target.code}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_currency_graph(rates) -> Dict[int, Dict[int, Decimal]]:
|
||||||
|
"""Build a graph representation of currency relationships"""
|
||||||
|
graph = {}
|
||||||
|
|
||||||
|
for rate in rates:
|
||||||
|
# Add both directions to make the graph bidirectional
|
||||||
|
if rate.from_currency_id not in graph:
|
||||||
|
graph[rate.from_currency_id] = {}
|
||||||
|
graph[rate.from_currency_id][rate.to_currency_id] = rate.rate
|
||||||
|
|
||||||
|
if rate.to_currency_id not in graph:
|
||||||
|
graph[rate.to_currency_id] = {}
|
||||||
|
graph[rate.to_currency_id][rate.from_currency_id] = Decimal("1") / rate.rate
|
||||||
|
|
||||||
|
return graph
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _find_conversion_path(
|
||||||
|
graph, from_id, to_id
|
||||||
|
) -> Tuple[Optional[list], Optional[Decimal]]:
|
||||||
|
"""Find the shortest path between currencies using breadth-first search"""
|
||||||
|
if from_id not in graph or to_id not in graph:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
queue = [(from_id, [from_id], Decimal("1"))]
|
||||||
|
visited = {from_id}
|
||||||
|
|
||||||
|
while queue:
|
||||||
|
current, path, current_rate = queue.pop(0)
|
||||||
|
|
||||||
|
if current == to_id:
|
||||||
|
return path, current_rate
|
||||||
|
|
||||||
|
for neighbor, rate in graph.get(current, {}).items():
|
||||||
|
if neighbor not in visited:
|
||||||
|
visited.add(neighbor)
|
||||||
|
queue.append((neighbor, path + [neighbor], current_rate * rate))
|
||||||
|
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
|
from crispy_bootstrap5.bootstrap5 import Switch
|
||||||
from crispy_forms.bootstrap import FormActions
|
from crispy_forms.bootstrap import FormActions
|
||||||
from crispy_forms.helper import FormHelper
|
from crispy_forms.helper import FormHelper
|
||||||
from crispy_forms.layout import Layout
|
from crispy_forms.layout import Layout, Row, Column
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.forms import CharField
|
from django.forms import CharField
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@@ -9,7 +10,7 @@ from apps.common.widgets.crispy.submit import NoClassSubmit
|
|||||||
from apps.common.widgets.datepicker import AirDateTimePickerInput
|
from apps.common.widgets.datepicker import AirDateTimePickerInput
|
||||||
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
|
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
|
||||||
from apps.common.widgets.tom_select import TomSelect
|
from apps.common.widgets.tom_select import TomSelect
|
||||||
from apps.currencies.models import Currency, ExchangeRate
|
from apps.currencies.models import Currency, ExchangeRate, ExchangeRateService
|
||||||
|
|
||||||
|
|
||||||
class CurrencyForm(forms.ModelForm):
|
class CurrencyForm(forms.ModelForm):
|
||||||
@@ -25,6 +26,7 @@ class CurrencyForm(forms.ModelForm):
|
|||||||
"suffix",
|
"suffix",
|
||||||
"code",
|
"code",
|
||||||
"exchange_currency",
|
"exchange_currency",
|
||||||
|
"is_archived",
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
"exchange_currency": TomSelect(),
|
"exchange_currency": TomSelect(),
|
||||||
@@ -39,6 +41,7 @@ class CurrencyForm(forms.ModelForm):
|
|||||||
self.helper.layout = Layout(
|
self.helper.layout = Layout(
|
||||||
"code",
|
"code",
|
||||||
"name",
|
"name",
|
||||||
|
Switch("is_archived"),
|
||||||
"decimal_places",
|
"decimal_places",
|
||||||
"prefix",
|
"prefix",
|
||||||
"suffix",
|
"suffix",
|
||||||
@@ -99,3 +102,56 @@ class ExchangeRateForm(forms.ModelForm):
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ExchangeRateServiceForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = ExchangeRateService
|
||||||
|
fields = [
|
||||||
|
"name",
|
||||||
|
"service_type",
|
||||||
|
"is_active",
|
||||||
|
"api_key",
|
||||||
|
"interval_type",
|
||||||
|
"fetch_interval",
|
||||||
|
"target_currencies",
|
||||||
|
"target_accounts",
|
||||||
|
"singleton",
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.helper = FormHelper()
|
||||||
|
self.helper.form_tag = False
|
||||||
|
self.helper.form_method = "post"
|
||||||
|
self.helper.layout = Layout(
|
||||||
|
"name",
|
||||||
|
"service_type",
|
||||||
|
Switch("is_active"),
|
||||||
|
Switch("singleton"),
|
||||||
|
"api_key",
|
||||||
|
Row(
|
||||||
|
Column("interval_type", css_class="form-group col-md-6"),
|
||||||
|
Column("fetch_interval", css_class="form-group col-md-6"),
|
||||||
|
),
|
||||||
|
"target_currencies",
|
||||||
|
"target_accounts",
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.instance and self.instance.pk:
|
||||||
|
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"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Generated by Django 5.1.5 on 2025-02-02 20:35
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('currencies', '0006_currency_exchange_currency'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ExchangeRateService',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=255, unique=True, verbose_name='Service Name')),
|
||||||
|
('service_type', models.CharField(choices=[('synth_finance', 'Synth Finance'), ('coingecko', 'CoinGecko')], max_length=255, verbose_name='Service Type')),
|
||||||
|
('is_active', models.BooleanField(default=True, verbose_name='Active')),
|
||||||
|
('api_key', models.CharField(blank=True, help_text='API key for the service (if required)', max_length=255, null=True, verbose_name='API Key')),
|
||||||
|
('fetch_interval_hours', models.PositiveIntegerField(default=24, validators=[django.core.validators.MinValueValidator(1)], verbose_name='Fetch Interval (hours)')),
|
||||||
|
('last_fetch', models.DateTimeField(blank=True, null=True, verbose_name='Last Successful Fetch')),
|
||||||
|
('target_currencies', models.ManyToManyField(help_text='Select currencies to fetch exchange rates for. Rates will be fetched for each currency against their exchange_currency.', related_name='exchange_services', to='currencies.currency', verbose_name='Target Currencies')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Exchange Rate Service',
|
||||||
|
'verbose_name_plural': 'Exchange Rate Services',
|
||||||
|
'ordering': ['name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Generated by Django 5.1.5 on 2025-02-03 01:52
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0008_alter_account_name'),
|
||||||
|
('currencies', '0007_exchangerateservice'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='exchangerateservice',
|
||||||
|
name='target_accounts',
|
||||||
|
field=models.ManyToManyField(help_text="Select accounts to fetch exchange rates for. Rates will be fetched for each account's currency against their set exchange currency.", related_name='exchange_services', to='accounts.account', verbose_name='Target Accounts'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='exchangerateservice',
|
||||||
|
name='target_currencies',
|
||||||
|
field=models.ManyToManyField(help_text='Select currencies to fetch exchange rates for. Rates will be fetched for each currency against their set exchange currency.', related_name='exchange_services', to='currencies.currency', verbose_name='Target Currencies'),
|
||||||
|
),
|
||||||
|
]
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
# Generated by Django 5.1.5 on 2025-02-03 01:55
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0008_alter_account_name'),
|
||||||
|
('currencies', '0008_exchangerateservice_target_accounts_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='exchangerateservice',
|
||||||
|
name='target_accounts',
|
||||||
|
field=models.ManyToManyField(blank=True, help_text="Select accounts to fetch exchange rates for. Rates will be fetched for each account's currency against their set exchange currency.", related_name='exchange_services', to='accounts.account', verbose_name='Target Accounts'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='exchangerateservice',
|
||||||
|
name='target_currencies',
|
||||||
|
field=models.ManyToManyField(blank=True, help_text='Select currencies to fetch exchange rates for. Rates will be fetched for each currency against their set exchange currency.', related_name='exchange_services', to='currencies.currency', verbose_name='Target Currencies'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.1.5 on 2025-02-03 03:17
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('currencies', '0009_alter_exchangerateservice_target_accounts_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='currency',
|
||||||
|
name='code',
|
||||||
|
field=models.CharField(max_length=255, verbose_name='Currency Code'),
|
||||||
|
),
|
||||||
|
]
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
# Generated by Django 5.1.5 on 2025-02-07 02:02
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('currencies', '0010_alter_currency_code'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='exchangerateservice',
|
||||||
|
name='fetch_interval_hours',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='exchangerateservice',
|
||||||
|
name='fetch_interval',
|
||||||
|
field=models.CharField(default='24', max_length=1000, verbose_name='Interval'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='exchangerateservice',
|
||||||
|
name='interval_type',
|
||||||
|
field=models.CharField(choices=[('on', 'On'), ('every', 'Every X hours'), ('not_on', 'Not on')], default='every', max_length=255, verbose_name='Interval Type'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='exchangerateservice',
|
||||||
|
name='service_type',
|
||||||
|
field=models.CharField(choices=[('synth_finance', 'Synth Finance'), ('coingecko_free', 'CoinGecko (Demo/Free)'), ('coingecko_pro', 'CoinGecko (Pro)')], max_length=255, verbose_name='Service Type'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.1.6 on 2025-03-02 01:28
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('currencies', '0011_remove_exchangerateservice_fetch_interval_hours_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='exchangerateservice',
|
||||||
|
name='service_type',
|
||||||
|
field=models.CharField(choices=[('synth_finance', 'Synth Finance'), ('synth_finance_stock', 'Synth Finance Stock'), ('coingecko_free', 'CoinGecko (Demo/Free)'), ('coingecko_pro', 'CoinGecko (Pro)')], max_length=255, verbose_name='Service Type'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.1.6 on 2025-03-02 01:58
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('currencies', '0012_alter_exchangerateservice_service_type'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='exchangerateservice',
|
||||||
|
name='service_type',
|
||||||
|
field=models.CharField(choices=[('synth_finance', 'Synth Finance'), ('synth_finance_stock', 'Synth Finance Stock'), ('coingecko_free', 'CoinGecko (Demo/Free)'), ('coingecko_pro', 'CoinGecko (Pro)'), ('transitive', 'Transitive (Calculated from Existing Rates)')], max_length=255, verbose_name='Service Type'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 5.1.7 on 2025-03-09 21:54
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('currencies', '0013_alter_exchangerateservice_service_type'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='currency',
|
||||||
|
options={'ordering': ['name', 'id'], 'verbose_name': 'Currency', 'verbose_name_plural': 'Currencies'},
|
||||||
|
),
|
||||||
|
]
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.4 on 2025-08-08 02:18
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('currencies', '0014_alter_currency_options'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='exchangerate',
|
||||||
|
name='automatic',
|
||||||
|
field=models.BooleanField(default=False, verbose_name='Automatic'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='exchangerateservice',
|
||||||
|
name='singleton',
|
||||||
|
field=models.BooleanField(default=False, help_text='Create one exchange rate and keep updating it. Avoids database clutter.', verbose_name='Single exchange rate'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.4 on 2025-08-08 02:38
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('currencies', '0015_exchangerate_automatic_exchangerateservice_singleton'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='exchangerate',
|
||||||
|
name='automatic',
|
||||||
|
field=models.BooleanField(default=False, verbose_name='Auto'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-08-16 22:18
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('currencies', '0016_alter_exchangerate_automatic'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='exchangerateservice',
|
||||||
|
name='service_type',
|
||||||
|
field=models.CharField(choices=[('synth_finance', 'Synth Finance'), ('synth_finance_stock', 'Synth Finance Stock'), ('coingecko_free', 'CoinGecko (Demo/Free)'), ('coingecko_pro', 'CoinGecko (Pro)'), ('transitive', 'Transitive (Calculated from Existing Rates)'), ('frankfurter', 'Frankfurter')], max_length=255, verbose_name='Service Type'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-08-17 03:54
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('currencies', '0017_alter_exchangerateservice_service_type'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='exchangerateservice',
|
||||||
|
name='service_type',
|
||||||
|
field=models.CharField(choices=[('synth_finance', 'Synth Finance'), ('synth_finance_stock', 'Synth Finance Stock'), ('coingecko_free', 'CoinGecko (Demo/Free)'), ('coingecko_pro', 'CoinGecko (Pro)'), ('transitive', 'Transitive (Calculated from Existing Rates)'), ('frankfurter', 'Frankfurter'), ('twelvedata', 'TwelveData')], max_length=255, verbose_name='Service Type'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-08-17 06:01
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('currencies', '0018_alter_exchangerateservice_service_type'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='exchangerateservice',
|
||||||
|
name='service_type',
|
||||||
|
field=models.CharField(choices=[('synth_finance', 'Synth Finance'), ('synth_finance_stock', 'Synth Finance Stock'), ('coingecko_free', 'CoinGecko (Demo/Free)'), ('coingecko_pro', 'CoinGecko (Pro)'), ('transitive', 'Transitive (Calculated from Existing Rates)'), ('frankfurter', 'Frankfurter'), ('twelvedata', 'TwelveData'), ('twelvedatamarkets', 'TwelveData Markets')], max_length=255, verbose_name='Service Type'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-08-17 06:25
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
# The new value we are migrating to
|
||||||
|
NEW_SERVICE_TYPE = "frankfurter"
|
||||||
|
# The old values we are deprecating
|
||||||
|
OLD_SERVICE_TYPE_TO_UPDATE = "synth_finance"
|
||||||
|
OLD_SERVICE_TYPE_TO_DELETE = "synth_finance_stock"
|
||||||
|
|
||||||
|
|
||||||
|
def forwards_func(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
Forward migration:
|
||||||
|
- Deletes all ExchangeRateService instances with service_type 'synth_finance_stock'.
|
||||||
|
- Updates all ExchangeRateService instances with service_type 'synth_finance' to 'frankfurter'.
|
||||||
|
"""
|
||||||
|
ExchangeRateService = apps.get_model("currencies", "ExchangeRateService")
|
||||||
|
db_alias = schema_editor.connection.alias
|
||||||
|
|
||||||
|
# 1. Delete the SYNTH_FINANCE_STOCK entries
|
||||||
|
ExchangeRateService.objects.using(db_alias).filter(
|
||||||
|
service_type=OLD_SERVICE_TYPE_TO_DELETE
|
||||||
|
).delete()
|
||||||
|
|
||||||
|
# 2. Update the SYNTH_FINANCE entries to FRANKFURTER
|
||||||
|
ExchangeRateService.objects.using(db_alias).filter(
|
||||||
|
service_type=OLD_SERVICE_TYPE_TO_UPDATE
|
||||||
|
).update(service_type=NEW_SERVICE_TYPE, api_key=None)
|
||||||
|
|
||||||
|
|
||||||
|
def backwards_func(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
Backward migration: This operation is not safely reversible.
|
||||||
|
- We cannot know which 'frankfurter' services were originally 'synth_finance'.
|
||||||
|
- The deleted 'synth_finance_stock' services cannot be recovered.
|
||||||
|
We will leave this function empty to allow migrating backwards without doing anything.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
# Add the previous migration file here
|
||||||
|
("currencies", "0019_alter_exchangerateservice_service_type"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(forwards_func, reverse_code=backwards_func),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-08-17 06:29
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('currencies', '0020_migrate_synth_finance_services'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='exchangerateservice',
|
||||||
|
name='service_type',
|
||||||
|
field=models.CharField(choices=[('coingecko_free', 'CoinGecko (Demo/Free)'), ('coingecko_pro', 'CoinGecko (Pro)'), ('transitive', 'Transitive (Calculated from Existing Rates)'), ('frankfurter', 'Frankfurter'), ('twelvedata', 'TwelveData'), ('twelvedatamarkets', 'TwelveData Markets')], max_length=255, verbose_name='Service Type'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-08-30 00:47
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('currencies', '0021_alter_exchangerateservice_service_type'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='currency',
|
||||||
|
name='is_archived',
|
||||||
|
field=models.BooleanField(default=False, verbose_name='Archived'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Set
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Currency(models.Model):
|
class Currency(models.Model):
|
||||||
code = models.CharField(max_length=10, unique=True, verbose_name=_("Currency Code"))
|
code = models.CharField(
|
||||||
|
max_length=255, unique=False, verbose_name=_("Currency Code")
|
||||||
|
)
|
||||||
name = models.CharField(max_length=50, verbose_name=_("Currency Name"), unique=True)
|
name = models.CharField(max_length=50, verbose_name=_("Currency Name"), unique=True)
|
||||||
decimal_places = models.PositiveIntegerField(
|
decimal_places = models.PositiveIntegerField(
|
||||||
default=2,
|
default=2,
|
||||||
@@ -25,12 +32,18 @@ class Currency(models.Model):
|
|||||||
help_text=_("Default currency for exchange calculations"),
|
help_text=_("Default currency for exchange calculations"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
is_archived = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name=_("Archived"),
|
||||||
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Currency")
|
verbose_name = _("Currency")
|
||||||
verbose_name_plural = _("Currencies")
|
verbose_name_plural = _("Currencies")
|
||||||
|
ordering = ["name", "id"]
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
super().clean()
|
super().clean()
|
||||||
@@ -62,6 +75,8 @@ class ExchangeRate(models.Model):
|
|||||||
)
|
)
|
||||||
date = models.DateTimeField(verbose_name=_("Date and Time"))
|
date = models.DateTimeField(verbose_name=_("Date and Time"))
|
||||||
|
|
||||||
|
automatic = models.BooleanField(verbose_name=_("Auto"), default=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Exchange Rate")
|
verbose_name = _("Exchange Rate")
|
||||||
verbose_name_plural = _("Exchange Rates")
|
verbose_name_plural = _("Exchange Rates")
|
||||||
@@ -78,3 +93,166 @@ class ExchangeRate(models.Model):
|
|||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
{"to_currency": _("From and To currencies cannot be the same.")}
|
{"to_currency": _("From and To currencies cannot be the same.")}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ExchangeRateService(models.Model):
|
||||||
|
"""Configuration for exchange rate services"""
|
||||||
|
|
||||||
|
class ServiceType(models.TextChoices):
|
||||||
|
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")
|
||||||
|
EVERY = "every", _("Every X hours")
|
||||||
|
NOT_ON = "not_on", _("Not on")
|
||||||
|
|
||||||
|
name = models.CharField(max_length=255, unique=True, verbose_name=_("Service Name"))
|
||||||
|
service_type = models.CharField(
|
||||||
|
max_length=255, choices=ServiceType.choices, verbose_name=_("Service Type")
|
||||||
|
)
|
||||||
|
is_active = models.BooleanField(default=True, verbose_name=_("Active"))
|
||||||
|
api_key = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name=_("API Key"),
|
||||||
|
help_text=_("API key for the service (if required)"),
|
||||||
|
)
|
||||||
|
interval_type = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
choices=IntervalType.choices,
|
||||||
|
verbose_name=_("Interval Type"),
|
||||||
|
default=IntervalType.EVERY,
|
||||||
|
)
|
||||||
|
fetch_interval = models.CharField(
|
||||||
|
max_length=1000, verbose_name=_("Interval"), default="24"
|
||||||
|
)
|
||||||
|
last_fetch = models.DateTimeField(
|
||||||
|
null=True, blank=True, verbose_name=_("Last Successful Fetch")
|
||||||
|
)
|
||||||
|
|
||||||
|
target_currencies = models.ManyToManyField(
|
||||||
|
Currency,
|
||||||
|
verbose_name=_("Target Currencies"),
|
||||||
|
help_text=_(
|
||||||
|
"Select currencies to fetch exchange rates for. Rates will be fetched for each currency against their set exchange currency."
|
||||||
|
),
|
||||||
|
related_name="exchange_services",
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
target_accounts = models.ManyToManyField(
|
||||||
|
"accounts.Account",
|
||||||
|
verbose_name=_("Target Accounts"),
|
||||||
|
help_text=_(
|
||||||
|
"Select accounts to fetch exchange rates for. Rates will be fetched for each account's currency against their set exchange currency."
|
||||||
|
),
|
||||||
|
related_name="exchange_services",
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
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")
|
||||||
|
ordering = ["name"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def get_provider(self):
|
||||||
|
from apps.currencies.exchange_rates.fetcher import PROVIDER_MAPPING
|
||||||
|
|
||||||
|
provider_class = PROVIDER_MAPPING[self.service_type]
|
||||||
|
return provider_class(self.api_key)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_hour_ranges(interval_str: str) -> Set[int]:
|
||||||
|
"""
|
||||||
|
Parse hour ranges and individual hours from string.
|
||||||
|
|
||||||
|
Valid formats:
|
||||||
|
- Single hours: "1,5,9"
|
||||||
|
- Ranges: "1-5"
|
||||||
|
- Mixed: "1-5,8,10-12"
|
||||||
|
|
||||||
|
Returns set of hours.
|
||||||
|
"""
|
||||||
|
hours = set()
|
||||||
|
|
||||||
|
for part in interval_str.strip().split(","):
|
||||||
|
part = part.strip()
|
||||||
|
if "-" in part:
|
||||||
|
start, end = part.split("-")
|
||||||
|
start, end = int(start), int(end)
|
||||||
|
if not (0 <= start <= 23 and 0 <= end <= 23):
|
||||||
|
raise ValueError("Hours must be between 0 and 23")
|
||||||
|
if start > end:
|
||||||
|
raise ValueError(f"Invalid range: {start}-{end}")
|
||||||
|
hours.update(range(start, end + 1))
|
||||||
|
else:
|
||||||
|
hour = int(part)
|
||||||
|
if not 0 <= hour <= 23:
|
||||||
|
raise ValueError("Hours must be between 0 and 23")
|
||||||
|
hours.add(hour)
|
||||||
|
|
||||||
|
return hours
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
super().clean()
|
||||||
|
try:
|
||||||
|
if self.interval_type == self.IntervalType.EVERY:
|
||||||
|
if not self.fetch_interval.isdigit():
|
||||||
|
raise ValidationError(
|
||||||
|
{
|
||||||
|
"fetch_interval": _(
|
||||||
|
"'Every X hours' interval type requires a positive integer."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
hours = int(self.fetch_interval)
|
||||||
|
if hours < 1 or hours > 24:
|
||||||
|
raise ValidationError(
|
||||||
|
{
|
||||||
|
"fetch_interval": _(
|
||||||
|
"'Every X hours' interval must be between 1 and 24."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
# Parse and validate hour ranges
|
||||||
|
hours = self._parse_hour_ranges(self.fetch_interval)
|
||||||
|
# Store in normalized format (optional)
|
||||||
|
self.fetch_interval = ",".join(str(h) for h in sorted(hours))
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValidationError(
|
||||||
|
{
|
||||||
|
"fetch_interval": _(
|
||||||
|
"Invalid hour format. Use comma-separated hours (0-23) "
|
||||||
|
"and/or ranges (e.g., '1-5,8,10-12')."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except ValidationError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise ValidationError(
|
||||||
|
{
|
||||||
|
"fetch_interval": _(
|
||||||
|
"Invalid format. Please check the requirements for your selected interval type."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from procrastinate.contrib.django import app
|
||||||
|
|
||||||
|
from apps.currencies.exchange_rates.fetcher import ExchangeRateFetcher
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@app.periodic(cron="0 * * * *") # Run every hour
|
||||||
|
@app.task(name="automatic_fetch_exchange_rates")
|
||||||
|
def automatic_fetch_exchange_rates(timestamp=None):
|
||||||
|
"""Fetch exchange rates for all due services"""
|
||||||
|
fetcher = ExchangeRateFetcher()
|
||||||
|
|
||||||
|
try:
|
||||||
|
fetcher.fetch_due_rates()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(e, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
@app.task(name="manual_fetch_exchange_rates")
|
||||||
|
def manual_fetch_exchange_rates(timestamp=None):
|
||||||
|
"""Fetch exchange rates for all due services"""
|
||||||
|
fetcher = ExchangeRateFetcher()
|
||||||
|
|
||||||
|
try:
|
||||||
|
fetcher.fetch_due_rates(force=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(e, exc_info=True)
|
||||||
@@ -40,12 +40,6 @@ class CurrencyTests(TestCase):
|
|||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
currency.full_clean()
|
currency.full_clean()
|
||||||
|
|
||||||
def test_currency_unique_code(self):
|
|
||||||
"""Test that currency codes must be unique"""
|
|
||||||
Currency.objects.create(code="USD", name="US Dollar", decimal_places=2)
|
|
||||||
with self.assertRaises(IntegrityError):
|
|
||||||
Currency.objects.create(code="USD", name="Another Dollar", decimal_places=2)
|
|
||||||
|
|
||||||
def test_currency_unique_name(self):
|
def test_currency_unique_name(self):
|
||||||
"""Test that currency names must be unique"""
|
"""Test that currency names must be unique"""
|
||||||
Currency.objects.create(code="USD", name="US Dollar", decimal_places=2)
|
Currency.objects.create(code="USD", name="US Dollar", decimal_places=2)
|
||||||
|
|||||||
@@ -34,4 +34,34 @@ urlpatterns = [
|
|||||||
views.exchange_rate_delete,
|
views.exchange_rate_delete,
|
||||||
name="exchange_rate_delete",
|
name="exchange_rate_delete",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"automatic-exchange-rates/",
|
||||||
|
views.exchange_rates_services_index,
|
||||||
|
name="automatic_exchange_rates_index",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"automatic-exchange-rates/list/",
|
||||||
|
views.exchange_rates_services_list,
|
||||||
|
name="automatic_exchange_rates_list",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"automatic-exchange-rates/add/",
|
||||||
|
views.exchange_rate_service_add,
|
||||||
|
name="automatic_exchange_rate_add",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"automatic-exchange-rates/force-fetch/",
|
||||||
|
views.exchange_rate_service_force_fetch,
|
||||||
|
name="automatic_exchange_rate_force_fetch",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"automatic-exchange-rates/<int:pk>/edit/",
|
||||||
|
views.exchange_rate_service_edit,
|
||||||
|
name="automatic_exchange_rate_edit",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"automatic-exchange-rates/<int:pk>/delete/",
|
||||||
|
views.exchange_rate_service_delete,
|
||||||
|
name="automatic_exchange_rate_delete",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
from .currencies import *
|
from .currencies import *
|
||||||
from .exchange_rates import *
|
from .exchange_rates import *
|
||||||
|
from .exchange_rates_services import *
|
||||||
|
|||||||
@@ -27,17 +27,17 @@ def exchange_rates_index(request):
|
|||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def exchange_rates_list(request):
|
def exchange_rates_list(request):
|
||||||
pairings = (
|
pairings = (
|
||||||
ExchangeRate.objects.values("from_currency__code", "to_currency__code")
|
ExchangeRate.objects.values("from_currency__name", "to_currency__name")
|
||||||
.distinct()
|
.distinct()
|
||||||
.annotate(
|
.annotate(
|
||||||
pair=Concat(
|
pair=Concat(
|
||||||
"from_currency__code",
|
"from_currency__name",
|
||||||
Value(" x "),
|
Value(" x "),
|
||||||
"to_currency__code",
|
"to_currency__name",
|
||||||
output_field=CharField(),
|
output_field=CharField(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.values_list("pair", "from_currency__code", "to_currency__code")
|
.values_list("pair", "from_currency__name", "to_currency__name")
|
||||||
)
|
)
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
@@ -56,7 +56,7 @@ def exchange_rates_list_pair(request):
|
|||||||
|
|
||||||
if from_currency and to_currency:
|
if from_currency and to_currency:
|
||||||
exchange_rates = ExchangeRate.objects.filter(
|
exchange_rates = ExchangeRate.objects.filter(
|
||||||
from_currency__code=from_currency, to_currency__code=to_currency
|
from_currency__name=from_currency, to_currency__name=to_currency
|
||||||
).order_by("-date")
|
).order_by("-date")
|
||||||
else:
|
else:
|
||||||
exchange_rates = ExchangeRate.objects.all().order_by("-date")
|
exchange_rates = ExchangeRate.objects.all().order_by("-date")
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.db.models import CharField, Value
|
||||||
|
from django.db.models.functions import Concat
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.shortcuts import render, get_object_or_404
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
|
from apps.common.decorators.htmx import only_htmx
|
||||||
|
from apps.currencies.forms import ExchangeRateForm, ExchangeRateServiceForm
|
||||||
|
from apps.currencies.models import ExchangeRate, ExchangeRateService
|
||||||
|
from apps.currencies.tasks import manual_fetch_exchange_rates
|
||||||
|
from apps.common.decorators.demo import disabled_on_demo
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def exchange_rates_services_index(request):
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"exchange_rates_services/pages/index.html",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def exchange_rates_services_list(request):
|
||||||
|
services = ExchangeRateService.objects.all()
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"exchange_rates_services/fragments/list.html",
|
||||||
|
{"services": services},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def exchange_rate_service_add(request):
|
||||||
|
if request.method == "POST":
|
||||||
|
form = ExchangeRateServiceForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
messages.success(request, _("Service added successfully"))
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
headers={
|
||||||
|
"HX-Trigger": "updated, hide_offcanvas",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
form = ExchangeRateServiceForm()
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"exchange_rates_services/fragments/add.html",
|
||||||
|
{"form": form},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def exchange_rate_service_edit(request, pk):
|
||||||
|
service = get_object_or_404(ExchangeRateService, id=pk)
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
form = ExchangeRateServiceForm(request.POST, instance=service)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
messages.success(request, _("Service updated successfully"))
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
headers={
|
||||||
|
"HX-Trigger": "updated, hide_offcanvas",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
form = ExchangeRateServiceForm(instance=service)
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"exchange_rates_services/fragments/edit.html",
|
||||||
|
{"form": form, "service": service},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
|
@require_http_methods(["DELETE"])
|
||||||
|
def exchange_rate_service_delete(request, pk):
|
||||||
|
service = get_object_or_404(ExchangeRateService, id=pk)
|
||||||
|
|
||||||
|
service.delete()
|
||||||
|
|
||||||
|
messages.success(request, _("Service deleted successfully"))
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
headers={
|
||||||
|
"HX-Trigger": "updated, hide_offcanvas",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def exchange_rate_service_force_fetch(request):
|
||||||
|
manual_fetch_exchange_rates.defer()
|
||||||
|
messages.success(request, _("Services queued successfully"))
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
headers={
|
||||||
|
"HX-Trigger": "toasts",
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from apps.dca.models import DCAStrategy, DCAEntry
|
from apps.dca.models import DCAStrategy, DCAEntry
|
||||||
|
from apps.common.admin import SharedObjectModelAdmin
|
||||||
|
|
||||||
|
|
||||||
# Register your models here.
|
|
||||||
admin.site.register(DCAStrategy)
|
|
||||||
admin.site.register(DCAEntry)
|
admin.site.register(DCAEntry)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(DCAStrategy)
|
||||||
|
class TransactionEntityModelAdmin(SharedObjectModelAdmin):
|
||||||
|
def get_queryset(self, request):
|
||||||
|
return DCAStrategy.all_objects.all()
|
||||||
|
|||||||
+274
-11
@@ -1,14 +1,22 @@
|
|||||||
from crispy_forms.bootstrap import FormActions
|
from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion
|
||||||
|
from crispy_forms.bootstrap import FormActions, AccordionGroup
|
||||||
from crispy_forms.helper import FormHelper
|
from crispy_forms.helper import FormHelper
|
||||||
from crispy_forms.layout import Layout, Row, Column
|
from crispy_forms.layout import Layout, Row, Column, HTML
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from apps.accounts.models import Account
|
||||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||||
from apps.common.widgets.datepicker import AirDatePickerInput
|
from apps.common.widgets.datepicker import AirDatePickerInput
|
||||||
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
|
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
|
||||||
from apps.common.widgets.tom_select import TomSelect
|
from apps.common.widgets.tom_select import TomSelect
|
||||||
from apps.dca.models import DCAStrategy, DCAEntry
|
from apps.dca.models import DCAStrategy, DCAEntry
|
||||||
|
from apps.common.widgets.tom_select import TransactionSelect
|
||||||
|
from apps.transactions.models import Transaction, TransactionTag, TransactionCategory
|
||||||
|
from apps.common.fields.forms.dynamic_select import (
|
||||||
|
DynamicModelChoiceField,
|
||||||
|
DynamicModelMultipleChoiceField,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DCAStrategyForm(forms.ModelForm):
|
class DCAStrategyForm(forms.ModelForm):
|
||||||
@@ -53,6 +61,75 @@ class DCAStrategyForm(forms.ModelForm):
|
|||||||
|
|
||||||
|
|
||||||
class DCAEntryForm(forms.ModelForm):
|
class DCAEntryForm(forms.ModelForm):
|
||||||
|
create_transaction = forms.BooleanField(
|
||||||
|
label=_("Create transaction"), initial=False, required=False
|
||||||
|
)
|
||||||
|
|
||||||
|
from_account = forms.ModelChoiceField(
|
||||||
|
queryset=Account.objects.filter(is_archived=False),
|
||||||
|
label=_("From Account"),
|
||||||
|
widget=TomSelect(clear_button=False, group_by="group"),
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
to_account = forms.ModelChoiceField(
|
||||||
|
queryset=Account.objects.filter(is_archived=False),
|
||||||
|
label=_("To Account"),
|
||||||
|
widget=TomSelect(clear_button=False, group_by="group"),
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
from_category = DynamicModelChoiceField(
|
||||||
|
create_field="name",
|
||||||
|
model=TransactionCategory,
|
||||||
|
required=False,
|
||||||
|
label=_("Category"),
|
||||||
|
queryset=TransactionCategory.objects.filter(active=True),
|
||||||
|
)
|
||||||
|
to_category = DynamicModelChoiceField(
|
||||||
|
create_field="name",
|
||||||
|
model=TransactionCategory,
|
||||||
|
required=False,
|
||||||
|
label=_("Category"),
|
||||||
|
queryset=TransactionCategory.objects.filter(active=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
from_tags = DynamicModelMultipleChoiceField(
|
||||||
|
model=TransactionTag,
|
||||||
|
to_field_name="name",
|
||||||
|
create_field="name",
|
||||||
|
required=False,
|
||||||
|
label=_("Tags"),
|
||||||
|
queryset=TransactionTag.objects.filter(active=True),
|
||||||
|
)
|
||||||
|
to_tags = DynamicModelMultipleChoiceField(
|
||||||
|
model=TransactionTag,
|
||||||
|
to_field_name="name",
|
||||||
|
create_field="name",
|
||||||
|
required=False,
|
||||||
|
label=_("Tags"),
|
||||||
|
queryset=TransactionTag.objects.filter(active=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
expense_transaction = DynamicModelChoiceField(
|
||||||
|
model=Transaction,
|
||||||
|
to_field_name="id",
|
||||||
|
label=_("Expense Transaction"),
|
||||||
|
required=False,
|
||||||
|
queryset=Transaction.objects.none(),
|
||||||
|
widget=TransactionSelect(clear_button=True, income=False, expense=True),
|
||||||
|
help_text=_("Type to search for a transaction to link to this entry"),
|
||||||
|
)
|
||||||
|
|
||||||
|
income_transaction = DynamicModelChoiceField(
|
||||||
|
model=Transaction,
|
||||||
|
to_field_name="id",
|
||||||
|
label=_("Income Transaction"),
|
||||||
|
required=False,
|
||||||
|
queryset=Transaction.objects.none(),
|
||||||
|
widget=TransactionSelect(clear_button=True, income=True, expense=False),
|
||||||
|
help_text=_("Type to search for a transaction to link to this entry"),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = DCAEntry
|
model = DCAEntry
|
||||||
fields = [
|
fields = [
|
||||||
@@ -60,13 +137,19 @@ class DCAEntryForm(forms.ModelForm):
|
|||||||
"amount_paid",
|
"amount_paid",
|
||||||
"amount_received",
|
"amount_received",
|
||||||
"notes",
|
"notes",
|
||||||
|
"expense_transaction",
|
||||||
|
"income_transaction",
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
"notes": forms.Textarea(attrs={"rows": 3}),
|
"notes": forms.Textarea(attrs={"rows": 3}),
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
strategy = kwargs.pop("strategy", None)
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.strategy = strategy if strategy else self.instance.strategy
|
||||||
|
|
||||||
self.helper = FormHelper()
|
self.helper = FormHelper()
|
||||||
self.helper.form_tag = False
|
self.helper.form_tag = False
|
||||||
self.helper.layout = Layout(
|
self.helper.layout = Layout(
|
||||||
@@ -75,18 +158,66 @@ class DCAEntryForm(forms.ModelForm):
|
|||||||
Column("amount_paid", css_class="form-group col-md-6"),
|
Column("amount_paid", css_class="form-group col-md-6"),
|
||||||
Column("amount_received", css_class="form-group col-md-6"),
|
Column("amount_received", css_class="form-group col-md-6"),
|
||||||
),
|
),
|
||||||
Row(
|
|
||||||
Column("expense_transaction", css_class="form-group col-md-6"),
|
|
||||||
Column("income_transaction", css_class="form-group col-md-6"),
|
|
||||||
),
|
|
||||||
"notes",
|
"notes",
|
||||||
|
BS5Accordion(
|
||||||
|
AccordionGroup(
|
||||||
|
_("Create transaction"),
|
||||||
|
Switch("create_transaction"),
|
||||||
|
Row(
|
||||||
|
Column(
|
||||||
|
Row(
|
||||||
|
Column(
|
||||||
|
"from_account",
|
||||||
|
css_class="form-group",
|
||||||
|
),
|
||||||
|
css_class="form-row",
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
Column(
|
||||||
|
"from_category",
|
||||||
|
css_class="form-group col-md-6 mb-0",
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
"from_tags", css_class="form-group col-md-6 mb-0"
|
||||||
|
),
|
||||||
|
css_class="form-row",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
css_class="p-1 mx-1 my-3 border rounded-3",
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
Column(
|
||||||
|
Row(
|
||||||
|
Column(
|
||||||
|
"to_account",
|
||||||
|
css_class="form-group",
|
||||||
|
),
|
||||||
|
css_class="form-row",
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
Column(
|
||||||
|
"to_category", css_class="form-group col-md-6 mb-0"
|
||||||
|
),
|
||||||
|
Column("to_tags", css_class="form-group col-md-6 mb-0"),
|
||||||
|
css_class="form-row",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
css_class="p-1 mx-1 my-3 border rounded-3",
|
||||||
|
),
|
||||||
|
active=False,
|
||||||
|
),
|
||||||
|
AccordionGroup(
|
||||||
|
_("Link transaction"),
|
||||||
|
"income_transaction",
|
||||||
|
"expense_transaction",
|
||||||
|
),
|
||||||
|
flush=False,
|
||||||
|
always_open=False,
|
||||||
|
css_class="mb-3",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.instance and self.instance.pk:
|
if self.instance and self.instance.pk:
|
||||||
# decimal_places = self.instance.account.currency.decimal_places
|
|
||||||
# self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput(
|
|
||||||
# decimal_places=decimal_places
|
|
||||||
# )
|
|
||||||
self.helper.layout.append(
|
self.helper.layout.append(
|
||||||
FormActions(
|
FormActions(
|
||||||
NoClassSubmit(
|
NoClassSubmit(
|
||||||
@@ -95,7 +226,6 @@ class DCAEntryForm(forms.ModelForm):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
|
||||||
self.helper.layout.append(
|
self.helper.layout.append(
|
||||||
FormActions(
|
FormActions(
|
||||||
NoClassSubmit(
|
NoClassSubmit(
|
||||||
@@ -107,3 +237,136 @@ class DCAEntryForm(forms.ModelForm):
|
|||||||
self.fields["amount_paid"].widget = ArbitraryDecimalDisplayNumberInput()
|
self.fields["amount_paid"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||||
self.fields["amount_received"].widget = ArbitraryDecimalDisplayNumberInput()
|
self.fields["amount_received"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||||
self.fields["date"].widget = AirDatePickerInput(clear_button=False)
|
self.fields["date"].widget = AirDatePickerInput(clear_button=False)
|
||||||
|
|
||||||
|
expense_transaction = None
|
||||||
|
income_transaction = None
|
||||||
|
if self.instance and self.instance.pk:
|
||||||
|
# Edit mode - get from instance
|
||||||
|
expense_transaction = self.instance.expense_transaction
|
||||||
|
income_transaction = self.instance.income_transaction
|
||||||
|
elif self.data.get("expense_transaction"):
|
||||||
|
# Form validation - get from submitted data
|
||||||
|
try:
|
||||||
|
expense_transaction = Transaction.objects.get(
|
||||||
|
id=self.data["expense_transaction"]
|
||||||
|
)
|
||||||
|
income_transaction = Transaction.objects.get(
|
||||||
|
id=self.data["income_transaction"]
|
||||||
|
)
|
||||||
|
except Transaction.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# If we have a current transaction, ensure it's in the queryset
|
||||||
|
if income_transaction:
|
||||||
|
self.fields["income_transaction"].queryset = Transaction.objects.filter(
|
||||||
|
id=income_transaction.id
|
||||||
|
)
|
||||||
|
if expense_transaction:
|
||||||
|
self.fields["expense_transaction"].queryset = Transaction.objects.filter(
|
||||||
|
id=expense_transaction.id
|
||||||
|
)
|
||||||
|
|
||||||
|
self.fields["from_account"].queryset = Account.objects.filter(
|
||||||
|
is_archived=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.fields["from_category"].queryset = TransactionCategory.objects.filter(
|
||||||
|
active=True
|
||||||
|
)
|
||||||
|
self.fields["from_tags"].queryset = TransactionTag.objects.filter(active=True)
|
||||||
|
|
||||||
|
self.fields["to_account"].queryset = Account.objects.filter(
|
||||||
|
is_archived=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.fields["to_category"].queryset = TransactionCategory.objects.filter(
|
||||||
|
active=True
|
||||||
|
)
|
||||||
|
self.fields["to_tags"].queryset = TransactionTag.objects.filter(active=True)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
|
||||||
|
if cleaned_data.get("create_transaction"):
|
||||||
|
from_account = cleaned_data.get("from_account")
|
||||||
|
to_account = cleaned_data.get("to_account")
|
||||||
|
|
||||||
|
if not from_account and not to_account:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
{
|
||||||
|
"from_account": _("You must provide an account."),
|
||||||
|
"to_account": _("You must provide an account."),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif not from_account and to_account:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
{"from_account": _("You must provide an account.")}
|
||||||
|
)
|
||||||
|
elif not to_account and from_account:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
{"to_account": _("You must provide an account.")}
|
||||||
|
)
|
||||||
|
|
||||||
|
if from_account == to_account:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
_("From and To accounts must be different.")
|
||||||
|
)
|
||||||
|
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
def save(self, **kwargs):
|
||||||
|
instance = super().save(commit=False)
|
||||||
|
|
||||||
|
if self.cleaned_data.get("create_transaction"):
|
||||||
|
from_account = self.cleaned_data["from_account"]
|
||||||
|
to_account = self.cleaned_data["to_account"]
|
||||||
|
from_amount = instance.amount_paid
|
||||||
|
to_amount = instance.amount_received
|
||||||
|
date = instance.date
|
||||||
|
description = _("DCA for %(strategy_name)s") % {
|
||||||
|
"strategy_name": self.strategy.name
|
||||||
|
}
|
||||||
|
from_category = self.cleaned_data.get("from_category")
|
||||||
|
to_category = self.cleaned_data.get("to_category")
|
||||||
|
notes = self.cleaned_data.get("notes")
|
||||||
|
|
||||||
|
# Create "From" transaction
|
||||||
|
from_transaction = Transaction.objects.create(
|
||||||
|
account=from_account,
|
||||||
|
type=Transaction.Type.EXPENSE,
|
||||||
|
is_paid=True,
|
||||||
|
date=date,
|
||||||
|
amount=from_amount,
|
||||||
|
description=description,
|
||||||
|
category=from_category,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
from_transaction.tags.set(self.cleaned_data.get("from_tags", []))
|
||||||
|
|
||||||
|
# Create "To" transaction
|
||||||
|
to_transaction = Transaction.objects.create(
|
||||||
|
account=to_account,
|
||||||
|
type=Transaction.Type.INCOME,
|
||||||
|
is_paid=True,
|
||||||
|
date=date,
|
||||||
|
amount=to_amount,
|
||||||
|
description=description,
|
||||||
|
category=to_category,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
to_transaction.tags.set(self.cleaned_data.get("to_tags", []))
|
||||||
|
|
||||||
|
instance.expense_transaction = from_transaction
|
||||||
|
instance.income_transaction = to_transaction
|
||||||
|
else:
|
||||||
|
if instance.expense_transaction:
|
||||||
|
instance.expense_transaction.amount = instance.amount_paid
|
||||||
|
instance.expense_transaction.save()
|
||||||
|
if instance.income_transaction:
|
||||||
|
instance.income_transaction.amount = instance.amount_received
|
||||||
|
instance.income_transaction.save()
|
||||||
|
|
||||||
|
instance.strategy = self.strategy
|
||||||
|
instance.save()
|
||||||
|
|
||||||
|
return instance
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Generated by Django 5.1.6 on 2025-03-07 18:20
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('dca', '0002_alter_dcaentry_amount_paid_and_more'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='dcastrategy',
|
||||||
|
name='owner',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='dcastrategy',
|
||||||
|
name='shared_with',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='dcastrategy',
|
||||||
|
name='visibility',
|
||||||
|
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,16 +1,15 @@
|
|||||||
from datetime import timedelta
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from statistics import mean, stdev
|
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.template.defaultfilters import date
|
from django.template.defaultfilters import date
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from apps.common.models import SharedObject, SharedObjectManager
|
||||||
from apps.currencies.utils.convert import convert, get_exchange_rate
|
from apps.currencies.utils.convert import convert, get_exchange_rate
|
||||||
|
|
||||||
|
|
||||||
class DCAStrategy(models.Model):
|
class DCAStrategy(SharedObject):
|
||||||
name = models.CharField(max_length=255, verbose_name=_("Name"))
|
name = models.CharField(max_length=255, verbose_name=_("Name"))
|
||||||
target_currency = models.ForeignKey(
|
target_currency = models.ForeignKey(
|
||||||
"currencies.Currency",
|
"currencies.Currency",
|
||||||
@@ -28,6 +27,9 @@ class DCAStrategy(models.Model):
|
|||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
objects = SharedObjectManager()
|
||||||
|
all_objects = models.Manager() # Unfiltered manager
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("DCA Strategy")
|
verbose_name = _("DCA Strategy")
|
||||||
verbose_name_plural = _("DCA Strategies")
|
verbose_name_plural = _("DCA Strategies")
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ urlpatterns = [
|
|||||||
views.strategy_delete,
|
views.strategy_delete,
|
||||||
name="dca_strategy_delete",
|
name="dca_strategy_delete",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"dca/<int:strategy_id>/take-ownership/",
|
||||||
|
views.strategy_take_ownership,
|
||||||
|
name="dca_strategy_take_ownership",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"dca/<int:pk>/share/",
|
||||||
|
views.strategy_share,
|
||||||
|
name="dca_strategy_share_settings",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"dca/<int:strategy_id>/",
|
"dca/<int:strategy_id>/",
|
||||||
views.strategy_detail_index,
|
views.strategy_detail_index,
|
||||||
|
|||||||
+81
-6
@@ -11,6 +11,8 @@ from django.views.decorators.http import require_http_methods
|
|||||||
from apps.common.decorators.htmx import only_htmx
|
from apps.common.decorators.htmx import only_htmx
|
||||||
from apps.dca.forms import DCAEntryForm, DCAStrategyForm
|
from apps.dca.forms import DCAEntryForm, DCAStrategyForm
|
||||||
from apps.dca.models import DCAStrategy, DCAEntry
|
from apps.dca.models import DCAStrategy, DCAEntry
|
||||||
|
from apps.common.models import SharedObject
|
||||||
|
from apps.common.forms import SharedObjectForm
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -57,6 +59,16 @@ def strategy_add(request):
|
|||||||
def strategy_edit(request, strategy_id):
|
def strategy_edit(request, strategy_id):
|
||||||
dca_strategy = get_object_or_404(DCAStrategy, id=strategy_id)
|
dca_strategy = get_object_or_404(DCAStrategy, id=strategy_id)
|
||||||
|
|
||||||
|
if dca_strategy.owner and dca_strategy.owner != request.user:
|
||||||
|
messages.error(request, _("Only the owner can edit this"))
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
headers={
|
||||||
|
"HX-Trigger": "updated, hide_offcanvas",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = DCAStrategyForm(request.POST, instance=dca_strategy)
|
form = DCAStrategyForm(request.POST, instance=dca_strategy)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
@@ -85,8 +97,14 @@ def strategy_edit(request, strategy_id):
|
|||||||
def strategy_delete(request, strategy_id):
|
def strategy_delete(request, strategy_id):
|
||||||
dca_strategy = get_object_or_404(DCAStrategy, id=strategy_id)
|
dca_strategy = get_object_or_404(DCAStrategy, id=strategy_id)
|
||||||
|
|
||||||
|
if (
|
||||||
|
dca_strategy.owner != request.user
|
||||||
|
and request.user in dca_strategy.shared_with.all()
|
||||||
|
):
|
||||||
|
dca_strategy.shared_with.remove(request.user)
|
||||||
|
messages.success(request, _("Item no longer shared with you"))
|
||||||
|
else:
|
||||||
dca_strategy.delete()
|
dca_strategy.delete()
|
||||||
|
|
||||||
messages.success(request, _("DCA strategy deleted successfully"))
|
messages.success(request, _("DCA strategy deleted successfully"))
|
||||||
|
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
@@ -97,6 +115,65 @@ def strategy_delete(request, strategy_id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def strategy_take_ownership(request, strategy_id):
|
||||||
|
dca_strategy = get_object_or_404(DCAStrategy, id=strategy_id)
|
||||||
|
|
||||||
|
if not dca_strategy.owner:
|
||||||
|
dca_strategy.owner = request.user
|
||||||
|
dca_strategy.visibility = SharedObject.Visibility.private
|
||||||
|
dca_strategy.save()
|
||||||
|
|
||||||
|
messages.success(request, _("Ownership taken successfully"))
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
headers={
|
||||||
|
"HX-Trigger": "updated, hide_offcanvas",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def strategy_share(request, pk):
|
||||||
|
obj = get_object_or_404(DCAStrategy, id=pk)
|
||||||
|
|
||||||
|
if obj.owner and obj.owner != request.user:
|
||||||
|
messages.error(request, _("Only the owner can edit this"))
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
headers={
|
||||||
|
"HX-Trigger": "updated, hide_offcanvas",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
form = SharedObjectForm(request.POST, instance=obj, user=request.user)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
messages.success(request, _("Configuration saved successfully"))
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
headers={
|
||||||
|
"HX-Trigger": "updated, hide_offcanvas",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
form = SharedObjectForm(instance=obj, user=request.user)
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"dca/fragments/strategy/share.html",
|
||||||
|
{"form": form, "object": obj},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def strategy_detail_index(request, strategy_id):
|
def strategy_detail_index(request, strategy_id):
|
||||||
strategy = get_object_or_404(DCAStrategy, id=strategy_id)
|
strategy = get_object_or_404(DCAStrategy, id=strategy_id)
|
||||||
@@ -155,11 +232,9 @@ def strategy_detail(request, strategy_id):
|
|||||||
def strategy_entry_add(request, strategy_id):
|
def strategy_entry_add(request, strategy_id):
|
||||||
strategy = get_object_or_404(DCAStrategy, id=strategy_id)
|
strategy = get_object_or_404(DCAStrategy, id=strategy_id)
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = DCAEntryForm(request.POST)
|
form = DCAEntryForm(request.POST, strategy=strategy)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
entry = form.save(commit=False)
|
entry = form.save()
|
||||||
entry.strategy = strategy
|
|
||||||
entry.save()
|
|
||||||
messages.success(request, _("Entry added successfully"))
|
messages.success(request, _("Entry added successfully"))
|
||||||
|
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
@@ -169,7 +244,7 @@ def strategy_entry_add(request, strategy_id):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
form = DCAEntryForm()
|
form = DCAEntryForm(strategy=strategy)
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ExportConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.export_app"
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
from crispy_forms.bootstrap import FormActions
|
||||||
|
from crispy_forms.helper import FormHelper
|
||||||
|
from crispy_forms.layout import Layout, HTML
|
||||||
|
from django import forms
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||||
|
|
||||||
|
|
||||||
|
class ExportForm(forms.Form):
|
||||||
|
users = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.CheckboxInput(),
|
||||||
|
label=_("Users"),
|
||||||
|
initial=True,
|
||||||
|
)
|
||||||
|
accounts = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.CheckboxInput(),
|
||||||
|
label=_("Accounts"),
|
||||||
|
initial=True,
|
||||||
|
)
|
||||||
|
currencies = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.CheckboxInput(),
|
||||||
|
label=_("Currencies"),
|
||||||
|
initial=True,
|
||||||
|
)
|
||||||
|
transactions = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.CheckboxInput(),
|
||||||
|
label=_("Transactions"),
|
||||||
|
initial=True,
|
||||||
|
)
|
||||||
|
categories = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.CheckboxInput(),
|
||||||
|
label=_("Categories"),
|
||||||
|
initial=True,
|
||||||
|
)
|
||||||
|
tags = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.CheckboxInput(),
|
||||||
|
label=_("Tags"),
|
||||||
|
initial=False,
|
||||||
|
)
|
||||||
|
entities = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.CheckboxInput(),
|
||||||
|
label=_("Entities"),
|
||||||
|
initial=False,
|
||||||
|
)
|
||||||
|
recurring_transactions = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.CheckboxInput(),
|
||||||
|
label=_("Recurring Transactions"),
|
||||||
|
initial=True,
|
||||||
|
)
|
||||||
|
installment_plans = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.CheckboxInput(),
|
||||||
|
label=_("Installment Plans"),
|
||||||
|
initial=True,
|
||||||
|
)
|
||||||
|
exchange_rates = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.CheckboxInput(),
|
||||||
|
label=_("Exchange Rates"),
|
||||||
|
initial=False,
|
||||||
|
)
|
||||||
|
exchange_rates_services = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.CheckboxInput(),
|
||||||
|
label=_("Automatic Exchange Rates"),
|
||||||
|
initial=False,
|
||||||
|
)
|
||||||
|
rules = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.CheckboxInput(),
|
||||||
|
label=_("Rules"),
|
||||||
|
initial=True,
|
||||||
|
)
|
||||||
|
dca = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.CheckboxInput(),
|
||||||
|
label=_("DCA"),
|
||||||
|
initial=False,
|
||||||
|
)
|
||||||
|
import_profiles = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.CheckboxInput(),
|
||||||
|
label=_("Import Profiles"),
|
||||||
|
initial=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.helper = FormHelper()
|
||||||
|
self.helper.form_tag = False
|
||||||
|
self.helper.form_method = "post"
|
||||||
|
self.helper.layout = Layout(
|
||||||
|
"users",
|
||||||
|
"accounts",
|
||||||
|
"currencies",
|
||||||
|
"transactions",
|
||||||
|
"categories",
|
||||||
|
"entities",
|
||||||
|
"tags",
|
||||||
|
"installment_plans",
|
||||||
|
"recurring_transactions",
|
||||||
|
"exchange_rates_services",
|
||||||
|
"exchange_rates",
|
||||||
|
"rules",
|
||||||
|
"dca",
|
||||||
|
"import_profiles",
|
||||||
|
FormActions(
|
||||||
|
NoClassSubmit(
|
||||||
|
"submit", _("Export"), css_class="btn btn-outline-primary w-100"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RestoreForm(forms.Form):
|
||||||
|
zip_file = forms.FileField(
|
||||||
|
required=False,
|
||||||
|
help_text=_("Import a ZIP file exported from WYGIWYH"),
|
||||||
|
label=_("ZIP File"),
|
||||||
|
)
|
||||||
|
users = forms.FileField(required=False, label=_("Users"))
|
||||||
|
accounts = forms.FileField(required=False, label=_("Accounts"))
|
||||||
|
currencies = forms.FileField(required=False, label=_("Currencies"))
|
||||||
|
transactions_categories = forms.FileField(required=False, label=_("Categories"))
|
||||||
|
transactions_tags = forms.FileField(required=False, label=_("Tags"))
|
||||||
|
transactions_entities = forms.FileField(required=False, label=_("Entities"))
|
||||||
|
transactions = forms.FileField(required=False, label=_("Transactions"))
|
||||||
|
installment_plans = forms.FileField(required=False, label=_("Installment Plans"))
|
||||||
|
recurring_transactions = forms.FileField(
|
||||||
|
required=False, label=_("Recurring Transactions")
|
||||||
|
)
|
||||||
|
automatic_exchange_rates = forms.FileField(
|
||||||
|
required=False, label=_("Automatic Exchange Rates")
|
||||||
|
)
|
||||||
|
exchange_rates = forms.FileField(required=False, label=_("Exchange Rates"))
|
||||||
|
transaction_rules = forms.FileField(required=False, label=_("Transaction rules"))
|
||||||
|
transaction_rules_actions = forms.FileField(
|
||||||
|
required=False, label=_("Edit transaction action")
|
||||||
|
)
|
||||||
|
transaction_rules_update_or_create = forms.FileField(
|
||||||
|
required=False, label=_("Update or create transaction actions")
|
||||||
|
)
|
||||||
|
dca_strategies = forms.FileField(required=False, label=_("DCA Strategies"))
|
||||||
|
dca_entries = forms.FileField(required=False, label=_("DCA Entries"))
|
||||||
|
import_profiles = forms.FileField(required=False, label=_("Import Profiles"))
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.helper = FormHelper()
|
||||||
|
self.helper.form_tag = False
|
||||||
|
self.helper.form_method = "post"
|
||||||
|
self.helper.layout = Layout(
|
||||||
|
"zip_file",
|
||||||
|
HTML("<hr />"),
|
||||||
|
"users",
|
||||||
|
"accounts",
|
||||||
|
"currencies",
|
||||||
|
"transactions",
|
||||||
|
"transactions_categories",
|
||||||
|
"transactions_entities",
|
||||||
|
"transactions_tags",
|
||||||
|
"installment_plans",
|
||||||
|
"recurring_transactions",
|
||||||
|
"automatic_exchange_rates",
|
||||||
|
"exchange_rates",
|
||||||
|
"transaction_rules",
|
||||||
|
"transaction_rules_actions",
|
||||||
|
"transaction_rules_update_or_create",
|
||||||
|
"dca_strategies",
|
||||||
|
"dca_entries",
|
||||||
|
"import_profiles",
|
||||||
|
FormActions(
|
||||||
|
NoClassSubmit(
|
||||||
|
"submit", _("Restore"), css_class="btn btn-outline-primary w-100"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
if not cleaned_data.get("zip_file") and not any(
|
||||||
|
cleaned_data.get(field) for field in self.fields if field != "zip_file"
|
||||||
|
):
|
||||||
|
raise forms.ValidationError(
|
||||||
|
_("Please upload either a ZIP file or at least one CSV file")
|
||||||
|
)
|
||||||
|
return cleaned_data
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
# Create your models here.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
from import_export import fields, resources, widgets
|
||||||
|
|
||||||
|
from apps.accounts.models import Account, AccountGroup
|
||||||
|
from apps.export_app.widgets.foreign_key import AutoCreateForeignKeyWidget
|
||||||
|
from apps.currencies.models import Currency
|
||||||
|
|
||||||
|
|
||||||
|
class AccountResource(resources.ModelResource):
|
||||||
|
group = fields.Field(
|
||||||
|
attribute="group",
|
||||||
|
column_name="group",
|
||||||
|
widget=AutoCreateForeignKeyWidget(AccountGroup, "name"),
|
||||||
|
)
|
||||||
|
currency = fields.Field(
|
||||||
|
attribute="currency",
|
||||||
|
column_name="currency",
|
||||||
|
widget=widgets.ForeignKeyWidget(Currency, "name"),
|
||||||
|
)
|
||||||
|
exchange_currency = fields.Field(
|
||||||
|
attribute="exchange_currency",
|
||||||
|
column_name="exchange_currency",
|
||||||
|
widget=widgets.ForeignKeyWidget(Currency, "name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Account
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Account.all_objects.all()
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
from import_export import fields, resources, widgets
|
||||||
|
|
||||||
|
from apps.accounts.models import Account
|
||||||
|
from apps.currencies.models import Currency, ExchangeRate, ExchangeRateService
|
||||||
|
from apps.export_app.widgets.foreign_key import SkipMissingForeignKeyWidget
|
||||||
|
from apps.export_app.widgets.numbers import UniversalDecimalWidget
|
||||||
|
|
||||||
|
|
||||||
|
class CurrencyResource(resources.ModelResource):
|
||||||
|
exchange_currency = fields.Field(
|
||||||
|
attribute="exchange_currency",
|
||||||
|
column_name="exchange_currency",
|
||||||
|
widget=SkipMissingForeignKeyWidget(Currency, "name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Currency
|
||||||
|
|
||||||
|
|
||||||
|
class ExchangeRateResource(resources.ModelResource):
|
||||||
|
from_currency = fields.Field(
|
||||||
|
attribute="from_currency",
|
||||||
|
column_name="from_currency",
|
||||||
|
widget=widgets.ForeignKeyWidget(Currency, "name"),
|
||||||
|
)
|
||||||
|
to_currency = fields.Field(
|
||||||
|
attribute="to_currency",
|
||||||
|
column_name="to_currency",
|
||||||
|
widget=widgets.ForeignKeyWidget(Currency, "name"),
|
||||||
|
)
|
||||||
|
rate = fields.Field(
|
||||||
|
attribute="rate", column_name="rate", widget=UniversalDecimalWidget()
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ExchangeRate
|
||||||
|
|
||||||
|
|
||||||
|
class ExchangeRateServiceResource(resources.ModelResource):
|
||||||
|
target_currencies = fields.Field(
|
||||||
|
attribute="target_currencies",
|
||||||
|
column_name="target_currencies",
|
||||||
|
widget=widgets.ManyToManyWidget(Currency, field="name"),
|
||||||
|
)
|
||||||
|
target_accounts = fields.Field(
|
||||||
|
attribute="target_accounts",
|
||||||
|
column_name="target_accounts",
|
||||||
|
widget=widgets.ManyToManyWidget(Account, field="name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ExchangeRateService
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
from import_export import fields, resources
|
||||||
|
from import_export.widgets import ForeignKeyWidget
|
||||||
|
|
||||||
|
from apps.dca.models import DCAStrategy, DCAEntry
|
||||||
|
from apps.currencies.models import Currency
|
||||||
|
from apps.export_app.widgets.numbers import UniversalDecimalWidget
|
||||||
|
|
||||||
|
|
||||||
|
class DCAStrategyResource(resources.ModelResource):
|
||||||
|
target_currency = fields.Field(
|
||||||
|
attribute="target_currency",
|
||||||
|
column_name="target_currency",
|
||||||
|
widget=ForeignKeyWidget(Currency, "name"),
|
||||||
|
)
|
||||||
|
payment_currency = fields.Field(
|
||||||
|
attribute="payment_currency",
|
||||||
|
column_name="payment_currency",
|
||||||
|
widget=ForeignKeyWidget(Currency, "name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = DCAStrategy
|
||||||
|
|
||||||
|
|
||||||
|
class DCAEntryResource(resources.ModelResource):
|
||||||
|
amount_paid = fields.Field(
|
||||||
|
attribute="amount_paid",
|
||||||
|
column_name="amount_paid",
|
||||||
|
widget=UniversalDecimalWidget(),
|
||||||
|
)
|
||||||
|
amount_received = fields.Field(
|
||||||
|
attribute="amount_received",
|
||||||
|
column_name="amount_received",
|
||||||
|
widget=UniversalDecimalWidget(),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = DCAEntry
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from import_export import resources
|
||||||
|
|
||||||
|
from apps.import_app.models import ImportProfile
|
||||||
|
|
||||||
|
|
||||||
|
class ImportProfileResource(resources.ModelResource):
|
||||||
|
class Meta:
|
||||||
|
model = ImportProfile
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from import_export import fields, resources
|
||||||
|
from import_export.widgets import ForeignKeyWidget
|
||||||
|
|
||||||
|
from apps.export_app.widgets.foreign_key import AutoCreateForeignKeyWidget
|
||||||
|
from apps.export_app.widgets.many_to_many import AutoCreateManyToManyWidget
|
||||||
|
from apps.rules.models import (
|
||||||
|
TransactionRule,
|
||||||
|
TransactionRuleAction,
|
||||||
|
UpdateOrCreateTransactionRuleAction,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionRuleResource(resources.ModelResource):
|
||||||
|
class Meta:
|
||||||
|
model = TransactionRule
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionRuleActionResource(resources.ModelResource):
|
||||||
|
class Meta:
|
||||||
|
model = TransactionRuleAction
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateOrCreateTransactionRuleResource(resources.ModelResource):
|
||||||
|
class Meta:
|
||||||
|
model = UpdateOrCreateTransactionRuleAction
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
from import_export import fields, resources
|
||||||
|
from import_export.widgets import ForeignKeyWidget
|
||||||
|
|
||||||
|
from apps.accounts.models import Account
|
||||||
|
from apps.export_app.widgets.foreign_key import AutoCreateForeignKeyWidget
|
||||||
|
from apps.export_app.widgets.many_to_many import AutoCreateManyToManyWidget
|
||||||
|
from apps.export_app.widgets.string import EmptyStringToNoneField
|
||||||
|
from apps.transactions.models import (
|
||||||
|
Transaction,
|
||||||
|
TransactionCategory,
|
||||||
|
TransactionTag,
|
||||||
|
TransactionEntity,
|
||||||
|
RecurringTransaction,
|
||||||
|
InstallmentPlan,
|
||||||
|
)
|
||||||
|
from apps.export_app.widgets.numbers import UniversalDecimalWidget
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionResource(resources.ModelResource):
|
||||||
|
account = fields.Field(
|
||||||
|
attribute="account",
|
||||||
|
column_name="account",
|
||||||
|
widget=ForeignKeyWidget(Account, "name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
category = fields.Field(
|
||||||
|
attribute="category",
|
||||||
|
column_name="category",
|
||||||
|
widget=AutoCreateForeignKeyWidget(TransactionCategory, "name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
tags = fields.Field(
|
||||||
|
attribute="tags",
|
||||||
|
column_name="tags",
|
||||||
|
widget=AutoCreateManyToManyWidget(TransactionTag, field="name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
entities = fields.Field(
|
||||||
|
attribute="entities",
|
||||||
|
column_name="entities",
|
||||||
|
widget=AutoCreateManyToManyWidget(TransactionEntity, field="name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
internal_id = EmptyStringToNoneField(
|
||||||
|
column_name="internal_id", attribute="internal_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
amount = fields.Field(
|
||||||
|
attribute="amount",
|
||||||
|
column_name="amount",
|
||||||
|
widget=UniversalDecimalWidget(),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Transaction
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Transaction.userless_all_objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionTagResource(resources.ModelResource):
|
||||||
|
class Meta:
|
||||||
|
model = TransactionTag
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return TransactionTag.all_objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionEntityResource(resources.ModelResource):
|
||||||
|
class Meta:
|
||||||
|
model = TransactionEntity
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return TransactionEntity.all_objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionCategoyResource(resources.ModelResource):
|
||||||
|
class Meta:
|
||||||
|
model = TransactionCategory
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return TransactionCategory.all_objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
class RecurringTransactionResource(resources.ModelResource):
|
||||||
|
account = fields.Field(
|
||||||
|
attribute="account",
|
||||||
|
column_name="account",
|
||||||
|
widget=ForeignKeyWidget(Account, "name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
category = fields.Field(
|
||||||
|
attribute="category",
|
||||||
|
column_name="category",
|
||||||
|
widget=AutoCreateForeignKeyWidget(TransactionCategory, "name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
tags = fields.Field(
|
||||||
|
attribute="tags",
|
||||||
|
column_name="tags",
|
||||||
|
widget=AutoCreateManyToManyWidget(TransactionTag, field="name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
entities = fields.Field(
|
||||||
|
attribute="entities",
|
||||||
|
column_name="entities",
|
||||||
|
widget=AutoCreateManyToManyWidget(TransactionEntity, field="name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
amount = fields.Field(
|
||||||
|
attribute="amount",
|
||||||
|
column_name="amount",
|
||||||
|
widget=UniversalDecimalWidget(),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = RecurringTransaction
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return RecurringTransaction.all_objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
class InstallmentPlanResource(resources.ModelResource):
|
||||||
|
account = fields.Field(
|
||||||
|
attribute="account",
|
||||||
|
column_name="account",
|
||||||
|
widget=ForeignKeyWidget(Account, "name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
category = fields.Field(
|
||||||
|
attribute="category",
|
||||||
|
column_name="category",
|
||||||
|
widget=AutoCreateForeignKeyWidget(TransactionCategory, "name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
tags = fields.Field(
|
||||||
|
attribute="tags",
|
||||||
|
column_name="tags",
|
||||||
|
widget=AutoCreateManyToManyWidget(TransactionTag, field="name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
entities = fields.Field(
|
||||||
|
attribute="entities",
|
||||||
|
column_name="entities",
|
||||||
|
widget=AutoCreateManyToManyWidget(TransactionEntity, field="name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
installment_amount = fields.Field(
|
||||||
|
attribute="installment_amount",
|
||||||
|
column_name="installment_amount",
|
||||||
|
widget=UniversalDecimalWidget(),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = InstallmentPlan
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return InstallmentPlan.all_objects.all()
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user