Compare commits

...

661 Commits

Author SHA1 Message Date
Herculino Trotta 0832ec75ca fix: visual bug when backspacing on the "amount" field 2025-07-09 20:24:27 -03:00
Herculino Trotta 6b4fbee7a6 Merge pull request #272
style: remove color from scrollbar
2025-06-29 17:39:12 -03:00
Herculino Trotta e7fe6622cd style: remove color from scrollbar 2025-06-29 17:38:49 -03:00
Herculino Trotta 3017593ed5 locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (661 of 661 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-06-29 19:34:02 +00:00
eitchtee ceb8e9ea97 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-06-29 19:29:11 +00:00
Herculino Trotta 9b5b7683dd git: merge conflict with weblate 2025-06-29 16:27:15 -03:00
Herculino Trotta 514600e34a locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (661 of 661 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-06-29 17:42:29 +00:00
eitchtee 07dd805b07 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-06-29 17:42:26 +00:00
Herculino Trotta 905e9b4c54 Merge pull request #271
feat: allow loading any available datepicker languages
2025-06-29 14:41:03 -03:00
Herculino Trotta 60d367dec5 feat: allow loading any available datepicker languages
instead of a pre-configured list
2025-06-29 14:40:41 -03:00
Herculino Trotta 6e0842a697 Merge pull request #270
chore: bump npm dependencies
2025-06-29 14:11:03 -03:00
Herculino Trotta 858934b7c5 chore: bump npm dependencies 2025-06-29 14:10:38 -03:00
Herculino Trotta 47d9e4078c Merge pull request #269
chore: update npm dependencies
2025-06-29 12:11:37 -03:00
Herculino Trotta fa6f3e87c0 chore: update npm dependencies 2025-06-29 12:11:17 -03:00
Herculino Trotta 5f101af879 Merge pull request #268
fix: broken distribution chart when number format is set to dot-comma
2025-06-29 01:32:07 -03:00
Herculino Trotta b27633a28e fix: broken distribution chart when number format is set to dot-comma 2025-06-29 01:31:43 -03:00
eitchtee 7716eee0b3 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-06-29 04:15:12 +00:00
Herculino Trotta 37c447ae0a Merge pull request #267
style: improve the look of secondary navbar buttons (profile and calc)
2025-06-29 01:14:32 -03:00
Herculino Trotta e544d7068b style: improve the look of secondary navbar buttons (profile and calc) 2025-06-29 01:14:06 -03:00
eitchtee 8d93da99c1 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-06-29 03:23:23 +00:00
Herculino Trotta cc87477a2e Merge pull request #266
feat: add sounds volume control to user settings
2025-06-29 00:21:54 -03:00
Herculino Trotta e86e0b8c08 feat: add sounds volume control to user settings 2025-06-29 00:21:32 -03:00
eitchtee eb0c872c50 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-06-28 03:05:50 +00:00
Herculino Trotta b4578df242 Merge pull request #265
feat: creating a quick transaction triggers the proper rule
2025-06-28 00:05:10 -03:00
Herculino Trotta 756de12835 feat: creating a quick transaction triggers the proper rule 2025-06-28 00:04:45 -03:00
ichi135 d573d02657 locale(Spanish): update translation
Currently translated at 17.6% (116 of 659 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/es/
2025-06-27 23:16:54 +00:00
Herculino Trotta 250b352217 Merge pull request #263
chore: update tailwind to v4
2025-06-21 16:13:06 -03:00
Herculino Trotta b4e9446cf6 chore: update tailwind to v4
As is customary in the JS world EVERYTHING must break with each major version
2025-06-21 16:12:44 -03:00
Dimitri Decrock 90944f0179 locale(Dutch): update translation
Currently translated at 100.0% (659 of 659 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-06-21 16:16:54 +00:00
Herculino Trotta 008d34b1d0 Merge remote-tracking branch 'origin/main' 2025-06-21 10:55:56 -03:00
Herculino Trotta 46dfc7dad4 chore: update npm dependencies 2025-06-21 10:55:32 -03:00
Herculino Trotta 22900b5d9e locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (659 of 659 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-06-20 07:16:54 +00:00
Herculino Trotta 0c48e9fe3d docs: wrong OIDC callback url 2025-06-20 02:16:37 -03:00
eitchtee b2e100d1b0 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-06-20 05:07:12 +00:00
Herculino Trotta e49b38a442 Merge pull request #260 from eitchtee/feat/oidc-integration
feat: add oidc support
2025-06-20 02:05:28 -03:00
Herculino Trotta 1f2902eea9 Merge branch 'main' into feat/oidc-integration 2025-06-20 02:03:48 -03:00
Herculino Trotta 7d60db8716 Merge pull request #262
style: slightly thicker scrollbar
2025-06-20 02:02:17 -03:00
eitchtee 873b0baed7 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-06-20 05:02:12 +00:00
Herculino Trotta 2313c97761 style: slightly thicker scrollbar 2025-06-20 02:01:53 -03:00
Herculino Trotta 9cd7337153 Merge pull request #261
feat: add quick transactions
2025-06-20 02:01:32 -03:00
Herculino Trotta d3b354e2b8 feat: add quick transactions 2025-06-20 02:01:09 -03:00
Herculino Trotta e137666e99 docs: add missing oidc variables to example env 2025-06-16 22:27:42 -03:00
Herculino Trotta 4291a5b97d feat: allow only social auth with django-allauth 2025-06-16 22:20:10 -03:00
Herculino Trotta c8d316857f feat: changes 2025-06-16 21:33:59 -03:00
eitchtee 3395a96949 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-06-15 18:46:38 +00:00
Herculino Trotta 8ab9624619 Merge pull request #259
feat: replace action row with a FAB
2025-06-15 15:45:13 -03:00
Herculino Trotta f9056c3a45 feat: replace action row with a FAB 2025-06-15 15:44:33 -03:00
Herculino Trotta a9df684ee2 Merge pull request #258
style(theme): improve dark colors for a less washed out look
2025-06-15 11:23:05 -03:00
Herculino Trotta e4d07c94d4 style(theme): improve dark colors for a less washed out look 2025-06-15 10:58:57 -03:00
google-labs-jules[bot] 5d5d172b3b feat: Initial OIDC integration with django-allauth
I've added django-allauth and configured it for OIDC authentication.
This included changes to settings, URLs, and login templates to support OIDC.
I verified that the User model and UserSettings creation are compatible.
I also added documentation for OIDC environment variables to README.md.
2025-05-31 02:31:01 +00:00
JHoh 99f746b6be locale(German): update translation
Currently translated at 97.2% (633 of 651 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/de/
2025-05-23 17:16:54 +00:00
Herculino Trotta a461a33dc2 Merge pull request #256
feat(net-worth): display consolidated amounts for currencies without transactions
2025-05-12 20:45:09 -03:00
Herculino Trotta 1213ffebeb feat(transactions:calculations): add deep_search param to return consolidated amounts for currencies without transactions / sort results by their final total 2025-05-12 20:43:47 -03:00
Herculino Trotta c5a352cf4d feat(networth): only display consolidated amounts if they're different from the total 2025-05-12 20:33:50 -03:00
Herculino Trotta cfcca54aa6 fix(networth): consolidated color doesn't reflect consolidated amount 2025-05-12 20:29:43 -03:00
Felix 234f8cd669 locale(Ukrainian): update translation
Currently translated at 14.7% (96 of 651 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/uk/
2025-05-12 14:16:54 +00:00
eitchtee 43184140f0 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-05-11 15:47:57 +00:00
Herculino Trotta acc325c150 Merge pull request #254
fix(api:accounts): unable to create an account with an account group
2025-05-11 12:47:23 -03:00
Herculino Trotta 46eb471a34 fix(api:transactions): wrong schema definition for TransactionCategory 2025-05-11 12:46:30 -03:00
Herculino Trotta 6dc14c73d6 fix(api:accounts): unable to create an account with an account group
Fixes #253
2025-05-11 12:41:26 -03:00
Felix f942924e7c locale((Ukrainian)): added translation using Weblate 2025-05-11 06:23:59 +00:00
Dimitri Decrock aa6019e0a9 locale(Dutch): update translation
Currently translated at 100.0% (651 of 651 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-05-01 09:16:53 +00:00
Herculino Trotta 9dfbd346bc locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (651 of 651 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-04-27 20:17:02 +00:00
Herculino Trotta 73b1d36dfd Merge remote-tracking branch 'weblate/main'
fix
2025-04-27 17:14:20 -03:00
Herculino Trotta 3662fb030a merge conflit 2025-04-27 16:48:38 -03:00
ThomasE a423ee1032 locale(French): update translation
Currently translated at 46.6% (303 of 650 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/fr/
2025-04-27 19:12:34 +00:00
eitchtee 72eb59d24f chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-04-27 19:12:31 +00:00
Herculino Trotta 1a0247e028 Merge pull request #252
fix: duplicate totals when account is shared with owner & prevent SharedObject from being shared with owner
2025-04-27 16:11:49 -03:00
Herculino Trotta 281a0fccda fix: prevent SharedObjects from being shared with their owner
#247
2025-04-27 16:07:54 -03:00
Herculino Trotta 59ce50299a fix(transactions): duplicate totals when account is shared with owner or owner-less and shared
#247
2025-04-27 15:57:55 -03:00
Dimitri Decrock be89509beb locale(Dutch): update translation
Currently translated at 100.0% (650 of 650 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-04-26 11:16:53 +00:00
H4ndrew 80cded234d locale(French): update translation
Currently translated at 45.3% (295 of 650 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/fr/
2025-04-23 09:16:53 +00:00
H4ndrew 030bb63586 locale(French): update translation
Currently translated at 39.3% (256 of 650 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/fr/
2025-04-22 13:16:53 +00:00
H4ndrew 66e8fc5884 locale(French): update translation
Currently translated at 36.6% (238 of 650 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/fr/
2025-04-22 11:16:53 +00:00
Dimitri Decrock 363047337d locale(Dutch): update translation
Currently translated at 99.8% (649 of 650 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-04-22 05:16:53 +00:00
eitchtee c7e32d1576 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-04-21 18:45:41 +00:00
Herculino Trotta 157e59a1d1 Merge pull request #250
fix(transactions): save and add similar not initializing dates properly
2025-04-21 15:45:05 -03:00
Herculino Trotta d9c505ac79 fix(transactions): save and add similar not initializing dates properly
Fixes #248
2025-04-21 15:44:42 -03:00
Herculino Trotta 7274a13f3c Merge pull request #249
fix(accounts): unable to share some accounts; wrong url getting used
2025-04-21 14:33:41 -03:00
Herculino Trotta 5d64665ddd fix(accounts): unable to share some accounts; wrong url getting used 2025-04-21 14:33:11 -03:00
Herculino Trotta e0d92d15c8 locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (650 of 650 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-04-20 21:16:53 +00:00
eitchtee 48dd658627 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-04-20 18:57:34 +00:00
Herculino Trotta 80dbbd02f0 Merge pull request #246 from eitchtee/dev
feat(transactions): add another transaction (or a similar one)
2025-04-20 15:56:58 -03:00
Herculino Trotta 4b7ca61c29 feat(transactions): add another transaction (or a similar one) right after adding one 2025-04-20 15:55:50 -03:00
eitchtee b2f04ae1f9 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-04-19 15:30:24 +00:00
Herculino Trotta f34d4b5e28 Merge pull request #245 from eitchtee/dev
feat(insights:category-overview): pick between Projected/Current/Final totals
2025-04-19 12:28:50 -03:00
Herculino Trotta d2ebfbd615 feat(insights:category-overview): pick between Projected/Current/Final totals 2025-04-19 12:28:22 -03:00
Herculino Trotta 812abbe488 feat(insights:category-overview): pick between Projected/Current/Final totals 2025-04-19 12:16:21 -03:00
eitchtee 9602a4affc chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-04-19 05:41:07 +00:00
Herculino Trotta bf548c0747 Merge pull request #244 from eitchtee/dev
feat(insights:category-overview): display tags breakdown alongside categories
2025-04-19 02:40:26 -03:00
Herculino Trotta 55ad2be08b feat(insights:category-overview): display tags breakdown alongside categories 2025-04-19 02:36:38 -03:00
eitchtee 2cd58c2464 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-04-19 05:36:33 +00:00
Herculino Trotta 4675ba9d56 Merge pull request #243
feat(insights:category-overview): select if you want to view table or bar charts, defaults to table
2025-04-19 02:35:55 -03:00
Herculino Trotta a25c992d5c feat(cotton): convert help icon to cotton template; allow setting custom icon 2025-04-19 02:34:16 -03:00
Herculino Trotta 2eadfe99a5 feat(insights:category-overview): select if you want to view table or bar charts, defaults to table 2025-04-19 00:00:31 -03:00
Herculino Trotta 11086a726f fix(insights): missing page title 2025-04-18 23:59:32 -03:00
Herculino Trotta cd99b40b0a Merge pull request #242
fix(users): users doesn't activate management menu entry
2025-04-18 15:08:19 -03:00
Herculino Trotta 63aa51dc0d fix(users): users doesn't activate management menu entry 2025-04-18 15:08:00 -03:00
eitchtee 4708c5bc7e chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-04-18 18:05:59 +00:00
Herculino Trotta 5a8462c050 Merge pull request #241
fix(users): disables profile editing on demo mode
2025-04-18 15:05:24 -03:00
Herculino Trotta 6cac02e01f fix(users): disables profile editing on demo mode 2025-04-18 15:04:58 -03:00
Dimitri Decrock 8d12ceeebb locale(Dutch): update translation
Currently translated at 100.0% (643 of 643 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-04-14 08:16:53 +00:00
Emil 4681d3ca1d locale(Swedish): update translation
Currently translated at 0.1% (1 of 643 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/sv/
2025-04-14 06:16:53 +00:00
Dimitri Decrock 60ded03ea9 locale(Dutch): update translation
Currently translated at 96.5% (621 of 643 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-04-14 06:16:53 +00:00
Emil b20d137dc3 locale((Swedish)): added translation using Weblate 2025-04-14 05:02:06 +00:00
Herculino Trotta 29ca6eed6c locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (643 of 643 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-04-13 23:16:53 +00:00
eitchtee fa85303f36 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-04-13 22:01:20 +00:00
Herculino Trotta a5f4f43678 Merge pull request #240
feat: user management screen; allow users to edit their profile
2025-04-13 19:00:43 -03:00
Herculino Trotta d807bd5da3 feat: user management screen; allow users to edit their profile 2025-04-13 19:00:25 -03:00
eitchtee 85314fb749 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-04-13 15:49:29 +00:00
Herculino Trotta c4d5e93a41 Merge pull request #239
feat(transactions): add transaction owner to recurring and installments
2025-04-13 12:48:50 -03:00
Herculino Trotta 86f0c4365e feat(transactions): add transaction owner to recurring and installments 2025-04-13 12:48:18 -03:00
Herculino Trotta 202592b940 Merge pull request #238
fix(transactions): recurring transactions not getting created
2025-04-13 12:47:35 -03:00
Herculino Trotta aea149bd13 fix(transactions): recurring transactions not getting created 2025-04-13 12:47:01 -03:00
Dimitri Decrock 411365f101 locale(Dutch): update translation
Currently translated at 100.0% (620 of 620 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-04-13 11:16:53 +00:00
Herculino Trotta 2008476021 locale(Portuguese): update translation
Currently translated at 100.0% (620 of 620 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt/
2025-04-13 08:16:53 +00:00
Herculino Trotta 53afe5b8eb locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (620 of 620 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-04-13 08:16:53 +00:00
Herculino Trotta 6193c7a048 Merge pull request #237
chore: bump deps
2025-04-13 03:57:45 -03:00
Herculino Trotta 41f81d90d7 chore: bump deps 2025-04-13 03:57:27 -03:00
eitchtee bf623cf16b chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-04-13 06:46:15 +00:00
Herculino Trotta ec213330cd Merge pull request #236
feat(insights:category-overview): add bar chart with category totals
2025-04-13 03:45:43 -03:00
Herculino Trotta 7aedf524c6 feat(insights:category-overview): add bar chart with category totals
Closes #231
2025-04-13 03:45:22 -03:00
eitchtee 04602b1964 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-04-13 04:00:29 +00:00
Herculino Trotta 15cfc4f300 Merge pull request #235
locale: add all supported languages by django as an available option
2025-04-13 00:59:51 -03:00
Herculino Trotta 3463c7c62c locale: add all supported languages by django as an available option 2025-04-13 00:58:57 -03:00
Herculino Trotta 7b76c10093 locale(pt): copy strings from pt-br 2025-04-13 00:25:47 -03:00
Herculino Trotta 7ad26a2e7b Merge pull request #234
fix(select): only 50 select options would be shown at a time
2025-04-13 00:03:03 -03:00
Herculino Trotta 7706ca2d5f fix(select): only 50 select options would be shown at a time 2025-04-13 00:02:23 -03:00
Herculino Trotta 56198e93ce locale((Portuguese)): added translation using Weblate 2025-04-13 02:50:39 +00:00
Herculino Trotta a74323f739 locale((English (United Kingdom))): deleted translation using Weblate 2025-04-13 02:50:09 +00:00
Herculino Trotta e4efde177b locale((English (Australia))): deleted translation using Weblate 2025-04-13 02:49:54 +00:00
Herculino Trotta 5871a03ee2 locale((Portuguese)): deleted translation using Weblate 2025-04-13 02:49:10 +00:00
Prefill add-on 67af4430e1 locale(Portuguese): update translation
Currently translated at 0.0% (0 of 618 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt/
2025-04-13 02:40:11 +00:00
Prefill add-on 696dcdf951 locale(English (United Kingdom)): update translation
Currently translated at 0.0% (0 of 618 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/en_GB/
2025-04-13 02:40:10 +00:00
Prefill add-on e35bad0e08 locale(English (Australia)): update translation
Currently translated at 0.0% (0 of 618 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/en_AU/
2025-04-13 02:40:09 +00:00
Prefill add-on 904f7cac22 locale(Spanish): update translation
Currently translated at 0.0% (0 of 618 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/es/
2025-04-13 02:40:09 +00:00
Prefill add-on ccd73963ca locale(French): update translation
Currently translated at 27.6% (171 of 618 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/fr/
2025-04-13 02:40:08 +00:00
Prefill add-on b5469b0413 locale(German): update translation
Currently translated at 98.7% (610 of 618 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/de/
2025-04-13 02:40:07 +00:00
Herculino Trotta dae848d951 locale((Portuguese)): added translation using Weblate 2025-04-13 02:34:18 +00:00
Herculino Trotta 45a33ad0c0 locale((English (United Kingdom))): added translation using Weblate 2025-04-13 02:33:05 +00:00
Herculino Trotta 89e50b17bd locale((English (Australia))): added translation using Weblate 2025-04-13 02:33:05 +00:00
Herculino Trotta ac54ba3da1 Merge pull request #233
fix(cotton): sometimes distribution bars don't get filled completely
2025-04-12 16:33:47 -03:00
Herculino Trotta 2da610f15e fix(cotton): sometimes distribution bars don't get filled completely 2025-04-12 16:33:22 -03:00
valentin-p 4ab6c4c6b6 locale(French): update translation
Currently translated at 27.6% (171 of 618 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/fr/
2025-04-07 08:16:53 +00:00
H4ndrew 68dbedd938 locale(French): update translation
Currently translated at 26.5% (164 of 618 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/fr/
2025-04-03 15:16:53 +00:00
Herculino Trotta 2800c53346 Create FUNDING.yml 2025-04-02 23:03:33 -03:00
Herculino Trotta 132547a074 locale((Spanish)): added translation using Weblate 2025-04-03 01:42:19 +00:00
Herculino Trotta 61ed87dc45 locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (618 of 618 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-04-02 20:16:53 +00:00
H4ndrew 96c1227c4f locale(French): update translation
Currently translated at 24.2% (150 of 618 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/fr/
2025-04-02 17:07:17 +00:00
H4ndrew 33f1ac1785 locale(French): update translation
Currently translated at 19.4% (120 of 618 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/fr/
2025-04-02 15:07:17 +00:00
Dimitri Decrock e9e94a8343 locale(Dutch): update translation
Currently translated at 100.0% (618 of 618 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-04-01 06:07:17 +00:00
Herculino Trotta ba24a53853 Merge pull request #229
feat(demo): add dummy data
2025-04-01 01:06:02 -03:00
Herculino Trotta 4955fbde33 feat(demo): add dummy data 2025-04-01 01:05:28 -03:00
eitchtee d04067a91d chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-04-01 04:05:11 +00:00
Herculino Trotta 01333a439b Merge pull request #228
fix(common:fields:month_year): unable to load data with a date
2025-04-01 01:04:35 -03:00
Herculino Trotta d26907ea94 fix(common:fields:month_year): unable to load data with a date 2025-04-01 01:04:20 -03:00
Herculino Trotta c98d9d3ce9 Update README.md 2025-03-31 03:03:35 -03:00
Herculino Trotta bfa4d3dea3 Merge pull request #226
fix(common:tasks): reset_demo_data not running via cron
2025-03-31 03:02:48 -03:00
Herculino Trotta 90323049eb fix(common:tasks): reset_demo_data not running via cron 2025-03-31 03:02:28 -03:00
Herculino Trotta b62122ed23 Merge pull request #225
fix(app): rename DEMO_MODE variable to DEMO for simplicity
2025-03-31 02:45:28 -03:00
Herculino Trotta f74946cba7 fix(app): rename DEMO_MODE variable to DEMO for simplicity 2025-03-31 02:45:09 -03:00
Herculino Trotta 585652064a locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (617 of 617 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-03-31 05:38:18 +00:00
eitchtee ea6f61d5e4 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-03-31 05:30:21 +00:00
Herculino Trotta e986f7d802 Merge pull request #224
feat: add demo mode and allow for automatic admin creation from env variables
2025-03-31 02:29:44 -03:00
Herculino Trotta 26b218ae51 feat(app): disable API when demo mode is enabled 2025-03-31 02:28:48 -03:00
Herculino Trotta 19f0bc1034 feat(app): show current user e-mail on user menu 2025-03-31 02:28:33 -03:00
Herculino Trotta 47d34f3c27 feat(app): add a demo mode 2025-03-31 02:14:00 -03:00
Herculino Trotta 046e02d506 feat(app): add environment variables to automatically create superuser on startup 2025-03-31 02:11:13 -03:00
valentin-p 92c7a29b6a locale(German): update translation
Currently translated at 99.8% (610 of 611 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/de/
2025-03-30 15:07:17 +00:00
valentin-p d95e5f71cc locale((French)): added translation using Weblate 2025-03-30 13:40:51 +00:00
Herculino Trotta 992c518dab Merge pull request #222
fix(net-worth): non-used currencies showing up on charts
2025-03-23 01:35:14 -03:00
Herculino Trotta 29aa1c9d2b fix(net-worth): non-used currencies showing up on charts 2025-03-23 01:34:53 -03:00
Herculino Trotta 1b3b7a583d Merge pull request #220 from eitchtee/dependabot/pip/gunicorn-23.0.0
chore(deps): bump gunicorn from 22.0.0 to 23.0.0
2025-03-22 01:02:43 -03:00
dependabot[bot] 2d22f961ad chore(deps): bump gunicorn from 22.0.0 to 23.0.0
Bumps [gunicorn](https://github.com/benoitc/gunicorn) from 22.0.0 to 23.0.0.
- [Release notes](https://github.com/benoitc/gunicorn/releases)
- [Commits](https://github.com/benoitc/gunicorn/compare/22.0.0...23.0.0)

---
updated-dependencies:
- dependency-name: gunicorn
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-22 04:01:48 +00:00
Herculino Trotta 71551d7651 Merge pull request #219
fix(insights:category-explorer): category field not loading available categories correctly
2025-03-15 11:37:46 -03:00
Herculino Trotta 62d58d1be3 fix(insights:category-explorer): category field not loading available categories correctly 2025-03-15 11:37:28 -03:00
Herculino Trotta 21917437f2 Merge pull request #218
fix(tools:currency-converter): currency list displaying oldest result instead of newest
2025-03-13 22:18:21 -03:00
Herculino Trotta 59acb14d05 fix(tools:currency-converter): currency list displaying oldest result instead of newest 2025-03-13 22:18:02 -03:00
Dimitri Decrock 050f794f2b locale(Dutch): update translation
Currently translated at 100.0% (611 of 611 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-03-13 07:05:35 +00:00
Schmitz Schmitz a5958c0937 locale(German): update translation
Currently translated at 99.6% (609 of 611 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/de/
2025-03-10 09:05:35 +00:00
Herculino Trotta ee73ada5ae Merge pull request #215
fix: missing selection when updating transactions in a transaction list
2025-03-09 20:22:18 -03:00
Herculino Trotta 736a116685 fix: missing selection when updating transactions in a transaction list 2025-03-09 20:21:48 -03:00
Herculino Trotta 6c03c7b4eb locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (611 of 611 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-03-09 23:10:59 +00:00
eitchtee 960e537709 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-03-09 21:56:43 +00:00
Herculino Trotta e32285ce75 Merge pull request #214
feat: alphabetically order most models by default
2025-03-09 18:56:10 -03:00
Herculino Trotta 73e8fdbf04 feat: alphabetically order most models by default
#207
2025-03-09 18:55:29 -03:00
Herculino Trotta d4c15da051 Merge pull request #212
feat(monthly_overview): preserve filter between month changes
2025-03-09 18:46:38 -03:00
Herculino Trotta 187b3174d2 feat(monthly_overview): preserve filter between month changes
#208
2025-03-09 18:45:55 -03:00
eitchtee c90ea7ef16 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-03-09 20:55:47 +00:00
Herculino Trotta 54713ecfe2 Merge pull request #211
fix(transactions:transfer): remove required description field
2025-03-09 17:55:15 -03:00
Herculino Trotta cf693aa0c3 fix(transactions:transfer): remove required description field
#209
2025-03-09 17:54:16 -03:00
eitchtee 3580f1b132 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-03-09 20:51:39 +00:00
Herculino Trotta febd9a8ae7 Merge pull request #210
feat(transactions): add option for removing Recurring/Installment descriptions and notes from generated transactions
2025-03-09 17:50:59 -03:00
Herculino Trotta 3809f82b60 feat(transactions): add option for removing Recurring/Installment descriptions and notes from generated transactions
#209
2025-03-09 17:50:27 -03:00
Dimitri Decrock 3c6b52462a locale(Dutch): update translation
Currently translated at 100.0% (609 of 609 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-03-09 12:05:35 +00:00
Herculino Trotta cc8a4c97a9 locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (609 of 609 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-03-09 04:56:21 +00:00
eitchtee 99fbb5f7db chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-03-09 04:55:05 +00:00
Herculino Trotta 3d61068ecf Merge pull request #206
feat(rules): trigger transaction rules on delete
2025-03-09 01:54:28 -03:00
Herculino Trotta f6f06f4d65 feat(rules): trigger transaction rules on delete 2025-03-09 01:54:03 -03:00
eitchtee 56346c26ee chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-03-09 04:27:30 +00:00
Herculino Trotta 23b74d73e5 Merge pull request #205
fix(rules): unable to save
2025-03-09 01:26:57 -03:00
Herculino Trotta 17697dc565 fix(rules): unable to save 2025-03-09 01:26:42 -03:00
Herculino Trotta e9bc35d9b2 Merge pull request #204
fix(api): re-order transactions from newest to oldest
2025-03-08 23:23:25 -03:00
Herculino Trotta d6fbb71f41 fix(api): re-order transactions from newest to oldest 2025-03-08 23:23:07 -03:00
eitchtee 9a9cf75bcd chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-03-09 01:10:53 +00:00
Herculino Trotta d6a8658fe1 Merge pull request #203
fix(api): unable to create transaction
2025-03-08 22:09:40 -03:00
Herculino Trotta 211963ea7d fix(api): unable to create transaction 2025-03-08 22:09:24 -03:00
Herculino Trotta 776068a438 locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (608 of 608 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-03-08 18:05:35 +00:00
Herculino Trotta 621799f445 locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (608 of 608 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-03-08 16:05:41 +00:00
eitchtee 124d29e965 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-03-08 15:04:27 +00:00
Herculino Trotta bf4d23f15e Merge pull request #202
feat: multi tenancy support
2025-03-08 12:03:54 -03:00
Herculino Trotta 020dd74f80 feat: multi tenancy support 2025-03-08 12:03:17 -03:00
Herculino Trotta c7d70a1748 locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (592 of 592 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-03-04 06:46:49 +00:00
Herculino Trotta 1025b80dda Merge remote-tracking branch 'weblate/main'
# Conflicts:
#	app/locale/nl/LC_MESSAGES/django.po
2025-03-04 03:39:49 -03:00
Dimitri Decrock 1ae245fe01 locale(Dutch): update translation
Currently translated at 100.0% (585 of 585 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-03-04 06:28:49 +00:00
eitchtee 46c5efb8a9 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-03-04 06:28:46 +00:00
Herculino Trotta abb0993435 ci: update translations.yml
[skip ci]
2025-03-04 03:26:45 -03:00
Herculino Trotta a9e7692f99 ci: update translations.yml
[skip ci]
2025-03-04 03:22:06 -03:00
Herculino Trotta 531571798a Update translations.yml
[skip ci]
2025-03-04 03:00:00 -03:00
Herculino Trotta 7282aa20ee ci: disable concurrency for release pipeline
[skip ci]
2025-03-04 02:59:18 -03:00
Herculino Trotta 13f9950afa Update translations.yml 2025-03-04 02:54:21 -03:00
Herculino Trotta 672cc5ebc7 Merge pull request #201
feat(insights): add Emergency Fund simulator
2025-03-04 02:52:52 -03:00
Herculino Trotta 8045e2c73a ci: automatically generate translation files 2025-03-04 02:50:46 -03:00
Herculino Trotta 7c042d9299 feat(insights): add Emergency Fund simulator 2025-03-04 02:42:07 -03:00
Herculino Trotta aba47f0eed locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (585 of 585 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-03-02 02:08:10 +00:00
Herculino Trotta 2010ccc92d locale(Dutch): update translation
Currently translated at 100.0% (585 of 585 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-03-02 02:08:10 +00:00
Herculino Trotta d73d6cbf22 locale(German): update translation
Currently translated at 100.0% (585 of 585 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/de/
2025-03-02 02:08:09 +00:00
Herculino Trotta e5a9b6e921 locale: update strings 2025-03-01 23:07:04 -03:00
Herculino Trotta dbd9774681 Merge pull request #198 from eitchtee/dev
fix(automatic-exchange-rates): unable to set 24 hour interval
2025-03-01 23:05:37 -03:00
Herculino Trotta 5a93a907e1 fix(automatic-exchange-rates): unable to set 24 hour interval 2025-03-01 23:05:14 -03:00
Schmitz Schmitz e0e159166b locale(German): update translation
Currently translated at 100.0% (585 of 585 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/de/
2025-03-02 01:59:16 +00:00
Herculino Trotta 6c7594ad14 Merge pull request #197 from eitchtee/dev
feat(automatic-exchange-rates): add Transitive rate provider
2025-03-01 22:59:00 -03:00
Herculino Trotta d3ea0e43da feat(automatic-exchange-rates): add Transitive rate provider 2025-03-01 22:58:33 -03:00
Herculino Trotta dde75416ca Merge pull request #196
feat(automatic-exchange-rates): add Synth Finance Stock
2025-03-01 22:41:12 -03:00
Herculino Trotta c9b346b791 feat(automatic-exchange-rates): add Synth Finance Stock 2025-03-01 22:40:50 -03:00
Dimitri Decrock 9896044a15 locale(Dutch): update translation
Currently translated at 100.0% (585 of 585 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-03-01 03:01:20 +00:00
Herculino Trotta eb65eb4590 add translation info on readme 2025-02-28 00:30:00 -03:00
Herculino Trotta 017c70e8b2 locale((Portuguese)): deleted translation using Weblate 2025-02-28 03:04:29 +00:00
Herculino Trotta 64b0830909 locale((Portuguese)): added translation using Weblate 2025-02-28 03:03:27 +00:00
Herculino Trotta 25d99cbece Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (585 of 585 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-02-28 02:37:39 +00:00
Herculino Trotta 033f0e1b0d Merge pull request #195
feat(insights): add Categories Overview
2025-02-27 23:33:25 -03:00
Herculino Trotta 35027ee0ae feat(insights): add Categories Overview
Closes #94
2025-02-27 23:33:05 -03:00
Herculino Trotta 91904e959b Merge pull request #194
locale(de): update translation - thanks to @CocaCola2701
2025-02-25 10:53:25 -03:00
Herculino Trotta a6a85ae3a2 locale(de): update translation - thanks to @CocaCola2701 2025-02-25 10:53:08 -03:00
Herculino Trotta b0f53f45f9 Merge pull request #193
fix(rules): Update or Create Transaction rule unable to match againt dates and other types
2025-02-25 10:49:01 -03:00
Herculino Trotta 0f60f8d486 fix(rules): Update or Create Transaction rule unable to match againt dates and other types 2025-02-25 10:48:43 -03:00
Herculino Trotta efb207a109 Merge pull request #191 from eitchtee/dev
locale: add en
2025-02-24 23:07:14 -03:00
Herculino Trotta 95b1481dd5 locale: add en 2025-02-24 23:06:15 -03:00
Herculino Trotta 8de340b68b Merge pull request #190
locale(de): enable Deutsch
2025-02-24 16:34:50 -03:00
Herculino Trotta ef15b85386 fix(locale): transactions quick search placeholder is not translatable 2025-02-24 16:34:05 -03:00
Herculino Trotta 45d939237d locale(de): enable Deutsch 2025-02-24 16:33:14 -03:00
Herculino Trotta 6bf262e514 Merge pull request #189
style(transactions): improve look on wider columns
2025-02-22 23:21:45 -03:00
Herculino Trotta f9d9137336 style(transactions): improve look on wider columns 2025-02-22 23:21:28 -03:00
Herculino Trotta b532521f27 Merge pull request #188 from DragonHeart69/main
update dutch to V0.11.3
2025-02-22 23:17:11 -03:00
Dimitri Decrock 1e06e2d34d update dutch to V0.11.3 2025-02-22 15:04:47 +01:00
Herculino Trotta a33fa5e184 Merge pull request #187 from eitchtee/dev
style(transactions): improve look on wider columns
2025-02-22 01:41:27 -03:00
Herculino Trotta a2453695d8 style(transactions): improve look on wider columns 2025-02-22 01:41:02 -03:00
Herculino Trotta 3e929d0433 Merge pull request #186
style(transactions): improve look on wider columns
2025-02-22 01:18:35 -03:00
Herculino Trotta 185fc464a5 style(transactions): improve look on wider columns 2025-02-22 01:18:20 -03:00
Herculino Trotta 647c009525 Merge pull request #185
fix(insights:latest-transactions): order transactions from newest to oldest
2025-02-22 01:02:56 -03:00
Herculino Trotta ba75492dcc fix(insights:latest-transactions): order transactions from newest to oldest 2025-02-22 01:02:35 -03:00
Herculino Trotta 8312baaf45 Merge pull request #184
feat(tools:currency-converter): show 1:1 rates for all available currencies
2025-02-20 23:48:32 -03:00
Herculino Trotta 4d346dc278 feat(tools:currency-converter): show 1:1 rates for all available currencies 2025-02-20 23:48:08 -03:00
Herculino Trotta 70ff7fab38 Merge pull request #183 from eitchtee/dev
feat(insights): add late and recent transactions
2025-02-19 23:07:51 -03:00
Herculino Trotta 6947c6affd feat(insights): add late and recent transactions 2025-02-19 23:07:28 -03:00
Herculino Trotta dcab83f936 Merge pull request #182
fix(insights:category-explorer): wrong sums
2025-02-19 16:02:14 -03:00
Herculino Trotta b228e4ec26 fix(insights:category-explorer): wrong sums 2025-02-19 16:01:53 -03:00
Herculino Trotta 4071a1301f Merge pull request #181 from eitchtee/dev
fix(export): unable to import decimals
2025-02-19 15:44:50 -03:00
Herculino Trotta 5c9db10710 fix(export): unable to import decimals 2025-02-19 15:44:18 -03:00
Herculino Trotta 19c92e0014 Merge pull request #180
fix(export): 403 when exporting
2025-02-19 14:02:52 -03:00
Herculino Trotta 6459f2eb46 fix(export): 403 when exporting 2025-02-19 14:02:31 -03:00
Herculino Trotta 7926e081ef locale: update locales 2025-02-19 13:50:45 -03:00
Herculino Trotta ceefe7075f locale: update locales 2025-02-19 13:48:54 -03:00
Herculino Trotta ad3230fd83 Merge pull request #179 from eitchtee/export
feat: export and restore
2025-02-19 13:41:53 -03:00
Herculino Trotta c89b07ed93 Merge branch 'main' into export 2025-02-19 13:41:04 -03:00
Herculino Trotta 201ccea842 feat: export (WIP) 2025-02-19 13:38:00 -03:00
Herculino Trotta 32ada488b4 Merge pull request #178
feat(transactions:actions): select all only selects displayed transactions
2025-02-19 09:08:06 -03:00
Herculino Trotta 794d11a355 feat(transactions:actions): select all only selects displayed transactions 2025-02-19 09:07:49 -03:00
Herculino Trotta 67f8f5fe89 Merge pull request #177
fix(transactions:actions): sum considers everything an expense
2025-02-19 09:00:02 -03:00
Herculino Trotta 9ac69fd92a fix(transactions:actions): sum considers everything an expense 2025-02-19 08:59:30 -03:00
Herculino Trotta 069f1b450c feat: export (WIP) 2025-02-19 08:51:33 -03:00
Herculino Trotta 2f388af928 Merge pull request #176
feat(insights): make sidebar sticky
2025-02-18 21:04:36 -03:00
Herculino Trotta beeb0579ce feat(insights): make sidebar sticky 2025-02-18 21:04:09 -03:00
Herculino Trotta a8666da57b Merge pull request #175
feat(insights:category-explorer): separate current and projected totals
2025-02-18 20:46:28 -03:00
Herculino Trotta 835316d0f3 feat(insights:category-explorer): separate current and projected totals 2025-02-18 20:46:06 -03:00
Herculino Trotta f5feeb9617 Merge pull request #174
feat(insights:category-explorer): allow for uncategorized totals
2025-02-18 20:45:24 -03:00
Herculino Trotta 09e380a480 feat(insights:category-explorer): allow for uncategorized totals 2025-02-18 20:45:07 -03:00
Herculino Trotta 3080df9b66 feat: export (WIP) 2025-02-18 19:55:12 -03:00
Herculino Trotta ebc41a8049 Merge pull request #173 from eitchtee/insights
fix(insights): error if filter is empty
2025-02-17 21:49:00 -03:00
Herculino Trotta 635628e30e fix(insights): error if filter is empty 2025-02-17 21:48:33 -03:00
Herculino Trotta 819a58ac06 Merge pull request #172
feat(datepicker): disable input and fix toggling dates
2025-02-17 21:37:16 -03:00
Herculino Trotta d433375522 feat(datepicker): disable input and fix toggling dates 2025-02-17 21:36:11 -03:00
Herculino Trotta c0150f71a8 Merge pull request #171 from eitchtee/insights
fix(insights:category-explorer): silent categories can't be displayed
2025-02-17 10:43:12 -03:00
Herculino Trotta 6119698d38 fix(insights:category-explorer): silent categories can't be displayed 2025-02-17 10:42:38 -03:00
Herculino Trotta f5ae231601 Merge pull request #170
feat(insights:category-explorer): add empty message when there's no data or no category selected
2025-02-17 10:28:55 -03:00
Herculino Trotta 972d23abbd feat(insights:category-explorer): add empty message when there's no data or no category selected 2025-02-17 10:28:37 -03:00
Herculino Trotta 9a514a8a69 Merge pull request #169
refactor(insights:flows): improve readability when there's a lot of nodes
2025-02-17 10:21:36 -03:00
Herculino Trotta 7325231548 refactor(insights:flows): improve readability when there's a lot of nodes 2025-02-17 10:21:18 -03:00
Herculino Trotta 570657371a Merge pull request #168
fix(insights:category-explorer): use currency name instead of code
2025-02-16 19:34:15 -03:00
Herculino Trotta 67da60b5b0 fix(insights:category-explorer): use currency name instead of code 2025-02-16 19:33:58 -03:00
Herculino Trotta 84c047c5ab Merge pull request #167 from eitchtee/insights
insights
2025-02-16 13:06:03 -03:00
Herculino Trotta 23f5d09bec locale: update locales 2025-02-16 13:05:35 -03:00
Herculino Trotta 2a19075e23 Merge pull request #166
feat(insights): category explorer
2025-02-16 13:03:20 -03:00
Herculino Trotta 7f231175b2 feat(insights): category explorer 2025-02-16 13:03:02 -03:00
Herculino Trotta 062e84f864 Merge pull request #165
fix(insights): sankey diagrams nodes too far from destination
2025-02-16 02:25:45 -03:00
Herculino Trotta 5521eb20bf fix(insights): sankey diagrams nodes too far from destination 2025-02-16 02:25:29 -03:00
Herculino Trotta 627b5d250b Merge pull request #164
feat: insights page
2025-02-16 00:14:56 -03:00
Herculino Trotta 195a8a68d6 feat: insight page 2025-02-16 00:14:23 -03:00
Herculino Trotta daf1f68b82 Merge remote-tracking branch 'origin/insights' into insights 2025-02-15 00:49:25 -03:00
Herculino Trotta dd24fd56d3 insights (wip) 2025-02-15 00:49:00 -03:00
Herculino Trotta 7a2acb6497 fix(insights): sankey diagram inconsistent sizing 2025-02-15 00:48:59 -03:00
Herculino Trotta 9c339faa72 chore(frontend): install chartjs-chart-sankey 2025-02-15 00:48:59 -03:00
Herculino Trotta 02376ad02b feat(insights): sankey diagram (WIP) 2025-02-15 00:48:59 -03:00
Herculino Trotta b53a4a0286 feat(insights): create app 2025-02-15 00:48:59 -03:00
Herculino Trotta a1f618434b Merge pull request #163 from eitchtee/dca_improvements
feat(dca): link transactions to DCA
2025-02-15 00:43:07 -03:00
Herculino Trotta 7b5be29f0d locale: update locales 2025-02-15 00:42:38 -03:00
Herculino Trotta 56a73b181a Merge remote-tracking branch 'origin/main' into dca_improvements
# Conflicts:
#	app/locale/nl/LC_MESSAGES/django.po
2025-02-15 00:41:49 -03:00
Herculino Trotta 865618e054 feat(dca): link transactions to DCA 2025-02-15 00:41:06 -03:00
Herculino Trotta 9e912b2736 locale: update locales 2025-02-15 00:40:44 -03:00
Herculino Trotta da7680e70f Merge pull request #159 from DragonHeart69/main
update NL to version 0.9.4
2025-02-14 10:20:40 -03:00
Herculino Trotta ab594eb511 Merge pull request #162
fix(style): selecting transaction no longer highlights it
2025-02-14 00:50:30 -03:00
Herculino Trotta cffaaa369a fix(style): selecting transaction no longer highlights it 2025-02-14 00:50:01 -03:00
Herculino Trotta 5f414e82ee Merge pull request #161
feat(internal): trigger rules on bulk actions
2025-02-14 00:35:10 -03:00
Herculino Trotta f3bcef534e feat(internal): trigger rules on bulk actions 2025-02-14 00:34:51 -03:00
Herculino Trotta d140ff5b70 Merge pull request #160
fix(frontend): loading indicator on empty div too close to the top
2025-02-14 00:04:03 -03:00
Herculino Trotta 7eceacfe68 fix(frontend): loading indicator on empty div too close to the top 2025-02-14 00:03:43 -03:00
Herculino Trotta 038438fba7 insights (wip) 2025-02-12 09:48:31 -03:00
Dimitri Decrock ee98a5ef12 update NL to version 0.9.4 2025-02-12 06:59:28 +01:00
Herculino Trotta 28b12faaf0 fix(insights): sankey diagram inconsistent sizing 2025-02-11 00:40:37 -03:00
Herculino Trotta d0f2742637 chore(frontend): install chartjs-chart-sankey 2025-02-11 00:37:48 -03:00
Herculino Trotta 9c55dac866 feat(insights): sankey diagram (WIP) 2025-02-11 00:37:30 -03:00
Herculino Trotta e6d8b548b7 Merge pull request #157
fix(docker): procrastinate can't recover if it crashes in a running instance
2025-02-10 23:13:33 -03:00
Herculino Trotta 4f8c2215c1 fix(docker): procrastinate can't recover if it crashes in a running instance 2025-02-10 23:13:16 -03:00
Herculino Trotta 851b34f07a Merge pull request #156 from eitchtee/dev
fix(transactions): paying transaction doesn't trigger update rules
2025-02-09 23:38:58 -03:00
Herculino Trotta 546ed5c6af fix(transactions): bulk (un)paying transactions doesn't trigger update rules 2025-02-09 23:38:22 -03:00
Herculino Trotta 04ae7337f5 fix(transactions): paying transaction doesn't trigger update rules 2025-02-09 23:33:57 -03:00
Herculino Trotta a3a8791e96 feat(insights): create app 2025-02-09 23:00:33 -03:00
Herculino Trotta 63069f0ec9 Merge pull request #155 from eitchtee/dev
refactor: don't display currency code
2025-02-09 19:50:09 -03:00
Herculino Trotta 32b522dad2 refactor: don't display currency code 2025-02-09 19:49:47 -03:00
Herculino Trotta 0c20a079e3 Merge pull request #154 from eitchtee/dev
locale: update locales
2025-02-09 17:31:03 -03:00
Herculino Trotta 7c9697f683 locale: update locales 2025-02-09 17:30:39 -03:00
Herculino Trotta 15d04230ae Merge pull request #153
feat(monthly): add quick-search field
2025-02-09 17:14:44 -03:00
Herculino Trotta ecc09ca6a6 feat(monthly): add quick-search field 2025-02-09 17:14:25 -03:00
Herculino Trotta cd753c5dd5 Merge pull request #152 from luzpaz/readme-typos
fix: typos in README
2025-02-09 10:55:54 -03:00
luzpaz a3b9952f80 fix: typos in README
Found via `codespell -q 3 -S "*.po" -L bu,nome,vew`
2025-02-09 09:47:03 +00:00
Herculino Trotta e93969c035 Merge pull request #151
feat(import:v1): add XLS and XLSX support
2025-02-09 00:51:46 -03:00
Herculino Trotta 6ec5b5df1e feat(import:v1): add XLS and XLSX support
Closes #47
2025-02-09 00:51:26 -03:00
Herculino Trotta 93e7adeea8 Merge pull request #150 from eitchtee/dev
feat(import): add Cajamar preset
2025-02-09 00:50:38 -03:00
Herculino Trotta 37b5a43c1f feat(import): add Cajamar preset
Thanks to Pablo Hinojosa for sharing his file
2025-02-09 00:50:11 -03:00
Herculino Trotta 87a07c25d1 Merge pull request #149
feat(import:v1): add "add" and "subtract" transformations
2025-02-08 18:30:25 -03:00
Herculino Trotta 9e27fef5e5 feat(import:v1): add "add" and "subtract" transformations 2025-02-08 18:30:06 -03:00
Herculino Trotta 2cbba53e06 Merge pull request #148
feat(import:v1): allow to source previously mapped data by prefixing it with "__" on transformations
2025-02-08 16:38:57 -03:00
Herculino Trotta d9e8be7efb feat(import:v1): allow to source previously mapped data by prefixing it with "__" on transformations 2025-02-08 16:38:36 -03:00
Herculino Trotta 7dc9ef9950 Merge pull request #147 from eitchtee/dev
refactor(import:v1): remove forced "required" from some fields
2025-02-08 16:36:48 -03:00
Herculino Trotta 00e83cf6a2 refactor(import:v1): remove forced "required" from some fields 2025-02-08 16:35:46 -03:00
Herculino Trotta 039242b48a Merge pull request #146 from eitchtee/dev
fix(dev): django-browser-reload not working
2025-02-08 16:01:06 -03:00
Herculino Trotta 94e2bdf93d fix(dev): django-browser-reload not working 2025-02-08 16:00:45 -03:00
Herculino Trotta 79b387ce60 Merge pull request #145 from eitchtee/dev
feat(import:v1): allow to source previously mapped data by prefixing it with "__"
2025-02-08 15:59:56 -03:00
Herculino Trotta 43eb87d3ba feat(import:v1): allow to source previously mapped data by prefixing it with "__" 2025-02-08 15:59:27 -03:00
Herculino Trotta 0110220b72 Merge pull request #144 from eitchtee/dev
feat: account and currency cards will no longer display unneeded zeros, only for totals
2025-02-08 11:43:24 -03:00
Herculino Trotta f5c86f3d97 feat: account and currency cards will no longer display unneeded zeros, only for totals 2025-02-08 11:42:46 -03:00
Herculino Trotta 7b7f58d34d Merge pull request #143 from eitchtee/dev
fix(logging): procrastinate job logs not showing up
2025-02-08 04:19:03 -03:00
Herculino Trotta 86112931d9 fix(logging): procrastinate job logs not showing up 2025-02-08 04:18:33 -03:00
Herculino Trotta e6e0e4caea Merge pull request #142
feat(rules): add Update or Create Transaction action
2025-02-08 04:18:00 -03:00
Herculino Trotta 942154480e feat(rules): add Update or Create Transaction action 2025-02-08 04:17:28 -03:00
Herculino Trotta 467131d9f1 feat(rules): add Update or Create Transaction action 2025-02-08 04:16:28 -03:00
Herculino Trotta fee1db8660 Merge pull request #141
fix(automatic-exchange-rates): skipping hours due to minutes
2025-02-07 14:34:58 -03:00
Herculino Trotta 4f7fc1c9c8 fix(automatic-exchange-rates): skipping hours due to minutes 2025-02-07 14:34:38 -03:00
Herculino Trotta f788709f97 Merge pull request #140
automatic exchange rates
2025-02-07 11:49:25 -03:00
Herculino Trotta 1a0de32ef8 locale: update locales 2025-02-07 11:46:57 -03:00
Herculino Trotta 8315adeb4a fix(automatic-exchange-rates): 1-24 should be 0-23 2025-02-07 11:46:33 -03:00
Herculino Trotta 5296820d46 refactor(automatic-exchange-rates): replace fetch_interval with fetch interval type and fetch interval 2025-02-07 11:40:37 -03:00
Herculino Trotta d5f5053821 Merge pull request #139 from eitchtee/dev
feat: cleanup and format logs
2025-02-07 11:31:40 -03:00
Herculino Trotta 852ffd5634 feat: cleanup and format logs 2025-02-07 11:31:14 -03:00
Herculino Trotta 8cb3f51ea4 Merge pull request #138
feat: add TZ env var
2025-02-07 11:29:48 -03:00
Herculino Trotta 62bfaaa62a feat: add TZ env var 2025-02-07 11:29:28 -03:00
Herculino Trotta dd1d4292d3 Merge pull request #137
automatic_exchange_rate
2025-02-06 21:48:29 -03:00
Herculino Trotta 93bb34166e feat(ui): auto-resize textareas when typing 2025-02-06 21:40:04 -03:00
Herculino Trotta 8f311d9924 Add Unraid setup details 2025-02-05 15:24:00 -03:00
Herculino Trotta a5a9f838f5 Merge pull request #135
fix(docker:single): procrastinate starts before django
2025-02-05 10:52:47 -03:00
Herculino Trotta 6c17b3babb fix(docker:single): procrastinate starts before django 2025-02-05 10:52:21 -03:00
Herculino Trotta d207760ae9 feat(currencies): add automatic exchange rate fetching
Closes #123
2025-02-05 10:16:04 -03:00
Herculino Trotta 996e0ee0eb Merge pull request #133
fix(transactions): transaction convert value doesn't take into account currency's exchange currency
2025-02-03 00:30:42 -03:00
Herculino Trotta 80edf557cb fix(transactions): transaction convert value doesn't take into account currency's exchange currency
account takes precedence
2025-02-03 00:30:26 -03:00
Herculino Trotta 2f3207b1f6 Merge pull request #132 from eitchtee/dev
refactor(currencies): remove currency's code reference in the UI
2025-02-03 00:28:53 -03:00
Herculino Trotta 7b95c806fb refactor(currencies): remove currency's code reference in the UI 2025-02-03 00:28:21 -03:00
Herculino Trotta 06e9383689 Merge pull request #131
refactor(currencies): make currency code non-unique and increase it's size
2025-02-03 00:27:31 -03:00
Herculino Trotta 56862cd025 refactor(currencies): make currency code non-unique and increase it's size 2025-02-03 00:27:11 -03:00
Herculino Trotta 35782cf14c Merge pull request #130
feat: internal code for automatic exchange rate fetching
2025-02-03 00:26:19 -03:00
Herculino Trotta f7768c8658 feat: internal code for automatic exchange rate fetching 2025-02-03 00:26:00 -03:00
Herculino Trotta 7f8fe6a516 Merge pull request #129
fix: unable to display exchange projected income value
2025-02-03 00:20:15 -03:00
Herculino Trotta aa8abe0e1c fix: unable to display exchange projected income value 2025-02-03 00:20:00 -03:00
Herculino Trotta 3190f3ae09 Merge pull request #128
fix: changing startpage to networth breaks homepage
2025-02-02 00:05:19 -03:00
Herculino Trotta 757f6647da fix: changing startpage to networth breaks homepage 2025-02-02 00:04:45 -03:00
Herculino Trotta 6721d9dfee Merge pull request #127
feat: indicate what paid/project button means
2025-02-01 19:06:23 -03:00
Herculino Trotta 9705441e2d feat: indicate what paid/project button means
Closes #122
2025-02-01 19:06:04 -03:00
Herculino Trotta 7123aefad0 Merge pull request #126 from eitchtee/dev
feat: indicate what paid/project button means
2025-02-01 15:05:26 -03:00
Herculino Trotta 712f5f428e feat: indicate what paid/project button means 2025-02-01 15:04:58 -03:00
Herculino Trotta a2e97b4ba2 Merge pull request #125
fix: changing startpage from monthly breaks homepage
2025-02-01 15:00:22 -03:00
Herculino Trotta 60a694635b fix: changing startpage from monthly breaks homepage
Fixes #121
2025-02-01 14:59:55 -03:00
Herculino Trotta 877816b649 Merge pull request #120
feat: add trash can to see deleted transactions
2025-02-01 11:13:18 -03:00
Herculino Trotta 0a3e47819a feat: add trash can to see deleted transactions 2025-02-01 11:12:43 -03:00
Herculino Trotta f9d299cb78 refactor: remove single 2025-02-01 09:43:48 -03:00
Herculino Trotta 52934124c1 Merge pull request #118 from eitchtee/dev
feat: add account and currency info to monthly view
2025-02-01 00:51:41 -03:00
Herculino Trotta 39c1f634b6 feat: add account and currency info to monthly view 2025-02-01 00:51:16 -03:00
Herculino Trotta fee5b93cea Merge pull request #117
fix: empty strings not considered as None when importing
2025-01-31 16:54:34 -03:00
Herculino Trotta a7d8f94412 fix: empty strings not considered as None when importing 2025-01-31 16:54:04 -03:00
Herculino Trotta 44b87da423 Merge pull request #115
feat: expose current version
2025-01-31 11:15:35 -03:00
Herculino Trotta 85794f5c01 feat: expose current version 2025-01-31 11:15:15 -03:00
Herculino Trotta f246d115e2 Merge pull request #114 from eitchtee/dev
ci: allow for manual custom docker release
2025-01-31 01:31:36 -03:00
Herculino Trotta aae85ecf94 ci: allow for manual custom docker release 2025-01-31 01:31:09 -03:00
Herculino Trotta ec911c0085 Merge pull request #113 from eitchtee/dev
feat: gracefully handle bigger title on info cards
2025-01-31 01:20:09 -03:00
Herculino Trotta 7b77f6f363 feat: gracefully handle bigger title on info cards 2025-01-31 01:19:28 -03:00
Herculino Trotta 239e9c4b2a Merge pull request #112
feat: turn quick transactions buttons in a component and gracefully handle buttons w/ long text
2025-01-31 01:13:06 -03:00
Herculino Trotta 5abd0b8d3c feat: turn quick transactions buttons in a component and gracefully handle buttons w/ long text 2025-01-31 01:12:45 -03:00
Herculino Trotta 320217f64a Remove procrastinate name from .env 2025-01-30 14:47:13 -03:00
Herculino Trotta 2735906d5e Update README.md 2025-01-30 14:45:24 -03:00
Herculino Trotta 1f03edcc2e Update README.md 2025-01-30 14:43:55 -03:00
Herculino Trotta 1405976292 Update README.md 2025-01-30 12:22:20 -03:00
Herculino Trotta 6a06d0ee88 Update README.md 2025-01-30 11:26:44 -03:00
Herculino Trotta 49c17f75b4 Merge pull request #111 from eitchtee/eitchtee-patch-1
Update README.md
2025-01-30 11:00:07 -03:00
Herculino Trotta 2ff6d69fac Update README.md 2025-01-30 10:59:49 -03:00
Herculino Trotta 3023f33d3d Merge pull request #110
fix: 'tags__id' does not resolve to an item that supports prefetching
2025-01-30 00:26:40 -03:00
Herculino Trotta b5671fcd0e fix: 'tags__id' does not resolve to an item that supports prefetching 2025-01-30 00:26:07 -03:00
Herculino Trotta 48408cead8 fix: 'tags__id' does not resolve to an item that supports prefetching 2025-01-30 00:22:37 -03:00
Herculino Trotta cd7ecd42ea Merge pull request #109
feat: allow for a subset of markdown (bold, italics, strikethrough, links) when displaying notes
2025-01-29 13:53:09 -03:00
Herculino Trotta 0b83ad6b3e feat: allow for a subset of markdown (bold, italics, strikethrough, links) when displaying notes 2025-01-29 13:52:46 -03:00
Herculino Trotta d0ef08252e Merge pull request #108
feat: improve transactions list loading time
2025-01-29 13:47:05 -03:00
Herculino Trotta 1140d9c896 feat: improve transactions list loading time
Prefetch more values and allow them to be cached
2025-01-29 13:46:06 -03:00
Herculino Trotta b2843a1ec1 Merge pull request #106 from DragonHeart69/main
Small change in Dutch translation
2025-01-29 08:40:31 -03:00
Dimitri Decrock d25aba7be9 small change to number format again 2025-01-29 06:12:54 +01:00
Dimitri Decrock c3eaca3e9a Merge branch 'eitchtee:main' into main 2025-01-29 06:10:17 +01:00
Herculino Trotta 5677706452 Merge pull request #105
fix: unable to load transactions on first login
2025-01-29 00:56:22 -03:00
Herculino Trotta 5bf7f9f272 fix: unable to load transactions on first login 2025-01-29 00:56:06 -03:00
Herculino Trotta 448841dadc Merge pull request #104 from eitchtee/dev
fix: wrong filename
2025-01-29 00:15:32 -03:00
Herculino Trotta 1b6934694e fix: wrong filename 2025-01-29 00:14:45 -03:00
Herculino Trotta d4d00ba02f Merge pull request #103 from eitchtee/dev
feat: reduce db queries when saving order on session
2025-01-29 00:14:18 -03:00
Herculino Trotta 19a65ac45f feat: reduce db queries when saving order on session 2025-01-29 00:12:47 -03:00
Herculino Trotta b72e7bd707 Merge pull request #102
docker: set single container as new default
2025-01-29 00:12:40 -03:00
Herculino Trotta 190be3e813 docker: set single container as new default 2025-01-29 00:11:39 -03:00
Herculino Trotta 88300b314c Merge pull request #101 from eitchtee/eitchtee-patch-1
Update release.yml
2025-01-28 23:47:34 -03:00
Herculino Trotta fab77c8d9f Update release.yml 2025-01-28 23:47:18 -03:00
Herculino Trotta 1ae7158d7e Merge pull request #100 from eitchtee/dev
docker: fix permission error
2025-01-28 23:46:11 -03:00
Herculino Trotta 05f0356288 docker: fix permission error 2025-01-28 23:45:01 -03:00
Herculino Trotta b3cea17b8d Merge pull request #99
docker: add single-container support
2025-01-28 23:35:08 -03:00
Herculino Trotta 0b66b23f16 docker: add single-container support 2025-01-28 23:34:48 -03:00
Herculino Trotta 80fdf70f7d Add a nightly docker tag built whenever there's a push to main 2025-01-28 23:13:23 -03:00
Herculino Trotta fa931b0db2 Merge pull request #98
feat: cleanup expired sessions every first day of month at 6am
2025-01-28 21:33:00 -03:00
Herculino Trotta cab79b4203 feat: cleanup expired sessions every first day of month at 6am 2025-01-28 21:32:41 -03:00
Herculino Trotta ddab3db6b5 Merge pull request #97
feat(import:v1): accept list as source, first valid one will be used.
2025-01-28 21:24:44 -03:00
Herculino Trotta 9fa704811c feat(import:v1): accept list as source, first valid one will be used. 2025-01-28 21:24:23 -03:00
Herculino Trotta 4c0d14def0 Merge pull request #96
feat: store selected "order by" on session
2025-01-28 20:05:46 -03:00
Herculino Trotta 43382d2ffe feat: store selected "order by" on session
Closes #95
2025-01-28 20:05:00 -03:00
Dimitri Decrock 65ad51c273 smal change to number format 2025-01-28 19:16:52 +01:00
Herculino Trotta 27d448afd6 feat: add locale files for de (german) 2025-01-28 14:03:38 -03:00
Herculino Trotta 1dd90974bd Merge pull request #93
refactor: remove toasts from login screen
2025-01-28 13:54:20 -03:00
Herculino Trotta 31cc8db3ac refactor: remove toasts from login screen
Fixes #91
2025-01-28 13:53:47 -03:00
Herculino Trotta 3d85a15ec9 Merge pull request #90
feat: enable bulk actions on specific transactions list (calendar, recurring and installment)
2025-01-27 22:46:19 -03:00
Herculino Trotta 90f98c2d15 feat: enable bulk actions on specific transactions list (calendar, recurring and installment) 2025-01-27 22:45:40 -03:00
Herculino Trotta 643855e60e Merge pull request #89 from eitchtee/dev
fix(calendar): tooltip error when transaction has no description and wrong color
2025-01-27 22:44:43 -03:00
Herculino Trotta e0f7b532f8 fix(calendar): tooltip error when transaction has no description and wrong color 2025-01-27 22:44:05 -03:00
Herculino Trotta b4d3e4b42f Merge pull request #88 from eitchtee/dev
feat: add "Clear cache" button to user menu
2025-01-27 21:50:48 -03:00
Herculino Trotta 9a7ccb0973 feat: add "Clear cache" button to user menu 2025-01-27 21:49:32 -03:00
Herculino Trotta a9b67ff272 Merge pull request #87
fix(security): toasts and month_year_picker accessible without login
2025-01-27 21:42:36 -03:00
Herculino Trotta 233b9629a2 fix(security): toasts and month_year_picker accessible without login 2025-01-27 21:41:55 -03:00
Herculino Trotta 4180c177f1 Merge pull request #86
fix: cleanup_deleted_transactions task couldn't trigger
2025-01-27 21:34:15 -03:00
Herculino Trotta f1bc04756f fix: cleanup_deleted_transactions task couldn't trigger 2025-01-27 21:33:46 -03:00
Herculino Trotta 13795c797f Merge pull request #85
feat: add number format user setting and improve date format handling
2025-01-27 13:31:28 -03:00
Herculino Trotta 331a7d5b18 locale: update translations 2025-01-27 13:30:06 -03:00
Herculino Trotta 81b8da30d6 feat: add number_format to user_settings form 2025-01-27 13:26:08 -03:00
Herculino Trotta 80bad240e7 refactor: remove custom_date filter 2025-01-27 13:25:47 -03:00
Herculino Trotta 187c56c96c refactor: remove user attr from datepicker
since monkey patched get_format already does what we want
2025-01-27 13:25:06 -03:00
Herculino Trotta 3796112d77 feat: monkey patch get_format to return usersettings 2025-01-27 13:22:21 -03:00
Herculino Trotta 958940089a feat: add number_format user setting 2025-01-27 13:20:12 -03:00
Herculino Trotta a08548bb13 feat: add local access to user and request from anywhere 2025-01-27 13:19:28 -03:00
Herculino Trotta 7fe446e510 refactor: remove custom_date filter 2025-01-27 13:18:57 -03:00
Herculino Trotta eccb0d15ee Merge pull request #83 from eitchtee/eitchtee-patch-1
Update README.md
2025-01-26 21:03:45 -03:00
Herculino Trotta 7ebd329706 Update README.md 2025-01-26 21:03:14 -03:00
Herculino Trotta d3fcd5fe7e Merge pull request #82
fix datepicker datetime handling and action-bar
2025-01-26 20:56:53 -03:00
Herculino Trotta b0a3acbdde fix: transactions action bar error on page change 2025-01-26 20:56:03 -03:00
Herculino Trotta 33ce38d74c feat(datepicker): improve value handling 2025-01-26 20:54:29 -03:00
Herculino Trotta fa51a7fef9 fix(datepicker): wrong datetime format 2025-01-26 20:53:16 -03:00
Herculino Trotta d7c072a35c fix(currencies): don't error out if from_currency or to_currency isn't set 2025-01-26 20:52:47 -03:00
Herculino Trotta c88a6dcf3a Update README.md 2025-01-26 11:49:28 -03:00
Herculino Trotta fcb54a0af2 Merge pull request #79 from DragonHeart69/main
Add new Dutch translations for v0.7.2
2025-01-26 11:20:35 -03:00
Herculino Trotta eec2ced481 refactor(settings): drop SQL_ENGINE env variable as only postgres is supported 2025-01-26 11:19:38 -03:00
Herculino Trotta 58a6048857 fix(settings): respect SQL_PORT env variable, defaulting to 5432 if not available 2025-01-26 11:17:38 -03:00
Herculino Trotta 93774cca64 docker: update python image from slim-buster to slim-bookworm 2025-01-26 11:16:39 -03:00
Dimitri Decrock 679f49badc Add new Dutch translations for v0.7.2 2025-01-26 13:37:06 +01:00
Herculino Trotta b535a12014 feat: enable Dutch (Nederlands) language choice 2025-01-25 15:55:42 -03:00
Herculino Trotta 72876bff43 Merge pull request #76 from DragonHeart69/main
1st edition of the Dutch translation
2025-01-25 15:36:38 -03:00
Dimitri Decrock 4411022027 delete merge 2025-01-25 19:36:51 +01:00
Dimitri Decrock 086210b39d Merge branch 'eitchtee-main' 2025-01-25 19:29:07 +01:00
Dimitri Decrock 73cb2d861b update 2025-01-25 19:26:37 +01:00
Dimitri Decrock 1c479ef85a Merge branch 'main' of https://github.com/eitchtee/WYGIWYH into eitchtee-main 2025-01-25 19:25:56 +01:00
Dimitri Decrock 51b2b11825 final translation Dutch 1st publication 2025-01-25 18:44:53 +01:00
Herculino Trotta c9d1b5b5f3 Merge pull request #75
locale: update locales
2025-01-25 13:55:09 -03:00
Herculino Trotta a22a95cb9f locale: update locales 2025-01-25 13:54:10 -03:00
Herculino Trotta 5c46a2c15e feat: pluralize toast for bulk edit 2025-01-25 13:48:32 -03:00
Herculino Trotta 4f091c601e Merge pull request #73
feat: add bulk duplicate action and toasts for existing actions
2025-01-25 13:44:55 -03:00
Herculino Trotta 0fac78d15a feat: add bulk duplicate action and toasts for existing actions 2025-01-25 13:44:39 -03:00
Herculino Trotta aa171c0e76 Merge pull request #72
fix: clear internal_id when duplicating
2025-01-25 13:42:54 -03:00
Herculino Trotta 73ca418dc8 fix: clear internal_id when duplicating 2025-01-25 13:42:23 -03:00
Herculino Trotta 7c34f36ffb Merge pull request #71 from eitchtee/dev
feat: tidy up transactions action bar
2025-01-25 12:44:48 -03:00
Herculino Trotta 2b6be8c6ac feat: tidy up transactions action bar 2025-01-25 12:43:53 -03:00
Herculino Trotta f643c41cf1 Merge pull request #70
feat: bulk edit selected transactions
2025-01-25 12:42:36 -03:00
Herculino Trotta 1ef7a780fb feat: bulk edit selected transactions 2025-01-25 12:41:55 -03:00
Herculino Trotta c3a753d221 Merge pull request #69 from eitchtee/dev
feat: add new animation to transactions action bar
2025-01-25 12:39:51 -03:00
Herculino Trotta c474b6cda9 feat: add new animation to transactions action bar 2025-01-25 12:37:30 -03:00
Herculino Trotta aff3aa7ed2 feat: add new animation to transactions action bar 2025-01-25 12:37:24 -03:00
Dimitri Decrock 414a9bb88a 4d part Dutch translation 2025-01-25 14:23:23 +01:00
Herculino Trotta 5f202a3820 Merge pull request #68
feat(transactions): proper clear button for filters
2025-01-25 01:30:43 -03:00
Herculino Trotta e71775292a feat(transactions): proper clear button for filters 2025-01-25 01:30:24 -03:00
Herculino Trotta 01aa8acb71 Merge pull request #67 from eitchtee/dev
refactor: add end slashes for some urls without
2025-01-24 22:56:20 -03:00
Herculino Trotta d030f9686b refactor: add end slashes for some urls without 2025-01-24 22:55:36 -03:00
Herculino Trotta 56d7e41bc5 Merge pull request #66
feat: add new /add/ endpoint for quickly adding new transactions
2025-01-24 22:52:17 -03:00
Herculino Trotta 0857b44fc3 feat: add new /add/ endpoint for quickly adding new transactions 2025-01-24 22:50:39 -03:00
Herculino Trotta d4b5afd8b2 Merge pull request #65
fix(transactions): unaligned type button
2025-01-24 22:49:42 -03:00
Herculino Trotta 9c4ba3a6de fix(transactions): unaligned type button 2025-01-24 22:48:24 -03:00
Herculino Trotta ec8b0e21d8 Merge pull request #63
feat(transactions): new is_paid switch
2025-01-24 22:47:20 -03:00
Herculino Trotta 6c60c3659c feat(transactions): new is_paid switch 2025-01-24 22:47:00 -03:00
Herculino Trotta a040b8acd2 Merge pull request #62
fix(transactions:filter): unaligned filter buttons
2025-01-24 22:42:20 -03:00
Herculino Trotta e72d6cd1ea fix(transactions:filter): unaligned filter buttons 2025-01-24 22:42:01 -03:00
Herculino Trotta 3fb670ef00 Merge pull request #61 from eitchtee/dev
locale: update translations
2025-01-24 16:31:30 -03:00
Herculino Trotta b9cd97f0b8 locale: update translations and remove dutch from available languages until translation is done 2025-01-24 16:30:31 -03:00
Herculino Trotta 011e0ad7c9 Merge pull request #60 from eitchtee/dev
fix: import preset not working behind nginx due to long url/csrf missing
2025-01-24 16:08:32 -03:00
Herculino Trotta 97465c07fe fix: import preset not working behind nginx due to long url/csrf missing 2025-01-24 16:06:47 -03:00
Dimitri Decrock f6d1a42b35 Merge branch 'eitchtee:main' into main 2025-01-24 19:22:03 +01:00
Dimitri Decrock eb25f8aeb3 3d part Dutch translation 2025-01-24 19:22:01 +01:00
Herculino Trotta 36cbe2935a Merge pull request #59
feat(pwa): better offline page and offline
2025-01-24 14:25:57 -03:00
Herculino Trotta dbea78cd3c feat(pwa): better offline page and offline request handler 2025-01-24 14:22:30 -03:00
Herculino Trotta d50c84f8e6 refactor: remove debug prints 2025-01-24 00:36:33 -03:00
Herculino Trotta f2d32fd7e9 feat(import): final changes for release 2025-01-23 23:52:54 -03:00
Herculino Trotta 53175aacb9 feat(import:templates): change wrong name 2025-01-23 22:49:09 -03:00
Herculino Trotta 1dc03b0a84 feat(import:v1:service): respect create and type fields 2025-01-23 22:48:23 -03:00
Herculino Trotta ba2d654f15 feat(accounts): make account names unique 2025-01-23 22:03:02 -03:00
Herculino Trotta 93d04572df feat(accounts): make account names unique 2025-01-23 22:02:45 -03:00
Herculino Trotta 38379ab2b1 feat(import): try to be more aggressive on cache invalidation 2025-01-23 21:12:13 -03:00
Herculino Trotta 928ad33111 feat(import): move required field check to end of process 2025-01-23 21:09:53 -03:00
Herculino Trotta d0172b5524 feat(import): convert deduplicate fields field into list 2025-01-23 21:09:21 -03:00
Herculino Trotta e4a2b83c83 feat: add new envs 2025-01-23 21:08:12 -03:00
Herculino Trotta 1c28dd5513 feat(import): show error if YAML is invalid 2025-01-23 21:08:03 -03:00
Herculino Trotta 1c713fac19 feat(import): add Nuconta preset 2025-01-23 21:07:48 -03:00
Herculino Trotta 096f24e0a2 feat(import): cleanup 2025-01-23 16:32:08 -03:00
Herculino Trotta f1cd658972 Merge pull request #58
feat: beta import function
2025-01-23 14:34:02 -03:00
Herculino Trotta a85221468a Merge remote-tracking branch 'origin/main' into 41-import-export-function
# Conflicts:
#	app/WYGIWYH/settings.py
2025-01-23 14:32:16 -03:00
Herculino Trotta e3d3a7cf91 feat: add new envs 2025-01-23 14:30:59 -03:00
Herculino Trotta 4ef4609a96 fix(navbar): wrong active link for navbar import item 2025-01-23 14:24:31 -03:00
Herculino Trotta 962a8efa26 feat(navbar): add import to management menu 2025-01-23 14:04:58 -03:00
Herculino Trotta d7de6c17a9 refactor: remove django-ace for now 2025-01-23 14:04:40 -03:00
Herculino Trotta a805880e9b git: keep import_presets folder 2025-01-23 12:55:01 -03:00
Herculino Trotta aaee602b71 refactor: remove django-ace for now 2025-01-23 12:54:26 -03:00
Herculino Trotta 7635b66638 Merge pull request #57
feat: PWA support
2025-01-23 12:50:17 -03:00
Herculino Trotta bcc96588bf feat: PWA support 2025-01-23 12:49:50 -03:00
Herculino Trotta cabd03e7e6 feat: presets 2025-01-23 11:43:35 -03:00
Dimitri Decrock 2ee64a534e 2nd part Dutch translation 2025-01-23 07:13:15 +01:00
Dimitri Decrock 14073d3555 Start with Dutch translation 2025-01-22 19:36:13 +01:00
Herculino Trotta 16fbead2f9 Merge remote-tracking branch 'origin/41-import-export-function' into 41-import-export-function 2025-01-22 10:44:36 -03:00
Herculino Trotta ece44f2726 feat(import): more UI and endpoints 2025-01-22 10:43:19 -03:00
Herculino Trotta a415e285ee feat(transactions): make deleted_at readonly on admin 2025-01-22 10:43:18 -03:00
Herculino Trotta 00b8727664 feat(transactions): add internal_id field to transactions 2025-01-22 10:43:18 -03:00
Herculino Trotta 6f096fd3ff feat(import): some views and urls 2025-01-22 10:43:18 -03:00
Herculino Trotta 07fcbe1f45 feat(import): some layouts 2025-01-22 10:43:18 -03:00
Herculino Trotta 0f14fd0c62 feat(import): test yaml_config before saving 2025-01-22 10:43:18 -03:00
Herculino Trotta 61d5aba67c feat(import): some layouts 2025-01-22 10:43:18 -03:00
Herculino Trotta 76df16e489 feat(import:v1:schema): add option for triggering rules 2025-01-22 10:43:18 -03:00
Herculino Trotta 34e6914d41 feat(transactions:tasks): add old deleted transactions cleanup task 2025-01-22 10:43:18 -03:00
Herculino Trotta f2cc070505 feat(settings): add KEEP_DELETED_TRANSACTIONS_FOR variable 2025-01-22 10:43:18 -03:00
Herculino Trotta 18d8e8ed1a feat(import): add migrations 2025-01-22 10:43:18 -03:00
Herculino Trotta 2ff33526ae feat(import): disable cache when running 2025-01-22 10:43:18 -03:00
Herculino Trotta 8a127a9f4f feat(transactions): soft delete 2025-01-22 10:43:17 -03:00
Herculino Trotta a52f682c4f feat(transactions): soft delete 2025-01-22 10:43:17 -03:00
Herculino Trotta 3440d4405e docker: add temp volume 2025-01-22 10:43:17 -03:00
Herculino Trotta 87345cf235 docs(requirements): add django_ace 2025-01-22 10:43:17 -03:00
Herculino Trotta 50efc51f87 feat(import): improve schema definition 2025-01-22 10:43:17 -03:00
Herculino Trotta 493bf268bb feat: rename app, some work on schema 2025-01-22 10:43:17 -03:00
Herculino Trotta 8992cd98b5 feat: add import app boilerplate 2025-01-22 10:43:17 -03:00
Herculino Trotta f7c3a2f320 locale: add nl (Dutch) language files 2025-01-22 10:21:35 -03:00
Herculino Trotta d96787cfeb feat(import): more UI and endpoints 2025-01-22 01:41:17 -03:00
Herculino Trotta 32b5864736 feat(transactions): make deleted_at readonly on admin 2025-01-20 23:10:11 -03:00
Herculino Trotta 02adfd828a feat(transactions): add internal_id field to transactions 2025-01-20 23:09:49 -03:00
Herculino Trotta c14b666921 Merge pull request #54 from eitchtee/datepicker_today_button
feat(datepicker): bring back today/now button behavior
2025-01-20 22:15:44 -03:00
Herculino Trotta 5d2b9ae0b3 locale(pt-BR): update translation 2025-01-20 22:14:42 -03:00
Herculino Trotta d5dfe5bba0 feat(datepicker): bring back today/now button behavior 2025-01-20 22:14:36 -03:00
Herculino Trotta 72ceec7452 Merge pull request #53 from eitchtee/50-date-notation
fix(datepicker): missing leading zeros on times
2025-01-20 21:49:14 -03:00
Herculino Trotta eae0e00d1f fix(datepicker): missing leading zeros on times 2025-01-20 21:48:09 -03:00
Herculino Trotta cc0125241f Merge pull request #52
locale(pt-BR): update translation
2025-01-20 19:47:26 -03:00
Herculino Trotta e3bab503a0 locale(pt-BR): update translation 2025-01-20 19:46:50 -03:00
Herculino Trotta c089c49b7d refactor: remove debug print 2025-01-20 19:40:33 -03:00
Herculino Trotta b18273a562 Merge pull request #51
feat(app): allow changing date and datetime format as a user setting
2025-01-20 19:36:13 -03:00
Herculino Trotta 60fe4c9681 feat(app): allow changing date and datetime format as a user setting 2025-01-20 19:35:22 -03:00
Herculino Trotta 0fccdbe573 feat(import): some views and urls 2025-01-20 14:31:12 -03:00
Herculino Trotta b9810ce062 feat(import): some layouts 2025-01-20 14:30:59 -03:00
Herculino Trotta 4cc32e3f57 feat(import): test yaml_config before saving 2025-01-20 14:30:40 -03:00
Herculino Trotta 8db13b082b feat(import): some layouts 2025-01-20 14:30:17 -03:00
Herculino Trotta e73e1dfc25 feat(import:v1:schema): add option for triggering rules 2025-01-19 15:20:25 -03:00
Herculino Trotta ae91c51967 feat(transactions:tasks): add old deleted transactions cleanup task 2025-01-19 15:17:18 -03:00
Herculino Trotta 3ef6b0ac5c feat(settings): add KEEP_DELETED_TRANSACTIONS_FOR variable 2025-01-19 15:16:47 -03:00
Herculino Trotta ba0c54767c feat(import): add migrations 2025-01-19 13:56:29 -03:00
Herculino Trotta 2d8864773c feat(import): disable cache when running 2025-01-19 13:56:13 -03:00
Herculino Trotta f96d8d2862 feat(transactions): soft delete 2025-01-19 13:55:25 -03:00
Herculino Trotta 3ccb0e19eb feat(transactions): soft delete 2025-01-19 13:55:17 -03:00
Herculino Trotta 238f205513 docker: add temp volume 2025-01-19 11:47:33 -03:00
Herculino Trotta a94e0b4904 docs(requirements): add django_ace 2025-01-19 11:45:06 -03:00
Herculino Trotta 86dac632c4 feat(import): improve schema definition 2025-01-19 11:27:14 -03:00
Herculino Trotta f68e954bc0 Merge remote-tracking branch 'origin/main' 2025-01-18 00:00:19 -03:00
Herculino Trotta 404036bafa feat(readme): add guide to build from source 2025-01-17 23:59:55 -03:00
Herculino Trotta 5e8074ea01 fix(readme): wrong comment about running app 2025-01-17 23:59:25 -03:00
Herculino Trotta c9cc942a10 Merge pull request #46
feat: add a duplicate/clone action to each transaction
2025-01-17 23:54:04 -03:00
Herculino Trotta 315f4e1269 feat: add a duplicate/clone action to each transaction 2025-01-17 23:53:39 -03:00
Herculino Trotta fbb26b8442 feat: rename app, some work on schema 2025-01-17 17:40:51 -03:00
Herculino Trotta c171e0419a feat: add import app boilerplate 2025-01-16 14:09:33 -03:00
Herculino Trotta b025ab7d24 docs: add more screenshots 2025-01-16 10:17:13 -03:00
Herculino Trotta e2134e98a5 docs: Add information about running locally 2025-01-16 10:06:18 -03:00
Herculino Trotta 3f250338a3 Merge pull request #44 from eitchtee/new_datepicker
docker: remove YAML anchor and merge directives from docker-compose.prod.yml
2025-01-16 09:24:06 -03:00
Herculino Trotta 97c6b13d57 security: actually use SECRET_KEY env variable. You will get logged out. 2025-01-16 09:23:18 -03:00
Herculino Trotta 3dcee4dbf2 docker: remove YAML anchor and merge directives from docker-compose.prod.yml
Fixes #42
2025-01-16 09:22:08 -03:00
Herculino Trotta 09d14b44fe Merge pull request #39 from eitchtee/dev
feat(transactions): make description optional
2025-01-14 23:49:45 -03:00
Herculino Trotta a5b78f7c83 Merge pull request #40 from eitchtee/new_datepicker
feat(datepicker): drop native datepickers in favor of AirDatePicker for better compatibility
2025-01-14 23:49:27 -03:00
Herculino Trotta 9543881aae Merge pull request #38 from eltociear/patch-1
docs: update README.md
2025-01-14 23:49:06 -03:00
Herculino Trotta 6955294283 feat(datepicker): drop native datepickers in favor of AirDatePicker for better compatibility
As Firefox (still) doesn't support month input type
2025-01-14 23:47:03 -03:00
Herculino Trotta 2b6a73af18 feat(transactions): make description optional 2025-01-14 10:04:46 -03:00
Ikko Eltociear Ashimine 526c2cb191 docs: update README.md
perfomance -> performance
2025-01-14 15:05:46 +09:00
Herculino Trotta 4fe62244cd docs(README): update README 2025-01-11 20:22:29 -03:00
Herculino Trotta 011e926e02 Merge pull request #37
locale(pt-BR): update translation
2025-01-11 13:42:11 -03:00
Herculino Trotta cd1b872b27 locale(pt-BR): update translation 2025-01-11 13:41:40 -03:00
Herculino Trotta 3791edce63 Merge pull request #36
feat(recurring-transaction): when explicitly finishing, delete any upcoming unpaid transactions
2025-01-11 13:40:28 -03:00
Herculino Trotta 2cb8100129 feat(recurring-transaction): when explicitly finishing, delete any upcoming unpaid transactions 2025-01-11 13:40:10 -03:00
Herculino Trotta e7e4ccafb6 Merge pull request #35 from eitchtee/dev
feat(recurring-transaction): when unpause start generating transactions from today or from existing date, whichever is higher
2025-01-11 13:39:26 -03:00
Herculino Trotta afbbf7b25d feat(recurring-transaction): when unpause start generating transactions from today or from existing date, whichever is higher 2025-01-11 13:38:51 -03:00
Herculino Trotta 1eba2b8731 Merge pull request #34 from eitchtee/dev
feat(installment-plan): don't update paid transactions amount
2025-01-11 13:37:19 -03:00
Herculino Trotta afe366c359 feat(installment-plan): don't update paid transactions amount 2025-01-11 13:35:52 -03:00
Herculino Trotta 3ee2bebc5c Merge pull request #33
feat(recurring-transaction): update unpaid transactions info when recurring transaction is updated
2025-01-11 13:35:14 -03:00
Herculino Trotta b951e5f069 feat(recurring-transaction): update unpaid transactions info when recurring transaction is updated 2025-01-11 13:34:49 -03:00
Herculino Trotta 4005a83a0d Merge pull request #32
fix(calculator): rounding errors
2025-01-07 16:17:00 -03:00
Herculino Trotta f81f1d83fd fix(calculator): rounding errors 2025-01-07 16:16:26 -03:00
Herculino Trotta 7816d6c55d Merge pull request #31
fix(transactions:action-bar): rounding errors when summing (again)
2025-01-06 00:50:41 -03:00
Herculino Trotta 6e3fdae4fe fix(transactions:action-bar): rounding errors when summing (again) 2025-01-06 00:50:17 -03:00
Herculino Trotta e2da996217 Merge pull request #30
fix(networth): chart initializing multiple times resulting in weird animation
2025-01-06 00:14:48 -03:00
Herculino Trotta cc2e2293ed fix(networth): chart initializing multiple times resulting in weird animation 2025-01-06 00:14:15 -03:00
Herculino Trotta 7060f07ccd Merge pull request #29
feat(calculator): localize result
2025-01-06 00:14:12 -03:00
Herculino Trotta 0adb991879 feat(calculator): localize result 2025-01-06 00:13:47 -03:00
Herculino Trotta 20e03df661 Merge pull request #28
fix(transactions:action-bar): rounding errors when summing
2025-01-06 00:11:55 -03:00
Herculino Trotta 71f59bfd68 fix(transactions:action-bar): rounding errors when summing 2025-01-06 00:10:40 -03:00
Herculino Trotta 6c76535f91 Merge pull request #27
fix(transactions:action-bar): min and max calculations take into account if value is income or expense
2025-01-05 15:53:21 -03:00
Herculino Trotta 5c8fbc9278 fix(transactions:action-bar): min and max calculations take into account if value is income or expense 2025-01-05 15:52:58 -03:00
Herculino Trotta 89b11421c2 Merge pull request #26
feat(transactions:action-bar): localize calculation results
2025-01-05 15:42:51 -03:00
Herculino Trotta 056fc4fced feat(transactions:action-bar): localize calculation results 2025-01-05 15:42:28 -03:00
Herculino Trotta 3f9765ec7b Merge pull request #25
refactor(transactions:action-bar): remove debug log
2025-01-05 15:22:52 -03:00
Herculino Trotta 0d9d13bf31 refactor(transactions:action-bar): remove debug log 2025-01-05 15:22:18 -03:00
Herculino Trotta 2f6c396eaf Merge pull request #24
fix(transactions:action-bar): sum button not copying correctly
2025-01-05 15:20:24 -03:00
Herculino Trotta d12b920e54 fix(transactions:action-bar): sum button not copying correctly 2025-01-05 15:19:58 -03:00
Herculino Trotta 9edbf7bd5a Merge pull request #23
feat(transactions:action-bar): add more math options in a dropdown
2025-01-05 14:36:07 -03:00
Herculino Trotta dbd3eea29a locale(pt-BR): update translation 2025-01-05 14:35:33 -03:00
Herculino Trotta 881fed1895 feat(transactions:action-bar): add more math options in a dropdown 2025-01-05 14:35:23 -03:00
Herculino Trotta 10a0ac42a2 Merge pull request #22
feat(api): add RecurringTransaction and InstallmentPlan endpoints
2025-01-05 11:14:03 -03:00
Herculino Trotta 1b47c12a22 feat(api): add RecurringTransaction and InstallmentPlan endpoints 2025-01-05 11:13:23 -03:00
Herculino Trotta 091f73bf8d feat(api): support string name and ids for installmentplan endpoint 2025-01-05 11:07:38 -03:00
Herculino Trotta 73fe17de64 feat(api): add auth permission to all api endpoint 2025-01-05 11:04:50 -03:00
Herculino Trotta 52af1b2260 Merge pull request #21
feat(api): add API endpoints to add DCA entries and strategies
2025-01-05 10:54:55 -03:00
Herculino Trotta 8efa087aee feat(api): add API endpoints to add DCA entries and strategies 2025-01-05 10:54:31 -03:00
407 changed files with 56656 additions and 7907 deletions
+22 -2
View File
@@ -1,6 +1,7 @@
SERVER_NAME=wygiwyh_server
DB_NAME=wygiwyh_pg
PROCRASTINATE_NAME=wygiwyh_procrastinate
TZ=UTC # Change to your timezone. This only affects some async tasks.
DEBUG=false
URL = https://...
@@ -9,7 +10,11 @@ SECRET_KEY=<GENERATE A SAFE SECRET KEY AND PLACE IT HERE>
DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1]
OUTBOUND_PORT=9005
SQL_ENGINE=django.db.backends.postgresql
# 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_USER=wygiwyh
SQL_PASSWORD=<INSERT A SAFE PASSWORD HERE>
@@ -18,3 +23,18 @@ SQL_PORT=5432
# Gunicorn
WEB_CONCURRENCY=4
# App Configs
# Enable this if you want to keep deleted transactions in the database
ENABLE_SOFT_DELETE=false
# If ENABLE_SOFT_DELETE is true, transactions deleted for more than KEEP_DELETED_TRANSACTIONS_FOR days will be truly deleted. Set to 0 to keep all.
KEEP_DELETED_TRANSACTIONS_FOR=365
TASK_WORKERS=1 # This only work if you're using the single container option. Increase to have more open queues via procrastinate, you probably don't need to increase this.
# OIDC Configuration. Uncomment the lines below if you want to add OIDC login to your instance
#OIDC_CLIENT_NAME=""
#OIDC_CLIENT_ID=""
#OIDC_CLIENT_SECRET=""
#OIDC_SERVER_URL=""
#OIDC_ALLOW_SIGNUP=true
+4
View File
@@ -0,0 +1,4 @@
# These are supported funding model platforms
github: eitchtee
custom: ["https://www.paypal.com/donate/?hosted_button_id=FFWM4W9NQDMM6"]
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

+59 -2
View File
@@ -2,11 +2,28 @@ name: Release Pipeline
on:
release:
types: [created]
types: [ created ]
push:
branches: [ main ]
workflow_dispatch:
inputs:
tag:
description: 'Custom tag name for the image'
required: true
type: string
ref:
description: 'Git ref to checkout (branch, tag, or SHA)'
required: true
default: 'main'
type: string
env:
IMAGE_NAME: wygiwyh
concurrency:
group: release
cancel-in-progress: false
jobs:
build-and-push:
runs-on: ubuntu-latest
@@ -16,6 +33,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.ref }}
if: github.event_name == 'workflow_dispatch'
- name: Checkout code (non-manual)
uses: actions/checkout@v4
if: github.event_name != 'workflow_dispatch'
- name: Log in to Docker Hub
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
@@ -29,16 +53,49 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push image
- name: Build and push nightly image
if: github.event_name == 'push'
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/prod/django/Dockerfile
push: true
provenance: false
build-args: |
VERSION=nightly
tags: ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:nightly
platforms: linux/amd64,linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push release image
if: github.event_name == 'release'
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/prod/django/Dockerfile
push: true
provenance: false
build-args: |
VERSION=${{ github.event.release.tag_name }}
tags: |
${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:latest
${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:${{ github.event.release.tag_name }}
platforms: linux/amd64,linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push custom image
if: github.event_name == 'workflow_dispatch'
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/prod/django/Dockerfile
push: true
provenance: false
build-args: |
VERSION=${{ github.event.inputs.tag }}
tags: ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:${{ github.event.inputs.tag }}
platforms: linux/amd64,linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
+72
View File
@@ -0,0 +1,72 @@
name: Django Translation Update
on:
push:
branches: [ main ]
# Add manual trigger
workflow_dispatch:
inputs:
reason:
description: 'Reason for running'
required: false
default: 'Manual update of translation files'
# Ensure only one translation job runs at a time
concurrency:
group: django-translations
cancel-in-progress: false
jobs:
update-translations:
runs-on: ubuntu-latest
permissions:
contents: write
# Skip on PRs from forks (which don't have write permissions)
# Allow manual runs and pushes to main
if: github.event_name == 'workflow_dispatch' || github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
token: ${{ secrets.PAT }}
ref: ${{ github.head_ref }}
- name: Set up Python 3.11
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Install gettext
run: sudo apt-get install -y gettext
- name: Run makemessages
run: |
cd app
python manage.py makemessages -a
- name: Check for changes
id: check_changes
run: |
if git diff --exit-code --quiet app/locale/; then
echo "No translation changes detected"
else
echo "changes_detected=true" >> $GITHUB_OUTPUT
echo "Translation changes detected"
fi
- name: Commit translation files
if: steps.check_changes.outputs.changes_detected == 'true'
uses: stefanzweifel/git-auto-commit-action@v5
with:
push_options: --force
commit_message: |
chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
file_pattern: "app/locale/**/*.po"
+117 -265
View File
@@ -6,17 +6,22 @@
<br>
</h1>
<h4 align="center">An optionated and powerful finance tracker.</h4>
<h4 align="center">An opinionated and powerful finance tracker.</h4>
<p align="center">
<a href="#why-wygiwyh">Why</a> •
<a href="#key-features">Features</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="#built-with">Built with</a>
</p>
**WYGIWYH** (_What You Get Is What You Have_) is a powerful, principles-first finance tracker designed for people who prefer a no-budget, straightforward approach to managing their money. With features like multi-currency support, customizable transactions, and a built-in dollar-cost averaging tracker, WYGIWYH helps you take control of your finances with simplicity and flexibility.
<img src=".github/img/monthly_view.png" width="18%"></img> <img src=".github/img/yearly.png" width="18%"></img> <img src=".github/img/networth.png" width="18%"></img> <img src=".github/img/calendar.png" width="18%"></img> <img src=".github/img/all_transactions.png" width="18%"></img>
# Why WYGIWYH?
Managing money can feel unnecessarily complex, but it doesnt have to be. WYGIWYH (pronounced "wiggy-wih") is based on a simple principle:
@@ -46,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.
* **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
To run this application, you'll need [Docker](https://docs.docker.com/engine/install/) with [docker-compose](https://docs.docker.com/compose/install/).
@@ -53,10 +69,10 @@ To run this application, you'll need [Docker](https://docs.docker.com/engine/ins
From your command line:
```bash
# Clone this repository
# Create a folder for WYGIWYH (optional)
$ mkdir WYGIWYH
# Go into the repository
# Go into the folder
$ cd WYGIWYH
$ touch docker-compose.yml
@@ -71,281 +87,117 @@ $ nano .env # or any other editor you want to use
# Run the app
$ 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
```
# How it works
> [!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
## Models
## Running locally
### Transactions
If you want to run WYGIWYH locally, on your env file:
Transactions are the core feature of WYGIWYH, representing expenses or income in your accounts. Each transaction consists of the following fields:
1. Remove `URL`
2. Set `HTTPS_ENABLED` to `false`
3. Leave the default `DJANGO_ALLOWED_HOSTS` (localhost 127.0.0.1 [::1])
#### Type
- **Income**: A positive amount entering your account
- **Expense**: A negative amount exiting your account
#### Paid Status
A transaction can be either:
- **Current**: When marked as paid
- **Projected**: When marked as unpaid
#### Account
The account associated with the transaction. Required, limited to one account per transaction.
#### Entity
The party involved in the transaction:
- For **Income**: The paying entity
- For **Expense**: The receiving entity
Optional field.
#### Date
The date when the transaction occurred. Required.
#### Reference Date
One of **WYGIWYH**'s key features. The reference date determines which month a transaction should count towards. For example, you can have a transaction that occurred on January 26th count towards February's finances.
Optional - defaults to the transaction date's month if not specified.
> [!CAUTION]
> While designed primarily for credit card closing dates, this feature allows for debt rolling across months. Use responsibly to maintain accurate financial tracking.
#### Type
- Income, meaning a positive amount (usually) entering your account
- Expense, meaning a negative amount exiting your account
#### Description
The name or purpose of the transaction. Required.
#### Amount
The monetary value of the transaction. Required.
#### Category
The primary classification of the transaction. Optional.
#### Tags
Additional labels for transaction categorization. Optional.
#### Notes
Additional information about the transaction. Optional.
![img_4.png](.github/img/readme_transaction.png)
### Installment Plan
An Installment Plan is a helper model that generates a series of recurring transactions over a fixed period.
#### Core Fields
- **Account**: The account for all transactions in the plan. Required.
- **Entity**: The paying or receiving party for all transactions. Optional.
- **Description**: The name of the installment plan, used for all transactions. Required.
- **Notes**: Additional information applied to all transactions. Optional.
#### Installment Configuration
- **Number of Installments**: Total number of transactions to create (e.g., 1/10, 2/10)
- **Installment Start**: Initial counting point
- **Start Date**: Date of the first transaction
- **Reference Date**: Reference date for the first transaction
- **Recurrence**: Frequency of transactions (e.g., Monthly)
![img_1.png](.github/img/readme_installment_plan.png)
### Transaction Details
- **Amount**: Value for each transaction. Required.
- **Category**: Primary classification for all transactions. Optional.
- **Tags**: Labels applied to all transactions. Optional.
### Recurring Transaction
A Recurring Transaction is a helper model that generates recurring transactions indefinitely or until a certain date.
#### Core Fields
- **Account**: The account for all transactions in the plan. Required.
- **Entity**: The paying or receiving party for all transactions. Optional.
- **Description**: The name of the recurring transaction, used for all transactions. Required.
- **Notes**: Additional information applied to all transactions. Optional.
#### Recurring Transaction Configuration
- **Start Date**: Date of the first transaction. Required.
- **Reference Date**: Reference date for the first transaction. Optional.
- **Recurrence Type**: Frequency of transactions (e.g., Monthly). Required.
- **Recurrence Interval**: The interval between transactions (e.g. every 1 month, every 2 weeks, etc.). Required.
- **End date**: When new transactions should stop being created. Optional.
#### Transaction Details
- **Amount**: Value for each transaction. Required.
- **Category**: Primary classification for all transactions. Optional.
- **Tags**: Labels applied to all transactions. Optional.
#### Other information
- Recurring transactions are checked and created every midnight using Procrastinate.
- **WYGIWYH** tries to keep at most **6** future transactions created at any time.
- If you delete a recurring transaction it will not be recreated.
- You can stop or pause a recurring transaction at any time on the config page (/recurring-trasanctions/)
![img_3.png](.github/img/readme_recurring_transaction.png)
### Account
TO-DO
### Account Groups
TO-DO
### Currency
TO-DO
### Exchange Rate
TO-DO
### Category
TO-DO
### Tag
TO-DO
### Entity
TO-DO
### Rule
TO-DO
---
## Helper actions
### Transfer
A transfer happens when you move a monetary value from one account to another. This will create two transactions, one expense and one income with the values set by the user.
Contrary to other finance trackers, due to our multi-currency support, **WYGIWYH**'s transfer system allows for non-zero transfers.
![img.png](.github/img/readme_transfer.png)
### Balance (Account Reconciliation)
A balance is a easy way of updating your accounts balance. It creates a transaction with the difference between the balance currently in **WYGIWYH** and the new balance informed by you.
This can be useful for savings accounts or other interest accruing investments.![img_2.png](.github/img/readme_balance.png)
---
## Views
### Monthly
TO-DO
### Yearly by currency
TO-DO
### Yearly by account
TO-DO
### Calendar
TO-DO
### Networh
#### Current
TO-DO
#### Projected
TO-DO
### All Transactions
TO-DO
### Configuration and Management
TO-DO
---
## Tools
### Calculator
The calculator is a floating widget that can be toggled by clicking the calculator icon on the navbar.
It allows for any math expression supported by [math.js](https://mathjs.org).
![calculator](.github/img/readme_calculator.gif)
### Dollar Cost Average Tracker
The DCA Tracker can be accessed from the navbar's **Tools** menu.
It allows for tracking DCA strategies and getting helpful information and insights.
> [!IMPORTANT]
> Currently DCA exists separately from your main transactions. You will need to add your entries manually.
<img src=".github/img/readme_dca_1.png" width="45%"></img> <img src=".github/img/readme_dca_2.png" width="45%"></img>
### Unit Price Calculator
The Unit Price Calculator can be accessed from the navbar's **Tools** menu.
This is a self-contained tool for comparing and finding the most cost-efficient item quickly and easily.
Input the price and the amount of each item, the cheapeast will be highlighted in green, and the most expensive in red.
You can add additional items by clicking the _Add_ button at the end of the page.
You can now access localhost:OUTBOUND_PORT
> [!NOTE]
> This doesn't do unit convertion. The amount of all items needs to be on the same the unit for proper functioning.
> - If you're planning on running this behind Tailscale or other similar service also add your machine given IP to `DJANGO_ALLOWED_HOSTS`
> - If you're going to use another IP that isn't localhost, add it to `DJANGO_ALLOWED_HOSTS`, without `http://`
![img.png](.github/img/readme_unit_price_calculator.png)
### Currency Converter
## Latest changes
Features are only added to `main` when ready, if you want to run the latest version, you must build from source or use the `:nightly` tag on docker. Keep in mind that there can be undocumented breaking changes.
TO-DO
All the required Dockerfiles are [here](https://github.com/eitchtee/WYGIWYH/tree/main/docker/prod).
## Unraid
[nwithan8](https://github.com/nwithan8) has kindly provided a Unraid template for WYGIWYH, have a look at the [unraid_templates](https://github.com/nwithan8/unraid_templates) repo.
WYGIWYH is available on the Unraid Store. You'll need to provision your own postgres (version 15 or up) database.
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 |
|-------------------------------|-------------|-----------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| DJANGO_ALLOWED_HOSTS | string | localhost 127.0.0.1 | A list of space separated domains and IPs representing the host/domain names that WYGIWYH site can serve. [Click here](https://docs.djangoproject.com/en/5.1/ref/settings/#allowed-hosts) for more details |
| HTTPS_ENABLED | true\|false | false | Whether to use secure cookies. If this is set to true, the cookie will be marked as “secure”, which means browsers may ensure that the cookie is only sent under an HTTPS connection |
| URL | string | http://localhost http://127.0.0.1 | A list of space separated domains and IPs (with the protocol) representing the trusted origins for unsafe requests (e.g. POST). [Click here](https://docs.djangoproject.com/en/5.1/ref/settings/#csrf-trusted-origins ) for more details |
| SECRET_KEY | string | "" | This is used to provide cryptographic signing, and should be set to a unique, unpredictable value. |
| DEBUG | true\|false | false | Turns DEBUG mode on or off, this is useful to gather more data about possible errors you're having. Don't use in production. |
| 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_PASSWORD | string | password | The password 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 |
| 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. |
| KEEP_DELETED_TRANSACTIONS_FOR | int | 365 | Time in days to keep soft deleted transactions for. If 0, will keep all transactions indefinitely. Only works if ENABLE_SOFT_DELETE is true. |
| TASK_WORKERS | int | 1 | How many workers to have for async tasks. One should be enough for most use cases |
| DEMO | true\|false | false | If demo mode is enabled. |
| ADMIN_EMAIL | string | None | Automatically creates an admin account with this email. Must have `ADMIN_PASSWORD` also set. |
| ADMIN_PASSWORD | string | None | Automatically creates an admin account with this password. Must have `ADMIN_EMAIL` also set. |
## 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
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
- I'm not an accountant, some terms and even calculations might be wrong. Make sure to open an issue if you see anything that could be improved.
- Pretty much all calculations are done at run time, this can lead to some performance degradation. On my personal instance, I have 3000+ transactions over 4+ years and 4000+ exchange rates, and load times average at around 500ms for each page, not bad overall.
- This isn't a budgeting or double-entry-accounting application, if you need those features there's a lot of options out there, if you really need them in WYGIWYH, open a discussion.
# Built with
WYGIWYH is possible thanks to a lot of amazing open source tools, to name a few:
- Django
- HTMX
- _hyperscript
- Procrastinate
- Bootstrap
- Tailwind
- Webpack
* Django
* HTMX
* _hyperscript
* Procrastinate
* Bootstrap
* Tailwind
* Webpack
* PostgreSQL
* Django REST framework
* Alpine.js
+233 -29
View File
@@ -14,6 +14,7 @@ import os
import sys
from pathlib import Path
from django.utils.text import slugify
SITE_TITLE = "WYGIWYH"
TITLE_SEPARATOR = "::"
@@ -26,15 +27,13 @@ ROOT_DIR = Path(__file__).resolve().parent.parent.parent
# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = "django-insecure-##6^&g49xwn7s67xc&33vf&=*4ibqfzn#xa*p-1sy8ag+zjjb9"
SECRET_KEY = os.getenv("SECRET_KEY", "")
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "localhost 127.0.0.1").split(" ")
CSRF_TRUSTED_ORIGINS = os.environ.get("URL", "http://localhost http://127.0.0.1").split(
" "
)
ALLOWED_HOSTS = os.getenv("DJANGO_ALLOWED_HOSTS", "localhost 127.0.0.1").split(" ")
CSRF_TRUSTED_ORIGINS = os.getenv("URL", "http://localhost http://127.0.0.1").split(" ")
# Application definition
@@ -44,6 +43,7 @@ INSTALLED_APPS = [
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.sites",
"whitenoise.runserver_nostatic",
"django.contrib.staticfiles",
"webpack_boilerplate",
@@ -57,13 +57,15 @@ INSTALLED_APPS = [
"hijack",
"hijack.contrib.admin",
"django_filters",
"import_export",
"apps.users.apps.UsersConfig",
"procrastinate.contrib.django",
"apps.transactions.apps.TransactionsConfig",
"apps.currencies.apps.CurrenciesConfig",
"apps.accounts.apps.AccountsConfig",
"apps.common.apps.CommonConfig",
"apps.net_worth.apps.NetWorthConfig",
"apps.import_app.apps.ImportConfig",
"apps.export_app.apps.ExportConfig",
"apps.api.apps.ApiConfig",
"cachalot",
"rest_framework",
@@ -72,9 +74,19 @@ INSTALLED_APPS = [
"apps.rules.apps.RulesConfig",
"apps.calendar_view.apps.CalendarViewConfig",
"apps.dca.apps.DcaConfig",
"pwa",
"allauth",
"allauth.account",
"allauth.socialaccount",
"allauth.socialaccount.providers.openid_connect",
"apps.common.apps.CommonConfig",
]
SITE_ID = 1
MIDDLEWARE = [
"django_browser_reload.middleware.BrowserReloadMiddleware",
"apps.common.middleware.thread_local.ThreadLocalMiddleware",
"debug_toolbar.middleware.DebugToolbarMiddleware",
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
@@ -86,8 +98,8 @@ MIDDLEWARE = [
"apps.common.middleware.localization.LocalizationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"django_browser_reload.middleware.BrowserReloadMiddleware",
"hijack.middleware.HijackUserMiddleware",
"allauth.account.middleware.AccountMiddleware",
]
ROOT_URLCONF = "WYGIWYH.urls"
@@ -124,12 +136,12 @@ WSGI_APPLICATION = "WYGIWYH.wsgi.application"
DATABASES = {
"default": {
"ENGINE": os.environ.get("SQL_ENGINE", "django.db.backends.sqlite3"),
"NAME": os.environ.get("SQL_DATABASE", BASE_DIR / "db.sqlite3"),
"USER": os.environ.get("SQL_USER", "user"),
"PASSWORD": os.environ.get("SQL_PASSWORD", "password"),
"HOST": os.environ.get("SQL_HOST", "localhost"),
"PORT": "5432",
"ENGINE": "django.db.backends.postgresql",
"NAME": os.getenv("SQL_DATABASE"),
"USER": os.getenv("SQL_USER", "user"),
"PASSWORD": os.getenv("SQL_PASSWORD", "password"),
"HOST": os.getenv("SQL_HOST", "localhost"),
"PORT": os.getenv("SQL_PORT", "5432"),
}
}
@@ -160,11 +172,108 @@ AUTH_USER_MODEL = "users.User"
LANGUAGE_CODE = "en"
LANGUAGES = (
("af", "Afrikaans"),
("ar", "العربية"),
("ar-dz", "العربية (الجزائر)"), # Algerian Arabic often uses the base name + region
("ast", "Asturianu"),
("az", "Azərbaycan"),
("bg", "Български"),
("be", "Беларуская"),
("bn", "বাংলা"),
("br", "Brezhoneg"),
("bs", "Bosanski"),
("ca", "Català"),
("ckb", "کوردیی ناوەندی"), # Central Kurdish (Sorani)
("cs", "Čeština"),
("cy", "Cymraeg"),
("da", "Dansk"),
("de", "Deutsch"),
("dsb", "Dolnoserbšćina"),
("el", "Ελληνικά"),
("en", "English"),
("en-au", "English (Australia)"),
("en-gb", "English (UK)"),
("eo", "Esperanto"),
("es", "Español"),
("es-ar", "Español (Argentina)"),
("es-co", "Español (Colombia)"),
("es-mx", "Español (México)"),
("es-ni", "Español (Nicaragua)"),
("es-ve", "Español (Venezuela)"),
("et", "Eesti"),
("eu", "Euskara"),
("fa", "فارسی"),
("fi", "Suomi"),
("fr", "Français"),
("fy", "Frysk"),
("ga", "Gaeilge"),
("gd", "Gàidhlig"),
("gl", "Galego"),
("he", "עברית"),
("hi", "हिन्दी"),
("hr", "Hrvatski"),
("hsb", "Hornjoserbšćina"),
("hu", "Magyar"),
("hy", "Հայերեն"),
("ia", "Interlingua"),
("id", "Bahasa Indonesia"),
("ig", "Igbo"),
("io", "Ido"),
("is", "Íslenska"),
("it", "Italiano"),
("ja", "日本語"),
("ka", "ქართული"),
("kab", "Taqbaylit"),
("kk", "Қазақша"),
("km", "ខ្មែរ"),
("kn", "ಕನ್ನಡ"),
("ko", "한국어"),
("ky", "Кыргызча"),
("lb", "Lëtzebuergesch"),
("lt", "Lietuvių"),
("lv", "Latviešu"),
("mk", "Македонски"),
("ml", "മലയാളം"),
("mn", "Монгол"),
("mr", "मराठी"),
("ms", "Bahasa Melayu"),
("my", "မြန်မာဘာသာ"),
("nb", "Norsk (Bokmål)"),
("ne", "नेपाली"),
("nl", "Nederlands"),
("nn", "Norsk (Nynorsk)"),
("os", "Ирон"), # Ossetic
("pa", "ਪੰਜਾਬੀ"),
("pl", "Polski"),
("pt", "Português"),
("pt-br", "Português (Brasil)"),
("ro", "Română"),
("ru", "Русский"),
("sk", "Slovenčina"),
("sl", "Slovenščina"),
("sq", "Shqip"),
("sr", "Српски"),
("sr-latn", "Srpski (Latinica)"),
("sv", "Svenska"),
("sw", "Kiswahili"),
("ta", "தமிழ்"),
("te", "తెలుగు"),
("tg", "Тоҷикӣ"),
("th", "ไทย"),
("tk", "Türkmençe"),
("tr", "Türkçe"),
("tt", "Татарча"),
("udm", "Удмурт"),
("ug", "ئۇيغۇرچە"),
("uk", "Українська"),
("ur", "اردو"),
("uz", "Oʻzbekcha"),
("vi", "Tiếng Việt"),
("zh-hans", "简体中文"),
("zh-hant", "繁體中文"),
)
TIME_ZONE = "UTC"
TIME_ZONE = os.getenv("TZ", "UTC")
USE_I18N = True
@@ -207,6 +316,42 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
LOGIN_REDIRECT_URL = "/"
LOGIN_URL = "/login/"
LOGOUT_REDIRECT_URL = "/login/"
# Allauth settings
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend", # Keep default
"allauth.account.auth_backends.AuthenticationBackend",
]
SOCIALACCOUNT_PROVIDERS = {"openid_connect": {"APPS": []}}
if (
os.getenv("OIDC_CLIENT_ID")
and os.getenv("OIDC_CLIENT_SECRET")
and os.getenv("OIDC_SERVER_URL")
):
SOCIALACCOUNT_PROVIDERS["openid_connect"]["APPS"].append(
{
"provider_id": slugify(os.getenv("OIDC_CLIENT_NAME", "OpenID Connect")),
"name": os.getenv("OIDC_CLIENT_NAME", "OpenID Connect"),
"client_id": os.getenv("OIDC_CLIENT_ID"),
"secret": os.getenv("OIDC_CLIENT_SECRET"),
"settings": {
"server_url": os.getenv("OIDC_SERVER_URL"),
},
}
)
ACCOUNT_LOGIN_METHODS = {"email"}
ACCOUNT_SIGNUP_FIELDS = ["email*", "password1*", "password2*"]
ACCOUNT_USER_MODEL_USERNAME_FIELD = None
ACCOUNT_EMAIL_VERIFICATION = "none"
SOCIALACCOUNT_LOGIN_ON_GET = True
SOCIALACCOUNT_ONLY = True
SOCIALACCOUNT_AUTO_SIGNUP = os.getenv("OIDC_ALLOW_SIGNUP", "true").lower() == "true"
ACCOUNT_ADAPTER = "allauth.account.adapter.DefaultAccountAdapter"
SOCIALACCOUNT_ADAPTER = "allauth.socialaccount.adapter.DefaultSocialAccountAdapter"
# CRISPY FORMS
CRISPY_ALLOWED_TEMPLATE_PACKS = ["bootstrap5", "crispy_forms/pure_text"]
@@ -218,7 +363,7 @@ SESSION_COOKIE_SECURE = os.getenv("HTTPS_ENABLED", "false").lower() == "true"
DEBUG_TOOLBAR_CONFIG = {
"ROOT_TAG_EXTRA_ATTRS": "hx-preserve",
"SHOW_TOOLBAR_CALLBACK": lambda r: False, # disables it}
# "SHOW_TOOLBAR_CALLBACK": lambda r: False, # disables it
}
DEBUG_TOOLBAR_PANELS = [
"debug_toolbar.panels.history.HistoryPanel",
@@ -256,7 +401,10 @@ if DEBUG:
REST_FRAMEWORK = {
# Use Django's standard `django.contrib.auth` permissions,
# 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",
"PAGE_SIZE": 10,
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
@@ -275,29 +423,32 @@ if "procrastinate" in sys.argv:
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"procrastinate": {
"format": "%(asctime)s %(levelname)-7s %(name)s %(message)s"
"standard": {
"format": "[%(asctime)s] - %(levelname)s - %(name)s - %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
},
},
"handlers": {
"procrastinate": {
"level": "DEBUG",
"level": "INFO",
"class": "logging.StreamHandler",
"formatter": "procrastinate",
"formatter": "standard",
},
"console": {
"class": "logging.StreamHandler",
"formatter": "standard",
"level": "INFO",
},
},
"loggers": {
"procrastinate": {
"handlers": ["procrastinate"],
"level": "INFO",
"propagate": False,
},
"root": {
"handlers": ["console"],
"level": "INFO",
"propagate": False,
},
},
}
@@ -306,24 +457,25 @@ else:
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"procrastinate": {
"format": "%(asctime)s %(levelname)-7s %(name)s %(message)s"
"standard": {
"format": "[%(asctime)s] - %(levelname)s - %(name)s - %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
},
},
"handlers": {
"procrastinate": {
"level": "DEBUG",
"class": "logging.StreamHandler",
"formatter": "procrastinate",
},
"console": {
"class": "logging.StreamHandler",
"formatter": "standard",
"level": "INFO",
},
"procrastinate": {
"level": "INFO",
"class": "logging.StreamHandler",
},
},
"loggers": {
"procrastinate": {
"handlers": None,
"level": "INFO",
"propagate": False,
},
"root": {
@@ -334,3 +486,55 @@ else:
}
CACHALOT_UNCACHABLE_TABLES = ("django_migrations", "procrastinate_jobs")
# PWA
PWA_APP_NAME = SITE_TITLE
PWA_APP_DESCRIPTION = "A simple and powerful finance tracker"
PWA_APP_THEME_COLOR = "#fbb700"
PWA_APP_BACKGROUND_COLOR = "#222222"
PWA_APP_DISPLAY = "standalone"
PWA_APP_SCOPE = "/"
PWA_APP_ORIENTATION = "any"
PWA_APP_START_URL = "/"
PWA_APP_STATUS_BAR_COLOR = "default"
PWA_APP_ICONS = [
{"src": "/static/img/favicon/android-icon-192x192.png", "sizes": "192x192"}
]
PWA_APP_ICONS_APPLE = [
{"src": "/static/img/favicon/apple-icon-180x180.png", "sizes": "180x180"}
]
PWA_APP_SPLASH_SCREEN = [
{
"src": "/static/img/pwa/splash-640x1136.png",
"media": "(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2)",
}
]
PWA_APP_DIR = "ltr"
PWA_APP_LANG = "en-US"
PWA_APP_SHORTCUTS = [
{
"name": "New Transaction",
"url": "/add/",
"description": "Add new transaction",
}
]
PWA_APP_SCREENSHOTS = [
{
"src": "/static/img/pwa/splash-750x1334.png",
"sizes": "750x1334",
"type": "image/png",
"form_factor": "wide",
},
{
"src": "/static/img/pwa/splash-750x1334.png",
"sizes": "750x1334",
"type": "image/png",
},
]
PWA_SERVICE_WORKER_PATH = BASE_DIR / "templates" / "pwa" / "serviceworker.js"
ENABLE_SOFT_DELETE = os.getenv("ENABLE_SOFT_DELETE", "false").lower() == "true"
KEEP_DELETED_TRANSACTIONS_FOR = int(os.getenv("KEEP_DELETED_ENTRIES_FOR", "365"))
APP_VERSION = os.getenv("APP_VERSION", "unknown")
DEMO = os.getenv("DEMO", "false").lower() == "true"
+13
View File
@@ -21,12 +21,15 @@ from drf_spectacular.views import (
SpectacularAPIView,
SpectacularSwaggerView,
)
from allauth.socialaccount.providers.openid_connect.views import login, callback
urlpatterns = [
path("admin/", admin.site.urls),
path("hijack/", include("hijack.urls")),
path("__debug__/", include("debug_toolbar.urls")),
path("__reload__/", include("django_browser_reload.urls")),
path("", include("pwa.urls")),
# path("api/", include("rest_framework.urls")),
path("api/", include("apps.api.urls")),
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
@@ -35,6 +38,13 @@ urlpatterns = [
SpectacularSwaggerView.as_view(url_name="schema"),
name="swagger-ui",
),
path("auth/", include("allauth.urls")), # allauth urls
# path("auth/oidc/<str:provider_id>/login/", login, name="openid_connect_login"),
# path(
# "auth/oidc/<str:provider_id>/login/callback/",
# callback,
# name="openid_connect_callback",
# ),
path("", include("apps.transactions.urls")),
path("", include("apps.common.urls")),
path("", include("apps.users.urls")),
@@ -47,4 +57,7 @@ urlpatterns = [
path("", include("apps.calendar_view.urls")),
path("", include("apps.dca.urls")),
path("", include("apps.mini_tools.urls")),
path("", include("apps.import_app.urls")),
path("", include("apps.export_app.urls")),
path("", include("apps.insights.urls")),
]
+10 -2
View File
@@ -1,6 +1,14 @@
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
+8
View File
@@ -77,6 +77,8 @@ class AccountForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["group"].queryset = AccountGroup.objects.all()
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.form_method = "post"
@@ -151,5 +153,11 @@ class AccountBalanceForm(forms.Form):
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)
@@ -0,0 +1,38 @@
from django.db import migrations, models
def make_names_unique(apps, schema_editor):
Account = apps.get_model("accounts", "Account")
# Get all accounts ordered by id
accounts = Account.objects.all().order_by("id")
# Track seen names
seen_names = {}
for account in accounts:
original_name = account.name
counter = seen_names.get(original_name, 0)
while account.name in seen_names:
counter += 1
account.name = f"{original_name} ({counter})"
seen_names[account.name] = counter
account.save()
def reverse_migration(apps, schema_editor):
# Can't restore original names, so do nothing
pass
class Migration(migrations.Migration):
dependencies = [
("accounts", "0006_rename_archived_account_is_archived_and_more"),
]
operations = [
migrations.RunPython(make_names_unique, reverse_migration),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.5 on 2025-01-24 00:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0007_make_account_names_unique'),
]
operations = [
migrations.AlterField(
model_name='account',
name='name',
field=models.CharField(max_length=255, unique=True, verbose_name='Name'),
),
]
@@ -0,0 +1,31 @@
# Generated by Django 5.1.6 on 2025-03-04 15:07
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0008_alter_account_name'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='account',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_accounts', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
),
migrations.AddField(
model_name='account',
name='shared_with',
field=models.ManyToManyField(blank=True, related_name='shared_accounts', to=settings.AUTH_USER_MODEL, verbose_name='Shared With'),
),
migrations.AddField(
model_name='accountgroup',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_account_groups', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
),
]
@@ -0,0 +1,46 @@
# Generated by Django 5.1.6 on 2025-03-05 02:42
import django.db.models.manager
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0009_account_owner_account_shared_with_accountgroup_owner'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterModelManagers(
name='account',
managers=[
('all_objects', django.db.models.manager.Manager()),
],
),
migrations.AlterModelManagers(
name='accountgroup',
managers=[
('all_objects', django.db.models.manager.Manager()),
],
),
migrations.AlterField(
model_name='account',
name='name',
field=models.CharField(max_length=255, verbose_name='Name'),
),
migrations.AlterField(
model_name='accountgroup',
name='name',
field=models.CharField(max_length=255, verbose_name='Name'),
),
migrations.AlterUniqueTogether(
name='account',
unique_together={('owner', 'name')},
),
migrations.AlterUniqueTogether(
name='accountgroup',
unique_together={('owner', 'name')},
),
]
@@ -0,0 +1,26 @@
# Generated by Django 5.1.6 on 2025-03-05 04:19
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0010_alter_account_managers_alter_accountgroup_managers_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='account',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='owned_accounts', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
),
migrations.AlterField(
model_name='accountgroup',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='owned_account_groups', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
),
]
@@ -0,0 +1,56 @@
# Generated by Django 5.1.6 on 2025-03-05 23:46
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0011_alter_account_owner_alter_accountgroup_owner'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterModelManagers(
name='account',
managers=[
],
),
migrations.AlterModelManagers(
name='accountgroup',
managers=[
],
),
migrations.AddField(
model_name='account',
name='visibility',
field=models.CharField(choices=[('private', 'Private'), ('shared', 'Shared'), ('public', 'Public')], default='private', max_length=10),
),
migrations.AddField(
model_name='accountgroup',
name='shared_with',
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='accountgroup',
name='visibility',
field=models.CharField(choices=[('private', 'Private'), ('shared', 'Shared'), ('public', 'Public')], default='private', max_length=10),
),
migrations.AlterField(
model_name='account',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='account',
name='shared_with',
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='accountgroup',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.1.6 on 2025-03-06 01:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0012_alter_account_managers_alter_accountgroup_managers_and_more'),
]
operations = [
migrations.AlterField(
model_name='account',
name='visibility',
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10),
),
migrations.AlterField(
model_name='accountgroup',
name='visibility',
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10),
),
]
@@ -0,0 +1,21 @@
# Generated by Django 5.1.7 on 2025-03-09 21:54
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('accounts', '0013_alter_account_visibility_and_more'),
]
operations = [
migrations.AlterModelOptions(
name='account',
options={'ordering': ['name', 'id'], 'verbose_name': 'Account', 'verbose_name_plural': 'Accounts'},
),
migrations.AlterModelOptions(
name='accountgroup',
options={'ordering': ['name', 'id'], 'verbose_name': 'Account Group', 'verbose_name_plural': 'Account Groups'},
),
]
+16 -3
View File
@@ -1,23 +1,31 @@
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from apps.transactions.models import Transaction
from apps.common.models import SharedObject, SharedObjectManager
class AccountGroup(models.Model):
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True)
class AccountGroup(SharedObject):
name = models.CharField(max_length=255, verbose_name=_("Name"))
objects = SharedObjectManager()
all_objects = models.Manager() # Unfiltered manager
class Meta:
verbose_name = _("Account Group")
verbose_name_plural = _("Account Groups")
db_table = "account_groups"
unique_together = (("owner", "name"),)
ordering = ["name", "id"]
def __str__(self):
return self.name
class Account(models.Model):
class Account(SharedObject):
name = models.CharField(max_length=255, verbose_name=_("Name"))
group = models.ForeignKey(
AccountGroup,
@@ -55,9 +63,14 @@ class Account(models.Model):
help_text=_("Archived accounts don't show up nor count towards your net worth"),
)
objects = SharedObjectManager()
all_objects = models.Manager() # Unfiltered manager
class Meta:
verbose_name = _("Account")
verbose_name_plural = _("Accounts")
unique_together = (("owner", "name"),)
ordering = ["name", "id"]
def __str__(self):
return self.name
+20
View File
@@ -16,11 +16,21 @@ urlpatterns = [
views.account_edit,
name="account_edit",
),
path(
"account/<int:pk>/share/",
views.account_share,
name="account_share_settings",
),
path(
"account/<int:pk>/delete/",
views.account_delete,
name="account_delete",
),
path(
"account/<int:pk>/take-ownership/",
views.account_take_ownership,
name="account_take_ownership",
),
path("account-groups/", views.account_groups_index, name="account_groups_index"),
path("account-groups/list/", views.account_groups_list, name="account_groups_list"),
path("account-groups/add/", views.account_group_add, name="account_group_add"),
@@ -34,4 +44,14 @@ urlpatterns = [
views.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",
),
]
+80 -6
View File
@@ -2,14 +2,14 @@ from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from apps.accounts.forms import AccountGroupForm
from apps.accounts.models import AccountGroup
from apps.common.decorators.htmx import only_htmx
from apps.common.models import SharedObject
from apps.common.forms import SharedObjectForm
@login_required
@@ -65,6 +65,16 @@ def account_group_add(request, **kwargs):
def account_group_edit(request, 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":
form = AccountGroupForm(request.POST, instance=account_group)
if form.is_valid():
@@ -89,14 +99,19 @@ def account_group_edit(request, pk):
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def account_group_delete(request, pk):
account_group = get_object_or_404(AccountGroup, id=pk)
account_group.delete()
messages.success(request, _("Account Group deleted successfully"))
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()
messages.success(request, _("Account Group deleted successfully"))
return HttpResponse(
status=204,
@@ -104,3 +119,62 @@ def account_group_delete(request, pk):
"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},
)
+76 -6
View File
@@ -2,14 +2,14 @@ from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from apps.accounts.forms import AccountForm
from apps.accounts.models import Account
from apps.common.decorators.htmx import only_htmx
from apps.common.models import SharedObject
from apps.common.forms import SharedObjectForm
@login_required
@@ -64,6 +64,15 @@ def account_add(request, **kwargs):
@require_http_methods(["GET", "POST"])
def account_edit(request, 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":
form = AccountForm(request.POST, instance=account)
@@ -89,14 +98,75 @@ def account_edit(request, pk):
@only_htmx
@login_required
@csrf_exempt
@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
@login_required
@require_http_methods(["DELETE"])
def account_delete(request, pk):
account = get_object_or_404(Account, id=pk)
account.delete()
messages.success(request, _("Account deleted successfully"))
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()
messages.success(request, _("Account deleted successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def account_take_ownership(request, pk):
account = get_object_or_404(Account, id=pk)
if not account.owner:
account.owner = request.user
account.visibility = SharedObject.Visibility.private
account.save()
messages.success(request, _("Ownership taken successfully"))
return HttpResponse(
status=204,
+3 -3
View File
@@ -38,9 +38,9 @@ def account_reconciliation(request):
"prefix": account.currency.prefix,
"current_balance": get_account_balance(account),
}
for account in Account.objects.filter(is_archived=False).select_related(
"currency", "group"
)
for account in Account.objects.filter(is_archived=False)
.select_related("currency", "group")
.order_by("group", "name")
]
if request.method == "POST":
View File
+6
View File
@@ -0,0 +1,6 @@
from rest_framework.pagination import PageNumberPagination
class CustomPageNumberPagination(PageNumberPagination):
page_size = 100
page_size_query_param = "page_size"
+27 -7
View File
@@ -1,8 +1,6 @@
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from django.utils.translation import gettext_lazy as _
from apps.transactions.models import (
TransactionCategory,
TransactionTag,
@@ -29,7 +27,11 @@ class TransactionCategoryField(serializers.Field):
_("Category with this ID does not exist.")
)
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
raise serializers.ValidationError(
_("Invalid category data. Provide an ID or name.")
@@ -39,7 +41,10 @@ class TransactionCategoryField(serializers.Field):
def get_schema():
return {
"type": "array",
"items": {"type": "string", "description": "TransactionTag ID or name"},
"items": {
"type": "string",
"description": "TransactionCategory ID or name",
},
}
@@ -65,7 +70,11 @@ class TransactionTagField(serializers.Field):
_("Tag with this ID does not exist.")
)
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:
raise serializers.ValidationError(
_("Invalid tag data. Provide an ID or name.")
@@ -74,6 +83,13 @@ class TransactionTagField(serializers.Field):
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):
def to_representation(self, value):
return [{"id": entity.id, "name": entity.name} for entity in value.all()]
@@ -84,12 +100,16 @@ class TransactionEntityField(serializers.Field):
if isinstance(item, int):
try:
entity = TransactionEntity.objects.get(pk=item)
except TransactionTag.DoesNotExist:
except TransactionEntity.DoesNotExist:
raise serializers.ValidationError(
_("Entity with this ID does not exist.")
)
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:
raise serializers.ValidationError(
_("Invalid entity data. Provide an ID or name.")
+10
View File
@@ -0,0 +1,10 @@
from rest_framework.permissions import BasePermission
from django.conf import settings
class NotInDemoMode(BasePermission):
def has_permission(self, request, view):
if settings.DEMO and not request.user.is_superuser:
return False
else:
return True
+1
View File
@@ -1,3 +1,4 @@
from .transactions import *
from .accounts import *
from .currencies import *
from .dca import *
+14
View File
@@ -1,4 +1,6 @@
from django.db.models import Q
from rest_framework import serializers
from rest_framework.permissions import IsAuthenticated
from apps.api.serializers.currencies import CurrencySerializer
from apps.accounts.models import AccountGroup, Account
@@ -6,6 +8,8 @@ from apps.currencies.models import Currency
class AccountGroupSerializer(serializers.ModelSerializer):
permission_classes = [IsAuthenticated]
class Meta:
model = AccountGroup
fields = "__all__"
@@ -19,6 +23,7 @@ class AccountSerializer(serializers.ModelSerializer):
write_only=True,
allow_null=True,
)
currency = CurrencySerializer(read_only=True)
currency_id = serializers.PrimaryKeyRelatedField(
queryset=Currency.objects.all(), source="currency", write_only=True
@@ -31,6 +36,8 @@ class AccountSerializer(serializers.ModelSerializer):
allow_null=True,
)
permission_classes = [IsAuthenticated]
class Meta:
model = Account
fields = [
@@ -45,6 +52,13 @@ class AccountSerializer(serializers.ModelSerializer):
"is_asset",
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
request = self.context.get("request")
if request and request.user.is_authenticated:
# Reload the queryset to get an updated version with the requesting user
self.fields["group_id"].queryset = AccountGroup.objects.all()
def create(self, validated_data):
return Account.objects.create(**validated_data)
+6
View File
@@ -1,8 +1,12 @@
from rest_framework import serializers
from rest_framework.permissions import IsAuthenticated
from apps.currencies.models import Currency, ExchangeRate
class CurrencySerializer(serializers.ModelSerializer):
permission_classes = [IsAuthenticated]
class Meta:
model = Currency
fields = "__all__"
@@ -24,6 +28,8 @@ class ExchangeRateSerializer(serializers.ModelSerializer):
queryset=Currency.objects.all(), source="to_currency", write_only=True
)
permission_classes = [IsAuthenticated]
class Meta:
model = ExchangeRate
fields = "__all__"
+85
View File
@@ -0,0 +1,85 @@
from rest_framework import serializers
from rest_framework.permissions import IsAuthenticated
from apps.dca.models import DCAEntry, DCAStrategy
class DCAEntrySerializer(serializers.ModelSerializer):
profit_loss = serializers.DecimalField(
max_digits=42, decimal_places=30, read_only=True
)
profit_loss_percentage = serializers.DecimalField(
max_digits=42, decimal_places=30, read_only=True
)
current_value = serializers.DecimalField(
max_digits=42, decimal_places=30, read_only=True
)
entry_price = serializers.DecimalField(
max_digits=42, decimal_places=30, read_only=True
)
permission_classes = [IsAuthenticated]
class Meta:
model = DCAEntry
fields = [
"id",
"strategy",
"date",
"amount_paid",
"amount_received",
"notes",
"created_at",
"updated_at",
"profit_loss",
"profit_loss_percentage",
"current_value",
"entry_price",
]
read_only_fields = ["created_at", "updated_at"]
class DCAStrategySerializer(serializers.ModelSerializer):
entries = DCAEntrySerializer(many=True, read_only=True)
total_invested = serializers.DecimalField(
max_digits=42, decimal_places=30, read_only=True
)
total_received = serializers.DecimalField(
max_digits=42, decimal_places=30, read_only=True
)
average_entry_price = serializers.DecimalField(
max_digits=42, decimal_places=30, read_only=True
)
total_entries = serializers.IntegerField(read_only=True)
current_total_value = serializers.DecimalField(
max_digits=42, decimal_places=30, read_only=True
)
total_profit_loss = serializers.DecimalField(
max_digits=42, decimal_places=30, read_only=True
)
total_profit_loss_percentage = serializers.DecimalField(
max_digits=42, decimal_places=30, read_only=True
)
permission_classes = [IsAuthenticated]
class Meta:
model = DCAStrategy
fields = [
"id",
"name",
"target_currency",
"payment_currency",
"notes",
"created_at",
"updated_at",
"entries",
"total_invested",
"total_received",
"average_entry_price",
"total_entries",
"current_total_value",
"total_profit_loss",
"total_profit_loss_percentage",
]
read_only_fields = ["created_at", "updated_at"]
+100 -5
View File
@@ -1,3 +1,5 @@
from decimal import Decimal
from django.utils.translation import gettext_lazy as _
from drf_spectacular import openapi
from drf_spectacular.types import OpenApiTypes
@@ -19,7 +21,9 @@ from apps.transactions.models import (
TransactionTag,
InstallmentPlan,
TransactionEntity,
RecurringTransaction,
)
from apps.common.middleware.thread_local import get_current_user
class TransactionCategorySerializer(serializers.ModelSerializer):
@@ -28,6 +32,10 @@ class TransactionCategorySerializer(serializers.ModelSerializer):
class Meta:
model = TransactionCategory
fields = "__all__"
read_only_fields = [
"id",
"owner",
]
class TransactionTagSerializer(serializers.ModelSerializer):
@@ -36,6 +44,10 @@ class TransactionTagSerializer(serializers.ModelSerializer):
class Meta:
model = TransactionTag
fields = "__all__"
read_only_fields = [
"id",
"owner",
]
class TransactionEntitySerializer(serializers.ModelSerializer):
@@ -44,20 +56,95 @@ class TransactionEntitySerializer(serializers.ModelSerializer):
class Meta:
model = TransactionEntity
fields = "__all__"
read_only_fields = [
"id",
"owner",
]
class InstallmentPlanSerializer(serializers.ModelSerializer):
category: str | int = TransactionCategoryField(required=False)
tags: str | int = TransactionTagField(required=False)
entities: str | int = TransactionEntityField(required=False)
permission_classes = [IsAuthenticated]
class Meta:
model = InstallmentPlan
fields = "__all__"
fields = [
"id",
"account",
"type",
"description",
"number_of_installments",
"installment_start",
"installment_total_number",
"start_date",
"reference_date",
"end_date",
"recurrence",
"installment_amount",
"category",
"tags",
"entities",
"notes",
]
read_only_fields = ["installment_total_number", "end_date"]
def create(self, validated_data):
instance = super().create(validated_data)
instance.create_transactions()
return instance
def update(self, instance, validated_data):
instance = super().update(instance, validated_data)
instance.update_transactions()
return instance
class RecurringTransactionSerializer(serializers.ModelSerializer):
category: str | int = TransactionCategoryField(required=False)
tags: str | int = TransactionTagField(required=False)
entities: str | int = TransactionEntityField(required=False)
class Meta:
model = RecurringTransaction
fields = [
"id",
"is_paused",
"account",
"type",
"amount",
"description",
"category",
"tags",
"entities",
"notes",
"reference_date",
"start_date",
"end_date",
"recurrence_type",
"recurrence_interval",
"last_generated_date",
"last_generated_reference_date",
]
read_only_fields = ["last_generated_date", "last_generated_reference_date"]
def create(self, validated_data):
instance = super().create(validated_data)
instance.create_upcoming_transactions()
return instance
def update(self, instance, validated_data):
instance = super().update(instance, validated_data)
instance.update_unpaid_transactions()
return instance
class TransactionSerializer(serializers.ModelSerializer):
category = TransactionCategoryField(required=False)
tags = TransactionTagField(required=False)
entities = TransactionEntityField(required=False)
category: str | int = TransactionCategoryField(required=False)
tags: str | int = TransactionTagField(required=False)
entities: str | int = TransactionEntityField(required=False)
exchanged_amount = serializers.SerializerMethodField()
@@ -83,8 +170,16 @@ class TransactionSerializer(serializers.ModelSerializer):
"installment_plan",
"recurring_transaction",
"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):
if not self.partial:
if "date" in data and "reference_date" not in data:
@@ -120,5 +215,5 @@ class TransactionSerializer(serializers.ModelSerializer):
return instance
@staticmethod
def get_exchanged_amount(obj):
def get_exchanged_amount(obj) -> Decimal:
return obj.exchanged_amount()
+3
View File
@@ -9,10 +9,13 @@ router.register(r"categories", views.TransactionCategoryViewSet)
router.register(r"tags", views.TransactionTagViewSet)
router.register(r"entities", views.TransactionEntityViewSet)
router.register(r"installment-plans", views.InstallmentPlanViewSet)
router.register(r"recurring-transactions", views.RecurringTransactionViewSet)
router.register(r"account-groups", views.AccountGroupViewSet)
router.register(r"accounts", views.AccountViewSet)
router.register(r"currencies", views.CurrencyViewSet)
router.register(r"exchange-rates", views.ExchangeRateViewSet)
router.register(r"dca/strategies", views.DCAStrategyViewSet)
router.register(r"dca/entries", views.DCAEntryViewSet)
urlpatterns = [
path("", include(router.urls)),
+1
View File
@@ -1,3 +1,4 @@
from .transactions import *
from .accounts import *
from .currencies import *
from .dca import *
+12 -2
View File
@@ -1,4 +1,6 @@
from rest_framework import viewsets
from apps.api.custom.pagination import CustomPageNumberPagination
from apps.accounts.models import AccountGroup, Account
from apps.api.serializers import AccountGroupSerializer, AccountSerializer
@@ -6,12 +8,20 @@ from apps.api.serializers import AccountGroupSerializer, AccountSerializer
class AccountGroupViewSet(viewsets.ModelViewSet):
queryset = AccountGroup.objects.all()
serializer_class = AccountGroupSerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self):
return AccountGroup.objects.all().order_by("id")
class AccountViewSet(viewsets.ModelViewSet):
queryset = Account.objects.all()
serializer_class = AccountSerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self):
queryset = super().get_queryset()
return queryset.select_related("group", "currency", "exchange_currency")
return (
Account.objects.all()
.order_by("id")
.select_related("group", "currency", "exchange_currency")
)
+41
View File
@@ -0,0 +1,41 @@
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from apps.dca.models import DCAStrategy, DCAEntry
from apps.api.serializers import DCAStrategySerializer, DCAEntrySerializer
class DCAStrategyViewSet(viewsets.ModelViewSet):
queryset = DCAStrategy.objects.all()
serializer_class = DCAStrategySerializer
@action(detail=True, methods=["get"])
def investment_frequency(self, request, pk=None):
strategy = self.get_object()
return Response(strategy.investment_frequency_data())
@action(detail=True, methods=["get"])
def price_comparison(self, request, pk=None):
strategy = self.get_object()
return Response(strategy.price_comparison_data())
@action(detail=True, methods=["get"])
def current_price(self, request, pk=None):
strategy = self.get_object()
price_data = strategy.current_price()
if price_data:
price, date = price_data
return Response({"price": price, "date": date})
return Response({"price": None, "date": None})
class DCAEntryViewSet(viewsets.ModelViewSet):
queryset = DCAEntry.objects.all()
serializer_class = DCAEntrySerializer
def get_queryset(self):
queryset = DCAEntry.objects.all()
strategy_id = self.request.query_params.get("strategy", None)
if strategy_id is not None:
queryset = queryset.filter(strategy_id=strategy_id)
return queryset
+31 -7
View File
@@ -1,11 +1,13 @@
from rest_framework import permissions, viewsets
from rest_framework import viewsets
from apps.api.custom.pagination import CustomPageNumberPagination
from apps.api.serializers import (
TransactionSerializer,
TransactionCategorySerializer,
TransactionTagSerializer,
InstallmentPlanSerializer,
TransactionEntitySerializer,
RecurringTransactionSerializer,
)
from apps.transactions.models import (
Transaction,
@@ -13,6 +15,7 @@ from apps.transactions.models import (
TransactionTag,
InstallmentPlan,
TransactionEntity,
RecurringTransaction,
)
from apps.rules.signals import transaction_updated, transaction_created
@@ -20,6 +23,7 @@ from apps.rules.signals import transaction_updated, transaction_created
class TransactionViewSet(viewsets.ModelViewSet):
queryset = Transaction.objects.all()
serializer_class = TransactionSerializer
pagination_class = CustomPageNumberPagination
def perform_create(self, serializer):
instance = serializer.save()
@@ -33,30 +37,50 @@ class TransactionViewSet(viewsets.ModelViewSet):
kwargs["partial"] = True
return self.update(request, *args, **kwargs)
def get_queryset(self):
return Transaction.objects.all().order_by("-id")
class TransactionCategoryViewSet(viewsets.ModelViewSet):
queryset = TransactionCategory.objects.all()
serializer_class = TransactionCategorySerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self):
return TransactionCategory.objects.all().order_by("id")
class TransactionTagViewSet(viewsets.ModelViewSet):
queryset = TransactionTag.objects.all()
serializer_class = TransactionTagSerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self):
return TransactionTag.objects.all().order_by("id")
class TransactionEntityViewSet(viewsets.ModelViewSet):
queryset = TransactionEntity.objects.all()
serializer_class = TransactionEntitySerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self):
return TransactionEntity.objects.all().order_by("id")
class InstallmentPlanViewSet(viewsets.ModelViewSet):
queryset = InstallmentPlan.objects.all()
serializer_class = InstallmentPlanSerializer
pagination_class = CustomPageNumberPagination
def perform_create(self, serializer):
instance = serializer.save()
instance.create_transactions()
def get_queryset(self):
return InstallmentPlan.objects.all().order_by("-id")
def perform_update(self, serializer):
instance = serializer.save()
instance.create_transactions()
class RecurringTransactionViewSet(viewsets.ModelViewSet):
queryset = RecurringTransaction.objects.all()
serializer_class = RecurringTransactionSerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self):
return RecurringTransaction.objects.all().order_by("-id")
+7
View File
@@ -0,0 +1,7 @@
from django.contrib import admin
class SharedObjectModelAdmin(admin.ModelAdmin):
def get_queryset(self, request):
# Use the all_objects manager to show all transactions, including deleted ones
return self.model.all_objects.all()
+14
View File
@@ -4,3 +4,17 @@ from django.apps import AppConfig
class CommonConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.common"
def ready(self):
from django.contrib import admin
from django.contrib.sites.models import Site
from allauth.socialaccount.models import (
SocialAccount,
SocialApp,
SocialToken,
)
admin.site.unregister(Site)
admin.site.unregister(SocialAccount)
admin.site.unregister(SocialApp)
admin.site.unregister(SocialToken)
+15
View File
@@ -0,0 +1,15 @@
from functools import wraps
from django.conf import settings
from django.core.exceptions import PermissionDenied
def disabled_on_demo(view):
@wraps(view)
def _view(request, *args, **kwargs):
if settings.DEMO and not request.user.is_superuser:
raise PermissionDenied
return view(request, *args, **kwargs)
return _view
+78
View File
@@ -0,0 +1,78 @@
from functools import wraps
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.http import HttpResponse
from django.shortcuts import redirect
from django.urls import reverse, NoReverseMatch
def is_superuser(view):
@wraps(view)
def _view(request, *args, **kwargs):
if not request.user.is_superuser:
raise PermissionDenied
return view(request, *args, **kwargs)
return _view
def htmx_login_required(function=None, login_url=None):
"""
Decorator that checks if the user is logged in.
Allows overriding the default login URL.
If the user is not logged in:
- If "hx-request" is present in the request header, it returns a 200 response
with a "HX-Redirect" header containing the determined login URL (including the "next" parameter).
- If "hx-request" is not present, it redirects to the determined login page normally.
Args:
function: The view function to decorate.
login_url: Optional. The URL or URL name to redirect to for login.
Defaults to settings.LOGIN_URL.
"""
def decorator(view_func):
# Simplified @wraps usage - it handles necessary attribute assignments by default
@wraps(view_func)
def wrapped_view(request, *args, **kwargs):
if request.user.is_authenticated:
return view_func(request, *args, **kwargs)
else:
# Determine the login URL
resolved_login_url = login_url
if not resolved_login_url:
resolved_login_url = settings.LOGIN_URL
# Try to reverse the URL name if it's not a path
try:
# Check if it looks like a URL path already
if "/" not in resolved_login_url and "." not in resolved_login_url:
login_url_path = reverse(resolved_login_url)
else:
login_url_path = resolved_login_url
except NoReverseMatch:
# If reverse fails, assume it's already a URL path
login_url_path = resolved_login_url
# Construct the full redirect path with 'next' parameter
# Ensure request.path is URL-encoded if needed, though Django usually handles this
redirect_path = f"{login_url_path}?next={request.get_full_path()}" # Use get_full_path() to include query params
if request.headers.get("hx-request"):
# For HTMX requests, return a 200 with the HX-Redirect header.
response = HttpResponse()
response["HX-Redirect"] = login_url_path
return response
else:
# For regular requests, redirect to the login page.
return redirect(redirect_path)
return wrapped_view
if function:
return decorator(function)
return decorator
+38 -38
View File
@@ -4,6 +4,7 @@ from django.db import transaction
from django.utils.translation import gettext_lazy as _
from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple
from apps.common.middleware.thread_local import get_current_user
class DynamicModelChoiceField(forms.ModelChoiceField):
@@ -12,15 +13,14 @@ class DynamicModelChoiceField(forms.ModelChoiceField):
self.to_field_name = kwargs.pop("to_field_name", "pk")
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())
super().__init__(queryset=self.queryset, *args, **kwargs)
self._created_instance = None
self.widget = TomSelect(clear_button=True, create=True)
super().__init__(queryset=self.queryset, *args, **kwargs)
self._created_instance = None
def to_python(self, value):
if value in self.empty_values:
return None
@@ -53,18 +53,27 @@ class DynamicModelChoiceField(forms.ModelChoiceField):
else:
raise self.model.DoesNotExist
except self.model.DoesNotExist:
try:
with transaction.atomic():
instance, _ = self.model.objects.update_or_create(
**{self.create_field: value}
)
self._created_instance = instance
return instance
except Exception as e:
print(e)
if self.create_field:
try:
with transaction.atomic():
# First try to get the object
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
return instance
except Exception as e:
raise ValidationError(_("Error creating new instance"))
else:
raise ValidationError(
self.error_messages["invalid_choice"], code="invalid_choice"
)
return super().clean(value)
def bound_data(self, data, initial):
@@ -87,8 +96,6 @@ class DynamicModelMultipleChoiceField(forms.ModelMultipleChoiceField):
def __init__(self, model, **kwargs):
"""
Initialize the CreateIfNotExistsModelMultipleChoiceField.
Args:
create_field (str): The name of the field to use when creating new instances.
*args: Variable length argument list.
@@ -120,33 +127,28 @@ class DynamicModelMultipleChoiceField(forms.ModelMultipleChoiceField):
"""
try:
with transaction.atomic():
instance, _ = self.model.objects.update_or_create(
**{self.create_field: value}
)
return instance
# Check if exists first without using update_or_create
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
except Exception as e:
print(e)
raise ValidationError(_("Error creating new instance"))
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:
return []
string_values = set(str(v) for v in value)
# Get existing objects first
existing_objects = list(
self.queryset.filter(**{f"{self.create_field}__in": string_values})
)
@@ -154,13 +156,11 @@ class DynamicModelMultipleChoiceField(forms.ModelMultipleChoiceField):
str(getattr(obj, self.create_field)) for obj in existing_objects
)
# Create new objects for missing values
new_values = string_values - existing_values
new_objects = []
for new_value in new_values:
try:
new_objects.append(self._create_new_instance(new_value))
except ValidationError as e:
raise ValidationError(_("Error creating new instance"))
new_objects.append(self._create_new_instance(new_value))
return existing_objects + new_objects
+18 -4
View File
@@ -1,9 +1,11 @@
import datetime
from django import forms
from django.db import models
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _
from apps.common.widgets.datepicker import AirMonthYearPickerInput
from apps.common.widgets.month_year import MonthYearWidget
@@ -18,7 +20,15 @@ class MonthYearModelField(models.DateField):
# Set the day to 1
return date.replace(day=1).date()
except ValueError:
raise ValidationError(_("Invalid date format. Use YYYY-MM."))
try:
# Also accept YYYY-MM-DD format (for loaddata)
return (
datetime.datetime.strptime(value, "%Y-%m-%d").replace(day=1).date()
)
except ValueError:
raise ValidationError(
_("Invalid date format. Use YYYY-MM or YYYY-MM-DD.")
)
def formfield(self, **kwargs):
kwargs["widget"] = MonthYearWidget
@@ -27,7 +37,7 @@ class MonthYearModelField(models.DateField):
class MonthYearFormField(forms.DateField):
widget = MonthYearWidget
widget = AirMonthYearPickerInput
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -42,7 +52,11 @@ class MonthYearFormField(forms.DateField):
date = datetime.datetime.strptime(value, "%Y-%m")
return date.replace(day=1).date()
except ValueError:
raise ValidationError(_("Invalid date format. Use YYYY-MM."))
try:
date = datetime.datetime.strptime(value, "%Y-%m-%d")
return date.replace(day=1).date()
except ValueError:
raise ValidationError(_("Invalid date format. Use YYYY-MM."))
def prepare_value(self, value):
if isinstance(value, datetime.date):
+113
View File
@@ -0,0 +1,113 @@
from crispy_forms.bootstrap import FormActions
from django import forms
from django.contrib.auth import get_user_model
from django.utils.translation import gettext_lazy as _
from django.core.exceptions import ValidationError
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Field, Submit, Div, HTML
from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple
from apps.common.models import SharedObject
from apps.common.widgets.crispy.submit import NoClassSubmit
User = get_user_model()
class SharedObjectForm(forms.Form):
"""
Generic form for editing visibility and sharing settings
for models inheriting from SharedObject.
"""
owner = forms.ModelChoiceField(
queryset=User.objects.all(),
required=False,
label=_("Owner"),
widget=TomSelect(clear_button=False),
help_text=_(
"The owner of this object, if empty all users can see, edit and take ownership."
),
)
shared_with_users = forms.ModelMultipleChoiceField(
queryset=User.objects.all(),
required=False,
widget=TomSelectMultiple(clear_button=True),
label=_("Shared with users"),
help_text=_("Select users to share this object with"),
)
visibility = forms.ChoiceField(
choices=SharedObject.Visibility.choices,
required=True,
label=_("Visibility"),
help_text=_(
"Private: Only shown for the owner and shared users. Only editable by the owner."
"<br/>"
"Public: Shown for all users. Only editable by the owner."
),
)
class Meta:
fields = ["visibility", "shared_with_users"]
widgets = {
"visibility": TomSelect(clear_button=False),
}
def __init__(self, *args, **kwargs):
# Get the current user to filter available sharing options
self.user = kwargs.pop("user", None)
self.instance = kwargs.pop("instance", None)
super().__init__(*args, **kwargs)
# Pre-populate shared users if instance exists
if self.instance:
self.fields["shared_with_users"].initial = self.instance.shared_with.all()
self.fields["visibility"].initial = self.instance.visibility
self.fields["owner"].initial = self.instance.owner
# Set up crispy form helper
self.helper = FormHelper()
self.helper.form_method = "post"
self.helper.form_tag = False
self.helper.layout = Layout(
Field("owner"),
Field("visibility"),
HTML("<hr>"),
Field("shared_with_users"),
FormActions(
NoClassSubmit(
"submit", _("Save"), css_class="btn btn-outline-primary w-100"
),
),
)
def clean(self):
cleaned_data = super().clean()
owner = cleaned_data.get("owner")
shared_with_users = cleaned_data.get("shared_with_users", [])
# Raise validation error if owner is in shared_with_users
if owner and owner in shared_with_users:
self.add_error(
"shared_with_users",
ValidationError(
_("You cannot share this item with its owner."),
code="invalid_share",
),
)
return cleaned_data
def save(self):
instance = self.instance
instance.visibility = self.cleaned_data["visibility"]
instance.owner = self.cleaned_data["owner"]
instance.save()
# Clear and set shared_with users
instance.shared_with.set(self.cleaned_data.get("shared_with_users", []))
return instance
+31
View File
@@ -0,0 +1,31 @@
from apps.common.middleware.thread_local import get_current_user
from django.utils.formats import get_format as original_get_format
def get_format(format_type=None, lang=None, use_l10n=None):
user = get_current_user()
if user and user.is_authenticated and hasattr(user, "settings") and use_l10n:
user_settings = user.settings
if format_type == "THOUSAND_SEPARATOR":
number_format = getattr(user_settings, "number_format", None)
if number_format == "DC":
return "."
elif number_format == "CD":
return ","
elif format_type == "DECIMAL_SEPARATOR":
number_format = getattr(user_settings, "number_format", None)
if number_format == "DC":
return ","
elif number_format == "CD":
return "."
elif format_type == "SHORT_DATE_FORMAT":
date_format = getattr(user_settings, "date_format", None)
if date_format and date_format != "SHORT_DATE_FORMAT":
return date_format
elif format_type == "SHORT_DATETIME_FORMAT":
datetime_format = getattr(user_settings, "datetime_format", None)
if datetime_format and datetime_format != "SHORT_DATETIME_FORMAT":
return datetime_format
return original_get_format(format_type, lang, use_l10n)
@@ -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."))
+11 -3
View File
@@ -1,14 +1,17 @@
import zoneinfo
from django.utils import formats
from django.utils import timezone, translation
from django.utils.translation import activate
from django.utils.functional import lazy
from apps.common.functions.format import get_format as custom_get_format
from apps.users.models import UserSettings
class LocalizationMiddleware:
def __init__(self, get_response):
self.get_response = get_response
self.patch_get_format()
def __call__(self, request):
tz = request.COOKIES.get("mytz")
@@ -33,9 +36,14 @@ class LocalizationMiddleware:
timezone.activate(zoneinfo.ZoneInfo("UTC"))
if user_language and user_language != "auto":
activate(user_language)
translation.activate(user_language)
else:
detected_language = translation.get_language_from_request(request)
activate(detected_language)
translation.activate(detected_language)
return self.get_response(request)
@staticmethod
def patch_get_format():
formats.get_format = custom_get_format
formats.get_format_lazy = lazy(custom_get_format, str, list, tuple)
@@ -0,0 +1,83 @@
"""
threadlocals middleware
~~~~~~~~~~~~~~~~~~~~~~~
make the request object everywhere available (e.g. in model instance).
based on: http://code.djangoproject.com/wiki/CookBookThreadlocalsAndUser
Put this into your settings:
--------------------------------------------------------------------------
MIDDLEWARE_CLASSES = (
...
'django_tools.middlewares.ThreadLocal.ThreadLocalMiddleware',
...
)
--------------------------------------------------------------------------
Usage:
--------------------------------------------------------------------------
from django_tools.middlewares import ThreadLocal
# Get the current request object:
request = ThreadLocal.get_current_request()
# You can get the current user directly with:
user = ThreadLocal.get_current_user()
--------------------------------------------------------------------------
:copyleft: 2009-2017 by the django-tools team, see AUTHORS for more details.
:license: GNU GPL v3 or above, see LICENSE for more details.
"""
try:
from threading import local
except ImportError:
from django.utils._threading_local import local
try:
from django.utils.deprecation import MiddlewareMixin
except ImportError:
MiddlewareMixin = object # fallback for Django < 1.10
_thread_locals = local()
def get_current_request():
"""returns the request object for this thread"""
return getattr(_thread_locals, "request", None)
def get_current_user():
"""returns the current user, if exist, otherwise returns None"""
request = get_current_request()
if request:
return getattr(request, "user", None)
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):
"""Simple middleware that adds the request object in thread local storage."""
def process_request(self, request):
_thread_locals.request = request
def process_response(self, request, response):
if hasattr(_thread_locals, "request"):
del _thread_locals.request
return response
def process_exception(self, request, exception):
if hasattr(_thread_locals, "request"):
del _thread_locals.request
+96
View File
@@ -0,0 +1,96 @@
from django.db import models
from django.conf import settings
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from apps.common.middleware.thread_local import get_current_user
class SharedObjectManager(models.Manager):
def get_queryset(self):
"""Return only objects the user can access"""
user = get_current_user()
base_qs = super().get_queryset()
if user and user.is_authenticated:
return base_qs.filter(
Q(visibility="public")
| Q(owner=user)
| Q(shared_with=user)
| Q(visibility="private", owner=None)
).distinct()
return base_qs.filter(visibility="public")
class SharedObject(models.Model):
# Access control enum
class Visibility(models.TextChoices):
private = "private", _("Private")
is_paid = "public", _("Public")
# Core sharing fields
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="%(class)s_owned",
null=True,
blank=True,
)
visibility = models.CharField(
max_length=10, choices=Visibility.choices, default=Visibility.private
)
shared_with = models.ManyToManyField(
settings.AUTH_USER_MODEL, related_name="%(class)s_shared", blank=True
)
# Use as abstract base class
class Meta:
abstract = True
indexes = [
models.Index(fields=["visibility"]),
]
def is_accessible_by(self, user):
"""Check if a user can access this object"""
return (
self.visibility == "public"
or self.owner == user
or (self.visibility == "shared" and user in self.shared_with.all())
)
def save(self, *args, **kwargs):
if not self.pk and not self.owner:
self.owner = get_current_user()
super().save(*args, **kwargs)
class OwnedObjectManager(models.Manager):
def get_queryset(self):
"""Return only objects the user can access"""
user = get_current_user()
base_qs = super().get_queryset()
if user and user.is_authenticated:
return base_qs.filter(Q(owner=user) | Q(owner=None)).distinct()
return base_qs
class OwnedObject(models.Model):
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="%(class)s_owned",
null=True,
blank=True,
)
# Use as abstract base class
class Meta:
abstract = True
def save(self, *args, **kwargs):
if not self.pk and not self.owner:
self.owner = get_current_user()
super().save(*args, **kwargs)
+56 -1
View File
@@ -1,5 +1,10 @@
import logging
from asgiref.sync import sync_to_async
from django.conf import settings
from django.core import management
from django.db import DEFAULT_DB_ALIAS
from procrastinate import builtin_tasks
from procrastinate.contrib.django import app
@@ -8,7 +13,7 @@ logger = logging.getLogger(__name__)
@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):
try:
return await builtin_tasks.remove_old_jobs(
@@ -24,3 +29,53 @@ async def remove_old_jobs(context, timestamp):
exc_info=True,
)
raise e
@app.periodic(cron="0 6 1 * *")
@app.task(queueing_lock="remove_expired_sessions", name="remove_expired_sessions")
async def remove_expired_sessions(timestamp=None):
"""Cleanup expired sessions by using Django management command."""
try:
await sync_to_async(management.call_command)("clearsessions", verbosity=0)
except Exception:
logger.error(
"Error while executing 'remove_expired_sessions' task",
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
+1 -1
View File
@@ -1,6 +1,6 @@
from django import template
from django.utils.formats import get_format
from apps.common.functions.format import get_format
register = template.Library()
+11
View File
@@ -0,0 +1,11 @@
import json
from django import template
register = template.Library()
@register.filter("json")
def convert_to_json(value):
return json.dumps(value)
+52
View File
@@ -0,0 +1,52 @@
from typing import Optional
import mistune
from django import template
from django.utils.safestring import mark_safe
from mistune import HTMLRenderer, Markdown, BlockParser, InlineParser, safe_entity
from mistune.plugins.formatting import strikethrough as plugin_strikethrough
from mistune.plugins.url import url as plugin_url
register = template.Library()
class CustomRenderer(HTMLRenderer):
def link(self, text: str, url: str, title: Optional[str] = None) -> str:
s = '<a rel="nofollow" target="_blank" href="' + self.safe_url(url) + '"'
if title:
s += ' title="' + safe_entity(title) + '"'
return s + ">" + text + "</a>"
def paragraph(self, text: str) -> str:
return text + "\n"
def softbreak(self) -> str:
return "\n"
def blank_line(self) -> str:
return "\n"
block = BlockParser()
block.rules = ["blank_line"]
inline = InlineParser(hard_wrap=False)
inline.rules = [
"emphasis",
"link",
"auto_link",
"auto_email",
"linebreak",
"softbreak",
]
markdown = Markdown(
renderer=CustomRenderer(escape=False),
block=block,
inline=inline,
plugins=[plugin_strikethrough, plugin_url],
)
@register.filter(name="limited_markdown")
def limited_markdown(value):
return mark_safe(markdown(value))
+9
View File
@@ -0,0 +1,9 @@
from django import template
from django.conf import settings
register = template.Library()
@register.simple_tag(name="settings")
def settings_value(name):
return getattr(settings, name, "")
+5
View File
@@ -13,4 +13,9 @@ urlpatterns = [
views.month_year_picker,
name="month_year_picker",
),
path(
"cache/invalidate/",
views.invalidate_cache,
name="invalidate_cache",
),
]
+161
View File
@@ -0,0 +1,161 @@
def django_to_python_datetime(django_format):
mapping = {
# Day
"j": "%d", # Day of the month without leading zeros
"d": "%d", # Day of the month with leading zeros
"D": "%a", # Day of the week, short version
"l": "%A", # Day of the week, full version
# Month
"n": "%m", # Month without leading zeros
"m": "%m", # Month with leading zeros
"M": "%b", # Month, short version
"F": "%B", # Month, full version
# Year
"y": "%y", # Year, 2 digits
"Y": "%Y", # Year, 4 digits
# Time
"g": "%I", # Hour (12-hour), without leading zeros
"G": "%H", # Hour (24-hour), without leading zeros
"h": "%I", # Hour (12-hour), with leading zeros
"H": "%H", # Hour (24-hour), with leading zeros
"i": "%M", # Minutes
"s": "%S", # Seconds
"a": "%p", # am/pm
"A": "%p", # AM/PM
"P": "%I:%M %p",
}
python_format = django_format
for django_code, python_code in mapping.items():
python_format = python_format.replace(django_code, python_code)
return python_format
def django_to_airdatepicker_datetime(django_format):
format_map = {
# Time
"h": "hh", # Hour (12-hour)
"H": "H", # Hour (24-hour)
"i": "m", # Minutes
"A": "AA", # AM/PM uppercase
"a": "aa", # am/pm lowercase
"P": "h:mm AA", # Localized time format (e.g., "2:30 PM")
# Date
"D": "E", # Short weekday name
"l": "EEEE", # Full weekday name
"j": "d", # Day of month without leading zero
"d": "dd", # Day of month with leading zero
"n": "M", # Month without leading zero
"m": "MM", # Month with leading zero
"M": "MMM", # Short month name
"F": "MMMM", # Full month name
"y": "yy", # Year, 2 digits
"Y": "yyyy", # Year, 4 digits
}
result = ""
i = 0
while i < len(django_format):
char = django_format[i]
if char == "\\": # Handle escaped characters
if i + 1 < len(django_format):
result += django_format[i + 1]
i += 2
continue
if char in format_map:
result += format_map[char]
else:
result += char
i += 1
return result
def django_to_airdatepicker_datetime_separated(django_format):
format_map = {
# Time formats
"h": "hh", # Hour (12-hour)
"H": "HH", # Hour (24-hour)
"i": "mm", # Minutes
"A": "AA", # AM/PM uppercase
"a": "aa", # am/pm lowercase
"P": "h:mm aa", # Localized time format
# Date formats
"D": "E", # Short weekday name
"l": "EEEE", # Full weekday name
"j": "d", # Day of month without leading zero
"d": "dd", # Day of month with leading zero
"n": "M", # Month without leading zero
"m": "MM", # Month with leading zero
"M": "MMM", # Short month name
"F": "MMMM", # Full month name
"y": "yy", # Year, 2 digits
"Y": "yyyy", # Year, 4 digits
}
# Define which characters belong to time format
time_chars = {"h", "H", "i", "A", "a", "P"}
date_chars = {"D", "l", "j", "d", "n", "m", "M", "F", "y", "Y"}
date_parts = []
time_parts = []
current_part = []
is_time = False
i = 0
while i < len(django_format):
char = django_format[i]
if char == "\\": # Handle escaped characters
if i + 1 < len(django_format):
current_part.append(django_format[i + 1])
i += 2
continue
if char in format_map:
if char in time_chars:
# If we were building a date part, save it and start a time part
if current_part and not is_time:
date_parts.append("".join(current_part))
current_part = []
is_time = True
current_part.append(format_map[char])
elif char in date_chars:
# If we were building a time part, save it and start a date part
if current_part and is_time:
time_parts.append("".join(current_part))
current_part = []
is_time = False
current_part.append(format_map[char])
else:
# Handle separators
if char in "/:.-":
current_part.append(char)
elif char == " ":
if current_part:
if is_time:
time_parts.append("".join(current_part))
else:
date_parts.append("".join(current_part))
current_part = []
current_part.append(char)
i += 1
# Don't forget the last part
if current_part:
if is_time:
time_parts.append("".join(current_part))
else:
date_parts.append("".join(current_part))
date_format = "".join(date_parts)
time_format = "".join(time_parts)
# Clean up multiple spaces while preserving necessary ones
date_format = " ".join(filter(None, date_format.split()))
time_format = " ".join(filter(None, time_format.split()))
return date_format, time_format
+32
View File
@@ -1,17 +1,33 @@
from dateutil.relativedelta import relativedelta
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.db.models import Count
from django.db.models.functions import ExtractYear, ExtractMonth
from django.http import HttpResponse
from django.shortcuts import render
from django.urls import reverse
from django.utils import timezone
from django.views.decorators.http import require_http_methods
from django.utils.translation import gettext_lazy as _
from cachalot.api import invalidate
from apps.common.decorators.htmx import only_htmx
from apps.transactions.models import Transaction
from apps.common.decorators.user import htmx_login_required
@only_htmx
@htmx_login_required
@require_http_methods(["GET"])
def toasts(request):
return render(request, "common/fragments/toasts.html")
@only_htmx
@login_required
@require_http_methods(["GET"])
def month_year_picker(request):
field = request.GET.get("field", "reference_date")
for_ = request.GET.get("for", None)
@@ -84,3 +100,19 @@ def month_year_picker(request):
"current_year": current_year,
},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def invalidate_cache(request):
invalidate()
messages.success(request, _("Cache cleared successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated",
},
)
+304
View File
@@ -0,0 +1,304 @@
import datetime
from django.forms import widgets
from django.utils import formats, translation, dates
from django.utils.translation import gettext_lazy as _
from apps.common.utils.django import (
django_to_python_datetime,
django_to_airdatepicker_datetime,
django_to_airdatepicker_datetime_separated,
)
from apps.common.functions.format import get_format
class AirDatePickerInput(widgets.DateInput):
def __init__(
self,
attrs=None,
format=None,
clear_button=True,
auto_close=True,
read_only=True,
toggle_selected=None,
*args,
**kwargs,
):
attrs = attrs or {}
super().__init__(attrs=attrs, format=format, *args, **kwargs)
self.clear_button = clear_button
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
def _get_current_language():
"""Get current language code in format compatible with AirDatepicker"""
lang_code = translation.get_language()
# AirDatepicker uses simple language codes, except for pt-br
if lang_code.lower() == "pt-br":
return "pt-BR"
return lang_code.split("-")[0]
def _get_format(self):
"""Get the format string based on user settings or default"""
if self.format:
return self.format
return get_format("SHORT_DATE_FORMAT", use_l10n=True)
def build_attrs(self, base_attrs, extra_attrs=None):
attrs = super().build_attrs(base_attrs, extra_attrs)
attrs["data-now-button-txt"] = _("Today")
attrs["data-auto-close"] = str(self.auto_close).lower()
attrs["data-clear-button"] = str(self.clear_button).lower()
attrs["data-toggle-selected"] = str(self.toggle_selected).lower()
attrs["data-language"] = self._get_current_language()
attrs["data-date-format"] = django_to_airdatepicker_datetime(self._get_format())
if self.read_only:
attrs["readonly"] = True
return attrs
def format_value(self, value):
"""Format the value for display in the widget."""
if value:
self.attrs["data-value"] = value
if value is None:
return ""
if isinstance(value, (datetime.date, datetime.datetime)):
return formats.date_format(value, format=self._get_format(), use_l10n=True)
return str(value)
def value_from_datadict(self, data, files, name):
"""Parse the datetime string from the form data."""
value = super().value_from_datadict(data, files, name)
if value:
try:
# This does it's best to convert Django's PHP-Style date format to a datetime format and reformat the
# value to be read by Django. Probably could be improved
return datetime.datetime.strptime(
value.strip(),
django_to_python_datetime(self._get_format())
or django_to_python_datetime(get_format("SHORT_DATETIME_FORMAT")),
).strftime("%Y-%m-%d")
except (ValueError, TypeError) as e:
return value
return None
class AirDateTimePickerInput(widgets.DateTimeInput):
def __init__(
self,
attrs=None,
format=None,
timepicker=True,
clear_button=True,
auto_close=True,
read_only=True,
toggle_selected=None,
*args,
**kwargs,
):
attrs = attrs or {}
super().__init__(attrs=attrs, format=format, *args, **kwargs)
self.timepicker = timepicker
self.clear_button = clear_button
self.auto_close = auto_close
self.read_only = read_only
self.toggle_selected = (
toggle_selected if isinstance(toggle_selected, bool) else self.clear_button
)
@staticmethod
def _get_current_language():
"""Get current language code in format compatible with AirDatepicker"""
lang_code = translation.get_language()
# AirDatepicker uses simple language codes
return lang_code.split("-")[0]
def _get_format(self):
"""Get the format string based on user settings or default"""
if self.format:
return self.format
return get_format("SHORT_DATETIME_FORMAT", use_l10n=True)
def build_attrs(self, base_attrs, extra_attrs=None):
attrs = super().build_attrs(base_attrs, extra_attrs)
date_format, time_format = django_to_airdatepicker_datetime_separated(
self._get_format()
)
# Add data attributes for AirDatepicker configuration
attrs["data-now-button-txt"] = _("Now")
attrs["data-timepicker"] = str(self.timepicker).lower()
attrs["data-auto-close"] = str(self.auto_close).lower()
attrs["data-toggle-selected"] = str(self.toggle_selected).lower()
attrs["data-clear-button"] = str(self.clear_button).lower()
attrs["data-language"] = self._get_current_language()
attrs["data-date-format"] = date_format
attrs["data-time-format"] = time_format
if self.read_only:
attrs["readonly"] = True
return attrs
def format_value(self, value):
"""Format the value for display in the widget."""
if value and isinstance(value, (datetime.date, datetime.datetime)):
self.attrs["data-value"] = datetime.datetime.strftime(
value, "%Y-%m-%dT%H:%M:00"
)
elif value and isinstance(value, str):
value = datetime.datetime.strptime(value, "%Y-%m-%d %H:%M:00")
self.attrs["data-value"] = datetime.datetime.strftime(
value, "%Y-%m-%dT%H:%M:00"
)
if value is None:
return ""
if isinstance(value, (datetime.date, datetime.datetime)):
return formats.date_format(value, format=self._get_format(), use_l10n=True)
return str(value)
def value_from_datadict(self, data, files, name):
"""Parse the datetime string from the form data."""
value = super().value_from_datadict(data, files, name)
if value:
try:
# This does it's best to convert Django's PHP-Style date format to a datetime format and reformat the
# value to be read by Django. Probably could be improved
return datetime.datetime.strptime(
value.strip(),
django_to_python_datetime(self._get_format())
or django_to_python_datetime(get_format("SHORT_DATETIME_FORMAT")),
).strftime("%Y-%m-%d %H:%M:%S")
except (ValueError, TypeError) as e:
return value
return None
class AirMonthYearPickerInput(AirDatePickerInput):
def __init__(self, attrs=None, format=None, *args, **kwargs):
super().__init__(attrs=attrs, format=format, *args, **kwargs)
# Store the display format for AirDatepicker
self.display_format = "MMMM yyyy"
# Store the Python format for internal use
self.python_format = "%B %Y"
@staticmethod
def _get_month_names():
"""Get month names using Django's date translation"""
return {dates.MONTHS[i]: i for i in range(1, 13)}
def build_attrs(self, base_attrs, extra_attrs=None):
attrs = super().build_attrs(base_attrs, extra_attrs)
# Add data attributes for AirDatepicker configuration
attrs["data-now-button-txt"] = _("Today")
attrs["data-date-format"] = "MMMM yyyy"
return attrs
def format_value(self, value):
"""Format the value for display in the widget."""
if value:
self.attrs["data-value"] = (
value # We use this to dynamically select the initial date on AirDatePicker
)
if value is None:
return ""
if isinstance(value, str):
try:
value = datetime.datetime.strptime(value, "%Y-%m-%d").date()
except ValueError:
return value
if isinstance(value, (datetime.datetime, datetime.date)):
# Use Django's date translation
month_name = dates.MONTHS[value.month]
return f"{month_name} {value.year}"
return value
def value_from_datadict(self, data, files, name):
"""Convert the value from the widget format back to a format Django can handle."""
value = super().value_from_datadict(data, files, name)
if value:
try:
# Split the value into month name and year
month_str, year_str = value.rsplit(" ", 1)
year = int(year_str)
# Get month number from translated month name
month_names = self._get_month_names()
month = month_names.get(month_str)
if month and year:
# Return the first day of the month in Django's expected format
return datetime.date(year, month, 1).strftime("%Y-%m-%d")
except (ValueError, KeyError):
return None
return None
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
+4 -1
View File
@@ -1,7 +1,9 @@
from decimal import Decimal, InvalidOperation
from django import forms
from django.utils.formats import get_format, number_format
from django.utils.formats import number_format
from apps.common.functions.format import get_format
def convert_to_decimal(value: str):
@@ -35,6 +37,7 @@ class ArbitraryDecimalDisplayNumberInput(forms.TextInput):
"x-data": "",
"x-mask:dynamic": f"$money($input, '{get_format('DECIMAL_SEPARATOR')}', "
f"'{get_format('THOUSAND_SEPARATOR')}', '30')",
"x-on:keyup": "$el.dispatchEvent(new Event('input'))",
}
)
+26
View File
@@ -1,4 +1,5 @@
from django.forms import widgets, SelectMultiple
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
@@ -129,3 +130,28 @@ class TomSelect(widgets.Select):
class TomSelectMultiple(SelectMultiple, TomSelect):
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
+16 -1
View File
@@ -1,6 +1,6 @@
from django.contrib import admin
from apps.currencies.models import Currency, ExchangeRate
from apps.currencies.models import Currency, ExchangeRate, ExchangeRateService
@admin.register(Currency)
@@ -11,4 +11,19 @@ class CurrencyAdmin(admin.ModelAdmin):
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)
@@ -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,227 @@
import logging
from datetime import timedelta
from django.db.models import QuerySet
from django.utils import timezone
from apps.currencies.exchange_rates.providers import (
SynthFinanceProvider,
SynthFinanceStockProvider,
CoinGeckoFreeProvider,
CoinGeckoProProvider,
TransitiveRateProvider,
)
from apps.currencies.models import ExchangeRateService, ExchangeRate, Currency
logger = logging.getLogger(__name__)
# Map service types to provider classes
PROVIDER_MAPPING = {
"synth_finance": SynthFinanceProvider,
"synth_finance_stock": SynthFinanceStockProvider,
"coingecko_free": CoinGeckoFreeProvider,
"coingecko_pro": CoinGeckoProProvider,
"transitive": TransitiveRateProvider,
}
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
ExchangeRate.objects.create(
from_currency=to_currency,
to_currency=from_currency,
rate=rate,
date=timezone.now(),
)
processed_pairs.add((to_currency.id, from_currency.id))
else:
# If rates are not inverted, we can use them as is
ExchangeRate.objects.create(
from_currency=from_currency,
to_currency=to_currency,
rate=rate,
date=timezone.now(),
)
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,308 @@
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 SynthFinanceProvider(ExchangeRateProvider):
"""Implementation for Synth Finance API (synthfinance.com)"""
BASE_URL = "https://api.synthfinance.com/rates/live"
rates_inverted = False # SynthFinance returns non-inverted rates
def __init__(self, api_key: str = None):
super().__init__(api_key)
self.session = requests.Session()
self.session.headers.update({"Authorization": f"Bearer {self.api_key}"})
def get_rates(
self, target_currencies: QuerySet, exchange_currencies: set
) -> List[Tuple[Currency, Currency, Decimal]]:
results = []
currency_groups = {}
for currency in target_currencies:
if currency.exchange_currency in exchange_currencies:
group = currency_groups.setdefault(currency.exchange_currency.code, [])
group.append(currency)
for base_currency, currencies in currency_groups.items():
try:
to_currencies = ",".join(
currency.code
for currency in currencies
if currency.code != base_currency
)
response = self.session.get(
f"{self.BASE_URL}",
params={"from": base_currency, "to": to_currencies},
)
response.raise_for_status()
data = response.json()
rates = data["data"]["rates"]
for currency in currencies:
if currency.code == base_currency:
rate = Decimal("1")
else:
rate = Decimal(str(rates[currency.code]))
# Return the rate as is, without inversion
results.append((currency.exchange_currency, currency, rate))
credits_used = data["meta"]["credits_used"]
credits_remaining = data["meta"]["credits_remaining"]
logger.info(
f"Synth Finance API call: {credits_used} credits used, {credits_remaining} remaining"
)
except requests.RequestException as e:
logger.error(
f"Error fetching rates from Synth Finance API for base {base_currency}: {e}"
)
except KeyError as e:
logger.error(
f"Unexpected response structure from Synth Finance API for base {base_currency}: {e}"
)
except Exception as e:
logger.error(
f"Unexpected error processing Synth Finance data for base {base_currency}: {e}"
)
return results
class CoinGeckoFreeProvider(ExchangeRateProvider):
"""Implementation for CoinGecko Free API"""
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 SynthFinanceStockProvider(ExchangeRateProvider):
"""Implementation for Synth Finance API Real-Time Prices endpoint (synthfinance.com)"""
BASE_URL = "https://api.synthfinance.com/tickers"
rates_inverted = True
def __init__(self, api_key: str = None):
super().__init__(api_key)
self.session = requests.Session()
self.session.headers.update(
{"Authorization": f"Bearer {self.api_key}", "accept": "application/json"}
)
def get_rates(
self, target_currencies: QuerySet, exchange_currencies: set
) -> List[Tuple[Currency, Currency, Decimal]]:
results = []
for currency in target_currencies:
if currency.exchange_currency not in exchange_currencies:
continue
try:
# Same currency has rate of 1
if currency.code == currency.exchange_currency.code:
rate = Decimal("1")
results.append((currency.exchange_currency, currency, rate))
continue
# Fetch real-time price for this ticker
response = self.session.get(
f"{self.BASE_URL}/{currency.code}/real-time"
)
response.raise_for_status()
data = response.json()
# Use fair market value as the rate
rate = Decimal(data["data"]["fair_market_value"])
results.append((currency.exchange_currency, currency, rate))
# Log API usage
credits_used = data["meta"]["credits_used"]
credits_remaining = data["meta"]["credits_remaining"]
logger.info(
f"Synth Finance API call for {currency.code}: {credits_used} credits used, {credits_remaining} remaining"
)
except requests.RequestException as e:
logger.error(
f"Error fetching rate from Synth Finance API for ticker {currency.code}: {e}",
exc_info=True,
)
except KeyError as e:
logger.error(
f"Unexpected response structure from Synth Finance API for ticker {currency.code}: {e}",
exc_info=True,
)
except Exception as e:
logger.error(
f"Unexpected error processing Synth Finance data for ticker {currency.code}: {e}",
exc_info=True,
)
return results
class TransitiveRateProvider(ExchangeRateProvider):
"""Calculates exchange rates through paths of existing rates"""
rates_inverted = True
def __init__(self, api_key: str = None):
super().__init__(api_key) # API key not needed but maintaining interface
@classmethod
def requires_api_key(cls) -> bool:
return False
def get_rates(
self, target_currencies: QuerySet, exchange_currencies: set
) -> List[Tuple[Currency, Currency, Decimal]]:
results = []
# Get recent rates for building the graph
recent_rates = ExchangeRate.objects.all()
# Build currency graph
currency_graph = self._build_currency_graph(recent_rates)
for target in target_currencies:
if (
not target.exchange_currency
or target.exchange_currency not in exchange_currencies
):
continue
# Find path and calculate rate
from_id = target.exchange_currency.id
to_id = target.id
path, rate = self._find_conversion_path(currency_graph, from_id, to_id)
if path and rate:
path_codes = [Currency.objects.get(id=cid).code for cid in path]
logger.info(
f"Found conversion path: {' -> '.join(path_codes)}, rate: {rate}"
)
results.append((target.exchange_currency, target, rate))
else:
logger.debug(
f"No conversion path found for {target.exchange_currency.code}->{target.code}"
)
return results
@staticmethod
def _build_currency_graph(rates) -> Dict[int, Dict[int, Decimal]]:
"""Build a graph representation of currency relationships"""
graph = {}
for rate in rates:
# Add both directions to make the graph bidirectional
if rate.from_currency_id not in graph:
graph[rate.from_currency_id] = {}
graph[rate.from_currency_id][rate.to_currency_id] = rate.rate
if rate.to_currency_id not in graph:
graph[rate.to_currency_id] = {}
graph[rate.to_currency_id][rate.from_currency_id] = Decimal("1") / rate.rate
return graph
@staticmethod
def _find_conversion_path(
graph, from_id, to_id
) -> Tuple[Optional[list], Optional[Decimal]]:
"""Find the shortest path between currencies using breadth-first search"""
if from_id not in graph or to_id not in graph:
return None, None
queue = [(from_id, [from_id], Decimal("1"))]
visited = {from_id}
while queue:
current, path, current_rate = queue.pop(0)
if current == to_id:
return path, current_rate
for neighbor, rate in graph.get(current, {}).items():
if neighbor not in visited:
visited.add(neighbor)
queue.append((neighbor, path + [neighbor], current_rate * rate))
return None, None
+57 -5
View File
@@ -1,14 +1,16 @@
from crispy_bootstrap5.bootstrap5 import Switch
from crispy_forms.bootstrap import FormActions
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.forms import CharField
from django.utils.translation import gettext_lazy as _
from apps.common.widgets.crispy.submit import NoClassSubmit
from apps.common.widgets.datepicker import AirDateTimePickerInput
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
from apps.currencies.models import Currency, ExchangeRate
from apps.common.widgets.tom_select import TomSelect
from apps.currencies.models import Currency, ExchangeRate, ExchangeRateService
class CurrencyForm(forms.ModelForm):
@@ -64,9 +66,7 @@ class CurrencyForm(forms.ModelForm):
class ExchangeRateForm(forms.ModelForm):
date = forms.DateTimeField(
widget=forms.DateTimeInput(
attrs={"type": "datetime-local"}, format="%Y-%m-%dT%H:%M"
)
label=_("Date"),
)
class Meta:
@@ -82,6 +82,58 @@ class ExchangeRateForm(forms.ModelForm):
self.helper.layout = Layout("date", "from_currency", "to_currency", "rate")
self.fields["rate"].widget = ArbitraryDecimalDisplayNumberInput()
self.fields["date"].widget = AirDateTimePickerInput(clear_button=False)
if self.instance and self.instance.pk:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
),
),
)
else:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
),
),
)
class ExchangeRateServiceForm(forms.ModelForm):
class Meta:
model = ExchangeRateService
fields = [
"name",
"service_type",
"is_active",
"api_key",
"interval_type",
"fetch_interval",
"target_currencies",
"target_accounts",
]
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"),
"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(
@@ -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'),
),
]
@@ -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'),
),
]
@@ -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'},
),
]
+167 -3
View File
@@ -1,11 +1,18 @@
import logging
from typing import Set
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.utils.translation import gettext_lazy as _
logger = logging.getLogger(__name__)
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)
decimal_places = models.PositiveIntegerField(
default=2,
@@ -31,6 +38,7 @@ class Currency(models.Model):
class Meta:
verbose_name = _("Currency")
verbose_name_plural = _("Currencies")
ordering = ["name", "id"]
def clean(self):
super().clean()
@@ -72,7 +80,163 @@ class ExchangeRate(models.Model):
def clean(self):
super().clean()
if self.from_currency == self.to_currency:
# Check if the attributes exist before comparing them
if hasattr(self, "from_currency") and hasattr(self, "to_currency"):
if self.from_currency == self.to_currency:
raise ValidationError(
{"to_currency": _("From and To currencies cannot be the same.")}
)
class ExchangeRateService(models.Model):
"""Configuration for exchange rate services"""
class ServiceType(models.TextChoices):
SYNTH_FINANCE = "synth_finance", "Synth Finance"
SYNTH_FINANCE_STOCK = "synth_finance_stock", "Synth Finance Stock"
COINGECKO_FREE = "coingecko_free", "CoinGecko (Demo/Free)"
COINGECKO_PRO = "coingecko_pro", "CoinGecko (Pro)"
TRANSITIVE = "transitive", "Transitive (Calculated from Existing Rates)"
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,
)
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(
{"to_currency": _("From and To currencies cannot be the same.")}
{
"fetch_interval": _(
"Invalid format. Please check the requirements for your selected interval type."
)
}
)
+30
View File
@@ -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)
+30
View File
@@ -34,4 +34,34 @@ urlpatterns = [
views.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
View File
@@ -1,2 +1,3 @@
from .currencies import *
from .exchange_rates import *
from .exchange_rates_services import *
-3
View File
@@ -2,9 +2,7 @@ from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx
@@ -89,7 +87,6 @@ def currency_edit(request, pk):
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def currency_delete(request, pk):
currency = get_object_or_404(Currency, id=pk)
+6 -8
View File
@@ -1,12 +1,11 @@
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import F, CharField, Value
from django.db.models import CharField, Value
from django.db.models.functions import Concat
from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx
@@ -28,17 +27,17 @@ def exchange_rates_index(request):
@require_http_methods(["GET"])
def exchange_rates_list(request):
pairings = (
ExchangeRate.objects.values("from_currency__code", "to_currency__code")
ExchangeRate.objects.values("from_currency__name", "to_currency__name")
.distinct()
.annotate(
pair=Concat(
"from_currency__code",
"from_currency__name",
Value(" x "),
"to_currency__code",
"to_currency__name",
output_field=CharField(),
)
)
.values_list("pair", "from_currency__code", "to_currency__code")
.values_list("pair", "from_currency__name", "to_currency__name")
)
return render(
@@ -57,7 +56,7 @@ def exchange_rates_list_pair(request):
if from_currency and to_currency:
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")
else:
exchange_rates = ExchangeRate.objects.all().order_by("-date")
@@ -135,7 +134,6 @@ def exchange_rate_edit(request, pk):
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def exchange_rate_delete(request, pk):
exchange_rate = get_object_or_404(ExchangeRate, id=pk)
@@ -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",
},
)
+8 -2
View File
@@ -1,7 +1,13 @@
from django.contrib import admin
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.register(DCAStrategy)
class TransactionEntityModelAdmin(SharedObjectModelAdmin):
def get_queryset(self, request):
return DCAStrategy.all_objects.all()
+279 -15
View File
@@ -1,13 +1,22 @@
from crispy_forms.bootstrap import FormActions
from django import forms
from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion
from crispy_forms.bootstrap import FormActions, AccordionGroup
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Row, Column
from crispy_forms.layout import Layout, Row, Column, HTML
from django import forms
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.datepicker import AirDatePickerInput
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
from apps.common.widgets.tom_select import TomSelect
from apps.dca.models import DCAStrategy, DCAEntry
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
from apps.common.widgets.crispy.submit import NoClassSubmit
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):
@@ -52,6 +61,75 @@ class DCAStrategyForm(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:
model = DCAEntry
fields = [
@@ -59,14 +137,19 @@ class DCAEntryForm(forms.ModelForm):
"amount_paid",
"amount_received",
"notes",
"expense_transaction",
"income_transaction",
]
widgets = {
"date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
"notes": forms.Textarea(attrs={"rows": 3}),
}
def __init__(self, *args, **kwargs):
strategy = kwargs.pop("strategy", None)
super().__init__(*args, **kwargs)
self.strategy = strategy if strategy else self.instance.strategy
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.layout = Layout(
@@ -75,18 +158,66 @@ class DCAEntryForm(forms.ModelForm):
Column("amount_paid", 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",
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:
# decimal_places = self.instance.account.currency.decimal_places
# self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput(
# decimal_places=decimal_places
# )
self.helper.layout.append(
FormActions(
NoClassSubmit(
@@ -95,7 +226,6 @@ class DCAEntryForm(forms.ModelForm):
),
)
else:
# self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
self.helper.layout.append(
FormActions(
NoClassSubmit(
@@ -106,3 +236,137 @@ class DCAEntryForm(forms.ModelForm):
self.fields["amount_paid"].widget = ArbitraryDecimalDisplayNumberInput()
self.fields["amount_received"].widget = ArbitraryDecimalDisplayNumberInput()
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),
),
]
+5 -3
View File
@@ -1,16 +1,15 @@
from datetime import timedelta
from decimal import Decimal
from statistics import mean, stdev
from django.db import models
from django.template.defaultfilters import date
from django.utils import timezone
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
class DCAStrategy(models.Model):
class DCAStrategy(SharedObject):
name = models.CharField(max_length=255, verbose_name=_("Name"))
target_currency = models.ForeignKey(
"currencies.Currency",
@@ -28,6 +27,9 @@ class DCAStrategy(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
objects = SharedObjectManager()
all_objects = models.Manager() # Unfiltered manager
class Meta:
verbose_name = _("DCA Strategy")
verbose_name_plural = _("DCA Strategies")
+10
View File
@@ -12,6 +12,16 @@ urlpatterns = [
views.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(
"dca/<int:strategy_id>/",
views.strategy_detail_index,
+84 -12
View File
@@ -6,12 +6,13 @@ from django.db.models.functions import TruncMonth
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.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx
from apps.dca.models import DCAStrategy, DCAEntry
from apps.dca.forms import DCAEntryForm, DCAStrategyForm
from apps.dca.models import DCAStrategy, DCAEntry
from apps.common.models import SharedObject
from apps.common.forms import SharedObjectForm
@login_required
@@ -58,6 +59,16 @@ def strategy_add(request):
def strategy_edit(request, 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":
form = DCAStrategyForm(request.POST, instance=dca_strategy)
if form.is_valid():
@@ -82,14 +93,19 @@ def strategy_edit(request, strategy_id):
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def strategy_delete(request, strategy_id):
dca_strategy = get_object_or_404(DCAStrategy, id=strategy_id)
dca_strategy.delete()
messages.success(request, _("DCA strategy deleted successfully"))
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()
messages.success(request, _("DCA strategy deleted successfully"))
return HttpResponse(
status=204,
@@ -99,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
def strategy_detail_index(request, strategy_id):
strategy = get_object_or_404(DCAStrategy, id=strategy_id)
@@ -157,11 +232,9 @@ def strategy_detail(request, strategy_id):
def strategy_entry_add(request, strategy_id):
strategy = get_object_or_404(DCAStrategy, id=strategy_id)
if request.method == "POST":
form = DCAEntryForm(request.POST)
form = DCAEntryForm(request.POST, strategy=strategy)
if form.is_valid():
entry = form.save(commit=False)
entry.strategy = strategy
entry.save()
entry = form.save()
messages.success(request, _("Entry added successfully"))
return HttpResponse(
@@ -171,7 +244,7 @@ def strategy_entry_add(request, strategy_id):
},
)
else:
form = DCAEntryForm()
form = DCAEntryForm(strategy=strategy)
return render(
request,
@@ -209,7 +282,6 @@ def strategy_entry_edit(request, strategy_id, entry_id):
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def strategy_entry_delete(request, entry_id, strategy_id):
dca_entry = get_object_or_404(DCAEntry, id=entry_id, strategy__id=strategy_id)
View File
+3
View File
@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ExportConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.export_app"
+198
View File
@@ -0,0 +1,198 @@
from crispy_forms.bootstrap import FormActions
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, HTML
from django import forms
from django.utils.translation import gettext_lazy as _
from apps.common.widgets.crispy.submit import NoClassSubmit
class ExportForm(forms.Form):
users = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Users"),
initial=True,
)
accounts = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Accounts"),
initial=True,
)
currencies = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Currencies"),
initial=True,
)
transactions = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Transactions"),
initial=True,
)
categories = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Categories"),
initial=True,
)
tags = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Tags"),
initial=False,
)
entities = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Entities"),
initial=False,
)
recurring_transactions = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Recurring Transactions"),
initial=True,
)
installment_plans = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Installment Plans"),
initial=True,
)
exchange_rates = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Exchange Rates"),
initial=False,
)
exchange_rates_services = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Automatic Exchange Rates"),
initial=False,
)
rules = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Rules"),
initial=True,
)
dca = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("DCA"),
initial=False,
)
import_profiles = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Import Profiles"),
initial=True,
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.form_method = "post"
self.helper.layout = Layout(
"users",
"accounts",
"currencies",
"transactions",
"categories",
"entities",
"tags",
"installment_plans",
"recurring_transactions",
"exchange_rates_services",
"exchange_rates",
"rules",
"dca",
"import_profiles",
FormActions(
NoClassSubmit(
"submit", _("Export"), css_class="btn btn-outline-primary w-100"
),
),
)
class RestoreForm(forms.Form):
zip_file = forms.FileField(
required=False,
help_text=_("Import a ZIP file exported from WYGIWYH"),
label=_("ZIP File"),
)
users = forms.FileField(required=False, label=_("Users"))
accounts = forms.FileField(required=False, label=_("Accounts"))
currencies = forms.FileField(required=False, label=_("Currencies"))
transactions_categories = forms.FileField(required=False, label=_("Categories"))
transactions_tags = forms.FileField(required=False, label=_("Tags"))
transactions_entities = forms.FileField(required=False, label=_("Entities"))
transactions = forms.FileField(required=False, label=_("Transactions"))
installment_plans = forms.FileField(required=False, label=_("Installment Plans"))
recurring_transactions = forms.FileField(
required=False, label=_("Recurring Transactions")
)
automatic_exchange_rates = forms.FileField(
required=False, label=_("Automatic Exchange Rates")
)
exchange_rates = forms.FileField(required=False, label=_("Exchange Rates"))
transaction_rules = forms.FileField(required=False, label=_("Transaction rules"))
transaction_rules_actions = forms.FileField(
required=False, label=_("Edit transaction action")
)
transaction_rules_update_or_create = forms.FileField(
required=False, label=_("Update or create transaction actions")
)
dca_strategies = forms.FileField(required=False, label=_("DCA Strategies"))
dca_entries = forms.FileField(required=False, label=_("DCA Entries"))
import_profiles = forms.FileField(required=False, label=_("Import Profiles"))
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.form_method = "post"
self.helper.layout = Layout(
"zip_file",
HTML("<hr />"),
"users",
"accounts",
"currencies",
"transactions",
"transactions_categories",
"transactions_entities",
"transactions_tags",
"installment_plans",
"recurring_transactions",
"automatic_exchange_rates",
"exchange_rates",
"transaction_rules",
"transaction_rules_actions",
"transaction_rules_update_or_create",
"dca_strategies",
"dca_entries",
"import_profiles",
FormActions(
NoClassSubmit(
"submit", _("Restore"), css_class="btn btn-outline-primary w-100"
),
),
)
def clean(self):
cleaned_data = super().clean()
if not cleaned_data.get("zip_file") and not any(
cleaned_data.get(field) for field in self.fields if field != "zip_file"
):
raise forms.ValidationError(
_("Please upload either a ZIP file or at least one CSV file")
)
return cleaned_data
+3
View File
@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

Some files were not shown because too many files have changed in this diff Show More