Compare commits

...

228 Commits
0.16.1 ... dev

Author SHA1 Message Date
Herculino Trotta
50e5492ea1 feat(automatic-exchange-rate): track unsuccessful runs 2026-01-10 14:10:21 -03:00
Herculino Trotta
b074ef7929 feat: add late section to monthly and all views (w/ default ordering) 2026-01-10 02:52:46 -03:00
Herculino Trotta
ff4bd79634 fix(dca): strategy api endpoint returns nothing 2026-01-09 23:51:31 -03:00
Herculino Trotta
dd6a390e6b fix(transactions): empty internal_id raises duplicate error when editing via django admin 2026-01-09 23:25:13 -03:00
Herculino Trotta
b455a0251a fix(import_restore): unable to restore installment plans when there's multiple accounts with the same name 2025-12-30 21:59:29 -03:00
Herculino Trotta
6c90e1bb7f refactor: improve month by month and year by year value display 2025-12-30 21:58:12 -03:00
Herculino Trotta
39f66b620a feat(insights): new month by month insight 2025-12-28 22:57:29 -03:00
Herculino Trotta
92cf526b76 feat(insights): new year by year insight 2025-12-28 22:55:58 -03:00
Herculino Trotta
700d35b5d5 feat(tests): add tests for monthly summaries 2025-12-28 13:36:21 -03:00
Herculino Trotta
01f91352d6 feat(transactions:filter): make montlhy summary filter-aware 2025-12-28 13:20:25 -03:00
Herculino Trotta
a2871d5289 feat(transactions:filter): add filter for muted and unmuted transactions 2025-12-28 13:09:41 -03:00
Herculino Trotta
2076903740 refactor: order management lists by name instead of id 2025-12-27 23:43:57 -03:00
Herculino Trotta
c7ff6db0bf feat(app): add sanity checks for env variables 2025-12-26 09:55:57 -03:00
Herculino Trotta
05ede58c36 fix: "lax" deduplication fails if the comparison field has a numeric value 2025-12-20 00:17:22 -03:00
Herculino Trotta
b0101dae1a fix: input fields with text inside looks wrong 2025-12-20 00:08:56 -03:00
Herculino Trotta
edcad37926 fix: recurring transactions not adding entities or tags to created transactions 2025-12-19 23:55:30 -03:00
Herculino Trotta
94f5c25829 fix: datepicker doesn't recalculate position when changing view mode 2025-12-19 22:21:03 -03:00
Herculino Trotta
3dce9e1c55 feat: speedup startup by moving collectstatic to the Dockerfile 2025-12-19 22:13:05 -03:00
Herculino Trotta
0545fb7651 fix: try to fix stale database connections (again) 2025-12-19 21:59:55 -03:00
Herculino Trotta
f918351303 fix: user settings form 2025-12-14 12:47:36 -03:00
Herculino Trotta
8f06c06d32 fix: extra space on some translations 2025-12-14 11:56:20 -03:00
Herculino Trotta
67f79effab feat: improve text for rules 2025-12-14 11:43:52 -03:00
Herculino Trotta
c168886968 feat: improve text for rules 2025-12-14 11:42:51 -03:00
Herculino Trotta
4aa29545ec feat: remove bootstrap's collapses; improve animations 2025-12-14 11:06:55 -03:00
Herculino Trotta
cf7d4b1404 feat: improve transactions action bar animation 2025-12-13 20:47:51 -03:00
Herculino Trotta
606e6b3843 fix(style): demo mode close button is place incorrectly 2025-12-13 16:45:57 -03:00
Herculino Trotta
29b6ee3af3 feat: add "invert selection" option to transactions action bar 2025-12-13 16:35:53 -03:00
Herculino Trotta
938c128d07 fix: show muted transactions/categories on account and currency flow. 2025-12-13 16:18:19 -03:00
Herculino Trotta
dc33fda5d3 feat: prevent background tasks from running all at once 2025-12-13 15:07:38 -03:00
Herculino Trotta
e8e1144fdd fix(api): inefficient transaction update operation 2025-12-07 13:53:30 -03:00
Herculino Trotta
d3a816d91b feat(api): add endpoints for importing files and getting account balance 2025-12-07 00:32:18 -03:00
Herculino Trotta
c55d688956 fix(import:v1): always_* types for is_paid and type requires assigning a source 2025-12-06 17:52:46 -03:00
Herculino Trotta
01ea0de4b3 fix: decouple DEBUG env variable from vite dev server 2025-12-06 17:32:34 -03:00
Herculino Trotta
92f7bcfd9e fix: try to fix "the connection is closed" db errors 2025-12-06 16:46:33 -03:00
Herculino Trotta
aed3fb11fe feat: accept query params on standalone add transaction page 2025-12-06 16:17:37 -03:00
Herculino Trotta
b6f52458db chore: bump dependencies 2025-12-06 14:22:56 -03:00
Herculino Trotta
a43e3d158f chore: bump dependencies for safety 2025-12-06 14:02:37 -03:00
Herculino Trotta
49f28834e9 fix: unable to create transactions with an empty reference date when importing
fixes #410
2025-11-26 16:39:51 -03:00
Herculino Trotta
c37aa6e059 Merge pull request #404 from eitchtee/weblate
Translations update from Weblate
2025-11-24 00:43:55 -03:00
Herculino Trotta
8a5a54dcbd locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (695 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-11-24 03:20:40 +00:00
eitchtee
24ee8ecd68 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-11-24 02:56:47 +00:00
Herculino Trotta
a14332bb80 Merge pull request #402 from eitchtee/feat/replace-webpack+bootstrap
feat: replace webpack with vite and bootstrap with daisyui
2025-11-23 23:56:02 -03:00
Herculino Trotta
32747071fe Merge remote-tracking branch 'origin/feat/replace-webpack+bootstrap' into feat/replace-webpack+bootstrap 2025-11-23 23:20:48 -03:00
Herculino Trotta
24fa9cde51 style: fix deleted transactions styling 2025-11-23 23:19:23 -03:00
Herculino Trotta
372ec2f30f Merge branch 'main' into feat/replace-webpack+bootstrap 2025-11-23 23:08:02 -03:00
Herculino Trotta
fffba037a6 Merge pull request #403 from eitchtee/internal_port
feat: add internal_port env var
2025-11-23 23:07:29 -03:00
Herculino Trotta
43488147d8 ci: try to improve build times using uv 2025-11-23 22:55:53 -03:00
Herculino Trotta
31a31e9922 ci: try to improve build times using uv 2025-11-23 22:54:17 -03:00
Herculino Trotta
7af6280b29 ci: try to improve build times using uv 2025-11-23 22:48:20 -03:00
Herculino Trotta
40389396e3 ci: try to improve build times using uv 2025-11-23 22:31:52 -03:00
Herculino Trotta
21845d501e style: remove scrollbar-gutter due to weird behavior 2025-11-23 22:28:45 -03:00
Herculino Trotta
5f098e11a3 ci: try to improve build times 2025-11-23 22:08:39 -03:00
Herculino Trotta
d2de0684fb feat: use scrollbar-gutter to prevent layout from shifting on dynamic loads 2025-11-23 20:27:12 -03:00
Herculino Trotta
eb4723e890 feat: cleanup vite configs 2025-11-23 20:15:06 -03:00
Herculino Trotta
890cc90420 Merge pull request #398 from eitchtee/weblate
Translations update from Weblate
2025-11-22 13:01:58 -03:00
Herculino Trotta
307af9e40a feat: theme selection and remove unused styling 2025-11-22 03:06:22 -03:00
Herculino Trotta
1eeb0b0f5e feat: theme toasts and move elements styling to their js 2025-11-22 03:04:00 -03:00
Herculino Trotta
605ece705e feat: fixes 2025-11-22 01:30:43 -03:00
Herculino Trotta
2ae57e83cb feat: fixes 2025-11-22 01:10:01 -03:00
Herculino Trotta
af72e3f44e fix(quick_transactions): ignore internal_id 2025-11-22 00:44:57 -03:00
Herculino Trotta
e2e1c5cff5 feat: changes and fixes 2025-11-18 01:04:39 -03:00
Herculino Trotta
ed3d58f1fd fix: slow down when page is loaded 2025-11-15 14:55:37 -03:00
Ursuleac Zsolt
b58f894dc6 locale(Hungarian): update translation
Currently translated at 4.7% (33 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/hu/
2025-11-13 16:20:41 +00:00
Ursuleac Zsolt
2ed7fa44c0 locale((Hungarian)): added translation using Weblate 2025-11-13 15:27:11 +00:00
Herculino Trotta
7c3120cd43 fix: general javascript improvements 2025-11-13 11:12:43 -03:00
Herculino Trotta
2bc5e24e51 fix: theme toggle not saving correctly 2025-11-12 00:10:42 -03:00
Herculino Trotta
d3f8a637bc feat: changes 2025-11-11 23:25:08 -03:00
Herculino Trotta
b02b6451d2 fix: alpine incompatibility 2025-11-11 23:22:51 -03:00
Herculino Trotta
0b0d760bab feat: guess what, more changes 2025-11-11 20:21:01 -03:00
Herculino Trotta
b38ed37bc5 feat: oh look, more changes 2025-11-10 00:28:16 -03:00
Herculino Trotta
5d7dd622f5 feat: add internal_port env var 2025-11-09 15:42:42 -03:00
Herculino Trotta
7e37948616 feat: more changes and fixes 2025-11-09 15:31:50 -03:00
Herculino Trotta
2afb6b1f5f feat: more changes and fixes 2025-11-08 14:21:36 -03:00
Herculino Trotta
cd54df6f2d feat: more changes and fixes 2025-11-08 14:06:01 -03:00
Marcin Kisielewski
3e4ace8993 locale(Polish): update translation
Currently translated at 1.5% (11 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pl/
2025-11-08 12:20:40 +00:00
Herculino Trotta
a878af28f1 feat: more changes and fixes 2025-11-05 13:09:31 -03:00
Herculino Trotta
0a4d4c12b9 feat: another batch of fixes 2025-11-04 10:29:40 -03:00
Herculino Trotta
9ade58a003 feat: another batch 2025-11-03 01:40:13 -03:00
Herculino Trotta
89b2d0118d feat: another batch of fixes 2025-11-02 03:03:22 -03:00
Marcin Kisielewski
232d5003b8 locale(Polish): update translation
Currently translated at 1.4% (10 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pl/
2025-11-01 12:17:43 +00:00
Marcin Kisielewski
133d70d3d1 locale(Polish): update translation
Currently translated at 1.2% (9 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pl/
2025-11-01 11:17:43 +00:00
Marcin Kisielewski
e70608eaaf locale((Polish)): added translation using Weblate 2025-11-01 11:08:00 +00:00
Herculino Trotta
a63367a772 feat: first batch of work 2025-11-01 03:15:44 -03:00
mlystopad
baef86b6cb locale(Ukrainian): update translation
Currently translated at 24.1% (168 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/uk/
2025-11-01 01:17:45 +00:00
mlystopad
3011b32fa6 locale(German): update translation
Currently translated at 93.9% (653 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/de/
2025-11-01 01:17:44 +00:00
mlystopad
910decfe00 locale(Ukrainian): update translation
Currently translated at 14.5% (101 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/uk/
2025-11-01 00:17:43 +00:00
Herculino Trotta
e600d87968 feat: automated replacement 2025-10-28 14:13:30 -03:00
Herculino Trotta
dd82289488 feat: automated replacement 2025-10-28 14:13:16 -03:00
Juan David Afanador
1e816ec80a locale(Spanish): update translation
Currently translated at 100.0% (695 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/es/
2025-10-27 14:17:43 +00:00
Juan David Afanador
3b5626cbd1 locale(Spanish): update translation
Currently translated at 88.0% (612 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/es/
2025-10-27 13:17:43 +00:00
Jorge Andres Marles Florez
a819ceaa43 locale(Spanish): update translation
Currently translated at 82.3% (572 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/es/
2025-10-26 20:17:43 +00:00
Herculino Trotta
de28dbb0f0 Merge pull request #393 from eitchtee/weblate
Translations update from Weblate
2025-10-25 11:05:50 -03:00
Juan David Afanador
cfb34a4dc3 locale(Spanish): update translation
Currently translated at 80.0% (556 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/es/
2025-10-25 05:17:43 +00:00
Jorge Andres Marles Florez
efdcfc192a locale(Spanish): update translation
Currently translated at 74.6% (519 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/es/
2025-10-25 04:17:43 +00:00
Juan David Afanador
a7856a6671 locale(Spanish): update translation
Currently translated at 74.6% (519 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/es/
2025-10-25 04:17:43 +00:00
Juan David Afanador
7b8e3b528a locale(Spanish): update translation
Currently translated at 54.2% (377 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/es/
2025-10-24 22:23:31 +00:00
Juan David Afanador
cc3244a034 locale(Spanish): update translation
Currently translated at 52.0% (362 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/es/
2025-10-24 22:17:43 +00:00
Juan David Afanador
2121a68c82 locale(Spanish): update translation
Currently translated at 41.0% (285 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/es/
2025-10-24 21:32:16 +00:00
Juan David Afanador
f35002f862 locale(Spanish): update translation
Currently translated at 38.5% (268 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/es/
2025-10-24 21:22:00 +00:00
Juan David Afanador
73a992256d locale(Spanish): update translation
Currently translated at 38.5% (268 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/es/
2025-10-24 21:19:22 +00:00
Juan David Afanador
9f1098d6b9 locale(Spanish): update translation
Currently translated at 38.4% (267 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/es/
2025-10-24 21:17:43 +00:00
Juan David Afanador
2c0936b7e5 locale(Spanish): update translation
Currently translated at 34.9% (243 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/es/
2025-10-24 21:07:54 +00:00
Juan David Afanador
5fb717c3fe locale(Spanish): update translation
Currently translated at 32.6% (227 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/es/
2025-10-24 20:57:46 +00:00
Juan David Afanador
c5f94fb34d locale(Spanish): update translation
Currently translated at 31.7% (221 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/es/
2025-10-24 20:55:19 +00:00
Juan David Afanador
29cdec4577 locale(Spanish): update translation
Currently translated at 31.0% (216 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/es/
2025-10-24 20:53:56 +00:00
Juan David Afanador
82efd48e53 locale(Spanish): update translation
Currently translated at 22.5% (157 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/es/
2025-10-24 20:17:43 +00:00
Juan David Afanador
5a3a0b7e5c locale(Spanish): update translation
Currently translated at 21.7% (151 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/es/
2025-10-24 20:11:34 +00:00
Juan David Afanador
41a5900f12 locale(Spanish): update translation
Currently translated at 20.7% (144 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/es/
2025-10-24 20:03:31 +00:00
Juan David Afanador
2dbdd02350 locale(Spanish): update translation
Currently translated at 20.2% (141 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/es/
2025-10-24 20:00:40 +00:00
Juan David Afanador
fa0cde1a4e locale(Spanish): update translation
Currently translated at 20.1% (140 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/es/
2025-10-24 19:57:30 +00:00
doody
623d91d26f locale(Chinese (Traditional Han script)): update translation
Currently translated at 100.0% (695 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/zh_Hant/
2025-10-08 16:17:43 +00:00
Erwan Colin
57200437dc locale(French): update translation
Currently translated at 100.0% (695 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/fr/
2025-10-07 20:17:43 +00:00
doody
6f4a2b687c locale(Chinese (Traditional Han script)): update translation
Currently translated at 100.0% (695 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/zh_Hant/
2025-10-07 04:17:43 +00:00
Herculino Trotta
8bb40be41c Merge pull request #388 from eitchtee/weblate
Translations update from Weblate
2025-10-06 09:25:37 -03:00
doody
66c1cf2371 locale(Chinese (Traditional Han script)): update translation
Currently translated at 100.0% (695 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/zh_Hant/
2025-10-06 10:17:42 +00:00
doody
4b23836544 locale(Chinese (Traditional Han script)): update translation
Currently translated at 99.5% (692 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/zh_Hant/
2025-10-06 09:17:42 +00:00
doody
585af1270f locale(Chinese (Traditional Han script)): update translation
Currently translated at 83.7% (582 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/zh_Hant/
2025-10-06 08:17:42 +00:00
doody
a0cc51b2ec locale(Chinese (Traditional Han script)): update translation
Currently translated at 52.2% (363 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/zh_Hant/
2025-10-06 07:17:42 +00:00
Weblate
6a5de7d94d Merge remote-tracking branch 'origin/main' 2025-10-05 20:50:54 +00:00
Herculino Trotta
6d9687de0b Merge pull request #390 from eitchtee/add-mcp-server-mention
Add MCP Server section to README
2025-10-05 17:50:50 -03:00
Herculino Trotta
e9acf1dd8f Add MCP Server section to README
Added information about the MCP Server for self-hosting.

Closes #389
2025-10-05 17:50:03 -03:00
TestXuser
698e05bd06 locale(Ukrainian): update translation
Currently translated at 14.2% (99 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/uk/
2025-10-05 09:17:43 +00:00
doody
90b3778e36 locale(Chinese (Traditional Han script)): update translation
Currently translated at 37.5% (261 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/zh_Hant/
2025-10-05 09:17:42 +00:00
doody
85a773bc01 locale(Chinese (Traditional Han script)): update translation
Currently translated at 18.7% (130 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/zh_Hant/
2025-10-05 08:17:42 +00:00
doody
355016a7a5 locale(Chinese (Traditional Han script)): update translation
Currently translated at 12.9% (90 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/zh_Hant/
2025-10-05 07:17:42 +00:00
doody
f04fcf99b7 locale(Chinese (Traditional Han script)): update translation
Currently translated at 2.1% (15 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/zh_Hant/
2025-10-04 17:17:42 +00:00
doody
0fb389e7e8 locale((Chinese (Traditional Han script))): added translation using Weblate 2025-10-04 16:46:45 +00:00
Herculino Trotta
63898aeef0 Merge pull request #386 from eitchtee/weblate
Translations update from Weblate
2025-09-23 09:18:59 -03:00
sorcierwax
4fdf00d098 locale(French): update translation
Currently translated at 100.0% (695 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/fr/
2025-09-23 06:17:42 +00:00
Herculino Trotta
025cc585d5 Merge pull request #385 from eitchtee/weblate
Translations update from Weblate
2025-09-21 18:30:26 -03:00
Dimitri Decrock
17018d87cd locale(Dutch): update translation
Currently translated at 100.0% (695 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-09-21 13:17:42 +00:00
Herculino Trotta
1e5f4f6583 Merge pull request #384 from eitchtee/weblate
Translations update from Weblate
2025-09-20 11:55:33 -03:00
Herculino Trotta
a99851cf9b locale((Portuguese)): deleted translation using Weblate 2025-09-20 14:55:08 +00:00
Weblate
9fb1ad4861 Merge remote-tracking branch 'origin/main' 2025-09-20 14:47:54 +00:00
Herculino Trotta
66c3abfe37 Remove PT-BR to PT translation merge step
Removed the step that merges PT-BR translations into PT in the workflow.
2025-09-20 11:47:51 -03:00
Weblate
8ca64f5820 Merge remote-tracking branch 'origin/main' 2025-09-20 14:44:20 +00:00
Herculino Trotta
e743821570 locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (695 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-09-20 14:44:20 +00:00
eitchtee
5c698d8735 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-09-20 14:44:17 +00:00
Herculino Trotta
3e5aa90df0 Merge PT-BR translations into PT 2025-09-20 11:42:01 -03:00
Herculino Trotta
b2add14238 Merge pull request #378 from eitchtee/weblate
Translations update from Weblate
2025-09-20 11:36:30 -03:00
Herculino Trotta
a052c00aa8 Merge branch 'main' into weblate 2025-09-20 11:36:20 -03:00
eitchtee
7f343708e0 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-09-20 14:08:24 +00:00
Herculino Trotta
22e95c7f4a Merge pull request #383
feat(dca): remove ticks from price chart
2025-09-20 11:06:17 -03:00
Herculino Trotta
7645153f77 feat(dca): remove ticks from price chart 2025-09-20 11:05:56 -03:00
eitchtee
1abfed9abf chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-09-20 14:05:29 +00:00
Weblate
eea0ab009d Merge remote-tracking branch 'origin/main' 2025-09-20 14:02:44 +00:00
Herculino Trotta
29446def22 Merge pull request #382
feat(networth): add a chart with the currency difference between each month
2025-09-20 11:02:41 -03:00
Herculino Trotta
9dce5e9efe feat(networth): add a chart with the currency difference between each month 2025-09-20 11:02:23 -03:00
Weblate
695e2cb322 Merge remote-tracking branch 'origin/main' 2025-09-20 04:40:47 +00:00
Herculino Trotta
b135ec3b15 Merge pull request #381
fix(login): use full dynamic height
2025-09-20 01:40:44 -03:00
Herculino Trotta
bb3cc5da6c fix(login): use full dynamic height 2025-09-20 01:40:22 -03:00
Phillip Maizza
ca7fe24a8a locale(Italian): update translation
Currently translated at 100.0% (694 of 694 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/it/
2025-09-15 18:17:42 +00:00
Phillip Maizza
483ba74010 locale(Italian): update translation
Currently translated at 100.0% (694 of 694 strings)

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

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

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

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

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

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

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

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

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

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-09-01 06:17:42 +00:00
eitchtee
8aae6f928f chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-31 12:07:49 +00:00
Herculino Trotta
7c43b06b9f Merge pull request #356
feat(rules): add optional rules ordering
2025-08-31 09:07:07 -03:00
Herculino Trotta
72904266bf feat(rules): add optional rules ordering 2025-08-31 09:06:48 -03:00
Herculino Trotta
e16e279911 Merge pull request #355
feat(rules): add rule function to fetch transactions totals and balance
2025-08-30 15:45:45 -03:00
Herculino Trotta
670bee4325 feat(rules): add rule function to fetch transactions totals and balance 2025-08-30 15:36:07 -03:00
Herculino Trotta
3e2c1184ce Merge pull request #354
fix(yearly-overview): display total for archived accounts
2025-08-30 11:13:05 -03:00
Herculino Trotta
731f351eef fix(yearly-overview): display total for archived accounts 2025-08-30 11:12:47 -03:00
eitchtee
b7056e7aa1 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-30 02:09:21 +00:00
Herculino Trotta
accceed630 Merge pull request #353
feat(insights:category-overview): add "No entity" totals
2025-08-29 23:08:34 -03:00
Herculino Trotta
76346cb503 feat(insights:category-overview): add "No entity" totals 2025-08-29 23:08:16 -03:00
eitchtee
3df8952ea2 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-30 01:48:37 +00:00
Herculino Trotta
9bd067da96 Merge pull request #352
feat(currencies): allow archiving
2025-08-29 22:47:26 -03:00
Herculino Trotta
1abe9e9f62 feat(currencies): allow archiving 2025-08-29 22:47:00 -03:00
eitchtee
1a86b5dea4 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-28 02:18:45 +00:00
Herculino Trotta
8f2f5a16c2 Merge pull request #349
fix(transactions:quick-transactions): error when saving due to wrong field definition
2025-08-27 23:17:09 -03:00
Herculino Trotta
4565dc770b fix(transactions:quick-transactions): error when saving due to wrong field definition 2025-08-27 23:16:06 -03:00
Herculino Trotta
23673def09 Merge pull request #346
fix(common:tasks): remove_old_jobs always failing
2025-08-24 10:41:48 -03:00
Herculino Trotta
dd2b9ead7e fix(common:tasks): remove_old_jobs always failing 2025-08-24 10:41:26 -03:00
Rhesa Daiva Bremana
2078e9f3e4 locale((Indonesian)): added translation using Weblate 2025-08-23 12:43:54 +00:00
eitchtee
e6bab57ab4 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-22 20:36:08 +00:00
Herculino Trotta
38d50a78f4 Merge pull request #344
fix(sidebar): sidebar status not saving properly
2025-08-22 17:34:42 -03:00
Herculino Trotta
0d947f9ba6 fix(sidebar): sidebar status not saving properly 2025-08-22 17:34:12 -03:00
326 changed files with 40019 additions and 28599 deletions

View File

@@ -12,7 +12,7 @@ on:
required: true
type: string
ref:
description: 'Git ref to checkout (branch, tag, or SHA)'
description: 'Git ref to checkout'
required: true
default: 'main'
type: string
@@ -29,73 +29,57 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # Needed if you switch to GHCR, good practice
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.ref }}
if: github.event_name == 'workflow_dispatch'
- name: Checkout code (non-manual)
uses: actions/checkout@v4
if: github.event_name != 'workflow_dispatch'
ref: ${{ inputs.ref || github.ref }}
- name: Log in to Docker Hub
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# This action handles all the logic for tags (nightly vs release vs custom)
- name: Docker Metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}
tags: |
# Logic for Push to Main -> nightly
type=raw,value=nightly,enable=${{ github.event_name == 'push' }}
# Logic for Release -> semver and latest
type=semver,pattern={{version}},enable=${{ github.event_name == 'release' }}
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
# Logic for Manual Dispatch -> custom input
type=raw,value=${{ inputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push nightly image
if: github.event_name == 'push'
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/prod/django/Dockerfile
push: true
provenance: false
# Pass the calculated tags from the meta step
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=nightly
tags: ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:nightly
VERSION=${{ steps.meta.outputs.version }}
platforms: linux/amd64,linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push release image
if: github.event_name == 'release'
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/prod/django/Dockerfile
push: true
provenance: false
build-args: |
VERSION=${{ github.event.release.tag_name }}
tags: |
${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:latest
${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:${{ github.event.release.tag_name }}
platforms: linux/amd64,linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push custom image
if: github.event_name == 'workflow_dispatch'
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/prod/django/Dockerfile
push: true
provenance: false
build-args: |
VERSION=${{ github.event.inputs.tag }}
tags: ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:${{ github.event.inputs.tag }}
platforms: linux/amd64,linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
# --- CACHE CONFIGURATION ---
# We set a specific 'scope' key.
# This allows the Release tag to see the cache created by the Main branch.
cache-from: type=gha,scope=build-cache
cache-to: type=gha,mode=max,scope=build-cache

5
.gitignore vendored
View File

@@ -123,6 +123,7 @@ celerybeat.pid
# Environments
.env
.prod.env
.venv
env/
venv/
@@ -160,3 +161,7 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
.idea/
node_modules/
postgres_data/
.prod.env

8
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"djlint.showInstallError": false,
"files.associations": {
"*.css": "tailwindcss"
},
"tailwindCSS.experimental.configFile": "frontend/src/styles/tailwind.css",
"djlint.profile": "django",
}

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="#mcp-server">MCP Server</a> •
<a href="#help-us-translate-wygiwyh">Translate</a> •
<a href="#caveats-and-warnings">Caveats and Warnings</a> •
<a href="#built-with">Built with</a>
@@ -126,6 +127,7 @@ To create the first user, open the container's console using Unraid's UI, by cli
| variable | type | default | explanation |
|-------------------------------|-------------|-----------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| INTERNAL_PORT | int | 8000 | The port on which the app listens on. Defaults to 8000 if not set. |
| DJANGO_ALLOWED_HOSTS | string | localhost 127.0.0.1 | A list of space separated domains and IPs representing the host/domain names that WYGIWYH site can serve. [Click here](https://docs.djangoproject.com/en/5.1/ref/settings/#allowed-hosts) for more details |
| HTTPS_ENABLED | true\|false | false | Whether to use secure cookies. If this is set to true, the cookie will be marked as “secure”, which means browsers may ensure that the cookie is only sent under an HTTPS connection |
| URL | string | http://localhost http://127.0.0.1 | A list of space separated domains and IPs (with the protocol) representing the trusted origins for unsafe requests (e.g. POST). [Click here](https://docs.djangoproject.com/en/5.1/ref/settings/#csrf-trusted-origins ) for more details |
@@ -143,7 +145,10 @@ To create the first user, open the container's console using Unraid's UI, by cli
| 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. |
| CHECK_FOR_UPDATES | true\|false | true | Check and notify users about new versions. The check is done by doing a single query to Github's API every 12 hours. |
| DJANGO_VITE_DEV_MODE | true\|false | false | Enables Vite dev server mode for frontend development. When true, assets are served from Vite's dev server instead of the build manifest. For development only! |
| DJANGO_VITE_DEV_SERVER_PORT | int | 5173 | The port where Vite's dev server is running. Only used when DJANGO_VITE_DEV_MODE is true. For development only! |
| DJANGO_VITE_DEV_SERVER_HOST | string | localhost | The host where Vite's dev server is running. Only used when DJANGO_VITE_DEV_MODE is true. For development only! |
## OIDC Configuration
@@ -182,6 +187,10 @@ Check out our [Wiki](https://github.com/eitchtee/WYGIWYH/wiki) for more informat
> [!NOTE]
> Login with your github account
# MCP Server
[IZIme07](https://github.com/IZIme07) has kindly created an MCP Server for WYGIWYH that you can self-host. [Check it out at MCP-WYGIWYH](https://github.com/ReNewator/MCP-WYGIWYH)!
# Caveats and Warnings
- I'm not an accountant, some terms and even calculations might be wrong. Make sure to open an issue if you see anything that could be improved.

View File

@@ -11,6 +11,7 @@ https://docs.djangoproject.com/en/5.1/ref/settings/
"""
import os
import re
import sys
from pathlib import Path
@@ -46,7 +47,7 @@ INSTALLED_APPS = [
"django.contrib.sites",
"whitenoise.runserver_nostatic",
"django.contrib.staticfiles",
"webpack_boilerplate",
"django_vite",
"django.contrib.humanize",
"django.contrib.postgres",
"django_browser_reload",
@@ -128,12 +129,23 @@ STORAGES = {
WHITENOISE_MANIFEST_STRICT = False
def immutable_file_test(path, url):
# Match vite (rollup)-generated hashes, à la, `some_file-CSliV9zW.js`
return re.match(r"^.+[.-][0-9a-zA-Z_-]{8,12}\..+$", url)
WHITENOISE_IMMUTABLE_FILE_TEST = immutable_file_test
WSGI_APPLICATION = "WYGIWYH.wsgi.application"
# Database
# https://docs.djangoproject.com/en/5.1/ref/settings/#databases
THREADS = int(os.getenv("GUNICORN_THREADS", 1))
MAX_POOL_SIZE = THREADS + 1
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
@@ -142,6 +154,17 @@ DATABASES = {
"PASSWORD": os.getenv("SQL_PASSWORD", "password"),
"HOST": os.getenv("SQL_HOST", "localhost"),
"PORT": os.getenv("SQL_PORT", "5432"),
"CONN_MAX_AGE": 0,
"CONN_HEALTH_CHECKS": True,
"OPTIONS": {
"pool": {
"min_size": 1,
"max_size": MAX_POOL_SIZE,
"timeout": 10,
"max_lifetime": 600,
"max_idle": 300,
},
},
}
}
@@ -289,7 +312,7 @@ STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "static_files"
STATICFILES_DIRS = [
ROOT_DIR / "frontend/build",
ROOT_DIR / "frontend" / "build",
BASE_DIR / "static",
]
@@ -305,9 +328,11 @@ CACHES = {
}
}
WEBPACK_LOADER = {
"MANIFEST_FILE": ROOT_DIR / "frontend/build/manifest.json",
}
DJANGO_VITE_ASSETS_PATH = STATIC_ROOT
DJANGO_VITE_MANIFEST_PATH = DJANGO_VITE_ASSETS_PATH / "manifest.json"
DJANGO_VITE_DEV_MODE = os.getenv("DJANGO_VITE_DEV_MODE", "false").lower() == "true"
DJANGO_VITE_DEV_SERVER_PORT = int(os.getenv("DJANGO_VITE_DEV_SERVER_PORT", "5173"))
DJANGO_VITE_DEV_SERVER_HOST = os.getenv("DJANGO_VITE_DEV_SERVER_HOST", "localhost")
# Default primary key field type
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
@@ -354,8 +379,11 @@ ACCOUNT_ADAPTER = "allauth.account.adapter.DefaultAccountAdapter"
SOCIALACCOUNT_ADAPTER = "allauth.socialaccount.adapter.DefaultSocialAccountAdapter"
# CRISPY FORMS
CRISPY_ALLOWED_TEMPLATE_PACKS = ["bootstrap5", "crispy_forms/pure_text"]
CRISPY_TEMPLATE_PACK = "bootstrap5"
CRISPY_ALLOWED_TEMPLATE_PACKS = [
"crispy_forms/pure_text",
"crispy-daisyui",
]
CRISPY_TEMPLATE_PACK = "crispy-daisyui"
SESSION_EXPIRE_AT_BROWSER_CLOSE = False
SESSION_COOKIE_AGE = int(os.getenv("SESSION_EXPIRY_TIME", 2678400)) # 31 days

View File

@@ -1,21 +1,21 @@
from crispy_bootstrap5.bootstrap5 import Switch
from apps.accounts.models import Account, AccountGroup
from apps.common.fields.forms.dynamic_select import (
DynamicModelChoiceField,
DynamicModelMultipleChoiceField,
)
from apps.common.widgets.crispy.daisyui import Switch
from apps.common.widgets.crispy.submit import NoClassSubmit
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
from apps.common.widgets.tom_select import TomSelect
from apps.currencies.models import Currency
from apps.transactions.models import TransactionCategory, TransactionTag
from crispy_forms.bootstrap import FormActions
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Field, Column, Row
from crispy_forms.layout import Column, Field, Layout, Row
from django import forms
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from apps.accounts.models import Account
from apps.accounts.models import AccountGroup
from apps.common.fields.forms.dynamic_select import (
DynamicModelMultipleChoiceField,
DynamicModelChoiceField,
)
from apps.common.widgets.crispy.submit import NoClassSubmit
from apps.common.widgets.tom_select import TomSelect
from apps.transactions.models import TransactionCategory, TransactionTag
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
class AccountGroupForm(forms.ModelForm):
class Meta:
@@ -36,17 +36,13 @@ class AccountGroupForm(forms.ModelForm):
if self.instance and self.instance.pk:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
),
)
else:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
),
)
@@ -79,6 +75,18 @@ class AccountForm(forms.ModelForm):
self.fields["group"].queryset = AccountGroup.objects.all()
if self.instance.id:
qs = Currency.objects.filter(
Q(is_archived=False) | Q(accounts=self.instance.id)
).distinct()
self.fields["currency"].queryset = qs
self.fields["exchange_currency"].queryset = qs
else:
qs = Currency.objects.filter(Q(is_archived=False))
self.fields["currency"].queryset = qs
self.fields["exchange_currency"].queryset = qs
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.form_method = "post"
@@ -94,17 +102,13 @@ class AccountForm(forms.ModelForm):
if self.instance and self.instance.pk:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
),
)
else:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
),
)
@@ -142,9 +146,8 @@ class AccountBalanceForm(forms.Form):
self.helper.layout = Layout(
"new_balance",
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",
Column("category"),
Column("tags"),
),
Field("account_id"),
)

View File

@@ -0,0 +1,33 @@
from decimal import Decimal
from django.db import models
from apps.accounts.models import Account
from apps.transactions.models import Transaction
def get_account_balance(account: Account, paid_only: bool = True) -> Decimal:
"""
Calculate account balance (income - expense).
Args:
account: Account instance to calculate balance for.
paid_only: If True, only count paid transactions (current balance).
If False, count all transactions (projected balance).
Returns:
Decimal: The calculated balance (income - expense).
"""
filters = {"account": account}
if paid_only:
filters["is_paid"] = True
income = Transaction.objects.filter(
type=Transaction.Type.INCOME, **filters
).aggregate(total=models.Sum("amount"))["total"] or Decimal("0")
expense = Transaction.objects.filter(
type=Transaction.Type.EXPENSE, **filters
).aggregate(total=models.Sum("amount"))["total"] or Decimal("0")
return income - expense

View File

@@ -1,3 +1,5 @@
from datetime import date
from django.test import TestCase
from apps.accounts.models import Account, AccountGroup
@@ -39,3 +41,135 @@ class AccountTests(TestCase):
exchange_currency=self.exchange_currency,
)
self.assertEqual(account.exchange_currency, self.exchange_currency)
class GetAccountBalanceServiceTests(TestCase):
"""Tests for the get_account_balance service function"""
def setUp(self):
"""Set up test data"""
from apps.transactions.models import Transaction
self.Transaction = Transaction
self.currency = Currency.objects.create(
code="BRL", name="Brazilian Real", decimal_places=2, prefix="R$ "
)
self.account_group = AccountGroup.objects.create(name="Service Test Group")
self.account = Account.objects.create(
name="Service Test Account", group=self.account_group, currency=self.currency
)
def test_balance_with_no_transactions(self):
"""Test balance is 0 when no transactions exist"""
from apps.accounts.services import get_account_balance
from decimal import Decimal
balance = get_account_balance(self.account, paid_only=True)
self.assertEqual(balance, Decimal("0"))
def test_current_balance_only_counts_paid(self):
"""Test current balance only counts paid transactions"""
from apps.accounts.services import get_account_balance
from decimal import Decimal
# Paid income
self.Transaction.objects.create(
account=self.account,
type=self.Transaction.Type.INCOME,
amount=Decimal("100.00"),
is_paid=True,
date=date(2025, 1, 1),
description="Paid income",
)
# Unpaid income (should not count)
self.Transaction.objects.create(
account=self.account,
type=self.Transaction.Type.INCOME,
amount=Decimal("50.00"),
is_paid=False,
date=date(2025, 1, 1),
description="Unpaid income",
)
# Paid expense
self.Transaction.objects.create(
account=self.account,
type=self.Transaction.Type.EXPENSE,
amount=Decimal("30.00"),
is_paid=True,
date=date(2025, 1, 1),
description="Paid expense",
)
balance = get_account_balance(self.account, paid_only=True)
self.assertEqual(balance, Decimal("70.00")) # 100 - 30
def test_projected_balance_counts_all(self):
"""Test projected balance counts all transactions"""
from apps.accounts.services import get_account_balance
from decimal import Decimal
# Paid income
self.Transaction.objects.create(
account=self.account,
type=self.Transaction.Type.INCOME,
amount=Decimal("100.00"),
is_paid=True,
date=date(2025, 1, 1),
description="Paid income",
)
# Unpaid income
self.Transaction.objects.create(
account=self.account,
type=self.Transaction.Type.INCOME,
amount=Decimal("50.00"),
is_paid=False,
date=date(2025, 1, 1),
description="Unpaid income",
)
# Paid expense
self.Transaction.objects.create(
account=self.account,
type=self.Transaction.Type.EXPENSE,
amount=Decimal("30.00"),
is_paid=True,
date=date(2025, 1, 1),
description="Paid expense",
)
# Unpaid expense
self.Transaction.objects.create(
account=self.account,
type=self.Transaction.Type.EXPENSE,
amount=Decimal("20.00"),
is_paid=False,
date=date(2025, 1, 1),
description="Unpaid expense",
)
balance = get_account_balance(self.account, paid_only=False)
self.assertEqual(balance, Decimal("100.00")) # (100 + 50) - (30 + 20)
def test_balance_defaults_to_paid_only(self):
"""Test that paid_only defaults to True"""
from apps.accounts.services import get_account_balance
from decimal import Decimal
self.Transaction.objects.create(
account=self.account,
type=self.Transaction.Type.INCOME,
amount=Decimal("100.00"),
is_paid=True,
date=date(2025, 1, 1),
description="Paid",
)
self.Transaction.objects.create(
account=self.account,
type=self.Transaction.Type.INCOME,
amount=Decimal("50.00"),
is_paid=False,
date=date(2025, 1, 1),
description="Unpaid",
)
balance = get_account_balance(self.account) # defaults to paid_only=True
self.assertEqual(balance, Decimal("100.00"))

View File

@@ -25,7 +25,7 @@ def account_groups_index(request):
@login_required
@require_http_methods(["GET"])
def account_groups_list(request):
account_groups = AccountGroup.objects.all().order_by("id")
account_groups = AccountGroup.objects.all().order_by("name")
return render(
request,
"account_groups/fragments/list.html",

View File

@@ -25,7 +25,7 @@ def accounts_index(request):
@login_required
@require_http_methods(["GET"])
def accounts_list(request):
accounts = Account.objects.all().order_by("id")
accounts = Account.objects.all().order_by("name")
return render(
request,
"accounts/fragments/list.html",

View File

@@ -11,23 +11,13 @@ from django.utils.translation import gettext_lazy as _
from apps.accounts.forms import AccountBalanceFormSet
from apps.accounts.models import Account, Transaction
from apps.accounts.services import get_account_balance
from apps.common.decorators.htmx import only_htmx
@only_htmx
@login_required
def account_reconciliation(request):
def get_account_balance(account):
income = Transaction.objects.filter(
account=account, type=Transaction.Type.INCOME, is_paid=True
).aggregate(total=models.Sum("amount"))["total"] or Decimal("0")
expense = Transaction.objects.filter(
account=account, type=Transaction.Type.EXPENSE, is_paid=True
).aggregate(total=models.Sum("amount"))["total"] or Decimal("0")
return income - expense
initial_data = [
{
"account_id": account.id,

View File

@@ -2,3 +2,5 @@ from .transactions import *
from .accounts import *
from .currencies import *
from .dca import *
from .imports import *

View File

@@ -67,3 +67,12 @@ class AccountSerializer(serializers.ModelSerializer):
setattr(instance, attr, value)
instance.save()
return instance
class AccountBalanceSerializer(serializers.Serializer):
"""Serializer for account balance response."""
current_balance = serializers.DecimalField(max_digits=20, decimal_places=10)
projected_balance = serializers.DecimalField(max_digits=20, decimal_places=10)
currency = CurrencySerializer()

View File

@@ -0,0 +1,41 @@
from rest_framework import serializers
from apps.import_app.models import ImportProfile, ImportRun
class ImportProfileSerializer(serializers.ModelSerializer):
"""Serializer for listing import profiles."""
class Meta:
model = ImportProfile
fields = ["id", "name", "version", "yaml_config"]
class ImportRunSerializer(serializers.ModelSerializer):
"""Serializer for listing import runs."""
class Meta:
model = ImportRun
fields = [
"id",
"status",
"profile",
"file_name",
"logs",
"processed_rows",
"total_rows",
"successful_rows",
"skipped_rows",
"failed_rows",
"started_at",
"finished_at",
]
class ImportFileSerializer(serializers.Serializer):
"""Serializer for uploading a file to import using an existing profile."""
profile_id = serializers.PrimaryKeyRelatedField(
queryset=ImportProfile.objects.all(), source="profile"
)
file = serializers.FileField()

View File

@@ -0,0 +1,4 @@
# Import all test classes for Django test discovery
from .test_imports import *
from .test_accounts import *

View File

@@ -0,0 +1,99 @@
from datetime import date
from decimal import Decimal
from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from rest_framework import status
from rest_framework.test import APIClient
from apps.accounts.models import Account, AccountGroup
from apps.currencies.models import Currency
from apps.transactions.models import Transaction
@override_settings(
STORAGES={
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
},
},
WHITENOISE_AUTOREFRESH=True,
)
class AccountBalanceAPITests(TestCase):
"""Tests for the Account Balance API endpoint"""
def setUp(self):
"""Set up test data"""
User = get_user_model()
self.user = User.objects.create_user(
email="testuser@test.com", password="testpass123"
)
self.client = APIClient()
self.client.force_authenticate(user=self.user)
self.currency = Currency.objects.create(
code="USD", name="US Dollar", decimal_places=2, prefix="$ "
)
self.account_group = AccountGroup.objects.create(name="Test Group")
self.account = Account.objects.create(
name="Test Account", group=self.account_group, currency=self.currency
)
# Create some transactions
Transaction.objects.create(
account=self.account,
type=Transaction.Type.INCOME,
amount=Decimal("500.00"),
is_paid=True,
date=date(2025, 1, 1),
description="Paid income",
)
Transaction.objects.create(
account=self.account,
type=Transaction.Type.INCOME,
amount=Decimal("200.00"),
is_paid=False,
date=date(2025, 1, 15),
description="Unpaid income",
)
Transaction.objects.create(
account=self.account,
type=Transaction.Type.EXPENSE,
amount=Decimal("100.00"),
is_paid=True,
date=date(2025, 1, 10),
description="Paid expense",
)
def test_get_balance_success(self):
"""Test successful balance retrieval"""
response = self.client.get(f"/api/accounts/{self.account.id}/balance/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn("current_balance", response.data)
self.assertIn("projected_balance", response.data)
self.assertIn("currency", response.data)
# Current: 500 - 100 = 400
self.assertEqual(Decimal(response.data["current_balance"]), Decimal("400.00"))
# Projected: (500 + 200) - 100 = 600
self.assertEqual(Decimal(response.data["projected_balance"]), Decimal("600.00"))
# Check currency data
self.assertEqual(response.data["currency"]["code"], "USD")
def test_get_balance_nonexistent_account(self):
"""Test balance for non-existent account returns 404"""
response = self.client.get("/api/accounts/99999/balance/")
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
def test_get_balance_unauthenticated(self):
"""Test unauthenticated request returns 403"""
unauthenticated_client = APIClient()
response = unauthenticated_client.get(
f"/api/accounts/{self.account.id}/balance/"
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

View File

@@ -0,0 +1,404 @@
from io import BytesIO
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from rest_framework import status
from rest_framework.test import APIClient
from apps.import_app.models import ImportProfile, ImportRun
@override_settings(
STORAGES={
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
},
},
WHITENOISE_AUTOREFRESH=True,
)
class ImportAPITests(TestCase):
"""Tests for the Import API endpoint"""
def setUp(self):
"""Set up test data"""
User = get_user_model()
self.user = User.objects.create_user(
email="testuser@test.com", password="testpass123"
)
self.client = APIClient()
self.client.force_authenticate(user=self.user)
# Create a basic import profile with minimal valid YAML config
self.profile = ImportProfile.objects.create(
name="Test Profile",
version=ImportProfile.Versions.VERSION_1,
yaml_config="""
file_type: csv
date_format: "%Y-%m-%d"
column_mapping:
date:
source: date
description:
source: description
amount:
source: amount
transaction_type:
detection_method: always_expense
is_paid:
detection_method: always_paid
account:
source: account
match_field: name
""",
)
@patch("apps.import_app.tasks.process_import.defer")
@patch("django.core.files.storage.FileSystemStorage.save")
@patch("django.core.files.storage.FileSystemStorage.path")
def test_create_import_success(self, mock_path, mock_save, mock_defer):
"""Test successful file upload creates ImportRun and queues task"""
mock_save.return_value = "test_file.csv"
mock_path.return_value = "/usr/src/app/temp/test_file.csv"
csv_content = b"date,description,amount,account\n2025-01-01,Test,100,Main"
file = SimpleUploadedFile(
"test_file.csv", csv_content, content_type="text/csv"
)
response = self.client.post(
"/api/import/import/",
{"profile_id": self.profile.id, "file": file},
format="multipart",
)
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
self.assertIn("import_run_id", response.data)
self.assertEqual(response.data["status"], "queued")
# Verify ImportRun was created
import_run = ImportRun.objects.get(id=response.data["import_run_id"])
self.assertEqual(import_run.profile, self.profile)
self.assertEqual(import_run.file_name, "test_file.csv")
# Verify task was deferred
mock_defer.assert_called_once_with(
import_run_id=import_run.id,
file_path="/usr/src/app/temp/test_file.csv",
user_id=self.user.id,
)
def test_create_import_missing_profile(self):
"""Test request without profile_id returns 400"""
csv_content = b"date,description,amount\n2025-01-01,Test,100"
file = SimpleUploadedFile(
"test_file.csv", csv_content, content_type="text/csv"
)
response = self.client.post(
"/api/import/import/",
{"file": file},
format="multipart",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("profile_id", response.data)
def test_create_import_missing_file(self):
"""Test request without file returns 400"""
response = self.client.post(
"/api/import/import/",
{"profile_id": self.profile.id},
format="multipart",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("file", response.data)
def test_create_import_invalid_profile(self):
"""Test request with non-existent profile returns 400"""
csv_content = b"date,description,amount\n2025-01-01,Test,100"
file = SimpleUploadedFile(
"test_file.csv", csv_content, content_type="text/csv"
)
response = self.client.post(
"/api/import/import/",
{"profile_id": 99999, "file": file},
format="multipart",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("profile_id", response.data)
@patch("apps.import_app.tasks.process_import.defer")
@patch("django.core.files.storage.FileSystemStorage.save")
@patch("django.core.files.storage.FileSystemStorage.path")
def test_create_import_xlsx(self, mock_path, mock_save, mock_defer):
"""Test successful XLSX file upload"""
mock_save.return_value = "test_file.xlsx"
mock_path.return_value = "/usr/src/app/temp/test_file.xlsx"
# Create a simple XLSX-like content (just for the upload test)
xlsx_content = BytesIO(b"PK\x03\x04") # XLSX files start with PK header
file = SimpleUploadedFile(
"test_file.xlsx",
xlsx_content.getvalue(),
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
response = self.client.post(
"/api/import/import/",
{"profile_id": self.profile.id, "file": file},
format="multipart",
)
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
self.assertIn("import_run_id", response.data)
def test_unauthenticated_request(self):
"""Test unauthenticated request returns 403"""
unauthenticated_client = APIClient()
csv_content = b"date,description,amount\n2025-01-01,Test,100"
file = SimpleUploadedFile(
"test_file.csv", csv_content, content_type="text/csv"
)
response = unauthenticated_client.post(
"/api/import/import/",
{"profile_id": self.profile.id, "file": file},
format="multipart",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
@override_settings(
STORAGES={
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
},
},
WHITENOISE_AUTOREFRESH=True,
)
class ImportProfileAPITests(TestCase):
"""Tests for the Import Profile API endpoints"""
def setUp(self):
"""Set up test data"""
User = get_user_model()
self.user = User.objects.create_user(
email="testuser@test.com", password="testpass123"
)
self.client = APIClient()
self.client.force_authenticate(user=self.user)
self.profile1 = ImportProfile.objects.create(
name="Profile 1",
version=ImportProfile.Versions.VERSION_1,
yaml_config="""
file_type: csv
date_format: "%Y-%m-%d"
column_mapping:
date:
source: date
description:
source: description
amount:
source: amount
transaction_type:
detection_method: always_expense
is_paid:
detection_method: always_paid
account:
source: account
match_field: name
""",
)
self.profile2 = ImportProfile.objects.create(
name="Profile 2",
version=ImportProfile.Versions.VERSION_1,
yaml_config="""
file_type: csv
date_format: "%Y-%m-%d"
column_mapping:
date:
source: date
description:
source: description
amount:
source: amount
transaction_type:
detection_method: always_income
is_paid:
detection_method: always_unpaid
account:
source: account
match_field: name
""",
)
def test_list_profiles(self):
"""Test listing all profiles"""
response = self.client.get("/api/import/profiles/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["count"], 2)
self.assertEqual(len(response.data["results"]), 2)
def test_retrieve_profile(self):
"""Test retrieving a specific profile"""
response = self.client.get(f"/api/import/profiles/{self.profile1.id}/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["id"], self.profile1.id)
self.assertEqual(response.data["name"], "Profile 1")
self.assertIn("yaml_config", response.data)
def test_retrieve_nonexistent_profile(self):
"""Test retrieving a non-existent profile returns 404"""
response = self.client.get("/api/import/profiles/99999/")
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
def test_profiles_unauthenticated(self):
"""Test unauthenticated request returns 403"""
unauthenticated_client = APIClient()
response = unauthenticated_client.get("/api/import/profiles/")
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
@override_settings(
STORAGES={
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
},
},
WHITENOISE_AUTOREFRESH=True,
)
class ImportRunAPITests(TestCase):
"""Tests for the Import Run API endpoints"""
def setUp(self):
"""Set up test data"""
User = get_user_model()
self.user = User.objects.create_user(
email="testuser@test.com", password="testpass123"
)
self.client = APIClient()
self.client.force_authenticate(user=self.user)
self.profile1 = ImportProfile.objects.create(
name="Profile 1",
version=ImportProfile.Versions.VERSION_1,
yaml_config="""
file_type: csv
date_format: "%Y-%m-%d"
column_mapping:
date:
source: date
description:
source: description
amount:
source: amount
transaction_type:
detection_method: always_expense
is_paid:
detection_method: always_paid
account:
source: account
match_field: name
""",
)
self.profile2 = ImportProfile.objects.create(
name="Profile 2",
version=ImportProfile.Versions.VERSION_1,
yaml_config="""
file_type: csv
date_format: "%Y-%m-%d"
column_mapping:
date:
source: date
description:
source: description
amount:
source: amount
transaction_type:
detection_method: always_income
is_paid:
detection_method: always_unpaid
account:
source: account
match_field: name
""",
)
# Create import runs
self.run1 = ImportRun.objects.create(
profile=self.profile1,
file_name="file1.csv",
status=ImportRun.Status.FINISHED,
)
self.run2 = ImportRun.objects.create(
profile=self.profile1,
file_name="file2.csv",
status=ImportRun.Status.QUEUED,
)
self.run3 = ImportRun.objects.create(
profile=self.profile2,
file_name="file3.csv",
status=ImportRun.Status.FINISHED,
)
def test_list_all_runs(self):
"""Test listing all runs"""
response = self.client.get("/api/import/runs/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["count"], 3)
self.assertEqual(len(response.data["results"]), 3)
def test_list_runs_by_profile(self):
"""Test filtering runs by profile_id"""
response = self.client.get(f"/api/import/runs/?profile_id={self.profile1.id}")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["count"], 2)
for run in response.data["results"]:
self.assertEqual(run["profile"], self.profile1.id)
def test_list_runs_by_other_profile(self):
"""Test filtering runs by another profile_id"""
response = self.client.get(f"/api/import/runs/?profile_id={self.profile2.id}")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["count"], 1)
self.assertEqual(response.data["results"][0]["profile"], self.profile2.id)
def test_retrieve_run(self):
"""Test retrieving a specific run"""
response = self.client.get(f"/api/import/runs/{self.run1.id}/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["id"], self.run1.id)
self.assertEqual(response.data["file_name"], "file1.csv")
self.assertEqual(response.data["status"], "FINISHED")
def test_retrieve_nonexistent_run(self):
"""Test retrieving a non-existent run returns 404"""
response = self.client.get("/api/import/runs/99999/")
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
def test_runs_unauthenticated(self):
"""Test unauthenticated request returns 403"""
unauthenticated_client = APIClient()
response = unauthenticated_client.get("/api/import/runs/")
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

View File

@@ -16,7 +16,11 @@ router.register(r"currencies", views.CurrencyViewSet)
router.register(r"exchange-rates", views.ExchangeRateViewSet)
router.register(r"dca/strategies", views.DCAStrategyViewSet)
router.register(r"dca/entries", views.DCAEntryViewSet)
router.register(r"import/profiles", views.ImportProfileViewSet, basename="import-profiles")
router.register(r"import/runs", views.ImportRunViewSet, basename="import-runs")
router.register(r"import/import", views.ImportViewSet, basename="import-import")
urlpatterns = [
path("", include(router.urls)),
]

View File

@@ -2,3 +2,5 @@ from .transactions import *
from .accounts import *
from .currencies import *
from .dca import *
from .imports import *

View File

@@ -1,11 +1,18 @@
from drf_spectacular.utils import extend_schema, extend_schema_view
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from apps.api.custom.pagination import CustomPageNumberPagination
from apps.accounts.models import AccountGroup, Account
from apps.api.serializers import AccountGroupSerializer, AccountSerializer
from apps.accounts.services import get_account_balance
from apps.api.custom.pagination import CustomPageNumberPagination
from apps.api.serializers import AccountGroupSerializer, AccountSerializer, AccountBalanceSerializer
class AccountGroupViewSet(viewsets.ModelViewSet):
"""ViewSet for managing account groups."""
queryset = AccountGroup.objects.all()
serializer_class = AccountGroupSerializer
pagination_class = CustomPageNumberPagination
@@ -14,7 +21,16 @@ class AccountGroupViewSet(viewsets.ModelViewSet):
return AccountGroup.objects.all().order_by("id")
@extend_schema_view(
balance=extend_schema(
summary="Get account balance",
description="Returns the current and projected balance for the account, along with currency data.",
responses={200: AccountBalanceSerializer},
),
)
class AccountViewSet(viewsets.ModelViewSet):
"""ViewSet for managing accounts."""
queryset = Account.objects.all()
serializer_class = AccountSerializer
pagination_class = CustomPageNumberPagination
@@ -25,3 +41,20 @@ class AccountViewSet(viewsets.ModelViewSet):
.order_by("id")
.select_related("group", "currency", "exchange_currency")
)
@action(detail=True, methods=["get"], permission_classes=[IsAuthenticated])
def balance(self, request, pk=None):
"""Get current and projected balance for an account."""
account = self.get_object()
current_balance = get_account_balance(account, paid_only=True)
projected_balance = get_account_balance(account, paid_only=False)
serializer = AccountBalanceSerializer({
"current_balance": current_balance,
"projected_balance": projected_balance,
"currency": account.currency,
})
return Response(serializer.data)

View File

@@ -9,6 +9,9 @@ class DCAStrategyViewSet(viewsets.ModelViewSet):
queryset = DCAStrategy.objects.all()
serializer_class = DCAStrategySerializer
def get_queryset(self):
return DCAStrategy.objects.all().order_by("id")
@action(detail=True, methods=["get"])
def investment_frequency(self, request, pk=None):
strategy = self.get_object()

View File

@@ -0,0 +1,123 @@
from django.core.files.storage import FileSystemStorage
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view, inline_serializer
from rest_framework import serializers as drf_serializers
from rest_framework import status, viewsets
from rest_framework.parsers import MultiPartParser
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from apps.api.serializers import ImportFileSerializer, ImportProfileSerializer, ImportRunSerializer
from apps.import_app.models import ImportProfile, ImportRun
from apps.import_app.tasks import process_import
@extend_schema_view(
list=extend_schema(
summary="List import profiles",
description="Returns a paginated list of all available import profiles.",
),
retrieve=extend_schema(
summary="Get import profile",
description="Returns the details of a specific import profile by ID.",
),
)
class ImportProfileViewSet(viewsets.ReadOnlyModelViewSet):
"""ViewSet for listing and retrieving import profiles."""
queryset = ImportProfile.objects.all()
serializer_class = ImportProfileSerializer
permission_classes = [IsAuthenticated]
@extend_schema_view(
list=extend_schema(
summary="List import runs",
description="Returns a paginated list of import runs. Optionally filter by profile_id.",
parameters=[
OpenApiParameter(
name="profile_id",
type=int,
location=OpenApiParameter.QUERY,
description="Filter runs by profile ID",
required=False,
),
],
),
retrieve=extend_schema(
summary="Get import run",
description="Returns the details of a specific import run by ID, including status and logs.",
),
)
class ImportRunViewSet(viewsets.ReadOnlyModelViewSet):
"""ViewSet for listing and retrieving import runs."""
queryset = ImportRun.objects.all().order_by("-id")
serializer_class = ImportRunSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
queryset = super().get_queryset()
profile_id = self.request.query_params.get("profile_id")
if profile_id:
queryset = queryset.filter(profile_id=profile_id)
return queryset
@extend_schema_view(
create=extend_schema(
summary="Import file",
description="Upload a CSV or XLSX file to import using an existing import profile. The import is queued and processed asynchronously.",
request={
"multipart/form-data": {
"type": "object",
"properties": {
"profile_id": {"type": "integer", "description": "ID of the ImportProfile to use"},
"file": {"type": "string", "format": "binary", "description": "CSV or XLSX file to import"},
},
"required": ["profile_id", "file"],
},
},
responses={
202: inline_serializer(
name="ImportResponse",
fields={
"import_run_id": drf_serializers.IntegerField(),
"status": drf_serializers.CharField(),
},
),
},
),
)
class ImportViewSet(viewsets.ViewSet):
"""ViewSet for importing data via file upload."""
permission_classes = [IsAuthenticated]
parser_classes = [MultiPartParser]
def create(self, request):
serializer = ImportFileSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
profile = serializer.validated_data["profile"]
uploaded_file = serializer.validated_data["file"]
# Save file to temp location
fs = FileSystemStorage(location="/usr/src/app/temp")
filename = fs.save(uploaded_file.name, uploaded_file)
file_path = fs.path(filename)
# Create ImportRun record
import_run = ImportRun.objects.create(profile=profile, file_name=filename)
# Queue import task
process_import.defer(
import_run_id=import_run.id,
file_path=file_path,
user_id=request.user.id,
)
return Response(
{"import_run_id": import_run.id, "status": "queued"},
status=status.HTTP_202_ACCEPTED,
)

View File

@@ -1,3 +1,5 @@
from copy import deepcopy
from rest_framework import viewsets
from apps.api.custom.pagination import CustomPageNumberPagination
@@ -30,8 +32,9 @@ class TransactionViewSet(viewsets.ModelViewSet):
transaction_created.send(sender=instance)
def perform_update(self, serializer):
old_data = deepcopy(self.get_object())
instance = serializer.save()
transaction_updated.send(sender=instance)
transaction_updated.send(sender=instance, old_data=old_data)
def partial_update(self, request, *args, **kwargs):
kwargs["partial"] = True

View File

@@ -23,3 +23,6 @@ class CommonConfig(AppConfig):
# 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")
# Register system checks for required environment variables
from apps.common import checks # noqa: F401

103
app/apps/common/checks.py Normal file
View File

@@ -0,0 +1,103 @@
"""
Django System Checks for required environment variables.
This module validates that required environment variables (those without defaults)
are present before the application starts.
"""
import os
from django.core.checks import Error, register
# List of environment variables that are required (no default values)
# Based on the README.md documentation
REQUIRED_ENV_VARS = [
("SECRET_KEY", "This is used to provide cryptographic signing."),
("SQL_DATABASE", "The name of your postgres database."),
]
# List of environment variables that must be valid integers if set
INT_ENV_VARS = [
("TASK_WORKERS", "How many workers to have for async tasks."),
("SESSION_EXPIRY_TIME", "The age of session cookies, in seconds."),
("INTERNAL_PORT", "The port on which the app listens on."),
("DJANGO_VITE_DEV_SERVER_PORT", "The port where Vite's dev server is running"),
]
@register()
def check_required_env_vars(app_configs, **kwargs):
"""
Check that all required environment variables are set.
Returns a list of Error objects for any missing required variables.
"""
errors = []
for var_name, description in REQUIRED_ENV_VARS:
value = os.getenv(var_name)
if not value:
errors.append(
Error(
f"Required environment variable '{var_name}' is not set.",
hint=f"{description} Please set this variable in your .env file or environment.",
id="wygiwyh.E001",
)
)
return errors
@register()
def check_int_env_vars(app_configs, **kwargs):
"""
Check that environment variables that should be integers are valid.
Returns a list of Error objects for any invalid integer variables.
"""
errors = []
for var_name, description in INT_ENV_VARS:
value = os.getenv(var_name)
if value is not None:
try:
int(value)
except ValueError:
errors.append(
Error(
f"Environment variable '{var_name}' must be a valid integer, got '{value}'.",
hint=f"{description}",
id="wygiwyh.E002",
)
)
return errors
@register()
def check_soft_delete_config(app_configs, **kwargs):
"""
Check that KEEP_DELETED_TRANSACTIONS_FOR is a valid integer when ENABLE_SOFT_DELETE is enabled.
Returns a list of Error objects if the configuration is invalid.
"""
errors = []
enable_soft_delete = os.getenv("ENABLE_SOFT_DELETE", "false").lower() == "true"
if enable_soft_delete:
keep_deleted_for = os.getenv("KEEP_DELETED_TRANSACTIONS_FOR")
if keep_deleted_for is not None:
try:
int(keep_deleted_for)
except ValueError:
errors.append(
Error(
f"Environment variable 'KEEP_DELETED_TRANSACTIONS_FOR' must be a valid integer when ENABLE_SOFT_DELETE is enabled, got '{keep_deleted_for}'.",
hint="Time in days to keep soft deleted transactions for. Set to 0 to keep all transactions indefinitely.",
id="wygiwyh.E003",
)
)
return errors

View File

@@ -1,14 +1,13 @@
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
from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple
from crispy_forms.bootstrap import FormActions
from crispy_forms.helper import FormHelper
from crispy_forms.layout import HTML, Div, Field, Layout, Submit
from django import forms
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
User = get_user_model()
@@ -39,6 +38,7 @@ class SharedObjectForm(forms.Form):
choices=SharedObject.Visibility.choices,
required=True,
label=_("Visibility"),
widget=TomSelect(clear_button=False),
help_text=_(
"Private: Only shown for the owner and shared users. Only editable by the owner."
"<br/>"
@@ -48,9 +48,6 @@ class SharedObjectForm(forms.Form):
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
@@ -73,12 +70,10 @@ class SharedObjectForm(forms.Form):
self.helper.layout = Layout(
Field("owner"),
Field("visibility"),
HTML("<hr>"),
HTML('<hr class="hr my-3">'),
Field("shared_with_users"),
FormActions(
NoClassSubmit(
"submit", _("Save"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Save"), css_class="btn btn-primary"),
),
)

View File

@@ -9,5 +9,8 @@ def truncate_decimal(value, decimal_places):
:param decimal_places: The number of decimal places to keep
:return: Truncated Decimal value
"""
if isinstance(value, (int, float)):
value = Decimal(str(value))
multiplier = Decimal(10**decimal_places)
return (value * multiplier).to_integral_value(rounding=ROUND_DOWN) / multiplier

View File

@@ -17,13 +17,18 @@ logger = logging.getLogger(__name__)
@app.periodic(cron="0 4 * * *")
@app.task(queueing_lock="remove_old_jobs", pass_context=True, name="remove_old_jobs")
@app.task(
lock="remove_old_jobs",
queueing_lock="remove_old_jobs",
pass_context=True,
name="remove_old_jobs",
)
async def remove_old_jobs(context, timestamp):
try:
return await builtin_tasks.remove_old_jobs(
context,
max_hours=744,
remove_error=True,
remove_failed=True,
remove_cancelled=True,
remove_aborted=True,
)
@@ -36,7 +41,11 @@ async def remove_old_jobs(context, timestamp):
@app.periodic(cron="0 6 1 * *")
@app.task(queueing_lock="remove_expired_sessions", name="remove_expired_sessions")
@app.task(
lock="remove_expired_sessions",
queueing_lock="remove_expired_sessions",
name="remove_expired_sessions",
)
async def remove_expired_sessions(timestamp=None):
"""Cleanup expired sessions by using Django management command."""
try:
@@ -49,7 +58,7 @@ async def remove_expired_sessions(timestamp=None):
@app.periodic(cron="0 8 * * *")
@app.task(name="reset_demo_data")
@app.task(lock="reset_demo_data", name="reset_demo_data")
def reset_demo_data(timestamp=None):
"""
Wipes the database and loads fresh demo data if DEMO mode is active.
@@ -86,9 +95,7 @@ def reset_demo_data(timestamp=None):
@app.periodic(cron="0 */12 * * *") # Every 12 hours
@app.task(
name="check_for_updates",
)
@app.task(lock="check_for_updates", name="check_for_updates")
def check_for_updates(timestamp=None):
if not settings.CHECK_FOR_UPDATES:
return "CHECK_FOR_UPDATES is disabled"

View File

@@ -0,0 +1,13 @@
from django import forms, template
register = template.Library()
@register.filter
def is_input(field):
return isinstance(field.field.widget, forms.TextInput)
@register.filter
def is_textarea(field):
return isinstance(field.field.widget, forms.Textarea)

View File

@@ -11,7 +11,7 @@ def toast_bg(tags):
elif "warning" in tags:
return "warning"
elif "error" in tags:
return "danger"
return "error"
elif "info" in tags:
return "info"

View File

@@ -0,0 +1,5 @@
from crispy_forms.layout import Field
class Switch(Field):
template = "crispy-daisyui/layout/switch.html"

View File

@@ -1,15 +1,14 @@
import datetime
from django.forms import widgets
from django.utils import formats, translation, dates
from django.utils.translation import gettext_lazy as _
from apps.common.functions.format import get_format
from apps.common.utils.django import (
django_to_python_datetime,
django_to_airdatepicker_datetime,
django_to_airdatepicker_datetime_separated,
django_to_python_datetime,
)
from apps.common.functions.format import get_format
from django.forms import widgets
from django.utils import dates, formats, translation
from django.utils.translation import gettext_lazy as _
class AirDatePickerInput(widgets.DateInput):
@@ -52,6 +51,8 @@ class AirDatePickerInput(widgets.DateInput):
def build_attrs(self, base_attrs, extra_attrs=None):
attrs = super().build_attrs(base_attrs, extra_attrs)
attrs["class"] = attrs.get("class", "") + " input"
attrs["data-now-button-txt"] = _("Today")
attrs["data-auto-close"] = str(self.auto_close).lower()
attrs["data-clear-button"] = str(self.clear_button).lower()

View File

@@ -36,7 +36,7 @@ class ArbitraryDecimalDisplayNumberInput(forms.TextInput):
{
"x-data": "",
"x-mask:dynamic": f"$money($input, '{get_format('DECIMAL_SEPARATOR')}', '{get_format('THOUSAND_SEPARATOR')}', '30')",
"x-on:keyup": "$el.dispatchEvent(new Event('input'))",
"x-on:keyup": "if (!['Control', 'Shift', 'Alt', 'Meta'].includes($event.key) && !(($event.ctrlKey || $event.metaKey) && $event.key.toLowerCase() === 'a')) $el.dispatchEvent(new Event('input'))",
}
)

View File

@@ -1,4 +1,4 @@
from django.forms import widgets, SelectMultiple
from django.forms import SelectMultiple, widgets
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
@@ -17,7 +17,7 @@ class TomSelect(widgets.Select):
checkboxes=False,
group_by=None,
*args,
**kwargs
**kwargs,
):
super().__init__(attrs, *args, **kwargs)
self.remove_button = remove_button

View File

@@ -1,5 +1,4 @@
import logging
from datetime import timedelta
from django.db.models import QuerySet
from django.utils import timezone
@@ -258,7 +257,10 @@ class ExchangeRateFetcher:
processed_pairs.add((from_currency.id, to_currency.id))
service.last_fetch = timezone.now()
service.failure_count = 0
service.save()
except Exception as e:
logger.error(f"Error fetching rates for {service.name}: {e}")
service.failure_count += 1
service.save()

View File

@@ -1,16 +1,15 @@
from crispy_bootstrap5.bootstrap5 import Switch
from crispy_forms.bootstrap import FormActions
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Row, Column
from django import forms
from django.forms import CharField
from django.utils.translation import gettext_lazy as _
from apps.common.widgets.crispy.daisyui import Switch
from apps.common.widgets.crispy.submit import NoClassSubmit
from apps.common.widgets.datepicker import AirDateTimePickerInput
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
from apps.common.widgets.tom_select import TomSelect
from apps.currencies.models import Currency, ExchangeRate, ExchangeRateService
from crispy_forms.bootstrap import FormActions
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Column, Layout, Row
from django import forms
from django.forms import CharField
from django.utils.translation import gettext_lazy as _
class CurrencyForm(forms.ModelForm):
@@ -26,6 +25,7 @@ class CurrencyForm(forms.ModelForm):
"suffix",
"code",
"exchange_currency",
"is_archived",
]
widgets = {
"exchange_currency": TomSelect(),
@@ -40,6 +40,7 @@ class CurrencyForm(forms.ModelForm):
self.helper.layout = Layout(
"code",
"name",
Switch("is_archived"),
"decimal_places",
"prefix",
"suffix",
@@ -49,17 +50,13 @@ class CurrencyForm(forms.ModelForm):
if self.instance and self.instance.pk:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
),
)
else:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
),
)
@@ -87,17 +84,13 @@ class ExchangeRateForm(forms.ModelForm):
if self.instance and self.instance.pk:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
),
)
else:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
),
)
@@ -130,8 +123,8 @@ class ExchangeRateServiceForm(forms.ModelForm):
Switch("singleton"),
"api_key",
Row(
Column("interval_type", css_class="form-group col-md-6"),
Column("fetch_interval", css_class="form-group col-md-6"),
Column("interval_type"),
Column("fetch_interval"),
),
"target_currencies",
"target_accounts",
@@ -140,16 +133,12 @@ class ExchangeRateServiceForm(forms.ModelForm):
if self.instance and self.instance.pk:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
),
)
else:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
),
)

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.5 on 2025-08-30 00:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('currencies', '0021_alter_exchangerateservice_service_type'),
]
operations = [
migrations.AddField(
model_name='currency',
name='is_archived',
field=models.BooleanField(default=False, verbose_name='Archived'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.10 on 2026-01-10 06:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('currencies', '0022_currency_is_archived'),
]
operations = [
migrations.AddField(
model_name='exchangerateservice',
name='failure_count',
field=models.PositiveIntegerField(default=0),
),
]

View File

@@ -32,6 +32,11 @@ class Currency(models.Model):
help_text=_("Default currency for exchange calculations"),
)
is_archived = models.BooleanField(
default=False,
verbose_name=_("Archived"),
)
def __str__(self):
return self.name
@@ -131,6 +136,8 @@ class ExchangeRateService(models.Model):
null=True, blank=True, verbose_name=_("Last Successful Fetch")
)
failure_count = models.PositiveIntegerField(default=0)
target_currencies = models.ManyToManyField(
Currency,
verbose_name=_("Target Currencies"),
@@ -232,7 +239,7 @@ class ExchangeRateService(models.Model):
hours = self._parse_hour_ranges(self.fetch_interval)
# Store in normalized format (optional)
self.fetch_interval = ",".join(str(h) for h in sorted(hours))
except ValueError as e:
except ValueError:
raise ValidationError(
{
"fetch_interval": _(
@@ -243,7 +250,7 @@ class ExchangeRateService(models.Model):
)
except ValidationError:
raise
except Exception as e:
except Exception:
raise ValidationError(
{
"fetch_interval": _(

View File

@@ -8,7 +8,7 @@ logger = logging.getLogger(__name__)
@app.periodic(cron="0 * * * *") # Run every hour
@app.task(name="automatic_fetch_exchange_rates")
@app.task(lock="automatic_fetch_exchange_rates", name="automatic_fetch_exchange_rates")
def automatic_fetch_exchange_rates(timestamp=None):
"""Fetch exchange rates for all due services"""
fetcher = ExchangeRateFetcher()
@@ -19,7 +19,7 @@ def automatic_fetch_exchange_rates(timestamp=None):
logger.error(e, exc_info=True)
@app.task(name="manual_fetch_exchange_rates")
@app.task(lock="manual_fetch_exchange_rates", name="manual_fetch_exchange_rates")
def manual_fetch_exchange_rates(timestamp=None):
"""Fetch exchange rates for all due services"""
fetcher = ExchangeRateFetcher()

View File

@@ -40,12 +40,6 @@ class CurrencyTests(TestCase):
with self.assertRaises(ValidationError):
currency.full_clean()
def test_currency_unique_code(self):
"""Test that currency codes must be unique"""
Currency.objects.create(code="USD", name="US Dollar", decimal_places=2)
with self.assertRaises(IntegrityError):
Currency.objects.create(code="USD", name="Another Dollar", decimal_places=2)
def test_currency_unique_name(self):
"""Test that currency names must be unique"""
Currency.objects.create(code="USD", name="US Dollar", decimal_places=2)

View File

@@ -0,0 +1 @@
# Tests package for currencies app

View File

@@ -0,0 +1,109 @@
from decimal import Decimal
from unittest.mock import patch, MagicMock
from django.test import TestCase
from django.utils import timezone
from apps.currencies.models import Currency, ExchangeRateService
from apps.currencies.exchange_rates.fetcher import ExchangeRateFetcher
class ExchangeRateServiceFailureTrackingTests(TestCase):
"""Tests for the failure count tracking functionality."""
def setUp(self):
"""Set up test data."""
self.usd = Currency.objects.create(
code="USD", name="US Dollar", decimal_places=2, prefix="$ "
)
self.eur = Currency.objects.create(
code="EUR", name="Euro", decimal_places=2, prefix=""
)
self.eur.exchange_currency = self.usd
self.eur.save()
self.service = ExchangeRateService.objects.create(
name="Test Service",
service_type=ExchangeRateService.ServiceType.FRANKFURTER,
is_active=True,
)
self.service.target_currencies.add(self.eur)
def test_failure_count_increments_on_provider_error(self):
"""Test that failure_count increments when provider raises an exception."""
self.assertEqual(self.service.failure_count, 0)
with patch.object(
self.service, "get_provider", side_effect=Exception("API Error")
):
ExchangeRateFetcher._fetch_service_rates(self.service)
self.service.refresh_from_db()
self.assertEqual(self.service.failure_count, 1)
def test_failure_count_resets_on_success(self):
"""Test that failure_count resets to 0 on successful fetch."""
# Set initial failure count
self.service.failure_count = 5
self.service.save()
# Mock a successful provider
mock_provider = MagicMock()
mock_provider.requires_api_key.return_value = False
mock_provider.get_rates.return_value = [(self.usd, self.eur, Decimal("0.85"))]
mock_provider.rates_inverted = False
with patch.object(self.service, "get_provider", return_value=mock_provider):
ExchangeRateFetcher._fetch_service_rates(self.service)
self.service.refresh_from_db()
self.assertEqual(self.service.failure_count, 0)
def test_failure_count_accumulates_across_fetches(self):
"""Test that failure_count accumulates with consecutive failures."""
self.assertEqual(self.service.failure_count, 0)
with patch.object(
self.service, "get_provider", side_effect=Exception("API Error")
):
ExchangeRateFetcher._fetch_service_rates(self.service)
self.service.refresh_from_db()
self.assertEqual(self.service.failure_count, 1)
ExchangeRateFetcher._fetch_service_rates(self.service)
self.service.refresh_from_db()
self.assertEqual(self.service.failure_count, 2)
ExchangeRateFetcher._fetch_service_rates(self.service)
self.service.refresh_from_db()
self.assertEqual(self.service.failure_count, 3)
def test_last_fetch_not_updated_on_failure(self):
"""Test that last_fetch is NOT updated when a failure occurs."""
original_last_fetch = self.service.last_fetch
self.assertIsNone(original_last_fetch)
with patch.object(
self.service, "get_provider", side_effect=Exception("API Error")
):
ExchangeRateFetcher._fetch_service_rates(self.service)
self.service.refresh_from_db()
self.assertIsNone(self.service.last_fetch)
self.assertEqual(self.service.failure_count, 1)
def test_last_fetch_updated_on_success(self):
"""Test that last_fetch IS updated when fetch succeeds."""
self.assertIsNone(self.service.last_fetch)
mock_provider = MagicMock()
mock_provider.requires_api_key.return_value = False
mock_provider.get_rates.return_value = [(self.usd, self.eur, Decimal("0.85"))]
mock_provider.rates_inverted = False
with patch.object(self.service, "get_provider", return_value=mock_provider):
ExchangeRateFetcher._fetch_service_rates(self.service)
self.service.refresh_from_db()
self.assertIsNotNone(self.service.last_fetch)
self.assertEqual(self.service.failure_count, 0)

View File

@@ -23,7 +23,7 @@ def currencies_index(request):
@login_required
@require_http_methods(["GET"])
def currencies_list(request):
currencies = Currency.objects.all().order_by("id")
currencies = Currency.objects.all().order_by("name")
return render(
request,
"currencies/fragments/list.html",

View File

@@ -1,22 +1,20 @@
from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion
from crispy_forms.bootstrap import FormActions, AccordionGroup
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Row, Column, HTML
from django import forms
from django.utils.translation import gettext_lazy as _
from apps.accounts.models import Account
from apps.common.widgets.crispy.submit import NoClassSubmit
from apps.common.widgets.datepicker import AirDatePickerInput
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
from apps.common.widgets.tom_select import TomSelect
from apps.dca.models import DCAStrategy, DCAEntry
from apps.common.widgets.tom_select import TransactionSelect
from apps.transactions.models import Transaction, TransactionTag, TransactionCategory
from apps.common.fields.forms.dynamic_select import (
DynamicModelChoiceField,
DynamicModelMultipleChoiceField,
)
from apps.common.widgets.crispy.daisyui import Switch
from apps.common.widgets.crispy.submit import NoClassSubmit
from apps.common.widgets.datepicker import AirDatePickerInput
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
from apps.common.widgets.tom_select import TomSelect, TransactionSelect
from apps.dca.models import DCAEntry, DCAStrategy
from apps.transactions.models import Transaction, TransactionCategory, TransactionTag
from crispy_forms.bootstrap import AccordionGroup, FormActions, Accordion
from crispy_forms.helper import FormHelper
from crispy_forms.layout import HTML, Column, Layout, Row
from django import forms
from django.utils.translation import gettext_lazy as _
class DCAStrategyForm(forms.ModelForm):
@@ -36,8 +34,8 @@ class DCAStrategyForm(forms.ModelForm):
self.helper.layout = Layout(
"name",
Row(
Column("payment_currency", css_class="form-group col-md-6"),
Column("target_currency", css_class="form-group col-md-6"),
Column("payment_currency"),
Column("target_currency"),
),
"notes",
)
@@ -45,17 +43,13 @@ class DCAStrategyForm(forms.ModelForm):
if self.instance and self.instance.pk:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
),
)
else:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
),
)
@@ -155,11 +149,11 @@ class DCAEntryForm(forms.ModelForm):
self.helper.layout = Layout(
"date",
Row(
Column("amount_paid", css_class="form-group col-md-6"),
Column("amount_received", css_class="form-group col-md-6"),
Column("amount_paid"),
Column("amount_received"),
),
"notes",
BS5Accordion(
Accordion(
AccordionGroup(
_("Create transaction"),
Switch("create_transaction"),
@@ -168,19 +162,11 @@ class DCAEntryForm(forms.ModelForm):
Row(
Column(
"from_account",
css_class="form-group",
),
css_class="form-row",
),
Row(
Column(
"from_category",
css_class="form-group col-md-6 mb-0",
),
Column(
"from_tags", css_class="form-group col-md-6 mb-0"
),
css_class="form-row",
Column("from_category"),
Column("from_tags"),
),
),
css_class="p-1 mx-1 my-3 border rounded-3",
@@ -192,14 +178,10 @@ class DCAEntryForm(forms.ModelForm):
"to_account",
css_class="form-group",
),
css_class="form-row",
),
Row(
Column(
"to_category", css_class="form-group col-md-6 mb-0"
),
Column("to_tags", css_class="form-group col-md-6 mb-0"),
css_class="form-row",
Column("to_category"),
Column("to_tags"),
),
),
css_class="p-1 mx-1 my-3 border rounded-3",
@@ -220,17 +202,13 @@ class DCAEntryForm(forms.ModelForm):
if self.instance and self.instance.pk:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
),
)
else:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
),
)

View File

@@ -1,4 +1,3 @@
# apps/dca_tracker/views.py
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.db.models import Sum, Avg
@@ -23,7 +22,7 @@ def strategy_index(request):
@only_htmx
@login_required
def strategy_list(request):
strategies = DCAStrategy.objects.all().order_by("created_at")
strategies = DCAStrategy.objects.all().order_by("name")
return render(
request, "dca/fragments/strategy/list.html", {"strategies": strategies}
)
@@ -234,7 +233,7 @@ def strategy_entry_add(request, strategy_id):
if request.method == "POST":
form = DCAEntryForm(request.POST, strategy=strategy)
if form.is_valid():
entry = form.save()
form.save()
messages.success(request, _("Entry added successfully"))
return HttpResponse(

View File

@@ -1,11 +1,10 @@
from apps.common.widgets.crispy.submit import NoClassSubmit
from crispy_forms.bootstrap import FormActions
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, HTML
from crispy_forms.layout import HTML, Layout
from django import forms
from django.utils.translation import gettext_lazy as _
from apps.common.widgets.crispy.submit import NoClassSubmit
class ExportForm(forms.Form):
users = forms.BooleanField(
@@ -115,9 +114,7 @@ class ExportForm(forms.Form):
"dca",
"import_profiles",
FormActions(
NoClassSubmit(
"submit", _("Export"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Export"), css_class="btn btn-primary"),
),
)
@@ -162,7 +159,7 @@ class RestoreForm(forms.Form):
self.helper.form_method = "post"
self.helper.layout = Layout(
"zip_file",
HTML("<hr />"),
HTML('<hr class="hr my-3"/>'),
"users",
"accounts",
"currencies",
@@ -181,9 +178,7 @@ class RestoreForm(forms.Form):
"dca_entries",
"import_profiles",
FormActions(
NoClassSubmit(
"submit", _("Restore"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Restore"), css_class="btn btn-primary"),
),
)

View File

@@ -1,8 +1,10 @@
from import_export import fields, resources
from import_export.widgets import ForeignKeyWidget
from apps.accounts.models import Account
from apps.export_app.widgets.foreign_key import AutoCreateForeignKeyWidget
from apps.export_app.widgets.foreign_key import (
AllObjectsForeignKeyWidget,
AutoCreateForeignKeyWidget,
)
from apps.export_app.widgets.many_to_many import AutoCreateManyToManyWidget
from apps.export_app.widgets.string import EmptyStringToNoneField
from apps.transactions.models import (
@@ -20,7 +22,7 @@ class TransactionResource(resources.ModelResource):
account = fields.Field(
attribute="account",
column_name="account",
widget=ForeignKeyWidget(Account, "name"),
widget=AllObjectsForeignKeyWidget(Account, "name"),
)
category = fields.Field(
@@ -86,7 +88,7 @@ class RecurringTransactionResource(resources.ModelResource):
account = fields.Field(
attribute="account",
column_name="account",
widget=ForeignKeyWidget(Account, "name"),
widget=AllObjectsForeignKeyWidget(Account, "name"),
)
category = fields.Field(
@@ -119,12 +121,16 @@ class RecurringTransactionResource(resources.ModelResource):
def get_queryset(self):
return RecurringTransaction.all_objects.all()
def dehydrate_account_owner(self, obj):
"""Export the account's owner ID for proper import matching."""
return obj.account.owner_id if obj.account else None
class InstallmentPlanResource(resources.ModelResource):
account = fields.Field(
attribute="account",
column_name="account",
widget=ForeignKeyWidget(Account, "name"),
widget=AllObjectsForeignKeyWidget(Account, "name"),
)
category = fields.Field(
@@ -156,3 +162,7 @@ class InstallmentPlanResource(resources.ModelResource):
def get_queryset(self):
return InstallmentPlan.all_objects.all()
def dehydrate_account_owner(self, obj):
"""Export the account's owner ID for proper import matching."""
return obj.account.owner_id if obj.account else None

View File

@@ -1,6 +1,60 @@
from import_export.widgets import ForeignKeyWidget
class AllObjectsForeignKeyWidget(ForeignKeyWidget):
"""
ForeignKeyWidget that uses 'all_objects' manager for lookups,
bypassing user-filtered managers like SharedObjectManager.
Also filters by owner if available in the row data.
"""
def get_queryset(self, value, row, *args, **kwargs):
# Use all_objects manager if available, otherwise fall back to default
if hasattr(self.model, "all_objects"):
qs = self.model.all_objects.all()
# Filter by owner if the row has an owner field and the model has owner
if row:
# Check for direct owner field first
owner_id = row.get("owner") if "owner" in row else None
# Fall back to account_owner for models like InstallmentPlan
if not owner_id and "account_owner" in row:
owner_id = row.get("account_owner")
# If still no owner, try to get it from the existing record's account
# This handles backward compatibility with older exports
if not owner_id and "id" in row and row.get("id"):
try:
# Try to find the existing record and get owner from its account
from apps.transactions.models import (
InstallmentPlan,
RecurringTransaction,
)
record_id = row.get("id")
# Try to find the existing InstallmentPlan or RecurringTransaction
for model_class in [InstallmentPlan, RecurringTransaction]:
try:
existing = model_class.all_objects.get(id=record_id)
if existing.account:
owner_id = existing.account.owner_id
break
except model_class.DoesNotExist:
continue
except Exception:
pass
# Final fallback: use the current logged-in user
# This handles restoring to a fresh database with older exports
if not owner_id:
from apps.common.middleware.thread_local import get_current_user
user = get_current_user()
if user and user.is_authenticated:
owner_id = user.id
if owner_id:
qs = qs.filter(owner_id=owner_id)
return qs
return super().get_queryset(value, row, *args, **kwargs)
class AutoCreateForeignKeyWidget(ForeignKeyWidget):
def clean(self, value, row=None, *args, **kwargs):
if value:

View File

@@ -1,3 +1,5 @@
from apps.common.widgets.crispy.submit import NoClassSubmit
from apps.import_app.models import ImportProfile
from crispy_forms.bootstrap import FormActions
from crispy_forms.helper import FormHelper
from crispy_forms.layout import (
@@ -6,9 +8,6 @@ from crispy_forms.layout import (
from django import forms
from django.utils.translation import gettext_lazy as _
from apps.import_app.models import ImportProfile
from apps.common.widgets.crispy.submit import NoClassSubmit
class ImportProfileForm(forms.ModelForm):
class Meta:
@@ -30,17 +29,13 @@ class ImportProfileForm(forms.ModelForm):
if self.instance and self.instance.pk:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
),
)
else:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
),
)
@@ -57,8 +52,6 @@ class ImportRunFileUploadForm(forms.Form):
self.helper.layout = Layout(
"file",
FormActions(
NoClassSubmit(
"submit", _("Import"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Import"), css_class="btn btn-primary"),
),
)

View File

@@ -459,12 +459,13 @@ class ImportService:
# Build query conditions for each field in the rule
for field in rule.fields:
if field in transaction_data:
if rule.match_type == "strict":
query = query.filter(**{field: transaction_data[field]})
else: # lax matching
query = query.filter(
**{f"{field}__iexact": transaction_data[field]}
)
value = transaction_data[field]
# Use __iexact only for string fields; non-string types
# (date, Decimal, bool, int, etc.) don't support UPPER()
if rule.match_type == "strict" or not isinstance(value, str):
query = query.filter(**{field: value})
else: # lax matching for strings only
query = query.filter(**{f"{field}__iexact": value})
# If we found any matching transaction, it's a duplicate
if query.exists():
@@ -475,11 +476,27 @@ class ImportService:
def _coerce_type(
self, value: str, mapping: version_1.ColumnMapping
) -> Union[str, int, bool, Decimal, datetime, list, None]:
coerce_to = mapping.coerce_to
# Handle detection methods that don't require a source value
if coerce_to == "transaction_type" and isinstance(
mapping, version_1.TransactionTypeMapping
):
if mapping.detection_method == "always_income":
return Transaction.Type.INCOME
elif mapping.detection_method == "always_expense":
return Transaction.Type.EXPENSE
elif coerce_to == "is_paid" and isinstance(
mapping, version_1.TransactionIsPaidMapping
):
if mapping.detection_method == "always_paid":
return True
elif mapping.detection_method == "always_unpaid":
return False
if not value:
return None
coerce_to = mapping.coerce_to
return self._coerce_single_type(value, coerce_to, mapping)
@staticmethod

View File

@@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -0,0 +1,275 @@
"""
Tests for ImportService v1, specifically for deduplication logic.
These tests verify that the _check_duplicate_transaction method handles
different field types correctly, particularly ensuring that __iexact
is only used for string fields (not dates, decimals, etc.).
"""
from datetime import date
from decimal import Decimal
from django.test import TestCase
from apps.accounts.models import Account, AccountGroup
from apps.currencies.models import Currency
from apps.import_app.models import ImportProfile, ImportRun
from apps.import_app.services.v1 import ImportService
from apps.transactions.models import Transaction
class DeduplicationTests(TestCase):
"""Tests for transaction deduplication during import."""
def setUp(self):
"""Set up test data."""
self.currency = Currency.objects.create(
code="USD", name="US Dollar", decimal_places=2, prefix="$ "
)
self.account_group = AccountGroup.objects.create(name="Test Group")
self.account = Account.objects.create(
name="Test Account", group=self.account_group, currency=self.currency
)
# Create an existing transaction for deduplication tests
self.existing_transaction = Transaction.objects.create(
account=self.account,
type=Transaction.Type.EXPENSE,
date=date(2024, 1, 15),
amount=Decimal("100.00"),
description="Existing Transaction",
internal_id="ABC123",
)
def _create_import_service_with_deduplication(
self, fields: list[str], match_type: str = "lax"
) -> ImportService:
"""Helper to create an ImportService with specific deduplication rules."""
yaml_config = f"""
settings:
file_type: csv
importing: transactions
trigger_transaction_rules: false
mapping:
date_field:
source: date
target: date
format: "%Y-%m-%d"
amount_field:
source: amount
target: amount
description_field:
source: description
target: description
account_field:
source: account
target: account
type: id
deduplication:
- type: compare
fields: {fields}
match_type: {match_type}
"""
profile = ImportProfile.objects.create(
name=f"Test Profile {match_type} {'_'.join(fields)}",
yaml_config=yaml_config,
version=ImportProfile.Versions.VERSION_1,
)
import_run = ImportRun.objects.create(
profile=profile,
file_name="test.csv",
)
return ImportService(import_run)
def test_deduplication_with_date_field_strict_match(self):
"""Test that date fields work with strict matching."""
service = self._create_import_service_with_deduplication(
fields=["date"], match_type="strict"
)
# Should find duplicate when date matches
is_duplicate = service._check_duplicate_transaction({"date": date(2024, 1, 15)})
self.assertTrue(is_duplicate)
# Should not find duplicate when date differs
is_duplicate = service._check_duplicate_transaction({"date": date(2024, 2, 20)})
self.assertFalse(is_duplicate)
def test_deduplication_with_date_field_lax_match(self):
"""
Test that date fields use strict matching even when match_type is 'lax'.
This is the fix for the UPPER(date) PostgreSQL error. Date fields
cannot use __iexact, so they should fall back to strict matching.
"""
service = self._create_import_service_with_deduplication(
fields=["date"], match_type="lax"
)
# Should find duplicate when date matches (using strict comparison)
is_duplicate = service._check_duplicate_transaction({"date": date(2024, 1, 15)})
self.assertTrue(is_duplicate)
# Should not find duplicate when date differs
is_duplicate = service._check_duplicate_transaction({"date": date(2024, 2, 20)})
self.assertFalse(is_duplicate)
def test_deduplication_with_amount_field_lax_match(self):
"""
Test that Decimal fields use strict matching even when match_type is 'lax'.
Decimal fields cannot use __iexact, so they should fall back to strict matching.
"""
service = self._create_import_service_with_deduplication(
fields=["amount"], match_type="lax"
)
# Should find duplicate when amount matches
is_duplicate = service._check_duplicate_transaction(
{"amount": Decimal("100.00")}
)
self.assertTrue(is_duplicate)
# Should not find duplicate when amount differs
is_duplicate = service._check_duplicate_transaction(
{"amount": Decimal("200.00")}
)
self.assertFalse(is_duplicate)
def test_deduplication_with_string_field_lax_match(self):
"""
Test that string fields use case-insensitive matching with match_type 'lax'.
"""
service = self._create_import_service_with_deduplication(
fields=["description"], match_type="lax"
)
# Should find duplicate with case-insensitive match
is_duplicate = service._check_duplicate_transaction(
{"description": "EXISTING TRANSACTION"}
)
self.assertTrue(is_duplicate)
# Should find duplicate with exact case match
is_duplicate = service._check_duplicate_transaction(
{"description": "Existing Transaction"}
)
self.assertTrue(is_duplicate)
# Should not find duplicate when description differs
is_duplicate = service._check_duplicate_transaction(
{"description": "Different Transaction"}
)
self.assertFalse(is_duplicate)
def test_deduplication_with_string_field_strict_match(self):
"""
Test that string fields use case-sensitive matching with match_type 'strict'.
"""
service = self._create_import_service_with_deduplication(
fields=["description"], match_type="strict"
)
# Should NOT find duplicate with different case (strict matching)
is_duplicate = service._check_duplicate_transaction(
{"description": "EXISTING TRANSACTION"}
)
self.assertFalse(is_duplicate)
# Should find duplicate with exact case match
is_duplicate = service._check_duplicate_transaction(
{"description": "Existing Transaction"}
)
self.assertTrue(is_duplicate)
def test_deduplication_with_multiple_fields_mixed_types(self):
"""
Test deduplication with multiple fields of different types.
Verifies that string fields use __iexact while non-string fields
use strict matching, all in the same deduplication rule.
"""
service = self._create_import_service_with_deduplication(
fields=["date", "amount", "description"], match_type="lax"
)
# Should find duplicate when all fields match (with case-insensitive description)
is_duplicate = service._check_duplicate_transaction(
{
"date": date(2024, 1, 15),
"amount": Decimal("100.00"),
"description": "existing transaction", # lowercase should match
}
)
self.assertTrue(is_duplicate)
# Should NOT find duplicate when date differs
is_duplicate = service._check_duplicate_transaction(
{
"date": date(2024, 2, 20),
"amount": Decimal("100.00"),
"description": "existing transaction",
}
)
self.assertFalse(is_duplicate)
# Should NOT find duplicate when amount differs
is_duplicate = service._check_duplicate_transaction(
{
"date": date(2024, 1, 15),
"amount": Decimal("999.99"),
"description": "existing transaction",
}
)
self.assertFalse(is_duplicate)
def test_deduplication_with_internal_id_lax_match(self):
"""Test deduplication with internal_id field using lax matching."""
service = self._create_import_service_with_deduplication(
fields=["internal_id"], match_type="lax"
)
# Should find duplicate with case-insensitive match
is_duplicate = service._check_duplicate_transaction(
{"internal_id": "abc123"} # lowercase should match ABC123
)
self.assertTrue(is_duplicate)
# Should find duplicate with exact match
is_duplicate = service._check_duplicate_transaction({"internal_id": "ABC123"})
self.assertTrue(is_duplicate)
# Should not find duplicate when internal_id differs
is_duplicate = service._check_duplicate_transaction({"internal_id": "XYZ789"})
self.assertFalse(is_duplicate)
def test_no_duplicate_when_no_transactions_exist(self):
"""Test that no duplicate is found when there are no matching transactions."""
# Hard delete to bypass signals that require user context
self.existing_transaction.hard_delete()
service = self._create_import_service_with_deduplication(
fields=["date", "amount"], match_type="lax"
)
is_duplicate = service._check_duplicate_transaction(
{
"date": date(2024, 1, 15),
"amount": Decimal("100.00"),
}
)
self.assertFalse(is_duplicate)
def test_deduplication_with_missing_field_in_data(self):
"""Test that missing fields in transaction_data are handled gracefully."""
service = self._create_import_service_with_deduplication(
fields=["date", "nonexistent_field"], match_type="lax"
)
# Should still work, only checking the fields that exist
is_duplicate = service._check_duplicate_transaction(
{
"date": date(2024, 1, 15),
}
)
self.assertTrue(is_duplicate)

View File

@@ -1,15 +1,14 @@
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Field, Row, Column
from django import forms
from django.utils.translation import gettext_lazy as _
from apps.common.widgets.datepicker import (
AirDatePickerInput,
AirMonthYearPickerInput,
AirYearPickerInput,
AirDatePickerInput,
)
from apps.transactions.models import TransactionCategory
from apps.common.widgets.tom_select import TomSelect
from apps.transactions.models import TransactionCategory
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Column, Field, Layout, Row
from django import forms
from django.utils.translation import gettext_lazy as _
class SingleMonthForm(forms.Form):
@@ -59,8 +58,8 @@ class MonthRangeForm(forms.Form):
self.helper.layout = Layout(
Row(
Column("month_from", css_class="form-group col-md-6"),
Column("month_to", css_class="form-group col-md-6"),
Column("month_from"),
Column("month_to"),
),
)
@@ -82,8 +81,8 @@ class YearRangeForm(forms.Form):
self.helper.layout = Layout(
Row(
Column("year_from", css_class="form-group col-md-6"),
Column("year_to", css_class="form-group col-md-6"),
Column("year_from"),
Column("year_to"),
),
)
@@ -105,8 +104,8 @@ class DateRangeForm(forms.Form):
self.helper.layout = Layout(
Row(
Column("date_from", css_class="form-group col-md-6"),
Column("date_to", css_class="form-group col-md-6"),
Column("date_from"),
Column("date_to"),
css_class="mb-0",
),
)

View File

@@ -49,4 +49,14 @@ urlpatterns = [
views.emergency_fund,
name="insights_emergency_fund",
),
path(
"insights/year-by-year/",
views.year_by_year,
name="insights_year_by_year",
),
path(
"insights/month-by-month/",
views.month_by_month,
name="insights_month_by_month",
),
]

View File

@@ -12,7 +12,10 @@ from apps.currencies.utils.convert import convert
def get_categories_totals(
transactions_queryset, ignore_empty=False, show_entities=False
):
# First get the category totals as before
# Step 1: Aggregate transaction data by category and currency.
# This query calculates the total current and projected income/expense for each
# category by grouping transactions and summing up their amounts based on their
# type (income/expense) and payment status (paid/unpaid).
category_currency_metrics = (
transactions_queryset.values(
"category",
@@ -76,7 +79,10 @@ def get_categories_totals(
.order_by("category__name")
)
# Get tag totals within each category with currency details
# Step 2: Aggregate transaction data by tag, category, and currency.
# This is similar to the category metrics but adds tags to the grouping,
# allowing for a breakdown of totals by tag within each category. It also
# handles untagged transactions, where the 'tags' field is None.
tag_metrics = transactions_queryset.values(
"category",
"tags",
@@ -131,10 +137,12 @@ def get_categories_totals(
),
)
# Process the results to structure by category
# Step 3: Initialize the main dictionary to structure the final results.
# The data will be organized hierarchically: category -> currency -> tags -> entities.
result = {}
# Process category totals first
# Step 4: Process the aggregated category metrics to build the initial result structure.
# This loop iterates through each category's metrics and populates the `result` dict.
for metric in category_currency_metrics:
# Skip empty categories if ignore_empty is True
if ignore_empty and all(
@@ -185,7 +193,7 @@ def get_categories_totals(
"total_final": total_final,
}
# Add exchanged values if exchange_currency exists
# Step 4a: Handle currency conversion for category totals if an exchange currency is defined.
if metric["account__currency__exchange_currency"]:
from_currency = Currency.objects.get(id=currency_id)
exchange_currency = Currency.objects.get(
@@ -224,7 +232,7 @@ def get_categories_totals(
result[category_id]["currencies"][currency_id] = currency_data
# Process tag totals and add them to the result, including untagged
# Step 5: Process the aggregated tag metrics and integrate them into the result structure.
for tag_metric in tag_metrics:
category_id = tag_metric["category"]
tag_id = tag_metric["tags"] # Will be None for untagged transactions
@@ -281,7 +289,7 @@ def get_categories_totals(
"total_final": tag_total_final,
}
# Add exchange currency support for tags
# Step 5a: Handle currency conversion for tag totals.
if tag_metric["account__currency__exchange_currency"]:
from_currency = Currency.objects.get(id=currency_id)
exchange_currency = Currency.objects.get(
@@ -322,6 +330,7 @@ def get_categories_totals(
currency_id
] = tag_currency_data
# Step 6: If requested, aggregate and process entity-level data.
if show_entities:
entity_metrics = transactions_queryset.values(
"category",
@@ -389,14 +398,15 @@ def get_categories_totals(
tag_id = entity_metric["tags"]
entity_id = entity_metric["entities"]
if not entity_id:
continue
if category_id in result:
tag_key = tag_id if tag_id is not None else "untagged"
if tag_key in result[category_id]["tags"]:
entity_key = entity_id
entity_name = entity_metric["entities__name"]
entity_key = entity_id if entity_id is not None else "no_entity"
entity_name = (
entity_metric["entities__name"]
if entity_id is not None
else None
)
if "entities" not in result[category_id]["tags"][tag_key]:
result[category_id]["tags"][tag_key]["entities"] = {}

View File

@@ -0,0 +1,316 @@
from collections import OrderedDict
from decimal import Decimal
from django.db import models
from django.db.models import Sum, Case, When, Value
from django.db.models.functions import Coalesce
from django.utils import timezone
from apps.currencies.models import Currency
from apps.currencies.utils.convert import convert
from apps.transactions.models import Transaction
def get_month_by_month_data(year=None, group_by="categories"):
"""
Aggregate transaction totals by month for a specific year, grouped by categories, tags, or entities.
Args:
year: The year to filter transactions (defaults to current year)
group_by: One of "categories", "tags", or "entities"
Returns:
{
"year": 2025,
"available_years": [2025, 2024, ...],
"months": [1, 2, 3, ..., 12],
"items": {
item_id: {
"name": "Item Name",
"month_totals": {
1: {"currencies": {...}},
...
},
"total": {"currencies": {...}}
},
...
},
"month_totals": {...},
"grand_total": {"currencies": {...}}
}
"""
if year is None:
year = timezone.localdate(timezone.now()).year
# Base queryset - all paid transactions, non-muted
transactions = Transaction.objects.filter(
is_paid=True,
account__is_archived=False,
).exclude(account__currency__is_archived=True)
# Get available years for the selector
available_years = list(
transactions.values_list("reference_date__year", flat=True)
.distinct()
.order_by("-reference_date__year")
)
# Filter by the selected year
transactions = transactions.filter(reference_date__year=year)
# Define grouping fields based on group_by parameter
if group_by == "tags":
group_field = "tags"
name_field = "tags__name"
elif group_by == "entities":
group_field = "entities"
name_field = "entities__name"
else: # Default to categories
group_field = "category"
name_field = "category__name"
# Months 1-12
months = list(range(1, 13))
if not available_years:
return {
"year": year,
"available_years": [],
"months": months,
"items": {},
"month_totals": {},
"grand_total": {"currencies": {}},
}
# Aggregate by group, month, and currency
metrics = (
transactions.values(
group_field,
name_field,
"reference_date__month",
"account__currency",
"account__currency__code",
"account__currency__name",
"account__currency__decimal_places",
"account__currency__prefix",
"account__currency__suffix",
"account__currency__exchange_currency",
)
.annotate(
expense_total=Coalesce(
Sum(
Case(
When(type=Transaction.Type.EXPENSE, then="amount"),
default=Value(0),
output_field=models.DecimalField(),
)
),
Decimal("0"),
),
income_total=Coalesce(
Sum(
Case(
When(type=Transaction.Type.INCOME, then="amount"),
default=Value(0),
output_field=models.DecimalField(),
)
),
Decimal("0"),
),
)
.order_by(name_field, "reference_date__month")
)
# Build result structure
result = {
"year": year,
"available_years": available_years,
"months": months,
"items": OrderedDict(),
"month_totals": {},
"grand_total": {"currencies": {}},
}
# Store currency info for later use in totals
currency_info = {}
for metric in metrics:
item_id = metric[group_field]
item_name = metric[name_field]
month = metric["reference_date__month"]
currency_id = metric["account__currency"]
# Use a consistent key for None (uncategorized/untagged/no entity)
item_key = item_id if item_id is not None else "__none__"
if item_key not in result["items"]:
result["items"][item_key] = {
"name": item_name,
"month_totals": {},
"total": {"currencies": {}},
}
if month not in result["items"][item_key]["month_totals"]:
result["items"][item_key]["month_totals"][month] = {"currencies": {}}
# Calculate final total (income - expense)
final_total = metric["income_total"] - metric["expense_total"]
# Store currency info for totals calculation
if currency_id not in currency_info:
currency_info[currency_id] = {
"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"],
"exchange_currency_id": metric["account__currency__exchange_currency"],
}
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"],
},
"final_total": final_total,
"income_total": metric["income_total"],
"expense_total": metric["expense_total"],
}
# Handle currency conversion if exchange currency is set
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"]
)
converted_amount, prefix, suffix, decimal_places = convert(
amount=final_total,
from_currency=from_currency,
to_currency=exchange_currency,
)
if converted_amount is not None:
currency_data["exchanged"] = {
"final_total": converted_amount,
"currency": {
"prefix": prefix,
"suffix": suffix,
"decimal_places": decimal_places,
"code": exchange_currency.code,
"name": exchange_currency.name,
},
}
result["items"][item_key]["month_totals"][month]["currencies"][currency_id] = (
currency_data
)
# Accumulate item total (across all months for this item)
if currency_id not in result["items"][item_key]["total"]["currencies"]:
result["items"][item_key]["total"]["currencies"][currency_id] = {
"currency": currency_data["currency"].copy(),
"final_total": Decimal("0"),
}
result["items"][item_key]["total"]["currencies"][currency_id][
"final_total"
] += final_total
# Accumulate month total (across all items for this month)
if month not in result["month_totals"]:
result["month_totals"][month] = {"currencies": {}}
if currency_id not in result["month_totals"][month]["currencies"]:
result["month_totals"][month]["currencies"][currency_id] = {
"currency": currency_data["currency"].copy(),
"final_total": Decimal("0"),
}
result["month_totals"][month]["currencies"][currency_id]["final_total"] += (
final_total
)
# Accumulate grand total
if currency_id not in result["grand_total"]["currencies"]:
result["grand_total"]["currencies"][currency_id] = {
"currency": currency_data["currency"].copy(),
"final_total": Decimal("0"),
}
result["grand_total"]["currencies"][currency_id]["final_total"] += final_total
# Add currency conversion for item totals
for item_key, item_data in result["items"].items():
for currency_id, total_data in item_data["total"]["currencies"].items():
if currency_info[currency_id]["exchange_currency_id"]:
from_currency = Currency.objects.get(id=currency_id)
exchange_currency = Currency.objects.get(
id=currency_info[currency_id]["exchange_currency_id"]
)
converted_amount, prefix, suffix, decimal_places = convert(
amount=total_data["final_total"],
from_currency=from_currency,
to_currency=exchange_currency,
)
if converted_amount is not None:
total_data["exchanged"] = {
"final_total": converted_amount,
"currency": {
"prefix": prefix,
"suffix": suffix,
"decimal_places": decimal_places,
"code": exchange_currency.code,
"name": exchange_currency.name,
},
}
# Add currency conversion for month totals
for month, month_data in result["month_totals"].items():
for currency_id, total_data in month_data["currencies"].items():
if currency_info[currency_id]["exchange_currency_id"]:
from_currency = Currency.objects.get(id=currency_id)
exchange_currency = Currency.objects.get(
id=currency_info[currency_id]["exchange_currency_id"]
)
converted_amount, prefix, suffix, decimal_places = convert(
amount=total_data["final_total"],
from_currency=from_currency,
to_currency=exchange_currency,
)
if converted_amount is not None:
total_data["exchanged"] = {
"final_total": converted_amount,
"currency": {
"prefix": prefix,
"suffix": suffix,
"decimal_places": decimal_places,
"code": exchange_currency.code,
"name": exchange_currency.name,
},
}
# Add currency conversion for grand total
for currency_id, total_data in result["grand_total"]["currencies"].items():
if currency_info[currency_id]["exchange_currency_id"]:
from_currency = Currency.objects.get(id=currency_id)
exchange_currency = Currency.objects.get(
id=currency_info[currency_id]["exchange_currency_id"]
)
converted_amount, prefix, suffix, decimal_places = convert(
amount=total_data["final_total"],
from_currency=from_currency,
to_currency=exchange_currency,
)
if converted_amount is not None:
total_data["exchanged"] = {
"final_total": converted_amount,
"currency": {
"prefix": prefix,
"suffix": suffix,
"decimal_places": decimal_places,
"code": exchange_currency.code,
"name": exchange_currency.name,
},
}
return result

View File

@@ -102,4 +102,6 @@ def get_transactions(
account__in=request.user.untracked_accounts.all()
)
transactions = transactions.exclude(account__currency__is_archived=True)
return transactions

View File

@@ -0,0 +1,303 @@
from collections import OrderedDict
from decimal import Decimal
from django.db import models
from django.db.models import Sum, Case, When, Value
from django.db.models.functions import Coalesce
from apps.currencies.models import Currency
from apps.currencies.utils.convert import convert
from apps.transactions.models import Transaction
def get_year_by_year_data(group_by="categories"):
"""
Aggregate transaction totals by year for categories, tags, or entities.
Args:
group_by: One of "categories", "tags", or "entities"
Returns:
{
"years": [2025, 2024, ...], # Sorted descending
"items": {
item_id: {
"name": "Item Name",
"year_totals": {
2025: {"currencies": {...}},
...
},
"total": {"currencies": {...}} # Sum across all years
},
...
},
"year_totals": { # Sum across all items for each year
2025: {"currencies": {...}},
...
},
"grand_total": {"currencies": {...}} # Sum of everything
}
"""
# Base queryset - all paid transactions, non-muted
transactions = Transaction.objects.filter(
is_paid=True,
account__is_archived=False,
).exclude(account__currency__is_archived=True)
# Define grouping fields based on group_by parameter
if group_by == "tags":
group_field = "tags"
name_field = "tags__name"
elif group_by == "entities":
group_field = "entities"
name_field = "entities__name"
else: # Default to categories
group_field = "category"
name_field = "category__name"
# Get all unique years with transactions
years = (
transactions.values_list("reference_date__year", flat=True)
.distinct()
.order_by("-reference_date__year")
)
years = list(years)
if not years:
return {
"years": [],
"items": {},
"year_totals": {},
"grand_total": {"currencies": {}},
}
# Aggregate by group, year, and currency
metrics = (
transactions.values(
group_field,
name_field,
"reference_date__year",
"account__currency",
"account__currency__code",
"account__currency__name",
"account__currency__decimal_places",
"account__currency__prefix",
"account__currency__suffix",
"account__currency__exchange_currency",
)
.annotate(
expense_total=Coalesce(
Sum(
Case(
When(type=Transaction.Type.EXPENSE, then="amount"),
default=Value(0),
output_field=models.DecimalField(),
)
),
Decimal("0"),
),
income_total=Coalesce(
Sum(
Case(
When(type=Transaction.Type.INCOME, then="amount"),
default=Value(0),
output_field=models.DecimalField(),
)
),
Decimal("0"),
),
)
.order_by(name_field, "-reference_date__year")
)
# Build result structure
result = {
"years": years,
"items": OrderedDict(),
"year_totals": {}, # Totals per year across all items
"grand_total": {"currencies": {}}, # Grand total across everything
}
# Store currency info for later use in totals
currency_info = {}
for metric in metrics:
item_id = metric[group_field]
item_name = metric[name_field]
year = metric["reference_date__year"]
currency_id = metric["account__currency"]
# Use a consistent key for None (uncategorized/untagged/no entity)
item_key = item_id if item_id is not None else "__none__"
if item_key not in result["items"]:
result["items"][item_key] = {
"name": item_name,
"year_totals": {},
"total": {"currencies": {}}, # Total for this item across all years
}
if year not in result["items"][item_key]["year_totals"]:
result["items"][item_key]["year_totals"][year] = {"currencies": {}}
# Calculate final total (income - expense)
final_total = metric["income_total"] - metric["expense_total"]
# Store currency info for totals calculation
if currency_id not in currency_info:
currency_info[currency_id] = {
"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"],
"exchange_currency_id": metric["account__currency__exchange_currency"],
}
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"],
},
"final_total": final_total,
"income_total": metric["income_total"],
"expense_total": metric["expense_total"],
}
# Handle currency conversion if exchange currency is set
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"]
)
converted_amount, prefix, suffix, decimal_places = convert(
amount=final_total,
from_currency=from_currency,
to_currency=exchange_currency,
)
if converted_amount is not None:
currency_data["exchanged"] = {
"final_total": converted_amount,
"currency": {
"prefix": prefix,
"suffix": suffix,
"decimal_places": decimal_places,
"code": exchange_currency.code,
"name": exchange_currency.name,
},
}
result["items"][item_key]["year_totals"][year]["currencies"][currency_id] = (
currency_data
)
# Accumulate item total (across all years for this item)
if currency_id not in result["items"][item_key]["total"]["currencies"]:
result["items"][item_key]["total"]["currencies"][currency_id] = {
"currency": currency_data["currency"].copy(),
"final_total": Decimal("0"),
}
result["items"][item_key]["total"]["currencies"][currency_id][
"final_total"
] += final_total
# Accumulate year total (across all items for this year)
if year not in result["year_totals"]:
result["year_totals"][year] = {"currencies": {}}
if currency_id not in result["year_totals"][year]["currencies"]:
result["year_totals"][year]["currencies"][currency_id] = {
"currency": currency_data["currency"].copy(),
"final_total": Decimal("0"),
}
result["year_totals"][year]["currencies"][currency_id]["final_total"] += (
final_total
)
# Accumulate grand total
if currency_id not in result["grand_total"]["currencies"]:
result["grand_total"]["currencies"][currency_id] = {
"currency": currency_data["currency"].copy(),
"final_total": Decimal("0"),
}
result["grand_total"]["currencies"][currency_id]["final_total"] += final_total
# Add currency conversion for item totals
for item_key, item_data in result["items"].items():
for currency_id, total_data in item_data["total"]["currencies"].items():
if currency_info[currency_id]["exchange_currency_id"]:
from_currency = Currency.objects.get(id=currency_id)
exchange_currency = Currency.objects.get(
id=currency_info[currency_id]["exchange_currency_id"]
)
converted_amount, prefix, suffix, decimal_places = convert(
amount=total_data["final_total"],
from_currency=from_currency,
to_currency=exchange_currency,
)
if converted_amount is not None:
total_data["exchanged"] = {
"final_total": converted_amount,
"currency": {
"prefix": prefix,
"suffix": suffix,
"decimal_places": decimal_places,
"code": exchange_currency.code,
"name": exchange_currency.name,
},
}
# Add currency conversion for year totals
for year, year_data in result["year_totals"].items():
for currency_id, total_data in year_data["currencies"].items():
if currency_info[currency_id]["exchange_currency_id"]:
from_currency = Currency.objects.get(id=currency_id)
exchange_currency = Currency.objects.get(
id=currency_info[currency_id]["exchange_currency_id"]
)
converted_amount, prefix, suffix, decimal_places = convert(
amount=total_data["final_total"],
from_currency=from_currency,
to_currency=exchange_currency,
)
if converted_amount is not None:
total_data["exchanged"] = {
"final_total": converted_amount,
"currency": {
"prefix": prefix,
"suffix": suffix,
"decimal_places": decimal_places,
"code": exchange_currency.code,
"name": exchange_currency.name,
},
}
# Add currency conversion for grand total
for currency_id, total_data in result["grand_total"]["currencies"].items():
if currency_info[currency_id]["exchange_currency_id"]:
from_currency = Currency.objects.get(id=currency_id)
exchange_currency = Currency.objects.get(
id=currency_info[currency_id]["exchange_currency_id"]
)
converted_amount, prefix, suffix, decimal_places = convert(
amount=total_data["final_total"],
from_currency=from_currency,
to_currency=exchange_currency,
)
if converted_amount is not None:
total_data["exchanged"] = {
"final_total": converted_amount,
"currency": {
"prefix": prefix,
"suffix": suffix,
"decimal_places": decimal_places,
"code": exchange_currency.code,
"name": exchange_currency.name,
},
}
return result

View File

@@ -26,6 +26,8 @@ from apps.insights.utils.sankey import (
generate_sankey_data_by_currency,
)
from apps.insights.utils.transactions import get_transactions
from apps.insights.utils.year_by_year import get_year_by_year_data
from apps.insights.utils.month_by_month import get_month_by_month_data
from apps.transactions.models import TransactionCategory, Transaction
from apps.transactions.utils.calculations import calculate_currency_totals
@@ -74,7 +76,9 @@ def index(request):
def sankey_by_account(request):
# Get filtered transactions
transactions = get_transactions(request, include_untracked_accounts=True)
transactions = get_transactions(
request, include_untracked_accounts=True, include_silent=True
)
# Generate Sankey data
sankey_data = generate_sankey_data_by_account(transactions)
@@ -91,7 +95,9 @@ def sankey_by_account(request):
@require_http_methods(["GET"])
def sankey_by_currency(request):
# Get filtered transactions
transactions = get_transactions(request)
transactions = get_transactions(
request, include_silent=True, include_untracked_accounts=True
)
# Generate Sankey data
sankey_data = generate_sankey_data_by_currency(transactions)
@@ -302,3 +308,71 @@ def emergency_fund(request):
"insights/fragments/emergency_fund.html",
{"data": currency_net_worth},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def year_by_year(request):
if "group_by" in request.GET:
group_by = request.GET["group_by"]
request.session["insights_year_by_year_group_by"] = group_by
else:
group_by = request.session.get("insights_year_by_year_group_by", "categories")
# Validate group_by value
if group_by not in ("categories", "tags", "entities"):
group_by = "categories"
data = get_year_by_year_data(group_by=group_by)
return render(
request,
"insights/fragments/year_by_year.html",
{
"data": data,
"group_by": group_by,
},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def month_by_month(request):
# Handle year selection
if "year" in request.GET:
try:
year = int(request.GET["year"])
request.session["insights_month_by_month_year"] = year
except (ValueError, TypeError):
year = request.session.get(
"insights_month_by_month_year", timezone.localdate(timezone.now()).year
)
else:
year = request.session.get(
"insights_month_by_month_year", timezone.localdate(timezone.now()).year
)
# Handle group_by selection
if "group_by" in request.GET:
group_by = request.GET["group_by"]
request.session["insights_month_by_month_group_by"] = group_by
else:
group_by = request.session.get("insights_month_by_month_group_by", "categories")
# Validate group_by value
if group_by not in ("categories", "tags", "entities"):
group_by = "categories"
data = get_month_by_month_data(year=year, group_by=group_by)
return render(
request,
"insights/fragments/month_by_month.html",
{
"data": data,
"group_by": group_by,
"selected_year": year,
},
)

View File

@@ -0,0 +1,331 @@
from datetime import date
from decimal import Decimal
from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from apps.accounts.models import Account, AccountGroup
from apps.currencies.models import Currency
from apps.transactions.models import (
Transaction,
TransactionCategory,
TransactionTag,
)
@override_settings(
STORAGES={
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
},
},
WHITENOISE_AUTOREFRESH=True,
)
class MonthlySummaryFilterBehaviorTests(TestCase):
"""Tests for monthly summary views filter behavior.
These tests verify that:
1. Views work correctly without any filters
2. Views work correctly with filters applied
3. The filter detection logic properly uses different querysets
4. Calculated values reflect the applied filters
"""
def setUp(self):
"""Set up test data"""
User = get_user_model()
self.user = User.objects.create_user(
email="testuser@test.com", password="testpass123"
)
self.client.login(username="testuser@test.com", password="testpass123")
self.currency = Currency.objects.create(
code="USD", name="US Dollar", decimal_places=2, prefix="$ "
)
self.account_group = AccountGroup.objects.create(name="Test Group")
self.account = Account.objects.create(
name="Test Account",
group=self.account_group,
currency=self.currency,
is_asset=False,
)
self.category = TransactionCategory.objects.create(
name="Test Category", owner=self.user
)
self.tag = TransactionTag.objects.create(name="TestTag", owner=self.user)
# Create test transactions for December 2025
# Income: 1000 (paid)
self.income_transaction = Transaction.objects.create(
account=self.account,
type=Transaction.Type.INCOME,
is_paid=True,
date=date(2025, 12, 10),
reference_date=date(2025, 12, 1),
amount=Decimal("1000.00"),
description="December Income",
owner=self.user,
)
# Expense: 200 (paid)
self.expense_transaction = Transaction.objects.create(
account=self.account,
type=Transaction.Type.EXPENSE,
is_paid=True,
date=date(2025, 12, 15),
reference_date=date(2025, 12, 1),
amount=Decimal("200.00"),
description="December Expense",
category=self.category,
owner=self.user,
)
self.expense_transaction.tags.add(self.tag)
# Expense: 150 (projected/unpaid)
self.projected_expense = Transaction.objects.create(
account=self.account,
type=Transaction.Type.EXPENSE,
is_paid=False,
date=date(2025, 12, 20),
reference_date=date(2025, 12, 1),
amount=Decimal("150.00"),
description="Projected Expense",
owner=self.user,
)
def _get_currency_data(self, context_dict):
"""Helper to extract data for our test currency from context dict.
The context dict is keyed by currency ID, so we need to find
the entry for our currency.
"""
if not context_dict:
return None
for currency_id, data in context_dict.items():
if data.get("currency", {}).get("code") == "USD":
return data
return None
# --- monthly_summary view tests ---
def test_monthly_summary_no_filter_returns_200(self):
"""Test that monthly_summary returns 200 without filters"""
response = self.client.get(
"/monthly/12/2025/summary/",
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
def test_monthly_summary_no_filter_includes_all_transactions(self):
"""Without filters, summary should include all transactions"""
response = self.client.get(
"/monthly/12/2025/summary/",
HTTP_HX_REQUEST="true",
)
context = response.context
# income_current should have the income: 1000
income_current = context.get("income_current", {})
usd_data = self._get_currency_data(income_current)
self.assertIsNotNone(usd_data)
self.assertEqual(usd_data["income_current"], Decimal("1000.00"))
# expense_current should have paid expense: 200
expense_current = context.get("expense_current", {})
usd_data = self._get_currency_data(expense_current)
self.assertIsNotNone(usd_data)
self.assertEqual(usd_data["expense_current"], Decimal("200.00"))
# expense_projected should have unpaid expense: 150
expense_projected = context.get("expense_projected", {})
usd_data = self._get_currency_data(expense_projected)
self.assertIsNotNone(usd_data)
self.assertEqual(usd_data["expense_projected"], Decimal("150.00"))
def test_monthly_summary_type_filter_only_income(self):
"""With type=IN filter, summary should only include income"""
response = self.client.get(
"/monthly/12/2025/summary/?type=IN",
HTTP_HX_REQUEST="true",
)
context = response.context
# income_current should still have 1000
income_current = context.get("income_current", {})
usd_data = self._get_currency_data(income_current)
self.assertIsNotNone(usd_data)
self.assertEqual(usd_data["income_current"], Decimal("1000.00"))
# expense_current should be empty/zero (filtered out)
expense_current = context.get("expense_current", {})
usd_data = self._get_currency_data(expense_current)
if usd_data:
self.assertEqual(usd_data.get("expense_current", 0), Decimal("0"))
# expense_projected should be empty/zero (filtered out)
expense_projected = context.get("expense_projected", {})
usd_data = self._get_currency_data(expense_projected)
if usd_data:
self.assertEqual(usd_data.get("expense_projected", 0), Decimal("0"))
def test_monthly_summary_type_filter_only_expenses(self):
"""With type=EX filter, summary should only include expenses"""
response = self.client.get(
"/monthly/12/2025/summary/?type=EX",
HTTP_HX_REQUEST="true",
)
context = response.context
# income_current should be empty/zero (filtered out)
income_current = context.get("income_current", {})
usd_data = self._get_currency_data(income_current)
if usd_data:
self.assertEqual(usd_data.get("income_current", 0), Decimal("0"))
# expense_current should have 200
expense_current = context.get("expense_current", {})
usd_data = self._get_currency_data(expense_current)
self.assertIsNotNone(usd_data)
self.assertEqual(usd_data["expense_current"], Decimal("200.00"))
# expense_projected should have 150
expense_projected = context.get("expense_projected", {})
usd_data = self._get_currency_data(expense_projected)
self.assertIsNotNone(usd_data)
self.assertEqual(usd_data["expense_projected"], Decimal("150.00"))
def test_monthly_summary_is_paid_filter_only_paid(self):
"""With is_paid=1 filter, summary should only include paid transactions"""
response = self.client.get(
"/monthly/12/2025/summary/?is_paid=1",
HTTP_HX_REQUEST="true",
)
context = response.context
# income_current should have 1000 (paid)
income_current = context.get("income_current", {})
usd_data = self._get_currency_data(income_current)
self.assertIsNotNone(usd_data)
self.assertEqual(usd_data["income_current"], Decimal("1000.00"))
# expense_current should have 200 (paid)
expense_current = context.get("expense_current", {})
usd_data = self._get_currency_data(expense_current)
self.assertIsNotNone(usd_data)
self.assertEqual(usd_data["expense_current"], Decimal("200.00"))
# expense_projected should be empty/zero (filtered out - unpaid)
expense_projected = context.get("expense_projected", {})
usd_data = self._get_currency_data(expense_projected)
if usd_data:
self.assertEqual(usd_data.get("expense_projected", 0), Decimal("0"))
def test_monthly_summary_is_paid_filter_only_unpaid(self):
"""With is_paid=0 filter, summary should only include unpaid transactions"""
response = self.client.get(
"/monthly/12/2025/summary/?is_paid=0",
HTTP_HX_REQUEST="true",
)
context = response.context
# income_current should be empty/zero (filtered out - paid)
income_current = context.get("income_current", {})
usd_data = self._get_currency_data(income_current)
if usd_data:
self.assertEqual(usd_data.get("income_current", 0), Decimal("0"))
# expense_current should be empty/zero (filtered out - paid)
expense_current = context.get("expense_current", {})
usd_data = self._get_currency_data(expense_current)
if usd_data:
self.assertEqual(usd_data.get("expense_current", 0), Decimal("0"))
# expense_projected should have 150 (unpaid)
expense_projected = context.get("expense_projected", {})
usd_data = self._get_currency_data(expense_projected)
self.assertIsNotNone(usd_data)
self.assertEqual(usd_data["expense_projected"], Decimal("150.00"))
def test_monthly_summary_description_filter(self):
"""With description filter, summary should only include matching transactions"""
response = self.client.get(
"/monthly/12/2025/summary/?description=Income",
HTTP_HX_REQUEST="true",
)
context = response.context
# Only income matches "Income" description
income_current = context.get("income_current", {})
usd_data = self._get_currency_data(income_current)
self.assertIsNotNone(usd_data)
self.assertEqual(usd_data["income_current"], Decimal("1000.00"))
# Expenses should be filtered out
expense_current = context.get("expense_current", {})
usd_data = self._get_currency_data(expense_current)
if usd_data:
self.assertEqual(usd_data.get("expense_current", 0), Decimal("0"))
def test_monthly_summary_amount_filter(self):
"""With amount filter, summary should only include transactions in range"""
# Filter to only get transactions between 100 and 250 (should get 200 and 150)
response = self.client.get(
"/monthly/12/2025/summary/?from_amount=100&to_amount=250",
HTTP_HX_REQUEST="true",
)
context = response.context
# Income (1000) should be filtered out
income_current = context.get("income_current", {})
usd_data = self._get_currency_data(income_current)
if usd_data:
self.assertEqual(usd_data.get("income_current", 0), Decimal("0"))
# expense_current should have 200
expense_current = context.get("expense_current", {})
usd_data = self._get_currency_data(expense_current)
self.assertIsNotNone(usd_data)
self.assertEqual(usd_data["expense_current"], Decimal("200.00"))
# expense_projected should have 150
expense_projected = context.get("expense_projected", {})
usd_data = self._get_currency_data(expense_projected)
self.assertIsNotNone(usd_data)
self.assertEqual(usd_data["expense_projected"], Decimal("150.00"))
# --- monthly_account_summary view tests ---
def test_monthly_account_summary_no_filter_returns_200(self):
"""Test that monthly_account_summary returns 200 without filters"""
response = self.client.get(
"/monthly/12/2025/summary/accounts/",
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
def test_monthly_account_summary_with_filter_returns_200(self):
"""Test that monthly_account_summary returns 200 with filter"""
response = self.client.get(
"/monthly/12/2025/summary/accounts/?type=IN",
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
# --- monthly_currency_summary view tests ---
def test_monthly_currency_summary_no_filter_returns_200(self):
"""Test that monthly_currency_summary returns 200 without filters"""
response = self.client.get(
"/monthly/12/2025/summary/currencies/",
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
def test_monthly_currency_summary_with_filter_returns_200(self):
"""Test that monthly_currency_summary returns 200 with filter"""
response = self.client.get(
"/monthly/12/2025/summary/currencies/?type=EX",
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)

View File

@@ -2,7 +2,8 @@ from django.contrib.auth.decorators import login_required
from django.db.models import (
Q,
)
from django.http import HttpResponse
from django.http import HttpResponse, Http404
from django.shortcuts import render, redirect
from django.utils import timezone
from django.views.decorators.http import require_http_methods
@@ -36,8 +37,6 @@ def monthly_overview(request, month: int, year: int):
summary_tab = request.session.get("monthly_summary_tab", "summary")
if month < 1 or month > 12:
from django.http import Http404
raise Http404("Month is out of range")
next_month = 1 if month == 12 else month + 1
@@ -76,6 +75,8 @@ def transactions_list(request, month: int, year: int):
if order != request.session.get("monthly_transactions_order", "default"):
request.session["monthly_transactions_order"] = order
today = timezone.localdate(timezone.now())
f = TransactionsFilter(request.GET)
transactions_filtered = f.qs.filter(
reference_date__year=year,
@@ -93,12 +94,28 @@ def transactions_list(request, month: int, year: int):
"dca_income_entries",
)
# Late transactions: date < today and is_paid = False (only shown for default ordering)
late_transactions = None
if order == "default":
late_transactions = transactions_filtered.filter(
date__lt=today,
is_paid=False,
).order_by("date", "id")
# Exclude late transactions from the main list
transactions_filtered = transactions_filtered.exclude(
date__lt=today,
is_paid=False,
)
transactions_filtered = default_order(transactions_filtered, order=order)
return render(
request,
"monthly_overview/fragments/list.html",
context={"transactions": transactions_filtered},
context={
"transactions": transactions_filtered,
"late_transactions": late_transactions,
},
)
@@ -107,17 +124,48 @@ 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(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True))
.exclude(account__in=request.user.untracked_accounts.all())
base_queryset = Transaction.objects.filter(
reference_date__year=year,
reference_date__month=month,
)
data = calculate_currency_totals(base_queryset, ignore_empty=True)
# Apply filters and check if any are active
f = TransactionsFilter(request.GET, queryset=base_queryset)
# Check if any filter has a non-default value
# Default values are: type=['IN', 'EX'], is_paid=['1', '0'], everything else empty
has_active_filter = False
if f.form.is_valid():
for name, value in f.form.cleaned_data.items():
# Skip fields with default/empty values
if not value:
continue
# Skip type if it has both default values
if name == "type" and set(value) == {"IN", "EX"}:
continue
# Skip is_paid if it has both default values (values are strings)
if name == "is_paid" and set(value) == {"1", "0"}:
continue
# Skip mute_status if it has both default values
if name == "mute_status" and set(value) == {"active", "muted"}:
continue
# If we get here, there's an active filter
has_active_filter = True
break
if has_active_filter:
queryset = f.qs
else:
queryset = (
base_queryset.exclude(
Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True)
)
.exclude(account__in=request.user.untracked_accounts.all())
.exclude(account__is_asset=True)
)
data = calculate_currency_totals(queryset, ignore_empty=True)
percentages = calculate_percentage_distribution(data)
context = {
@@ -132,6 +180,7 @@ def monthly_summary(request, month: int, year: int):
currency_totals=data, month=month, year=year
),
"percentages": percentages,
"has_active_filter": has_active_filter,
}
return render(
@@ -149,9 +198,38 @@ def monthly_account_summary(request, month: int, year: int):
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))
)
account_data = calculate_account_totals(transactions_queryset=base_queryset.all())
# Apply filters and check if any are active
f = TransactionsFilter(request.GET, queryset=base_queryset)
# Check if any filter has a non-default value
has_active_filter = False
if f.form.is_valid():
for name, value in f.form.cleaned_data.items():
if not value:
continue
if name == "type" and set(value) == {"IN", "EX"}:
continue
if name == "is_paid" and set(value) == {"1", "0"}:
continue
if name == "mute_status" and set(value) == {"active", "muted"}:
continue
has_active_filter = True
break
if has_active_filter:
queryset = f.qs
else:
queryset = (
base_queryset.exclude(
Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True)
)
.exclude(account__in=request.user.untracked_accounts.all())
.exclude(account__is_asset=True)
)
account_data = calculate_account_totals(transactions_queryset=queryset.all())
account_percentages = calculate_percentage_distribution(account_data)
context = {
@@ -171,16 +249,41 @@ 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(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True))
.exclude(account__in=request.user.untracked_accounts.all())
base_queryset = Transaction.objects.filter(
reference_date__year=year,
reference_date__month=month,
)
currency_data = calculate_currency_totals(base_queryset.all(), ignore_empty=True)
# Apply filters and check if any are active
f = TransactionsFilter(request.GET, queryset=base_queryset)
# Check if any filter has a non-default value
has_active_filter = False
if f.form.is_valid():
for name, value in f.form.cleaned_data.items():
if not value:
continue
if name == "type" and set(value) == {"IN", "EX"}:
continue
if name == "is_paid" and set(value) == {"1", "0"}:
continue
if name == "mute_status" and set(value) == {"active", "muted"}:
continue
has_active_filter = True
break
if has_active_filter:
queryset = f.qs
else:
queryset = (
base_queryset.exclude(
Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True)
)
.exclude(account__in=request.user.untracked_accounts.all())
.exclude(account__is_asset=True)
)
currency_data = calculate_currency_totals(queryset.all(), ignore_empty=True)
currency_percentages = calculate_percentage_distribution(currency_data)
context = {

View File

@@ -30,6 +30,7 @@ def calculate_historical_currency_net_worth(queryset):
| Q(accounts__visibility="private", accounts__owner=None),
accounts__is_archived=False,
accounts__isnull=False,
is_archived=False,
)
.values_list("name", flat=True)
.distinct()
@@ -181,3 +182,29 @@ def calculate_historical_account_balance(queryset):
historical_account_balance[date_filter(end_date, "b Y")] = month_data
return historical_account_balance
def calculate_monthly_net_worth_difference(historical_net_worth):
diff_dict = OrderedDict()
if not historical_net_worth:
return diff_dict
# Get all currencies
currencies = set()
for data in historical_net_worth.values():
currencies.update(data.keys())
# Initialize prev_values for all currencies
prev_values = {currency: Decimal("0.00") for currency in currencies}
for month, values in historical_net_worth.items():
diff_values = {}
for currency in sorted(list(currencies)):
current_val = values.get(currency, Decimal("0.00"))
prev_val = prev_values.get(currency, Decimal("0.00"))
diff_values[currency] = current_val - prev_val
diff_dict[month] = diff_values
prev_values = values.copy()
return diff_dict

View File

@@ -8,6 +8,7 @@ from django.views.decorators.http import require_http_methods
from apps.net_worth.utils.calculate_net_worth import (
calculate_historical_currency_net_worth,
calculate_historical_account_balance,
calculate_monthly_net_worth_difference,
)
from apps.transactions.models import Transaction
from apps.transactions.utils.calculations import (
@@ -96,6 +97,38 @@ def net_worth(request):
chart_data_currency_json = json.dumps(chart_data_currency, cls=DjangoJSONEncoder)
monthly_difference_data = calculate_monthly_net_worth_difference(
historical_net_worth=historical_currency_net_worth
)
diff_labels = (
list(monthly_difference_data.keys()) if monthly_difference_data else []
)
diff_currencies = (
list(monthly_difference_data[diff_labels[0]].keys())
if monthly_difference_data and diff_labels
else []
)
diff_datasets = []
for i, currency in enumerate(diff_currencies):
data = [
float(month_data.get(currency, 0))
for month_data in monthly_difference_data.values()
]
diff_datasets.append(
{
"label": currency,
"data": data,
"borderWidth": 3,
}
)
chart_data_monthly_difference = {"labels": diff_labels, "datasets": diff_datasets}
chart_data_monthly_difference_json = json.dumps(
chart_data_monthly_difference, cls=DjangoJSONEncoder
)
historical_account_balance = calculate_historical_account_balance(
queryset=transactions_account_queryset
)
@@ -140,6 +173,7 @@ def net_worth(request):
"chart_data_accounts_json": chart_data_accounts_json,
"accounts": accounts,
"type": view_type,
"chart_data_monthly_difference_json": chart_data_monthly_difference_json,
},
)

View File

@@ -1,16 +1,22 @@
from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion
from crispy_forms.bootstrap import FormActions, AccordionGroup
from crispy_forms.bootstrap import Alert
from apps.common.fields.forms.dynamic_select import DynamicModelChoiceField
from apps.common.widgets.crispy.daisyui import Switch
from apps.common.widgets.crispy.submit import NoClassSubmit
from apps.common.widgets.tom_select import TomSelect, TransactionSelect
from apps.rules.models import (
TransactionRule,
TransactionRuleAction,
UpdateOrCreateTransactionRuleAction,
)
from apps.transactions.forms import BulkEditTransactionForm
from apps.transactions.models import Transaction
from crispy_forms.bootstrap import AccordionGroup, FormActions, Accordion
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Field, Row, Column
from crispy_forms.layout import HTML, Column, Field, Layout, Row
from django import forms
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from apps.common.widgets.crispy.submit import NoClassSubmit
from apps.common.widgets.tom_select import TomSelect
from apps.rules.models import TransactionRule, UpdateOrCreateTransactionRuleAction
from apps.rules.models import TransactionRuleAction
class TransactionRuleForm(forms.ModelForm):
class Meta:
@@ -31,7 +37,6 @@ class TransactionRuleForm(forms.ModelForm):
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.form_method = "post"
# TO-DO: Add helper with available commands
self.helper.layout = Layout(
Switch("active"),
"name",
@@ -40,24 +45,25 @@ class TransactionRuleForm(forms.ModelForm):
Column(Switch("on_create")),
Column(Switch("on_delete")),
),
"order",
Switch("sequenced"),
"description",
"trigger",
Alert(
_("You can add actions to this rule in the next screen."), dismiss=False
),
)
if self.instance and self.instance.pk:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
),
)
else:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
),
)
@@ -65,10 +71,11 @@ class TransactionRuleForm(forms.ModelForm):
class TransactionRuleActionForm(forms.ModelForm):
class Meta:
model = TransactionRuleAction
fields = ("value", "field")
fields = ("value", "field", "order")
labels = {
"field": _("Set field"),
"value": _("To"),
"order": _("Order"),
}
widgets = {"field": TomSelect(clear_button=False)}
@@ -82,6 +89,7 @@ class TransactionRuleActionForm(forms.ModelForm):
self.helper.form_method = "post"
# TO-DO: Add helper with available commands
self.helper.layout = Layout(
"order",
"field",
"value",
)
@@ -89,17 +97,13 @@ class TransactionRuleActionForm(forms.ModelForm):
if self.instance and self.instance.pk:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
),
)
else:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
),
)
@@ -147,9 +151,11 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
"search_category_operator": TomSelect(clear_button=False),
"search_internal_note_operator": TomSelect(clear_button=False),
"search_internal_id_operator": TomSelect(clear_button=False),
"search_mute_operator": TomSelect(clear_button=False),
}
labels = {
"order": _("Order"),
"search_account_operator": _("Operator"),
"search_type_operator": _("Operator"),
"search_is_paid_operator": _("Operator"),
@@ -163,6 +169,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
"search_internal_id_operator": _("Operator"),
"search_tags_operator": _("Operator"),
"search_entities_operator": _("Operator"),
"search_mute_operator": _("Operator"),
"search_account": _("Account"),
"search_type": _("Type"),
"search_is_paid": _("Paid"),
@@ -176,6 +183,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
"search_internal_id": _("Internal ID"),
"search_tags": _("Tags"),
"search_entities": _("Entities"),
"search_mute": _("Mute"),
"set_account": _("Account"),
"set_type": _("Type"),
"set_is_paid": _("Paid"),
@@ -189,6 +197,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
"set_category": _("Category"),
"set_internal_note": _("Internal Note"),
"set_internal_id": _("Internal ID"),
"set_mute": _("Mute"),
}
def __init__(self, *args, **kwargs):
@@ -200,138 +209,149 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
self.helper.form_method = "post"
self.helper.layout = Layout(
BS5Accordion(
"order",
Accordion(
AccordionGroup(
_("Search Criteria"),
Field("filter", rows=1),
Row(
Column(
Field("search_type_operator"),
css_class="form-group col-md-4",
css_class="col-span-12 md:col-span-4",
),
Column(
Field("search_type", rows=1),
css_class="form-group col-md-8",
css_class="col-span-12 md:col-span-8",
),
),
Row(
Column(
Field("search_is_paid_operator"),
css_class="form-group col-md-4",
css_class="col-span-12 md:col-span-4",
),
Column(
Field("search_is_paid", rows=1),
css_class="form-group col-md-8",
css_class="col-span-12 md:col-span-8",
),
),
Row(
Column(
Field("search_mute_operator"),
css_class="col-span-12 md:col-span-4",
),
Column(
Field("search_mute", rows=1),
css_class="col-span-12 md:col-span-8",
),
),
Row(
Column(
Field("search_account_operator"),
css_class="form-group col-md-4",
css_class="col-span-12 md:col-span-4",
),
Column(
Field("search_account", rows=1),
css_class="form-group col-md-8",
css_class="col-span-12 md:col-span-8",
),
),
Row(
Column(
Field("search_entities_operator"),
css_class="form-group col-md-4",
css_class="col-span-12 md:col-span-4",
),
Column(
Field("search_entities", rows=1),
css_class="form-group col-md-8",
css_class="col-span-12 md:col-span-8",
),
),
Row(
Column(
Field("search_date_operator"),
css_class="form-group col-md-4",
css_class="col-span-12 md:col-span-4",
),
Column(
Field("search_date", rows=1),
css_class="form-group col-md-8",
css_class="col-span-12 md:col-span-8",
),
),
Row(
Column(
Field("search_reference_date_operator"),
css_class="form-group col-md-4",
css_class="col-span-12 md:col-span-4",
),
Column(
Field("search_reference_date", rows=1),
css_class="form-group col-md-8",
css_class="col-span-12 md:col-span-8",
),
),
Row(
Column(
Field("search_description_operator"),
css_class="form-group col-md-4",
css_class="col-span-12 md:col-span-4",
),
Column(
Field("search_description", rows=1),
css_class="form-group col-md-8",
css_class="col-span-12 md:col-span-8",
),
),
Row(
Column(
Field("search_amount_operator"),
css_class="form-group col-md-4",
css_class="col-span-12 md:col-span-4",
),
Column(
Field("search_amount", rows=1),
css_class="form-group col-md-8",
css_class="col-span-12 md:col-span-8",
),
),
Row(
Column(
Field("search_category_operator"),
css_class="form-group col-md-4",
css_class="col-span-12 md:col-span-4",
),
Column(
Field("search_category", rows=1),
css_class="form-group col-md-8",
css_class="col-span-12 md:col-span-8",
),
),
Row(
Column(
Field("search_tags_operator"),
css_class="form-group col-md-4",
css_class="col-span-12 md:col-span-4",
),
Column(
Field("search_tags", rows=1),
css_class="form-group col-md-8",
css_class="col-span-12 md:col-span-8",
),
),
Row(
Column(
Field("search_notes_operator"),
css_class="form-group col-md-4",
css_class="col-span-12 md:col-span-4",
),
Column(
Field("search_notes", rows=1),
css_class="form-group col-md-8",
css_class="col-span-12 md:col-span-8",
),
),
Row(
Column(
Field("search_internal_note_operator"),
css_class="form-group col-md-4",
css_class="col-span-12 md:col-span-4",
),
Column(
Field("search_internal_note", rows=1),
css_class="form-group col-md-8",
css_class="col-span-12 md:col-span-8",
),
),
Row(
Column(
Field("search_internal_id_operator"),
css_class="form-group col-md-4",
css_class="col-span-12 md:col-span-4",
),
Column(
Field("search_internal_id", rows=1),
css_class="form-group col-md-8",
css_class="col-span-12 md:col-span-8",
),
),
active=True,
@@ -340,6 +360,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
_("Set Values"),
Field("set_type", rows=1),
Field("set_is_paid", rows=1),
Field("set_mute", rows=1),
Field("set_account", rows=1),
Field("set_entities", rows=1),
Field("set_date", rows=1),
@@ -361,17 +382,13 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
if self.instance and self.instance.pk:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
),
)
else:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
),
)
@@ -381,3 +398,106 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
if commit:
instance.save()
return instance
class DryRunCreatedTransacion(forms.Form):
transaction = DynamicModelChoiceField(
model=Transaction,
to_field_name="id",
label=_("Transaction"),
required=True,
queryset=Transaction.objects.none(),
widget=TransactionSelect(clear_button=False, income=True, expense=True),
help_text=_("Type to search for a transaction"),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.layout = Layout(
"transaction",
FormActions(
NoClassSubmit("submit", _("Test"), css_class="btn btn-primary"),
),
)
if self.data.get("transaction"):
try:
transaction = Transaction.objects.get(id=self.data.get("transaction"))
except Transaction.DoesNotExist:
transaction = None
if transaction:
self.fields["transaction"].queryset = Transaction.objects.filter(
id=transaction.id
)
class DryRunDeletedTransacion(forms.Form):
transaction = DynamicModelChoiceField(
model=Transaction,
to_field_name="id",
label=_("Transaction"),
required=True,
queryset=Transaction.objects.none(),
widget=TransactionSelect(clear_button=False, income=True, expense=True),
help_text=_("Type to search for a transaction"),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.layout = Layout(
"transaction",
FormActions(
NoClassSubmit("submit", _("Test"), css_class="btn btn-primary"),
),
)
if self.data.get("transaction"):
try:
transaction = Transaction.objects.get(id=self.data.get("transaction"))
except Transaction.DoesNotExist:
transaction = None
if transaction:
self.fields["transaction"].queryset = Transaction.objects.filter(
id=transaction.id
)
class DryRunUpdatedTransactionForm(BulkEditTransactionForm):
transaction = DynamicModelChoiceField(
model=Transaction,
to_field_name="id",
label=_("Transaction"),
required=True,
queryset=Transaction.objects.none(),
widget=TransactionSelect(clear_button=False, income=True, expense=True),
help_text=_("Type to search for a transaction"),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper.layout.insert(0, "transaction")
self.helper.layout.insert(1, HTML('<hr class="hr my-3" />'))
# Change submit button
self.helper.layout[-1] = FormActions(
NoClassSubmit("submit", _("Test"), css_class="btn btn-primary")
)
if self.data.get("transaction"):
try:
transaction = Transaction.objects.get(id=self.data.get("transaction"))
except Transaction.DoesNotExist:
transaction = None
if transaction:
self.fields["transaction"].queryset = Transaction.objects.filter(
id=transaction.id
)

View File

@@ -0,0 +1,39 @@
# Generated by Django 5.2 on 2025-08-30 18:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("rules", "0014_alter_transactionrule_owner_and_more"),
]
operations = [
migrations.AlterModelOptions(
name="transactionruleaction",
options={
"ordering": ["order"],
"verbose_name": "Edit transaction action",
"verbose_name_plural": "Edit transaction actions",
},
),
migrations.AlterModelOptions(
name="updateorcreatetransactionruleaction",
options={
"ordering": ["order"],
"verbose_name": "Update or create transaction action",
"verbose_name_plural": "Update or create transaction actions",
},
),
migrations.AddField(
model_name="transactionruleaction",
name="order",
field=models.PositiveIntegerField(default=0, verbose_name="Order"),
),
migrations.AddField(
model_name="updateorcreatetransactionruleaction",
name="order",
field=models.PositiveIntegerField(default=0, verbose_name="Order"),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.5 on 2025-08-31 18:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rules', '0015_alter_transactionruleaction_options_and_more'),
]
operations = [
migrations.AddField(
model_name='transactionrule',
name='sequenced',
field=models.BooleanField(default=False, verbose_name='Sequenced'),
),
]

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.2.5 on 2025-08-31 19:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rules', '0016_transactionrule_sequenced'),
]
operations = [
migrations.AddField(
model_name='updateorcreatetransactionruleaction',
name='search_mute',
field=models.TextField(blank=True, verbose_name='Search Mute'),
),
migrations.AddField(
model_name='updateorcreatetransactionruleaction',
name='search_mute_operator',
field=models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='exact', max_length=10, verbose_name='Mute Operator'),
),
migrations.AddField(
model_name='updateorcreatetransactionruleaction',
name='set_mute',
field=models.TextField(blank=True, verbose_name='Mute'),
),
migrations.AlterField(
model_name='transactionruleaction',
name='field',
field=models.CharField(choices=[('account', 'Account'), ('type', 'Type'), ('is_paid', 'Paid'), ('date', 'Date'), ('reference_date', 'Reference Date'), ('mute', 'Mute'), ('amount', 'Amount'), ('description', 'Description'), ('notes', 'Notes'), ('category', 'Category'), ('tags', 'Tags'), ('entities', 'Entities'), ('internal_nome', 'Internal Note'), ('internal_id', 'Internal ID')], max_length=50, verbose_name='Field'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.5 on 2025-09-02 14:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rules', '0017_updateorcreatetransactionruleaction_search_mute_and_more'),
]
operations = [
migrations.AddField(
model_name='transactionrule',
name='order',
field=models.PositiveIntegerField(default=0, verbose_name='Order'),
),
]

View File

@@ -13,6 +13,11 @@ class TransactionRule(SharedObject):
name = models.CharField(max_length=100, verbose_name=_("Name"))
description = models.TextField(blank=True, null=True, verbose_name=_("Description"))
trigger = models.TextField(verbose_name=_("Trigger"))
sequenced = models.BooleanField(
verbose_name=_("Sequenced"),
default=False,
)
order = models.PositiveIntegerField(default=0, verbose_name=_("Order"))
objects = SharedObjectManager()
all_objects = models.Manager() # Unfiltered manager
@@ -32,12 +37,15 @@ class TransactionRuleAction(models.Model):
is_paid = "is_paid", _("Paid")
date = "date", _("Date")
reference_date = "reference_date", _("Reference Date")
mute = "mute", _("Mute")
amount = "amount", _("Amount")
description = "description", _("Description")
notes = "notes", _("Notes")
category = "category", _("Category")
tags = "tags", _("Tags")
entities = "entities", _("Entities")
internal_note = "internal_nome", _("Internal Note")
internal_id = "internal_id", _("Internal ID")
rule = models.ForeignKey(
TransactionRule,
@@ -51,6 +59,7 @@ class TransactionRuleAction(models.Model):
verbose_name=_("Field"),
)
value = models.TextField(verbose_name=_("Value"))
order = models.PositiveIntegerField(default=0, verbose_name=_("Order"))
def __str__(self):
return f"{self.rule} - {self.field} - {self.value}"
@@ -59,6 +68,11 @@ class TransactionRuleAction(models.Model):
verbose_name = _("Edit transaction action")
verbose_name_plural = _("Edit transaction actions")
unique_together = (("rule", "field"),)
ordering = ["order"]
@property
def action_type(self):
return "edit_transaction"
class UpdateOrCreateTransactionRuleAction(models.Model):
@@ -237,6 +251,17 @@ class UpdateOrCreateTransactionRuleAction(models.Model):
verbose_name="Internal ID Operator",
)
search_mute = models.TextField(
verbose_name="Search Mute",
blank=True,
)
search_mute_operator = models.CharField(
max_length=10,
choices=SearchOperator.choices,
default=SearchOperator.EXACT,
verbose_name="Mute Operator",
)
# Set fields
set_account = models.TextField(
verbose_name=_("Account"),
@@ -290,10 +315,21 @@ class UpdateOrCreateTransactionRuleAction(models.Model):
verbose_name=_("Tags"),
blank=True,
)
set_mute = models.TextField(
verbose_name=_("Mute"),
blank=True,
)
order = models.PositiveIntegerField(default=0, verbose_name=_("Order"))
class Meta:
verbose_name = _("Update or create transaction action")
verbose_name_plural = _("Update or create transaction actions")
ordering = ["order"]
@property
def action_type(self):
return "update_or_create_transaction"
def __str__(self):
return f"Update or create transaction action for {self.rule}"
@@ -325,6 +361,10 @@ class UpdateOrCreateTransactionRuleAction(models.Model):
value = simple.eval(self.search_is_paid)
search_query &= add_to_query("is_paid", value, self.search_is_paid_operator)
if self.search_mute:
value = simple.eval(self.search_mute)
search_query &= add_to_query("mute", value, self.search_mute_operator)
if self.search_date:
value = simple.eval(self.search_date)
search_query &= add_to_query("date", value, self.search_date_operator)

View File

@@ -9,40 +9,17 @@ from apps.transactions.models import (
)
from apps.rules.tasks import check_for_transaction_rules
from apps.common.middleware.thread_local import get_current_user
from apps.rules.utils.transactions import serialize_transaction
@receiver(transaction_created)
@receiver(transaction_updated)
@receiver(transaction_deleted)
def transaction_changed_receiver(sender: Transaction, signal, **kwargs):
old_data = kwargs.get("old_data")
if signal is transaction_deleted:
# Serialize transaction data for processing
transaction_data = {
"id": sender.id,
"account": (sender.account.id, sender.account.name),
"account_group": (
sender.account.group.id if sender.account.group else None,
sender.account.group.name if sender.account.group else None,
),
"type": str(sender.type),
"is_paid": sender.is_paid,
"is_asset": sender.account.is_asset,
"is_archived": sender.account.is_archived,
"category": (
sender.category.id if sender.category else None,
sender.category.name if sender.category else None,
),
"date": sender.date.isoformat(),
"reference_date": sender.reference_date.isoformat(),
"amount": str(sender.amount),
"description": sender.description,
"notes": sender.notes,
"tags": list(sender.tags.values_list("id", "name")),
"entities": list(sender.entities.values_list("id", "name")),
"deleted": True,
"internal_note": sender.internal_note,
"internal_id": sender.internal_id,
}
transaction_data = serialize_transaction(sender, deleted=True)
check_for_transaction_rules.defer(
transaction_data=transaction_data,
@@ -59,6 +36,9 @@ def transaction_changed_receiver(sender: Transaction, signal, **kwargs):
dca_entry.amount_received = sender.amount
dca_entry.save()
if signal is transaction_updated and old_data:
old_data = serialize_transaction(old_data, deleted=False)
check_for_transaction_rules.defer(
instance_id=sender.id,
user_id=get_current_user().id,
@@ -67,4 +47,5 @@ def transaction_changed_receiver(sender: Transaction, signal, **kwargs):
if signal is transaction_created
else "transaction_updated"
),
old_data=old_data,
)

File diff suppressed because it is too large Load Diff

View File

@@ -42,6 +42,21 @@ urlpatterns = [
views.transaction_rule_take_ownership,
name="transaction_rule_take_ownership",
),
path(
"rules/transaction/<int:pk>/dry-run/created/",
views.dry_run_rule_created,
name="transaction_rule_dry_run_created",
),
path(
"rules/transaction/<int:pk>/dry-run/deleted/",
views.dry_run_rule_deleted,
name="transaction_rule_dry_run_deleted",
),
path(
"rules/transaction/<int:pk>/dry-run/updated/",
views.dry_run_rule_updated,
name="transaction_rule_dry_run_updated",
),
path(
"rules/transaction/<int:pk>/share/",
views.transaction_rule_share,

View File

View File

@@ -0,0 +1,101 @@
import logging
from decimal import Decimal
from django.db.models import Sum, Value, DecimalField, Case, When, F
from django.db.models.functions import Coalesce
from apps.transactions.models import (
Transaction,
)
logger = logging.getLogger(__name__)
class TransactionsGetter:
def __init__(self, **filters):
self.__queryset = Transaction.objects.filter(**filters)
def exclude(self, **exclude_filters):
self.__queryset = self.__queryset.exclude(**exclude_filters)
return self
@property
def sum(self):
return self.__queryset.aggregate(
total=Coalesce(
Sum("amount"), Value(Decimal("0")), output_field=DecimalField()
)
)["total"]
@property
def balance(self):
return abs(
self.__queryset.aggregate(
balance=Coalesce(
Sum(
Case(
When(type=Transaction.Type.EXPENSE, then=-F("amount")),
default=F("amount"),
output_field=DecimalField(),
)
),
Value(Decimal("0")),
output_field=DecimalField(),
)
)["balance"]
)
@property
def raw_balance(self):
return self.__queryset.aggregate(
balance=Coalesce(
Sum(
Case(
When(type=Transaction.Type.EXPENSE, then=-F("amount")),
default=F("amount"),
output_field=DecimalField(),
)
),
Value(Decimal("0")),
output_field=DecimalField(),
)
)["balance"]
def serialize_transaction(sender: Transaction, deleted: bool):
return {
"id": sender.id,
"account": (sender.account.id, sender.account.name),
"account_group": (
sender.account.group.id if sender.account.group else None,
sender.account.group.name if sender.account.group else None,
),
"type": str(sender.type),
"is_paid": sender.is_paid,
"is_asset": sender.account.is_asset,
"is_archived": sender.account.is_archived,
"category": (
sender.category.id if sender.category else None,
sender.category.name if sender.category else None,
),
"date": sender.date.isoformat(),
"reference_date": sender.reference_date.isoformat(),
"amount": str(sender.amount),
"description": sender.description,
"notes": sender.notes,
"tags": list(sender.tags.values_list("id", "name")),
"entities": list(sender.entities.values_list("id", "name")),
"deleted": deleted,
"internal_note": sender.internal_note,
"internal_id": sender.internal_id,
"mute": sender.mute,
"installment_id": sender.installment_id if sender.installment_plan else None,
"installment_total": (
sender.installment_plan.number_of_installments
if sender.installment_plan is not None
else None
),
"installment": sender.installment_plan is not None,
"recurring_transaction": sender.recurring_transaction is not None,
}

View File

@@ -1,5 +1,10 @@
from itertools import chain
from copy import deepcopy
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.db import transaction
from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404, redirect
from django.utils.translation import gettext_lazy as _
@@ -10,6 +15,9 @@ from apps.rules.forms import (
TransactionRuleForm,
TransactionRuleActionForm,
UpdateOrCreateTransactionRuleActionForm,
DryRunCreatedTransacion,
DryRunDeletedTransacion,
DryRunUpdatedTransactionForm,
)
from apps.rules.models import (
TransactionRule,
@@ -19,6 +27,11 @@ from apps.rules.models import (
from apps.common.models import SharedObject
from apps.common.forms import SharedObjectForm
from apps.common.decorators.demo import disabled_on_demo
from apps.rules.tasks import check_for_transaction_rules
from apps.common.middleware.thread_local import get_current_user
from apps.rules.signals import transaction_created, transaction_updated
from apps.rules.utils.transactions import serialize_transaction
from apps.transactions.models import Transaction
@login_required
@@ -36,7 +49,7 @@ def rules_index(request):
@disabled_on_demo
@require_http_methods(["GET"])
def rules_list(request):
transaction_rules = TransactionRule.objects.all().order_by("id")
transaction_rules = TransactionRule.objects.all().order_by("order", "id")
return render(
request,
"rules/fragments/list.html",
@@ -140,10 +153,20 @@ def transaction_rule_edit(request, transaction_rule_id):
def transaction_rule_view(request, transaction_rule_id):
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
edit_actions = transaction_rule.transaction_actions.all()
update_or_create_actions = (
transaction_rule.update_or_create_transaction_actions.all()
)
all_actions = sorted(
chain(edit_actions, update_or_create_actions),
key=lambda a: a.order,
)
return render(
request,
"rules/fragments/transaction_rule/view.html",
{"transaction_rule": transaction_rule},
{"transaction_rule": transaction_rule, "all_actions": all_actions},
)
@@ -406,3 +429,156 @@ def update_or_create_transaction_rule_action_delete(request, pk):
"HX-Trigger": "updated, hide_offcanvas",
},
)
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def dry_run_rule_created(request, pk):
rule = get_object_or_404(TransactionRule, id=pk)
logs = None
results = None
if request.method == "POST":
form = DryRunCreatedTransacion(request.POST)
if form.is_valid():
try:
with transaction.atomic():
logs, results = check_for_transaction_rules(
instance_id=form.cleaned_data["transaction"].id,
signal="transaction_created",
dry_run=True,
rule_id=rule.id,
user_id=get_current_user().id,
)
logs = "\n".join(logs)
response = render(
request,
"rules/fragments/transaction_rule/dry_run/created.html",
{"form": form, "rule": rule, "logs": logs, "results": results},
)
raise Exception("ROLLBACK")
except Exception:
pass
return response
else:
form = DryRunCreatedTransacion()
return render(
request,
"rules/fragments/transaction_rule/dry_run/created.html",
{"form": form, "rule": rule, "logs": logs, "results": results},
)
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def dry_run_rule_deleted(request, pk):
rule = get_object_or_404(TransactionRule, id=pk)
logs = None
results = None
if request.method == "POST":
form = DryRunDeletedTransacion(request.POST)
if form.is_valid():
try:
with transaction.atomic():
logs, results = check_for_transaction_rules(
instance_id=form.cleaned_data["transaction"].id,
signal="transaction_deleted",
dry_run=True,
rule_id=rule.id,
user_id=get_current_user().id,
)
logs = "\n".join(logs)
response = render(
request,
"rules/fragments/transaction_rule/dry_run/created.html",
{"form": form, "rule": rule, "logs": logs, "results": results},
)
raise Exception("ROLLBACK")
except Exception:
pass
return response
else:
form = DryRunDeletedTransacion()
return render(
request,
"rules/fragments/transaction_rule/dry_run/deleted.html",
{"form": form, "rule": rule, "logs": logs, "results": results},
)
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def dry_run_rule_updated(request, pk):
rule = get_object_or_404(TransactionRule, id=pk)
logs = None
results = None
if request.method == "POST":
form = DryRunUpdatedTransactionForm(request.POST)
if form.is_valid():
base_transaction = Transaction.objects.get(
id=request.POST.get("transaction")
)
old_data = deepcopy(base_transaction)
try:
with transaction.atomic():
for field_name, value in form.cleaned_data.items():
if value or isinstance(
value, bool
): # Only update fields that have been filled in the form
if field_name == "tags":
base_transaction.tags.set(value)
elif field_name == "entities":
base_transaction.entities.set(value)
else:
setattr(base_transaction, field_name, value)
base_transaction.save()
logs, results = check_for_transaction_rules(
instance_id=base_transaction.id,
signal="transaction_updated",
dry_run=True,
rule_id=rule.id,
user_id=get_current_user().id,
old_data=old_data,
)
logs = "\n".join(logs) if logs else ""
response = render(
request,
"rules/fragments/transaction_rule/dry_run/updated.html",
{"form": form, "rule": rule, "logs": logs, "results": results},
)
# This will rollback the transaction
raise Exception("ROLLBACK")
except Exception:
pass
return response
else:
form = DryRunUpdatedTransactionForm(initial={"is_paid": None, "type": None})
return render(
request,
"rules/fragments/transaction_rule/dry_run/updated.html",
{"form": form, "rule": rule, "logs": logs, "results": results},
)

View File

@@ -1,11 +1,4 @@
import django_filters
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Field, Row, Column
from django import forms
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from django_filters import Filter
from apps.accounts.models import Account
from apps.common.fields.month_year import MonthYearFormField
from apps.common.widgets.datepicker import AirDatePickerInput
@@ -15,15 +8,26 @@ from apps.currencies.models import Currency
from apps.transactions.models import (
Transaction,
TransactionCategory,
TransactionTag,
TransactionEntity,
TransactionTag,
)
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Column, Field, Layout, Row
from django import forms
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from django_filters import Filter
SITUACAO_CHOICES = (
("1", _("Paid")),
("0", _("Projected")),
)
MUTE_STATUS_CHOICES = (
("active", _("Active")),
("muted", _("Muted")),
)
def content_filter(queryset, name, value):
queryset = queryset.filter(
@@ -79,6 +83,11 @@ class TransactionsFilter(django_filters.FilterSet):
choices=SITUACAO_CHOICES,
field_name="is_paid",
)
mute_status = django_filters.MultipleChoiceFilter(
choices=MUTE_STATUS_CHOICES,
method="filter_mute_status",
label=_("Mute Status"),
)
date_start = django_filters.DateFilter(
field_name="date",
lookup_expr="gte",
@@ -141,6 +150,9 @@ class TransactionsFilter(django_filters.FilterSet):
if data.get("is_paid") is None:
data.setlist("is_paid", ["1", "0"])
if data.get("mute_status") is None:
data.setlist("mute_status", ["active", "muted"])
super().__init__(data, *args, **kwargs)
self.form.helper = FormHelper()
@@ -156,17 +168,19 @@ class TransactionsFilter(django_filters.FilterSet):
"is_paid",
template="transactions/widgets/transaction_type_filter_buttons.html",
),
Field(
"mute_status",
template="transactions/widgets/transaction_type_filter_buttons.html",
),
Field("description"),
Row(Column("date_start"), Column("date_end")),
Row(
Column("reference_date_start", css_class="form-group col-md-6 mb-0"),
Column("reference_date_end", css_class="form-group col-md-6 mb-0"),
css_class="form-row",
Column("reference_date_start"),
Column("reference_date_end"),
),
Row(
Column("from_amount", css_class="form-group col-md-6 mb-0"),
Column("to_amount", css_class="form-group col-md-6 mb-0"),
css_class="form-row",
Column("from_amount"),
Column("to_amount"),
),
Field("account", size=1),
Field("currency", size=1),
@@ -271,3 +285,36 @@ class TransactionsFilter(django_filters.FilterSet):
return queryset.filter(q).distinct()
return queryset
@staticmethod
def filter_mute_status(queryset, name, value):
from apps.common.middleware.thread_local import get_current_user
if not value:
return queryset
value = list(value)
# If both are selected, return all
if "active" in value and "muted" in value:
return queryset
user = get_current_user()
# Only Active selected: exclude muted transactions
if "active" in value:
return (
queryset.exclude(account__untracked_by=user)
.filter(
mute=False,
)
.filter(Q(category__mute=False) | Q(category__isnull=True))
)
# Only Muted selected: include only muted transactions
if "muted" in value:
return queryset.filter(
Q(account__untracked_by=user) | Q(category__mute=True) | Q(mute=True)
)
return queryset

View File

@@ -1,37 +1,38 @@
from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion
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
from django.utils.translation import gettext_lazy as _
from copy import deepcopy
from apps.accounts.models import Account
from apps.common.fields.forms.dynamic_select import (
DynamicModelChoiceField,
DynamicModelMultipleChoiceField,
)
from apps.common.widgets.crispy.daisyui import Switch
from apps.common.widgets.crispy.submit import NoClassSubmit
from apps.common.widgets.datepicker import AirDatePickerInput, AirMonthYearPickerInput
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
from apps.common.widgets.tom_select import TomSelect
from apps.rules.signals import transaction_created, transaction_updated
from apps.transactions.models import (
InstallmentPlan,
QuickTransaction,
RecurringTransaction,
Transaction,
TransactionCategory,
TransactionTag,
InstallmentPlan,
RecurringTransaction,
TransactionEntity,
QuickTransaction,
TransactionTag,
)
from crispy_forms.bootstrap import AccordionGroup, AppendedText, FormActions, Accordion
from crispy_forms.helper import FormHelper
from crispy_forms.layout import (
HTML,
Column,
Div,
Field,
Layout,
Row,
)
from django import forms
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
class TransactionForm(forms.ModelForm):
@@ -132,21 +133,18 @@ class TransactionForm(forms.ModelForm):
),
Field("is_paid", template="transactions/widgets/paid_toggle_button.html"),
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",
Column("account"),
Column("entities"),
),
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",
Column(Field("date")),
Column(Field("reference_date")),
),
"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",
Column("category"),
Column("tags"),
),
"notes",
)
@@ -162,20 +160,18 @@ class TransactionForm(forms.ModelForm):
Field("is_paid", template="transactions/widgets/paid_toggle_button.html"),
"account",
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",
Column(Field("date")),
Column(Field("reference_date")),
),
"description",
Field("amount", inputmode="decimal"),
BS5Accordion(
Accordion(
AccordionGroup(
_("More"),
"entities",
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",
Column("category"),
Column("tags"),
),
"notes",
active=False,
@@ -185,9 +181,7 @@ class TransactionForm(forms.ModelForm):
css_class="mb-3",
),
FormActions(
NoClassSubmit(
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
),
)
@@ -200,29 +194,25 @@ class TransactionForm(forms.ModelForm):
)
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
),
)
else:
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
self.helper.layout.append(
Div(
NoClassSubmit(
"submit", _("Add"), css_class="btn btn-outline-primary"
),
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
NoClassSubmit(
"submit_and_similar",
_("Save and add similar"),
css_class="btn btn-outline-primary",
css_class="btn btn-primary btn-soft",
),
NoClassSubmit(
"submit_and_another",
_("Save and add another"),
css_class="btn btn-outline-primary",
css_class="btn btn-primary btn-soft",
),
css_class="d-grid gap-2",
css_class="flex flex-col gap-2 mt-3",
),
)
@@ -239,11 +229,16 @@ class TransactionForm(forms.ModelForm):
def save(self, **kwargs):
is_new = not self.instance.id
if not is_new:
old_data = deepcopy(Transaction.objects.get(pk=self.instance.id))
else:
old_data = None
instance = super().save(**kwargs)
if is_new:
transaction_created.send(sender=instance)
else:
transaction_updated.send(sender=instance)
transaction_updated.send(sender=instance, old_data=old_data)
return instance
@@ -341,23 +336,16 @@ class QuickTransactionForm(forms.ModelForm):
),
Field("is_paid", template="transactions/widgets/paid_toggle_button.html"),
"name",
HTML("<hr />"),
HTML('<hr class="hr my-3" />'),
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",
Column("account"),
Column("entities"),
),
"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",
Column("category"),
Column("tags"),
),
"notes",
Switch("mute"),
@@ -370,59 +358,132 @@ class QuickTransactionForm(forms.ModelForm):
)
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
),
)
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",
FormActions(
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
),
)
class BulkEditTransactionForm(TransactionForm):
is_paid = forms.NullBooleanField(required=False)
class BulkEditTransactionForm(forms.Form):
type = forms.ChoiceField(
choices=(Transaction.Type.choices),
required=False,
label=_("Type"),
)
is_paid = forms.NullBooleanField(
required=False,
label=_("Paid"),
)
account = DynamicModelChoiceField(
model=Account,
required=False,
label=_("Account"),
queryset=Account.objects.filter(is_archived=False),
widget=TomSelect(clear_button=False, group_by="group"),
)
date = forms.DateField(
label=_("Date"),
required=False,
widget=AirDatePickerInput(clear_button=False),
)
reference_date = forms.DateField(
widget=AirMonthYearPickerInput(),
label=_("Reference Date"),
required=False,
)
amount = forms.DecimalField(
max_digits=42,
decimal_places=30,
required=False,
label=_("Amount"),
widget=ArbitraryDecimalDisplayNumberInput(),
)
description = forms.CharField(
max_length=500, required=False, label=_("Description")
)
notes = forms.CharField(
required=False,
widget=forms.Textarea(attrs={"rows": 3}),
label=_("Notes"),
)
category = DynamicModelChoiceField(
create_field="name",
model=TransactionCategory,
required=False,
label=_("Category"),
queryset=TransactionCategory.objects.filter(active=True),
)
tags = DynamicModelMultipleChoiceField(
model=TransactionTag,
to_field_name="name",
create_field="name",
required=False,
label=_("Tags"),
queryset=TransactionTag.objects.filter(active=True),
)
entities = DynamicModelMultipleChoiceField(
model=TransactionEntity,
to_field_name="name",
create_field="name",
required=False,
label=_("Entities"),
queryset=TransactionEntity.objects.all(),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Make all fields optional
for field_name, field in self.fields.items():
field.required = False
del self.helper.layout[-1] # Remove button
del self.helper.layout[0:2] # Remove type, is_paid field
self.fields["account"].queryset = Account.objects.filter(
is_archived=False,
)
self.helper.layout.insert(
0,
self.fields["category"].queryset = TransactionCategory.objects.filter(
active=True
)
self.fields["tags"].queryset = TransactionTag.objects.filter(active=True)
self.fields["entities"].queryset = TransactionEntity.objects.all()
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.form_method = "post"
self.helper.layout = Layout(
Field(
"type",
template="transactions/widgets/unselectable_income_expense_toggle_buttons.html",
),
)
self.helper.layout.insert(
1,
Field(
"is_paid",
template="transactions/widgets/unselectable_paid_toggle_button.html",
),
)
self.helper.layout.append(
Row(
Column("account"),
Column("entities"),
),
Row(
Column(Field("date")),
Column(Field("reference_date")),
),
"description",
Field("amount", inputmode="decimal"),
Row(
Column("category"),
Column("tags"),
),
"notes",
FormActions(
NoClassSubmit(
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
),
)
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
self.fields["date"].widget = AirDatePickerInput(clear_button=False)
class TransferForm(forms.Form):
from_account = forms.ModelChoiceField(
@@ -515,62 +576,34 @@ class TransferForm(forms.Form):
self.helper.layout = Layout(
Row(
Column(Field("date"), css_class="form-group col-md-6 mb-0"),
Column(Field("date")),
Column(
Field("reference_date"),
css_class="form-group col-md-6 mb-0",
),
css_class="form-row",
),
Field("description"),
Field("notes"),
Switch("mute"),
Row(
Column(
Row(
Column(
"from_account",
css_class="form-group col-md-6 mb-0",
),
Column(
Field("from_amount"),
css_class="form-group col-md-6 mb-0",
),
css_class="form-row",
),
Row(
Column("from_category", css_class="form-group col-md-6 mb-0"),
Column("from_tags", css_class="form-group col-md-6 mb-0"),
css_class="form-row",
),
),
css_class="p-1 mx-1 my-3 border rounded-3",
Column("from_account"),
Column(Field("from_amount")),
Column("from_category"),
Column("from_tags"),
css_class="bg-base-100 rounded-box p-4 border-base-content/60 border my-3",
),
Row(
Column(
Row(
Column(
"to_account",
css_class="form-group col-md-6 mb-0",
),
Column(
Field("to_amount"),
css_class="form-group col-md-6 mb-0",
),
css_class="form-row",
),
Row(
Column("to_category", css_class="form-group col-md-6 mb-0"),
Column("to_tags", css_class="form-group col-md-6 mb-0"),
css_class="form-row",
),
"to_account",
),
css_class="p-1 mx-1 my-3 border rounded-3",
Column(
Field("to_amount"),
),
Column("to_category"),
Column("to_tags"),
css_class="bg-base-100 rounded-box p-4 border-base-content/60 border",
),
FormActions(
NoClassSubmit(
"submit", _("Transfer"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Transfer"), css_class="btn btn-primary"),
),
)
@@ -756,30 +789,26 @@ class InstallmentPlanForm(forms.ModelForm):
template="transactions/widgets/income_expense_toggle_buttons.html",
),
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",
Column("account"),
Column("entities"),
),
"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"),
css_class="form-row",
Column("number_of_installments"),
Column("installment_start"),
),
Row(
Column("start_date", css_class="form-group col-md-4 mb-0"),
Column("reference_date", css_class="form-group col-md-4 mb-0"),
Column("recurrence", css_class="form-group col-md-4 mb-0"),
css_class="form-row",
Column("start_date", css_class="col-span-12 md:col-span-4"),
Column("reference_date", css_class="col-span-12 md:col-span-4"),
Column("recurrence", css_class="col-span-12 md:col-span-4"),
),
"installment_amount",
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",
Column("category"),
Column("tags"),
),
)
@@ -789,17 +818,13 @@ class InstallmentPlanForm(forms.ModelForm):
if self.instance and self.instance.pk:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
),
)
else:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
),
)
@@ -832,17 +857,13 @@ class TransactionTagForm(forms.ModelForm):
if self.instance and self.instance.pk:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
),
)
else:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
),
)
@@ -864,17 +885,13 @@ class TransactionEntityForm(forms.ModelForm):
if self.instance and self.instance.pk:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
),
)
else:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
),
)
@@ -899,17 +916,13 @@ class TransactionCategoryForm(forms.ModelForm):
if self.instance and self.instance.pk:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
),
)
else:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
),
)
@@ -1018,30 +1031,26 @@ class RecurringTransactionForm(forms.ModelForm):
template="transactions/widgets/income_expense_toggle_buttons.html",
),
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",
Column("account"),
Column("entities"),
),
"description",
Switch("add_description_to_transaction"),
"amount",
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",
Column("category"),
Column("tags"),
),
"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"),
css_class="form-row",
Column("start_date"),
Column("reference_date"),
),
Row(
Column("recurrence_interval", css_class="form-group col-md-4 mb-0"),
Column("recurrence_type", css_class="form-group col-md-4 mb-0"),
Column("end_date", css_class="form-group col-md-4 mb-0"),
css_class="form-row",
Column("recurrence_interval", css_class="col-span-12 md:col-span-4"),
Column("recurrence_type", css_class="col-span-12 md:col-span-4"),
Column("end_date", css_class="col-span-12 md:col-span-4"),
),
AppendedText("keep_at_most", _("future transactions")),
)
@@ -1053,17 +1062,13 @@ class RecurringTransactionForm(forms.ModelForm):
if self.instance and self.instance.pk:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
),
)
else:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
),
)

View File

@@ -1,5 +1,19 @@
import decimal
import logging
from copy import deepcopy
from apps.common.fields.month_year import MonthYearModelField
from apps.common.functions.decimals import truncate_decimal
from apps.common.middleware.thread_local import get_current_user
from apps.common.models import (
OwnedObject,
OwnedObjectManager,
SharedObject,
SharedObjectManager,
)
from apps.common.templatetags.decimal import drop_trailing_zeros, localize_number
from apps.currencies.utils.convert import convert
from apps.transactions.validators import validate_decimal_places, validate_non_negative
from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.core.validators import MinValueValidator
@@ -10,19 +24,6 @@ from django.template.defaultfilters import date
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from apps.common.fields.month_year import MonthYearModelField
from apps.common.functions.decimals import truncate_decimal
from apps.common.templatetags.decimal import localize_number, drop_trailing_zeros
from apps.currencies.utils.convert import convert
from apps.transactions.validators import validate_decimal_places, validate_non_negative
from apps.common.middleware.thread_local import get_current_user
from apps.common.models import (
SharedObject,
SharedObjectManager,
OwnedObject,
OwnedObjectManager,
)
logger = logging.getLogger()
@@ -33,13 +34,13 @@ transaction_deleted = Signal()
class SoftDeleteQuerySet(models.QuerySet):
@staticmethod
def _emit_signals(instances, created=False):
def _emit_signals(instances, created=False, old_data=None):
"""Helper to emit signals for multiple instances"""
for instance in instances:
for i, instance in enumerate(instances):
if created:
transaction_created.send(sender=instance)
else:
transaction_updated.send(sender=instance)
transaction_updated.send(sender=instance, old_data=old_data[i])
def bulk_create(self, objs, emit_signal=True, **kwargs):
instances = super().bulk_create(objs, **kwargs)
@@ -50,22 +51,25 @@ class SoftDeleteQuerySet(models.QuerySet):
return instances
def bulk_update(self, objs, fields, emit_signal=True, **kwargs):
old_data = deepcopy(objs)
result = super().bulk_update(objs, fields, **kwargs)
if emit_signal:
self._emit_signals(objs, created=False)
self._emit_signals(objs, created=False, old_data=old_data)
return result
def update(self, emit_signal=True, **kwargs):
# Get instances before update
instances = list(self)
old_data = deepcopy(instances)
result = super().update(**kwargs)
if emit_signal:
# Refresh instances to get new values
refreshed = self.model.objects.filter(pk__in=[obj.pk for obj in instances])
self._emit_signals(refreshed, created=False)
self._emit_signals(refreshed, created=False, old_data=old_data)
return result
@@ -376,16 +380,43 @@ class Transaction(OwnedObject):
db_table = "transactions"
default_manager_name = "objects"
def save(self, *args, **kwargs):
def clean(self):
super().clean()
# Convert empty internal_id to None to allow multiple "empty" values with unique constraint
if self.internal_id == "":
self.internal_id = None
# Only process amount and reference_date if account exists
# If account is missing, Django's required field validation will handle it
try:
account = self.account
except Transaction.account.RelatedObjectDoesNotExist:
# Account doesn't exist, skip processing that depends on it
# Django will add the required field error
return
# Validate and normalize amount
if isinstance(self.amount, (str, int, float)):
self.amount = decimal.Decimal(str(self.amount))
self.amount = truncate_decimal(
value=self.amount, decimal_places=self.account.currency.decimal_places
value=self.amount, decimal_places=account.currency.decimal_places
)
# Normalize reference_date
if self.reference_date:
self.reference_date = self.reference_date.replace(day=1)
elif not self.reference_date and self.date:
self.reference_date = self.date.replace(day=1)
def save(self, *args, **kwargs):
# This is here so Django validation doesn't trigger an error before clean() is ran
if not self.reference_date and self.date:
self.reference_date = self.date.replace(day=1)
# This is not recommended as it will run twice on some cases like form and API saves.
# We only do this here because we forgot to independently call it on multiple places.
self.full_clean()
super().save(*args, **kwargs)
@@ -443,12 +474,58 @@ class Transaction(OwnedObject):
type_display = self.get_type_display()
frmt_date = date(self.date, "SHORT_DATE_FORMAT")
account = self.account
tags = ", ".join([x.name for x in self.tags.all()]) or _("No tags")
tags = (
", ".join([x.name for x in self.tags.all()])
if self.id
else None or _("No tags")
)
category = self.category or _("No category")
amount = localize_number(drop_trailing_zeros(self.amount))
description = self.description or _("No description")
return f"[{frmt_date}][{type_display}][{account}] {description}{category}{tags}{amount}"
def deepcopy(self, memo=None):
"""
Creates a deep copy of the transaction instance.
This method returns a new, unsaved Transaction instance with the same
values as the original, including its many-to-many relationships.
The primary key and any other unique fields are reset to avoid
database integrity errors upon saving.
"""
if memo is None:
memo = {}
# Create a new instance of the class
new_obj = self.__class__()
memo[id(self)] = new_obj
# Copy all concrete fields from the original to the new object
for field in self._meta.concrete_fields:
# Skip the primary key to allow the database to generate a new one
if field.primary_key:
continue
# Reset any unique fields to None to avoid constraint violations
if field.unique and field.name == "internal_id":
setattr(new_obj, field.name, None)
continue
# Copy the value of the field
setattr(new_obj, field.name, getattr(self, field.name))
# Save the new object to the database to get a primary key
new_obj.save()
# Copy the many-to-many relationships
for field in self._meta.many_to_many:
source_manager = getattr(self, field.name)
destination_manager = getattr(new_obj, field.name)
# Set the M2M relationships for the new object
destination_manager.set(source_manager.all())
return new_obj
class InstallmentPlan(models.Model):
class Recurrence(models.TextChoices):
@@ -797,10 +874,8 @@ class RecurringTransaction(models.Model):
notes=self.notes if self.add_notes_to_transaction else "",
owner=self.account.owner,
)
if self.tags.exists():
created_transaction.tags.set(self.tags.all())
if self.entities.exists():
created_transaction.entities.set(self.entities.all())
created_transaction.tags.set(self.tags.all())
created_transaction.entities.set(self.entities.all())
def get_recurrence_delta(self):
if self.recurrence_type == self.RecurrenceType.DAY:

View File

@@ -13,7 +13,9 @@ logger = logging.getLogger(__name__)
@app.periodic(cron="0 0 * * *")
@app.task(name="generate_recurring_transactions")
@app.task(
lock="generate_recurring_transactions", name="generate_recurring_transactions"
)
def generate_recurring_transactions(timestamp=None):
try:
RecurringTransaction.generate_upcoming_transactions()
@@ -26,7 +28,7 @@ def generate_recurring_transactions(timestamp=None):
@app.periodic(cron="10 1 * * *")
@app.task(name="cleanup_deleted_transactions")
@app.task(lock="cleanup_deleted_transactions", name="cleanup_deleted_transactions")
def cleanup_deleted_transactions(timestamp=None):
if settings.ENABLE_SOFT_DELETE and settings.KEEP_DELETED_TRANSACTIONS_FOR == 0:
return "KEEP_DELETED_TRANSACTIONS_FOR is 0, no cleanup performed."

View File

@@ -3,7 +3,6 @@ from decimal import Decimal
from django import template
from django.utils.formats import number_format
register = template.Library()
@@ -13,13 +12,27 @@ def _format_string(prefix, amount, decimal_places, suffix):
value=abs(amount), decimal_pos=decimal_places, force_grouping=True
)
if amount < 0:
return "-", prefix, formatted_amount, suffix
return f"-{prefix}{formatted_amount}{suffix}"
else:
return "", prefix, formatted_amount, suffix
return f"{prefix}{formatted_amount}{suffix}"
else:
return "ERR"
return "", "", "ERR", ""
@register.simple_tag(name="currency_display")
def currency_display(amount, prefix, suffix, decimal_places):
return _format_string(prefix, amount, decimal_places, suffix)
def currency_display(amount, prefix, suffix, decimal_places, string=False):
sign, prefix, amount, suffix = _format_string(
prefix, amount, decimal_places, suffix
)
if string:
return f"{sign}{prefix}{amount}{suffix}"
return {
"sign": sign,
"prefix": prefix,
"amount": amount,
"suffix": suffix,
}

View File

View File

@@ -1,9 +1,7 @@
import datetime
from decimal import Decimal
from datetime import date, timedelta
from django.test import TestCase
from django.core.exceptions import ValidationError
from django.utils import timezone
from apps.transactions.models import (
@@ -127,6 +125,70 @@ class TransactionTests(TestCase):
datetime.datetime(day=1, month=2, year=2000).date(),
)
def test_empty_internal_id_converts_to_none(self):
"""Test that empty string internal_id is converted to None"""
transaction = Transaction.objects.create(
account=self.account,
type=Transaction.Type.EXPENSE,
date=timezone.now().date(),
amount=Decimal("100.00"),
description="Test transaction",
internal_id="", # Empty string should become None
)
self.assertIsNone(transaction.internal_id)
def test_unique_internal_id_works(self):
"""Test that unique non-empty internal_id values work correctly"""
transaction1 = Transaction.objects.create(
account=self.account,
type=Transaction.Type.EXPENSE,
date=timezone.now().date(),
amount=Decimal("100.00"),
description="Test transaction 1",
internal_id="unique-id-123",
)
transaction2 = Transaction.objects.create(
account=self.account,
type=Transaction.Type.EXPENSE,
date=timezone.now().date(),
amount=Decimal("100.00"),
description="Test transaction 2",
internal_id="unique-id-456",
)
self.assertEqual(transaction1.internal_id, "unique-id-123")
self.assertEqual(transaction2.internal_id, "unique-id-456")
def test_multiple_transactions_with_empty_internal_id(self):
"""Test that multiple transactions can have empty/None internal_id"""
transaction1 = Transaction.objects.create(
account=self.account,
type=Transaction.Type.EXPENSE,
date=timezone.now().date(),
amount=Decimal("100.00"),
description="Test transaction 1",
internal_id="",
)
transaction2 = Transaction.objects.create(
account=self.account,
type=Transaction.Type.EXPENSE,
date=timezone.now().date(),
amount=Decimal("100.00"),
description="Test transaction 2",
internal_id="",
)
transaction3 = Transaction.objects.create(
account=self.account,
type=Transaction.Type.EXPENSE,
date=timezone.now().date(),
amount=Decimal("100.00"),
description="Test transaction 3",
internal_id=None,
)
# All should be saved successfully with None internal_id
self.assertIsNone(transaction1.internal_id)
self.assertIsNone(transaction2.internal_id)
self.assertIsNone(transaction3.internal_id)
class InstallmentPlanTests(TestCase):
def setUp(self):
@@ -175,6 +237,6 @@ class RecurringTransactionTests(TestCase):
recurrence_type=RecurringTransaction.RecurrenceType.MONTH,
recurrence_interval=1,
)
self.assertFalse(recurring.paused)
self.assertFalse(recurring.is_paused)
self.assertEqual(recurring.recurrence_interval, 1)
self.assertEqual(recurring.account.currency.code, "USD")

View File

@@ -0,0 +1,174 @@
from datetime import date
from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from django.utils import timezone
from apps.accounts.models import Account, AccountGroup
from apps.currencies.models import Currency
from apps.transactions.models import (
Transaction,
TransactionCategory,
TransactionTag,
)
@override_settings(
STORAGES={
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"},
},
WHITENOISE_AUTOREFRESH=True,
)
class TransactionSimpleAddViewTests(TestCase):
"""Tests for the transaction_simple_add view with query parameters"""
def setUp(self):
"""Set up test data"""
User = get_user_model()
self.user = User.objects.create_user(
email="testuser@test.com", password="testpass123"
)
self.client.login(username="testuser@test.com", password="testpass123")
self.currency = Currency.objects.create(
code="USD", name="US Dollar", decimal_places=2, prefix="$ "
)
self.account_group = AccountGroup.objects.create(name="Test Group")
self.account = Account.objects.create(
name="Test Account", group=self.account_group, currency=self.currency
)
self.category = TransactionCategory.objects.create(name="Test Category")
self.tag = TransactionTag.objects.create(name="TestTag")
def test_get_returns_form_with_default_values(self):
"""Test GET request returns 200 and form with defaults"""
response = self.client.get("/add/")
self.assertEqual(response.status_code, 200)
self.assertIn("form", response.context)
def test_get_with_type_param(self):
"""Test type param sets form initial value"""
response = self.client.get("/add/?type=EX")
self.assertEqual(response.status_code, 200)
form = response.context["form"]
self.assertEqual(form.initial.get("type"), Transaction.Type.EXPENSE)
def test_get_with_account_param(self):
"""Test account param sets form initial value"""
response = self.client.get(f"/add/?account={self.account.id}")
self.assertEqual(response.status_code, 200)
form = response.context["form"]
self.assertEqual(form.initial.get("account"), self.account.id)
def test_get_with_is_paid_param_true(self):
"""Test is_paid param with true value"""
response = self.client.get("/add/?is_paid=true")
self.assertEqual(response.status_code, 200)
form = response.context["form"]
self.assertTrue(form.initial.get("is_paid"))
def test_get_with_is_paid_param_false(self):
"""Test is_paid param with false value"""
response = self.client.get("/add/?is_paid=false")
self.assertEqual(response.status_code, 200)
form = response.context["form"]
self.assertFalse(form.initial.get("is_paid"))
def test_get_with_amount_param(self):
"""Test amount param sets form initial value"""
response = self.client.get("/add/?amount=150.50")
self.assertEqual(response.status_code, 200)
form = response.context["form"]
self.assertEqual(form.initial.get("amount"), "150.50")
def test_get_with_description_param(self):
"""Test description param sets form initial value"""
response = self.client.get("/add/?description=Test%20Transaction")
self.assertEqual(response.status_code, 200)
form = response.context["form"]
self.assertEqual(form.initial.get("description"), "Test Transaction")
def test_get_with_notes_param(self):
"""Test notes param sets form initial value"""
response = self.client.get("/add/?notes=Some%20notes")
self.assertEqual(response.status_code, 200)
form = response.context["form"]
self.assertEqual(form.initial.get("notes"), "Some notes")
def test_get_with_category_param(self):
"""Test category param sets form initial value"""
response = self.client.get(f"/add/?category={self.category.id}")
self.assertEqual(response.status_code, 200)
form = response.context["form"]
self.assertEqual(form.initial.get("category"), self.category.id)
def test_get_with_tags_param(self):
"""Test tags param as comma-separated names"""
response = self.client.get("/add/?tags=TestTag,AnotherTag")
self.assertEqual(response.status_code, 200)
form = response.context["form"]
self.assertEqual(form.initial.get("tags"), ["TestTag", "AnotherTag"])
def test_get_with_all_params(self):
"""Test all params together work correctly"""
url = (
f"/add/?type=EX&account={self.account.id}&is_paid=true"
f"&amount=200.00&description=Full%20Test&notes=Test%20notes"
f"&category={self.category.id}&tags=TestTag"
)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
form = response.context["form"]
self.assertEqual(form.initial.get("type"), Transaction.Type.EXPENSE)
self.assertEqual(form.initial.get("account"), self.account.id)
self.assertTrue(form.initial.get("is_paid"))
self.assertEqual(form.initial.get("amount"), "200.00")
self.assertEqual(form.initial.get("description"), "Full Test")
self.assertEqual(form.initial.get("notes"), "Test notes")
self.assertEqual(form.initial.get("category"), self.category.id)
self.assertEqual(form.initial.get("tags"), ["TestTag"])
def test_post_creates_transaction(self):
"""Test form submission creates transaction"""
data = {
"account": self.account.id,
"type": "EX",
"is_paid": True,
"date": timezone.now().date().isoformat(),
"amount": "100.00",
"description": "Test Transaction",
}
response = self.client.post("/add/", data)
self.assertEqual(response.status_code, 200)
self.assertTrue(
Transaction.objects.filter(description="Test Transaction").exists()
)
def test_get_with_date_param(self):
"""Test date param overrides expected date"""
response = self.client.get("/add/?date=2025-06-15")
self.assertEqual(response.status_code, 200)
form = response.context["form"]
self.assertEqual(form.initial.get("date"), date(2025, 6, 15))
def test_get_with_reference_date_param(self):
"""Test reference_date param sets form initial value"""
response = self.client.get("/add/?reference_date=2025-07-01")
self.assertEqual(response.status_code, 200)
form = response.context["form"]
self.assertEqual(form.initial.get("reference_date"), date(2025, 7, 1))
def test_get_with_account_name_param(self):
"""Test account param by name (case-insensitive)"""
response = self.client.get("/add/?account=Test%20Account")
self.assertEqual(response.status_code, 200)
form = response.context["form"]
self.assertEqual(form.initial.get("account"), self.account.id)
def test_get_with_category_name_param(self):
"""Test category param by name (case-insensitive)"""
response = self.client.get("/add/?category=Test%20Category")
self.assertEqual(response.status_code, 200)
form = response.context["form"]
self.assertEqual(form.initial.get("category"), self.category.id)

View File

@@ -35,7 +35,7 @@ def categories_list(request):
@login_required
@require_http_methods(["GET"])
def categories_table_active(request):
categories = TransactionCategory.objects.filter(active=True).order_by("id")
categories = TransactionCategory.objects.filter(active=True).order_by("name")
return render(
request,
"categories/fragments/table.html",
@@ -47,7 +47,7 @@ def categories_table_active(request):
@login_required
@require_http_methods(["GET"])
def categories_table_archived(request):
categories = TransactionCategory.objects.filter(active=False).order_by("id")
categories = TransactionCategory.objects.filter(active=False).order_by("name")
return render(
request,
"categories/fragments/table.html",

View File

@@ -35,7 +35,7 @@ def entities_list(request):
@login_required
@require_http_methods(["GET"])
def entities_table_active(request):
entities = TransactionEntity.objects.filter(active=True).order_by("id")
entities = TransactionEntity.objects.filter(active=True).order_by("name")
return render(
request,
"entities/fragments/table.html",
@@ -47,7 +47,7 @@ def entities_table_active(request):
@login_required
@require_http_methods(["GET"])
def entities_table_archived(request):
entities = TransactionEntity.objects.filter(active=False).order_by("id")
entities = TransactionEntity.objects.filter(active=False).order_by("name")
return render(
request,
"entities/fragments/table.html",

View File

@@ -137,6 +137,7 @@ def quick_transaction_add_as_transaction(request, quick_transaction_id):
"category",
"tags",
"entities",
"internal_id",
],
)
@@ -206,6 +207,7 @@ def quick_transaction_add_as_quick_transaction(request, transaction_id):
"recurring_transaction",
"deleted",
"deleted_at",
"internal_id",
],
)

View File

@@ -35,7 +35,7 @@ def tags_list(request):
@login_required
@require_http_methods(["GET"])
def tags_table_active(request):
tags = TransactionTag.objects.filter(active=True).order_by("id")
tags = TransactionTag.objects.filter(active=True).order_by("name")
return render(
request,
"tags/fragments/table.html",
@@ -47,7 +47,7 @@ def tags_table_active(request):
@login_required
@require_http_methods(["GET"])
def tags_table_archived(request):
tags = TransactionTag.objects.filter(active=False).order_by("id")
tags = TransactionTag.objects.filter(active=False).order_by("name")
return render(
request,
"tags/fragments/table.html",

View File

@@ -142,26 +142,105 @@ def transaction_simple_add(request):
year=year,
).date()
# Build initial data from query parameters
initial_data = {
"date": expected_date,
"type": transaction_type,
}
# Handle date param (ISO format: YYYY-MM-DD) - overrides expected_date
date_param = request.GET.get("date")
if date_param:
try:
initial_data["date"] = datetime.datetime.strptime(
date_param, "%Y-%m-%d"
).date()
except ValueError:
pass
# Handle reference_date param (ISO format: YYYY-MM-DD)
reference_date_param = request.GET.get("reference_date")
if reference_date_param:
try:
initial_data["reference_date"] = datetime.datetime.strptime(
reference_date_param, "%Y-%m-%d"
).date()
except ValueError:
pass
# Handle account param (by ID or name)
account_param = request.GET.get("account")
if account_param:
try:
initial_data["account"] = int(account_param)
except (ValueError, TypeError):
# Try to find by name
from apps.accounts.models import Account
account = Account.objects.filter(
name__iexact=account_param, is_archived=False
).first()
if account:
initial_data["account"] = account.pk
# Handle is_paid param (boolean)
is_paid = request.GET.get("is_paid")
if is_paid is not None:
initial_data["is_paid"] = is_paid.lower() in ("true", "1", "yes")
# Handle amount param (decimal)
amount = request.GET.get("amount")
if amount:
try:
initial_data["amount"] = amount
except (ValueError, TypeError):
pass
# Handle description param (string)
description = request.GET.get("description")
if description:
initial_data["description"] = description
# Handle notes param (string)
notes = request.GET.get("notes")
if notes:
initial_data["notes"] = notes
# Handle category param (by ID or name)
category_param = request.GET.get("category")
if category_param:
try:
initial_data["category"] = int(category_param)
except (ValueError, TypeError):
# Try to find by name
from apps.transactions.models import TransactionCategory
category = TransactionCategory.objects.filter(
name__iexact=category_param, active=True
).first()
if category:
initial_data["category"] = category.pk
# Handle tags param (comma-separated names)
tags = request.GET.get("tags")
if tags:
initial_data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
# Handle entities param (comma-separated names)
entities = request.GET.get("entities")
if entities:
initial_data["entities"] = [e.strip() for e in entities.split(",") if e.strip()]
if request.method == "POST":
form = TransactionForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, _("Transaction added successfully"))
form = TransactionForm(
initial={
"date": expected_date,
"type": transaction_type,
},
)
# Only reset form after successful save
form = TransactionForm(initial=initial_data)
else:
form = TransactionForm(
initial={
"date": expected_date,
"type": transaction_type,
},
)
form = TransactionForm(initial=initial_data)
return render(
request,
@@ -213,6 +292,7 @@ def transactions_bulk_edit(request):
if form.is_valid():
# Apply changes from the form to all selected transactions
for transaction in transactions:
old_data = deepcopy(transaction)
for field_name, value in form.cleaned_data.items():
if value or isinstance(
value, bool
@@ -225,7 +305,7 @@ def transactions_bulk_edit(request):
setattr(transaction, field_name, value)
transaction.save()
transaction_updated.send(sender=transaction)
transaction_updated.send(sender=transaction, old_data=old_data)
messages.success(
request,
@@ -373,10 +453,13 @@ def transactions_transfer(request):
@require_http_methods(["GET"])
def transaction_pay(request, transaction_id):
transaction = get_object_or_404(Transaction, pk=transaction_id)
old_data = deepcopy(transaction)
new_is_paid = False if transaction.is_paid else True
transaction.is_paid = new_is_paid
transaction.save()
transaction_updated.send(sender=transaction)
transaction_updated.send(sender=transaction, old_data=old_data)
response = render(
request,
@@ -384,7 +467,7 @@ def transaction_pay(request, transaction_id):
context={"transaction": transaction, **request.GET},
)
response.headers["HX-Trigger"] = (
f'{"paid" if new_is_paid else "unpaid"}, selective_update'
f"{'paid' if new_is_paid else 'unpaid'}, selective_update"
)
return response
@@ -394,11 +477,12 @@ def transaction_pay(request, transaction_id):
@require_http_methods(["GET"])
def transaction_mute(request, transaction_id):
transaction = get_object_or_404(Transaction, pk=transaction_id)
old_data = deepcopy(transaction)
new_mute = False if transaction.mute else True
transaction.mute = new_mute
transaction.save()
transaction_updated.send(sender=transaction)
transaction_updated.send(sender=transaction, old_data=old_data)
response = render(
request,
@@ -414,19 +498,20 @@ def transaction_mute(request, transaction_id):
@require_http_methods(["GET"])
def transaction_change_month(request, transaction_id, change_type):
transaction: Transaction = get_object_or_404(Transaction, pk=transaction_id)
old_data = deepcopy(transaction)
if change_type == "next":
transaction.reference_date = transaction.reference_date + relativedelta(
months=1
)
transaction.save()
transaction_updated.send(sender=transaction)
transaction_updated.send(sender=transaction, old_data=old_data)
elif change_type == "previous":
transaction.reference_date = transaction.reference_date - relativedelta(
months=1
)
transaction.save()
transaction_updated.send(sender=transaction)
transaction_updated.send(sender=transaction, old_data=old_data)
return HttpResponse(
status=204,
@@ -440,9 +525,11 @@ def transaction_change_month(request, transaction_id, change_type):
def transaction_move_to_today(request, transaction_id):
transaction: Transaction = get_object_or_404(Transaction, pk=transaction_id)
old_data = deepcopy(transaction)
transaction.date = timezone.localdate(timezone.now())
transaction.save()
transaction_updated.send(sender=transaction)
transaction_updated.send(sender=transaction, old_data=old_data)
return HttpResponse(
status=204,
@@ -475,6 +562,8 @@ def transaction_all_list(request):
if order != request.session.get("all_transactions_order", "default"):
request.session["all_transactions_order"] = order
today = timezone.localdate(timezone.now())
transactions = Transaction.objects.prefetch_related(
"account",
"account__group",
@@ -488,12 +577,27 @@ def transaction_all_list(request):
"dca_income_entries",
).all()
transactions = default_order(transactions, order=order)
f = TransactionsFilter(request.GET, queryset=transactions)
# Late transactions: date < today and is_paid = False (only shown for default ordering on first page)
late_transactions = None
page_number = request.GET.get("page", 1)
paginator = Paginator(f.qs, 100)
if order == "default" and str(page_number) == "1":
late_transactions = f.qs.filter(
date__lt=today,
is_paid=False,
).order_by("date", "id")
# Exclude late transactions from the main paginated list
main_transactions = f.qs.exclude(
date__lt=today,
is_paid=False,
)
else:
main_transactions = f.qs
main_transactions = default_order(main_transactions, order=order)
paginator = Paginator(main_transactions, 100)
page_obj = paginator.get_page(page_number)
return render(
@@ -502,6 +606,7 @@ def transaction_all_list(request):
{
"page_obj": page_obj,
"paginator": paginator,
"late_transactions": late_transactions,
},
)

View File

@@ -1,35 +1,43 @@
from apps.common.middleware.thread_local import get_current_user
from apps.common.widgets.crispy.submit import NoClassSubmit
from apps.users.models import UserSettings
from crispy_forms.bootstrap import (
FormActions,
)
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Submit, Row, Column, Field, Div, HTML
from crispy_forms.layout import HTML, Column, Div, Field, Layout, Row, Submit
from django import forms
from django.contrib.auth import get_user_model
from django.contrib.auth.forms import (
UsernameField,
AuthenticationForm,
UserCreationForm,
UsernameField,
)
from django.db import transaction
from django.utils.translation import gettext_lazy as _
from apps.common.widgets.crispy.submit import NoClassSubmit
from apps.users.models import UserSettings
from apps.common.middleware.thread_local import get_current_user
class LoginForm(AuthenticationForm):
username = UsernameField(
label=_("E-mail"),
widget=forms.EmailInput(
attrs={"class": "form-control", "placeholder": "E-mail", "name": "email"}
attrs={
"class": "input",
"placeholder": _("E-mail"),
"name": "email",
"autocomplete": "email",
}
),
)
password = forms.CharField(
label=_("Password"),
strip=False,
widget=forms.PasswordInput(
attrs={"class": "form-control", "placeholder": "Senha"}
attrs={
"class": "input",
"placeholder": _("Password"),
"autocomplete": "current-password",
}
),
)
@@ -45,7 +53,7 @@ class LoginForm(AuthenticationForm):
self.helper.layout = Layout(
"username",
"password",
Submit("Submit", "Login", css_class="btn btn-primary w-100"),
Submit("Submit", "Login", css_class="w-full mt-3"),
)
@@ -129,18 +137,16 @@ class UserSettingsForm(forms.ModelForm):
self.helper.layout = Layout(
"language",
"timezone",
HTML("<hr />"),
HTML('<hr class="hr my-3" />'),
"date_format",
"datetime_format",
"number_format",
HTML("<hr />"),
HTML('<hr class="hr my-3" />'),
"start_page",
HTML("<hr />"),
HTML('<hr class="hr my-3" />'),
"volume",
FormActions(
NoClassSubmit(
"submit", _("Save"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Save"), css_class="btn btn-primary"),
),
)
@@ -191,8 +197,8 @@ class UserUpdateForm(forms.ModelForm):
# Define the layout using Crispy Forms, including the new fields
self.helper.layout = Layout(
Row(
Column("first_name", css_class="form-group col-md-6"),
Column("last_name", css_class="form-group col-md-6"),
Column("first_name"),
Column("last_name"),
css_class="row",
),
Field("email"),
@@ -213,17 +219,13 @@ class UserUpdateForm(forms.ModelForm):
if self.instance and self.instance.pk:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
),
)
else:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
),
)
@@ -354,8 +356,8 @@ class UserAddForm(UserCreationForm):
self.helper.layout = Layout(
Field("email"),
Row(
Column("first_name", css_class="form-group col-md-6"),
Column("last_name", css_class="form-group col-md-6"),
Column("first_name"),
Column("last_name"),
css_class="row",
),
# UserCreationForm provides 'password1' and 'password2' fields
@@ -375,17 +377,13 @@ class UserAddForm(UserCreationForm):
if self.instance and self.instance.pk:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Update"), css_class="btn btn-primary"),
),
)
else:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
),
)

View File

@@ -18,10 +18,15 @@ urlpatterns = [
name="toggle_sound_playing",
),
path(
"user/toggle-sidebar/",
"user/session/toggle-sidebar/",
views.toggle_sidebar_status,
name="toggle_sidebar_status",
),
path(
"user/session/toggle-theme/",
views.toggle_theme,
name="toggle_theme",
),
path(
"user/settings/",
views.update_settings,

View File

@@ -1,27 +1,26 @@
from apps.common.decorators.demo import disabled_on_demo
from apps.common.decorators.htmx import only_htmx
from apps.common.decorators.user import htmx_login_required, is_superuser
from apps.users.forms import (
LoginForm,
UserAddForm,
UserSettingsForm,
UserUpdateForm,
)
from apps.users.models import UserSettings
from django.contrib import messages
from django.contrib.auth import logout, get_user_model
from django.contrib.auth import get_user_model, logout
from django.contrib.auth.decorators import login_required
from django.contrib.auth.views import (
LoginView,
)
from django.core.exceptions import PermissionDenied
from django.http import HttpResponse
from django.shortcuts import redirect, render, get_object_or_404
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx
from apps.common.decorators.user import is_superuser, htmx_login_required
from apps.users.forms import (
LoginForm,
UserSettingsForm,
UserUpdateForm,
UserAddForm,
)
from apps.users.models import UserSettings
from apps.common.decorators.demo import disabled_on_demo
def logout_view(request):
logout(request)
@@ -118,7 +117,11 @@ def update_settings(request):
@only_htmx
@htmx_login_required
@require_http_methods(["GET"])
def toggle_sidebar_status(request):
if not request.session.get("sidebar_status"):
request.session["sidebar_status"] = "floating"
if request.session["sidebar_status"] == "floating":
request.session["sidebar_status"] = "fixed"
elif request.session["sidebar_status"] == "fixed":
@@ -131,6 +134,24 @@ def toggle_sidebar_status(request):
)
@htmx_login_required
@require_http_methods(["GET"])
def toggle_theme(request):
if not request.session.get("theme"):
request.session["theme"] = "wygiwyh_dark"
if request.session["theme"] == "wygiwyh_dark":
request.session["theme"] = "wygiwyh_light"
elif request.session["theme"] == "wygiwyh_light":
request.session["theme"] = "wygiwyh_dark"
else:
request.session["theme"] = "wygiwyh_light"
return HttpResponse(
status=204,
)
@htmx_login_required
@is_superuser
@require_http_methods(["GET"])

View File

@@ -79,7 +79,7 @@ def yearly_overview_by_currency(request, year: int):
currency = request.GET.get("currency")
# Base query filter
filter_params = {"reference_date__year": year, "account__is_archived": False}
filter_params = {"reference_date__year": year}
# Add month filter if provided
if month:

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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