Compare commits

..

784 Commits

Author SHA1 Message Date
Herculino Trotta 5d7dd622f5 feat: add internal_port env var 2025-11-09 15:42:42 -03:00
eitchtee f2abeff31a chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-09-14 04:57:39 +00:00
Herculino Trotta 666eaff167 Merge pull request #377
fix(rules:dry-run): rename offcanvas
2025-09-14 01:56:48 -03:00
Herculino Trotta d72454f854 fix(rules:dry-run): rename offcanvas 2025-09-14 01:56:31 -03:00
Herculino Trotta 333aa81923 Merge pull request #376
fix(rules:dry-run): current_user getting overwritten and delete on synchronous call
2025-09-14 01:37:23 -03:00
eitchtee 41b8cfd1e7 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-09-14 04:37:05 +00:00
Herculino Trotta 1fa7985b01 fix(rules:dry-run): current_user getting overwritten and delete on synchronous call 2025-09-14 01:37:03 -03:00
Herculino Trotta 38392a6322 Merge pull request #375
feat(transactions): Try to convert amount to the expected Decimal if it is a str, int or float
2025-09-14 01:36:19 -03:00
Herculino Trotta 637c62319b feat(transactions): Try to convert amount to the expected Decimal if it is a str, int or float 2025-09-14 01:23:49 -03:00
Herculino Trotta f91fe67629 Merge pull request #374
feat(rules): expose if the transaction is recurring/installment
2025-09-14 01:18:28 -03:00
Herculino Trotta 9eb1818a20 feat(rules): expose if the transaction is recurring/installment 2025-09-14 01:18:08 -03:00
Herculino Trotta 50ac679e33 Merge pull request #373
fix(rules:dry-run): Edit/Update transaction not showing message when transaction can't be found
2025-09-14 00:41:28 -03:00
Herculino Trotta 2a463c63b8 fix(rules:dry-run): Edit/Update transaction not showing message when transaction can't be found 2025-09-14 00:41:04 -03:00
eitchtee dce65f2faf chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-09-13 06:24:57 +00:00
Herculino Trotta a053cb3947 Merge pull request #372
feat(ui:sidebar): smoother transition when clicking on menu items
2025-09-13 03:21:13 -03:00
Herculino Trotta 2d43072120 feat(ui:sidebar): smoother transition when clicking on menu items 2025-09-13 03:20:55 -03:00
Herculino Trotta 70bdee065e Merge pull request #371
feat(ui:sidebar): add a chevron to the management menu to indicate it opens another "page"
2025-09-13 03:20:01 -03:00
Herculino Trotta 95db27a32f feat(ui:sidebar): add a chevron to the management menu to indicate it opens another "page" 2025-09-13 03:19:36 -03:00
Herculino Trotta d6d4e6a102 Merge pull request #370
feat(ui:sidebar): keep management menu open if the user is on a management page
2025-09-13 03:19:06 -03:00
Herculino Trotta bc0f30fead feat(ui:sidebar): keep management menu open if the user is on a management page 2025-09-13 03:18:45 -03:00
Herculino Trotta a9a86fc491 Merge pull request #368 from eitchtee/weblate
Translations update from Weblate
2025-09-12 09:15:44 -03:00
Phillip Maizza c3b5f2bf39 locale(Italian): update translation
Currently translated at 100.0% (694 of 694 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/it/
2025-09-11 22:17:42 +00:00
Herculino Trotta 19128e5aed Merge pull request #367 from eitchtee/weblate
Translations update from Weblate
2025-09-11 18:49:31 -03:00
Phillip Maizza 9b5c6d3413 locale(Italian): update translation
Currently translated at 100.0% (694 of 694 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/it/
2025-09-11 21:17:42 +00:00
Phillip Maizza 73c873a2ad locale(Italian): update translation
Currently translated at 79.8% (554 of 694 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/it/
2025-09-11 19:17:42 +00:00
Phillip Maizza 9d2be22a77 locale(Italian): update translation
Currently translated at 28.8% (200 of 694 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/it/
2025-09-11 18:17:42 +00:00
Phillip Maizza 6a3d31f37d locale((Italian)): added translation using Weblate 2025-09-11 17:46:47 +00:00
Herculino Trotta 3be3a3c14b Merge pull request #366 from eitchtee/weblate
Translations update from Weblate
2025-09-09 23:02:50 -03:00
Dimitri Decrock a5b0f4efb7 locale(Dutch): update translation
Currently translated at 100.0% (694 of 694 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-09-09 18:17:42 +00:00
Herculino Trotta 6da50db417 Merge pull request #365 from samuelthng/patch-1
fix(app): pwa title colour
2025-09-09 00:10:15 -03:00
Samuel a6c1daf902 fix(app): PWA Title Colour 2025-09-09 08:12:16 +08:00
eitchtee 6a271fb3d7 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-09-08 12:20:45 +00:00
Herculino Trotta 2cf9a9dd0f Merge pull request #364
fix(accounts): unable to update accounts
2025-09-08 09:19:49 -03:00
Herculino Trotta 64b32316ca fix(accounts): unable to update accounts
due to wrong currency queryset
2025-09-08 09:19:17 -03:00
sorcierwax 0deaabe719 locale(French): update translation
Currently translated at 100.0% (694 of 694 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/fr/
2025-09-08 06:17:42 +00:00
Herculino Trotta b14342af2e Merge pull request #362
fix(rules): duplicating transactions when ran outside of test mode
2025-09-07 22:15:11 -03:00
Herculino Trotta efe020efb3 fix(rules): duplicating transactions when ran outside of test mode 2025-09-07 22:14:40 -03:00
Herculino Trotta 2c14ce6366 Merge pull request #361
fix(rules): add .exclude() to transactions() function
2025-09-07 21:30:32 -03:00
Herculino Trotta 8c133f92ce fix(rules): add .exclude() to transactions() function 2025-09-07 21:30:03 -03:00
Herculino Trotta 2dd887b0d9 Merge pull request #360
feat(rules): add .exclude() to transactions() function
2025-09-07 21:25:18 -03:00
Herculino Trotta f3c9d8faea feat(rules): add .exclude() to transactions() function 2025-09-07 21:24:53 -03:00
Herculino Trotta 8be7758dc0 Merge pull request #359
feat(rules): add .exclude() to transactions() function
2025-09-07 20:41:36 -03:00
Herculino Trotta 8f5204a17b feat(rules): add .exclude() to transactions() function 2025-09-07 20:41:09 -03:00
Herculino Trotta 05dd782df5 locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (694 of 694 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-09-07 14:17:42 +00:00
eitchtee 187fe43283 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-09-07 13:08:56 +00:00
Herculino Trotta cae73376db Merge pull request #358
feat(rules): many improvements
2025-09-07 10:07:19 -03:00
Herculino Trotta 7225454a6e Merge pull request #357
fix(ui): unable to CTRL + A amount fields
2025-09-05 23:05:49 -03:00
Herculino Trotta 70c8c1e07c fix(ui): unable to CTRL + A amount fields 2025-09-05 23:04:12 -03:00
Herculino Trotta 2235bdeabb changes 2025-09-02 23:17:04 -03:00
Herculino Trotta d724300513 changes 2025-09-02 15:54:45 -03:00
Herculino Trotta eacafa1def changes 2025-09-02 09:47:35 -03:00
Herculino Trotta c738f5ee29 changes 2025-09-02 09:47:27 -03:00
sorcierwax c392a2c988 locale(French): update translation
Currently translated at 100.0% (686 of 686 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/fr/
2025-09-01 06:17:42 +00:00
Dimitri Decrock 17ea859fd2 locale(Dutch): update translation
Currently translated at 100.0% (686 of 686 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-09-01 06:17:42 +00:00
eitchtee 8aae6f928f chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-31 12:07:49 +00:00
Herculino Trotta 7c43b06b9f Merge pull request #356
feat(rules): add optional rules ordering
2025-08-31 09:07:07 -03:00
Herculino Trotta 72904266bf feat(rules): add optional rules ordering 2025-08-31 09:06:48 -03:00
Herculino Trotta e16e279911 Merge pull request #355
feat(rules): add rule function to fetch transactions totals and balance
2025-08-30 15:45:45 -03:00
Herculino Trotta 670bee4325 feat(rules): add rule function to fetch transactions totals and balance 2025-08-30 15:36:07 -03:00
Herculino Trotta 3e2c1184ce Merge pull request #354
fix(yearly-overview): display total for archived accounts
2025-08-30 11:13:05 -03:00
Herculino Trotta 731f351eef fix(yearly-overview): display total for archived accounts 2025-08-30 11:12:47 -03:00
eitchtee b7056e7aa1 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-30 02:09:21 +00:00
Herculino Trotta accceed630 Merge pull request #353
feat(insights:category-overview): add "No entity" totals
2025-08-29 23:08:34 -03:00
Herculino Trotta 76346cb503 feat(insights:category-overview): add "No entity" totals 2025-08-29 23:08:16 -03:00
eitchtee 3df8952ea2 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-30 01:48:37 +00:00
Herculino Trotta 9bd067da96 Merge pull request #352
feat(currencies): allow archiving
2025-08-29 22:47:26 -03:00
Herculino Trotta 1abe9e9f62 feat(currencies): allow archiving 2025-08-29 22:47:00 -03:00
eitchtee 1a86b5dea4 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-28 02:18:45 +00:00
Herculino Trotta 8f2f5a16c2 Merge pull request #349
fix(transactions:quick-transactions): error when saving due to wrong field definition
2025-08-27 23:17:09 -03:00
Herculino Trotta 4565dc770b fix(transactions:quick-transactions): error when saving due to wrong field definition 2025-08-27 23:16:06 -03:00
Herculino Trotta 23673def09 Merge pull request #346
fix(common:tasks): remove_old_jobs always failing
2025-08-24 10:41:48 -03:00
Herculino Trotta dd2b9ead7e fix(common:tasks): remove_old_jobs always failing 2025-08-24 10:41:26 -03:00
Rhesa Daiva Bremana 2078e9f3e4 locale((Indonesian)): added translation using Weblate 2025-08-23 12:43:54 +00:00
eitchtee e6bab57ab4 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-22 20:36:08 +00:00
Herculino Trotta 38d50a78f4 Merge pull request #344
fix(sidebar): sidebar status not saving properly
2025-08-22 17:34:42 -03:00
Herculino Trotta 0d947f9ba6 fix(sidebar): sidebar status not saving properly 2025-08-22 17:34:12 -03:00
eitchtee 99c85a56bb chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-22 20:16:36 +00:00
Herculino Trotta ab1c074f27 Merge pull request #343
feat(sidebar): add button to keep it open
2025-08-22 17:15:55 -03:00
Herculino Trotta abf3a148cc feat(sidebar): add button to keep it open 2025-08-22 17:15:32 -03:00
Herculino Trotta 2733c92da5 style(sidebar): truncate e-mail if it's too long 2025-08-22 13:14:47 -03:00
eitchtee 9bfbe54ed5 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-19 17:03:42 +00:00
Herculino Trotta 5b27dea07c Merge pull request #340
feat: turn filter, order and search into a single bar
2025-08-19 14:02:06 -03:00
Herculino Trotta 791e1000a3 feat(all-transactions): turn filter, order and search into a single bar 2025-08-19 14:01:35 -03:00
Herculino Trotta 7301d9f475 feat(monthly): turn filter, order and search into a single bar 2025-08-19 13:39:57 -03:00
sorcierwax 47a44e96f8 locale(French): update translation
Currently translated at 100.0% (685 of 685 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/fr/
2025-08-18 08:17:42 +00:00
Herculino Trotta 7d247eb737 locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (685 of 685 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-08-17 08:17:42 +00:00
Dimitri Decrock 373616e7bb locale(Dutch): update translation
Currently translated at 100.0% (685 of 685 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-08-17 08:17:42 +00:00
eitchtee bf3c11d582 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-17 06:55:57 +00:00
Herculino Trotta b4b1c10db9 Merge pull request #338
refactor(currencies): DEPRECATE SYNTH FINANCE
2025-08-17 03:54:28 -03:00
Herculino Trotta 5ca531f47d refactor(currencies): DEPRECATE SYNTH FINANCE 2025-08-17 03:54:10 -03:00
eitchtee 07673cb528 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-17 06:23:31 +00:00
Herculino Trotta 67c6d81897 Merge pull request #337
feat(currencies): add TwelveData and TwelveDataMarkets as providers
2025-08-17 03:22:11 -03:00
Herculino Trotta 3c85da46b0 feat(currencies): add TwelveData and TwelveDataMarkets as providers 2025-08-17 03:21:55 -03:00
eitchtee d263936be7 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-17 03:51:16 +00:00
Herculino Trotta 1524063d5a Merge pull request #336
feat(currencies): add Frankfurter as an Exchange Rate provider
2025-08-17 00:49:54 -03:00
Herculino Trotta c3a403b8f0 feat(currencies): add Frankfurter as an Exchange Rate provider 2025-08-17 00:49:32 -03:00
eitchtee 1c1018adae chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-16 20:41:25 +00:00
Herculino Trotta 350d5adf25 Merge pull request #335
refactor: remove debug prints
2025-08-16 17:38:44 -03:00
Herculino Trotta 7e4defb9cc refactor: remove debug prints 2025-08-16 17:38:22 -03:00
Herculino Trotta 7121e4bc09 Merge pull request #334
fix(tooltips): sometimes not getting created on htmx swap
2025-08-16 17:37:53 -03:00
Herculino Trotta 4540e48fe5 fix(tooltips): sometimes not getting created on htmx swap 2025-08-16 17:37:27 -03:00
Herculino Trotta d06b51421f Merge pull request #333
feat(insights:category-overview): display entities on table
2025-08-16 17:37:15 -03:00
Herculino Trotta e096912e41 feat(insights:category-overview): display entities on table 2025-08-16 17:36:19 -03:00
Dimitri Decrock f0ad6e16fe locale(Dutch): update translation
Currently translated at 100.0% (684 of 684 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-08-16 11:17:42 +00:00
Herculino Trotta 734a302fa7 locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (684 of 684 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-08-16 04:17:41 +00:00
eitchtee 89b1b7bcb7 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-16 01:55:05 +00:00
Herculino Trotta 37b40f89bb Merge pull request #332
feat: add today button to MonthYearPicker
2025-08-15 22:54:21 -03:00
Herculino Trotta 0c63552d1b feat: add today button to MonthYearPicker 2025-08-15 22:54:04 -03:00
Herculino Trotta 7db517e848 Merge pull request #331
feat(export): improve export flow by using HTMX
2025-08-15 22:40:00 -03:00
Herculino Trotta 7e3ed6cf94 feat(export): improve export flow by using HTMX 2025-08-15 22:39:18 -03:00
Herculino Trotta e10a88c00e Merge pull request #330
fix(sidebar): management menu not scroll correctly
2025-08-15 12:53:01 -03:00
Herculino Trotta b912a33b93 fix(sidebar): management menu not scroll correctly 2025-08-15 12:49:14 -03:00
eitchtee d9fb3627cc chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-15 15:31:23 +00:00
Herculino Trotta 78ffa68ba4 Merge pull request #329
feat(transactions): filter for unset category/tag/entity
2025-08-15 12:30:14 -03:00
Herculino Trotta 37f4ead058 feat(transactions): filter for unset category/tag/entity
implements #327
2025-08-15 12:29:32 -03:00
Herculino Trotta 61630fca5b locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (680 of 680 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-08-12 22:17:41 +00:00
sorcierwax 910d4c84a3 locale(French): update translation
Currently translated at 99.1% (674 of 680 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/fr/
2025-08-12 13:07:17 +00:00
sorcierwax be1f29d8c1 locale(French): update translation
Currently translated at 99.1% (674 of 680 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/fr/
2025-08-12 11:17:42 +00:00
sorcierwax 9784d840cc locale(French): update translation
Currently translated at 83.3% (567 of 680 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/fr/
2025-08-12 09:47:01 +00:00
sorcierwax db5ce13ff3 locale(French): update translation
Currently translated at 76.3% (519 of 680 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/fr/
2025-08-12 09:25:38 +00:00
sorcierwax a2b943d949 locale(French): update translation
Currently translated at 62.0% (422 of 680 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/fr/
2025-08-12 08:45:52 +00:00
eitchtee d8098b4486 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-10 15:35:43 +00:00
Herculino Trotta f8cff6623f Merge pull request #324
feat(locale): add space-dot and space-comma number formatting options, where the thousand separator is a space
2025-08-10 12:34:59 -03:00
Herculino Trotta 65c61f76ff feat(locale): add space-dot and space-comma number formatting options, where the thousand separator is a space 2025-08-10 12:34:29 -03:00
Herculino Trotta 74899f63ab Merge pull request #323
fix(locale): get_format doesn't override number formatting if use_l10n is None
2025-08-10 12:24:37 -03:00
Herculino Trotta 66a5e6d613 fix(locale): get_format doesn't override number formatting if use_l10n is None 2025-08-10 12:23:41 -03:00
Dimitri Decrock e0ab32ec03 locale(Dutch): update translation
Currently translated at 100.0% (680 of 680 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-08-09 10:17:41 +00:00
eitchtee a912e4a511 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-09 06:57:32 +00:00
Herculino Trotta 57ba672c91 Merge pull request #321
feat(accounts): add option for untracking accounts on a per user basis
2025-08-09 03:55:59 -03:00
Herculino Trotta 20c6989ffb fix(insights:emergency-fund): hide untracked accounts 2025-08-09 03:45:16 -03:00
Herculino Trotta c6cd525c49 fix(insights): display untracked accounts on sankey by account 2025-08-09 03:42:21 -03:00
Herculino Trotta 55c4b920ee feat(accounts): add option for untracking accounts on a per user basis 2025-08-09 03:35:39 -03:00
google-labs-jules[bot] 7f8261b9cc refactor: Style transaction items for untracked accounts
This commit extends the "Untrack Account" feature by applying a special style to transaction items that belong to an untracked account.

- The transaction item template is modified to apply a "dimmed" style to transactions from untracked accounts.
- The styling follows the precedence: Account (untracked) > Category (muted) > Transaction (hidden).
- The dropdown menu for transaction items now shows "Controlled by account" if the transaction's account is untracked.
2025-08-09 05:47:18 +00:00
Herculino Trotta 9102654eab Merge pull request #320
style(swal): move swal prompt over tooltips
2025-08-08 19:01:54 -03:00
Herculino Trotta 1ff49a8a04 style(swal): move swal prompt over tooltips 2025-08-08 19:01:35 -03:00
Herculino Trotta 846dd1fd73 Merge remote-tracking branch 'origin/main' 2025-08-08 16:49:54 -03:00
Herculino Trotta 9eed3b6692 style(transactions): remove menu vertical positioning on smaller screens 2025-08-08 16:49:37 -03:00
Dimitri Decrock b7c53a3c2d locale(Dutch): update translation
Currently translated at 99.7% (673 of 675 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-08-08 06:17:41 +00:00
Herculino Trotta b378c8f6f7 locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (675 of 675 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-08-08 04:17:41 +00:00
Herculino Trotta ccc4deb1d8 Merge branch 'main' of https://github.com/eitchtee/WYGIWYH 2025-08-07 23:48:45 -03:00
Herculino Trotta d3ecf55375 Merge remote-tracking branch 'weblate/main' 2025-08-07 23:43:25 -03:00
eitchtee 580f3e7345 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-08 02:41:32 +00:00
Herculino Trotta 0e5843094b Merge pull request #319
dev
2025-08-07 23:39:41 -03:00
Herculino Trotta ed65945d19 feat(automatic-exchange-rates): rename automatic field 2025-08-07 23:39:21 -03:00
Herculino Trotta 18d8837c64 locale(Portuguese (Brazil)): update translation
Currently translated at 99.2% (671 of 676 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-08-08 02:37:06 +00:00
eitchtee 067d819077 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-08 02:37:02 +00:00
Herculino Trotta bbaae4746a Merge pull request #318
feat(transactions:recurring): try to create transactions on update
2025-08-07 23:36:12 -03:00
Herculino Trotta d2e5c1d6b3 feat(transactions:recurring): try to create transactions on update 2025-08-07 23:35:57 -03:00
eitchtee ffef61d514 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-08 02:27:52 +00:00
Herculino Trotta 9020f6f972 Merge pull request #317
feat(automatic-exchange-rates): add "Single exchange rate" where only one exchange rate is added and updated to avoid db clutter
2025-08-07 23:26:10 -03:00
Herculino Trotta 540235c1b0 feat(automatic-exchange-rates): add "Single exchange rate" where only one exchange rate is added and updated to avoid db clutter 2025-08-07 23:25:51 -03:00
eitchtee 9070bc5705 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-07 20:04:56 +00:00
Herculino Trotta ba5a6c9772 Merge pull request #316 from eitchtee/dev
feat(transactions): add menu itens for quickly changing transaction date
2025-08-07 17:04:11 -03:00
Herculino Trotta 2f33b5989f feat(transactions): add menu itens for quickly changing transaction date 2025-08-07 15:30:42 -03:00
Herculino Trotta 5f24d05540 Merge pull request #315
style(login): make login button take full width as open id login
2025-08-07 12:03:02 -03:00
Herculino Trotta 31cf62e277 style(login): make login button take full width as open id login 2025-08-07 12:02:41 -03:00
Dimitri Decrock 15d990007e locale(Dutch): update translation
Currently translated at 100.0% (670 of 670 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-08-07 06:17:41 +00:00
Herculino Trotta 3d5bc9cd3f Merge pull request #314
feat(tasks:check_for_updates): add env variable to disable checking
2025-08-06 16:59:15 -03:00
Herculino Trotta a544dc4943 feat(tasks:check_for_updates): add env variable to disable checking 2025-08-06 16:58:58 -03:00
Herculino Trotta b1178198e9 Merge remote-tracking branch 'origin/main' 2025-08-06 16:37:48 -03:00
Herculino Trotta 02a488bfff fix(sidebar): management menu gets hidden on my mobile by floating UI 2025-08-06 16:37:12 -03:00
Herculino Trotta b05285947b locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (670 of 670 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-08-06 18:17:41 +00:00
eitchtee d7b7dd28c7 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-06 16:21:44 +00:00
Herculino Trotta 9353d498ef Merge pull request #313
feat(transactions:recurring): allow to set how many future instances of a recurring transaction to create in advance
2025-08-06 13:20:58 -03:00
Herculino Trotta 4f6903e8e4 feat(transactions:recurring): Allow to set how many future instances of a recurring transaction to create in advance 2025-08-06 13:13:59 -03:00
eitchtee 7d3d6ea2fc chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-06 14:48:14 +00:00
Herculino Trotta cce9c7a7a5 Merge pull request #312
refactor(transactions:all): reduce screen state by moving filter to top
2025-08-06 11:46:06 -03:00
Herculino Trotta f80f74a01a refactor(transactions:all): reduce screen state by moving filter to top 2025-08-06 11:45:43 -03:00
Herculino Trotta df47ffc49c Merge pull request #311
refactor(yearly): convert into a single page instead of two
2025-08-06 11:45:07 -03:00
eitchtee 4f35647a22 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-06 14:45:03 +00:00
Herculino Trotta 368342853f refactor(yearly): convert into a single page instead of two 2025-08-06 11:44:36 -03:00
Herculino Trotta 5a675f674d Merge pull request #310
refactor(networth): convert into a single page instead of two
2025-08-06 11:44:15 -03:00
Herculino Trotta 9ef8fdec49 refactor(networth): convert into a single page instead of two 2025-08-06 11:43:49 -03:00
Herculino Trotta f29a8d8bc0 refactor: remove debug print from category overview 2025-08-06 11:42:53 -03:00
Herculino Trotta 8c43365ec0 Merge pull request #309
fix: bulk delete not working
2025-08-06 11:42:24 -03:00
Herculino Trotta 2cdcc4ee26 fix: bulk delete not working 2025-08-06 11:41:57 -03:00
eitchtee 84852012f9 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-06 14:36:50 +00:00
Herculino Trotta edf0e2c66f Merge pull request #308
feat: replace navbar with sidebar
2025-08-06 11:35:58 -03:00
Herculino Trotta f90a31f2b9 feat: add sidebar 2025-08-06 11:35:17 -03:00
Herculino Trotta dd1f6a6ef2 feat: add sidebar 2025-08-04 22:10:44 -03:00
Herculino Trotta 57f98ba171 locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (669 of 669 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-08-03 16:17:41 +00:00
eitchtee f2e93f7df9 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-02 18:39:27 +00:00
Herculino Trotta 26cfa493b3 Merge pull request #305 from eitchtee/dev
fix(ui:transactions): transaction menu overflows screen on smaller screens
2025-08-02 15:37:30 -03:00
Herculino Trotta c6e003ed86 fix(ui:transactions): transaction menu overflows screen on smaller screens 2025-08-02 15:36:57 -03:00
Herculino Trotta c09ad0e49d Merge pull request #304
fix(ui:transactions): transaction menu overflows screen on smaller screens + alternative menu placement
2025-08-02 15:35:42 -03:00
Herculino Trotta 9250131396 fix(ui:transactions): transaction menu overflows screen on smaller screens + alternative menu placement 2025-08-02 15:35:20 -03:00
Herculino Trotta 5f503149ce Merge pull request #303
fix(mobile): tooltips show up on top of offcanvas
2025-08-02 15:34:18 -03:00
Herculino Trotta d45b4f2942 fix(mobile): tooltips show up on top of offcanvas 2025-08-02 15:34:01 -03:00
Erwan Colin 4a8493c7d9 locale(French): update translation
Currently translated at 51.1% (342 of 669 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/fr/
2025-07-28 22:17:41 +00:00
Dimitri Decrock c39c3ccacb locale(Dutch): update translation
Currently translated at 100.0% (669 of 669 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-07-28 17:17:41 +00:00
Herculino Trotta 4bb8ef6582 locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (669 of 669 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-07-28 04:17:41 +00:00
eitchtee d711ccca69 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-07-28 02:21:41 +00:00
Herculino Trotta 76d59f1038 Merge pull request #302
refactor: properly name shareable objects generic fields
2025-07-27 23:20:01 -03:00
Herculino Trotta 5b6c123fa1 refactor: properly name shareable objects generic fields 2025-07-27 23:19:39 -03:00
Herculino Trotta 782ab11ae4 Merge pull request #301
feat: add quick transactions to django admin and properly name it
2025-07-27 23:19:01 -03:00
Herculino Trotta 8db885f47d feat: add quick transactions to django admin and properly name it
+ fix some wrongly assigned admin instances
2025-07-27 23:18:42 -03:00
Herculino Trotta 01bd8710d8 Merge pull request #300
feat(sharing): add action for turning shareable objects into public or private on Django Admin
2025-07-27 23:17:31 -03:00
Herculino Trotta 569d08711c feat(sharing): add action for turning shareable objects into public or private on Django Admin 2025-07-27 23:17:14 -03:00
eitchtee a285f055e4 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-07-28 01:58:29 +00:00
Herculino Trotta 6aae9b1207 Merge pull request #299
feat(transactions): display more information about recurring, quick and installments
2025-07-27 22:56:26 -03:00
Herculino Trotta 9d2206f8a4 feat(transactions): display more information about recurring, quick and installments 2025-07-27 22:55:59 -03:00
Herculino Trotta d7e3c50c2c Merge pull request #298
chore: bump python requirements
2025-07-27 22:55:27 -03:00
Herculino Trotta 789fd4eb80 chore: bump python requirements 2025-07-27 22:54:50 -03:00
Herculino Trotta 586b3a5d44 locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (667 of 667 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-07-27 06:17:41 +00:00
eitchtee 9248e8bd77 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-07-27 04:41:52 +00:00
Herculino Trotta c44247f6a5 Merge pull request #297
feat: automatically dismiss htmx error popups after 1 minute
2025-07-27 01:39:33 -03:00
Herculino Trotta 8ba89434f8 feat: automatically dismiss htmx error popups after 1 minute
this is mostly to avoid being greeted by a popup when you leave the app open and it is unable to auto-update every few minutes
2025-07-27 01:39:02 -03:00
eitchtee f2f41981a3 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-07-27 04:29:40 +00:00
Herculino Trotta 1153fd6b0a Merge pull request #296
feat: check and notify users of new versions
2025-07-27 01:28:54 -03:00
Herculino Trotta 76822224a0 feat: check and notify users of new versions
checks are done against github's API with one request every 12 hours
2025-07-27 01:28:27 -03:00
Herculino Trotta 31b2b98eb9 Merge pull request #295 from eitchtee/dev
chore: bump python requirements
2025-07-27 01:26:51 -03:00
Herculino Trotta d7a4e79321 chore: bump python requirements 2025-07-27 01:25:20 -03:00
Herculino Trotta 985f07e792 Merge pull request #294
fix(insights): filters not changing on click
2025-07-26 23:32:18 -03:00
Herculino Trotta 5465bb1eeb fix(insights): filters not changing on click 2025-07-26 23:31:54 -03:00
eitchtee 451a85a998 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-07-27 02:07:51 +00:00
Herculino Trotta 54c74e7c07 Merge pull request #293
fix(insights): filters not changing on click
2025-07-26 23:07:12 -03:00
Herculino Trotta d6e9e123b7 fix(insights): filters not changing on click 2025-07-26 23:06:45 -03:00
seraphblade2010 80c9c43a02 locale(German): update translation
Currently translated at 98.0% (653 of 666 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/de/
2025-07-22 06:17:41 +00:00
afermar 3e34f088fc locale(Spanish): update translation
Currently translated at 19.6% (131 of 666 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/es/
2025-07-21 18:17:41 +00:00
afermar 5b9e5c6003 locale(Spanish): update translation
Currently translated at 19.5% (130 of 666 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/es/
2025-07-21 16:41:11 +00:00
eitchtee c266b8809f chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-07-21 13:16:57 +00:00
Herculino Trotta 8cda4116bc Merge pull request #290
fix: FAB gets bellow hovered transaction
2025-07-21 10:16:14 -03:00
Herculino Trotta c2510b2261 feat: allow to select if transfer transactions are muted or not on creation 2025-07-21 10:15:02 -03:00
Herculino Trotta dcdaf756f9 fix: FAB gets bellow hovered transaction 2025-07-21 09:51:11 -03:00
Herculino Trotta 50ca08165a Merge pull request #289
fix: hover detection doesn't work with Firefox mobile
2025-07-21 09:39:13 -03:00
Herculino Trotta f85618fa01 fix: hover detection doesn't work with Firefox mobile 2025-07-21 09:38:48 -03:00
Dimitri Decrock 635f87a8ad locale(Dutch): update translation
Currently translated at 100.0% (666 of 666 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-07-20 09:17:41 +00:00
Herculino Trotta 1a073ba53d Merge pull request #287
feat: improve ordering when searching existing transactions on DCA creation
2025-07-20 00:24:51 -03:00
Herculino Trotta 5412e5b12c feat: improve ordering when searching existing transactions on DCA creation 2025-07-20 00:24:31 -03:00
Herculino Trotta 2103ba1b38 Merge pull request #286
fix: DCA and other indicators too close together when there's more than one
2025-07-20 00:09:25 -03:00
Herculino Trotta 04fb15224c fix: DCA and other indicators too close together when there's more than one 2025-07-20 00:09:09 -03:00
Herculino Trotta 2fc526beac Merge pull request #285
docs: update README
2025-07-20 00:02:46 -03:00
Herculino Trotta cc3ca4f4a3 docs: improve README 2025-07-20 00:02:11 -03:00
Herculino Trotta 8d3844c431 Merge pull request #284
feat: add FAB to networth and all transactions pages
2025-07-20 00:00:14 -03:00
Herculino Trotta 5e7e918085 feat: add FAB to networth and all transactions pages 2025-07-19 23:59:58 -03:00
Herculino Trotta c3f02320b5 locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (666 of 666 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-07-19 21:17:41 +00:00
eitchtee da8bbbfb0b chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-07-19 19:24:50 +00:00
Herculino Trotta e3f74538d2 Merge pull request #283
feat: quickly add an existing transaction as a model for quick transactions
2025-07-19 16:23:16 -03:00
Herculino Trotta d8234950c6 feat: quickly add an existing transaction as a model for quick transactions
Use the dropdown menu on a transaction
2025-07-19 16:22:50 -03:00
Herculino Trotta 58f19ce1ca Merge pull request #282
feat: allow single transactions to be hidden from summaries

fix #274
2025-07-19 16:19:50 -03:00
Herculino Trotta ef5f3580a0 feat: allow single transactions to be hidden from summaries
Control is done via the dropdown menu on a transaction item
Transfers are hidden by default

fix #274
2025-07-19 16:19:11 -03:00
eitchtee efe0f99cb4 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-07-19 18:47:16 +00:00
Herculino Trotta dccb5079ad Merge pull request #281
feat: add selected count to floating transactions bar
2025-07-19 15:45:55 -03:00
Herculino Trotta 6c90150661 feat: add selected count to floating transactions bar 2025-07-19 15:39:57 -03:00
eitchtee c33d6fab69 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-07-19 17:48:14 +00:00
Herculino Trotta c0c57a6d77 Merge pull request #280
feat: add dropdown to transaction menu and move duplication to there
2025-07-19 14:46:41 -03:00
Herculino Trotta f19d58a2bd feat: add dropdown to transaction menu and move duplication to there 2025-07-19 14:46:23 -03:00
eitchtee dfe99093e9 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-07-19 17:43:49 +00:00
Herculino Trotta d737e573cc Merge pull request #279
refactor: improve transaction menu displaying
2025-07-19 14:43:10 -03:00
Herculino Trotta 805d3f419e refactor: improve transaction menu displaying
Use hover instead of hyperscript and events
2025-07-19 14:42:50 -03:00
Herculino Trotta 9777aac746 Merge pull request #278
feat: slightly opaque transactions if they're silenced
2025-07-18 23:35:20 -03:00
Herculino Trotta 61b782104d feat: slightly opaque transactions if they're silenced 2025-07-18 23:34:55 -03:00
eitchtee 79dec2b515 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-07-12 05:27:34 +00:00
Herculino Trotta db23e162c4 Merge pull request #276
feat: auto-refresh overview and networth pages (10m for overview and 1h for networth)
2025-07-12 02:26:50 -03:00
Herculino Trotta d81d89d9f6 feat: auto-refresh overview and networth pages (10m for overview and 1h for networth)
Useful if you want to leave WYGIWYH open in a fixed tab
2025-07-12 02:26:15 -03:00
Herculino Trotta 6826cfe79a Merge pull request #275
fix: backspacing on currency fields doesn't update mask
2025-07-09 20:27:40 -03:00
Herculino Trotta 0832ec75ca fix: visual bug when backspacing on the "amount" field 2025-07-09 20:24:27 -03:00
Dimitri Decrock 3090f632de locale(Dutch): update translation
Currently translated at 100.0% (661 of 661 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-06-30 06:17:41 +00: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
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
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
403 changed files with 64119 additions and 9678 deletions
+16 -2
View File
@@ -1,6 +1,7 @@
SERVER_NAME=wygiwyh_server SERVER_NAME=wygiwyh_server
DB_NAME=wygiwyh_pg DB_NAME=wygiwyh_pg
PROCRASTINATE_NAME=wygiwyh_procrastinate
TZ=UTC # Change to your timezone. This only affects some async tasks.
DEBUG=false DEBUG=false
URL = https://... 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] DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1]
OUTBOUND_PORT=9005 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_DATABASE=wygiwyh
SQL_USER=wygiwyh SQL_USER=wygiwyh
SQL_PASSWORD=<INSERT A SAFE PASSWORD HERE> SQL_PASSWORD=<INSERT A SAFE PASSWORD HERE>
@@ -24,3 +29,12 @@ WEB_CONCURRENCY=4
ENABLE_SOFT_DELETE=false 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. # 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 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"]
+58 -1
View File
@@ -3,10 +3,27 @@ name: Release Pipeline
on: on:
release: 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: env:
IMAGE_NAME: wygiwyh IMAGE_NAME: wygiwyh
concurrency:
group: release
cancel-in-progress: false
jobs: jobs:
build-and-push: build-and-push:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -16,6 +33,13 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 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 - name: Log in to Docker Hub
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
@@ -29,16 +53,49 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 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 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: ./docker/prod/django/Dockerfile file: ./docker/prod/django/Dockerfile
push: true push: true
provenance: false 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: | tags: |
${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:latest ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:latest
${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:${{ github.event.release.tag_name }} ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:${{ github.event.release.tag_name }}
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max 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"
+3
View File
@@ -160,3 +160,6 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear # and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
.idea/ .idea/
postgres_data/
.prod.env
+82 -454
View File
@@ -13,6 +13,7 @@
<a href="#key-features">Features</a> • <a href="#key-features">Features</a> •
<a href="#how-to-use">Usage</a> • <a href="#how-to-use">Usage</a> •
<a href="#how-it-works">How</a> • <a href="#how-it-works">How</a> •
<a href="#help-us-translate-wygiwyh">Translate</a> •
<a href="#caveats-and-warnings">Caveats and Warnings</a> • <a href="#caveats-and-warnings">Caveats and Warnings</a> •
<a href="#built-with">Built with</a> <a href="#built-with">Built with</a>
</p> </p>
@@ -28,15 +29,15 @@ Managing money can feel unnecessarily complex, but it doesnt have to be. WYGI
By sticking to this straightforward approach, you avoid dipping into your savings while still keeping tabs on where your money goes. By sticking to this straightforward approach, you avoid dipping into your savings while still keeping tabs on where your money goes.
While this philosophy is simple, finding tools to make it work wasnt. I initially used a spreadsheet, which served me well for yearsuntil it became unwieldy as I started managing multiple currencies, accounts, and investments. I tried various financial management apps, but none met my key requirements: While this philosophy is simple, finding tools to make it work wasnt. I initially used a spreadsheet, which served me well for years, until it became unwieldy as I started managing multiple currencies, accounts, and investments. I tried various financial management apps, but none met my key requirements:
1. **Multi-currency support** to track income and expenses in different currencies. 1. **Multi-currency support** to track income and expenses in different currencies.
2. **Not a budgeting app** as I dislike budgeting constraints. 2. **Not a budgeting app** as I dislike budgeting constraints.
3. **Web app usability** (ideally with mobile support, though optional). 3. **Web app usability** (ideally with mobile support, though optional).
4. **Automation-ready API** to integrate with other tools and services. 4. **Automation-ready API** to integrate with other tools and services.
5. **Custom transaction rules** for credit card billing cycles or similar quirks. 5. **Custom transaction rules** for credit card billing cycles or similar quirks.
Frustrated by the lack of comprehensive options, I set out to build **WYGIWYH** an opinionated yet powerful tool that I believe will resonate with like-minded users. Frustrated by the lack of comprehensive options, I set out to build **WYGIWYH**, an opinionated yet powerful tool that I believe will resonate with like-minded users.
# Key Features # Key Features
@@ -50,6 +51,17 @@ Frustrated by the lack of comprehensive options, I set out to build **WYGIWYH**
* **Built-in Dollar-Cost Average (DCA) tracker**: Essential for tracking recurring investments, especially for crypto and stocks. * **Built-in Dollar-Cost Average (DCA) tracker**: Essential for tracking recurring investments, especially for crypto and stocks.
* **API support for automation**: Seamlessly integrate with existing services to synchronize transactions. * **API support for automation**: Seamlessly integrate with existing services to synchronize transactions.
# Demo
You can try WYGIWYH on [wygiwyh-demo.herculino.com](https://wygiwyh-demo.herculino.com/) with the credentials below:
> [!NOTE]
> E-mail: `demo@demo.com`
>
> Password: `wygiwyhdemo`
Keep in mind that **any data you add will be wiped in 24 hours or less**. And that **most automation features like the API, Rules, Automatic Exchange Rates and Import/Export are disabled**.
# How To Use # How To Use
To run this application, you'll need [Docker](https://docs.docker.com/engine/install/) with [docker-compose](https://docs.docker.com/compose/install/). To run this application, you'll need [Docker](https://docs.docker.com/engine/install/) with [docker-compose](https://docs.docker.com/compose/install/).
@@ -75,10 +87,13 @@ $ nano .env # or any other editor you want to use
# Run the app # Run the app
$ docker compose up -d $ docker compose up -d
# Create the first admin account # Create the first admin account. This isn't required if you set the enviroment variables: ADMIN_EMAIL and ADMIN_PASSWORD.
$ docker compose exec -it web python manage.py createsuperuser $ docker compose exec -it web python manage.py createsuperuser
``` ```
> [!NOTE]
> If you're using Unraid, you don't need to follow these steps, use the app on the store. Make sure to read the [Unraid section](#unraid) and [Environment Variables](#environment-variables) for an explanation of all available variables
## Running locally ## Running locally
If you want to run WYGIWYH locally, on your env file: If you want to run WYGIWYH locally, on your env file:
@@ -90,471 +105,84 @@ If you want to run WYGIWYH locally, on your env file:
You can now access localhost:OUTBOUND_PORT You can now access localhost:OUTBOUND_PORT
> [!NOTE] > [!NOTE]
> 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 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://`
## 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.
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 |
|-------------------------------|-------------|-----------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| INTERNAL_PORT | int | 8000 | The port on which the app listens on. Defaults to 8000 if not set. |
| 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. |
| CHECK_FOR_UPDATES | bool | true | Check and notify users about new versions. The check is done by doing a single query to Github's API every 12 hours. |
## OIDC Configuration
WYGIWYH supports login via OpenID Connect (OIDC) through `django-allauth`. This allows users to authenticate using an external OIDC provider.
> [!NOTE] > [!NOTE]
> If you're going to use another IP that isn't localhost, add it to `DJANGO_ALLOWED_HOSTS`, without `http://` > 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:
## Building from source | 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`. |
Features are only added to `main` when ready, if you want to run the latest version, you must build from source. **Callback URL (Redirect URI):**
```bash 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:
# Create a folder for WYGIWYH (optional)
$ mkdir WYGIWYH
# Go into the folder `https://your.wygiwyh.domain/auth/oidc/<OIDC_CLIENT_NAME>/login/callback/`
$ cd WYGIWYH
# Clone this repository 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.
$ git clone https://github.com/eitchtee/WYGIWYH.git .
$ cp docker-compose.prod.yml docker-compose.yml
$ cp .env.example .env
# Now edit both files as you see fit
# Run the app
$ docker compose up -d --build
# Create the first admin account
$ docker compose exec -it web python manage.py createsuperuser
```
# How it works # How it works
## Models Check out our [Wiki](https://github.com/eitchtee/WYGIWYH/wiki) for more information.
### Transactions # Help us translate WYGIWYH!
<a href="https://translations.herculino.com/engage/wygiwyh/">
Transactions are the core feature of WYGIWYH, representing expenses or income in your accounts. Each transaction consists of the following fields: <img src="https://translations.herculino.com/widget/wygiwyh/open-graph.png" alt="Translation status" />
</a>
#### 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
Accounts represent different financial entities where transactions occur. They have the following attributes:
- **Name**: A unique identifier for the account.
- **Group**: An optional [account group](#account-groups) the account belongs to for organizational purposes.
- **Currency**: The primary [currency](#currency) of the account.
- **Exchange Currency**: An optional currency used for exchange rate calculations.
- **Is Asset**: A boolean indicating if the account is considered an asset (counts towards net worth).
- **Is Archived**: A boolean indicating if the account is archived (doesn't show up in active lists or count towards net worth).
### Account Groups
Account Groups are used to organize accounts into logical categories. They consist of:
- **Name**: A unique identifier for the group.
### Currency
Currencies represent different monetary units. They include:
* **Code**: A unique identifier for the currency (e.g., USD, EUR).
* **Name**: The full name of the currency.
* **Decimal Place**: The number of decimal places used for the currency.
* **Prefix**: An optional symbol or text that comes before the amount.
* **Suffix**: An optional symbol or text that comes after the amount.
### Exchange Rate
Exchange Rates store conversion rates between currencies:
* **From Currency**: The source currency.
* **To Currency**: The target currency.
* **Rate**: The conversion rate.
* **Date**: The date the rate was recorded or is valid for.
### Category
Categories are used to classify transactions:
* **Name**: A unique identifier for the category.
* **Muted**: Muted categories won't count towards your monthly total.
* **Active**: A boolean indicating if the category is currently in use. This will disable its use on new transactions.
### Tag
Tags provide additional labeling for transactions:
* **Name**: A unique identifier for the tag.
* **Active**: A boolean indicating if the category is currently in use. This will disable its use on new transactions.
### Entity
Entities represent parties involved in transactions:
* **Name**: A unique identifier for the entity.
* **Active**: A boolean indicating if the entity is currently in use. This will disable its use on new transactions.
---
## 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
The Monthly view provides an overview of your financial activity for a specific month. It includes:
* Total income and expenses for the month
* Daily spending allowance calculation
* List of transactions for the month
> [!NOTE] > [!NOTE]
> Reference dates are taken into account here. > Login with your github account
### Yearly by currency
This view gives you a yearly summary of your finances grouped by currency. It shows:
* Total income and expenses for each currency
* Monthly breakdown of income and expenses
### Yearly by account
Similar to the [yearly by currency](#yearly-by-currency) view, but groups the data by account instead.
### Calendar
The Calendar view presents your transactions in a monthly calendar format, allowing you to see your financial activity day by day. It includes:
* Visual representation of daily transaction totals
* Ability to view details of transactions for each day
> [!NOTE]
> Reference dates are **not** taken into account here.
### Networh
#### Current
The Current Net Worth view shows your present financial standing, including:
* Total value of all asset accounts
* Breakdown of assets by account and currency
* Historical net worth trend
#### Projected
The Projected Net Worth view estimates your future financial position based on current data and recurring transactions. It includes:
* Your total net worth with projected and current transactions
* Breakdown of assets by account and currency
* Historical and future net worth trend
### All Transactions
This view provides a comprehensive list of all transactions across all accounts. Features include:
* Advanced filtering and sorting options
* Detailed information
You can use this to see how much you spent on a given category, or a given day, etc..
### Configuration and Management
#### Management
The Management section in the navbar allows you to add and edit most elements of WYGIWYH, including:
* Accounts and Groups
* Currencies and Exchange Rates
* Categories, Tags and Entities
* Rules
#### User Settings
WYGIWYH allows users to personalize their experience through customizable settings. Each user can configure:
* **Language**: Choose your preferred interface language.
* **Timezone**: Set your local timezone for accurate date and time display.
* **Start Page**: Select which page you want to see first when you log in.
* **Sound Preferences**: Toggle sound effects on or off.
* **Amount Display**: Choose to show or hide monetary amounts by default.
To access and modify these settings:
1. Click on your username in the top-right corner of the page.
2. Select "Settings" from the dropdown menu.
3. Adjust your preferences as desired.
4. Click "Save" to apply your changes.
These settings ensure that WYGIWYH adapts to your personal preferences and working style.
#### Django Admin
From here you can also access Django's own admin site.
> [!WARNING]
> Most side effects aren't triggered from the admin.
> Only use it if you know what you're doing or were told by a developer to do so.
---
## Tools
### Calculator
The calculator is a floating widget that can be toggled by clicking the calculator icon on the navbar or by pressing <kbd>Alt</kbd> + <kbd>C</kbd> on any page.
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.
> [!NOTE]
> This doesn't do unit convertion. The amount of all items needs to be on the same the unit for proper functioning.
![img.png](.github/img/readme_unit_price_calculator.png)
### Currency Converter
The Currency Converter is a tool that allows you to quickly convert amounts between different currencies.
> [!NOTE]
> There's no external Exchange Rate fetching. This uses the Exchange Rates configured in the [Management](#configuration-and-management) page for [Exchange Rates](#exchange-rate)
## Automation
### API
WYGIWYH has a comprehensive API, it's documentation can be accessed on `<your-wygiwyh-url>/api/docs/`
> [!NOTE]
> While the API works, there's still much to be added to it to equipare functionality with the main web app.
### Transaction Rules
Transaction Rules are a powerful feature in WYGIWYH that allow for automatic modification of transactions based on specified criteria. This can save time and ensure consistency in your financial tracking.
Key Aspects of Transaction Rules:
* **Conditions**: Set specific criteria that a transaction must meet for the rule to apply. This can include attributes like description, amount, account, etc.
* **Actions**: Define what changes should be made to a transaction when the conditions are met. This can include setting categories, tags, or modifying other fields.
* **Activation Options**: Rules can be set to apply when transactions are created, updated, or both.
#### Actions and Conditions
When creating a new rule, you will need to add a Condition and, later, Actions.
Both use a limited subset of Python, via [SimpleEval](https://github.com/danthedeckie/simpleeval).
The Condition must evaluate to True or False, and the Action must evaluate to a value that will be set on the selected field.
You may use any of the available [variables](#available-variables) and [functions](#available-functions).
#### Available variables
* `account_name`
* `account_id`
* `account_group_name`
* `account_group_id`
* `is_asset_account`
* `is_archived_account`
* `category_name`
* `category_id`
* `tag_names`
* `tag_ids`
* `entities_names`
* `entities_ids`
* `is_expense`
* `is_income`
* `is_paid`
* `description`
* `amount`
* `notes`
* `date`
* `reference_date`
#### Available functions
* `relativedelta`
#### Examples
Add a tag to an income transaction if it happens in a specific account
```
If...
account_name == "My Investing Account" and is_income
Then...
Set Tags to
tag_names + ["Yield"]
```
---
Move credit card transactions to next month when they happen at a cutoff date
```
If...
account_name == "My credit card" and date.day >= 26 and reference_date.month == date.month
Then...
Set Reference Date to
reference_date + relativedelta(months=1)).replace(day=1)
```
# Caveats and Warnings # Caveats and Warnings
- I'm not an accountant, some terms and even calculations might be wrong. Make sure to open an issue if you see anything that could be improved. - I'm not an accountant, some terms and even calculations might be wrong. Make sure to open an issue if you see anything that could be improved.
+191 -31
View File
@@ -14,6 +14,7 @@ import os
import sys import sys
from pathlib import Path from pathlib import Path
from django.utils.text import slugify
SITE_TITLE = "WYGIWYH" SITE_TITLE = "WYGIWYH"
TITLE_SEPARATOR = "::" TITLE_SEPARATOR = "::"
@@ -31,10 +32,8 @@ SECRET_KEY = os.getenv("SECRET_KEY", "")
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = os.getenv("DEBUG", "false").lower() == "true" DEBUG = os.getenv("DEBUG", "false").lower() == "true"
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "localhost 127.0.0.1").split(" ") ALLOWED_HOSTS = os.getenv("DJANGO_ALLOWED_HOSTS", "localhost 127.0.0.1").split(" ")
CSRF_TRUSTED_ORIGINS = os.environ.get("URL", "http://localhost http://127.0.0.1").split( CSRF_TRUSTED_ORIGINS = os.getenv("URL", "http://localhost http://127.0.0.1").split(" ")
" "
)
# Application definition # Application definition
@@ -44,6 +43,7 @@ INSTALLED_APPS = [
"django.contrib.contenttypes", "django.contrib.contenttypes",
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.messages", "django.contrib.messages",
"django.contrib.sites",
"whitenoise.runserver_nostatic", "whitenoise.runserver_nostatic",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"webpack_boilerplate", "webpack_boilerplate",
@@ -57,14 +57,15 @@ INSTALLED_APPS = [
"hijack", "hijack",
"hijack.contrib.admin", "hijack.contrib.admin",
"django_filters", "django_filters",
"import_export",
"apps.users.apps.UsersConfig", "apps.users.apps.UsersConfig",
"procrastinate.contrib.django", "procrastinate.contrib.django",
"apps.transactions.apps.TransactionsConfig", "apps.transactions.apps.TransactionsConfig",
"apps.currencies.apps.CurrenciesConfig", "apps.currencies.apps.CurrenciesConfig",
"apps.accounts.apps.AccountsConfig", "apps.accounts.apps.AccountsConfig",
"apps.common.apps.CommonConfig",
"apps.net_worth.apps.NetWorthConfig", "apps.net_worth.apps.NetWorthConfig",
"apps.import_app.apps.ImportConfig", "apps.import_app.apps.ImportConfig",
"apps.export_app.apps.ExportConfig",
"apps.api.apps.ApiConfig", "apps.api.apps.ApiConfig",
"cachalot", "cachalot",
"rest_framework", "rest_framework",
@@ -74,9 +75,18 @@ INSTALLED_APPS = [
"apps.calendar_view.apps.CalendarViewConfig", "apps.calendar_view.apps.CalendarViewConfig",
"apps.dca.apps.DcaConfig", "apps.dca.apps.DcaConfig",
"pwa", "pwa",
"allauth",
"allauth.account",
"allauth.socialaccount",
"allauth.socialaccount.providers.openid_connect",
"apps.common.apps.CommonConfig",
] ]
SITE_ID = 1
MIDDLEWARE = [ MIDDLEWARE = [
"django_browser_reload.middleware.BrowserReloadMiddleware",
"apps.common.middleware.thread_local.ThreadLocalMiddleware",
"debug_toolbar.middleware.DebugToolbarMiddleware", "debug_toolbar.middleware.DebugToolbarMiddleware",
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware", "whitenoise.middleware.WhiteNoiseMiddleware",
@@ -88,8 +98,8 @@ MIDDLEWARE = [
"apps.common.middleware.localization.LocalizationMiddleware", "apps.common.middleware.localization.LocalizationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
"django_browser_reload.middleware.BrowserReloadMiddleware",
"hijack.middleware.HijackUserMiddleware", "hijack.middleware.HijackUserMiddleware",
"allauth.account.middleware.AccountMiddleware",
] ]
ROOT_URLCONF = "WYGIWYH.urls" ROOT_URLCONF = "WYGIWYH.urls"
@@ -126,12 +136,12 @@ WSGI_APPLICATION = "WYGIWYH.wsgi.application"
DATABASES = { DATABASES = {
"default": { "default": {
"ENGINE": os.environ.get("SQL_ENGINE", "django.db.backends.sqlite3"), "ENGINE": "django.db.backends.postgresql",
"NAME": os.environ.get("SQL_DATABASE", BASE_DIR / "db.sqlite3"), "NAME": os.getenv("SQL_DATABASE"),
"USER": os.environ.get("SQL_USER", "user"), "USER": os.getenv("SQL_USER", "user"),
"PASSWORD": os.environ.get("SQL_PASSWORD", "password"), "PASSWORD": os.getenv("SQL_PASSWORD", "password"),
"HOST": os.environ.get("SQL_HOST", "localhost"), "HOST": os.getenv("SQL_HOST", "localhost"),
"PORT": "5432", "PORT": os.getenv("SQL_PORT", "5432"),
} }
} }
@@ -162,12 +172,108 @@ AUTH_USER_MODEL = "users.User"
LANGUAGE_CODE = "en" LANGUAGE_CODE = "en"
LANGUAGES = ( LANGUAGES = (
("af", "Afrikaans"),
("ar", "العربية"),
("ar-dz", "العربية (الجزائر)"), # Algerian Arabic often uses the base name + region
("ast", "Asturianu"),
("az", "Azərbaycan"),
("bg", "Български"),
("be", "Беларуская"),
("bn", "বাংলা"),
("br", "Brezhoneg"),
("bs", "Bosanski"),
("ca", "Català"),
("ckb", "کوردیی ناوەندی"), # Central Kurdish (Sorani)
("cs", "Čeština"),
("cy", "Cymraeg"),
("da", "Dansk"),
("de", "Deutsch"),
("dsb", "Dolnoserbšćina"),
("el", "Ελληνικά"),
("en", "English"), ("en", "English"),
# ("nl", "Nederlands"), ("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)"), ("pt-br", "Português (Brasil)"),
("ro", "Română"),
("ru", "Русский"),
("sk", "Slovenčina"),
("sl", "Slovenščina"),
("sq", "Shqip"),
("sr", "Српски"),
("sr-latn", "Srpski (Latinica)"),
("sv", "Svenska"),
("sw", "Kiswahili"),
("ta", "தமிழ்"),
("te", "తెలుగు"),
("tg", "Тоҷикӣ"),
("th", "ไทย"),
("tk", "Türkmençe"),
("tr", "Türkçe"),
("tt", "Татарча"),
("udm", "Удмурт"),
("ug", "ئۇيغۇرچە"),
("uk", "Українська"),
("ur", "اردو"),
("uz", "Oʻzbekcha"),
("vi", "Tiếng Việt"),
("zh-hans", "简体中文"),
("zh-hant", "繁體中文"),
) )
TIME_ZONE = "UTC" TIME_ZONE = os.getenv("TZ", "UTC")
USE_I18N = True USE_I18N = True
@@ -210,6 +316,42 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
LOGIN_REDIRECT_URL = "/" LOGIN_REDIRECT_URL = "/"
LOGIN_URL = "/login/" LOGIN_URL = "/login/"
LOGOUT_REDIRECT_URL = "/login/"
# Allauth settings
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend", # Keep default
"allauth.account.auth_backends.AuthenticationBackend",
]
SOCIALACCOUNT_PROVIDERS = {"openid_connect": {"APPS": []}}
if (
os.getenv("OIDC_CLIENT_ID")
and os.getenv("OIDC_CLIENT_SECRET")
and os.getenv("OIDC_SERVER_URL")
):
SOCIALACCOUNT_PROVIDERS["openid_connect"]["APPS"].append(
{
"provider_id": slugify(os.getenv("OIDC_CLIENT_NAME", "OpenID Connect")),
"name": os.getenv("OIDC_CLIENT_NAME", "OpenID Connect"),
"client_id": os.getenv("OIDC_CLIENT_ID"),
"secret": os.getenv("OIDC_CLIENT_SECRET"),
"settings": {
"server_url": os.getenv("OIDC_SERVER_URL"),
},
}
)
ACCOUNT_LOGIN_METHODS = {"email"}
ACCOUNT_SIGNUP_FIELDS = ["email*", "password1*", "password2*"]
ACCOUNT_USER_MODEL_USERNAME_FIELD = None
ACCOUNT_EMAIL_VERIFICATION = "none"
SOCIALACCOUNT_LOGIN_ON_GET = True
SOCIALACCOUNT_ONLY = True
SOCIALACCOUNT_AUTO_SIGNUP = os.getenv("OIDC_ALLOW_SIGNUP", "true").lower() == "true"
ACCOUNT_ADAPTER = "allauth.account.adapter.DefaultAccountAdapter"
SOCIALACCOUNT_ADAPTER = "allauth.socialaccount.adapter.DefaultSocialAccountAdapter"
# CRISPY FORMS # CRISPY FORMS
CRISPY_ALLOWED_TEMPLATE_PACKS = ["bootstrap5", "crispy_forms/pure_text"] CRISPY_ALLOWED_TEMPLATE_PACKS = ["bootstrap5", "crispy_forms/pure_text"]
@@ -221,7 +363,7 @@ SESSION_COOKIE_SECURE = os.getenv("HTTPS_ENABLED", "false").lower() == "true"
DEBUG_TOOLBAR_CONFIG = { DEBUG_TOOLBAR_CONFIG = {
"ROOT_TAG_EXTRA_ATTRS": "hx-preserve", "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 = [
"debug_toolbar.panels.history.HistoryPanel", "debug_toolbar.panels.history.HistoryPanel",
@@ -237,7 +379,7 @@ DEBUG_TOOLBAR_PANELS = [
"debug_toolbar.panels.signals.SignalsPanel", "debug_toolbar.panels.signals.SignalsPanel",
"debug_toolbar.panels.redirects.RedirectsPanel", "debug_toolbar.panels.redirects.RedirectsPanel",
"debug_toolbar.panels.profiling.ProfilingPanel", "debug_toolbar.panels.profiling.ProfilingPanel",
"cachalot.panels.CachalotPanel", # "cachalot.panels.CachalotPanel",
] ]
INTERNAL_IPS = [ INTERNAL_IPS = [
"127.0.0.1", "127.0.0.1",
@@ -259,7 +401,10 @@ if DEBUG:
REST_FRAMEWORK = { REST_FRAMEWORK = {
# Use Django's standard `django.contrib.auth` permissions, # Use Django's standard `django.contrib.auth` permissions,
# or allow read-only access for unauthenticated users. # or allow read-only access for unauthenticated users.
"DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.DjangoModelPermissions"], "DEFAULT_PERMISSION_CLASSES": [
"apps.api.permissions.NotInDemoMode",
"rest_framework.permissions.DjangoModelPermissions",
],
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 10, "PAGE_SIZE": 10,
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
@@ -278,29 +423,32 @@ if "procrastinate" in sys.argv:
"version": 1, "version": 1,
"disable_existing_loggers": False, "disable_existing_loggers": False,
"formatters": { "formatters": {
"procrastinate": { "standard": {
"format": "%(asctime)s %(levelname)-7s %(name)s %(message)s" "format": "[%(asctime)s] - %(levelname)s - %(name)s - %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
}, },
}, },
"handlers": { "handlers": {
"procrastinate": { "procrastinate": {
"level": "DEBUG", "level": "INFO",
"class": "logging.StreamHandler", "class": "logging.StreamHandler",
"formatter": "procrastinate", "formatter": "standard",
}, },
"console": { "console": {
"class": "logging.StreamHandler", "class": "logging.StreamHandler",
"formatter": "standard",
"level": "INFO",
}, },
}, },
"loggers": { "loggers": {
"procrastinate": { "procrastinate": {
"handlers": ["procrastinate"], "handlers": ["procrastinate"],
"level": "INFO",
"propagate": False, "propagate": False,
}, },
"root": { "root": {
"handlers": ["console"], "handlers": ["console"],
"level": "INFO", "level": "INFO",
"propagate": False,
}, },
}, },
} }
@@ -309,24 +457,25 @@ else:
"version": 1, "version": 1,
"disable_existing_loggers": False, "disable_existing_loggers": False,
"formatters": { "formatters": {
"procrastinate": { "standard": {
"format": "%(asctime)s %(levelname)-7s %(name)s %(message)s" "format": "[%(asctime)s] - %(levelname)s - %(name)s - %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
}, },
}, },
"handlers": { "handlers": {
"procrastinate": {
"level": "DEBUG",
"class": "logging.StreamHandler",
"formatter": "procrastinate",
},
"console": { "console": {
"class": "logging.StreamHandler", "class": "logging.StreamHandler",
"formatter": "standard",
"level": "INFO",
},
"procrastinate": {
"level": "INFO",
"class": "logging.StreamHandler",
}, },
}, },
"loggers": { "loggers": {
"procrastinate": { "procrastinate": {
"handlers": None, "handlers": None,
"level": "INFO",
"propagate": False, "propagate": False,
}, },
"root": { "root": {
@@ -338,6 +487,8 @@ else:
CACHALOT_UNCACHABLE_TABLES = ("django_migrations", "procrastinate_jobs") CACHALOT_UNCACHABLE_TABLES = ("django_migrations", "procrastinate_jobs")
# Procrastinate
PROCRASTINATE_ON_APP_READY = "apps.common.procrastinate.on_app_ready"
# PWA # PWA
PWA_APP_NAME = SITE_TITLE PWA_APP_NAME = SITE_TITLE
@@ -363,7 +514,13 @@ PWA_APP_SPLASH_SCREEN = [
] ]
PWA_APP_DIR = "ltr" PWA_APP_DIR = "ltr"
PWA_APP_LANG = "en-US" PWA_APP_LANG = "en-US"
PWA_APP_SHORTCUTS = [] PWA_APP_SHORTCUTS = [
{
"name": "New Transaction",
"url": "/add/",
"description": "Add new transaction",
}
]
PWA_APP_SCREENSHOTS = [ PWA_APP_SCREENSHOTS = [
{ {
"src": "/static/img/pwa/splash-750x1334.png", "src": "/static/img/pwa/splash-750x1334.png",
@@ -380,4 +537,7 @@ PWA_APP_SCREENSHOTS = [
PWA_SERVICE_WORKER_PATH = BASE_DIR / "templates" / "pwa" / "serviceworker.js" PWA_SERVICE_WORKER_PATH = BASE_DIR / "templates" / "pwa" / "serviceworker.js"
ENABLE_SOFT_DELETE = os.getenv("ENABLE_SOFT_DELETE", "false").lower() == "true" ENABLE_SOFT_DELETE = os.getenv("ENABLE_SOFT_DELETE", "false").lower() == "true"
CHECK_FOR_UPDATES = os.getenv("CHECK_FOR_UPDATES", "true").lower() == "true"
KEEP_DELETED_TRANSACTIONS_FOR = int(os.getenv("KEEP_DELETED_ENTRIES_FOR", "365")) KEEP_DELETED_TRANSACTIONS_FOR = int(os.getenv("KEEP_DELETED_ENTRIES_FOR", "365"))
APP_VERSION = os.getenv("APP_VERSION", "unknown")
DEMO = os.getenv("DEMO", "false").lower() == "true"
+11
View File
@@ -21,6 +21,8 @@ from drf_spectacular.views import (
SpectacularAPIView, SpectacularAPIView,
SpectacularSwaggerView, SpectacularSwaggerView,
) )
from allauth.socialaccount.providers.openid_connect.views import login, callback
urlpatterns = [ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
@@ -36,6 +38,13 @@ urlpatterns = [
SpectacularSwaggerView.as_view(url_name="schema"), SpectacularSwaggerView.as_view(url_name="schema"),
name="swagger-ui", name="swagger-ui",
), ),
path("auth/", include("allauth.urls")), # allauth urls
# path("auth/oidc/<str:provider_id>/login/", login, name="openid_connect_login"),
# path(
# "auth/oidc/<str:provider_id>/login/callback/",
# callback,
# name="openid_connect_callback",
# ),
path("", include("apps.transactions.urls")), path("", include("apps.transactions.urls")),
path("", include("apps.common.urls")), path("", include("apps.common.urls")),
path("", include("apps.users.urls")), path("", include("apps.users.urls")),
@@ -49,4 +58,6 @@ urlpatterns = [
path("", include("apps.dca.urls")), path("", include("apps.dca.urls")),
path("", include("apps.mini_tools.urls")), path("", include("apps.mini_tools.urls")),
path("", include("apps.import_app.urls")), path("", include("apps.import_app.urls")),
path("", include("apps.export_app.urls")),
path("", include("apps.insights.urls")),
] ]
+10 -2
View File
@@ -1,6 +1,14 @@
from django.contrib import admin from django.contrib import admin
from apps.accounts.models import Account from apps.accounts.models import Account, AccountGroup
from apps.common.admin import SharedObjectModelAdmin
admin.site.register(Account) @admin.register(Account)
class AccountModelAdmin(SharedObjectModelAdmin):
pass
@admin.register(AccountGroup)
class AccountGroupModelAdmin(SharedObjectModelAdmin):
pass
+22
View File
@@ -3,6 +3,7 @@ from crispy_forms.bootstrap import FormActions
from crispy_forms.helper import FormHelper from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Field, Column, Row from crispy_forms.layout import Layout, Field, Column, Row
from django import forms from django import forms
from django.db.models import Q
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from apps.accounts.models import Account from apps.accounts.models import Account
@@ -15,6 +16,7 @@ from apps.common.widgets.crispy.submit import NoClassSubmit
from apps.common.widgets.tom_select import TomSelect from apps.common.widgets.tom_select import TomSelect
from apps.transactions.models import TransactionCategory, TransactionTag from apps.transactions.models import TransactionCategory, TransactionTag
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
from apps.currencies.models import Currency
class AccountGroupForm(forms.ModelForm): class AccountGroupForm(forms.ModelForm):
@@ -77,6 +79,20 @@ class AccountForm(forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["group"].queryset = AccountGroup.objects.all()
if self.instance.id:
qs = Currency.objects.filter(
Q(is_archived=False) | Q(accounts=self.instance.id)
).distinct()
self.fields["currency"].queryset = qs
self.fields["exchange_currency"].queryset = qs
else:
qs = Currency.objects.filter(Q(is_archived=False))
self.fields["currency"].queryset = qs
self.fields["exchange_currency"].queryset = qs
self.helper = FormHelper() self.helper = FormHelper()
self.helper.form_tag = False self.helper.form_tag = False
self.helper.form_method = "post" self.helper.form_method = "post"
@@ -151,5 +167,11 @@ class AccountBalanceForm(forms.Form):
decimal_places=self.currency_decimal_places decimal_places=self.currency_decimal_places
) )
self.fields["category"].queryset = TransactionCategory.objects.filter(
active=True
)
self.fields["tags"].queryset = TransactionTag.objects.filter(active=True)
AccountBalanceFormSet = forms.formset_factory(AccountBalanceForm, extra=0) AccountBalanceFormSet = forms.formset_factory(AccountBalanceForm, extra=0)
@@ -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'},
),
]
@@ -0,0 +1,46 @@
# Generated by Django 5.2.4 on 2025-07-28 02:15
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0014_alter_account_options_alter_accountgroup_options'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='account',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
),
migrations.AlterField(
model_name='account',
name='shared_with',
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL, verbose_name='Shared with users'),
),
migrations.AlterField(
model_name='account',
name='visibility',
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10, verbose_name='Visibility'),
),
migrations.AlterField(
model_name='accountgroup',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
),
migrations.AlterField(
model_name='accountgroup',
name='shared_with',
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL, verbose_name='Shared with users'),
),
migrations.AlterField(
model_name='accountgroup',
name='visibility',
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10, verbose_name='Visibility'),
),
]
@@ -0,0 +1,20 @@
# Generated by Django 5.2.4 on 2025-08-09 05:52
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0015_alter_account_owner_alter_account_shared_with_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='account',
name='untracked_by',
field=models.ManyToManyField(blank=True, related_name='untracked_accounts', to=settings.AUTH_USER_MODEL),
),
]
+26 -4
View File
@@ -1,24 +1,32 @@
from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from apps.common.middleware.thread_local import get_current_user
from apps.common.models import SharedObject, SharedObjectManager
from apps.transactions.models import Transaction from apps.transactions.models import Transaction
class AccountGroup(models.Model): class AccountGroup(SharedObject):
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True) name = models.CharField(max_length=255, verbose_name=_("Name"))
objects = SharedObjectManager()
all_objects = models.Manager() # Unfiltered manager
class Meta: class Meta:
verbose_name = _("Account Group") verbose_name = _("Account Group")
verbose_name_plural = _("Account Groups") verbose_name_plural = _("Account Groups")
db_table = "account_groups" db_table = "account_groups"
unique_together = (("owner", "name"),)
ordering = ["name", "id"]
def __str__(self): def __str__(self):
return self.name return self.name
class Account(models.Model): class Account(SharedObject):
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True) name = models.CharField(max_length=255, verbose_name=_("Name"))
group = models.ForeignKey( group = models.ForeignKey(
AccountGroup, AccountGroup,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
@@ -54,14 +62,28 @@ class Account(models.Model):
verbose_name=_("Archived"), verbose_name=_("Archived"),
help_text=_("Archived accounts don't show up nor count towards your net worth"), help_text=_("Archived accounts don't show up nor count towards your net worth"),
) )
untracked_by = models.ManyToManyField(
settings.AUTH_USER_MODEL,
blank=True,
related_name="untracked_accounts",
)
objects = SharedObjectManager()
all_objects = models.Manager() # Unfiltered manager
class Meta: class Meta:
verbose_name = _("Account") verbose_name = _("Account")
verbose_name_plural = _("Accounts") verbose_name_plural = _("Accounts")
unique_together = (("owner", "name"),)
ordering = ["name", "id"]
def __str__(self): def __str__(self):
return self.name return self.name
def is_untracked_by(self):
user = get_current_user()
return self.untracked_by.filter(pk=user.pk).exists()
def clean(self): def clean(self):
super().clean() super().clean()
if self.exchange_currency == self.currency: if self.exchange_currency == self.currency:
+25
View File
@@ -16,11 +16,26 @@ urlpatterns = [
views.account_edit, views.account_edit,
name="account_edit", name="account_edit",
), ),
path(
"account/<int:pk>/share/",
views.account_share,
name="account_share_settings",
),
path( path(
"account/<int:pk>/delete/", "account/<int:pk>/delete/",
views.account_delete, views.account_delete,
name="account_delete", name="account_delete",
), ),
path(
"account/<int:pk>/take-ownership/",
views.account_take_ownership,
name="account_take_ownership",
),
path(
"account/<int:pk>/toggle-untracked/",
views.account_toggle_untracked,
name="account_toggle_untracked",
),
path("account-groups/", views.account_groups_index, name="account_groups_index"), path("account-groups/", views.account_groups_index, name="account_groups_index"),
path("account-groups/list/", views.account_groups_list, name="account_groups_list"), path("account-groups/list/", views.account_groups_list, name="account_groups_list"),
path("account-groups/add/", views.account_group_add, name="account_group_add"), path("account-groups/add/", views.account_group_add, name="account_group_add"),
@@ -34,4 +49,14 @@ urlpatterns = [
views.account_group_delete, views.account_group_delete,
name="account_group_delete", name="account_group_delete",
), ),
path(
"account-groups/<int:pk>/take-ownership/",
views.account_group_take_ownership,
name="account_group_take_ownership",
),
path(
"account-groups/<int:pk>/share/",
views.account_group_share,
name="account_group_share_settings",
),
] ]
+78 -1
View File
@@ -8,6 +8,8 @@ from django.views.decorators.http import require_http_methods
from apps.accounts.forms import AccountGroupForm from apps.accounts.forms import AccountGroupForm
from apps.accounts.models import AccountGroup from apps.accounts.models import AccountGroup
from apps.common.decorators.htmx import only_htmx from apps.common.decorators.htmx import only_htmx
from apps.common.models import SharedObject
from apps.common.forms import SharedObjectForm
@login_required @login_required
@@ -63,6 +65,16 @@ def account_group_add(request, **kwargs):
def account_group_edit(request, pk): def account_group_edit(request, pk):
account_group = get_object_or_404(AccountGroup, id=pk) account_group = get_object_or_404(AccountGroup, id=pk)
if account_group.owner and account_group.owner != request.user:
messages.error(request, _("Only the owner can edit this"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
if request.method == "POST": if request.method == "POST":
form = AccountGroupForm(request.POST, instance=account_group) form = AccountGroupForm(request.POST, instance=account_group)
if form.is_valid(): if form.is_valid():
@@ -91,8 +103,14 @@ def account_group_edit(request, pk):
def account_group_delete(request, pk): def account_group_delete(request, pk):
account_group = get_object_or_404(AccountGroup, id=pk) account_group = get_object_or_404(AccountGroup, id=pk)
if (
account_group.owner != request.user
and request.user in account_group.shared_with.all()
):
account_group.shared_with.remove(request.user)
messages.success(request, _("Item no longer shared with you"))
else:
account_group.delete() account_group.delete()
messages.success(request, _("Account Group deleted successfully")) messages.success(request, _("Account Group deleted successfully"))
return HttpResponse( return HttpResponse(
@@ -101,3 +119,62 @@ def account_group_delete(request, pk):
"HX-Trigger": "updated, hide_offcanvas", "HX-Trigger": "updated, hide_offcanvas",
}, },
) )
@only_htmx
@login_required
@require_http_methods(["GET"])
def account_group_take_ownership(request, pk):
account_group = get_object_or_404(AccountGroup, id=pk)
if not account_group.owner:
account_group.owner = request.user
account_group.visibility = SharedObject.Visibility.private
account_group.save()
messages.success(request, _("Ownership taken successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def account_group_share(request, pk):
obj = get_object_or_404(AccountGroup, id=pk)
if obj.owner and obj.owner != request.user:
messages.error(request, _("Only the owner can edit this"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
if request.method == "POST":
form = SharedObjectForm(request.POST, instance=obj, user=request.user)
if form.is_valid():
form.save()
messages.success(request, _("Configuration saved successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
else:
form = SharedObjectForm(instance=obj, user=request.user)
return render(
request,
"accounts/fragments/share.html",
{"form": form, "object": obj},
)
+94 -1
View File
@@ -8,6 +8,8 @@ from django.views.decorators.http import require_http_methods
from apps.accounts.forms import AccountForm from apps.accounts.forms import AccountForm
from apps.accounts.models import Account from apps.accounts.models import Account
from apps.common.decorators.htmx import only_htmx from apps.common.decorators.htmx import only_htmx
from apps.common.models import SharedObject
from apps.common.forms import SharedObjectForm
@login_required @login_required
@@ -62,6 +64,15 @@ def account_add(request, **kwargs):
@require_http_methods(["GET", "POST"]) @require_http_methods(["GET", "POST"])
def account_edit(request, pk): def account_edit(request, pk):
account = get_object_or_404(Account, id=pk) account = get_object_or_404(Account, id=pk)
if account.owner and account.owner != request.user:
messages.error(request, _("Only the owner can edit this"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
if request.method == "POST": if request.method == "POST":
form = AccountForm(request.POST, instance=account) form = AccountForm(request.POST, instance=account)
@@ -85,14 +96,55 @@ def account_edit(request, pk):
) )
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def account_share(request, pk):
obj = get_object_or_404(Account, id=pk)
if obj.owner and obj.owner != request.user:
messages.error(request, _("Only the owner can edit this"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
if request.method == "POST":
form = SharedObjectForm(request.POST, instance=obj, user=request.user)
if form.is_valid():
form.save()
messages.success(request, _("Configuration saved successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
else:
form = SharedObjectForm(instance=obj, user=request.user)
return render(
request,
"accounts/fragments/share.html",
{"form": form, "object": obj},
)
@only_htmx @only_htmx
@login_required @login_required
@require_http_methods(["DELETE"]) @require_http_methods(["DELETE"])
def account_delete(request, pk): def account_delete(request, pk):
account = get_object_or_404(Account, id=pk) account = get_object_or_404(Account, id=pk)
if account.owner != request.user and request.user in account.shared_with.all():
account.shared_with.remove(request.user)
messages.success(request, _("Item no longer shared with you"))
else:
account.delete() account.delete()
messages.success(request, _("Account deleted successfully")) messages.success(request, _("Account deleted successfully"))
return HttpResponse( return HttpResponse(
@@ -101,3 +153,44 @@ def account_delete(request, pk):
"HX-Trigger": "updated, hide_offcanvas", "HX-Trigger": "updated, hide_offcanvas",
}, },
) )
@only_htmx
@login_required
@require_http_methods(["GET"])
def account_toggle_untracked(request, pk):
account = get_object_or_404(Account, id=pk)
if account.is_untracked_by():
account.untracked_by.remove(request.user)
messages.success(request, _("Account is now tracked"))
else:
account.untracked_by.add(request.user)
messages.success(request, _("Account is now untracked"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated",
},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def account_take_ownership(request, pk):
account = get_object_or_404(Account, id=pk)
if not account.owner:
account.owner = request.user
account.visibility = SharedObject.Visibility.private
account.save()
messages.success(request, _("Ownership taken successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
+3 -3
View File
@@ -38,9 +38,9 @@ def account_reconciliation(request):
"prefix": account.currency.prefix, "prefix": account.currency.prefix,
"current_balance": get_account_balance(account), "current_balance": get_account_balance(account),
} }
for account in Account.objects.filter(is_archived=False).select_related( for account in Account.objects.filter(is_archived=False)
"currency", "group" .select_related("currency", "group")
) .order_by("group", "name")
] ]
if request.method == "POST": if request.method == "POST":
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 drf_spectacular.utils import extend_schema_field
from rest_framework import serializers from rest_framework import serializers
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from apps.transactions.models import ( from apps.transactions.models import (
TransactionCategory, TransactionCategory,
TransactionTag, TransactionTag,
@@ -29,7 +27,11 @@ class TransactionCategoryField(serializers.Field):
_("Category with this ID does not exist.") _("Category with this ID does not exist.")
) )
elif isinstance(data, str): elif isinstance(data, str):
category, created = TransactionCategory.objects.get_or_create(name=data) try:
category = TransactionCategory.objects.get(name=data)
except TransactionCategory.DoesNotExist:
category = TransactionCategory(name=data)
category.save()
return category return category
raise serializers.ValidationError( raise serializers.ValidationError(
_("Invalid category data. Provide an ID or name.") _("Invalid category data. Provide an ID or name.")
@@ -39,7 +41,10 @@ class TransactionCategoryField(serializers.Field):
def get_schema(): def get_schema():
return { return {
"type": "array", "type": "array",
"items": {"type": "string", "description": "TransactionTag ID or name"}, "items": {
"type": "string",
"description": "TransactionCategory ID or name",
},
} }
@@ -65,7 +70,11 @@ class TransactionTagField(serializers.Field):
_("Tag with this ID does not exist.") _("Tag with this ID does not exist.")
) )
elif isinstance(item, str): elif isinstance(item, str):
tag, created = TransactionTag.objects.get_or_create(name=item) try:
tag = TransactionTag.objects.get(name=item)
except TransactionTag.DoesNotExist:
tag = TransactionTag(name=item)
tag.save()
else: else:
raise serializers.ValidationError( raise serializers.ValidationError(
_("Invalid tag data. Provide an ID or name.") _("Invalid tag data. Provide an ID or name.")
@@ -74,6 +83,13 @@ class TransactionTagField(serializers.Field):
return tags return tags
@extend_schema_field(
{
"type": "array",
"items": {"oneOf": [{"type": "string"}, {"type": "integer"}]},
"description": "TransactionEntity ID or name. If the name doesn't exist, a new one will be created",
}
)
class TransactionEntityField(serializers.Field): class TransactionEntityField(serializers.Field):
def to_representation(self, value): def to_representation(self, value):
return [{"id": entity.id, "name": entity.name} for entity in value.all()] return [{"id": entity.id, "name": entity.name} for entity in value.all()]
@@ -84,12 +100,16 @@ class TransactionEntityField(serializers.Field):
if isinstance(item, int): if isinstance(item, int):
try: try:
entity = TransactionEntity.objects.get(pk=item) entity = TransactionEntity.objects.get(pk=item)
except TransactionTag.DoesNotExist: except TransactionEntity.DoesNotExist:
raise serializers.ValidationError( raise serializers.ValidationError(
_("Entity with this ID does not exist.") _("Entity with this ID does not exist.")
) )
elif isinstance(item, str): elif isinstance(item, str):
entity, created = TransactionEntity.objects.get_or_create(name=item) try:
entity = TransactionEntity.objects.get(name=item)
except TransactionEntity.DoesNotExist:
entity = TransactionEntity(name=item)
entity.save()
else: else:
raise serializers.ValidationError( raise serializers.ValidationError(
_("Invalid entity data. Provide an ID or name.") _("Invalid entity data. Provide an ID or name.")
+10
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
+9
View File
@@ -1,3 +1,4 @@
from django.db.models import Q
from rest_framework import serializers from rest_framework import serializers
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
@@ -22,6 +23,7 @@ class AccountSerializer(serializers.ModelSerializer):
write_only=True, write_only=True,
allow_null=True, allow_null=True,
) )
currency = CurrencySerializer(read_only=True) currency = CurrencySerializer(read_only=True)
currency_id = serializers.PrimaryKeyRelatedField( currency_id = serializers.PrimaryKeyRelatedField(
queryset=Currency.objects.all(), source="currency", write_only=True queryset=Currency.objects.all(), source="currency", write_only=True
@@ -50,6 +52,13 @@ class AccountSerializer(serializers.ModelSerializer):
"is_asset", "is_asset",
] ]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
request = self.context.get("request")
if request and request.user.is_authenticated:
# Reload the queryset to get an updated version with the requesting user
self.fields["group_id"].queryset = AccountGroup.objects.all()
def create(self, validated_data): def create(self, validated_data):
return Account.objects.create(**validated_data) return Account.objects.create(**validated_data)
+34 -10
View File
@@ -1,3 +1,5 @@
from decimal import Decimal
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from drf_spectacular import openapi from drf_spectacular import openapi
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
@@ -21,6 +23,7 @@ from apps.transactions.models import (
TransactionEntity, TransactionEntity,
RecurringTransaction, RecurringTransaction,
) )
from apps.common.middleware.thread_local import get_current_user
class TransactionCategorySerializer(serializers.ModelSerializer): class TransactionCategorySerializer(serializers.ModelSerializer):
@@ -29,6 +32,10 @@ class TransactionCategorySerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = TransactionCategory model = TransactionCategory
fields = "__all__" fields = "__all__"
read_only_fields = [
"id",
"owner",
]
class TransactionTagSerializer(serializers.ModelSerializer): class TransactionTagSerializer(serializers.ModelSerializer):
@@ -37,6 +44,10 @@ class TransactionTagSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = TransactionTag model = TransactionTag
fields = "__all__" fields = "__all__"
read_only_fields = [
"id",
"owner",
]
class TransactionEntitySerializer(serializers.ModelSerializer): class TransactionEntitySerializer(serializers.ModelSerializer):
@@ -45,12 +56,16 @@ class TransactionEntitySerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = TransactionEntity model = TransactionEntity
fields = "__all__" fields = "__all__"
read_only_fields = [
"id",
"owner",
]
class InstallmentPlanSerializer(serializers.ModelSerializer): class InstallmentPlanSerializer(serializers.ModelSerializer):
category = TransactionCategoryField(required=False) category: str | int = TransactionCategoryField(required=False)
tags = TransactionTagField(required=False) tags: str | int = TransactionTagField(required=False)
entities = TransactionEntityField(required=False) entities: str | int = TransactionEntityField(required=False)
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
@@ -88,9 +103,9 @@ class InstallmentPlanSerializer(serializers.ModelSerializer):
class RecurringTransactionSerializer(serializers.ModelSerializer): class RecurringTransactionSerializer(serializers.ModelSerializer):
category = TransactionCategoryField(required=False) category: str | int = TransactionCategoryField(required=False)
tags = TransactionTagField(required=False) tags: str | int = TransactionTagField(required=False)
entities = TransactionEntityField(required=False) entities: str | int = TransactionEntityField(required=False)
class Meta: class Meta:
model = RecurringTransaction model = RecurringTransaction
@@ -123,13 +138,14 @@ class RecurringTransactionSerializer(serializers.ModelSerializer):
def update(self, instance, validated_data): def update(self, instance, validated_data):
instance = super().update(instance, validated_data) instance = super().update(instance, validated_data)
instance.update_unpaid_transactions() instance.update_unpaid_transactions()
instance.generate_upcoming_transactions()
return instance return instance
class TransactionSerializer(serializers.ModelSerializer): class TransactionSerializer(serializers.ModelSerializer):
category = TransactionCategoryField(required=False) category: str | int = TransactionCategoryField(required=False)
tags = TransactionTagField(required=False) tags: str | int = TransactionTagField(required=False)
entities = TransactionEntityField(required=False) entities: str | int = TransactionEntityField(required=False)
exchanged_amount = serializers.SerializerMethodField() exchanged_amount = serializers.SerializerMethodField()
@@ -155,8 +171,16 @@ class TransactionSerializer(serializers.ModelSerializer):
"installment_plan", "installment_plan",
"recurring_transaction", "recurring_transaction",
"installment_id", "installment_id",
"owner",
"deleted_at",
"deleted",
] ]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["account_id"].queryset = Account.objects.all()
def validate(self, data): def validate(self, data):
if not self.partial: if not self.partial:
if "date" in data and "reference_date" not in data: if "date" in data and "reference_date" not in data:
@@ -192,5 +216,5 @@ class TransactionSerializer(serializers.ModelSerializer):
return instance return instance
@staticmethod @staticmethod
def get_exchanged_amount(obj): def get_exchanged_amount(obj) -> Decimal:
return obj.exchanged_amount() return obj.exchanged_amount()
+12 -2
View File
@@ -1,4 +1,6 @@
from rest_framework import viewsets from rest_framework import viewsets
from apps.api.custom.pagination import CustomPageNumberPagination
from apps.accounts.models import AccountGroup, Account from apps.accounts.models import AccountGroup, Account
from apps.api.serializers import AccountGroupSerializer, AccountSerializer from apps.api.serializers import AccountGroupSerializer, AccountSerializer
@@ -6,12 +8,20 @@ from apps.api.serializers import AccountGroupSerializer, AccountSerializer
class AccountGroupViewSet(viewsets.ModelViewSet): class AccountGroupViewSet(viewsets.ModelViewSet):
queryset = AccountGroup.objects.all() queryset = AccountGroup.objects.all()
serializer_class = AccountGroupSerializer serializer_class = AccountGroupSerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self):
return AccountGroup.objects.all().order_by("id")
class AccountViewSet(viewsets.ModelViewSet): class AccountViewSet(viewsets.ModelViewSet):
queryset = Account.objects.all() queryset = Account.objects.all()
serializer_class = AccountSerializer serializer_class = AccountSerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self): def get_queryset(self):
queryset = super().get_queryset() return (
return queryset.select_related("group", "currency", "exchange_currency") Account.objects.all()
.order_by("id")
.select_related("group", "currency", "exchange_currency")
)
+29 -1
View File
@@ -1,5 +1,8 @@
from copy import deepcopy
from rest_framework import viewsets from rest_framework import viewsets
from apps.api.custom.pagination import CustomPageNumberPagination
from apps.api.serializers import ( from apps.api.serializers import (
TransactionSerializer, TransactionSerializer,
TransactionCategorySerializer, TransactionCategorySerializer,
@@ -22,40 +25,65 @@ from apps.rules.signals import transaction_updated, transaction_created
class TransactionViewSet(viewsets.ModelViewSet): class TransactionViewSet(viewsets.ModelViewSet):
queryset = Transaction.objects.all() queryset = Transaction.objects.all()
serializer_class = TransactionSerializer serializer_class = TransactionSerializer
pagination_class = CustomPageNumberPagination
def perform_create(self, serializer): def perform_create(self, serializer):
instance = serializer.save() instance = serializer.save()
transaction_created.send(sender=instance) transaction_created.send(sender=instance)
def perform_update(self, serializer): def perform_update(self, serializer):
old_data = deepcopy(Transaction.objects.get(pk=serializer.data["pk"]))
instance = serializer.save() instance = serializer.save()
transaction_updated.send(sender=instance) transaction_updated.send(sender=instance, old_data=old_data)
def partial_update(self, request, *args, **kwargs): def partial_update(self, request, *args, **kwargs):
kwargs["partial"] = True kwargs["partial"] = True
return self.update(request, *args, **kwargs) return self.update(request, *args, **kwargs)
def get_queryset(self):
return Transaction.objects.all().order_by("-id")
class TransactionCategoryViewSet(viewsets.ModelViewSet): class TransactionCategoryViewSet(viewsets.ModelViewSet):
queryset = TransactionCategory.objects.all() queryset = TransactionCategory.objects.all()
serializer_class = TransactionCategorySerializer serializer_class = TransactionCategorySerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self):
return TransactionCategory.objects.all().order_by("id")
class TransactionTagViewSet(viewsets.ModelViewSet): class TransactionTagViewSet(viewsets.ModelViewSet):
queryset = TransactionTag.objects.all() queryset = TransactionTag.objects.all()
serializer_class = TransactionTagSerializer serializer_class = TransactionTagSerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self):
return TransactionTag.objects.all().order_by("id")
class TransactionEntityViewSet(viewsets.ModelViewSet): class TransactionEntityViewSet(viewsets.ModelViewSet):
queryset = TransactionEntity.objects.all() queryset = TransactionEntity.objects.all()
serializer_class = TransactionEntitySerializer serializer_class = TransactionEntitySerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self):
return TransactionEntity.objects.all().order_by("id")
class InstallmentPlanViewSet(viewsets.ModelViewSet): class InstallmentPlanViewSet(viewsets.ModelViewSet):
queryset = InstallmentPlan.objects.all() queryset = InstallmentPlan.objects.all()
serializer_class = InstallmentPlanSerializer serializer_class = InstallmentPlanSerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self):
return InstallmentPlan.objects.all().order_by("-id")
class RecurringTransactionViewSet(viewsets.ModelViewSet): class RecurringTransactionViewSet(viewsets.ModelViewSet):
queryset = RecurringTransaction.objects.all() queryset = RecurringTransaction.objects.all()
serializer_class = RecurringTransactionSerializer serializer_class = RecurringTransactionSerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self):
return RecurringTransaction.objects.all().order_by("-id")
+26
View File
@@ -0,0 +1,26 @@
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
@admin.action(description=_("Make public"))
def make_public(modeladmin, request, queryset):
queryset.update(visibility="public")
@admin.action(description=_("Make private"))
def make_private(modeladmin, request, queryset):
queryset.update(visibility="private")
class SharedObjectModelAdmin(admin.ModelAdmin):
actions = [make_public, make_private]
list_display = ("__str__", "visibility", "owner", "get_shared_with")
@admin.display(description=_("Shared with users"))
def get_shared_with(self, obj):
return ", ".join([p.email for p in obj.shared_with.all()])
def get_queryset(self, request):
# Use the all_objects manager to show all transactions, including deleted ones
return self.model.all_objects.all()
+19
View File
@@ -1,6 +1,25 @@
from django.apps import AppConfig from django.apps import AppConfig
from django.core.cache import cache
class CommonConfig(AppConfig): class CommonConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField" default_auto_field = "django.db.models.BigAutoField"
name = "apps.common" name = "apps.common"
def ready(self):
from django.contrib import admin
from django.contrib.sites.models import Site
from allauth.socialaccount.models import (
SocialAccount,
SocialApp,
SocialToken,
)
admin.site.unregister(Site)
admin.site.unregister(SocialAccount)
admin.site.unregister(SocialApp)
admin.site.unregister(SocialToken)
# Delete the cache for update checks to prevent false-positives when the app is restarted
# this will be recreated by the check_for_updates task
cache.delete("update_check")
+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
+30 -30
View File
@@ -4,6 +4,7 @@ from django.db import transaction
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple
from apps.common.middleware.thread_local import get_current_user
class DynamicModelChoiceField(forms.ModelChoiceField): class DynamicModelChoiceField(forms.ModelChoiceField):
@@ -12,15 +13,14 @@ class DynamicModelChoiceField(forms.ModelChoiceField):
self.to_field_name = kwargs.pop("to_field_name", "pk") self.to_field_name = kwargs.pop("to_field_name", "pk")
self.create_field = kwargs.pop("create_field", None) self.create_field = kwargs.pop("create_field", None)
if not self.create_field:
raise ValueError("The 'create_field' parameter is required.")
self.queryset = kwargs.pop("queryset", model.objects.all()) self.queryset = kwargs.pop("queryset", model.objects.all())
super().__init__(queryset=self.queryset, *args, **kwargs)
self._created_instance = None
self.widget = TomSelect(clear_button=True, create=True) self.widget = TomSelect(clear_button=True, create=True)
super().__init__(queryset=self.queryset, *args, **kwargs)
self._created_instance = None
def to_python(self, value): def to_python(self, value):
if value in self.empty_values: if value in self.empty_values:
return None return None
@@ -53,17 +53,27 @@ class DynamicModelChoiceField(forms.ModelChoiceField):
else: else:
raise self.model.DoesNotExist raise self.model.DoesNotExist
except self.model.DoesNotExist: except self.model.DoesNotExist:
if self.create_field:
try: try:
with transaction.atomic(): with transaction.atomic():
instance, _ = self.model.objects.update_or_create( # First try to get the object
**{self.create_field: value} lookup = {self.create_field: value}
) try:
instance = self.model.objects.get(**lookup)
except self.model.DoesNotExist:
# Create a new instance directly
instance = self.model(**lookup)
instance.save()
self._created_instance = instance self._created_instance = instance
return instance return instance
except Exception as e: except Exception as e:
raise ValidationError(_("Error creating new instance"))
else:
raise ValidationError( raise ValidationError(
self.error_messages["invalid_choice"], code="invalid_choice" self.error_messages["invalid_choice"], code="invalid_choice"
) )
return super().clean(value) return super().clean(value)
def bound_data(self, data, initial): def bound_data(self, data, initial):
@@ -86,8 +96,6 @@ class DynamicModelMultipleChoiceField(forms.ModelMultipleChoiceField):
def __init__(self, model, **kwargs): def __init__(self, model, **kwargs):
""" """
Initialize the CreateIfNotExistsModelMultipleChoiceField.
Args: Args:
create_field (str): The name of the field to use when creating new instances. create_field (str): The name of the field to use when creating new instances.
*args: Variable length argument list. *args: Variable length argument list.
@@ -119,33 +127,27 @@ class DynamicModelMultipleChoiceField(forms.ModelMultipleChoiceField):
""" """
try: try:
with transaction.atomic(): with transaction.atomic():
instance, _ = self.model.objects.update_or_create( # Check if exists first without using update_or_create
**{self.create_field: value} lookup = {self.create_field: value}
) try:
# Use base manager to bypass distinct filters
instance = self.model.objects.get(**lookup)
return instance
except self.model.DoesNotExist:
# Create a new instance directly
instance = self.model(**lookup)
instance.save()
return instance return instance
except Exception as e: except Exception as e:
raise ValidationError(_("Error creating new instance")) raise ValidationError(_("Error creating new instance"))
def clean(self, value): def clean(self, value):
"""
Clean and validate the field value.
This method checks if each selected choice exists in the database.
If a choice doesn't exist, it creates a new instance of the model.
Args:
value (list): List of selected values.
Returns:
list: A list containing all selected and newly created model instances.
Raises:
ValidationError: If there's an error during the cleaning process.
"""
if not value: if not value:
return [] return []
string_values = set(str(v) for v in value) string_values = set(str(v) for v in value)
# Get existing objects first
existing_objects = list( existing_objects = list(
self.queryset.filter(**{f"{self.create_field}__in": string_values}) self.queryset.filter(**{f"{self.create_field}__in": string_values})
) )
@@ -153,13 +155,11 @@ class DynamicModelMultipleChoiceField(forms.ModelMultipleChoiceField):
str(getattr(obj, self.create_field)) for obj in existing_objects str(getattr(obj, self.create_field)) for obj in existing_objects
) )
# Create new objects for missing values
new_values = string_values - existing_values new_values = string_values - existing_values
new_objects = [] new_objects = []
for new_value in new_values: for new_value in new_values:
try:
new_objects.append(self._create_new_instance(new_value)) new_objects.append(self._create_new_instance(new_value))
except ValidationError as e:
raise ValidationError(_("Error creating new instance"))
return existing_objects + new_objects return existing_objects + new_objects
+9 -1
View File
@@ -20,7 +20,15 @@ class MonthYearModelField(models.DateField):
# Set the day to 1 # Set the day to 1
return date.replace(day=1).date() return date.replace(day=1).date()
except ValueError: except ValueError:
raise ValidationError(_("Invalid date format. Use YYYY-MM.")) try:
# Also accept YYYY-MM-DD format (for loaddata)
return (
datetime.datetime.strptime(value, "%Y-%m-%d").replace(day=1).date()
)
except ValueError:
raise ValidationError(
_("Invalid date format. Use YYYY-MM or YYYY-MM-DD.")
)
def formfield(self, **kwargs): def formfield(self, **kwargs):
kwargs["widget"] = MonthYearWidget kwargs["widget"] = MonthYearWidget
+113
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
+3
View File
@@ -9,5 +9,8 @@ def truncate_decimal(value, decimal_places):
:param decimal_places: The number of decimal places to keep :param decimal_places: The number of decimal places to keep
:return: Truncated Decimal value :return: Truncated Decimal value
""" """
if isinstance(value, (int, float)):
value = Decimal(str(value))
multiplier = Decimal(10**decimal_places) multiplier = Decimal(10**decimal_places)
return (value * multiplier).to_integral_value(rounding=ROUND_DOWN) / multiplier return (value * multiplier).to_integral_value(rounding=ROUND_DOWN) / multiplier
+38
View File
@@ -0,0 +1,38 @@
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 is not False
):
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 number_format == "SD" or number_format == "SC":
return " "
elif format_type == "DECIMAL_SEPARATOR":
number_format = getattr(user_settings, "number_format", None)
if number_format == "DC" or number_format == "SC":
return ","
elif number_format == "CD" or number_format == "SD":
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 import zoneinfo
from django.utils import formats
from django.utils import timezone, translation 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 from apps.users.models import UserSettings
class LocalizationMiddleware: class LocalizationMiddleware:
def __init__(self, get_response): def __init__(self, get_response):
self.get_response = get_response self.get_response = get_response
self.patch_get_format()
def __call__(self, request): def __call__(self, request):
tz = request.COOKIES.get("mytz") tz = request.COOKIES.get("mytz")
@@ -33,9 +36,14 @@ class LocalizationMiddleware:
timezone.activate(zoneinfo.ZoneInfo("UTC")) timezone.activate(zoneinfo.ZoneInfo("UTC"))
if user_language and user_language != "auto": if user_language and user_language != "auto":
activate(user_language) translation.activate(user_language)
else: else:
detected_language = translation.get_language_from_request(request) detected_language = translation.get_language_from_request(request)
activate(detected_language) translation.activate(detected_language)
return self.get_response(request) 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
+103
View File
@@ -0,0 +1,103 @@
from django.db import models
from django.conf import settings
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from apps.common.middleware.thread_local import get_current_user
class SharedObjectManager(models.Manager):
def get_queryset(self):
"""Return only objects the user can access"""
user = get_current_user()
base_qs = super().get_queryset()
if user and user.is_authenticated:
return base_qs.filter(
Q(visibility="public")
| Q(owner=user)
| Q(shared_with=user)
| Q(visibility="private", owner=None)
).distinct()
return base_qs.filter(visibility="public")
class SharedObject(models.Model):
# Access control enum
class Visibility(models.TextChoices):
private = "private", _("Private")
is_paid = "public", _("Public")
# Core sharing fields
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="%(class)s_owned",
null=True,
blank=True,
verbose_name=_("Owner"),
)
visibility = models.CharField(
max_length=10,
choices=Visibility.choices,
default=Visibility.private,
verbose_name=_("Visibility"),
)
shared_with = models.ManyToManyField(
settings.AUTH_USER_MODEL,
related_name="%(class)s_shared",
blank=True,
verbose_name=_("Shared with users"),
)
# Use as abstract base class
class Meta:
abstract = True
indexes = [
models.Index(fields=["visibility"]),
]
def is_accessible_by(self, user):
"""Check if a user can access this object"""
return (
self.visibility == "public"
or self.owner == user
or (self.visibility == "shared" and user in self.shared_with.all())
)
def save(self, *args, **kwargs):
if not self.pk and not self.owner:
self.owner = get_current_user()
super().save(*args, **kwargs)
class OwnedObjectManager(models.Manager):
def get_queryset(self):
"""Return only objects the user can access"""
user = get_current_user()
base_qs = super().get_queryset()
if user and user.is_authenticated:
return base_qs.filter(Q(owner=user) | Q(owner=None)).distinct()
return base_qs
class OwnedObject(models.Model):
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="%(class)s_owned",
null=True,
blank=True,
)
# Use as abstract base class
class Meta:
abstract = True
def save(self, *args, **kwargs):
if not self.pk and not self.owner:
self.owner = get_current_user()
super().save(*args, **kwargs)
+6
View File
@@ -0,0 +1,6 @@
import procrastinate
def on_app_ready(app: procrastinate.App):
"""This function is ran upon procrastinate initialization."""
...
+107 -2
View File
@@ -1,20 +1,29 @@
import logging import logging
from packaging.version import parse as parse_version, InvalidVersion
from asgiref.sync import sync_to_async
from django.conf import settings
from django.core import management
from django.db import DEFAULT_DB_ALIAS
from django.core.cache import cache
from procrastinate import builtin_tasks from procrastinate import builtin_tasks
from procrastinate.contrib.django import app from procrastinate.contrib.django import app
import requests
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@app.periodic(cron="0 4 * * *") @app.periodic(cron="0 4 * * *")
@app.task(queueing_lock="remove_old_jobs", pass_context=True) @app.task(queueing_lock="remove_old_jobs", pass_context=True, name="remove_old_jobs")
async def remove_old_jobs(context, timestamp): async def remove_old_jobs(context, timestamp):
try: try:
return await builtin_tasks.remove_old_jobs( return await builtin_tasks.remove_old_jobs(
context, context,
max_hours=744, max_hours=744,
remove_error=True, remove_failed=True,
remove_cancelled=True, remove_cancelled=True,
remove_aborted=True, remove_aborted=True,
) )
@@ -24,3 +33,99 @@ async def remove_old_jobs(context, timestamp):
exc_info=True, exc_info=True,
) )
raise e 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
@app.periodic(cron="0 */12 * * *") # Every 12 hours
@app.task(
name="check_for_updates",
)
def check_for_updates(timestamp=None):
if not settings.CHECK_FOR_UPDATES:
return "CHECK_FOR_UPDATES is disabled"
url = "https://api.github.com/repos/eitchtee/WYGIWYH/releases/latest"
try:
response = requests.get(url, timeout=60)
response.raise_for_status() # Raise an exception for bad status codes (4xx or 5xx)
data = response.json()
latest_version = data.get("tag_name")
if latest_version:
try:
current_v = parse_version(settings.APP_VERSION)
except InvalidVersion:
current_v = parse_version("0.0.0")
try:
latest_v = parse_version(latest_version)
except InvalidVersion:
latest_v = parse_version("0.0.0")
update_info = {
"update_available": False,
"current_version": str(current_v),
"latest_version": str(latest_v),
}
if latest_v > current_v:
update_info["update_available"] = True
# Cache the entire dictionary
cache.set("update_check", update_info, 60 * 60 * 25)
logger.info(f"Update check complete. Result: {update_info}")
else:
logger.warning("Could not find 'tag_name' in GitHub API response.")
except requests.exceptions.RequestException as e:
logger.error(f"Failed to fetch updates from GitHub: {e}")
@@ -0,0 +1,17 @@
# core/templatetags/update_tags.py
from django import template
from django.core.cache import cache
register = template.Library()
@register.simple_tag
def get_update_check():
"""
Retrieves the update status dictionary from the cache.
Returns a default dictionary if nothing is found.
"""
return cache.get("update_check") or {
"update_available": False,
"latest_version": "N/A",
}
-32
View File
@@ -1,32 +0,0 @@
from django import template
from django.template.defaultfilters import date as date_filter
from django.utils import formats, timezone
register = template.Library()
@register.filter
def custom_date(value, user=None):
if not value:
return ""
# Determine if the value is a datetime or just a date
is_datetime = hasattr(value, "hour")
# Convert to current timezone if it's a datetime
if is_datetime and timezone.is_aware(value):
value = timezone.localtime(value)
if user and user.is_authenticated:
user_settings = user.settings
if is_datetime:
format_setting = user_settings.datetime_format
else:
format_setting = user_settings.date_format
return formats.date_format(value, format_setting, use_l10n=True)
return date_filter(
value, "SHORT_DATE_FORMAT" if not is_datetime else "SHORT_DATETIME_FORMAT"
)
+1 -1
View File
@@ -1,6 +1,6 @@
from django import template from django import template
from django.utils.formats import get_format
from apps.common.functions.format import get_format
register = template.Library() register = template.Library()
+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, views.month_year_picker,
name="month_year_picker", name="month_year_picker",
), ),
path(
"cache/invalidate/",
views.invalidate_cache,
name="invalidate_cache",
),
] ]
+2 -2
View File
@@ -35,7 +35,7 @@ def django_to_python_datetime(django_format):
def django_to_airdatepicker_datetime(django_format): def django_to_airdatepicker_datetime(django_format):
format_map = { format_map = {
# Time # Time
"h": "h", # Hour (12-hour) "h": "hh", # Hour (12-hour)
"H": "H", # Hour (24-hour) "H": "H", # Hour (24-hour)
"i": "m", # Minutes "i": "m", # Minutes
"A": "AA", # AM/PM uppercase "A": "AA", # AM/PM uppercase
@@ -76,7 +76,7 @@ def django_to_airdatepicker_datetime(django_format):
def django_to_airdatepicker_datetime_separated(django_format): def django_to_airdatepicker_datetime_separated(django_format):
format_map = { format_map = {
# Time formats # Time formats
"h": "hH", # Hour (12-hour) "h": "hh", # Hour (12-hour)
"H": "HH", # Hour (24-hour) "H": "HH", # Hour (24-hour)
"i": "mm", # Minutes "i": "mm", # Minutes
"A": "AA", # AM/PM uppercase "A": "AA", # AM/PM uppercase
+39
View File
@@ -1,17 +1,33 @@
from dateutil.relativedelta import relativedelta 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 import Count
from django.db.models.functions import ExtractYear, ExtractMonth from django.db.models.functions import ExtractYear, ExtractMonth
from django.http import HttpResponse
from django.shortcuts import render from django.shortcuts import render
from django.urls import reverse from django.urls import reverse
from django.utils import timezone 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.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): def toasts(request):
return render(request, "common/fragments/toasts.html") return render(request, "common/fragments/toasts.html")
@only_htmx
@login_required
@require_http_methods(["GET"])
def month_year_picker(request): def month_year_picker(request):
field = request.GET.get("field", "reference_date") field = request.GET.get("field", "reference_date")
for_ = request.GET.get("for", None) for_ = request.GET.get("for", None)
@@ -75,6 +91,12 @@ def month_year_picker(request):
for date in all_months for date in all_months
] ]
today_url = (
reverse(url, kwargs={"month": current_date.month, "year": current_date.year})
if url
else ""
)
return render( return render(
request, request,
"common/fragments/month_year_picker.html", "common/fragments/month_year_picker.html",
@@ -82,5 +104,22 @@ def month_year_picker(request):
"month_year_data": result, "month_year_data": result,
"current_month": current_month, "current_month": current_month,
"current_year": current_year, "current_year": current_year,
"today_url": today_url,
},
)
@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",
}, },
) )
+85 -20
View File
@@ -2,7 +2,6 @@ import datetime
from django.forms import widgets from django.forms import widgets
from django.utils import formats, translation, dates from django.utils import formats, translation, dates
from django.utils.formats import get_format
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from apps.common.utils.django import ( from apps.common.utils.django import (
@@ -10,6 +9,7 @@ from apps.common.utils.django import (
django_to_airdatepicker_datetime, django_to_airdatepicker_datetime,
django_to_airdatepicker_datetime_separated, django_to_airdatepicker_datetime_separated,
) )
from apps.common.functions.format import get_format
class AirDatePickerInput(widgets.DateInput): class AirDatePickerInput(widgets.DateInput):
@@ -19,21 +19,27 @@ class AirDatePickerInput(widgets.DateInput):
format=None, format=None,
clear_button=True, clear_button=True,
auto_close=True, auto_close=True,
user=None, read_only=True,
toggle_selected=None,
*args, *args,
**kwargs, **kwargs,
): ):
attrs = attrs or {} attrs = attrs or {}
self.user = user
super().__init__(attrs=attrs, format=format, *args, **kwargs) super().__init__(attrs=attrs, format=format, *args, **kwargs)
self.clear_button = clear_button self.clear_button = clear_button
self.auto_close = auto_close self.auto_close = auto_close
self.read_only = read_only
self.toggle_selected = (
toggle_selected if isinstance(toggle_selected, bool) else self.clear_button
)
@staticmethod @staticmethod
def _get_current_language(): def _get_current_language():
"""Get current language code in format compatible with AirDatepicker""" """Get current language code in format compatible with AirDatepicker"""
lang_code = translation.get_language() lang_code = translation.get_language()
# AirDatepicker uses simple language codes # AirDatepicker uses simple language codes, except for pt-br
if lang_code.lower() == "pt-br":
return "pt-BR"
return lang_code.split("-")[0] return lang_code.split("-")[0]
def _get_format(self): def _get_format(self):
@@ -41,12 +47,6 @@ class AirDatePickerInput(widgets.DateInput):
if self.format: if self.format:
return self.format return self.format
if self.user and hasattr(self.user, "settings"):
user_format = self.user.settings.date_format
if user_format == "SHORT_DATE_FORMAT":
return get_format("SHORT_DATE_FORMAT", use_l10n=True)
return user_format
return get_format("SHORT_DATE_FORMAT", use_l10n=True) return get_format("SHORT_DATE_FORMAT", use_l10n=True)
def build_attrs(self, base_attrs, extra_attrs=None): def build_attrs(self, base_attrs, extra_attrs=None):
@@ -55,9 +55,13 @@ class AirDatePickerInput(widgets.DateInput):
attrs["data-now-button-txt"] = _("Today") attrs["data-now-button-txt"] = _("Today")
attrs["data-auto-close"] = str(self.auto_close).lower() attrs["data-auto-close"] = str(self.auto_close).lower()
attrs["data-clear-button"] = str(self.clear_button).lower() attrs["data-clear-button"] = str(self.clear_button).lower()
attrs["data-toggle-selected"] = str(self.toggle_selected).lower()
attrs["data-language"] = self._get_current_language() attrs["data-language"] = self._get_current_language()
attrs["data-date-format"] = django_to_airdatepicker_datetime(self._get_format()) attrs["data-date-format"] = django_to_airdatepicker_datetime(self._get_format())
if self.read_only:
attrs["readonly"] = True
return attrs return attrs
def format_value(self, value): def format_value(self, value):
@@ -97,16 +101,20 @@ class AirDateTimePickerInput(widgets.DateTimeInput):
timepicker=True, timepicker=True,
clear_button=True, clear_button=True,
auto_close=True, auto_close=True,
user=None, read_only=True,
toggle_selected=None,
*args, *args,
**kwargs, **kwargs,
): ):
attrs = attrs or {} attrs = attrs or {}
self.user = user
super().__init__(attrs=attrs, format=format, *args, **kwargs) super().__init__(attrs=attrs, format=format, *args, **kwargs)
self.timepicker = timepicker self.timepicker = timepicker
self.clear_button = clear_button self.clear_button = clear_button
self.auto_close = auto_close self.auto_close = auto_close
self.read_only = read_only
self.toggle_selected = (
toggle_selected if isinstance(toggle_selected, bool) else self.clear_button
)
@staticmethod @staticmethod
def _get_current_language(): def _get_current_language():
@@ -120,12 +128,6 @@ class AirDateTimePickerInput(widgets.DateTimeInput):
if self.format: if self.format:
return self.format return self.format
if self.user and hasattr(self.user, "settings"):
user_format = self.user.settings.datetime_format
if user_format == "SHORT_DATETIME_FORMAT":
return get_format("SHORT_DATETIME_FORMAT", use_l10n=True)
return user_format
return get_format("SHORT_DATETIME_FORMAT", use_l10n=True) return get_format("SHORT_DATETIME_FORMAT", use_l10n=True)
def build_attrs(self, base_attrs, extra_attrs=None): def build_attrs(self, base_attrs, extra_attrs=None):
@@ -139,18 +141,27 @@ class AirDateTimePickerInput(widgets.DateTimeInput):
attrs["data-now-button-txt"] = _("Now") attrs["data-now-button-txt"] = _("Now")
attrs["data-timepicker"] = str(self.timepicker).lower() attrs["data-timepicker"] = str(self.timepicker).lower()
attrs["data-auto-close"] = str(self.auto_close).lower() attrs["data-auto-close"] = str(self.auto_close).lower()
attrs["data-toggle-selected"] = str(self.toggle_selected).lower()
attrs["data-clear-button"] = str(self.clear_button).lower() attrs["data-clear-button"] = str(self.clear_button).lower()
attrs["data-language"] = self._get_current_language() attrs["data-language"] = self._get_current_language()
attrs["data-date-format"] = date_format attrs["data-date-format"] = date_format
attrs["data-time-format"] = time_format attrs["data-time-format"] = time_format
if self.read_only:
attrs["readonly"] = True
return attrs return attrs
def format_value(self, value): def format_value(self, value):
"""Format the value for display in the widget.""" """Format the value for display in the widget."""
if value: if value and isinstance(value, (datetime.date, datetime.datetime)):
self.attrs["data-value"] = datetime.datetime.strftime( self.attrs["data-value"] = datetime.datetime.strftime(
value, "%Y-%m-%d %H:%M:00" 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: if value is None:
@@ -195,6 +206,7 @@ class AirMonthYearPickerInput(AirDatePickerInput):
# Add data attributes for AirDatepicker configuration # Add data attributes for AirDatepicker configuration
attrs["data-now-button-txt"] = _("Today") attrs["data-now-button-txt"] = _("Today")
attrs["data-date-format"] = "MMMM yyyy"
return attrs return attrs
@@ -237,3 +249,56 @@ class AirMonthYearPickerInput(AirDatePickerInput):
except (ValueError, KeyError): except (ValueError, KeyError):
return None return None
return None return None
class AirYearPickerInput(AirDatePickerInput):
def __init__(self, attrs=None, format=None, *args, **kwargs):
super().__init__(attrs=attrs, format=format, *args, **kwargs)
# Store the display format for AirDatepicker
self.display_format = "yyyy"
# Store the Python format for internal use
self.python_format = "%Y"
def build_attrs(self, base_attrs, extra_attrs=None):
attrs = super().build_attrs(base_attrs, extra_attrs)
# Add data attributes for AirDatepicker configuration
attrs["data-now-button-txt"] = _("Today")
attrs["data-date-format"] = "yyyy"
return attrs
def format_value(self, value):
"""Format the value for display in the widget."""
if value:
self.attrs["data-value"] = (
value # We use this to dynamically select the initial date on AirDatePicker
)
if value is None:
return ""
if isinstance(value, str):
try:
value = datetime.datetime.strptime(value, "%Y-%m-%d").date()
except ValueError:
return value
if isinstance(value, (datetime.datetime, datetime.date)):
# Use Django's date translation
return f"{value.year}"
return value
def value_from_datadict(self, data, files, name):
"""Convert the value from the widget format back to a format Django can handle."""
value = super().value_from_datadict(data, files, name)
if value:
try:
# Split the value into month name and year
year_str = value
year = int(year_str)
if year:
# Return the first day of the month in Django's expected format
return datetime.date(year, 1, 1).strftime("%Y-%m-%d")
except (ValueError, KeyError):
return None
return None
+5 -3
View File
@@ -1,7 +1,9 @@
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from django import forms 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): def convert_to_decimal(value: str):
@@ -33,8 +35,8 @@ class ArbitraryDecimalDisplayNumberInput(forms.TextInput):
self.attrs.update( self.attrs.update(
{ {
"x-data": "", "x-data": "",
"x-mask:dynamic": f"$money($input, '{get_format('DECIMAL_SEPARATOR')}', " "x-mask:dynamic": f"$money($input, '{get_format('DECIMAL_SEPARATOR')}', '{get_format('THOUSAND_SEPARATOR')}', '30')",
f"'{get_format('THOUSAND_SEPARATOR')}', '30')", "x-on:keyup": "if (!['Control', 'Shift', 'Alt', 'Meta'].includes($event.key) && !(($event.ctrlKey || $event.metaKey) && $event.key.toLowerCase() === 'a')) $el.dispatchEvent(new Event('input'))",
} }
) )
+26
View File
@@ -1,4 +1,5 @@
from django.forms import widgets, SelectMultiple from django.forms import widgets, SelectMultiple
from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -129,3 +130,28 @@ class TomSelect(widgets.Select):
class TomSelectMultiple(SelectMultiple, TomSelect): class TomSelectMultiple(SelectMultiple, TomSelect):
pass pass
class TransactionSelect(TomSelect):
def __init__(self, income: bool = True, expense: bool = True, *args, **kwargs):
super().__init__(*args, **kwargs)
self.load_income = income
self.load_expense = expense
self.create = False
def build_attrs(self, base_attrs, extra_attrs=None):
attrs = super().build_attrs(base_attrs, extra_attrs)
if self.load_income and self.load_expense:
attrs["data-load"] = reverse("transactions_search")
elif self.load_income and not self.load_expense:
attrs["data-load"] = reverse(
"transactions_search", kwargs={"filter_type": "income"}
)
elif self.load_expense and not self.load_income:
attrs["data-load"] = reverse(
"transactions_search", kwargs={"filter_type": "expenses"}
)
return attrs
+16 -1
View File
@@ -1,6 +1,6 @@
from django.contrib import admin from django.contrib import admin
from apps.currencies.models import Currency, ExchangeRate from apps.currencies.models import Currency, ExchangeRate, ExchangeRateService
@admin.register(Currency) @admin.register(Currency)
@@ -11,4 +11,19 @@ class CurrencyAdmin(admin.ModelAdmin):
return super().formfield_for_dbfield(db_field, request, **kwargs) return super().formfield_for_dbfield(db_field, request, **kwargs)
@admin.register(ExchangeRateService)
class ExchangeRateServiceAdmin(admin.ModelAdmin):
list_display = [
"name",
"service_type",
"is_active",
"interval_type",
"fetch_interval",
"last_fetch",
]
list_filter = ["is_active", "service_type"]
search_fields = ["name"]
filter_horizontal = ["target_currencies"]
admin.site.register(ExchangeRate) admin.site.register(ExchangeRate)
@@ -0,0 +1,30 @@
from abc import ABC, abstractmethod
from decimal import Decimal
from typing import List, Tuple, Optional
from django.db.models import QuerySet
from apps.currencies.models import Currency
class ExchangeRateProvider(ABC):
rates_inverted = False
def __init__(self, api_key: Optional[str] = None):
self.api_key = api_key
@abstractmethod
def get_rates(
self, target_currencies: QuerySet, exchange_currencies: set
) -> List[Tuple[Currency, Currency, Decimal]]:
"""Fetch exchange rates for multiple currency pairs"""
raise NotImplementedError("Subclasses must implement get_rates method")
@classmethod
def requires_api_key(cls) -> bool:
"""Return True if the service requires an API key"""
return True
@staticmethod
def invert_rate(rate: Decimal) -> Decimal:
"""Invert the given rate."""
return Decimal("1") / rate
@@ -0,0 +1,264 @@
import logging
from datetime import timedelta
from django.db.models import QuerySet
from django.utils import timezone
import apps.currencies.exchange_rates.providers as providers
from apps.currencies.models import ExchangeRateService, ExchangeRate, Currency
logger = logging.getLogger(__name__)
# Map service types to provider classes
PROVIDER_MAPPING = {
"coingecko_free": providers.CoinGeckoFreeProvider,
"coingecko_pro": providers.CoinGeckoProProvider,
"transitive": providers.TransitiveRateProvider,
"frankfurter": providers.FrankfurterProvider,
"twelvedata": providers.TwelveDataProvider,
"twelvedatamarkets": providers.TwelveDataMarketsProvider,
}
class ExchangeRateFetcher:
def _should_fetch_at_hour(service: ExchangeRateService, current_hour: int) -> bool:
"""Check if service should fetch rates at given hour based on interval type."""
try:
if service.interval_type == ExchangeRateService.IntervalType.NOT_ON:
blocked_hours = ExchangeRateService._parse_hour_ranges(
service.fetch_interval
)
should_fetch = current_hour not in blocked_hours
logger.info(
f"NOT_ON check for {service.name}: "
f"current_hour={current_hour}, "
f"blocked_hours={blocked_hours}, "
f"should_fetch={should_fetch}"
)
return should_fetch
if service.interval_type == ExchangeRateService.IntervalType.ON:
allowed_hours = ExchangeRateService._parse_hour_ranges(
service.fetch_interval
)
should_fetch = current_hour in allowed_hours
logger.info(
f"ON check for {service.name}: "
f"current_hour={current_hour}, "
f"allowed_hours={allowed_hours}, "
f"should_fetch={should_fetch}"
)
return should_fetch
if service.interval_type == ExchangeRateService.IntervalType.EVERY:
try:
interval_hours = int(service.fetch_interval)
if service.last_fetch is None:
return True
# Round down to nearest hour
now = timezone.now().replace(minute=0, second=0, microsecond=0)
last_fetch = service.last_fetch.replace(
minute=0, second=0, microsecond=0
)
hours_since_last = (now - last_fetch).total_seconds() / 3600
should_fetch = hours_since_last >= interval_hours
logger.info(
f"EVERY check for {service.name}: "
f"hours_since_last={hours_since_last:.1f}, "
f"interval={interval_hours}, "
f"should_fetch={should_fetch}"
)
return should_fetch
except ValueError:
logger.error(
f"Invalid EVERY interval format for {service.name}: "
f"expected single number, got '{service.fetch_interval}'"
)
return False
return False
except ValueError as e:
logger.error(f"Error parsing fetch_interval for {service.name}: {e}")
return False
@staticmethod
def fetch_due_rates(force: bool = False) -> None:
"""
Fetch rates for all services that are due for update.
Args:
force (bool): If True, fetches all active services regardless of their schedule.
"""
services = ExchangeRateService.objects.filter(is_active=True)
current_time = timezone.now().astimezone()
current_hour = current_time.hour
for service in services:
try:
if force:
logger.info(f"Force fetching rates for {service.name}")
ExchangeRateFetcher._fetch_service_rates(service)
continue
# Check if service should fetch based on interval type
if ExchangeRateFetcher._should_fetch_at_hour(service, current_hour):
logger.info(
f"Fetching rates for {service.name}. "
f"Last fetch: {service.last_fetch}, "
f"Interval type: {service.interval_type}, "
f"Current hour: {current_hour}"
)
ExchangeRateFetcher._fetch_service_rates(service)
else:
logger.debug(
f"Skipping {service.name}. "
f"Current hour: {current_hour}, "
f"Interval type: {service.interval_type}, "
f"Fetch interval: {service.fetch_interval}"
)
except Exception as e:
logger.error(f"Error checking fetch schedule for {service.name}: {e}")
@staticmethod
def _get_unique_currency_pairs(
service: ExchangeRateService,
) -> tuple[QuerySet, set]:
"""
Get unique currency pairs from both target_currencies and target_accounts
Returns a tuple of (target_currencies QuerySet, exchange_currencies set)
"""
# Get currencies from target_currencies
target_currencies = set(service.target_currencies.all())
# Add currencies from target_accounts
for account in service.target_accounts.all():
if account.currency and account.exchange_currency:
target_currencies.add(account.currency)
# Convert back to QuerySet for compatibility with existing code
target_currencies_qs = Currency.objects.filter(
id__in=[curr.id for curr in target_currencies]
)
# Get unique exchange currencies
exchange_currencies = set()
# From target_currencies
for currency in target_currencies:
if currency.exchange_currency:
exchange_currencies.add(currency.exchange_currency)
# From target_accounts
for account in service.target_accounts.all():
if account.exchange_currency:
exchange_currencies.add(account.exchange_currency)
return target_currencies_qs, exchange_currencies
@staticmethod
def _fetch_service_rates(service: ExchangeRateService) -> None:
"""Fetch rates for a specific service"""
try:
provider = service.get_provider()
# Check if API key is required but missing
if provider.requires_api_key() and not service.api_key:
logger.error(f"API key required but not provided for {service.name}")
return
# Get unique currency pairs from both sources
target_currencies, exchange_currencies = (
ExchangeRateFetcher._get_unique_currency_pairs(service)
)
# Skip if no currencies to process
if not target_currencies or not exchange_currencies:
logger.info(f"No currency pairs to process for service {service.name}")
return
rates = provider.get_rates(target_currencies, exchange_currencies)
# Track processed currency pairs to avoid duplicates
processed_pairs = set()
for from_currency, to_currency, rate in rates:
# Create a unique identifier for this currency pair
pair_key = (from_currency.id, to_currency.id)
if pair_key in processed_pairs:
continue
if provider.rates_inverted:
# If rates are inverted, we need to swap currencies
if service.singleton:
# Try to get the last automatically created exchange rate
exchange_rate = (
ExchangeRate.objects.filter(
automatic=True,
from_currency=to_currency,
to_currency=from_currency,
)
.order_by("-date")
.first()
)
else:
exchange_rate = None
if not exchange_rate:
ExchangeRate.objects.create(
automatic=True,
from_currency=to_currency,
to_currency=from_currency,
rate=rate,
date=timezone.now(),
)
else:
exchange_rate.rate = rate
exchange_rate.date = timezone.now()
exchange_rate.save()
processed_pairs.add((to_currency.id, from_currency.id))
else:
# If rates are not inverted, we can use them as is
if service.singleton:
# Try to get the last automatically created exchange rate
exchange_rate = (
ExchangeRate.objects.filter(
automatic=True,
from_currency=from_currency,
to_currency=to_currency,
)
.order_by("-date")
.first()
)
else:
exchange_rate = None
if not exchange_rate:
ExchangeRate.objects.create(
automatic=True,
from_currency=from_currency,
to_currency=to_currency,
rate=rate,
date=timezone.now(),
)
else:
exchange_rate.rate = rate
exchange_rate.date = timezone.now()
exchange_rate.save()
processed_pairs.add((from_currency.id, to_currency.id))
service.last_fetch = timezone.now()
service.save()
except Exception as e:
logger.error(f"Error fetching rates for {service.name}: {e}")
@@ -0,0 +1,505 @@
import logging
import time
import requests
from decimal import Decimal
from typing import Tuple, List, Optional, Dict
from django.db.models import QuerySet
from apps.currencies.models import Currency, ExchangeRate
from apps.currencies.exchange_rates.base import ExchangeRateProvider
logger = logging.getLogger(__name__)
class CoinGeckoFreeProvider(ExchangeRateProvider):
"""Implementation for CoinGecko Free API"""
BASE_URL = "https://api.coingecko.com/api/v3"
rates_inverted = True
def __init__(self, api_key: str):
super().__init__(api_key)
self.session = requests.Session()
self.session.headers.update({"x-cg-demo-api-key": api_key})
@classmethod
def requires_api_key(cls) -> bool:
return True
def get_rates(
self, target_currencies: QuerySet, exchange_currencies: set
) -> List[Tuple[Currency, Currency, Decimal]]:
results = []
all_currencies = set(currency.code.lower() for currency in target_currencies)
all_currencies.update(currency.code.lower() for currency in exchange_currencies)
try:
response = self.session.get(
f"{self.BASE_URL}/simple/price",
params={
"ids": ",".join(all_currencies),
"vs_currencies": ",".join(all_currencies),
},
)
response.raise_for_status()
rates_data = response.json()
for target_currency in target_currencies:
if target_currency.exchange_currency in exchange_currencies:
try:
rate = Decimal(
str(
rates_data[target_currency.code.lower()][
target_currency.exchange_currency.code.lower()
]
)
)
# The rate is already inverted, so we don't need to invert it again
results.append(
(target_currency.exchange_currency, target_currency, rate)
)
except KeyError:
logger.error(
f"Rate not found for {target_currency.code} or {target_currency.exchange_currency.code}"
)
except Exception as e:
logger.error(
f"Error calculating rate for {target_currency.code}: {e}"
)
time.sleep(1) # CoinGecko allows 10-30 calls/minute for free tier
except requests.RequestException as e:
logger.error(f"Error fetching rates from CoinGecko API: {e}")
return results
class CoinGeckoProProvider(CoinGeckoFreeProvider):
"""Implementation for CoinGecko Pro API"""
BASE_URL = "https://pro-api.coingecko.com/api/v3/simple/price"
rates_inverted = True
def __init__(self, api_key: str):
super().__init__(api_key)
self.session = requests.Session()
self.session.headers.update({"x-cg-pro-api-key": api_key})
class TransitiveRateProvider(ExchangeRateProvider):
"""Calculates exchange rates through paths of existing rates"""
rates_inverted = True
def __init__(self, api_key: str = None):
super().__init__(api_key) # API key not needed but maintaining interface
@classmethod
def requires_api_key(cls) -> bool:
return False
def get_rates(
self, target_currencies: QuerySet, exchange_currencies: set
) -> List[Tuple[Currency, Currency, Decimal]]:
results = []
# Get recent rates for building the graph
recent_rates = ExchangeRate.objects.all()
# Build currency graph
currency_graph = self._build_currency_graph(recent_rates)
for target in target_currencies:
if (
not target.exchange_currency
or target.exchange_currency not in exchange_currencies
):
continue
# Find path and calculate rate
from_id = target.exchange_currency.id
to_id = target.id
path, rate = self._find_conversion_path(currency_graph, from_id, to_id)
if path and rate:
path_codes = [Currency.objects.get(id=cid).code for cid in path]
logger.info(
f"Found conversion path: {' -> '.join(path_codes)}, rate: {rate}"
)
results.append((target.exchange_currency, target, rate))
else:
logger.debug(
f"No conversion path found for {target.exchange_currency.code}->{target.code}"
)
return results
@staticmethod
def _build_currency_graph(rates) -> Dict[int, Dict[int, Decimal]]:
"""Build a graph representation of currency relationships"""
graph = {}
for rate in rates:
# Add both directions to make the graph bidirectional
if rate.from_currency_id not in graph:
graph[rate.from_currency_id] = {}
graph[rate.from_currency_id][rate.to_currency_id] = rate.rate
if rate.to_currency_id not in graph:
graph[rate.to_currency_id] = {}
graph[rate.to_currency_id][rate.from_currency_id] = Decimal("1") / rate.rate
return graph
@staticmethod
def _find_conversion_path(
graph, from_id, to_id
) -> Tuple[Optional[list], Optional[Decimal]]:
"""Find the shortest path between currencies using breadth-first search"""
if from_id not in graph or to_id not in graph:
return None, None
queue = [(from_id, [from_id], Decimal("1"))]
visited = {from_id}
while queue:
current, path, current_rate = queue.pop(0)
if current == to_id:
return path, current_rate
for neighbor, rate in graph.get(current, {}).items():
if neighbor not in visited:
visited.add(neighbor)
queue.append((neighbor, path + [neighbor], current_rate * rate))
return None, None
class FrankfurterProvider(ExchangeRateProvider):
"""Implementation for the Frankfurter API (frankfurter.dev)"""
BASE_URL = "https://api.frankfurter.dev/v1/latest"
rates_inverted = (
False # Frankfurter returns non-inverted rates (e.g., 1 EUR = 1.1 USD)
)
def __init__(self, api_key: str = None):
"""
Initializes the provider. The Frankfurter API does not require an API key,
so the api_key parameter is ignored.
"""
super().__init__(api_key)
self.session = requests.Session()
@classmethod
def requires_api_key(cls) -> bool:
return False
def get_rates(
self, target_currencies: QuerySet, exchange_currencies: set
) -> List[Tuple[Currency, Currency, Decimal]]:
results = []
currency_groups = {}
# Group target currencies by their exchange (base) currency to minimize API calls
for currency in target_currencies:
if currency.exchange_currency in exchange_currencies:
group = currency_groups.setdefault(currency.exchange_currency.code, [])
group.append(currency)
# Make one API call for each base currency
for base_currency, currencies in currency_groups.items():
try:
# Create a comma-separated list of target currency codes
to_currencies = ",".join(
currency.code
for currency in currencies
if currency.code != base_currency
)
# If there are no target currencies other than the base, skip the API call
if not to_currencies:
# Handle the case where the only request is for the base rate (e.g., USD to USD)
for currency in currencies:
if currency.code == base_currency:
results.append(
(currency.exchange_currency, currency, Decimal("1"))
)
continue
response = self.session.get(
self.BASE_URL,
params={"base": base_currency, "symbols": to_currencies},
)
response.raise_for_status()
data = response.json()
rates = data["rates"]
# Process the returned rates
for currency in currencies:
if currency.code == base_currency:
# The rate for the base currency to itself is always 1
rate = Decimal("1")
else:
rate = Decimal(str(rates[currency.code]))
results.append((currency.exchange_currency, currency, rate))
except requests.RequestException as e:
logger.error(
f"Error fetching rates from Frankfurter API for base {base_currency}: {e}"
)
except KeyError as e:
logger.error(
f"Unexpected response structure from Frankfurter API for base {base_currency}: {e}"
)
except Exception as e:
logger.error(
f"Unexpected error processing Frankfurter data for base {base_currency}: {e}"
)
return results
class TwelveDataProvider(ExchangeRateProvider):
"""Implementation for the Twelve Data API (twelvedata.com)"""
BASE_URL = "https://api.twelvedata.com/exchange_rate"
rates_inverted = (
False # The API returns direct rates, e.g., for EUR/USD it's 1 EUR = X USD
)
def __init__(self, api_key: str):
"""
Initializes the provider with an API key and a requests session.
"""
super().__init__(api_key)
self.session = requests.Session()
@classmethod
def requires_api_key(cls) -> bool:
"""This provider requires an API key."""
return True
def get_rates(
self, target_currencies: QuerySet, exchange_currencies: set
) -> List[Tuple[Currency, Currency, Decimal]]:
"""
Fetches exchange rates from the Twelve Data API for the given currency pairs.
This provider makes one API call for each requested currency pair.
"""
results = []
for target_currency in target_currencies:
# Ensure the target currency's exchange currency is one we're interested in
if target_currency.exchange_currency not in exchange_currencies:
continue
base_currency = target_currency.exchange_currency
# The exchange rate for the same currency is always 1
if base_currency.code == target_currency.code:
rate = Decimal("1")
results.append((base_currency, target_currency, rate))
continue
# Construct the symbol in the format "BASE/TARGET", e.g., "EUR/USD"
symbol = f"{base_currency.code}/{target_currency.code}"
try:
params = {
"symbol": symbol,
"apikey": self.api_key,
}
response = self.session.get(self.BASE_URL, params=params)
response.raise_for_status() # Raise an HTTPError for bad responses (4xx or 5xx)
data = response.json()
# The API may return an error message in a JSON object
if "rate" not in data:
error_message = data.get("message", "Rate not found in response.")
logger.error(
f"Could not fetch rate for {symbol} from Twelve Data: {error_message}"
)
continue
# Convert the rate to a Decimal for precision
rate = Decimal(str(data["rate"]))
results.append((base_currency, target_currency, rate))
logger.info(f"Successfully fetched rate for {symbol} from Twelve Data.")
time.sleep(
60
) # We sleep every pair as to not step over TwelveData's minute limit
except requests.RequestException as e:
logger.error(
f"Error fetching rate from Twelve Data API for symbol {symbol}: {e}"
)
except KeyError as e:
logger.error(
f"Unexpected response structure from Twelve Data API for symbol {symbol}: Missing key {e}"
)
except Exception as e:
logger.error(
f"An unexpected error occurred while processing Twelve Data for {symbol}: {e}"
)
return results
class TwelveDataMarketsProvider(ExchangeRateProvider):
"""
Provides prices for market instruments (stocks, ETFs, etc.) using the Twelve Data API.
This provider performs a multi-step process:
1. Parses instrument codes which can be symbols, FIGI, CUSIP, or ISIN.
2. For CUSIPs, it defaults the currency to USD. For all others, it searches
for the instrument to determine its native trading currency.
3. Fetches the latest price for the instrument in its native currency.
4. Converts the price to the requested target exchange currency.
"""
SYMBOL_SEARCH_URL = "https://api.twelvedata.com/symbol_search"
PRICE_URL = "https://api.twelvedata.com/price"
EXCHANGE_RATE_URL = "https://api.twelvedata.com/exchange_rate"
rates_inverted = True
def __init__(self, api_key: str):
super().__init__(api_key)
self.session = requests.Session()
@classmethod
def requires_api_key(cls) -> bool:
return True
def _parse_code(self, raw_code: str) -> Tuple[str, str]:
"""Parses the raw code to determine its type and value."""
if raw_code.startswith("figi:"):
return "figi", raw_code.removeprefix("figi:")
if raw_code.startswith("cusip:"):
return "cusip", raw_code.removeprefix("cusip:")
if raw_code.startswith("isin:"):
return "isin", raw_code.removeprefix("isin:")
return "symbol", raw_code
def get_rates(
self, target_currencies: QuerySet, exchange_currencies: set
) -> List[Tuple[Currency, Currency, Decimal]]:
results = []
for asset in target_currencies:
if asset.exchange_currency not in exchange_currencies:
continue
code_type, code_value = self._parse_code(asset.code)
original_currency_code = None
try:
# Determine the instrument's native currency
if code_type == "cusip":
# CUSIP codes always default to USD
original_currency_code = "USD"
logger.info(f"Defaulting CUSIP {code_value} to USD currency.")
else:
# For all other types, find currency via symbol search
search_params = {"symbol": code_value, "apikey": "demo"}
search_res = self.session.get(
self.SYMBOL_SEARCH_URL, params=search_params
)
search_res.raise_for_status()
search_data = search_res.json()
if not search_data.get("data"):
logger.warning(
f"TwelveDataMarkets: Symbol search for '{code_value}' returned no results."
)
continue
instrument_data = search_data["data"][0]
original_currency_code = instrument_data.get("currency")
if not original_currency_code:
logger.error(
f"TwelveDataMarkets: Could not determine original currency for '{code_value}'."
)
continue
# Get the instrument's price in its native currency
price_params = {code_type: code_value, "apikey": self.api_key}
price_res = self.session.get(self.PRICE_URL, params=price_params)
price_res.raise_for_status()
price_data = price_res.json()
if "price" not in price_data:
error_message = price_data.get(
"message", "Price key not found in response"
)
logger.error(
f"TwelveDataMarkets: Could not get price for {code_type} '{code_value}': {error_message}"
)
continue
price_in_original_currency = Decimal(price_data["price"])
# Convert price to the target exchange currency
target_exchange_currency = asset.exchange_currency
if (
original_currency_code.upper()
== target_exchange_currency.code.upper()
):
final_price = price_in_original_currency
else:
rate_symbol = (
f"{original_currency_code}/{target_exchange_currency.code}"
)
rate_params = {"symbol": rate_symbol, "apikey": self.api_key}
rate_res = self.session.get(
self.EXCHANGE_RATE_URL, params=rate_params
)
rate_res.raise_for_status()
rate_data = rate_res.json()
if "rate" not in rate_data:
error_message = rate_data.get(
"message", "Rate key not found in response"
)
logger.error(
f"TwelveDataMarkets: Could not get conversion rate for '{rate_symbol}': {error_message}"
)
continue
conversion_rate = Decimal(str(rate_data["rate"]))
final_price = price_in_original_currency * conversion_rate
results.append((target_exchange_currency, asset, final_price))
logger.info(
f"Successfully processed price for {asset.code} as {final_price} {target_exchange_currency.code}"
)
time.sleep(
60
) # We sleep every pair as to not step over TwelveData's minute limit
except requests.RequestException as e:
logger.error(
f"TwelveDataMarkets: API request failed for {code_value}: {e}"
)
except (KeyError, IndexError) as e:
logger.error(
f"TwelveDataMarkets: Error processing API response for {code_value}: {e}"
)
except Exception as e:
logger.error(
f"TwelveDataMarkets: An unexpected error occurred for {code_value}: {e}"
)
return results
+59 -5
View File
@@ -1,6 +1,7 @@
from crispy_bootstrap5.bootstrap5 import Switch
from crispy_forms.bootstrap import FormActions from crispy_forms.bootstrap import FormActions
from crispy_forms.helper import FormHelper from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout from crispy_forms.layout import Layout, Row, Column
from django import forms from django import forms
from django.forms import CharField from django.forms import CharField
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -9,7 +10,7 @@ from apps.common.widgets.crispy.submit import NoClassSubmit
from apps.common.widgets.datepicker import AirDateTimePickerInput from apps.common.widgets.datepicker import AirDateTimePickerInput
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
from apps.common.widgets.tom_select import TomSelect from apps.common.widgets.tom_select import TomSelect
from apps.currencies.models import Currency, ExchangeRate from apps.currencies.models import Currency, ExchangeRate, ExchangeRateService
class CurrencyForm(forms.ModelForm): class CurrencyForm(forms.ModelForm):
@@ -25,6 +26,7 @@ class CurrencyForm(forms.ModelForm):
"suffix", "suffix",
"code", "code",
"exchange_currency", "exchange_currency",
"is_archived",
] ]
widgets = { widgets = {
"exchange_currency": TomSelect(), "exchange_currency": TomSelect(),
@@ -39,6 +41,7 @@ class CurrencyForm(forms.ModelForm):
self.helper.layout = Layout( self.helper.layout = Layout(
"code", "code",
"name", "name",
Switch("is_archived"),
"decimal_places", "decimal_places",
"prefix", "prefix",
"suffix", "suffix",
@@ -72,7 +75,7 @@ class ExchangeRateForm(forms.ModelForm):
model = ExchangeRate model = ExchangeRate
fields = ["from_currency", "to_currency", "rate", "date"] fields = ["from_currency", "to_currency", "rate", "date"]
def __init__(self, *args, user=None, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.helper = FormHelper() self.helper = FormHelper()
@@ -81,8 +84,59 @@ class ExchangeRateForm(forms.ModelForm):
self.helper.layout = Layout("date", "from_currency", "to_currency", "rate") self.helper.layout = Layout("date", "from_currency", "to_currency", "rate")
self.fields["rate"].widget = ArbitraryDecimalDisplayNumberInput() self.fields["rate"].widget = ArbitraryDecimalDisplayNumberInput()
self.fields["date"].widget = AirDateTimePickerInput( self.fields["date"].widget = AirDateTimePickerInput(clear_button=False)
clear_button=False, user=user
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",
"singleton",
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.form_method = "post"
self.helper.layout = Layout(
"name",
"service_type",
Switch("is_active"),
Switch("singleton"),
"api_key",
Row(
Column("interval_type", css_class="form-group col-md-6"),
Column("fetch_interval", css_class="form-group col-md-6"),
),
"target_currencies",
"target_accounts",
) )
if self.instance and self.instance.pk: if self.instance and self.instance.pk:
@@ -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'},
),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.2.4 on 2025-08-08 02:18
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('currencies', '0014_alter_currency_options'),
]
operations = [
migrations.AddField(
model_name='exchangerate',
name='automatic',
field=models.BooleanField(default=False, verbose_name='Automatic'),
),
migrations.AddField(
model_name='exchangerateservice',
name='singleton',
field=models.BooleanField(default=False, help_text='Create one exchange rate and keep updating it. Avoids database clutter.', verbose_name='Single exchange rate'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-08-08 02:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('currencies', '0015_exchangerate_automatic_exchangerateservice_singleton'),
]
operations = [
migrations.AlterField(
model_name='exchangerate',
name='automatic',
field=models.BooleanField(default=False, verbose_name='Auto'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.5 on 2025-08-16 22:18
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('currencies', '0016_alter_exchangerate_automatic'),
]
operations = [
migrations.AlterField(
model_name='exchangerateservice',
name='service_type',
field=models.CharField(choices=[('synth_finance', 'Synth Finance'), ('synth_finance_stock', 'Synth Finance Stock'), ('coingecko_free', 'CoinGecko (Demo/Free)'), ('coingecko_pro', 'CoinGecko (Pro)'), ('transitive', 'Transitive (Calculated from Existing Rates)'), ('frankfurter', 'Frankfurter')], max_length=255, verbose_name='Service Type'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.5 on 2025-08-17 03:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('currencies', '0017_alter_exchangerateservice_service_type'),
]
operations = [
migrations.AlterField(
model_name='exchangerateservice',
name='service_type',
field=models.CharField(choices=[('synth_finance', 'Synth Finance'), ('synth_finance_stock', 'Synth Finance Stock'), ('coingecko_free', 'CoinGecko (Demo/Free)'), ('coingecko_pro', 'CoinGecko (Pro)'), ('transitive', 'Transitive (Calculated from Existing Rates)'), ('frankfurter', 'Frankfurter'), ('twelvedata', 'TwelveData')], max_length=255, verbose_name='Service Type'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.5 on 2025-08-17 06:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('currencies', '0018_alter_exchangerateservice_service_type'),
]
operations = [
migrations.AlterField(
model_name='exchangerateservice',
name='service_type',
field=models.CharField(choices=[('synth_finance', 'Synth Finance'), ('synth_finance_stock', 'Synth Finance Stock'), ('coingecko_free', 'CoinGecko (Demo/Free)'), ('coingecko_pro', 'CoinGecko (Pro)'), ('transitive', 'Transitive (Calculated from Existing Rates)'), ('frankfurter', 'Frankfurter'), ('twelvedata', 'TwelveData'), ('twelvedatamarkets', 'TwelveData Markets')], max_length=255, verbose_name='Service Type'),
),
]
@@ -0,0 +1,51 @@
# Generated by Django 5.2.5 on 2025-08-17 06:25
from django.db import migrations
# The new value we are migrating to
NEW_SERVICE_TYPE = "frankfurter"
# The old values we are deprecating
OLD_SERVICE_TYPE_TO_UPDATE = "synth_finance"
OLD_SERVICE_TYPE_TO_DELETE = "synth_finance_stock"
def forwards_func(apps, schema_editor):
"""
Forward migration:
- Deletes all ExchangeRateService instances with service_type 'synth_finance_stock'.
- Updates all ExchangeRateService instances with service_type 'synth_finance' to 'frankfurter'.
"""
ExchangeRateService = apps.get_model("currencies", "ExchangeRateService")
db_alias = schema_editor.connection.alias
# 1. Delete the SYNTH_FINANCE_STOCK entries
ExchangeRateService.objects.using(db_alias).filter(
service_type=OLD_SERVICE_TYPE_TO_DELETE
).delete()
# 2. Update the SYNTH_FINANCE entries to FRANKFURTER
ExchangeRateService.objects.using(db_alias).filter(
service_type=OLD_SERVICE_TYPE_TO_UPDATE
).update(service_type=NEW_SERVICE_TYPE, api_key=None)
def backwards_func(apps, schema_editor):
"""
Backward migration: This operation is not safely reversible.
- We cannot know which 'frankfurter' services were originally 'synth_finance'.
- The deleted 'synth_finance_stock' services cannot be recovered.
We will leave this function empty to allow migrating backwards without doing anything.
"""
pass
class Migration(migrations.Migration):
dependencies = [
# Add the previous migration file here
("currencies", "0019_alter_exchangerateservice_service_type"),
]
operations = [
migrations.RunPython(forwards_func, reverse_code=backwards_func),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.5 on 2025-08-17 06:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('currencies', '0020_migrate_synth_finance_services'),
]
operations = [
migrations.AlterField(
model_name='exchangerateservice',
name='service_type',
field=models.CharField(choices=[('coingecko_free', 'CoinGecko (Demo/Free)'), ('coingecko_pro', 'CoinGecko (Pro)'), ('transitive', 'Transitive (Calculated from Existing Rates)'), ('frankfurter', 'Frankfurter'), ('twelvedata', 'TwelveData'), ('twelvedatamarkets', 'TwelveData Markets')], max_length=255, verbose_name='Service Type'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.5 on 2025-08-30 00:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('currencies', '0021_alter_exchangerateservice_service_type'),
]
operations = [
migrations.AddField(
model_name='currency',
name='is_archived',
field=models.BooleanField(default=False, verbose_name='Archived'),
),
]
+181 -1
View File
@@ -1,11 +1,18 @@
import logging
from typing import Set
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
logger = logging.getLogger(__name__)
class Currency(models.Model): class Currency(models.Model):
code = models.CharField(max_length=10, unique=True, verbose_name=_("Currency Code")) code = models.CharField(
max_length=255, unique=False, verbose_name=_("Currency Code")
)
name = models.CharField(max_length=50, verbose_name=_("Currency Name"), unique=True) name = models.CharField(max_length=50, verbose_name=_("Currency Name"), unique=True)
decimal_places = models.PositiveIntegerField( decimal_places = models.PositiveIntegerField(
default=2, default=2,
@@ -25,12 +32,18 @@ class Currency(models.Model):
help_text=_("Default currency for exchange calculations"), help_text=_("Default currency for exchange calculations"),
) )
is_archived = models.BooleanField(
default=False,
verbose_name=_("Archived"),
)
def __str__(self): def __str__(self):
return self.name return self.name
class Meta: class Meta:
verbose_name = _("Currency") verbose_name = _("Currency")
verbose_name_plural = _("Currencies") verbose_name_plural = _("Currencies")
ordering = ["name", "id"]
def clean(self): def clean(self):
super().clean() super().clean()
@@ -62,6 +75,8 @@ class ExchangeRate(models.Model):
) )
date = models.DateTimeField(verbose_name=_("Date and Time")) date = models.DateTimeField(verbose_name=_("Date and Time"))
automatic = models.BooleanField(verbose_name=_("Auto"), default=False)
class Meta: class Meta:
verbose_name = _("Exchange Rate") verbose_name = _("Exchange Rate")
verbose_name_plural = _("Exchange Rates") verbose_name_plural = _("Exchange Rates")
@@ -72,7 +87,172 @@ class ExchangeRate(models.Model):
def clean(self): def clean(self):
super().clean() super().clean()
# 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: if self.from_currency == self.to_currency:
raise ValidationError( raise ValidationError(
{"to_currency": _("From and To currencies cannot be the same.")} {"to_currency": _("From and To currencies cannot be the same.")}
) )
class ExchangeRateService(models.Model):
"""Configuration for exchange rate services"""
class ServiceType(models.TextChoices):
COINGECKO_FREE = "coingecko_free", "CoinGecko (Demo/Free)"
COINGECKO_PRO = "coingecko_pro", "CoinGecko (Pro)"
TRANSITIVE = "transitive", "Transitive (Calculated from Existing Rates)"
FRANKFURTER = "frankfurter", "Frankfurter"
TWELVEDATA = "twelvedata", "TwelveData"
TWELVEDATA_MARKETS = "twelvedatamarkets", "TwelveData Markets"
class IntervalType(models.TextChoices):
ON = "on", _("On")
EVERY = "every", _("Every X hours")
NOT_ON = "not_on", _("Not on")
name = models.CharField(max_length=255, unique=True, verbose_name=_("Service Name"))
service_type = models.CharField(
max_length=255, choices=ServiceType.choices, verbose_name=_("Service Type")
)
is_active = models.BooleanField(default=True, verbose_name=_("Active"))
api_key = models.CharField(
max_length=255,
blank=True,
null=True,
verbose_name=_("API Key"),
help_text=_("API key for the service (if required)"),
)
interval_type = models.CharField(
max_length=255,
choices=IntervalType.choices,
verbose_name=_("Interval Type"),
default=IntervalType.EVERY,
)
fetch_interval = models.CharField(
max_length=1000, verbose_name=_("Interval"), default="24"
)
last_fetch = models.DateTimeField(
null=True, blank=True, verbose_name=_("Last Successful Fetch")
)
target_currencies = models.ManyToManyField(
Currency,
verbose_name=_("Target Currencies"),
help_text=_(
"Select currencies to fetch exchange rates for. Rates will be fetched for each currency against their set exchange currency."
),
related_name="exchange_services",
blank=True,
)
target_accounts = models.ManyToManyField(
"accounts.Account",
verbose_name=_("Target Accounts"),
help_text=_(
"Select accounts to fetch exchange rates for. Rates will be fetched for each account's currency against their set exchange currency."
),
related_name="exchange_services",
blank=True,
)
singleton = models.BooleanField(
verbose_name=_("Single exchange rate"),
default=False,
help_text=_(
"Create one exchange rate and keep updating it. Avoids database clutter."
),
)
class Meta:
verbose_name = _("Exchange Rate Service")
verbose_name_plural = _("Exchange Rate Services")
ordering = ["name"]
def __str__(self):
return self.name
def get_provider(self):
from apps.currencies.exchange_rates.fetcher import PROVIDER_MAPPING
provider_class = PROVIDER_MAPPING[self.service_type]
return provider_class(self.api_key)
@staticmethod
def _parse_hour_ranges(interval_str: str) -> Set[int]:
"""
Parse hour ranges and individual hours from string.
Valid formats:
- Single hours: "1,5,9"
- Ranges: "1-5"
- Mixed: "1-5,8,10-12"
Returns set of hours.
"""
hours = set()
for part in interval_str.strip().split(","):
part = part.strip()
if "-" in part:
start, end = part.split("-")
start, end = int(start), int(end)
if not (0 <= start <= 23 and 0 <= end <= 23):
raise ValueError("Hours must be between 0 and 23")
if start > end:
raise ValueError(f"Invalid range: {start}-{end}")
hours.update(range(start, end + 1))
else:
hour = int(part)
if not 0 <= hour <= 23:
raise ValueError("Hours must be between 0 and 23")
hours.add(hour)
return hours
def clean(self):
super().clean()
try:
if self.interval_type == self.IntervalType.EVERY:
if not self.fetch_interval.isdigit():
raise ValidationError(
{
"fetch_interval": _(
"'Every X hours' interval type requires a positive integer."
)
}
)
hours = int(self.fetch_interval)
if hours < 1 or hours > 24:
raise ValidationError(
{
"fetch_interval": _(
"'Every X hours' interval must be between 1 and 24."
)
}
)
else:
try:
# Parse and validate hour ranges
hours = self._parse_hour_ranges(self.fetch_interval)
# Store in normalized format (optional)
self.fetch_interval = ",".join(str(h) for h in sorted(hours))
except ValueError as e:
raise ValidationError(
{
"fetch_interval": _(
"Invalid hour format. Use comma-separated hours (0-23) "
"and/or ranges (e.g., '1-5,8,10-12')."
)
}
)
except ValidationError:
raise
except Exception as e:
raise ValidationError(
{
"fetch_interval": _(
"Invalid format. Please check the requirements for your selected interval type."
)
}
)
+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)
-6
View File
@@ -40,12 +40,6 @@ class CurrencyTests(TestCase):
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
currency.full_clean() currency.full_clean()
def test_currency_unique_code(self):
"""Test that currency codes must be unique"""
Currency.objects.create(code="USD", name="US Dollar", decimal_places=2)
with self.assertRaises(IntegrityError):
Currency.objects.create(code="USD", name="Another Dollar", decimal_places=2)
def test_currency_unique_name(self): def test_currency_unique_name(self):
"""Test that currency names must be unique""" """Test that currency names must be unique"""
Currency.objects.create(code="USD", name="US Dollar", decimal_places=2) Currency.objects.create(code="USD", name="US Dollar", decimal_places=2)
+30
View File
@@ -34,4 +34,34 @@ urlpatterns = [
views.exchange_rate_delete, views.exchange_rate_delete,
name="exchange_rate_delete", name="exchange_rate_delete",
), ),
path(
"automatic-exchange-rates/",
views.exchange_rates_services_index,
name="automatic_exchange_rates_index",
),
path(
"automatic-exchange-rates/list/",
views.exchange_rates_services_list,
name="automatic_exchange_rates_list",
),
path(
"automatic-exchange-rates/add/",
views.exchange_rate_service_add,
name="automatic_exchange_rate_add",
),
path(
"automatic-exchange-rates/force-fetch/",
views.exchange_rate_service_force_fetch,
name="automatic_exchange_rate_force_fetch",
),
path(
"automatic-exchange-rates/<int:pk>/edit/",
views.exchange_rate_service_edit,
name="automatic_exchange_rate_edit",
),
path(
"automatic-exchange-rates/<int:pk>/delete/",
views.exchange_rate_service_delete,
name="automatic_exchange_rate_delete",
),
] ]
+1
View File
@@ -1,2 +1,3 @@
from .currencies import * from .currencies import *
from .exchange_rates import * from .exchange_rates import *
from .exchange_rates_services import *
+9 -9
View File
@@ -27,17 +27,17 @@ def exchange_rates_index(request):
@require_http_methods(["GET"]) @require_http_methods(["GET"])
def exchange_rates_list(request): def exchange_rates_list(request):
pairings = ( pairings = (
ExchangeRate.objects.values("from_currency__code", "to_currency__code") ExchangeRate.objects.values("from_currency__name", "to_currency__name")
.distinct() .distinct()
.annotate( .annotate(
pair=Concat( pair=Concat(
"from_currency__code", "from_currency__name",
Value(" x "), Value(" x "),
"to_currency__code", "to_currency__name",
output_field=CharField(), output_field=CharField(),
) )
) )
.values_list("pair", "from_currency__code", "to_currency__code") .values_list("pair", "from_currency__name", "to_currency__name")
) )
return render( return render(
@@ -56,7 +56,7 @@ def exchange_rates_list_pair(request):
if from_currency and to_currency: if from_currency and to_currency:
exchange_rates = ExchangeRate.objects.filter( exchange_rates = ExchangeRate.objects.filter(
from_currency__code=from_currency, to_currency__code=to_currency from_currency__name=from_currency, to_currency__name=to_currency
).order_by("-date") ).order_by("-date")
else: else:
exchange_rates = ExchangeRate.objects.all().order_by("-date") exchange_rates = ExchangeRate.objects.all().order_by("-date")
@@ -83,7 +83,7 @@ def exchange_rates_list_pair(request):
@require_http_methods(["GET", "POST"]) @require_http_methods(["GET", "POST"])
def exchange_rate_add(request): def exchange_rate_add(request):
if request.method == "POST": if request.method == "POST":
form = ExchangeRateForm(request.POST, user=request.user) form = ExchangeRateForm(request.POST)
if form.is_valid(): if form.is_valid():
form.save() form.save()
messages.success(request, _("Exchange rate added successfully")) messages.success(request, _("Exchange rate added successfully"))
@@ -95,7 +95,7 @@ def exchange_rate_add(request):
}, },
) )
else: else:
form = ExchangeRateForm(user=request.user) form = ExchangeRateForm()
return render( return render(
request, request,
@@ -111,7 +111,7 @@ def exchange_rate_edit(request, pk):
exchange_rate = get_object_or_404(ExchangeRate, id=pk) exchange_rate = get_object_or_404(ExchangeRate, id=pk)
if request.method == "POST": if request.method == "POST":
form = ExchangeRateForm(request.POST, instance=exchange_rate, user=request.user) form = ExchangeRateForm(request.POST, instance=exchange_rate)
if form.is_valid(): if form.is_valid():
form.save() form.save()
messages.success(request, _("Exchange rate updated successfully")) messages.success(request, _("Exchange rate updated successfully"))
@@ -123,7 +123,7 @@ def exchange_rate_edit(request, pk):
}, },
) )
else: else:
form = ExchangeRateForm(instance=exchange_rate, user=request.user) form = ExchangeRateForm(instance=exchange_rate)
return render( return render(
request, request,
@@ -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 django.contrib import admin
from apps.dca.models import DCAStrategy, DCAEntry from apps.dca.models import DCAStrategy, DCAEntry
from apps.common.admin import SharedObjectModelAdmin
# Register your models here.
admin.site.register(DCAStrategy)
admin.site.register(DCAEntry) admin.site.register(DCAEntry)
@admin.register(DCAStrategy)
class TransactionEntityModelAdmin(SharedObjectModelAdmin):
def get_queryset(self, request):
return DCAStrategy.all_objects.all()
+276 -13
View File
@@ -1,14 +1,22 @@
from crispy_forms.bootstrap import FormActions from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion
from crispy_forms.bootstrap import FormActions, AccordionGroup
from crispy_forms.helper import FormHelper from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Row, Column from crispy_forms.layout import Layout, Row, Column, HTML
from django import forms from django import forms
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from apps.accounts.models import Account
from apps.common.widgets.crispy.submit import NoClassSubmit from apps.common.widgets.crispy.submit import NoClassSubmit
from apps.common.widgets.datepicker import AirDatePickerInput from apps.common.widgets.datepicker import AirDatePickerInput
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
from apps.common.widgets.tom_select import TomSelect from apps.common.widgets.tom_select import TomSelect
from apps.dca.models import DCAStrategy, DCAEntry from apps.dca.models import DCAStrategy, DCAEntry
from apps.common.widgets.tom_select import TransactionSelect
from apps.transactions.models import Transaction, TransactionTag, TransactionCategory
from apps.common.fields.forms.dynamic_select import (
DynamicModelChoiceField,
DynamicModelMultipleChoiceField,
)
class DCAStrategyForm(forms.ModelForm): class DCAStrategyForm(forms.ModelForm):
@@ -53,6 +61,75 @@ class DCAStrategyForm(forms.ModelForm):
class DCAEntryForm(forms.ModelForm): class DCAEntryForm(forms.ModelForm):
create_transaction = forms.BooleanField(
label=_("Create transaction"), initial=False, required=False
)
from_account = forms.ModelChoiceField(
queryset=Account.objects.filter(is_archived=False),
label=_("From Account"),
widget=TomSelect(clear_button=False, group_by="group"),
required=False,
)
to_account = forms.ModelChoiceField(
queryset=Account.objects.filter(is_archived=False),
label=_("To Account"),
widget=TomSelect(clear_button=False, group_by="group"),
required=False,
)
from_category = DynamicModelChoiceField(
create_field="name",
model=TransactionCategory,
required=False,
label=_("Category"),
queryset=TransactionCategory.objects.filter(active=True),
)
to_category = DynamicModelChoiceField(
create_field="name",
model=TransactionCategory,
required=False,
label=_("Category"),
queryset=TransactionCategory.objects.filter(active=True),
)
from_tags = DynamicModelMultipleChoiceField(
model=TransactionTag,
to_field_name="name",
create_field="name",
required=False,
label=_("Tags"),
queryset=TransactionTag.objects.filter(active=True),
)
to_tags = DynamicModelMultipleChoiceField(
model=TransactionTag,
to_field_name="name",
create_field="name",
required=False,
label=_("Tags"),
queryset=TransactionTag.objects.filter(active=True),
)
expense_transaction = DynamicModelChoiceField(
model=Transaction,
to_field_name="id",
label=_("Expense Transaction"),
required=False,
queryset=Transaction.objects.none(),
widget=TransactionSelect(clear_button=True, income=False, expense=True),
help_text=_("Type to search for a transaction to link to this entry"),
)
income_transaction = DynamicModelChoiceField(
model=Transaction,
to_field_name="id",
label=_("Income Transaction"),
required=False,
queryset=Transaction.objects.none(),
widget=TransactionSelect(clear_button=True, income=True, expense=False),
help_text=_("Type to search for a transaction to link to this entry"),
)
class Meta: class Meta:
model = DCAEntry model = DCAEntry
fields = [ fields = [
@@ -60,13 +137,19 @@ class DCAEntryForm(forms.ModelForm):
"amount_paid", "amount_paid",
"amount_received", "amount_received",
"notes", "notes",
"expense_transaction",
"income_transaction",
] ]
widgets = { widgets = {
"notes": forms.Textarea(attrs={"rows": 3}), "notes": forms.Textarea(attrs={"rows": 3}),
} }
def __init__(self, *args, user=None, **kwargs): def __init__(self, *args, **kwargs):
strategy = kwargs.pop("strategy", None)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.strategy = strategy if strategy else self.instance.strategy
self.helper = FormHelper() self.helper = FormHelper()
self.helper.form_tag = False self.helper.form_tag = False
self.helper.layout = Layout( self.helper.layout = Layout(
@@ -75,18 +158,66 @@ class DCAEntryForm(forms.ModelForm):
Column("amount_paid", css_class="form-group col-md-6"), Column("amount_paid", css_class="form-group col-md-6"),
Column("amount_received", css_class="form-group col-md-6"), Column("amount_received", css_class="form-group col-md-6"),
), ),
Row(
Column("expense_transaction", css_class="form-group col-md-6"),
Column("income_transaction", css_class="form-group col-md-6"),
),
"notes", "notes",
BS5Accordion(
AccordionGroup(
_("Create transaction"),
Switch("create_transaction"),
Row(
Column(
Row(
Column(
"from_account",
css_class="form-group",
),
css_class="form-row",
),
Row(
Column(
"from_category",
css_class="form-group col-md-6 mb-0",
),
Column(
"from_tags", css_class="form-group col-md-6 mb-0"
),
css_class="form-row",
),
),
css_class="p-1 mx-1 my-3 border rounded-3",
),
Row(
Column(
Row(
Column(
"to_account",
css_class="form-group",
),
css_class="form-row",
),
Row(
Column(
"to_category", css_class="form-group col-md-6 mb-0"
),
Column("to_tags", css_class="form-group col-md-6 mb-0"),
css_class="form-row",
),
),
css_class="p-1 mx-1 my-3 border rounded-3",
),
active=False,
),
AccordionGroup(
_("Link transaction"),
"income_transaction",
"expense_transaction",
),
flush=False,
always_open=False,
css_class="mb-3",
),
) )
if self.instance and self.instance.pk: if self.instance and self.instance.pk:
# decimal_places = self.instance.account.currency.decimal_places
# self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput(
# decimal_places=decimal_places
# )
self.helper.layout.append( self.helper.layout.append(
FormActions( FormActions(
NoClassSubmit( NoClassSubmit(
@@ -95,7 +226,6 @@ class DCAEntryForm(forms.ModelForm):
), ),
) )
else: else:
# self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
self.helper.layout.append( self.helper.layout.append(
FormActions( FormActions(
NoClassSubmit( NoClassSubmit(
@@ -106,4 +236,137 @@ class DCAEntryForm(forms.ModelForm):
self.fields["amount_paid"].widget = ArbitraryDecimalDisplayNumberInput() self.fields["amount_paid"].widget = ArbitraryDecimalDisplayNumberInput()
self.fields["amount_received"].widget = ArbitraryDecimalDisplayNumberInput() self.fields["amount_received"].widget = ArbitraryDecimalDisplayNumberInput()
self.fields["date"].widget = AirDatePickerInput(clear_button=False, user=user) self.fields["date"].widget = AirDatePickerInput(clear_button=False)
expense_transaction = None
income_transaction = None
if self.instance and self.instance.pk:
# Edit mode - get from instance
expense_transaction = self.instance.expense_transaction
income_transaction = self.instance.income_transaction
elif self.data.get("expense_transaction"):
# Form validation - get from submitted data
try:
expense_transaction = Transaction.objects.get(
id=self.data["expense_transaction"]
)
income_transaction = Transaction.objects.get(
id=self.data["income_transaction"]
)
except Transaction.DoesNotExist:
pass
# If we have a current transaction, ensure it's in the queryset
if income_transaction:
self.fields["income_transaction"].queryset = Transaction.objects.filter(
id=income_transaction.id
)
if expense_transaction:
self.fields["expense_transaction"].queryset = Transaction.objects.filter(
id=expense_transaction.id
)
self.fields["from_account"].queryset = Account.objects.filter(
is_archived=False,
)
self.fields["from_category"].queryset = TransactionCategory.objects.filter(
active=True
)
self.fields["from_tags"].queryset = TransactionTag.objects.filter(active=True)
self.fields["to_account"].queryset = Account.objects.filter(
is_archived=False,
)
self.fields["to_category"].queryset = TransactionCategory.objects.filter(
active=True
)
self.fields["to_tags"].queryset = TransactionTag.objects.filter(active=True)
def clean(self):
cleaned_data = super().clean()
if cleaned_data.get("create_transaction"):
from_account = cleaned_data.get("from_account")
to_account = cleaned_data.get("to_account")
if not from_account and not to_account:
raise forms.ValidationError(
{
"from_account": _("You must provide an account."),
"to_account": _("You must provide an account."),
}
)
elif not from_account and to_account:
raise forms.ValidationError(
{"from_account": _("You must provide an account.")}
)
elif not to_account and from_account:
raise forms.ValidationError(
{"to_account": _("You must provide an account.")}
)
if from_account == to_account:
raise forms.ValidationError(
_("From and To accounts must be different.")
)
return cleaned_data
def save(self, **kwargs):
instance = super().save(commit=False)
if self.cleaned_data.get("create_transaction"):
from_account = self.cleaned_data["from_account"]
to_account = self.cleaned_data["to_account"]
from_amount = instance.amount_paid
to_amount = instance.amount_received
date = instance.date
description = _("DCA for %(strategy_name)s") % {
"strategy_name": self.strategy.name
}
from_category = self.cleaned_data.get("from_category")
to_category = self.cleaned_data.get("to_category")
notes = self.cleaned_data.get("notes")
# Create "From" transaction
from_transaction = Transaction.objects.create(
account=from_account,
type=Transaction.Type.EXPENSE,
is_paid=True,
date=date,
amount=from_amount,
description=description,
category=from_category,
notes=notes,
)
from_transaction.tags.set(self.cleaned_data.get("from_tags", []))
# Create "To" transaction
to_transaction = Transaction.objects.create(
account=to_account,
type=Transaction.Type.INCOME,
is_paid=True,
date=date,
amount=to_amount,
description=description,
category=to_category,
notes=notes,
)
to_transaction.tags.set(self.cleaned_data.get("to_tags", []))
instance.expense_transaction = from_transaction
instance.income_transaction = to_transaction
else:
if instance.expense_transaction:
instance.expense_transaction.amount = instance.amount_paid
instance.expense_transaction.save()
if instance.income_transaction:
instance.income_transaction.amount = instance.amount_received
instance.income_transaction.save()
instance.strategy = self.strategy
instance.save()
return instance
@@ -0,0 +1,31 @@
# Generated by Django 5.1.6 on 2025-03-07 18:20
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dca', '0002_alter_dcaentry_amount_paid_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='dcastrategy',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='dcastrategy',
name='shared_with',
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='dcastrategy',
name='visibility',
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10),
),
]
@@ -0,0 +1,31 @@
# Generated by Django 5.2.4 on 2025-07-28 02:15
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dca', '0003_dcastrategy_owner_dcastrategy_shared_with_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='dcastrategy',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
),
migrations.AlterField(
model_name='dcastrategy',
name='shared_with',
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL, verbose_name='Shared with users'),
),
migrations.AlterField(
model_name='dcastrategy',
name='visibility',
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10, verbose_name='Visibility'),
),
]
+5 -3
View File
@@ -1,16 +1,15 @@
from datetime import timedelta
from decimal import Decimal from decimal import Decimal
from statistics import mean, stdev
from django.db import models from django.db import models
from django.template.defaultfilters import date from django.template.defaultfilters import date
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from apps.common.models import SharedObject, SharedObjectManager
from apps.currencies.utils.convert import convert, get_exchange_rate from apps.currencies.utils.convert import convert, get_exchange_rate
class DCAStrategy(models.Model): class DCAStrategy(SharedObject):
name = models.CharField(max_length=255, verbose_name=_("Name")) name = models.CharField(max_length=255, verbose_name=_("Name"))
target_currency = models.ForeignKey( target_currency = models.ForeignKey(
"currencies.Currency", "currencies.Currency",
@@ -28,6 +27,9 @@ class DCAStrategy(models.Model):
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
objects = SharedObjectManager()
all_objects = models.Manager() # Unfiltered manager
class Meta: class Meta:
verbose_name = _("DCA Strategy") verbose_name = _("DCA Strategy")
verbose_name_plural = _("DCA Strategies") verbose_name_plural = _("DCA Strategies")
+10
View File
@@ -12,6 +12,16 @@ urlpatterns = [
views.strategy_delete, views.strategy_delete,
name="dca_strategy_delete", name="dca_strategy_delete",
), ),
path(
"dca/<int:strategy_id>/take-ownership/",
views.strategy_take_ownership,
name="dca_strategy_take_ownership",
),
path(
"dca/<int:pk>/share/",
views.strategy_share,
name="dca_strategy_share_settings",
),
path( path(
"dca/<int:strategy_id>/", "dca/<int:strategy_id>/",
views.strategy_detail_index, views.strategy_detail_index,
+83 -8
View File
@@ -11,6 +11,8 @@ from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx from apps.common.decorators.htmx import only_htmx
from apps.dca.forms import DCAEntryForm, DCAStrategyForm from apps.dca.forms import DCAEntryForm, DCAStrategyForm
from apps.dca.models import DCAStrategy, DCAEntry from apps.dca.models import DCAStrategy, DCAEntry
from apps.common.models import SharedObject
from apps.common.forms import SharedObjectForm
@login_required @login_required
@@ -57,6 +59,16 @@ def strategy_add(request):
def strategy_edit(request, strategy_id): def strategy_edit(request, strategy_id):
dca_strategy = get_object_or_404(DCAStrategy, id=strategy_id) dca_strategy = get_object_or_404(DCAStrategy, id=strategy_id)
if dca_strategy.owner and dca_strategy.owner != request.user:
messages.error(request, _("Only the owner can edit this"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
if request.method == "POST": if request.method == "POST":
form = DCAStrategyForm(request.POST, instance=dca_strategy) form = DCAStrategyForm(request.POST, instance=dca_strategy)
if form.is_valid(): if form.is_valid():
@@ -85,8 +97,14 @@ def strategy_edit(request, strategy_id):
def strategy_delete(request, strategy_id): def strategy_delete(request, strategy_id):
dca_strategy = get_object_or_404(DCAStrategy, id=strategy_id) dca_strategy = get_object_or_404(DCAStrategy, id=strategy_id)
if (
dca_strategy.owner != request.user
and request.user in dca_strategy.shared_with.all()
):
dca_strategy.shared_with.remove(request.user)
messages.success(request, _("Item no longer shared with you"))
else:
dca_strategy.delete() dca_strategy.delete()
messages.success(request, _("DCA strategy deleted successfully")) messages.success(request, _("DCA strategy deleted successfully"))
return HttpResponse( return HttpResponse(
@@ -97,6 +115,65 @@ def strategy_delete(request, strategy_id):
) )
@only_htmx
@login_required
@require_http_methods(["GET"])
def strategy_take_ownership(request, strategy_id):
dca_strategy = get_object_or_404(DCAStrategy, id=strategy_id)
if not dca_strategy.owner:
dca_strategy.owner = request.user
dca_strategy.visibility = SharedObject.Visibility.private
dca_strategy.save()
messages.success(request, _("Ownership taken successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def strategy_share(request, pk):
obj = get_object_or_404(DCAStrategy, id=pk)
if obj.owner and obj.owner != request.user:
messages.error(request, _("Only the owner can edit this"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
if request.method == "POST":
form = SharedObjectForm(request.POST, instance=obj, user=request.user)
if form.is_valid():
form.save()
messages.success(request, _("Configuration saved successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
else:
form = SharedObjectForm(instance=obj, user=request.user)
return render(
request,
"dca/fragments/strategy/share.html",
{"form": form, "object": obj},
)
@login_required @login_required
def strategy_detail_index(request, strategy_id): def strategy_detail_index(request, strategy_id):
strategy = get_object_or_404(DCAStrategy, id=strategy_id) strategy = get_object_or_404(DCAStrategy, id=strategy_id)
@@ -155,11 +232,9 @@ def strategy_detail(request, strategy_id):
def strategy_entry_add(request, strategy_id): def strategy_entry_add(request, strategy_id):
strategy = get_object_or_404(DCAStrategy, id=strategy_id) strategy = get_object_or_404(DCAStrategy, id=strategy_id)
if request.method == "POST": if request.method == "POST":
form = DCAEntryForm(request.POST, user=request.user) form = DCAEntryForm(request.POST, strategy=strategy)
if form.is_valid(): if form.is_valid():
entry = form.save(commit=False) entry = form.save()
entry.strategy = strategy
entry.save()
messages.success(request, _("Entry added successfully")) messages.success(request, _("Entry added successfully"))
return HttpResponse( return HttpResponse(
@@ -169,7 +244,7 @@ def strategy_entry_add(request, strategy_id):
}, },
) )
else: else:
form = DCAEntryForm(user=request.user) form = DCAEntryForm(strategy=strategy)
return render( return render(
request, request,
@@ -184,7 +259,7 @@ def strategy_entry_edit(request, strategy_id, entry_id):
dca_entry = get_object_or_404(DCAEntry, id=entry_id, strategy__id=strategy_id) dca_entry = get_object_or_404(DCAEntry, id=entry_id, strategy__id=strategy_id)
if request.method == "POST": if request.method == "POST":
form = DCAEntryForm(request.POST, instance=dca_entry, user=request.user) form = DCAEntryForm(request.POST, instance=dca_entry)
if form.is_valid(): if form.is_valid():
form.save() form.save()
messages.success(request, _("Entry updated successfully")) messages.success(request, _("Entry updated successfully"))
@@ -196,7 +271,7 @@ def strategy_entry_edit(request, strategy_id, entry_id):
}, },
) )
else: else:
form = DCAEntryForm(instance=dca_entry, user=request.user) form = DCAEntryForm(instance=dca_entry)
return render( return render(
request, request,
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

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