Compare commits

..

395 Commits

Author SHA1 Message Date
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
66a5e6d613 fix(locale): get_format doesn't override number formatting if use_l10n is None 2025-08-10 12:23:41 -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
271 changed files with 41254 additions and 7909 deletions

View File

@@ -10,6 +10,11 @@ SECRET_KEY=<GENERATE A SAFE SECRET KEY AND PLACE IT HERE>
DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1]
OUTBOUND_PORT=9005
# Uncomment these variables to automatically create an admin account using these credentials on startup.
# After your first successfull login you can remove these variables from your file for safety reasons.
#ADMIN_EMAIL=<ENTER YOUR EMAIL>
#ADMIN_PASSWORD=<YOUR SAFE PASSWORD>
SQL_DATABASE=wygiwyh
SQL_USER=wygiwyh
SQL_PASSWORD=<INSERT A SAFE PASSWORD HERE>
@@ -26,3 +31,10 @@ ENABLE_SOFT_DELETE=false
KEEP_DELETED_TRANSACTIONS_FOR=365
TASK_WORKERS=1 # This only work if you're using the single container option. Increase to have more open queues via procrastinate, you probably don't need to increase this.
# OIDC Configuration. Uncomment the lines below if you want to add OIDC login to your instance
#OIDC_CLIENT_NAME=""
#OIDC_CLIENT_ID=""
#OIDC_CLIENT_SECRET=""
#OIDC_SERVER_URL=""
#OIDC_ALLOW_SIGNUP=true

4
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,4 @@
# These are supported funding model platforms
github: eitchtee
custom: ["https://www.paypal.com/donate/?hosted_button_id=FFWM4W9NQDMM6"]

View File

@@ -20,6 +20,10 @@ on:
env:
IMAGE_NAME: wygiwyh
concurrency:
group: release
cancel-in-progress: false
jobs:
build-and-push:
runs-on: ubuntu-latest

72
.github/workflows/translations.yml vendored Normal file
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"

View File

@@ -13,6 +13,7 @@
<a href="#key-features">Features</a> •
<a href="#how-to-use">Usage</a> •
<a href="#how-it-works">How</a> •
<a href="#help-us-translate-wygiwyh">Translate</a> •
<a href="#caveats-and-warnings">Caveats and Warnings</a> •
<a href="#built-with">Built with</a>
</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.
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.
2. **Not a budgeting app** as I dislike budgeting constraints.
2. **Not a budgeting app** as I dislike budgeting constraints.
3. **Web app usability** (ideally with mobile support, though optional).
4. **Automation-ready API** to integrate with other tools and services.
5. **Custom transaction rules** for credit card billing cycles or similar quirks.
Frustrated by the lack of comprehensive options, I set out to build **WYGIWYH** an opinionated yet powerful tool that I believe will resonate with like-minded users.
Frustrated by the lack of comprehensive options, I set out to build **WYGIWYH**, an opinionated yet powerful tool that I believe will resonate with like-minded users.
# Key Features
@@ -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.
* **API support for automation**: Seamlessly integrate with existing services to synchronize transactions.
# Demo
You can try WYGIWYH on [wygiwyh-demo.herculino.com](https://wygiwyh-demo.herculino.com/) with the credentials below:
> [!NOTE]
> E-mail: `demo@demo.com`
>
> Password: `wygiwyhdemo`
Keep in mind that **any data you add will be wiped in 24 hours or less**. And that **most automation features like the API, Rules, Automatic Exchange Rates and Import/Export are disabled**.
# How To Use
To run this application, you'll need [Docker](https://docs.docker.com/engine/install/) with [docker-compose](https://docs.docker.com/compose/install/).
@@ -75,7 +87,7 @@ $ nano .env # or any other editor you want to use
# Run the app
$ docker compose up -d
# Create the first admin account
# Create the first admin account. This isn't required if you set the enviroment variables: ADMIN_EMAIL and ADMIN_PASSWORD.
$ docker compose exec -it web python manage.py createsuperuser
```
@@ -116,7 +128,7 @@ To create the first user, open the container's console using Unraid's UI, by cli
|-------------------------------|-------------|-----------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| 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 |
| 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 |
@@ -128,11 +140,48 @@ To create the first user, open the container's console using Unraid's UI, by cli
| ENABLE_SOFT_DELETE | true\|false | false | Whether to enable transactions soft delete, if enabled, deleted transactions will remain in the database. Useful for imports and avoiding duplicate entries. |
| KEEP_DELETED_TRANSACTIONS_FOR | int | 365 | Time in days to keep soft deleted transactions for. If 0, will keep all transactions indefinitely. Only works if ENABLE_SOFT_DELETE is true. |
| TASK_WORKERS | int | 1 | How many workers to have for async tasks. One should be enough for most use cases |
| DEMO | true\|false | false | If demo mode is enabled. |
| ADMIN_EMAIL | string | None | Automatically creates an admin account with this email. Must have `ADMIN_PASSWORD` also set. |
| ADMIN_PASSWORD | string | None | Automatically creates an admin account with this password. Must have `ADMIN_EMAIL` also set. |
| CHECK_FOR_UPDATES | bool | true | Check and notify users about new versions. The check is done by doing a single query to Github's API every 12 hours. |
## OIDC Configuration
WYGIWYH supports login via OpenID Connect (OIDC) through `django-allauth`. This allows users to authenticate using an external OIDC provider.
> [!NOTE]
> Currently only OpenID Connect is supported as a provider, open an issue if you need something else.
To configure OIDC, you need to set the following environment variables:
| Variable | Description |
|----------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `OIDC_CLIENT_NAME` | The name of the provider. will be displayed in the login page. Defaults to `OpenID Connect` |
| `OIDC_CLIENT_ID` | The Client ID provided by your OIDC provider. |
| `OIDC_CLIENT_SECRET` | The Client Secret provided by your OIDC provider. |
| `OIDC_SERVER_URL` | The base URL of your OIDC provider's discovery document or authorization server (e.g., `https://your-provider.com/auth/realms/your-realm`). `django-allauth` will use this to discover the necessary endpoints (authorization, token, userinfo, etc.). |
| `OIDC_ALLOW_SIGNUP` | Allow the automatic creation of inexistent accounts on a successfull authentication. Defaults to `true`. |
**Callback URL (Redirect URI):**
When configuring your OIDC provider, you will need to provide a callback URL (also known as a Redirect URI). For WYGIWYH, the default callback URL is:
`https://your.wygiwyh.domain/auth/oidc/<OIDC_CLIENT_NAME>/login/callback/`
Replace `https://your.wygiwyh.domain` with the actual URL where your WYGIWYH instance is accessible. And `<OIDC_CLIENT_NAME>` with the slugfied value set in OIDC_CLIENT_NAME or the default `openid-connect` if you haven't set this variable.
# How it works
Check out our [Wiki](https://github.com/eitchtee/WYGIWYH/wiki) for more information.
# Help us translate WYGIWYH!
<a href="https://translations.herculino.com/engage/wygiwyh/">
<img src="https://translations.herculino.com/widget/wygiwyh/open-graph.png" alt="Translation status" />
</a>
> [!NOTE]
> Login with your github account
# Caveats and Warnings
- I'm not an accountant, some terms and even calculations might be wrong. Make sure to open an issue if you see anything that could be improved.

View File

@@ -14,6 +14,7 @@ import os
import sys
from pathlib import Path
from django.utils.text import slugify
SITE_TITLE = "WYGIWYH"
TITLE_SEPARATOR = "::"
@@ -42,6 +43,7 @@ INSTALLED_APPS = [
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.sites",
"whitenoise.runserver_nostatic",
"django.contrib.staticfiles",
"webpack_boilerplate",
@@ -61,7 +63,6 @@ INSTALLED_APPS = [
"apps.transactions.apps.TransactionsConfig",
"apps.currencies.apps.CurrenciesConfig",
"apps.accounts.apps.AccountsConfig",
"apps.common.apps.CommonConfig",
"apps.net_worth.apps.NetWorthConfig",
"apps.import_app.apps.ImportConfig",
"apps.export_app.apps.ExportConfig",
@@ -74,8 +75,15 @@ INSTALLED_APPS = [
"apps.calendar_view.apps.CalendarViewConfig",
"apps.dca.apps.DcaConfig",
"pwa",
"allauth",
"allauth.account",
"allauth.socialaccount",
"allauth.socialaccount.providers.openid_connect",
"apps.common.apps.CommonConfig",
]
SITE_ID = 1
MIDDLEWARE = [
"django_browser_reload.middleware.BrowserReloadMiddleware",
"apps.common.middleware.thread_local.ThreadLocalMiddleware",
@@ -91,6 +99,7 @@ MIDDLEWARE = [
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"hijack.middleware.HijackUserMiddleware",
"allauth.account.middleware.AccountMiddleware",
]
ROOT_URLCONF = "WYGIWYH.urls"
@@ -163,9 +172,105 @@ AUTH_USER_MODEL = "users.User"
LANGUAGE_CODE = "en"
LANGUAGES = (
("af", "Afrikaans"),
("ar", "العربية"),
("ar-dz", "العربية (الجزائر)"), # Algerian Arabic often uses the base name + region
("ast", "Asturianu"),
("az", "Azərbaycan"),
("bg", "Български"),
("be", "Беларуская"),
("bn", "বাংলা"),
("br", "Brezhoneg"),
("bs", "Bosanski"),
("ca", "Català"),
("ckb", "کوردیی ناوەندی"), # Central Kurdish (Sorani)
("cs", "Čeština"),
("cy", "Cymraeg"),
("da", "Dansk"),
("de", "Deutsch"),
("dsb", "Dolnoserbšćina"),
("el", "Ελληνικά"),
("en", "English"),
("en-au", "English (Australia)"),
("en-gb", "English (UK)"),
("eo", "Esperanto"),
("es", "Español"),
("es-ar", "Español (Argentina)"),
("es-co", "Español (Colombia)"),
("es-mx", "Español (México)"),
("es-ni", "Español (Nicaragua)"),
("es-ve", "Español (Venezuela)"),
("et", "Eesti"),
("eu", "Euskara"),
("fa", "فارسی"),
("fi", "Suomi"),
("fr", "Français"),
("fy", "Frysk"),
("ga", "Gaeilge"),
("gd", "Gàidhlig"),
("gl", "Galego"),
("he", "עברית"),
("hi", "हिन्दी"),
("hr", "Hrvatski"),
("hsb", "Hornjoserbšćina"),
("hu", "Magyar"),
("hy", "Հայերեն"),
("ia", "Interlingua"),
("id", "Bahasa Indonesia"),
("ig", "Igbo"),
("io", "Ido"),
("is", "Íslenska"),
("it", "Italiano"),
("ja", "日本語"),
("ka", "ქართული"),
("kab", "Taqbaylit"),
("kk", "Қазақша"),
("km", "ខ្មែរ"),
("kn", "ಕನ್ನಡ"),
("ko", "한국어"),
("ky", "Кыргызча"),
("lb", "Lëtzebuergesch"),
("lt", "Lietuvių"),
("lv", "Latviešu"),
("mk", "Македонски"),
("ml", "മലയാളം"),
("mn", "Монгол"),
("mr", "मराठी"),
("ms", "Bahasa Melayu"),
("my", "မြန်မာဘာသာ"),
("nb", "Norsk (Bokmål)"),
("ne", "नेपाली"),
("nl", "Nederlands"),
("nn", "Norsk (Nynorsk)"),
("os", "Ирон"), # Ossetic
("pa", "ਪੰਜਾਬੀ"),
("pl", "Polski"),
("pt", "Português"),
("pt-br", "Português (Brasil)"),
("ro", "Română"),
("ru", "Русский"),
("sk", "Slovenčina"),
("sl", "Slovenščina"),
("sq", "Shqip"),
("sr", "Српски"),
("sr-latn", "Srpski (Latinica)"),
("sv", "Svenska"),
("sw", "Kiswahili"),
("ta", "தமிழ்"),
("te", "తెలుగు"),
("tg", "Тоҷикӣ"),
("th", "ไทย"),
("tk", "Türkmençe"),
("tr", "Türkçe"),
("tt", "Татарча"),
("udm", "Удмурт"),
("ug", "ئۇيغۇرچە"),
("uk", "Українська"),
("ur", "اردو"),
("uz", "Oʻzbekcha"),
("vi", "Tiếng Việt"),
("zh-hans", "简体中文"),
("zh-hant", "繁體中文"),
)
TIME_ZONE = os.getenv("TZ", "UTC")
@@ -211,6 +316,42 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
LOGIN_REDIRECT_URL = "/"
LOGIN_URL = "/login/"
LOGOUT_REDIRECT_URL = "/login/"
# Allauth settings
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend", # Keep default
"allauth.account.auth_backends.AuthenticationBackend",
]
SOCIALACCOUNT_PROVIDERS = {"openid_connect": {"APPS": []}}
if (
os.getenv("OIDC_CLIENT_ID")
and os.getenv("OIDC_CLIENT_SECRET")
and os.getenv("OIDC_SERVER_URL")
):
SOCIALACCOUNT_PROVIDERS["openid_connect"]["APPS"].append(
{
"provider_id": slugify(os.getenv("OIDC_CLIENT_NAME", "OpenID Connect")),
"name": os.getenv("OIDC_CLIENT_NAME", "OpenID Connect"),
"client_id": os.getenv("OIDC_CLIENT_ID"),
"secret": os.getenv("OIDC_CLIENT_SECRET"),
"settings": {
"server_url": os.getenv("OIDC_SERVER_URL"),
},
}
)
ACCOUNT_LOGIN_METHODS = {"email"}
ACCOUNT_SIGNUP_FIELDS = ["email*", "password1*", "password2*"]
ACCOUNT_USER_MODEL_USERNAME_FIELD = None
ACCOUNT_EMAIL_VERIFICATION = "none"
SOCIALACCOUNT_LOGIN_ON_GET = True
SOCIALACCOUNT_ONLY = True
SOCIALACCOUNT_AUTO_SIGNUP = os.getenv("OIDC_ALLOW_SIGNUP", "true").lower() == "true"
ACCOUNT_ADAPTER = "allauth.account.adapter.DefaultAccountAdapter"
SOCIALACCOUNT_ADAPTER = "allauth.socialaccount.adapter.DefaultSocialAccountAdapter"
# CRISPY FORMS
CRISPY_ALLOWED_TEMPLATE_PACKS = ["bootstrap5", "crispy_forms/pure_text"]
@@ -260,7 +401,10 @@ if DEBUG:
REST_FRAMEWORK = {
# Use Django's standard `django.contrib.auth` permissions,
# or allow read-only access for unauthenticated users.
"DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.DjangoModelPermissions"],
"DEFAULT_PERMISSION_CLASSES": [
"apps.api.permissions.NotInDemoMode",
"rest_framework.permissions.DjangoModelPermissions",
],
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 10,
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
@@ -343,6 +487,8 @@ else:
CACHALOT_UNCACHABLE_TABLES = ("django_migrations", "procrastinate_jobs")
# Procrastinate
PROCRASTINATE_ON_APP_READY = "apps.common.procrastinate.on_app_ready"
# PWA
PWA_APP_NAME = SITE_TITLE
@@ -391,5 +537,7 @@ PWA_APP_SCREENSHOTS = [
PWA_SERVICE_WORKER_PATH = BASE_DIR / "templates" / "pwa" / "serviceworker.js"
ENABLE_SOFT_DELETE = os.getenv("ENABLE_SOFT_DELETE", "false").lower() == "true"
CHECK_FOR_UPDATES = os.getenv("CHECK_FOR_UPDATES", "true").lower() == "true"
KEEP_DELETED_TRANSACTIONS_FOR = int(os.getenv("KEEP_DELETED_ENTRIES_FOR", "365"))
APP_VERSION = os.getenv("APP_VERSION", "unknown")
DEMO = os.getenv("DEMO", "false").lower() == "true"

View File

@@ -21,6 +21,8 @@ from drf_spectacular.views import (
SpectacularAPIView,
SpectacularSwaggerView,
)
from allauth.socialaccount.providers.openid_connect.views import login, callback
urlpatterns = [
path("admin/", admin.site.urls),
@@ -36,6 +38,13 @@ urlpatterns = [
SpectacularSwaggerView.as_view(url_name="schema"),
name="swagger-ui",
),
path("auth/", include("allauth.urls")), # allauth urls
# path("auth/oidc/<str:provider_id>/login/", login, name="openid_connect_login"),
# path(
# "auth/oidc/<str:provider_id>/login/callback/",
# callback,
# name="openid_connect_callback",
# ),
path("", include("apps.transactions.urls")),
path("", include("apps.common.urls")),
path("", include("apps.users.urls")),

View File

@@ -1,6 +1,14 @@
from django.contrib import admin
from apps.accounts.models import Account
from apps.accounts.models import Account, AccountGroup
from apps.common.admin import SharedObjectModelAdmin
admin.site.register(Account)
@admin.register(Account)
class AccountModelAdmin(SharedObjectModelAdmin):
pass
@admin.register(AccountGroup)
class AccountGroupModelAdmin(SharedObjectModelAdmin):
pass

View File

@@ -77,6 +77,8 @@ class AccountForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["group"].queryset = AccountGroup.objects.all()
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.form_method = "post"
@@ -151,5 +153,11 @@ class AccountBalanceForm(forms.Form):
decimal_places=self.currency_decimal_places
)
self.fields["category"].queryset = TransactionCategory.objects.filter(
active=True
)
self.fields["tags"].queryset = TransactionTag.objects.filter(active=True)
AccountBalanceFormSet = forms.formset_factory(AccountBalanceForm, extra=0)

View File

@@ -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'),
),
]

View File

@@ -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')},
),
]

View File

@@ -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'),
),
]

View File

@@ -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),
),
]

View File

@@ -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),
),
]

View File

@@ -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'},
),
]

View File

@@ -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'),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 5.2.4 on 2025-08-09 05:52
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0015_alter_account_owner_alter_account_shared_with_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='account',
name='untracked_by',
field=models.ManyToManyField(blank=True, related_name='untracked_accounts', to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -1,24 +1,32 @@
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
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
class AccountGroup(models.Model):
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True)
class AccountGroup(SharedObject):
name = models.CharField(max_length=255, verbose_name=_("Name"))
objects = SharedObjectManager()
all_objects = models.Manager() # Unfiltered manager
class Meta:
verbose_name = _("Account Group")
verbose_name_plural = _("Account Groups")
db_table = "account_groups"
unique_together = (("owner", "name"),)
ordering = ["name", "id"]
def __str__(self):
return self.name
class Account(models.Model):
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True)
class Account(SharedObject):
name = models.CharField(max_length=255, verbose_name=_("Name"))
group = models.ForeignKey(
AccountGroup,
on_delete=models.SET_NULL,
@@ -54,14 +62,28 @@ class Account(models.Model):
verbose_name=_("Archived"),
help_text=_("Archived accounts don't show up nor count towards your net worth"),
)
untracked_by = models.ManyToManyField(
settings.AUTH_USER_MODEL,
blank=True,
related_name="untracked_accounts",
)
objects = SharedObjectManager()
all_objects = models.Manager() # Unfiltered manager
class Meta:
verbose_name = _("Account")
verbose_name_plural = _("Accounts")
unique_together = (("owner", "name"),)
ordering = ["name", "id"]
def __str__(self):
return self.name
def is_untracked_by(self):
user = get_current_user()
return self.untracked_by.filter(pk=user.pk).exists()
def clean(self):
super().clean()
if self.exchange_currency == self.currency:

View File

@@ -16,11 +16,26 @@ urlpatterns = [
views.account_edit,
name="account_edit",
),
path(
"account/<int:pk>/share/",
views.account_share,
name="account_share_settings",
),
path(
"account/<int:pk>/delete/",
views.account_delete,
name="account_delete",
),
path(
"account/<int:pk>/take-ownership/",
views.account_take_ownership,
name="account_take_ownership",
),
path(
"account/<int:pk>/toggle-untracked/",
views.account_toggle_untracked,
name="account_toggle_untracked",
),
path("account-groups/", views.account_groups_index, name="account_groups_index"),
path("account-groups/list/", views.account_groups_list, name="account_groups_list"),
path("account-groups/add/", views.account_group_add, name="account_group_add"),
@@ -34,4 +49,14 @@ urlpatterns = [
views.account_group_delete,
name="account_group_delete",
),
path(
"account-groups/<int:pk>/take-ownership/",
views.account_group_take_ownership,
name="account_group_take_ownership",
),
path(
"account-groups/<int:pk>/share/",
views.account_group_share,
name="account_group_share_settings",
),
]

View File

@@ -8,6 +8,8 @@ from django.views.decorators.http import require_http_methods
from apps.accounts.forms import AccountGroupForm
from apps.accounts.models import AccountGroup
from apps.common.decorators.htmx import only_htmx
from apps.common.models import SharedObject
from apps.common.forms import SharedObjectForm
@login_required
@@ -63,6 +65,16 @@ def account_group_add(request, **kwargs):
def account_group_edit(request, pk):
account_group = get_object_or_404(AccountGroup, id=pk)
if account_group.owner and account_group.owner != request.user:
messages.error(request, _("Only the owner can edit this"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
if request.method == "POST":
form = AccountGroupForm(request.POST, instance=account_group)
if form.is_valid():
@@ -91,9 +103,15 @@ def account_group_edit(request, pk):
def account_group_delete(request, pk):
account_group = get_object_or_404(AccountGroup, id=pk)
account_group.delete()
messages.success(request, _("Account Group deleted successfully"))
if (
account_group.owner != request.user
and request.user in account_group.shared_with.all()
):
account_group.shared_with.remove(request.user)
messages.success(request, _("Item no longer shared with you"))
else:
account_group.delete()
messages.success(request, _("Account Group deleted successfully"))
return HttpResponse(
status=204,
@@ -101,3 +119,62 @@ def account_group_delete(request, pk):
"HX-Trigger": "updated, hide_offcanvas",
},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def account_group_take_ownership(request, pk):
account_group = get_object_or_404(AccountGroup, id=pk)
if not account_group.owner:
account_group.owner = request.user
account_group.visibility = SharedObject.Visibility.private
account_group.save()
messages.success(request, _("Ownership taken successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def account_group_share(request, pk):
obj = get_object_or_404(AccountGroup, id=pk)
if obj.owner and obj.owner != request.user:
messages.error(request, _("Only the owner can edit this"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
if request.method == "POST":
form = SharedObjectForm(request.POST, instance=obj, user=request.user)
if form.is_valid():
form.save()
messages.success(request, _("Configuration saved successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
else:
form = SharedObjectForm(instance=obj, user=request.user)
return render(
request,
"accounts/fragments/share.html",
{"form": form, "object": obj},
)

View File

@@ -8,6 +8,8 @@ from django.views.decorators.http import require_http_methods
from apps.accounts.forms import AccountForm
from apps.accounts.models import Account
from apps.common.decorators.htmx import only_htmx
from apps.common.models import SharedObject
from apps.common.forms import SharedObjectForm
@login_required
@@ -62,6 +64,15 @@ def account_add(request, **kwargs):
@require_http_methods(["GET", "POST"])
def account_edit(request, pk):
account = get_object_or_404(Account, id=pk)
if account.owner and account.owner != request.user:
messages.error(request, _("Only the owner can edit this"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
if request.method == "POST":
form = AccountForm(request.POST, instance=account)
@@ -85,15 +96,97 @@ 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
@login_required
@require_http_methods(["DELETE"])
def account_delete(request, pk):
account = get_object_or_404(Account, id=pk)
account.delete()
messages.success(request, _("Account deleted successfully"))
if account.owner != request.user and request.user in account.shared_with.all():
account.shared_with.remove(request.user)
messages.success(request, _("Item no longer shared with you"))
else:
account.delete()
messages.success(request, _("Account deleted successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def account_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,

View File

@@ -38,9 +38,9 @@ def account_reconciliation(request):
"prefix": account.currency.prefix,
"current_balance": get_account_balance(account),
}
for account in Account.objects.filter(is_archived=False).select_related(
"currency", "group"
)
for account in Account.objects.filter(is_archived=False)
.select_related("currency", "group")
.order_by("group", "name")
]
if request.method == "POST":

View File

View File

@@ -0,0 +1,6 @@
from rest_framework.pagination import PageNumberPagination
class CustomPageNumberPagination(PageNumberPagination):
page_size = 100
page_size_query_param = "page_size"

View File

@@ -1,8 +1,6 @@
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from django.utils.translation import gettext_lazy as _
from apps.transactions.models import (
TransactionCategory,
TransactionTag,
@@ -29,7 +27,11 @@ class TransactionCategoryField(serializers.Field):
_("Category with this ID does not exist.")
)
elif isinstance(data, str):
category, created = TransactionCategory.objects.get_or_create(name=data)
try:
category = TransactionCategory.objects.get(name=data)
except TransactionCategory.DoesNotExist:
category = TransactionCategory(name=data)
category.save()
return category
raise serializers.ValidationError(
_("Invalid category data. Provide an ID or name.")
@@ -39,7 +41,10 @@ class TransactionCategoryField(serializers.Field):
def get_schema():
return {
"type": "array",
"items": {"type": "string", "description": "TransactionTag ID or name"},
"items": {
"type": "string",
"description": "TransactionCategory ID or name",
},
}
@@ -65,7 +70,11 @@ class TransactionTagField(serializers.Field):
_("Tag with this ID does not exist.")
)
elif isinstance(item, str):
tag, created = TransactionTag.objects.get_or_create(name=item)
try:
tag = TransactionTag.objects.get(name=item)
except TransactionTag.DoesNotExist:
tag = TransactionTag(name=item)
tag.save()
else:
raise serializers.ValidationError(
_("Invalid tag data. Provide an ID or name.")
@@ -74,6 +83,13 @@ class TransactionTagField(serializers.Field):
return tags
@extend_schema_field(
{
"type": "array",
"items": {"oneOf": [{"type": "string"}, {"type": "integer"}]},
"description": "TransactionEntity ID or name. If the name doesn't exist, a new one will be created",
}
)
class TransactionEntityField(serializers.Field):
def to_representation(self, value):
return [{"id": entity.id, "name": entity.name} for entity in value.all()]
@@ -84,12 +100,16 @@ class TransactionEntityField(serializers.Field):
if isinstance(item, int):
try:
entity = TransactionEntity.objects.get(pk=item)
except TransactionTag.DoesNotExist:
except TransactionEntity.DoesNotExist:
raise serializers.ValidationError(
_("Entity with this ID does not exist.")
)
elif isinstance(item, str):
entity, created = TransactionEntity.objects.get_or_create(name=item)
try:
entity = TransactionEntity.objects.get(name=item)
except TransactionEntity.DoesNotExist:
entity = TransactionEntity(name=item)
entity.save()
else:
raise serializers.ValidationError(
_("Invalid entity data. Provide an ID or name.")

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

View File

@@ -1,3 +1,4 @@
from django.db.models import Q
from rest_framework import serializers
from rest_framework.permissions import IsAuthenticated
@@ -22,6 +23,7 @@ class AccountSerializer(serializers.ModelSerializer):
write_only=True,
allow_null=True,
)
currency = CurrencySerializer(read_only=True)
currency_id = serializers.PrimaryKeyRelatedField(
queryset=Currency.objects.all(), source="currency", write_only=True
@@ -50,6 +52,13 @@ class AccountSerializer(serializers.ModelSerializer):
"is_asset",
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
request = self.context.get("request")
if request and request.user.is_authenticated:
# Reload the queryset to get an updated version with the requesting user
self.fields["group_id"].queryset = AccountGroup.objects.all()
def create(self, validated_data):
return Account.objects.create(**validated_data)

View File

@@ -1,3 +1,5 @@
from decimal import Decimal
from django.utils.translation import gettext_lazy as _
from drf_spectacular import openapi
from drf_spectacular.types import OpenApiTypes
@@ -21,6 +23,7 @@ from apps.transactions.models import (
TransactionEntity,
RecurringTransaction,
)
from apps.common.middleware.thread_local import get_current_user
class TransactionCategorySerializer(serializers.ModelSerializer):
@@ -29,6 +32,10 @@ class TransactionCategorySerializer(serializers.ModelSerializer):
class Meta:
model = TransactionCategory
fields = "__all__"
read_only_fields = [
"id",
"owner",
]
class TransactionTagSerializer(serializers.ModelSerializer):
@@ -37,6 +44,10 @@ class TransactionTagSerializer(serializers.ModelSerializer):
class Meta:
model = TransactionTag
fields = "__all__"
read_only_fields = [
"id",
"owner",
]
class TransactionEntitySerializer(serializers.ModelSerializer):
@@ -45,12 +56,16 @@ class TransactionEntitySerializer(serializers.ModelSerializer):
class Meta:
model = TransactionEntity
fields = "__all__"
read_only_fields = [
"id",
"owner",
]
class InstallmentPlanSerializer(serializers.ModelSerializer):
category = TransactionCategoryField(required=False)
tags = TransactionTagField(required=False)
entities = TransactionEntityField(required=False)
category: str | int = TransactionCategoryField(required=False)
tags: str | int = TransactionTagField(required=False)
entities: str | int = TransactionEntityField(required=False)
permission_classes = [IsAuthenticated]
@@ -88,9 +103,9 @@ class InstallmentPlanSerializer(serializers.ModelSerializer):
class RecurringTransactionSerializer(serializers.ModelSerializer):
category = TransactionCategoryField(required=False)
tags = TransactionTagField(required=False)
entities = TransactionEntityField(required=False)
category: str | int = TransactionCategoryField(required=False)
tags: str | int = TransactionTagField(required=False)
entities: str | int = TransactionEntityField(required=False)
class Meta:
model = RecurringTransaction
@@ -123,13 +138,14 @@ class RecurringTransactionSerializer(serializers.ModelSerializer):
def update(self, instance, validated_data):
instance = super().update(instance, validated_data)
instance.update_unpaid_transactions()
instance.generate_upcoming_transactions()
return instance
class TransactionSerializer(serializers.ModelSerializer):
category = TransactionCategoryField(required=False)
tags = TransactionTagField(required=False)
entities = TransactionEntityField(required=False)
category: str | int = TransactionCategoryField(required=False)
tags: str | int = TransactionTagField(required=False)
entities: str | int = TransactionEntityField(required=False)
exchanged_amount = serializers.SerializerMethodField()
@@ -155,8 +171,16 @@ class TransactionSerializer(serializers.ModelSerializer):
"installment_plan",
"recurring_transaction",
"installment_id",
"owner",
"deleted_at",
"deleted",
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["account_id"].queryset = Account.objects.all()
def validate(self, data):
if not self.partial:
if "date" in data and "reference_date" not in data:
@@ -192,5 +216,5 @@ class TransactionSerializer(serializers.ModelSerializer):
return instance
@staticmethod
def get_exchanged_amount(obj):
def get_exchanged_amount(obj) -> Decimal:
return obj.exchanged_amount()

View File

@@ -1,4 +1,6 @@
from rest_framework import viewsets
from apps.api.custom.pagination import CustomPageNumberPagination
from apps.accounts.models import AccountGroup, Account
from apps.api.serializers import AccountGroupSerializer, AccountSerializer
@@ -6,12 +8,20 @@ from apps.api.serializers import AccountGroupSerializer, AccountSerializer
class AccountGroupViewSet(viewsets.ModelViewSet):
queryset = AccountGroup.objects.all()
serializer_class = AccountGroupSerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self):
return AccountGroup.objects.all().order_by("id")
class AccountViewSet(viewsets.ModelViewSet):
queryset = Account.objects.all()
serializer_class = AccountSerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self):
queryset = super().get_queryset()
return queryset.select_related("group", "currency", "exchange_currency")
return (
Account.objects.all()
.order_by("id")
.select_related("group", "currency", "exchange_currency")
)

View File

@@ -1,5 +1,6 @@
from rest_framework import viewsets
from apps.api.custom.pagination import CustomPageNumberPagination
from apps.api.serializers import (
TransactionSerializer,
TransactionCategorySerializer,
@@ -22,6 +23,7 @@ from apps.rules.signals import transaction_updated, transaction_created
class TransactionViewSet(viewsets.ModelViewSet):
queryset = Transaction.objects.all()
serializer_class = TransactionSerializer
pagination_class = CustomPageNumberPagination
def perform_create(self, serializer):
instance = serializer.save()
@@ -35,27 +37,50 @@ class TransactionViewSet(viewsets.ModelViewSet):
kwargs["partial"] = True
return self.update(request, *args, **kwargs)
def get_queryset(self):
return Transaction.objects.all().order_by("-id")
class TransactionCategoryViewSet(viewsets.ModelViewSet):
queryset = TransactionCategory.objects.all()
serializer_class = TransactionCategorySerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self):
return TransactionCategory.objects.all().order_by("id")
class TransactionTagViewSet(viewsets.ModelViewSet):
queryset = TransactionTag.objects.all()
serializer_class = TransactionTagSerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self):
return TransactionTag.objects.all().order_by("id")
class TransactionEntityViewSet(viewsets.ModelViewSet):
queryset = TransactionEntity.objects.all()
serializer_class = TransactionEntitySerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self):
return TransactionEntity.objects.all().order_by("id")
class InstallmentPlanViewSet(viewsets.ModelViewSet):
queryset = InstallmentPlan.objects.all()
serializer_class = InstallmentPlanSerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self):
return InstallmentPlan.objects.all().order_by("-id")
class RecurringTransactionViewSet(viewsets.ModelViewSet):
queryset = RecurringTransaction.objects.all()
serializer_class = RecurringTransactionSerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self):
return RecurringTransaction.objects.all().order_by("-id")

26
app/apps/common/admin.py Normal file
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()

View File

@@ -1,6 +1,25 @@
from django.apps import AppConfig
from django.core.cache import cache
class CommonConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.common"
def ready(self):
from django.contrib import admin
from django.contrib.sites.models import Site
from allauth.socialaccount.models import (
SocialAccount,
SocialApp,
SocialToken,
)
admin.site.unregister(Site)
admin.site.unregister(SocialAccount)
admin.site.unregister(SocialApp)
admin.site.unregister(SocialToken)
# Delete the cache for update checks to prevent false-positives when the app is restarted
# this will be recreated by the check_for_updates task
cache.delete("update_check")

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

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

View File

@@ -4,6 +4,7 @@ from django.db import transaction
from django.utils.translation import gettext_lazy as _
from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple
from apps.common.middleware.thread_local import get_current_user
class DynamicModelChoiceField(forms.ModelChoiceField):
@@ -55,19 +56,24 @@ class DynamicModelChoiceField(forms.ModelChoiceField):
if self.create_field:
try:
with transaction.atomic():
instance, _ = self.model.objects.update_or_create(
**{self.create_field: value}
)
# First try to get the object
lookup = {self.create_field: value}
try:
instance = self.model.objects.get(**lookup)
except self.model.DoesNotExist:
# Create a new instance directly
instance = self.model(**lookup)
instance.save()
self._created_instance = instance
return instance
except Exception as e:
raise ValidationError(
self.error_messages["invalid_choice"], code="invalid_choice"
)
raise ValidationError(_("Error creating new instance"))
else:
raise ValidationError(
self.error_messages["invalid_choice"], code="invalid_choice"
)
return super().clean(value)
def bound_data(self, data, initial):
@@ -90,8 +96,6 @@ class DynamicModelMultipleChoiceField(forms.ModelMultipleChoiceField):
def __init__(self, model, **kwargs):
"""
Initialize the CreateIfNotExistsModelMultipleChoiceField.
Args:
create_field (str): The name of the field to use when creating new instances.
*args: Variable length argument list.
@@ -123,33 +127,28 @@ class DynamicModelMultipleChoiceField(forms.ModelMultipleChoiceField):
"""
try:
with transaction.atomic():
instance, _ = self.model.objects.update_or_create(
**{self.create_field: value}
)
return instance
# Check if exists first without using update_or_create
lookup = {self.create_field: value}
try:
# Use base manager to bypass distinct filters
instance = self.model.objects.get(**lookup)
return instance
except self.model.DoesNotExist:
# Create a new instance directly
instance = self.model(**lookup)
instance.save()
return instance
except Exception as e:
print(e)
raise ValidationError(_("Error creating new instance"))
def clean(self, value):
"""
Clean and validate the field value.
This method checks if each selected choice exists in the database.
If a choice doesn't exist, it creates a new instance of the model.
Args:
value (list): List of selected values.
Returns:
list: A list containing all selected and newly created model instances.
Raises:
ValidationError: If there's an error during the cleaning process.
"""
if not value:
return []
string_values = set(str(v) for v in value)
# Get existing objects first
existing_objects = list(
self.queryset.filter(**{f"{self.create_field}__in": string_values})
)
@@ -157,13 +156,11 @@ class DynamicModelMultipleChoiceField(forms.ModelMultipleChoiceField):
str(getattr(obj, self.create_field)) for obj in existing_objects
)
# Create new objects for missing values
new_values = string_values - existing_values
new_objects = []
for new_value in new_values:
try:
new_objects.append(self._create_new_instance(new_value))
except ValidationError as e:
raise ValidationError(_("Error creating new instance"))
new_objects.append(self._create_new_instance(new_value))
return existing_objects + new_objects

View File

@@ -20,7 +20,15 @@ class MonthYearModelField(models.DateField):
# Set the day to 1
return date.replace(day=1).date()
except ValueError:
raise ValidationError(_("Invalid date format. Use YYYY-MM."))
try:
# Also accept YYYY-MM-DD format (for loaddata)
return (
datetime.datetime.strptime(value, "%Y-%m-%d").replace(day=1).date()
)
except ValueError:
raise ValidationError(
_("Invalid date format. Use YYYY-MM or YYYY-MM-DD.")
)
def formfield(self, **kwargs):
kwargs["widget"] = MonthYearWidget

113
app/apps/common/forms.py Normal file
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

View File

@@ -5,7 +5,12 @@ from django.utils.formats import get_format as original_get_format
def get_format(format_type=None, lang=None, use_l10n=None):
user = get_current_user()
if user and user.is_authenticated and hasattr(user, "settings"):
if (
user
and user.is_authenticated
and hasattr(user, "settings")
and use_l10n is not False
):
user_settings = user.settings
if format_type == "THOUSAND_SEPARATOR":
number_format = getattr(user_settings, "number_format", None)
@@ -13,11 +18,13 @@ def get_format(format_type=None, lang=None, use_l10n=None):
return "."
elif number_format == "CD":
return ","
elif number_format == "SD" or number_format == "SC":
return " "
elif format_type == "DECIMAL_SEPARATOR":
number_format = getattr(user_settings, "number_format", None)
if number_format == "DC":
if number_format == "DC" or number_format == "SC":
return ","
elif number_format == "CD":
elif number_format == "CD" or number_format == "SD":
return "."
elif format_type == "SHORT_DATE_FORMAT":
date_format = getattr(user_settings, "date_format", None)

View File

View File

@@ -0,0 +1,137 @@
import os
from django.core.management.base import BaseCommand, CommandError
from django.contrib.auth import get_user_model
from django.conf import settings
from django.db import IntegrityError
# Get the custom User model if defined, otherwise the default User model
User = get_user_model()
class Command(BaseCommand):
help = (
"Creates a superuser from environment variables (ADMIN_EMAIL, ADMIN_PASSWORD) "
"and optionally creates a demo user (demo@demo.com) if settings.DEMO is True."
)
def handle(self, *args, **options):
self.stdout.write("Starting user setup...")
# --- Create Superuser ---
admin_email = os.environ.get("ADMIN_EMAIL")
admin_password = os.environ.get("ADMIN_PASSWORD")
if admin_email and admin_password:
self.stdout.write(f"Attempting to create superuser: {admin_email}")
# Use email as username for simplicity, requires USERNAME_FIELD='email'
# or adapt if your USERNAME_FIELD is different.
# If USERNAME_FIELD is 'username', you might need ADMIN_USERNAME env var.
username_field = User.USERNAME_FIELD # Get the actual username field name
# Check if the user already exists by email or username
user_exists_kwargs = {"email": admin_email}
if username_field != "email":
# Assume username should also be the email if not explicitly provided
user_exists_kwargs[username_field] = admin_email
if User.objects.filter(**user_exists_kwargs).exists():
self.stdout.write(
self.style.WARNING(
f"Superuser with email '{admin_email}' (or corresponding username) already exists. Skipping creation."
)
)
else:
try:
create_kwargs = {
username_field: admin_email, # Use email as username by default
"email": admin_email,
"password": admin_password,
}
User.objects.create_superuser(**create_kwargs)
self.stdout.write(
self.style.SUCCESS(
f"Superuser '{admin_email}' created successfully."
)
)
except IntegrityError as e:
self.stdout.write(
self.style.ERROR(
f"Failed to create superuser '{admin_email}'. IntegrityError: {e}"
)
)
except Exception as e:
self.stdout.write(
self.style.ERROR(
f"An unexpected error occurred creating superuser '{admin_email}': {e}"
)
)
else:
self.stdout.write(
self.style.NOTICE(
"ADMIN_EMAIL or ADMIN_PASSWORD environment variables not set. Skipping superuser creation."
)
)
self.stdout.write("---") # Separator
# --- Create Demo User ---
# Use getattr to safely check for the DEMO setting, default to False if not present
create_demo_user = getattr(settings, "DEMO", False)
if create_demo_user:
demo_email = "demo@demo.com"
demo_password = (
"wygiwyhdemo" # Consider making this an env var too for security
)
demo_username = demo_email # Using email as username for consistency
self.stdout.write(
f"DEMO setting is True. Attempting to create demo user: {demo_email}"
)
username_field = User.USERNAME_FIELD # Get the actual username field name
# Check if the user already exists by email or username
user_exists_kwargs = {"email": demo_email}
if username_field != "email":
user_exists_kwargs[username_field] = demo_username
if User.objects.filter(**user_exists_kwargs).exists():
self.stdout.write(
self.style.WARNING(
f"Demo user with email '{demo_email}' (or corresponding username) already exists. Skipping creation."
)
)
else:
try:
create_kwargs = {
username_field: demo_username,
"email": demo_email,
"password": demo_password,
}
User.objects.create_user(**create_kwargs)
self.stdout.write(
self.style.SUCCESS(
f"Demo user '{demo_email}' created successfully."
)
)
except IntegrityError as e:
self.stdout.write(
self.style.ERROR(
f"Failed to create demo user '{demo_email}'. IntegrityError: {e}"
)
)
except Exception as e:
self.stdout.write(
self.style.ERROR(
f"An unexpected error occurred creating demo user '{demo_email}': {e}"
)
)
else:
self.stdout.write(
self.style.NOTICE(
"DEMO setting is not True (or not set). Skipping demo user creation."
)
)
self.stdout.write(self.style.SUCCESS("User setup command finished."))

View File

@@ -56,6 +56,16 @@ def get_current_user():
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."""

103
app/apps/common/models.py Normal file
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)

View File

@@ -0,0 +1,6 @@
import procrastinate
def on_app_ready(app: procrastinate.App):
"""This function is ran upon procrastinate initialization."""
...

View File

@@ -1,11 +1,17 @@
import logging
from packaging.version import parse as parse_version, InvalidVersion
from asgiref.sync import sync_to_async
from django.conf import settings
from django.core import management
from django.db import DEFAULT_DB_ALIAS
from django.core.cache import cache
from procrastinate import builtin_tasks
from procrastinate.contrib.django import app
import requests
logger = logging.getLogger(__name__)
@@ -40,3 +46,86 @@ async def remove_expired_sessions(timestamp=None):
"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}")

View File

@@ -0,0 +1,17 @@
# core/templatetags/update_tags.py
from django import template
from django.core.cache import cache
register = template.Library()
@register.simple_tag
def get_update_check():
"""
Retrieves the update status dictionary from the cache.
Returns a default dictionary if nothing is found.
"""
return cache.get("update_check") or {
"update_available": False,
"latest_version": "N/A",
}

View File

@@ -15,10 +15,11 @@ from cachalot.api import invalidate
from apps.common.decorators.htmx import only_htmx
from apps.transactions.models import Transaction
from apps.common.decorators.user import htmx_login_required
@only_htmx
@login_required
@htmx_login_required
@require_http_methods(["GET"])
def toasts(request):
return render(request, "common/fragments/toasts.html")

View File

@@ -37,7 +37,9 @@ class AirDatePickerInput(widgets.DateInput):
def _get_current_language():
"""Get current language code in format compatible with AirDatepicker"""
lang_code = translation.get_language()
# AirDatepicker uses simple language codes
# AirDatepicker uses simple language codes, except for pt-br
if lang_code.lower() == "pt-br":
return "pt-BR"
return lang_code.split("-")[0]
def _get_format(self):

View File

@@ -35,8 +35,8 @@ class ArbitraryDecimalDisplayNumberInput(forms.TextInput):
self.attrs.update(
{
"x-data": "",
"x-mask:dynamic": f"$money($input, '{get_format('DECIMAL_SEPARATOR')}', "
f"'{get_format('THOUSAND_SEPARATOR')}', '30')",
"x-mask:dynamic": f"$money($input, '{get_format('DECIMAL_SEPARATOR')}', '{get_format('THOUSAND_SEPARATOR')}', '30')",
"x-on:keyup": "$el.dispatchEvent(new Event('input'))",
}
)

View File

@@ -6,8 +6,10 @@ from django.utils import timezone
from apps.currencies.exchange_rates.providers import (
SynthFinanceProvider,
SynthFinanceStockProvider,
CoinGeckoFreeProvider,
CoinGeckoProProvider,
TransitiveRateProvider,
)
from apps.currencies.models import ExchangeRateService, ExchangeRate, Currency
@@ -17,8 +19,10 @@ logger = logging.getLogger(__name__)
# Map service types to provider classes
PROVIDER_MAPPING = {
"synth_finance": SynthFinanceProvider,
"synth_finance_stock": SynthFinanceStockProvider,
"coingecko_free": CoinGeckoFreeProvider,
"coingecko_pro": CoinGeckoProProvider,
"transitive": TransitiveRateProvider,
}
@@ -199,21 +203,63 @@ class ExchangeRateFetcher:
if provider.rates_inverted:
# If rates are inverted, we need to swap currencies
ExchangeRate.objects.create(
from_currency=to_currency,
to_currency=from_currency,
rate=rate,
date=timezone.now(),
)
if service.singleton:
# Try to get the last automatically created exchange rate
exchange_rate = (
ExchangeRate.objects.filter(
automatic=True,
from_currency=to_currency,
to_currency=from_currency,
)
.order_by("-date")
.first()
)
else:
exchange_rate = None
if not exchange_rate:
ExchangeRate.objects.create(
automatic=True,
from_currency=to_currency,
to_currency=from_currency,
rate=rate,
date=timezone.now(),
)
else:
exchange_rate.rate = rate
exchange_rate.date = timezone.now()
exchange_rate.save()
processed_pairs.add((to_currency.id, from_currency.id))
else:
# If rates are not inverted, we can use them as is
ExchangeRate.objects.create(
from_currency=from_currency,
to_currency=to_currency,
rate=rate,
date=timezone.now(),
)
if service.singleton:
# Try to get the last automatically created exchange rate
exchange_rate = (
ExchangeRate.objects.filter(
automatic=True,
from_currency=from_currency,
to_currency=to_currency,
)
.order_by("-date")
.first()
)
else:
exchange_rate = None
if not exchange_rate:
ExchangeRate.objects.create(
automatic=True,
from_currency=from_currency,
to_currency=to_currency,
rate=rate,
date=timezone.now(),
)
else:
exchange_rate.rate = rate
exchange_rate.date = timezone.now()
exchange_rate.save()
processed_pairs.add((from_currency.id, to_currency.id))
service.last_fetch = timezone.now()

View File

@@ -3,11 +3,11 @@ import time
import requests
from decimal import Decimal
from typing import Tuple, List
from typing import Tuple, List, Optional, Dict
from django.db.models import QuerySet
from apps.currencies.models import Currency
from apps.currencies.models import Currency, ExchangeRate
from apps.currencies.exchange_rates.base import ExchangeRateProvider
logger = logging.getLogger(__name__)
@@ -150,3 +150,159 @@ class CoinGeckoProProvider(CoinGeckoFreeProvider):
super().__init__(api_key)
self.session = requests.Session()
self.session.headers.update({"x-cg-pro-api-key": api_key})
class SynthFinanceStockProvider(ExchangeRateProvider):
"""Implementation for Synth Finance API Real-Time Prices endpoint (synthfinance.com)"""
BASE_URL = "https://api.synthfinance.com/tickers"
rates_inverted = True
def __init__(self, api_key: str = None):
super().__init__(api_key)
self.session = requests.Session()
self.session.headers.update(
{"Authorization": f"Bearer {self.api_key}", "accept": "application/json"}
)
def get_rates(
self, target_currencies: QuerySet, exchange_currencies: set
) -> List[Tuple[Currency, Currency, Decimal]]:
results = []
for currency in target_currencies:
if currency.exchange_currency not in exchange_currencies:
continue
try:
# Same currency has rate of 1
if currency.code == currency.exchange_currency.code:
rate = Decimal("1")
results.append((currency.exchange_currency, currency, rate))
continue
# Fetch real-time price for this ticker
response = self.session.get(
f"{self.BASE_URL}/{currency.code}/real-time"
)
response.raise_for_status()
data = response.json()
# Use fair market value as the rate
rate = Decimal(data["data"]["fair_market_value"])
results.append((currency.exchange_currency, currency, rate))
# Log API usage
credits_used = data["meta"]["credits_used"]
credits_remaining = data["meta"]["credits_remaining"]
logger.info(
f"Synth Finance API call for {currency.code}: {credits_used} credits used, {credits_remaining} remaining"
)
except requests.RequestException as e:
logger.error(
f"Error fetching rate from Synth Finance API for ticker {currency.code}: {e}",
exc_info=True,
)
except KeyError as e:
logger.error(
f"Unexpected response structure from Synth Finance API for ticker {currency.code}: {e}",
exc_info=True,
)
except Exception as e:
logger.error(
f"Unexpected error processing Synth Finance data for ticker {currency.code}: {e}",
exc_info=True,
)
return results
class TransitiveRateProvider(ExchangeRateProvider):
"""Calculates exchange rates through paths of existing rates"""
rates_inverted = True
def __init__(self, api_key: str = None):
super().__init__(api_key) # API key not needed but maintaining interface
@classmethod
def requires_api_key(cls) -> bool:
return False
def get_rates(
self, target_currencies: QuerySet, exchange_currencies: set
) -> List[Tuple[Currency, Currency, Decimal]]:
results = []
# Get recent rates for building the graph
recent_rates = ExchangeRate.objects.all()
# Build currency graph
currency_graph = self._build_currency_graph(recent_rates)
for target in target_currencies:
if (
not target.exchange_currency
or target.exchange_currency not in exchange_currencies
):
continue
# Find path and calculate rate
from_id = target.exchange_currency.id
to_id = target.id
path, rate = self._find_conversion_path(currency_graph, from_id, to_id)
if path and rate:
path_codes = [Currency.objects.get(id=cid).code for cid in path]
logger.info(
f"Found conversion path: {' -> '.join(path_codes)}, rate: {rate}"
)
results.append((target.exchange_currency, target, rate))
else:
logger.debug(
f"No conversion path found for {target.exchange_currency.code}->{target.code}"
)
return results
@staticmethod
def _build_currency_graph(rates) -> Dict[int, Dict[int, Decimal]]:
"""Build a graph representation of currency relationships"""
graph = {}
for rate in rates:
# Add both directions to make the graph bidirectional
if rate.from_currency_id not in graph:
graph[rate.from_currency_id] = {}
graph[rate.from_currency_id][rate.to_currency_id] = rate.rate
if rate.to_currency_id not in graph:
graph[rate.to_currency_id] = {}
graph[rate.to_currency_id][rate.from_currency_id] = Decimal("1") / rate.rate
return graph
@staticmethod
def _find_conversion_path(
graph, from_id, to_id
) -> Tuple[Optional[list], Optional[Decimal]]:
"""Find the shortest path between currencies using breadth-first search"""
if from_id not in graph or to_id not in graph:
return None, None
queue = [(from_id, [from_id], Decimal("1"))]
visited = {from_id}
while queue:
current, path, current_rate = queue.pop(0)
if current == to_id:
return path, current_rate
for neighbor, rate in graph.get(current, {}).items():
if neighbor not in visited:
visited.add(neighbor)
queue.append((neighbor, path + [neighbor], current_rate * rate))
return None, None

View File

@@ -114,6 +114,7 @@ class ExchangeRateServiceForm(forms.ModelForm):
"fetch_interval",
"target_currencies",
"target_accounts",
"singleton",
]
def __init__(self, *args, **kwargs):
@@ -126,6 +127,7 @@ class ExchangeRateServiceForm(forms.ModelForm):
"name",
"service_type",
Switch("is_active"),
Switch("singleton"),
"api_key",
Row(
Column("interval_type", css_class="form-group col-md-6"),

View File

@@ -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'),
),
]

View File

@@ -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'),
),
]

View File

@@ -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'},
),
]

View File

@@ -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'),
),
]

View File

@@ -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'),
),
]

View File

@@ -38,6 +38,7 @@ class Currency(models.Model):
class Meta:
verbose_name = _("Currency")
verbose_name_plural = _("Currencies")
ordering = ["name", "id"]
def clean(self):
super().clean()
@@ -69,6 +70,8 @@ class ExchangeRate(models.Model):
)
date = models.DateTimeField(verbose_name=_("Date and Time"))
automatic = models.BooleanField(verbose_name=_("Auto"), default=False)
class Meta:
verbose_name = _("Exchange Rate")
verbose_name_plural = _("Exchange Rates")
@@ -92,8 +95,10 @@ class ExchangeRateService(models.Model):
class ServiceType(models.TextChoices):
SYNTH_FINANCE = "synth_finance", "Synth Finance"
SYNTH_FINANCE_STOCK = "synth_finance_stock", "Synth Finance Stock"
COINGECKO_FREE = "coingecko_free", "CoinGecko (Demo/Free)"
COINGECKO_PRO = "coingecko_pro", "CoinGecko (Pro)"
TRANSITIVE = "transitive", "Transitive (Calculated from Existing Rates)"
class IntervalType(models.TextChoices):
ON = "on", _("On")
@@ -145,6 +150,14 @@ class ExchangeRateService(models.Model):
blank=True,
)
singleton = models.BooleanField(
verbose_name=_("Single exchange rate"),
default=False,
help_text=_(
"Create one exchange rate and keep updating it. Avoids database clutter."
),
)
class Meta:
verbose_name = _("Exchange Rate Service")
verbose_name_plural = _("Exchange Rate Services")
@@ -204,11 +217,11 @@ class ExchangeRateService(models.Model):
}
)
hours = int(self.fetch_interval)
if hours < 0 or hours > 23:
if hours < 1 or hours > 24:
raise ValidationError(
{
"fetch_interval": _(
"'Every X hours' interval must be between 0 and 23."
"'Every X hours' interval must be between 1 and 24."
)
}
)

View File

@@ -11,9 +11,11 @@ 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(
@@ -24,6 +26,7 @@ def exchange_rates_services_index(request):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET"])
def exchange_rates_services_list(request):
services = ExchangeRateService.objects.all()
@@ -37,6 +40,7 @@ def exchange_rates_services_list(request):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def exchange_rate_service_add(request):
if request.method == "POST":
@@ -63,6 +67,7 @@ def exchange_rate_service_add(request):
@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)
@@ -91,6 +96,7 @@ def exchange_rate_service_edit(request, pk):
@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)
@@ -109,6 +115,7 @@ def exchange_rate_service_delete(request, pk):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET"])
def exchange_rate_service_force_fetch(request):
manual_fetch_exchange_rates.defer()

View File

@@ -1,7 +1,13 @@
from django.contrib import admin
from apps.dca.models import DCAStrategy, DCAEntry
from apps.common.admin import SharedObjectModelAdmin
# Register your models here.
admin.site.register(DCAStrategy)
admin.site.register(DCAEntry)
@admin.register(DCAStrategy)
class TransactionEntityModelAdmin(SharedObjectModelAdmin):
def get_queryset(self, request):
return DCAStrategy.all_objects.all()

View File

@@ -168,7 +168,7 @@ class DCAEntryForm(forms.ModelForm):
Row(
Column(
"from_account",
css_class="form-group col-md-6 mb-0",
css_class="form-group",
),
css_class="form-row",
),
@@ -190,7 +190,7 @@ class DCAEntryForm(forms.ModelForm):
Row(
Column(
"to_account",
css_class="form-group col-md-6 mb-0",
css_class="form-group",
),
css_class="form-row",
),
@@ -266,6 +266,24 @@ class DCAEntryForm(forms.ModelForm):
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()

View File

@@ -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),
),
]

View File

@@ -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'),
),
]

View File

@@ -1,16 +1,15 @@
from datetime import timedelta
from decimal import Decimal
from statistics import mean, stdev
from django.db import models
from django.template.defaultfilters import date
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from apps.common.models import SharedObject, SharedObjectManager
from apps.currencies.utils.convert import convert, get_exchange_rate
class DCAStrategy(models.Model):
class DCAStrategy(SharedObject):
name = models.CharField(max_length=255, verbose_name=_("Name"))
target_currency = models.ForeignKey(
"currencies.Currency",
@@ -28,6 +27,9 @@ class DCAStrategy(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
objects = SharedObjectManager()
all_objects = models.Manager() # Unfiltered manager
class Meta:
verbose_name = _("DCA Strategy")
verbose_name_plural = _("DCA Strategies")

View File

@@ -12,6 +12,16 @@ urlpatterns = [
views.strategy_delete,
name="dca_strategy_delete",
),
path(
"dca/<int:strategy_id>/take-ownership/",
views.strategy_take_ownership,
name="dca_strategy_take_ownership",
),
path(
"dca/<int:pk>/share/",
views.strategy_share,
name="dca_strategy_share_settings",
),
path(
"dca/<int:strategy_id>/",
views.strategy_detail_index,

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.dca.forms import DCAEntryForm, DCAStrategyForm
from apps.dca.models import DCAStrategy, DCAEntry
from apps.common.models import SharedObject
from apps.common.forms import SharedObjectForm
@login_required
@@ -57,6 +59,16 @@ def strategy_add(request):
def strategy_edit(request, strategy_id):
dca_strategy = get_object_or_404(DCAStrategy, id=strategy_id)
if dca_strategy.owner and dca_strategy.owner != request.user:
messages.error(request, _("Only the owner can edit this"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
if request.method == "POST":
form = DCAStrategyForm(request.POST, instance=dca_strategy)
if form.is_valid():
@@ -85,9 +97,15 @@ def strategy_edit(request, strategy_id):
def strategy_delete(request, strategy_id):
dca_strategy = get_object_or_404(DCAStrategy, id=strategy_id)
dca_strategy.delete()
messages.success(request, _("DCA strategy deleted successfully"))
if (
dca_strategy.owner != request.user
and request.user in dca_strategy.shared_with.all()
):
dca_strategy.shared_with.remove(request.user)
messages.success(request, _("Item no longer shared with you"))
else:
dca_strategy.delete()
messages.success(request, _("DCA strategy deleted successfully"))
return HttpResponse(
status=204,
@@ -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
def strategy_detail_index(request, strategy_id):
strategy = get_object_or_404(DCAStrategy, id=strategy_id)

View File

@@ -8,6 +8,12 @@ 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(),
@@ -94,6 +100,7 @@ class ExportForm(forms.Form):
self.helper.form_tag = False
self.helper.form_method = "post"
self.helper.layout = Layout(
"users",
"accounts",
"currencies",
"transactions",
@@ -121,6 +128,7 @@ class RestoreForm(forms.Form):
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"))
@@ -155,6 +163,7 @@ class RestoreForm(forms.Form):
self.helper.layout = Layout(
"zip_file",
HTML("<hr />"),
"users",
"accounts",
"currencies",
"transactions",

View File

@@ -24,3 +24,6 @@ class AccountResource(resources.ModelResource):
class Meta:
model = Account
def get_queryset(self):
return Account.all_objects.all()

View File

@@ -55,23 +55,32 @@ class TransactionResource(resources.ModelResource):
model = Transaction
def get_queryset(self):
return Transaction.all_objects.all()
return Transaction.userless_all_objects.all()
class TransactionTagResource(resources.ModelResource):
class Meta:
model = TransactionTag
def get_queryset(self):
return TransactionTag.all_objects.all()
class TransactionEntityResource(resources.ModelResource):
class Meta:
model = TransactionEntity
def get_queryset(self):
return TransactionEntity.all_objects.all()
class TransactionCategoyResource(resources.ModelResource):
class Meta:
model = TransactionCategory
def get_queryset(self):
return TransactionCategory.all_objects.all()
class RecurringTransactionResource(resources.ModelResource):
account = fields.Field(
@@ -107,6 +116,9 @@ class RecurringTransactionResource(resources.ModelResource):
class Meta:
model = RecurringTransaction
def get_queryset(self):
return RecurringTransaction.all_objects.all()
class InstallmentPlanResource(resources.ModelResource):
account = fields.Field(
@@ -141,3 +153,6 @@ class InstallmentPlanResource(resources.ModelResource):
class Meta:
model = InstallmentPlan
def get_queryset(self):
return InstallmentPlan.all_objects.all()

View File

@@ -0,0 +1,161 @@
from import_export import resources, fields
from django.contrib.auth import get_user_model
from django.conf import settings
from apps.users.models import UserSettings
User = get_user_model()
class UserResource(resources.ModelResource):
# User fields
email = fields.Field(attribute="email", column_name="Email")
# UserSettings fields - for export only
hide_amounts = fields.Field(
attribute="settings__hide_amounts", column_name="Hide Amounts", readonly=True
)
mute_sounds = fields.Field(
attribute="settings__mute_sounds", column_name="Mute Sounds", readonly=True
)
date_format = fields.Field(
attribute="settings__date_format", column_name="Date Format", readonly=True
)
datetime_format = fields.Field(
attribute="settings__datetime_format",
column_name="Datetime Format",
readonly=True,
)
number_format = fields.Field(
attribute="settings__number_format", column_name="Number Format", readonly=True
)
language = fields.Field(
attribute="settings__language", column_name="Language", readonly=True
)
timezone = fields.Field(
attribute="settings__timezone", column_name="Timezone", readonly=True
)
start_page = fields.Field(
attribute="settings__start_page", column_name="Start Page", readonly=True
)
# Human-readable fields for choice values
start_page_display = fields.Field(column_name="Start Page Display", readonly=True)
language_display = fields.Field(column_name="Language Display", readonly=True)
timezone_display = fields.Field(column_name="Timezone Display", readonly=True)
@staticmethod
def dehydrate_start_page_display(user):
if hasattr(user, "settings"):
return dict(UserSettings.StartPage.choices).get(
user.settings.start_page, ""
)
return ""
@staticmethod
def dehydrate_language_display(user):
if hasattr(user, "settings"):
languages = dict([("auto", "Auto")] + list(settings.LANGUAGES))
return languages.get(user.settings.language, user.settings.language)
return ""
@staticmethod
def dehydrate_timezone_display(user):
if hasattr(user, "settings"):
if user.settings.timezone == "auto":
return "Auto"
return user.settings.timezone
return ""
def after_init_instance(self, instance, new, row, **kwargs):
"""
Store settings data on the instance to be used after save
"""
# Process boolean fields properly
hide_amounts = row.get("Hide Amounts", "").lower() == "true"
mute_sounds = row.get("Mute Sounds", "").lower() == "true"
# Store settings data on the instance for later use
instance._settings_data = {
"hide_amounts": hide_amounts,
"mute_sounds": mute_sounds,
"date_format": row.get("Date Format", "SHORT_DATE_FORMAT"),
"datetime_format": row.get("Datetime Format", "SHORT_DATETIME_FORMAT"),
"number_format": row.get("Number Format", "AA"),
"language": row.get("Language", "auto"),
"timezone": row.get("Timezone", "auto"),
"start_page": row.get("Start Page", UserSettings.StartPage.MONTHLY),
}
return instance
def after_save_instance(self, instance, row, **kwargs):
"""
Create or update UserSettings after User is saved
"""
if not hasattr(instance, "_settings_data"):
return
settings_data = instance._settings_data
# Create or update UserSettings
try:
user_settings = UserSettings.objects.get(user=instance)
# Update existing settings
for key, value in settings_data.items():
setattr(user_settings, key, value)
user_settings.save()
except UserSettings.DoesNotExist:
# Create new settings
UserSettings.objects.create(user=instance, **settings_data)
def get_queryset(self):
"""
Ensure settings are prefetched when exporting users
"""
return super().get_queryset().select_related("settings")
class Meta:
model = User
import_id_fields = ["id"]
fields = (
"id",
"email",
"first_name",
"last_name",
"is_active",
"date_joined",
"password",
"hide_amounts",
"mute_sounds",
"date_format",
"datetime_format",
"number_format",
"language",
"language_display",
"timezone",
"timezone_display",
"start_page",
"start_page_display",
)
export_order = (
"id",
"email",
"first_name",
"last_name",
"is_active",
"date_joined",
"password",
"hide_amounts",
"mute_sounds",
"date_format",
"datetime_format",
"number_format",
"language",
"language_display",
"timezone",
"timezone_display",
"start_page",
"start_page_display",
)

View File

@@ -1,9 +1,9 @@
import logging
import zipfile
from io import BytesIO, TextIOWrapper
from io import BytesIO
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.auth.decorators import login_required, user_passes_test
from django.db import transaction
from django.http import HttpResponse
from django.shortcuts import render
@@ -12,26 +12,14 @@ from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_http_methods
from tablib import Dataset
from apps.common.decorators.htmx import only_htmx
from apps.export_app.forms import ExportForm, RestoreForm
from apps.export_app.resources.accounts import AccountResource
from apps.export_app.resources.transactions import (
TransactionResource,
TransactionTagResource,
TransactionEntityResource,
TransactionCategoyResource,
InstallmentPlanResource,
RecurringTransactionResource,
)
from apps.export_app.resources.currencies import (
CurrencyResource,
ExchangeRateResource,
ExchangeRateServiceResource,
)
from apps.export_app.resources.rules import (
TransactionRuleResource,
TransactionRuleActionResource,
UpdateOrCreateTransactionRuleResource,
)
from apps.export_app.resources.dca import (
DCAStrategyResource,
DCAEntryResource,
@@ -39,18 +27,36 @@ from apps.export_app.resources.dca import (
from apps.export_app.resources.import_app import (
ImportProfileResource,
)
from apps.common.decorators.htmx import only_htmx
from apps.export_app.resources.rules import (
TransactionRuleResource,
TransactionRuleActionResource,
UpdateOrCreateTransactionRuleResource,
)
from apps.export_app.resources.transactions import (
TransactionResource,
TransactionTagResource,
TransactionEntityResource,
TransactionCategoyResource,
InstallmentPlanResource,
RecurringTransactionResource,
)
from apps.export_app.resources.users import UserResource
from apps.common.decorators.demo import disabled_on_demo
logger = logging.getLogger()
@login_required
@disabled_on_demo
@user_passes_test(lambda u: u.is_superuser)
@require_http_methods(["GET"])
def export_index(request):
return render(request, "export_app/pages/index.html")
@login_required
@disabled_on_demo
@user_passes_test(lambda u: u.is_superuser)
@require_http_methods(["GET", "POST"])
def export_form(request):
timestamp = timezone.localtime(timezone.now()).strftime("%Y-%m-%dT%H-%M-%S")
@@ -60,6 +66,7 @@ def export_form(request):
if form.is_valid():
zip_buffer = BytesIO()
export_users = form.cleaned_data.get("users", False)
export_accounts = form.cleaned_data.get("accounts", False)
export_currencies = form.cleaned_data.get("currencies", False)
export_transactions = form.cleaned_data.get("transactions", False)
@@ -80,6 +87,8 @@ def export_form(request):
export_import_profiles = form.cleaned_data.get("import_profiles", False)
exports = []
if export_users:
exports.append((UserResource().export(), "users"))
if export_accounts:
exports.append((AccountResource().export(), "accounts"))
if export_currencies:
@@ -176,6 +185,8 @@ def export_form(request):
@only_htmx
@login_required
@disabled_on_demo
@user_passes_test(lambda u: u.is_superuser)
@require_http_methods(["GET", "POST"])
def import_form(request):
if request.method == "POST":
@@ -209,6 +220,7 @@ def import_form(request):
def process_imports(request, cleaned_data):
# Define import order to handle dependencies
import_order = [
("users", UserResource),
("currencies", CurrencyResource),
(
"currencies",

View File

@@ -268,14 +268,17 @@ class ImportService:
category = TransactionCategory.objects.get(id=category_name)
else: # name
if getattr(category_mapping, "create", False):
category, _ = TransactionCategory.objects.get_or_create(
name=category_name
)
try:
category = TransactionCategory.objects.get(
name=category_name
)
except TransactionCategory.DoesNotExist:
category = TransactionCategory(name=category_name)
category.save()
else:
category = TransactionCategory.objects.filter(
name=category_name
).first()
if category:
data["category"] = category
self.import_run.categories.add(category)
@@ -325,9 +328,13 @@ class ImportService:
tag = TransactionTag.objects.filter(id=tag_name).first()
else: # name
if getattr(tags_mapping, "create", False):
tag, _ = TransactionTag.objects.get_or_create(
name=tag_name.strip()
)
try:
tag = TransactionTag.objects.get(
name=tag_name.strip()
)
except TransactionTag.DoesNotExist:
tag = TransactionTag(name=tag_name.strip())
tag.save()
else:
tag = TransactionTag.objects.filter(
name=tag_name.strip()
@@ -361,9 +368,13 @@ class ImportService:
).first()
else: # name
if getattr(entities_mapping, "create", False):
entity, _ = TransactionEntity.objects.get_or_create(
name=entity_name.strip()
)
try:
entity = TransactionEntity.objects.get(
name=entity_name.strip()
)
except TransactionEntity.DoesNotExist:
entity = TransactionEntity(name=entity_name.strip())
entity.save()
else:
entity = TransactionEntity.objects.filter(
name=entity_name.strip()
@@ -394,7 +405,11 @@ class ImportService:
def _create_account(self, data: Dict[str, Any]) -> Account:
if "group" in data:
group_name = data.pop("group")
group, _ = AccountGroup.objects.get_or_create(name=group_name)
try:
group = AccountGroup.objects.get(name=group_name)
except AccountGroup.DoesNotExist:
group = AccountGroup(name=group_name)
group.save()
data["group"] = group
# Handle currency references

View File

@@ -1,7 +1,9 @@
import logging
from django.contrib.auth import get_user_model
from procrastinate.contrib.django import app
from apps.common.middleware.thread_local import write_current_user, delete_current_user
from apps.import_app.models import ImportRun
from apps.import_app.services import ImportServiceV1
@@ -9,10 +11,15 @@ logger = logging.getLogger(__name__)
@app.task(name="process_import")
def process_import(import_run_id: int, file_path: str):
def process_import(import_run_id: int, file_path: str, user_id: int):
user = get_user_model().objects.get(id=user_id)
write_current_user(user)
try:
import_run = ImportRun.objects.get(id=import_run_id)
import_service = ImportServiceV1(import_run)
import_service.process_file(file_path)
delete_current_user()
except ImportRun.DoesNotExist:
delete_current_user()
raise ValueError(f"ImportRun with id {import_run_id} not found")

View File

@@ -2,7 +2,6 @@ from django.urls import path
import apps.import_app.views as views
urlpatterns = [
path("import/", views.import_view, name="import"),
path(
"import/presets/",
views.import_presets_list,

View File

@@ -13,22 +13,11 @@ from apps.import_app.forms import ImportRunFileUploadForm, ImportProfileForm
from apps.import_app.models import ImportRun, ImportProfile
from apps.import_app.services import PresetService
from apps.import_app.tasks import process_import
def import_view(request):
import_profile = ImportProfile.objects.get(id=2)
shutil.copyfile(
"/usr/src/app/apps/import_app/teste2.csv", "/usr/src/app/temp/teste2.csv"
)
ir = ImportRun.objects.create(profile=import_profile, file_name="teste.csv")
process_import.defer(
import_run_id=ir.id,
file_path="/usr/src/app/temp/teste2.csv",
)
return HttpResponse("Hello, world. You're at the polls page.")
from apps.common.decorators.demo import disabled_on_demo
@login_required
@disabled_on_demo
@require_http_methods(["GET"])
def import_presets_list(request):
presets = PresetService.get_all_presets()
@@ -40,6 +29,7 @@ def import_presets_list(request):
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def import_profile_index(request):
return render(
@@ -50,6 +40,7 @@ def import_profile_index(request):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def import_profile_list(request):
profiles = ImportProfile.objects.all()
@@ -63,6 +54,7 @@ def import_profile_list(request):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def import_profile_add(request):
message = request.POST.get("message", None)
@@ -98,6 +90,7 @@ def import_profile_add(request):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def import_profile_edit(request, profile_id):
profile = get_object_or_404(ImportProfile, id=profile_id)
@@ -127,6 +120,7 @@ def import_profile_edit(request, profile_id):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["DELETE"])
def import_profile_delete(request, profile_id):
profile = ImportProfile.objects.get(id=profile_id)
@@ -145,6 +139,7 @@ def import_profile_delete(request, profile_id):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def import_runs_list(request, profile_id):
profile = ImportProfile.objects.get(id=profile_id)
@@ -160,6 +155,7 @@ def import_runs_list(request, profile_id):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def import_run_log(request, profile_id, run_id):
run = ImportRun.objects.get(profile__id=profile_id, id=run_id)
@@ -173,6 +169,7 @@ def import_run_log(request, profile_id, run_id):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def import_run_add(request, profile_id):
profile = ImportProfile.objects.get(id=profile_id)
@@ -189,7 +186,11 @@ def import_run_add(request, profile_id):
import_run = ImportRun.objects.create(profile=profile, file_name=filename)
# Defer the procrastinate task
process_import.defer(import_run_id=import_run.id, file_path=file_path)
process_import.defer(
import_run_id=import_run.id,
file_path=file_path,
user_id=request.user.id,
)
messages.success(request, _("Import Run queued successfully"))
@@ -211,6 +212,7 @@ def import_run_add(request, profile_id):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["DELETE"])
def import_run_delete(request, profile_id, run_id):
run = ImportRun.objects.get(profile__id=profile_id, id=run_id)

View File

@@ -117,13 +117,15 @@ class CategoryForm(forms.Form):
required=False,
label=_("Category"),
empty_label=_("Uncategorized"),
queryset=TransactionCategory.objects.filter(active=True),
queryset=TransactionCategory.objects.all(),
widget=TomSelect(clear_button=True),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["category"].queryset = TransactionCategory.objects.all()
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.disable_csrf = True

View File

@@ -29,4 +29,24 @@ urlpatterns = [
views.category_sum_by_currency,
name="category_sum_by_currency",
),
path(
"insights/category-overview/",
views.category_overview,
name="category_overview",
),
path(
"insights/late-transactions/",
views.late_transactions,
name="insights_late_transactions",
),
path(
"insights/latest-transactions/",
views.latest_transactions,
name="insights_latest_transactions",
),
path(
"insights/emergency-fund/",
views.emergency_fund,
name="insights_emergency_fund",
),
]

View File

@@ -0,0 +1,322 @@
from decimal import Decimal
from django.db import models
from django.db.models import Sum, Case, When, Value, DecimalField
from django.db.models.functions import Coalesce
from apps.transactions.models import Transaction
from apps.currencies.models import Currency
from apps.currencies.utils.convert import convert
def get_categories_totals(transactions_queryset, ignore_empty=False):
# First get the category totals as before
category_currency_metrics = (
transactions_queryset.values(
"category",
"category__name",
"account__currency",
"account__currency__code",
"account__currency__name",
"account__currency__decimal_places",
"account__currency__prefix",
"account__currency__suffix",
"account__currency__exchange_currency",
)
.annotate(
expense_current=Coalesce(
Sum(
Case(
When(
type=Transaction.Type.EXPENSE, is_paid=True, then="amount"
),
default=Value(0),
output_field=models.DecimalField(),
)
),
Decimal("0"),
),
expense_projected=Coalesce(
Sum(
Case(
When(
type=Transaction.Type.EXPENSE, is_paid=False, then="amount"
),
default=Value(0),
output_field=models.DecimalField(),
)
),
Decimal("0"),
),
income_current=Coalesce(
Sum(
Case(
When(type=Transaction.Type.INCOME, is_paid=True, then="amount"),
default=Value(0),
output_field=models.DecimalField(),
)
),
Decimal("0"),
),
income_projected=Coalesce(
Sum(
Case(
When(
type=Transaction.Type.INCOME, is_paid=False, then="amount"
),
default=Value(0),
output_field=models.DecimalField(),
)
),
Decimal("0"),
),
)
.order_by("category__name")
)
# Get tag totals within each category with currency details
tag_metrics = transactions_queryset.values(
"category",
"tags",
"tags__name",
"account__currency",
"account__currency__code",
"account__currency__name",
"account__currency__decimal_places",
"account__currency__prefix",
"account__currency__suffix",
"account__currency__exchange_currency",
).annotate(
expense_current=Coalesce(
Sum(
Case(
When(type=Transaction.Type.EXPENSE, is_paid=True, then="amount"),
default=Value(0),
output_field=models.DecimalField(),
)
),
Decimal("0"),
),
expense_projected=Coalesce(
Sum(
Case(
When(type=Transaction.Type.EXPENSE, is_paid=False, then="amount"),
default=Value(0),
output_field=models.DecimalField(),
)
),
Decimal("0"),
),
income_current=Coalesce(
Sum(
Case(
When(type=Transaction.Type.INCOME, is_paid=True, then="amount"),
default=Value(0),
output_field=models.DecimalField(),
)
),
Decimal("0"),
),
income_projected=Coalesce(
Sum(
Case(
When(type=Transaction.Type.INCOME, is_paid=False, then="amount"),
default=Value(0),
output_field=models.DecimalField(),
)
),
Decimal("0"),
),
)
# Process the results to structure by category
result = {}
# Process category totals first
for metric in category_currency_metrics:
# Skip empty categories if ignore_empty is True
if ignore_empty and all(
metric[field] == Decimal("0")
for field in [
"expense_current",
"expense_projected",
"income_current",
"income_projected",
]
):
continue
# Calculate derived totals
total_current = metric["income_current"] - metric["expense_current"]
total_projected = metric["income_projected"] - metric["expense_projected"]
total_income = metric["income_current"] + metric["income_projected"]
total_expense = metric["expense_current"] + metric["expense_projected"]
total_final = total_current + total_projected
category_id = metric["category"]
currency_id = metric["account__currency"]
if category_id not in result:
result[category_id] = {
"name": metric["category__name"],
"currencies": {},
"tags": {}, # Add tags container
}
# Add currency data
currency_data = {
"currency": {
"code": metric["account__currency__code"],
"name": metric["account__currency__name"],
"decimal_places": metric["account__currency__decimal_places"],
"prefix": metric["account__currency__prefix"],
"suffix": metric["account__currency__suffix"],
},
"expense_current": metric["expense_current"],
"expense_projected": metric["expense_projected"],
"total_expense": total_expense,
"income_current": metric["income_current"],
"income_projected": metric["income_projected"],
"total_income": total_income,
"total_current": total_current,
"total_projected": total_projected,
"total_final": total_final,
}
# Add exchanged values if exchange_currency exists
if metric["account__currency__exchange_currency"]:
from_currency = Currency.objects.get(id=currency_id)
exchange_currency = Currency.objects.get(
id=metric["account__currency__exchange_currency"]
)
exchanged = {}
for field in [
"expense_current",
"expense_projected",
"income_current",
"income_projected",
"total_income",
"total_expense",
"total_current",
"total_projected",
"total_final",
]:
amount, prefix, suffix, decimal_places = convert(
amount=currency_data[field],
from_currency=from_currency,
to_currency=exchange_currency,
)
if amount is not None:
exchanged[field] = amount
if "currency" not in exchanged:
exchanged["currency"] = {
"prefix": prefix,
"suffix": suffix,
"decimal_places": decimal_places,
"code": exchange_currency.code,
"name": exchange_currency.name,
}
if exchanged:
currency_data["exchanged"] = exchanged
result[category_id]["currencies"][currency_id] = currency_data
# Process tag totals and add them to the result, including untagged
for tag_metric in tag_metrics:
category_id = tag_metric["category"]
tag_id = tag_metric["tags"] # Will be None for untagged transactions
if category_id in result:
# Initialize the tag container if not exists
if "tags" not in result[category_id]:
result[category_id]["tags"] = {}
# Determine if this is a tagged or untagged transaction
tag_key = tag_id if tag_id is not None else "untagged"
tag_name = tag_metric["tags__name"] if tag_id is not None else None
if tag_key not in result[category_id]["tags"]:
result[category_id]["tags"][tag_key] = {
"name": tag_name,
"currencies": {},
}
currency_id = tag_metric["account__currency"]
# Calculate tag totals
tag_total_current = (
tag_metric["income_current"] - tag_metric["expense_current"]
)
tag_total_projected = (
tag_metric["income_projected"] - tag_metric["expense_projected"]
)
tag_total_income = (
tag_metric["income_current"] + tag_metric["income_projected"]
)
tag_total_expense = (
tag_metric["expense_current"] + tag_metric["expense_projected"]
)
tag_total_final = tag_total_current + tag_total_projected
tag_currency_data = {
"currency": {
"code": tag_metric["account__currency__code"],
"name": tag_metric["account__currency__name"],
"decimal_places": tag_metric["account__currency__decimal_places"],
"prefix": tag_metric["account__currency__prefix"],
"suffix": tag_metric["account__currency__suffix"],
},
"expense_current": tag_metric["expense_current"],
"expense_projected": tag_metric["expense_projected"],
"total_expense": tag_total_expense,
"income_current": tag_metric["income_current"],
"income_projected": tag_metric["income_projected"],
"total_income": tag_total_income,
"total_current": tag_total_current,
"total_projected": tag_total_projected,
"total_final": tag_total_final,
}
# Add exchange currency support for tags
if tag_metric["account__currency__exchange_currency"]:
from_currency = Currency.objects.get(id=currency_id)
exchange_currency = Currency.objects.get(
id=tag_metric["account__currency__exchange_currency"]
)
exchanged = {}
for field in [
"expense_current",
"expense_projected",
"income_current",
"income_projected",
"total_income",
"total_expense",
"total_current",
"total_projected",
"total_final",
]:
amount, prefix, suffix, decimal_places = convert(
amount=tag_currency_data[field],
from_currency=from_currency,
to_currency=exchange_currency,
)
if amount is not None:
exchanged[field] = amount
if "currency" not in exchanged:
exchanged["currency"] = {
"prefix": prefix,
"suffix": suffix,
"decimal_places": decimal_places,
"code": exchange_currency.code,
"name": exchange_currency.name,
}
if exchanged:
tag_currency_data["exchanged"] = exchanged
result[category_id]["tags"][tag_key]["currencies"][
currency_id
] = tag_currency_data
return result

View File

@@ -13,7 +13,9 @@ from apps.insights.forms import (
)
def get_transactions(request, include_unpaid=True, include_silent=False):
def get_transactions(
request, include_unpaid=True, include_silent=False, include_untracked_accounts=False
):
transactions = Transaction.objects.all()
filter_type = request.GET.get("type", None)
@@ -91,6 +93,13 @@ def get_transactions(request, include_unpaid=True, include_silent=False):
transactions = transactions.filter(is_paid=True)
if not include_silent:
transactions = transactions.exclude(Q(category__mute=True) & ~Q(category=None))
transactions = transactions.exclude(
Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True)
)
if not include_untracked_accounts:
transactions = transactions.exclude(
account__in=request.user.untracked_accounts.all()
)
return transactions

View File

@@ -1,5 +1,8 @@
from collections import defaultdict
from dateutil.relativedelta import relativedelta
from django.contrib.auth.decorators import login_required
from django.db.models import Sum
from django.shortcuts import render
from django.utils import timezone
from django.views.decorators.http import require_http_methods
@@ -17,12 +20,14 @@ from apps.insights.utils.category_explorer import (
get_category_sums_by_account,
get_category_sums_by_currency,
)
from apps.insights.utils.category_overview import get_categories_totals
from apps.insights.utils.sankey import (
generate_sankey_data_by_account,
generate_sankey_data_by_currency,
)
from apps.insights.utils.transactions import get_transactions
from apps.transactions.models import TransactionCategory
from apps.transactions.models import TransactionCategory, Transaction
from apps.transactions.utils.calculations import calculate_currency_totals
@login_required
@@ -69,7 +74,7 @@ def index(request):
def sankey_by_account(request):
# Get filtered transactions
transactions = get_transactions(request)
transactions = get_transactions(request, include_untracked_accounts=True)
# Generate Sankey data
sankey_data = generate_sankey_data_by_account(transactions)
@@ -157,3 +162,132 @@ def category_sum_by_currency(request):
"insights/fragments/category_explorer/charts/currency.html",
{"currency_data": currency_data},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def category_overview(request):
if "view_type" in request.GET:
view_type = request.GET["view_type"]
request.session["insights_category_explorer_view_type"] = view_type
else:
view_type = request.session.get("insights_category_explorer_view_type", "table")
if "show_tags" in request.GET:
show_tags = request.GET["show_tags"] == "on"
request.session["insights_category_explorer_show_tags"] = show_tags
else:
show_tags = request.session.get("insights_category_explorer_show_tags", True)
if "showing" in request.GET:
showing = request.GET["showing"]
request.session["insights_category_explorer_showing"] = showing
else:
showing = request.session.get("insights_category_explorer_showing", "final")
# Get filtered transactions
transactions = get_transactions(request, include_silent=True)
total_table = get_categories_totals(
transactions_queryset=transactions, ignore_empty=False
)
return render(
request,
"insights/fragments/category_overview/index.html",
{
"total_table": total_table,
"view_type": view_type,
"show_tags": show_tags,
"showing": showing,
},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def latest_transactions(request):
limit = timezone.now() - relativedelta(days=3)
transactions = Transaction.objects.filter(created_at__gte=limit).order_by("-id")[
:30
]
return render(
request,
"insights/fragments/latest_transactions.html",
{"transactions": transactions},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def late_transactions(request):
now = timezone.localdate(timezone.now())
transactions = Transaction.objects.filter(is_paid=False, date__lt=now)
return render(
request,
"insights/fragments/late_transactions.html",
{"transactions": transactions},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def emergency_fund(request):
transactions_currency_queryset = (
Transaction.objects.filter(
is_paid=True, account__is_archived=False, account__is_asset=False
)
.exclude(account__in=request.user.untracked_accounts.all())
.order_by(
"account__currency__name",
)
)
currency_net_worth = calculate_currency_totals(
transactions_queryset=transactions_currency_queryset, ignore_empty=False
)
end_date = (timezone.now() - relativedelta(months=1)).replace(day=1)
start_date = (end_date - relativedelta(months=12)).replace(day=1)
# Step 1: Calculate total expense for each month and currency
monthly_expenses = (
Transaction.objects.filter(
type=Transaction.Type.EXPENSE,
is_paid=True,
account__is_asset=False,
reference_date__gte=start_date,
reference_date__lte=end_date,
category__mute=False,
mute=False,
)
.exclude(account__in=request.user.untracked_accounts.all())
.values("reference_date", "account__currency")
.annotate(monthly_total=Sum("amount"))
)
# Step 2: Calculate averages by currency using Python
currency_totals = defaultdict(list)
for expense in monthly_expenses:
currency_id = expense["account__currency"]
currency_totals[currency_id].append(expense["monthly_total"])
for currency_id, totals in currency_totals.items():
avg = currency_net_worth[currency_id]["average"] = sum(totals) / len(totals)
if currency_net_worth[currency_id]["total_current"] < 0:
currency_net_worth[currency_id]["months"] = 0
else:
currency_net_worth[currency_id]["months"] = int(
currency_net_worth[currency_id]["total_current"] / avg
)
return render(
request,
"insights/fragments/emergency_fund.html",
{"data": currency_net_worth},
)

View File

View File

@@ -0,0 +1,85 @@
from typing import Dict
from django.db.models import Func, F, Value
from django.db.models.functions import Extract
from django.utils import timezone
from apps.currencies.models import ExchangeRate
def get_currency_exchange_map(date=None) -> Dict[str, dict]:
"""
Creates a nested dictionary of exchange rates and currency information.
Returns:
{
'BTC': {
'decimal_places': 8,
'prefix': '',
'suffix': '',
'rates': {'USD': Decimal('34000.00'), 'EUR': Decimal('31000.00')}
},
'USD': {
'decimal_places': 2,
'prefix': '$',
'suffix': '',
'rates': {'BTC': Decimal('0.0000294'), 'EUR': Decimal('0.91')}
},
...
}
"""
if date is None:
date = timezone.localtime(timezone.now())
# Get all exchange rates for the closest date
exchange_rates = (
ExchangeRate.objects.select_related(
"from_currency", "to_currency"
) # Optimize currency queries
.annotate(
date_diff=Func(Extract(F("date") - Value(date), "epoch"), function="ABS"),
effective_rate=F("rate"),
)
.order_by("from_currency", "to_currency", "-date_diff")
.distinct()
)
# Initialize the result dictionary
rate_map = {}
# Build the exchange rate mapping with currency info
for rate in exchange_rates:
# Add from_currency info if not exists
if rate.from_currency.name not in rate_map:
rate_map[rate.from_currency.name] = {
"decimal_places": rate.from_currency.decimal_places,
"prefix": rate.from_currency.prefix,
"suffix": rate.from_currency.suffix,
"rates": {},
}
# Add to_currency info if not exists
if rate.to_currency.name not in rate_map:
rate_map[rate.to_currency.name] = {
"decimal_places": rate.to_currency.decimal_places,
"prefix": rate.to_currency.prefix,
"suffix": rate.to_currency.suffix,
"rates": {},
}
# Add direct rate
rate_map[rate.from_currency.name]["rates"][rate.to_currency.name] = {
"rate": rate.rate,
"decimal_places": rate.to_currency.decimal_places,
"prefix": rate.to_currency.prefix,
"suffix": rate.to_currency.suffix,
}
# Add inverse rate
rate_map[rate.to_currency.name]["rates"][rate.from_currency.name] = {
"rate": 1 / rate.rate,
"decimal_places": rate.from_currency.decimal_places,
"prefix": rate.from_currency.prefix,
"suffix": rate.from_currency.suffix,
}
return rate_map

View File

@@ -5,6 +5,7 @@ from apps.common.widgets.decimal import convert_to_decimal
from apps.currencies.models import Currency
from apps.currencies.utils.convert import convert
from apps.mini_tools.forms import CurrencyConverterForm
from apps.mini_tools.utils.exchange_rate_map import get_currency_exchange_map
@login_required
@@ -14,11 +15,13 @@ def unit_price_calculator(request):
@login_required
def currency_converter(request):
rate_map = get_currency_exchange_map()
form = CurrencyConverterForm()
return render(
request,
"mini_tools/currency_converter/currency_converter.html",
context={"form": form},
context={"form": form, "rate_map": rate_map},
)

View File

@@ -77,24 +77,20 @@ def transactions_list(request, month: int, year: int):
request.session["monthly_transactions_order"] = order
f = TransactionsFilter(request.GET)
transactions_filtered = (
f.qs.filter()
.filter(
reference_date__year=year,
reference_date__month=month,
)
.prefetch_related(
"account",
"account__group",
"category",
"tags",
"account__exchange_currency",
"account__currency",
"installment_plan",
"entities",
"dca_expense_entries",
"dca_income_entries",
)
transactions_filtered = f.qs.filter(
reference_date__year=year,
reference_date__month=month,
).prefetch_related(
"account",
"account__group",
"category",
"tags",
"account__exchange_currency",
"account__currency",
"installment_plan",
"entities",
"dca_expense_entries",
"dca_income_entries",
)
transactions_filtered = default_order(transactions_filtered, order=order)
@@ -111,9 +107,15 @@ def transactions_list(request, month: int, year: int):
@require_http_methods(["GET"])
def monthly_summary(request, month: int, year: int):
# Base queryset with all required filters
base_queryset = Transaction.objects.filter(
reference_date__year=year, reference_date__month=month, account__is_asset=False
).exclude(Q(category__mute=True) & ~Q(category=None))
base_queryset = (
Transaction.objects.filter(
reference_date__year=year,
reference_date__month=month,
account__is_asset=False,
)
.exclude(Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True))
.exclude(account__in=request.user.untracked_accounts.all())
)
data = calculate_currency_totals(base_queryset, ignore_empty=True)
percentages = calculate_percentage_distribution(data)
@@ -147,7 +149,7 @@ def monthly_account_summary(request, month: int, year: int):
base_queryset = Transaction.objects.filter(
reference_date__year=year,
reference_date__month=month,
).exclude(Q(category__mute=True) & ~Q(category=None))
).exclude(Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True))
account_data = calculate_account_totals(transactions_queryset=base_queryset.all())
account_percentages = calculate_percentage_distribution(account_data)
@@ -169,10 +171,14 @@ def monthly_account_summary(request, month: int, year: int):
@require_http_methods(["GET"])
def monthly_currency_summary(request, month: int, year: int):
# Base queryset with all required filters
base_queryset = Transaction.objects.filter(
reference_date__year=year,
reference_date__month=month,
).exclude(Q(category__mute=True) & ~Q(category=None))
base_queryset = (
Transaction.objects.filter(
reference_date__year=year,
reference_date__month=month,
)
.exclude(Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True))
.exclude(account__in=request.user.untracked_accounts.all())
)
currency_data = calculate_currency_totals(base_queryset.all(), ignore_empty=True)
currency_percentages = calculate_percentage_distribution(currency_data)

View File

@@ -5,4 +5,5 @@ from . import views
urlpatterns = [
path("net-worth/current/", views.net_worth_current, name="net_worth_current"),
path("net-worth/projected/", views.net_worth_projected, name="net_worth_projected"),
path("net-worth/", views.net_worth, name="net_worth"),
]

View File

@@ -2,32 +2,38 @@ from collections import OrderedDict, defaultdict
from decimal import Decimal
from dateutil.relativedelta import relativedelta
from django.db.models import (
OuterRef,
Subquery,
)
from django.db.models import Sum, Min, Max, Case, When, F, Value, DecimalField
from django.db.models.functions import Coalesce
from django.db.models import Sum, Min, Max, Case, When, F, Value, DecimalField, Q
from django.db.models.functions import TruncMonth
from django.template.defaultfilters import date as date_filter
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from apps.accounts.models import Account
from apps.common.middleware.thread_local import get_current_user
from apps.currencies.models import Currency
from apps.currencies.utils.convert import convert
from apps.transactions.models import Transaction
def calculate_historical_currency_net_worth(is_paid=True):
transactions_params = {**{k: v for k, v in [("is_paid", True)] if is_paid}}
def calculate_historical_currency_net_worth(queryset):
# Get all currencies and date range in a single query
aggregates = Transaction.objects.aggregate(
aggregates = queryset.aggregate(
min_date=Min("reference_date"),
max_date=Max("reference_date"),
)
currencies = list(Currency.objects.values_list("name", flat=True))
user = get_current_user()
currencies = list(
Currency.objects.filter(
Q(accounts__visibility="public")
| Q(accounts__owner=user)
| Q(accounts__shared_with=user)
| Q(accounts__visibility="private", accounts__owner=None),
accounts__is_archived=False,
accounts__isnull=False,
)
.values_list("name", flat=True)
.distinct()
)
if not aggregates.get("min_date"):
start_date = timezone.localdate(timezone.now())
@@ -41,8 +47,7 @@ def calculate_historical_currency_net_worth(is_paid=True):
# Calculate cumulative balances for each account, currency, and month
cumulative_balances = (
Transaction.objects.filter(**transactions_params)
.annotate(month=TruncMonth("reference_date"))
queryset.annotate(month=TruncMonth("reference_date"))
.values("account__currency__name", "month")
.annotate(
balance=Sum(
@@ -101,13 +106,14 @@ def calculate_historical_currency_net_worth(is_paid=True):
return historical_net_worth
def calculate_historical_account_balance(is_paid=True):
transactions_params = {**{k: v for k, v in [("is_paid", True)] if is_paid}}
def calculate_historical_account_balance(queryset):
# Get all accounts
accounts = Account.objects.filter(is_archived=False)
accounts = Account.objects.filter(
is_archived=False,
)
# Get the date range
date_range = Transaction.objects.filter(**transactions_params).aggregate(
date_range = queryset.aggregate(
min_date=Min("reference_date"), max_date=Max("reference_date")
)
@@ -123,8 +129,7 @@ def calculate_historical_account_balance(is_paid=True):
# Calculate balances for each account and month
balances = (
Transaction.objects.filter(**transactions_params)
.annotate(month=TruncMonth("reference_date"))
queryset.annotate(month=TruncMonth("reference_date"))
.values("account", "month")
.annotate(
balance=Sum(

View File

@@ -1,7 +1,9 @@
import json
from django.contrib.auth.decorators import login_required
from django.core.serializers.json import DjangoJSONEncoder
from django.shortcuts import render
from django.shortcuts import render, redirect
from django.views.decorators.http import require_http_methods
from apps.net_worth.utils.calculate_net_worth import (
calculate_historical_currency_net_worth,
@@ -14,127 +16,54 @@ from apps.transactions.utils.calculations import (
)
def net_worth_current(request):
transactions_currency_queryset = Transaction.objects.filter(
is_paid=True, account__is_archived=False
).order_by(
"account__currency__name",
)
transactions_account_queryset = Transaction.objects.filter(
is_paid=True, account__is_archived=False
).order_by(
"account__group__name",
"account__name",
)
@login_required
@require_http_methods(["GET"])
def net_worth(request):
if "view_type" in request.GET:
print(request.GET["view_type"])
view_type = request.GET["view_type"]
request.session["networth_view_type"] = view_type
else:
view_type = request.session.get("networth_view_type", "current")
currency_net_worth = calculate_currency_totals(
transactions_queryset=transactions_currency_queryset
)
account_net_worth = calculate_account_totals(
transactions_queryset=transactions_account_queryset
)
historical_currency_net_worth = calculate_historical_currency_net_worth()
labels = (
list(historical_currency_net_worth.keys())
if historical_currency_net_worth
else []
)
currencies = (
list(historical_currency_net_worth[labels[0]].keys())
if historical_currency_net_worth
else []
)
datasets = []
for i, currency in enumerate(currencies):
data = [
float(month_data[currency])
for month_data in historical_currency_net_worth.values()
]
datasets.append(
{
"label": currency,
"data": data,
"yAxisID": f"y{i}",
"fill": False,
"tension": 0.1,
}
if view_type == "current":
transactions_currency_queryset = (
Transaction.objects.filter(is_paid=True, account__is_archived=False)
.order_by(
"account__currency__name",
)
.exclude(account__in=request.user.untracked_accounts.all())
)
transactions_account_queryset = Transaction.objects.filter(
is_paid=True, account__is_archived=False
).order_by(
"account__group__name",
"account__name",
)
else:
transactions_currency_queryset = (
Transaction.objects.filter(account__is_archived=False)
.order_by(
"account__currency__name",
)
.exclude(account__in=request.user.untracked_accounts.all())
)
transactions_account_queryset = Transaction.objects.filter(
account__is_archived=False
).order_by(
"account__group__name",
"account__name",
)
chart_data_currency = {"labels": labels, "datasets": datasets}
chart_data_currency_json = json.dumps(chart_data_currency, cls=DjangoJSONEncoder)
historical_account_balance = calculate_historical_account_balance()
labels = (
list(historical_account_balance.keys()) if historical_account_balance else []
)
accounts = (
list(historical_account_balance[labels[0]].keys())
if historical_account_balance
else []
)
datasets = []
for i, account in enumerate(accounts):
data = [
float(month_data[account])
for month_data in historical_account_balance.values()
]
datasets.append(
{
"label": account,
"data": data,
"fill": False,
"tension": 0.1,
"yAxisID": f"y-axis-{i}", # Assign each dataset to its own Y-axis
}
)
chart_data_accounts = {"labels": labels, "datasets": datasets}
chart_data_accounts_json = json.dumps(chart_data_accounts, cls=DjangoJSONEncoder)
return render(
request,
"net_worth/net_worth.html",
{
"currency_net_worth": currency_net_worth,
"account_net_worth": account_net_worth,
"chart_data_currency_json": chart_data_currency_json,
"currencies": currencies,
"chart_data_accounts_json": chart_data_accounts_json,
"accounts": accounts,
"type": "current",
},
)
def net_worth_projected(request):
transactions_currency_queryset = Transaction.objects.filter(
account__is_archived=False
).order_by(
"account__currency__name",
)
transactions_account_queryset = Transaction.objects.filter(
account__is_archived=False
).order_by(
"account__group__name",
"account__name",
)
currency_net_worth = calculate_currency_totals(
transactions_queryset=transactions_currency_queryset
transactions_queryset=transactions_currency_queryset, deep_search=True
)
account_net_worth = calculate_account_totals(
transactions_queryset=transactions_account_queryset
)
historical_currency_net_worth = calculate_historical_currency_net_worth(
is_paid=False
queryset=transactions_currency_queryset
)
labels = (
@@ -168,7 +97,9 @@ def net_worth_projected(request):
chart_data_currency_json = json.dumps(chart_data_currency, cls=DjangoJSONEncoder)
historical_account_balance = calculate_historical_account_balance(is_paid=False)
historical_account_balance = calculate_historical_account_balance(
queryset=transactions_account_queryset
)
labels = (
list(historical_account_balance.keys()) if historical_account_balance else []
@@ -209,6 +140,22 @@ def net_worth_projected(request):
"currencies": currencies,
"chart_data_accounts_json": chart_data_accounts_json,
"accounts": accounts,
"type": "projected",
"type": view_type,
},
)
@login_required
@require_http_methods(["GET"])
def net_worth_current(request):
request.session["networth_view_type"] = "current"
return redirect("net_worth")
@login_required
@require_http_methods(["GET"])
def net_worth_projected(request):
request.session["networth_view_type"] = "projected"
return redirect("net_worth")

View File

@@ -16,9 +16,11 @@ class TransactionRuleForm(forms.ModelForm):
class Meta:
model = TransactionRule
fields = "__all__"
exclude = ("owner", "shared_with", "visibility")
labels = {
"on_create": _("Run on creation"),
"on_update": _("Run on update"),
"on_delete": _("Run on delete"),
"trigger": _("If..."),
}
widgets = {"description": forms.widgets.TextInput}
@@ -33,7 +35,11 @@ class TransactionRuleForm(forms.ModelForm):
self.helper.layout = Layout(
Switch("active"),
"name",
Row(Column(Switch("on_update")), Column(Switch("on_create"))),
Row(
Column(Switch("on_update")),
Column(Switch("on_create")),
Column(Switch("on_delete")),
),
"description",
"trigger",
)

View File

@@ -0,0 +1,31 @@
# Generated by Django 5.1.6 on 2025-03-07 02:46
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rules', '0011_alter_updateorcreatetransactionruleaction_set_is_paid'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='transactionrule',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='transactionrule',
name='shared_with',
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='transactionrule',
name='visibility',
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.7 on 2025-03-09 03:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rules', '0012_transactionrule_owner_transactionrule_shared_with_and_more'),
]
operations = [
migrations.AddField(
model_name='transactionrule',
name='on_delete',
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,31 @@
# Generated by Django 5.2.4 on 2025-07-28 02:15
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rules', '0013_transactionrule_on_delete'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='transactionrule',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
),
migrations.AlterField(
model_name='transactionrule',
name='shared_with',
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL, verbose_name='Shared with users'),
),
migrations.AlterField(
model_name='transactionrule',
name='visibility',
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10, verbose_name='Visibility'),
),
]

View File

@@ -2,15 +2,21 @@ from django.db import models
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from apps.common.models import SharedObject, SharedObjectManager
class TransactionRule(models.Model):
class TransactionRule(SharedObject):
active = models.BooleanField(default=True)
on_update = models.BooleanField(default=False)
on_create = models.BooleanField(default=True)
on_delete = models.BooleanField(default=False)
name = models.CharField(max_length=100, verbose_name=_("Name"))
description = models.TextField(blank=True, null=True, verbose_name=_("Description"))
trigger = models.TextField(verbose_name=_("Trigger"))
objects = SharedObjectManager()
all_objects = models.Manager() # Unfiltered manager
class Meta:
verbose_name = _("Transaction rule")
verbose_name_plural = _("Transaction rules")
@@ -297,10 +303,8 @@ class UpdateOrCreateTransactionRuleAction(models.Model):
search_query = Q()
def add_to_query(field_name, value, operator):
if isinstance(value, (int, str)):
lookup = f"{field_name}__{operator}"
return Q(**{lookup: value})
return Q()
lookup = f"{field_name}__{operator}"
return Q(**{lookup: value})
if self.search_account:
value = simple.eval(self.search_account)

View File

@@ -1,16 +1,57 @@
from django.conf import settings
from django.dispatch import receiver
from apps.transactions.models import (
Transaction,
transaction_created,
transaction_updated,
transaction_deleted,
)
from apps.rules.tasks import check_for_transaction_rules
from apps.common.middleware.thread_local import get_current_user
@receiver(transaction_created)
@receiver(transaction_updated)
@receiver(transaction_deleted)
def transaction_changed_receiver(sender: Transaction, signal, **kwargs):
if signal is transaction_deleted:
# Serialize transaction data for processing
transaction_data = {
"id": sender.id,
"account": (sender.account.id, sender.account.name),
"account_group": (
sender.account.group.id if sender.account.group else None,
sender.account.group.name if sender.account.group else None,
),
"type": str(sender.type),
"is_paid": sender.is_paid,
"is_asset": sender.account.is_asset,
"is_archived": sender.account.is_archived,
"category": (
sender.category.id if sender.category else None,
sender.category.name if sender.category else None,
),
"date": sender.date.isoformat(),
"reference_date": sender.reference_date.isoformat(),
"amount": str(sender.amount),
"description": sender.description,
"notes": sender.notes,
"tags": list(sender.tags.values_list("id", "name")),
"entities": list(sender.entities.values_list("id", "name")),
"deleted": True,
"internal_note": sender.internal_note,
"internal_id": sender.internal_id,
}
check_for_transaction_rules.defer(
transaction_data=transaction_data,
user_id=get_current_user().id,
signal="transaction_deleted",
is_hard_deleted=kwargs.get("hard_delete", not settings.ENABLE_SOFT_DELETE),
)
return
for dca_entry in sender.dca_expense_entries.all():
dca_entry.amount_paid = sender.amount
dca_entry.save()
@@ -20,6 +61,7 @@ def transaction_changed_receiver(sender: Transaction, signal, **kwargs):
check_for_transaction_rules.defer(
instance_id=sender.id,
user_id=get_current_user().id,
signal=(
"transaction_created"
if signal is transaction_created

View File

@@ -1,9 +1,12 @@
import decimal
import logging
from datetime import datetime, date
from decimal import Decimal
from typing import Any
from cachalot.api import cachalot_disabled
from dateutil.relativedelta import relativedelta
from django.contrib.auth import get_user_model
from procrastinate.contrib.django import app
from simpleeval import EvalWithCompoundTypes
@@ -18,18 +21,34 @@ from apps.transactions.models import (
TransactionTag,
TransactionEntity,
)
from apps.common.middleware.thread_local import write_current_user, delete_current_user
logger = logging.getLogger(__name__)
@app.task(name="check_for_transaction_rules")
def check_for_transaction_rules(
instance_id: int,
signal,
instance_id=None,
transaction_data=None,
user_id=None,
signal=None,
is_hard_deleted=False,
):
user = get_user_model().objects.get(id=user_id)
write_current_user(user)
try:
with cachalot_disabled():
instance = Transaction.objects.get(id=instance_id)
# For deleted transactions
if signal == "transaction_deleted" and transaction_data:
# Create a transaction-like object from the serialized data
if is_hard_deleted:
instance = transaction_data
else:
instance = Transaction.deleted_objects.get(id=instance_id)
else:
# Regular transaction processing for creates and updates
instance = Transaction.objects.get(id=instance_id)
functions = {
"relativedelta": relativedelta,
@@ -41,10 +60,11 @@ def check_for_transaction_rules(
"date": date,
}
simple = EvalWithCompoundTypes(
names=_get_names(instance), functions=functions
)
names = _get_names(instance)
simple = EvalWithCompoundTypes(names=names, functions=functions)
# Select rules based on the signal type
if signal == "transaction_created":
rules = TransactionRule.objects.filter(
active=True, on_create=True
@@ -53,77 +73,125 @@ def check_for_transaction_rules(
rules = TransactionRule.objects.filter(
active=True, on_update=True
).order_by("id")
elif signal == "transaction_deleted":
rules = TransactionRule.objects.filter(
active=True, on_delete=True
).order_by("id")
else:
rules = TransactionRule.objects.filter(active=True).order_by("id")
# Process the rules as before
for rule in rules:
if simple.eval(rule.trigger):
for action in rule.transaction_actions.all():
try:
instance = _process_edit_transaction_action(
instance=instance, action=action, simple_eval=simple
)
except Exception as e:
logger.error(
f"Error processing edit transaction action {action.id}",
exc_info=True,
)
# else:
# simple.names.update(_get_names(instance))
# instance.save()
# For deleted transactions, we might want to limit what actions can be performed
if signal == "transaction_deleted":
# Process only create/update actions, not edit actions
for action in rule.update_or_create_transaction_actions.all():
try:
_process_update_or_create_transaction_action(
action=action, simple_eval=simple
)
except Exception as e:
logger.error(
f"Error processing update or create transaction action {action.id} on deletion",
exc_info=True,
)
else:
# Normal processing for non-deleted transactions
for action in rule.transaction_actions.all():
try:
instance = _process_edit_transaction_action(
instance=instance, action=action, simple_eval=simple
)
except Exception as e:
logger.error(
f"Error processing edit transaction action {action.id}",
exc_info=True,
)
simple.names.update(_get_names(instance))
instance.save()
for action in rule.update_or_create_transaction_actions.all():
try:
_process_update_or_create_transaction_action(
action=action, simple_eval=simple
)
except Exception as e:
logger.error(
f"Error processing update or create transaction action {action.id}",
exc_info=True,
)
simple.names.update(_get_names(instance))
if signal != "transaction_deleted":
instance.save()
for action in rule.update_or_create_transaction_actions.all():
try:
_process_update_or_create_transaction_action(
action=action, simple_eval=simple
)
except Exception as e:
logger.error(
f"Error processing update or create transaction action {action.id}",
exc_info=True,
)
except Exception as e:
logger.error(
"Error while executing 'check_for_transaction_rules' task",
exc_info=True,
)
delete_current_user()
raise e
delete_current_user()
def _get_names(instance):
return {
"id": instance.id,
"account_name": instance.account.name,
"account_id": instance.account.id,
"account_group_name": (
instance.account.group.name if instance.account.group else None
),
"account_group_id": (
instance.account.group.id if instance.account.group else None
),
"is_asset_account": instance.account.is_asset,
"is_archived_account": instance.account.is_archived,
"category_name": instance.category.name if instance.category else None,
"category_id": instance.category.id if instance.category else None,
"tag_names": [x.name for x in instance.tags.all()],
"tag_ids": [x.id for x in instance.tags.all()],
"entities_names": [x.name for x in instance.entities.all()],
"entities_ids": [x.id for x in instance.entities.all()],
"is_expense": instance.type == Transaction.Type.EXPENSE,
"is_income": instance.type == Transaction.Type.INCOME,
"is_paid": instance.is_paid,
"description": instance.description,
"amount": instance.amount,
"notes": instance.notes,
"date": instance.date,
"reference_date": instance.reference_date,
"internal_note": instance.internal_note,
"internal_id": instance.internal_id,
}
def _get_names(instance: Transaction | dict):
if isinstance(instance, Transaction):
return {
"id": instance.id,
"account_name": instance.account.name,
"account_id": instance.account.id,
"account_group_name": (
instance.account.group.name if instance.account.group else None
),
"account_group_id": (
instance.account.group.id if instance.account.group else None
),
"is_asset_account": instance.account.is_asset,
"is_archived_account": instance.account.is_archived,
"category_name": instance.category.name if instance.category else None,
"category_id": instance.category.id if instance.category else None,
"tag_names": [x.name for x in instance.tags.all()],
"tag_ids": [x.id for x in instance.tags.all()],
"entities_names": [x.name for x in instance.entities.all()],
"entities_ids": [x.id for x in instance.entities.all()],
"is_expense": instance.type == Transaction.Type.EXPENSE,
"is_income": instance.type == Transaction.Type.INCOME,
"is_paid": instance.is_paid,
"description": instance.description,
"amount": instance.amount,
"notes": instance.notes,
"date": instance.date,
"reference_date": instance.reference_date,
"internal_note": instance.internal_note,
"internal_id": instance.internal_id,
"is_deleted": instance.deleted,
}
else:
return {
"id": instance.get("id"),
"account_name": instance.get("account", (None, None))[1],
"account_id": instance.get("account", (None, None))[0],
"account_group_name": instance.get("account_group", (None, None))[1],
"account_group_id": instance.get("account_group", (None, None))[0],
"is_asset_account": instance.get("is_asset"),
"is_archived_account": instance.get("is_archived"),
"category_name": instance.get("category", (None, None))[1],
"category_id": instance.get("category", (None, None))[0],
"tag_names": [x[1] for x in instance.get("tags", [])],
"tag_ids": [x[0] for x in instance.get("tags", [])],
"entities_names": [x[1] for x in instance.get("entities", [])],
"entities_ids": [x[0] for x in instance.get("entities", [])],
"is_expense": instance.get("type") == Transaction.Type.EXPENSE,
"is_income": instance.get("type") == Transaction.Type.INCOME,
"is_paid": instance.get("is_paid"),
"description": instance.get("description", ""),
"amount": Decimal(instance.get("amount")),
"notes": instance.get("notes", ""),
"date": datetime.fromisoformat(instance.get("date")),
"reference_date": datetime.fromisoformat(instance.get("reference_date")),
"internal_note": instance.get("internal_note", ""),
"internal_id": instance.get("internal_id", ""),
"is_deleted": instance.get("deleted", True),
}
def _process_update_or_create_transaction_action(action, simple_eval):
@@ -131,14 +199,16 @@ def _process_update_or_create_transaction_action(action, simple_eval):
# Build search query using the helper method
search_query = action.build_search_query(simple_eval)
logger.info("Searching transactions using: %s", search_query)
# Find latest matching transaction or create new
if search_query:
transaction = (
Transaction.objects.filter(search_query).order_by("-date", "-id").first()
)
transactions = Transaction.objects.filter(search_query).order_by("-date", "-id")
transaction = transactions.first()
logger.info("Found at least one matching transaction, using latest")
else:
transaction = None
logger.info("No matching transaction found, creating a new transaction")
if not transaction:
transaction = Transaction()

View File

@@ -37,6 +37,16 @@ urlpatterns = [
views.transaction_rule_delete,
name="transaction_rule_delete",
),
path(
"rules/transaction/<int:transaction_rule_id>/take-ownership/",
views.transaction_rule_take_ownership,
name="transaction_rule_take_ownership",
),
path(
"rules/transaction/<int:pk>/share/",
views.transaction_rule_share,
name="transaction_rule_share_settings",
),
path(
"rules/transaction/<int:transaction_rule_id>/transaction-action/add/",
views.transaction_rule_action_add,

View File

@@ -16,9 +16,13 @@ from apps.rules.models import (
TransactionRuleAction,
UpdateOrCreateTransactionRuleAction,
)
from apps.common.models import SharedObject
from apps.common.forms import SharedObjectForm
from apps.common.decorators.demo import disabled_on_demo
@login_required
@disabled_on_demo
@require_http_methods(["GET"])
def rules_index(request):
return render(
@@ -29,6 +33,7 @@ def rules_index(request):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET"])
def rules_list(request):
transaction_rules = TransactionRule.objects.all().order_by("id")
@@ -41,6 +46,7 @@ def rules_list(request):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def transaction_rule_toggle_activity(request, transaction_rule_id, **kwargs):
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
@@ -63,6 +69,7 @@ def transaction_rule_toggle_activity(request, transaction_rule_id, **kwargs):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def transaction_rule_add(request, **kwargs):
if request.method == "POST":
@@ -89,10 +96,21 @@ def transaction_rule_add(request, **kwargs):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def transaction_rule_edit(request, transaction_rule_id):
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
if transaction_rule.owner and transaction_rule.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 = TransactionRuleForm(request.POST, instance=transaction_rule)
if form.is_valid():
@@ -117,6 +135,7 @@ def transaction_rule_edit(request, transaction_rule_id):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def transaction_rule_view(request, transaction_rule_id):
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
@@ -130,13 +149,20 @@ def transaction_rule_view(request, transaction_rule_id):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["DELETE"])
def transaction_rule_delete(request, transaction_rule_id):
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
transaction_rule.delete()
messages.success(request, _("Rule deleted successfully"))
if (
transaction_rule.owner != request.user
and request.user in transaction_rule.shared_with.all()
):
transaction_rule.shared_with.remove(request.user)
messages.success(request, _("Item no longer shared with you"))
else:
transaction_rule.delete()
messages.success(request, _("Rule deleted successfully"))
return HttpResponse(
status=204,
@@ -148,6 +174,68 @@ def transaction_rule_delete(request, transaction_rule_id):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET"])
def transaction_rule_take_ownership(request, transaction_rule_id):
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
if not transaction_rule.owner:
transaction_rule.owner = request.user
transaction_rule.visibility = SharedObject.Visibility.private
transaction_rule.save()
messages.success(request, _("Ownership taken successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def transaction_rule_share(request, pk):
obj = get_object_or_404(TransactionRule, 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,
"rules/fragments/share.html",
{"form": form, "object": obj},
)
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def transaction_rule_action_add(request, transaction_rule_id):
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
@@ -175,6 +263,7 @@ def transaction_rule_action_add(request, transaction_rule_id):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def transaction_rule_action_edit(request, transaction_rule_action_id):
transaction_rule_action = get_object_or_404(
@@ -212,6 +301,7 @@ def transaction_rule_action_edit(request, transaction_rule_action_id):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["DELETE"])
def transaction_rule_action_delete(request, transaction_rule_action_id):
transaction_rule_action = get_object_or_404(
@@ -232,6 +322,7 @@ def transaction_rule_action_delete(request, transaction_rule_action_id):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def update_or_create_transaction_rule_action_add(request, transaction_rule_id):
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
@@ -263,6 +354,7 @@ def update_or_create_transaction_rule_action_add(request, transaction_rule_id):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def update_or_create_transaction_rule_action_edit(request, pk):
linked_action = get_object_or_404(UpdateOrCreateTransactionRuleAction, id=pk)
@@ -297,6 +389,7 @@ def update_or_create_transaction_rule_action_edit(request, pk):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["DELETE"])
def update_or_create_transaction_rule_action_delete(request, pk):
linked_action = get_object_or_404(UpdateOrCreateTransactionRuleAction, id=pk)

View File

@@ -7,14 +7,16 @@ from apps.transactions.models import (
InstallmentPlan,
RecurringTransaction,
TransactionEntity,
QuickTransaction,
)
from apps.common.admin import SharedObjectModelAdmin
@admin.register(Transaction)
class TransactionModelAdmin(admin.ModelAdmin):
def get_queryset(self, request):
# Use the all_objects manager to show all transactions, including deleted ones
return self.model.all_objects.all()
return self.model.userless_all_objects.all()
list_filter = ["deleted", "type", "is_paid", "date", "account"]
@@ -61,6 +63,19 @@ class RecurringTransactionAdmin(admin.ModelAdmin):
]
admin.site.register(TransactionCategory)
admin.site.register(TransactionTag)
admin.site.register(TransactionEntity)
admin.site.register(QuickTransaction)
@admin.register(TransactionCategory)
class TransactionCategoryModelAdmin(SharedObjectModelAdmin):
pass
@admin.register(TransactionTag)
class TransactionTagModelAdmin(SharedObjectModelAdmin):
pass
@admin.register(TransactionEntity)
class TransactionEntityModelAdmin(SharedObjectModelAdmin):
pass

View File

@@ -184,3 +184,8 @@ class TransactionsFilter(django_filters.FilterSet):
self.form.fields["from_amount"].widget = ArbitraryDecimalDisplayNumberInput()
self.form.fields["date_start"].widget = AirDatePickerInput()
self.form.fields["date_end"].widget = AirDatePickerInput()
self.form.fields["account"].queryset = Account.objects.all()
self.form.fields["category"].queryset = TransactionCategory.objects.all()
self.form.fields["tags"].queryset = TransactionTag.objects.all()
self.form.fields["entities"].queryset = TransactionEntity.objects.all()

View File

@@ -1,11 +1,13 @@
from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion
from crispy_forms.bootstrap import FormActions, AccordionGroup
from crispy_forms.bootstrap import FormActions, AccordionGroup, AppendedText
from crispy_forms.helper import FormHelper
from crispy_forms.layout import (
Layout,
Row,
Column,
Field,
Div,
HTML,
)
from django import forms
from django.db.models import Q
@@ -28,6 +30,7 @@ from apps.transactions.models import (
InstallmentPlan,
RecurringTransaction,
TransactionEntity,
QuickTransaction,
)
@@ -94,20 +97,30 @@ class TransactionForm(forms.ModelForm):
# if editing a transaction display non-archived items and it's own item even if it's archived
if self.instance.id:
self.fields["account"].queryset = Account.objects.filter(
Q(is_archived=False) | Q(transactions=self.instance.id)
).distinct()
Q(is_archived=False) | Q(transactions=self.instance.id),
)
self.fields["category"].queryset = TransactionCategory.objects.filter(
Q(active=True) | Q(transaction=self.instance.id)
).distinct()
)
self.fields["tags"].queryset = TransactionTag.objects.filter(
Q(active=True) | Q(transaction=self.instance.id)
).distinct()
)
self.fields["entities"].queryset = TransactionEntity.objects.filter(
Q(active=True) | Q(transactions=self.instance.id)
).distinct()
)
else:
self.fields["account"].queryset = Account.objects.filter(
is_archived=False,
)
self.fields["category"].queryset = TransactionCategory.objects.filter(
active=True
)
self.fields["tags"].queryset = TransactionTag.objects.filter(active=True)
self.fields["entities"].queryset = TransactionEntity.objects.all()
self.helper = FormHelper()
self.helper.form_tag = False
@@ -195,10 +208,21 @@ class TransactionForm(forms.ModelForm):
else:
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
self.helper.layout.append(
FormActions(
Div(
NoClassSubmit(
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
"submit", _("Add"), css_class="btn btn-outline-primary"
),
NoClassSubmit(
"submit_and_similar",
_("Save and add similar"),
css_class="btn btn-outline-primary",
),
NoClassSubmit(
"submit_and_another",
_("Save and add another"),
css_class="btn btn-outline-primary",
),
css_class="d-grid gap-2",
),
)
@@ -224,6 +248,145 @@ class TransactionForm(forms.ModelForm):
return instance
class QuickTransactionForm(forms.ModelForm):
category = DynamicModelChoiceField(
create_field="name",
model=TransactionCategory,
required=False,
label=_("Category"),
queryset=TransactionCategory.objects.filter(active=True),
)
tags = DynamicModelMultipleChoiceField(
model=TransactionTag,
to_field_name="name",
create_field="name",
required=False,
label=_("Tags"),
queryset=TransactionTag.objects.filter(active=True),
)
entities = DynamicModelMultipleChoiceField(
model=TransactionEntity,
to_field_name="name",
create_field="name",
required=False,
label=_("Entities"),
)
account = forms.ModelChoiceField(
queryset=Account.objects.filter(is_archived=False),
label=_("Account"),
widget=TomSelect(clear_button=False, group_by="group"),
)
class Meta:
model = QuickTransaction
fields = [
"name",
"account",
"type",
"is_paid",
"amount",
"description",
"notes",
"category",
"tags",
"entities",
"mute",
]
widgets = {
"notes": forms.Textarea(attrs={"rows": 3}),
"account": TomSelect(clear_button=False, group_by="group"),
}
help_texts = {
"mute": _("Muted transactions won't be displayed on monthly summaries")
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# if editing a transaction display non-archived items and it's own item even if it's archived
if self.instance.id:
self.fields["account"].queryset = Account.objects.filter(
Q(is_archived=False) | Q(transactions=self.instance.id),
)
self.fields["category"].queryset = TransactionCategory.objects.filter(
Q(active=True) | Q(transaction=self.instance.id)
)
self.fields["tags"].queryset = TransactionTag.objects.filter(
Q(active=True) | Q(transaction=self.instance.id)
)
self.fields["entities"].queryset = TransactionEntity.objects.filter(
Q(active=True) | Q(transactions=self.instance.id)
)
else:
self.fields["account"].queryset = Account.objects.filter(
is_archived=False,
)
self.fields["category"].queryset = TransactionCategory.objects.filter(
active=True
)
self.fields["tags"].queryset = TransactionTag.objects.filter(active=True)
self.fields["entities"].queryset = TransactionEntity.objects.all()
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.form_method = "post"
self.helper.layout = Layout(
Field(
"type",
template="transactions/widgets/income_expense_toggle_buttons.html",
),
Field("is_paid", template="transactions/widgets/paid_toggle_button.html"),
"name",
HTML("<hr />"),
Row(
Column("account", css_class="form-group col-md-6 mb-0"),
Column("entities", css_class="form-group col-md-6 mb-0"),
css_class="form-row",
),
Row(
Column(Field("date"), css_class="form-group col-md-6 mb-0"),
Column(Field("reference_date"), css_class="form-group col-md-6 mb-0"),
css_class="form-row",
),
"description",
Field("amount", inputmode="decimal"),
Row(
Column("category", css_class="form-group col-md-6 mb-0"),
Column("tags", css_class="form-group col-md-6 mb-0"),
css_class="form-row",
),
"notes",
Switch("mute"),
)
if self.instance and self.instance.pk:
decimal_places = self.instance.account.currency.decimal_places
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput(
decimal_places=decimal_places
)
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
),
),
)
else:
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
self.helper.layout.append(
Div(
NoClassSubmit(
"submit", _("Add"), css_class="btn btn-outline-primary"
),
css_class="d-grid gap-2",
),
)
class BulkEditTransactionForm(TransactionForm):
is_paid = forms.NullBooleanField(required=False)
@@ -323,7 +486,9 @@ class TransferForm(forms.Form):
widget=AirMonthYearPickerInput(), label=_("Reference Date"), required=False
)
description = forms.CharField(max_length=500, label=_("Description"))
description = forms.CharField(
max_length=500, label=_("Description"), required=False
)
notes = forms.CharField(
required=False,
widget=forms.Textarea(
@@ -334,6 +499,13 @@ class TransferForm(forms.Form):
label=_("Notes"),
)
mute = forms.BooleanField(
label=_("Mute"),
initial=True,
required=False,
help_text=_("Muted transactions won't be displayed on monthly summaries"),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -352,6 +524,7 @@ class TransferForm(forms.Form):
),
Field("description"),
Field("notes"),
Switch("mute"),
Row(
Column(
Row(
@@ -405,6 +578,24 @@ class TransferForm(forms.Form):
self.fields["to_amount"].widget = ArbitraryDecimalDisplayNumberInput()
self.fields["date"].widget = AirDatePickerInput(clear_button=False)
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()
from_account = cleaned_data.get("from_account")
@@ -416,6 +607,8 @@ class TransferForm(forms.Form):
return cleaned_data
def save(self):
mute = self.cleaned_data["mute"]
from_account = self.cleaned_data["from_account"]
to_account = self.cleaned_data["to_account"]
from_amount = self.cleaned_data["from_amount"]
@@ -438,6 +631,7 @@ class TransferForm(forms.Form):
description=description,
category=from_category,
notes=notes,
mute=mute,
)
from_transaction.tags.set(self.cleaned_data.get("from_tags", []))
@@ -452,6 +646,7 @@ class TransferForm(forms.Form):
description=description,
category=to_category,
notes=notes,
mute=mute,
)
to_transaction.tags.set(self.cleaned_data.get("to_tags", []))
@@ -509,6 +704,8 @@ class InstallmentPlanForm(forms.ModelForm):
"notes",
"installment_start",
"entities",
"add_description_to_transaction",
"add_notes_to_transaction",
]
widgets = {
"account": TomSelect(),
@@ -536,6 +733,18 @@ class InstallmentPlanForm(forms.ModelForm):
self.fields["entities"].queryset = TransactionEntity.objects.filter(
Q(active=True) | Q(installmentplan=self.instance.id)
).distinct()
else:
self.fields["account"].queryset = Account.objects.filter(is_archived=False)
self.fields["category"].queryset = TransactionCategory.objects.filter(
active=True
)
self.fields["tags"].queryset = TransactionTag.objects.filter(active=True)
self.fields["entities"].queryset = TransactionEntity.objects.filter(
active=True
)
self.helper = FormHelper()
self.helper.form_tag = False
@@ -552,7 +761,9 @@ class InstallmentPlanForm(forms.ModelForm):
css_class="form-row",
),
"description",
Switch("add_description_to_transaction"),
"notes",
Switch("add_notes_to_transaction"),
Row(
Column("number_of_installments", css_class="form-group col-md-6 mb-0"),
Column("installment_start", css_class="form-group col-md-6 mb-0"),
@@ -674,7 +885,7 @@ class TransactionCategoryForm(forms.ModelForm):
fields = ["name", "mute", "active"]
labels = {"name": _("Category name")}
help_texts = {
"mute": _("Muted categories won't count towards your monthly total")
"mute": _("Muted categories won't be displayed on monthly summaries")
}
def __init__(self, *args, **kwargs):
@@ -741,6 +952,7 @@ class RecurringTransactionForm(forms.ModelForm):
"type",
"amount",
"description",
"add_description_to_transaction",
"category",
"tags",
"start_date",
@@ -749,7 +961,9 @@ class RecurringTransactionForm(forms.ModelForm):
"recurrence_type",
"recurrence_interval",
"notes",
"add_notes_to_transaction",
"entities",
"keep_at_most",
]
widgets = {
"reference_date": AirMonthYearPickerInput(),
@@ -781,6 +995,18 @@ class RecurringTransactionForm(forms.ModelForm):
self.fields["entities"].queryset = TransactionEntity.objects.filter(
Q(active=True) | Q(recurringtransaction=self.instance.id)
).distinct()
else:
self.fields["account"].queryset = Account.objects.filter(is_archived=False)
self.fields["category"].queryset = TransactionCategory.objects.filter(
active=True
)
self.fields["tags"].queryset = TransactionTag.objects.filter(active=True)
self.fields["entities"].queryset = TransactionEntity.objects.filter(
active=True
)
self.helper = FormHelper()
self.helper.form_method = "post"
@@ -797,6 +1023,7 @@ class RecurringTransactionForm(forms.ModelForm):
css_class="form-row",
),
"description",
Switch("add_description_to_transaction"),
"amount",
Row(
Column("category", css_class="form-group col-md-6 mb-0"),
@@ -804,6 +1031,7 @@ class RecurringTransactionForm(forms.ModelForm):
css_class="form-row",
),
"notes",
Switch("add_notes_to_transaction"),
Row(
Column("start_date", css_class="form-group col-md-6 mb-0"),
Column("reference_date", css_class="form-group col-md-6 mb-0"),
@@ -815,6 +1043,7 @@ class RecurringTransactionForm(forms.ModelForm):
Column("end_date", css_class="form-group col-md-4 mb-0"),
css_class="form-row",
),
AppendedText("keep_at_most", _("future transactions")),
)
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
@@ -856,5 +1085,6 @@ class RecurringTransactionForm(forms.ModelForm):
instance.create_upcoming_transactions()
else:
instance.update_unpaid_transactions()
instance.generate_upcoming_transactions()
return instance

View File

@@ -0,0 +1,62 @@
# Generated by Django 5.1.6 on 2025-03-05 04:19
import django.db.models.deletion
import django.db.models.manager
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('transactions', '0033_transaction_internal_id'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterModelManagers(
name='installmentplan',
managers=[
('all_objects', django.db.models.manager.Manager()),
],
),
migrations.AlterModelManagers(
name='recurringtransaction',
managers=[
('all_objects', django.db.models.manager.Manager()),
],
),
migrations.AlterModelManagers(
name='transactioncategory',
managers=[
('all_objects', django.db.models.manager.Manager()),
],
),
migrations.AlterModelManagers(
name='transactionentity',
managers=[
('all_objects', django.db.models.manager.Manager()),
],
),
migrations.AlterModelManagers(
name='transactiontag',
managers=[
('all_objects', django.db.models.manager.Manager()),
],
),
migrations.AddField(
model_name='transactioncategory',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='owned_transaction_categories', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
),
migrations.AddField(
model_name='transactionentity',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='owned_transaction_entities', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
),
migrations.AddField(
model_name='transactiontag',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='owned_transaction_tags', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
),
]

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