Compare commits

...

754 Commits
0.9.2 ... 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
eitchtee
99c85a56bb chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-22 20:16:36 +00:00
Herculino Trotta
ab1c074f27 Merge pull request #343
feat(sidebar): add button to keep it open
2025-08-22 17:15:55 -03:00
Herculino Trotta
abf3a148cc feat(sidebar): add button to keep it open 2025-08-22 17:15:32 -03:00
Herculino Trotta
2733c92da5 style(sidebar): truncate e-mail if it's too long 2025-08-22 13:14:47 -03:00
eitchtee
9bfbe54ed5 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-19 17:03:42 +00:00
Herculino Trotta
5b27dea07c Merge pull request #340
feat: turn filter, order and search into a single bar
2025-08-19 14:02:06 -03:00
Herculino Trotta
791e1000a3 feat(all-transactions): turn filter, order and search into a single bar 2025-08-19 14:01:35 -03:00
Herculino Trotta
7301d9f475 feat(monthly): turn filter, order and search into a single bar 2025-08-19 13:39:57 -03:00
sorcierwax
47a44e96f8 locale(French): update translation
Currently translated at 100.0% (685 of 685 strings)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-06-30 06:17:41 +00:00
Herculino Trotta
6b4fbee7a6 Merge pull request #272
style: remove color from scrollbar
2025-06-29 17:39:12 -03:00
Herculino Trotta
e7fe6622cd style: remove color from scrollbar 2025-06-29 17:38:49 -03:00
Herculino Trotta
3017593ed5 locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (661 of 661 strings)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-02-28 02:37:39 +00:00
Herculino Trotta
033f0e1b0d Merge pull request #195
feat(insights): add Categories Overview
2025-02-27 23:33:25 -03:00
Herculino Trotta
35027ee0ae feat(insights): add Categories Overview
Closes #94
2025-02-27 23:33:05 -03:00
Herculino Trotta
91904e959b Merge pull request #194
locale(de): update translation - thanks to @CocaCola2701
2025-02-25 10:53:25 -03:00
Herculino Trotta
a6a85ae3a2 locale(de): update translation - thanks to @CocaCola2701 2025-02-25 10:53:08 -03:00
Herculino Trotta
b0f53f45f9 Merge pull request #193
fix(rules): Update or Create Transaction rule unable to match againt dates and other types
2025-02-25 10:49:01 -03:00
Herculino Trotta
0f60f8d486 fix(rules): Update or Create Transaction rule unable to match againt dates and other types 2025-02-25 10:48:43 -03:00
Herculino Trotta
efb207a109 Merge pull request #191 from eitchtee/dev
locale: add en
2025-02-24 23:07:14 -03:00
Herculino Trotta
95b1481dd5 locale: add en 2025-02-24 23:06:15 -03:00
Herculino Trotta
8de340b68b Merge pull request #190
locale(de): enable Deutsch
2025-02-24 16:34:50 -03:00
Herculino Trotta
ef15b85386 fix(locale): transactions quick search placeholder is not translatable 2025-02-24 16:34:05 -03:00
Herculino Trotta
45d939237d locale(de): enable Deutsch 2025-02-24 16:33:14 -03:00
Herculino Trotta
6bf262e514 Merge pull request #189
style(transactions): improve look on wider columns
2025-02-22 23:21:45 -03:00
Herculino Trotta
f9d9137336 style(transactions): improve look on wider columns 2025-02-22 23:21:28 -03:00
Herculino Trotta
b532521f27 Merge pull request #188 from DragonHeart69/main
update dutch to V0.11.3
2025-02-22 23:17:11 -03:00
Dimitri Decrock
1e06e2d34d update dutch to V0.11.3 2025-02-22 15:04:47 +01:00
Herculino Trotta
a33fa5e184 Merge pull request #187 from eitchtee/dev
style(transactions): improve look on wider columns
2025-02-22 01:41:27 -03:00
Herculino Trotta
a2453695d8 style(transactions): improve look on wider columns 2025-02-22 01:41:02 -03:00
Herculino Trotta
3e929d0433 Merge pull request #186
style(transactions): improve look on wider columns
2025-02-22 01:18:35 -03:00
Herculino Trotta
185fc464a5 style(transactions): improve look on wider columns 2025-02-22 01:18:20 -03:00
Herculino Trotta
647c009525 Merge pull request #185
fix(insights:latest-transactions): order transactions from newest to oldest
2025-02-22 01:02:56 -03:00
Herculino Trotta
ba75492dcc fix(insights:latest-transactions): order transactions from newest to oldest 2025-02-22 01:02:35 -03:00
Herculino Trotta
8312baaf45 Merge pull request #184
feat(tools:currency-converter): show 1:1 rates for all available currencies
2025-02-20 23:48:32 -03:00
Herculino Trotta
4d346dc278 feat(tools:currency-converter): show 1:1 rates for all available currencies 2025-02-20 23:48:08 -03:00
Herculino Trotta
70ff7fab38 Merge pull request #183 from eitchtee/dev
feat(insights): add late and recent transactions
2025-02-19 23:07:51 -03:00
Herculino Trotta
6947c6affd feat(insights): add late and recent transactions 2025-02-19 23:07:28 -03:00
Herculino Trotta
dcab83f936 Merge pull request #182
fix(insights:category-explorer): wrong sums
2025-02-19 16:02:14 -03:00
Herculino Trotta
b228e4ec26 fix(insights:category-explorer): wrong sums 2025-02-19 16:01:53 -03:00
Herculino Trotta
4071a1301f Merge pull request #181 from eitchtee/dev
fix(export): unable to import decimals
2025-02-19 15:44:50 -03:00
Herculino Trotta
5c9db10710 fix(export): unable to import decimals 2025-02-19 15:44:18 -03:00
Herculino Trotta
19c92e0014 Merge pull request #180
fix(export): 403 when exporting
2025-02-19 14:02:52 -03:00
Herculino Trotta
6459f2eb46 fix(export): 403 when exporting 2025-02-19 14:02:31 -03:00
Herculino Trotta
7926e081ef locale: update locales 2025-02-19 13:50:45 -03:00
Herculino Trotta
ceefe7075f locale: update locales 2025-02-19 13:48:54 -03:00
Herculino Trotta
ad3230fd83 Merge pull request #179 from eitchtee/export
feat: export and restore
2025-02-19 13:41:53 -03:00
Herculino Trotta
c89b07ed93 Merge branch 'main' into export 2025-02-19 13:41:04 -03:00
Herculino Trotta
201ccea842 feat: export (WIP) 2025-02-19 13:38:00 -03:00
Herculino Trotta
32ada488b4 Merge pull request #178
feat(transactions:actions): select all only selects displayed transactions
2025-02-19 09:08:06 -03:00
Herculino Trotta
794d11a355 feat(transactions:actions): select all only selects displayed transactions 2025-02-19 09:07:49 -03:00
Herculino Trotta
67f8f5fe89 Merge pull request #177
fix(transactions:actions): sum considers everything an expense
2025-02-19 09:00:02 -03:00
Herculino Trotta
9ac69fd92a fix(transactions:actions): sum considers everything an expense 2025-02-19 08:59:30 -03:00
Herculino Trotta
069f1b450c feat: export (WIP) 2025-02-19 08:51:33 -03:00
Herculino Trotta
2f388af928 Merge pull request #176
feat(insights): make sidebar sticky
2025-02-18 21:04:36 -03:00
Herculino Trotta
beeb0579ce feat(insights): make sidebar sticky 2025-02-18 21:04:09 -03:00
Herculino Trotta
a8666da57b Merge pull request #175
feat(insights:category-explorer): separate current and projected totals
2025-02-18 20:46:28 -03:00
Herculino Trotta
835316d0f3 feat(insights:category-explorer): separate current and projected totals 2025-02-18 20:46:06 -03:00
Herculino Trotta
f5feeb9617 Merge pull request #174
feat(insights:category-explorer): allow for uncategorized totals
2025-02-18 20:45:24 -03:00
Herculino Trotta
09e380a480 feat(insights:category-explorer): allow for uncategorized totals 2025-02-18 20:45:07 -03:00
Herculino Trotta
3080df9b66 feat: export (WIP) 2025-02-18 19:55:12 -03:00
Herculino Trotta
ebc41a8049 Merge pull request #173 from eitchtee/insights
fix(insights): error if filter is empty
2025-02-17 21:49:00 -03:00
Herculino Trotta
635628e30e fix(insights): error if filter is empty 2025-02-17 21:48:33 -03:00
Herculino Trotta
819a58ac06 Merge pull request #172
feat(datepicker): disable input and fix toggling dates
2025-02-17 21:37:16 -03:00
Herculino Trotta
d433375522 feat(datepicker): disable input and fix toggling dates 2025-02-17 21:36:11 -03:00
Herculino Trotta
c0150f71a8 Merge pull request #171 from eitchtee/insights
fix(insights:category-explorer): silent categories can't be displayed
2025-02-17 10:43:12 -03:00
Herculino Trotta
6119698d38 fix(insights:category-explorer): silent categories can't be displayed 2025-02-17 10:42:38 -03:00
Herculino Trotta
f5ae231601 Merge pull request #170
feat(insights:category-explorer): add empty message when there's no data or no category selected
2025-02-17 10:28:55 -03:00
Herculino Trotta
972d23abbd feat(insights:category-explorer): add empty message when there's no data or no category selected 2025-02-17 10:28:37 -03:00
Herculino Trotta
9a514a8a69 Merge pull request #169
refactor(insights:flows): improve readability when there's a lot of nodes
2025-02-17 10:21:36 -03:00
Herculino Trotta
7325231548 refactor(insights:flows): improve readability when there's a lot of nodes 2025-02-17 10:21:18 -03:00
Herculino Trotta
570657371a Merge pull request #168
fix(insights:category-explorer): use currency name instead of code
2025-02-16 19:34:15 -03:00
Herculino Trotta
67da60b5b0 fix(insights:category-explorer): use currency name instead of code 2025-02-16 19:33:58 -03:00
Herculino Trotta
84c047c5ab Merge pull request #167 from eitchtee/insights
insights
2025-02-16 13:06:03 -03:00
Herculino Trotta
23f5d09bec locale: update locales 2025-02-16 13:05:35 -03:00
Herculino Trotta
2a19075e23 Merge pull request #166
feat(insights): category explorer
2025-02-16 13:03:20 -03:00
Herculino Trotta
7f231175b2 feat(insights): category explorer 2025-02-16 13:03:02 -03:00
Herculino Trotta
062e84f864 Merge pull request #165
fix(insights): sankey diagrams nodes too far from destination
2025-02-16 02:25:45 -03:00
Herculino Trotta
5521eb20bf fix(insights): sankey diagrams nodes too far from destination 2025-02-16 02:25:29 -03:00
Herculino Trotta
627b5d250b Merge pull request #164
feat: insights page
2025-02-16 00:14:56 -03:00
Herculino Trotta
195a8a68d6 feat: insight page 2025-02-16 00:14:23 -03:00
Herculino Trotta
daf1f68b82 Merge remote-tracking branch 'origin/insights' into insights 2025-02-15 00:49:25 -03:00
Herculino Trotta
dd24fd56d3 insights (wip) 2025-02-15 00:49:00 -03:00
Herculino Trotta
7a2acb6497 fix(insights): sankey diagram inconsistent sizing 2025-02-15 00:48:59 -03:00
Herculino Trotta
9c339faa72 chore(frontend): install chartjs-chart-sankey 2025-02-15 00:48:59 -03:00
Herculino Trotta
02376ad02b feat(insights): sankey diagram (WIP) 2025-02-15 00:48:59 -03:00
Herculino Trotta
b53a4a0286 feat(insights): create app 2025-02-15 00:48:59 -03:00
Herculino Trotta
a1f618434b Merge pull request #163 from eitchtee/dca_improvements
feat(dca): link transactions to DCA
2025-02-15 00:43:07 -03:00
Herculino Trotta
7b5be29f0d locale: update locales 2025-02-15 00:42:38 -03:00
Herculino Trotta
56a73b181a Merge remote-tracking branch 'origin/main' into dca_improvements
# Conflicts:
#	app/locale/nl/LC_MESSAGES/django.po
2025-02-15 00:41:49 -03:00
Herculino Trotta
865618e054 feat(dca): link transactions to DCA 2025-02-15 00:41:06 -03:00
Herculino Trotta
9e912b2736 locale: update locales 2025-02-15 00:40:44 -03:00
Herculino Trotta
da7680e70f Merge pull request #159 from DragonHeart69/main
update NL to version 0.9.4
2025-02-14 10:20:40 -03:00
Herculino Trotta
ab594eb511 Merge pull request #162
fix(style): selecting transaction no longer highlights it
2025-02-14 00:50:30 -03:00
Herculino Trotta
cffaaa369a fix(style): selecting transaction no longer highlights it 2025-02-14 00:50:01 -03:00
Herculino Trotta
5f414e82ee Merge pull request #161
feat(internal): trigger rules on bulk actions
2025-02-14 00:35:10 -03:00
Herculino Trotta
f3bcef534e feat(internal): trigger rules on bulk actions 2025-02-14 00:34:51 -03:00
Herculino Trotta
d140ff5b70 Merge pull request #160
fix(frontend): loading indicator on empty div too close to the top
2025-02-14 00:04:03 -03:00
Herculino Trotta
7eceacfe68 fix(frontend): loading indicator on empty div too close to the top 2025-02-14 00:03:43 -03:00
Herculino Trotta
038438fba7 insights (wip) 2025-02-12 09:48:31 -03:00
Dimitri Decrock
ee98a5ef12 update NL to version 0.9.4 2025-02-12 06:59:28 +01:00
Herculino Trotta
28b12faaf0 fix(insights): sankey diagram inconsistent sizing 2025-02-11 00:40:37 -03:00
Herculino Trotta
d0f2742637 chore(frontend): install chartjs-chart-sankey 2025-02-11 00:37:48 -03:00
Herculino Trotta
9c55dac866 feat(insights): sankey diagram (WIP) 2025-02-11 00:37:30 -03:00
Herculino Trotta
e6d8b548b7 Merge pull request #157
fix(docker): procrastinate can't recover if it crashes in a running instance
2025-02-10 23:13:33 -03:00
Herculino Trotta
4f8c2215c1 fix(docker): procrastinate can't recover if it crashes in a running instance 2025-02-10 23:13:16 -03:00
Herculino Trotta
851b34f07a Merge pull request #156 from eitchtee/dev
fix(transactions): paying transaction doesn't trigger update rules
2025-02-09 23:38:58 -03:00
Herculino Trotta
546ed5c6af fix(transactions): bulk (un)paying transactions doesn't trigger update rules 2025-02-09 23:38:22 -03:00
Herculino Trotta
04ae7337f5 fix(transactions): paying transaction doesn't trigger update rules 2025-02-09 23:33:57 -03:00
Herculino Trotta
a3a8791e96 feat(insights): create app 2025-02-09 23:00:33 -03:00
Herculino Trotta
63069f0ec9 Merge pull request #155 from eitchtee/dev
refactor: don't display currency code
2025-02-09 19:50:09 -03:00
Herculino Trotta
32b522dad2 refactor: don't display currency code 2025-02-09 19:49:47 -03:00
Herculino Trotta
0c20a079e3 Merge pull request #154 from eitchtee/dev
locale: update locales
2025-02-09 17:31:03 -03:00
Herculino Trotta
7c9697f683 locale: update locales 2025-02-09 17:30:39 -03:00
Herculino Trotta
15d04230ae Merge pull request #153
feat(monthly): add quick-search field
2025-02-09 17:14:44 -03:00
Herculino Trotta
ecc09ca6a6 feat(monthly): add quick-search field 2025-02-09 17:14:25 -03:00
Herculino Trotta
cd753c5dd5 Merge pull request #152 from luzpaz/readme-typos
fix: typos in README
2025-02-09 10:55:54 -03:00
luzpaz
a3b9952f80 fix: typos in README
Found via `codespell -q 3 -S "*.po" -L bu,nome,vew`
2025-02-09 09:47:03 +00:00
472 changed files with 69057 additions and 18761 deletions

View File

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

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

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

View File

@@ -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
@@ -20,78 +20,66 @@ on:
env:
IMAGE_NAME: wygiwyh
concurrency:
group: release
cancel-in-progress: false
jobs:
build-and-push:
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

72
.github/workflows/translations.yml vendored Normal file
View File

@@ -0,0 +1,72 @@
name: Django Translation Update
on:
push:
branches: [ main ]
# Add manual trigger
workflow_dispatch:
inputs:
reason:
description: 'Reason for running'
required: false
default: 'Manual update of translation files'
# Ensure only one translation job runs at a time
concurrency:
group: django-translations
cancel-in-progress: false
jobs:
update-translations:
runs-on: ubuntu-latest
permissions:
contents: write
# Skip on PRs from forks (which don't have write permissions)
# Allow manual runs and pushes to main
if: github.event_name == 'workflow_dispatch' || github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
token: ${{ secrets.PAT }}
ref: ${{ github.head_ref }}
- name: Set up Python 3.11
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Install gettext
run: sudo apt-get install -y gettext
- name: Run makemessages
run: |
cd app
python manage.py makemessages -a
- name: Check for changes
id: check_changes
run: |
if git diff --exit-code --quiet app/locale/; then
echo "No translation changes detected"
else
echo "changes_detected=true" >> $GITHUB_OUTPUT
echo "Translation changes detected"
fi
- name: Commit translation files
if: steps.check_changes.outputs.changes_detected == 'true'
uses: stefanzweifel/git-auto-commit-action@v5
with:
push_options: --force
commit_message: |
chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
file_pattern: "app/locale/**/*.po"

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,8 @@
<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>
</p>
@@ -28,15 +30,15 @@ Managing money can feel unnecessarily complex, but it doesnt have to be. WYGI
By sticking to this straightforward approach, you avoid dipping into your savings while still keeping tabs on where your money goes.
While this philosophy is simple, finding tools to make it work wasnt. I initially used a spreadsheet, which served me well for yearsuntil it became unwieldy as I started managing multiple currencies, accounts, and investments. I tried various financial management apps, but none met my key requirements:
While this philosophy is simple, finding tools to make it work wasnt. I initially used a spreadsheet, which served me well for years, until it became unwieldy as I started managing multiple currencies, accounts, and investments. I tried various financial management apps, but none met my key requirements:
1. **Multi-currency support** to track income and expenses in different currencies.
2. **Not a budgeting app** as I dislike budgeting constraints.
2. **Not a budgeting app** as I dislike budgeting constraints.
3. **Web app usability** (ideally with mobile support, though optional).
4. **Automation-ready API** to integrate with other tools and services.
5. **Custom transaction rules** for credit card billing cycles or similar quirks.
Frustrated by the lack of comprehensive options, I set out to build **WYGIWYH** an opinionated yet powerful tool that I believe will resonate with like-minded users.
Frustrated by the lack of comprehensive options, I set out to build **WYGIWYH**, an opinionated yet powerful tool that I believe will resonate with like-minded users.
# Key Features
@@ -50,6 +52,17 @@ Frustrated by the lack of comprehensive options, I set out to build **WYGIWYH**
* **Built-in Dollar-Cost Average (DCA) tracker**: Essential for tracking recurring investments, especially for crypto and stocks.
* **API support for automation**: Seamlessly integrate with existing services to synchronize transactions.
# Demo
You can try WYGIWYH on [wygiwyh-demo.herculino.com](https://wygiwyh-demo.herculino.com/) with the credentials below:
> [!NOTE]
> E-mail: `demo@demo.com`
>
> Password: `wygiwyhdemo`
Keep in mind that **any data you add will be wiped in 24 hours or less**. And that **most automation features like the API, Rules, Automatic Exchange Rates and Import/Export are disabled**.
# How To Use
To run this application, you'll need [Docker](https://docs.docker.com/engine/install/) with [docker-compose](https://docs.docker.com/compose/install/).
@@ -75,12 +88,12 @@ $ nano .env # or any other editor you want to use
# Run the app
$ docker compose up -d
# Create the first admin account
# Create the first admin account. This isn't required if you set the enviroment variables: ADMIN_EMAIL and ADMIN_PASSWORD.
$ docker compose exec -it web python manage.py createsuperuser
```
> [!NOTE]
> If you're using Unraid, you don't need to follow these steps, use the app on the store. Make sure to read the [Unraid section](#unraid) and [Enviroment Variables](#enviroment-variables) for an explanation of all available variables
> If you're using Unraid, you don't need to follow these steps, use the app on the store. Make sure to read the [Unraid section](#unraid) and [Environment Variables](#environment-variables) for an explanation of all available variables
## Running locally
@@ -110,29 +123,74 @@ WYGIWYH is available on the Unraid Store. You'll need to provision your own post
To create the first user, open the container's console using Unraid's UI, by clicking on WYGIWYH icon on the Docker page and selecting `Console`, then type `python manage.py createsuperuser`, you'll them be prompted to input your e-mail and password.
## Enviroment Variables
## Environment Variables
| variable | type | default | explanation |
|-------------------------------|-------------|-----------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| INTERNAL_PORT | int | 8000 | The port on which the app listens on. Defaults to 8000 if not set. |
| DJANGO_ALLOWED_HOSTS | string | localhost 127.0.0.1 | A list of space separated domains and IPs representing the host/domain names that WYGIWYH site can serve. [Click here](https://docs.djangoproject.com/en/5.1/ref/settings/#allowed-hosts) for more details |
| HTTPS_ENABLED | true\|false | false | Whether to use secure cookies. If this is set to true, the cookie will be marked as “secure”, which means browsers may ensure that the cookie is only sent under an HTTPS connection |
| URL | string | http://localhost http://127.0.0.1 | A list of space separated domains and IPs (with the protocol) representing the trusted origins for unsafe requests (e.g. POST). [Click here](https://docs.djangoproject.com/en/5.1/ref/settings/#csrf-trusted-origins ) for more details |
| URL | string | http://localhost http://127.0.0.1 | A list of space separated domains and IPs (with the protocol) representing the trusted origins for unsafe requests (e.g. POST). [Click here](https://docs.djangoproject.com/en/5.1/ref/settings/#csrf-trusted-origins ) for more details |
| SECRET_KEY | string | "" | This is used to provide cryptographic signing, and should be set to a unique, unpredictable value. |
| DEBUG | true\|false | false | Turns DEBUG mode on or off, this is useful to gather more data about possible errors you're having. Don't use in production. |
| SQL_DATABASE | string | None *required | The name of your postgres database |
| SQL_USER | string | user | The username used to connect to your postgres database |
| SQL_PASSWORD | string | password | The password used to connect to your postgres database |
| SQL_HOST | string | localhost | The adress used to connect to your postgres database |
| SQL_HOST | string | localhost | The address used to connect to your postgres database |
| SQL_PORT | string | 5432 | The port used to connect to your postgres database |
| SESSION_EXPIRY_TIME | int | 2678400 (31 days) | The age of session cookies, in seconds. E.g. how long you will stay logged in |
| ENABLE_SOFT_DELETE | true\|false | false | Whether to enable transactions soft delete, if enabled, deleted transactions will remain in the database. Useful for imports and avoiding duplicate entries. |
| KEEP_DELETED_TRANSACTIONS_FOR | int | 365 | Time in days to keep soft deleted transactions for. If 0, will keep all transactions indefinitely. Only works if ENABLE_SOFT_DELETE is true. |
| TASK_WORKERS | int | 1 | How many workers to have for async tasks. One should be enough for most use cases |
| DEMO | true\|false | false | If demo mode is enabled. |
| ADMIN_EMAIL | string | None | Automatically creates an admin account with this email. Must have `ADMIN_PASSWORD` also set. |
| ADMIN_PASSWORD | string | None | Automatically creates an admin account with this password. Must have `ADMIN_EMAIL` also set. |
| CHECK_FOR_UPDATES | 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
WYGIWYH supports login via OpenID Connect (OIDC) through `django-allauth`. This allows users to authenticate using an external OIDC provider.
> [!NOTE]
> Currently only OpenID Connect is supported as a provider, open an issue if you need something else.
To configure OIDC, you need to set the following environment variables:
| Variable | Description |
|----------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `OIDC_CLIENT_NAME` | The name of the provider. will be displayed in the login page. Defaults to `OpenID Connect` |
| `OIDC_CLIENT_ID` | The Client ID provided by your OIDC provider. |
| `OIDC_CLIENT_SECRET` | The Client Secret provided by your OIDC provider. |
| `OIDC_SERVER_URL` | The base URL of your OIDC provider's discovery document or authorization server (e.g., `https://your-provider.com/auth/realms/your-realm`). `django-allauth` will use this to discover the necessary endpoints (authorization, token, userinfo, etc.). |
| `OIDC_ALLOW_SIGNUP` | Allow the automatic creation of inexistent accounts on a successfull authentication. Defaults to `true`. |
**Callback URL (Redirect URI):**
When configuring your OIDC provider, you will need to provide a callback URL (also known as a Redirect URI). For WYGIWYH, the default callback URL is:
`https://your.wygiwyh.domain/auth/oidc/<OIDC_CLIENT_NAME>/login/callback/`
Replace `https://your.wygiwyh.domain` with the actual URL where your WYGIWYH instance is accessible. And `<OIDC_CLIENT_NAME>` with the slugfied value set in OIDC_CLIENT_NAME or the default `openid-connect` if you haven't set this variable.
# How it works
Check out our [Wiki](https://github.com/eitchtee/WYGIWYH/wiki) for more information.
# Help us translate WYGIWYH!
<a href="https://translations.herculino.com/engage/wygiwyh/">
<img src="https://translations.herculino.com/widget/wygiwyh/open-graph.png" alt="Translation status" />
</a>
> [!NOTE]
> Login with your github account
# 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,9 +11,11 @@ https://docs.djangoproject.com/en/5.1/ref/settings/
"""
import os
import re
import sys
from pathlib import Path
from django.utils.text import slugify
SITE_TITLE = "WYGIWYH"
TITLE_SEPARATOR = "::"
@@ -42,9 +44,10 @@ INSTALLED_APPS = [
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.sites",
"whitenoise.runserver_nostatic",
"django.contrib.staticfiles",
"webpack_boilerplate",
"django_vite",
"django.contrib.humanize",
"django.contrib.postgres",
"django_browser_reload",
@@ -55,14 +58,15 @@ INSTALLED_APPS = [
"hijack",
"hijack.contrib.admin",
"django_filters",
"import_export",
"apps.users.apps.UsersConfig",
"procrastinate.contrib.django",
"apps.transactions.apps.TransactionsConfig",
"apps.currencies.apps.CurrenciesConfig",
"apps.accounts.apps.AccountsConfig",
"apps.common.apps.CommonConfig",
"apps.net_worth.apps.NetWorthConfig",
"apps.import_app.apps.ImportConfig",
"apps.export_app.apps.ExportConfig",
"apps.api.apps.ApiConfig",
"cachalot",
"rest_framework",
@@ -72,8 +76,15 @@ INSTALLED_APPS = [
"apps.calendar_view.apps.CalendarViewConfig",
"apps.dca.apps.DcaConfig",
"pwa",
"allauth",
"allauth.account",
"allauth.socialaccount",
"allauth.socialaccount.providers.openid_connect",
"apps.common.apps.CommonConfig",
]
SITE_ID = 1
MIDDLEWARE = [
"django_browser_reload.middleware.BrowserReloadMiddleware",
"apps.common.middleware.thread_local.ThreadLocalMiddleware",
@@ -89,6 +100,7 @@ MIDDLEWARE = [
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"hijack.middleware.HijackUserMiddleware",
"allauth.account.middleware.AccountMiddleware",
]
ROOT_URLCONF = "WYGIWYH.urls"
@@ -117,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",
@@ -131,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,
},
},
}
}
@@ -161,9 +195,105 @@ AUTH_USER_MODEL = "users.User"
LANGUAGE_CODE = "en"
LANGUAGES = (
("af", "Afrikaans"),
("ar", "العربية"),
("ar-dz", "العربية (الجزائر)"), # Algerian Arabic often uses the base name + region
("ast", "Asturianu"),
("az", "Azərbaycan"),
("bg", "Български"),
("be", "Беларуская"),
("bn", "বাংলা"),
("br", "Brezhoneg"),
("bs", "Bosanski"),
("ca", "Català"),
("ckb", "کوردیی ناوەندی"), # Central Kurdish (Sorani)
("cs", "Čeština"),
("cy", "Cymraeg"),
("da", "Dansk"),
("de", "Deutsch"),
("dsb", "Dolnoserbšćina"),
("el", "Ελληνικά"),
("en", "English"),
("en-au", "English (Australia)"),
("en-gb", "English (UK)"),
("eo", "Esperanto"),
("es", "Español"),
("es-ar", "Español (Argentina)"),
("es-co", "Español (Colombia)"),
("es-mx", "Español (México)"),
("es-ni", "Español (Nicaragua)"),
("es-ve", "Español (Venezuela)"),
("et", "Eesti"),
("eu", "Euskara"),
("fa", "فارسی"),
("fi", "Suomi"),
("fr", "Français"),
("fy", "Frysk"),
("ga", "Gaeilge"),
("gd", "Gàidhlig"),
("gl", "Galego"),
("he", "עברית"),
("hi", "हिन्दी"),
("hr", "Hrvatski"),
("hsb", "Hornjoserbšćina"),
("hu", "Magyar"),
("hy", "Հայերեն"),
("ia", "Interlingua"),
("id", "Bahasa Indonesia"),
("ig", "Igbo"),
("io", "Ido"),
("is", "Íslenska"),
("it", "Italiano"),
("ja", "日本語"),
("ka", "ქართული"),
("kab", "Taqbaylit"),
("kk", "Қазақша"),
("km", "ខ្មែរ"),
("kn", "ಕನ್ನಡ"),
("ko", "한국어"),
("ky", "Кыргызча"),
("lb", "Lëtzebuergesch"),
("lt", "Lietuvių"),
("lv", "Latviešu"),
("mk", "Македонски"),
("ml", "മലയാളം"),
("mn", "Монгол"),
("mr", "मराठी"),
("ms", "Bahasa Melayu"),
("my", "မြန်မာဘာသာ"),
("nb", "Norsk (Bokmål)"),
("ne", "नेपाली"),
("nl", "Nederlands"),
("nn", "Norsk (Nynorsk)"),
("os", "Ирон"), # Ossetic
("pa", "ਪੰਜਾਬੀ"),
("pl", "Polski"),
("pt", "Português"),
("pt-br", "Português (Brasil)"),
("ro", "Română"),
("ru", "Русский"),
("sk", "Slovenčina"),
("sl", "Slovenščina"),
("sq", "Shqip"),
("sr", "Српски"),
("sr-latn", "Srpski (Latinica)"),
("sv", "Svenska"),
("sw", "Kiswahili"),
("ta", "தமிழ்"),
("te", "తెలుగు"),
("tg", "Тоҷикӣ"),
("th", "ไทย"),
("tk", "Türkmençe"),
("tr", "Türkçe"),
("tt", "Татарча"),
("udm", "Удмурт"),
("ug", "ئۇيغۇرچە"),
("uk", "Українська"),
("ur", "اردو"),
("uz", "Oʻzbekcha"),
("vi", "Tiếng Việt"),
("zh-hans", "简体中文"),
("zh-hant", "繁體中文"),
)
TIME_ZONE = os.getenv("TZ", "UTC")
@@ -182,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",
]
@@ -198,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
@@ -209,10 +341,49 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
LOGIN_REDIRECT_URL = "/"
LOGIN_URL = "/login/"
LOGOUT_REDIRECT_URL = "/login/"
# Allauth settings
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend", # Keep default
"allauth.account.auth_backends.AuthenticationBackend",
]
SOCIALACCOUNT_PROVIDERS = {"openid_connect": {"APPS": []}}
if (
os.getenv("OIDC_CLIENT_ID")
and os.getenv("OIDC_CLIENT_SECRET")
and os.getenv("OIDC_SERVER_URL")
):
SOCIALACCOUNT_PROVIDERS["openid_connect"]["APPS"].append(
{
"provider_id": slugify(os.getenv("OIDC_CLIENT_NAME", "OpenID Connect")),
"name": os.getenv("OIDC_CLIENT_NAME", "OpenID Connect"),
"client_id": os.getenv("OIDC_CLIENT_ID"),
"secret": os.getenv("OIDC_CLIENT_SECRET"),
"settings": {
"server_url": os.getenv("OIDC_SERVER_URL"),
},
}
)
ACCOUNT_LOGIN_METHODS = {"email"}
ACCOUNT_SIGNUP_FIELDS = ["email*", "password1*", "password2*"]
ACCOUNT_USER_MODEL_USERNAME_FIELD = None
ACCOUNT_EMAIL_VERIFICATION = "none"
SOCIALACCOUNT_LOGIN_ON_GET = True
SOCIALACCOUNT_ONLY = True
SOCIALACCOUNT_AUTO_SIGNUP = os.getenv("OIDC_ALLOW_SIGNUP", "true").lower() == "true"
ACCOUNT_ADAPTER = "allauth.account.adapter.DefaultAccountAdapter"
SOCIALACCOUNT_ADAPTER = "allauth.socialaccount.adapter.DefaultSocialAccountAdapter"
# CRISPY FORMS
CRISPY_ALLOWED_TEMPLATE_PACKS = ["bootstrap5", "crispy_forms/pure_text"]
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
@@ -236,7 +407,7 @@ DEBUG_TOOLBAR_PANELS = [
"debug_toolbar.panels.signals.SignalsPanel",
"debug_toolbar.panels.redirects.RedirectsPanel",
"debug_toolbar.panels.profiling.ProfilingPanel",
"cachalot.panels.CachalotPanel",
# "cachalot.panels.CachalotPanel",
]
INTERNAL_IPS = [
"127.0.0.1",
@@ -258,7 +429,10 @@ if DEBUG:
REST_FRAMEWORK = {
# Use Django's standard `django.contrib.auth` permissions,
# or allow read-only access for unauthenticated users.
"DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.DjangoModelPermissions"],
"DEFAULT_PERMISSION_CLASSES": [
"apps.api.permissions.NotInDemoMode",
"rest_framework.permissions.DjangoModelPermissions",
],
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 10,
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
@@ -341,6 +515,8 @@ else:
CACHALOT_UNCACHABLE_TABLES = ("django_migrations", "procrastinate_jobs")
# Procrastinate
PROCRASTINATE_ON_APP_READY = "apps.common.procrastinate.on_app_ready"
# PWA
PWA_APP_NAME = SITE_TITLE
@@ -389,5 +565,7 @@ PWA_APP_SCREENSHOTS = [
PWA_SERVICE_WORKER_PATH = BASE_DIR / "templates" / "pwa" / "serviceworker.js"
ENABLE_SOFT_DELETE = os.getenv("ENABLE_SOFT_DELETE", "false").lower() == "true"
CHECK_FOR_UPDATES = os.getenv("CHECK_FOR_UPDATES", "true").lower() == "true"
KEEP_DELETED_TRANSACTIONS_FOR = int(os.getenv("KEEP_DELETED_ENTRIES_FOR", "365"))
APP_VERSION = os.getenv("APP_VERSION", "unknown")
DEMO = os.getenv("DEMO", "false").lower() == "true"

View File

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

View File

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

View File

@@ -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"),
),
)
@@ -77,6 +73,20 @@ class AccountForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["group"].queryset = AccountGroup.objects.all()
if self.instance.id:
qs = Currency.objects.filter(
Q(is_archived=False) | Q(accounts=self.instance.id)
).distinct()
self.fields["currency"].queryset = qs
self.fields["exchange_currency"].queryset = qs
else:
qs = Currency.objects.filter(Q(is_archived=False))
self.fields["currency"].queryset = qs
self.fields["exchange_currency"].queryset = qs
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.form_method = "post"
@@ -92,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"),
),
)
@@ -140,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"),
)
@@ -151,5 +156,11 @@ class AccountBalanceForm(forms.Form):
decimal_places=self.currency_decimal_places
)
self.fields["category"].queryset = TransactionCategory.objects.filter(
active=True
)
self.fields["tags"].queryset = TransactionTag.objects.filter(active=True)
AccountBalanceFormSet = forms.formset_factory(AccountBalanceForm, extra=0)

View File

@@ -0,0 +1,31 @@
# Generated by Django 5.1.6 on 2025-03-04 15:07
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0008_alter_account_name'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='account',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_accounts', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
),
migrations.AddField(
model_name='account',
name='shared_with',
field=models.ManyToManyField(blank=True, related_name='shared_accounts', to=settings.AUTH_USER_MODEL, verbose_name='Shared With'),
),
migrations.AddField(
model_name='accountgroup',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_account_groups', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
),
]

View File

@@ -0,0 +1,46 @@
# Generated by Django 5.1.6 on 2025-03-05 02:42
import django.db.models.manager
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0009_account_owner_account_shared_with_accountgroup_owner'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterModelManagers(
name='account',
managers=[
('all_objects', django.db.models.manager.Manager()),
],
),
migrations.AlterModelManagers(
name='accountgroup',
managers=[
('all_objects', django.db.models.manager.Manager()),
],
),
migrations.AlterField(
model_name='account',
name='name',
field=models.CharField(max_length=255, verbose_name='Name'),
),
migrations.AlterField(
model_name='accountgroup',
name='name',
field=models.CharField(max_length=255, verbose_name='Name'),
),
migrations.AlterUniqueTogether(
name='account',
unique_together={('owner', 'name')},
),
migrations.AlterUniqueTogether(
name='accountgroup',
unique_together={('owner', 'name')},
),
]

View File

@@ -0,0 +1,26 @@
# Generated by Django 5.1.6 on 2025-03-05 04:19
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0010_alter_account_managers_alter_accountgroup_managers_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='account',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='owned_accounts', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
),
migrations.AlterField(
model_name='accountgroup',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='owned_account_groups', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
),
]

View File

@@ -0,0 +1,56 @@
# Generated by Django 5.1.6 on 2025-03-05 23:46
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0011_alter_account_owner_alter_accountgroup_owner'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterModelManagers(
name='account',
managers=[
],
),
migrations.AlterModelManagers(
name='accountgroup',
managers=[
],
),
migrations.AddField(
model_name='account',
name='visibility',
field=models.CharField(choices=[('private', 'Private'), ('shared', 'Shared'), ('public', 'Public')], default='private', max_length=10),
),
migrations.AddField(
model_name='accountgroup',
name='shared_with',
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='accountgroup',
name='visibility',
field=models.CharField(choices=[('private', 'Private'), ('shared', 'Shared'), ('public', 'Public')], default='private', max_length=10),
),
migrations.AlterField(
model_name='account',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='account',
name='shared_with',
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='accountgroup',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.1.6 on 2025-03-06 01:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0012_alter_account_managers_alter_accountgroup_managers_and_more'),
]
operations = [
migrations.AlterField(
model_name='account',
name='visibility',
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10),
),
migrations.AlterField(
model_name='accountgroup',
name='visibility',
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10),
),
]

View File

@@ -0,0 +1,21 @@
# Generated by Django 5.1.7 on 2025-03-09 21:54
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('accounts', '0013_alter_account_visibility_and_more'),
]
operations = [
migrations.AlterModelOptions(
name='account',
options={'ordering': ['name', 'id'], 'verbose_name': 'Account', 'verbose_name_plural': 'Accounts'},
),
migrations.AlterModelOptions(
name='accountgroup',
options={'ordering': ['name', 'id'], 'verbose_name': 'Account Group', 'verbose_name_plural': 'Account Groups'},
),
]

View File

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

View File

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

View File

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

View File

@@ -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

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

View File

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

View File

@@ -8,6 +8,8 @@ from django.views.decorators.http import require_http_methods
from apps.accounts.forms import AccountForm
from apps.accounts.models import Account
from apps.common.decorators.htmx import only_htmx
from apps.common.models import SharedObject
from apps.common.forms import SharedObjectForm
@login_required
@@ -23,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",
@@ -62,6 +64,15 @@ def account_add(request, **kwargs):
@require_http_methods(["GET", "POST"])
def account_edit(request, pk):
account = get_object_or_404(Account, id=pk)
if account.owner and account.owner != request.user:
messages.error(request, _("Only the owner can edit this"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
if request.method == "POST":
form = AccountForm(request.POST, instance=account)
@@ -85,15 +96,97 @@ def account_edit(request, pk):
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def account_share(request, pk):
obj = get_object_or_404(Account, id=pk)
if obj.owner and obj.owner != request.user:
messages.error(request, _("Only the owner can edit this"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
if request.method == "POST":
form = SharedObjectForm(request.POST, instance=obj, user=request.user)
if form.is_valid():
form.save()
messages.success(request, _("Configuration saved successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
else:
form = SharedObjectForm(instance=obj, user=request.user)
return render(
request,
"accounts/fragments/share.html",
{"form": form, "object": obj},
)
@only_htmx
@login_required
@require_http_methods(["DELETE"])
def account_delete(request, pk):
account = get_object_or_404(Account, id=pk)
account.delete()
messages.success(request, _("Account deleted successfully"))
if account.owner != request.user and request.user in account.shared_with.all():
account.shared_with.remove(request.user)
messages.success(request, _("Item no longer shared with you"))
else:
account.delete()
messages.success(request, _("Account deleted successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def account_toggle_untracked(request, pk):
account = get_object_or_404(Account, id=pk)
if account.is_untracked_by():
account.untracked_by.remove(request.user)
messages.success(request, _("Account is now tracked"))
else:
account.untracked_by.add(request.user)
messages.success(request, _("Account is now untracked"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated",
},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def account_take_ownership(request, pk):
account = get_object_or_404(Account, id=pk)
if not account.owner:
account.owner = request.user
account.visibility = SharedObject.Visibility.private
account.save()
messages.success(request, _("Ownership taken successfully"))
return HttpResponse(
status=204,

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
from rest_framework.permissions import BasePermission
from django.conf import settings
class NotInDemoMode(BasePermission):
def has_permission(self, request, view):
if settings.DEMO and not request.user.is_superuser:
return False
else:
return True

View File

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

View File

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

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

View File

@@ -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,17 +1,60 @@
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.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
class AccountViewSet(viewsets.ModelViewSet):
queryset = Account.objects.all()
serializer_class = AccountSerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self):
queryset = super().get_queryset()
return queryset.select_related("group", "currency", "exchange_currency")
return 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
def get_queryset(self):
return (
Account.objects.all()
.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,5 +1,8 @@
from copy import deepcopy
from rest_framework import viewsets
from apps.api.custom.pagination import CustomPageNumberPagination
from apps.api.serializers import (
TransactionSerializer,
TransactionCategorySerializer,
@@ -22,40 +25,65 @@ from apps.rules.signals import transaction_updated, transaction_created
class TransactionViewSet(viewsets.ModelViewSet):
queryset = Transaction.objects.all()
serializer_class = TransactionSerializer
pagination_class = CustomPageNumberPagination
def perform_create(self, serializer):
instance = serializer.save()
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
return self.update(request, *args, **kwargs)
def get_queryset(self):
return Transaction.objects.all().order_by("-id")
class TransactionCategoryViewSet(viewsets.ModelViewSet):
queryset = TransactionCategory.objects.all()
serializer_class = TransactionCategorySerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self):
return TransactionCategory.objects.all().order_by("id")
class TransactionTagViewSet(viewsets.ModelViewSet):
queryset = TransactionTag.objects.all()
serializer_class = TransactionTagSerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self):
return TransactionTag.objects.all().order_by("id")
class TransactionEntityViewSet(viewsets.ModelViewSet):
queryset = TransactionEntity.objects.all()
serializer_class = TransactionEntitySerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self):
return TransactionEntity.objects.all().order_by("id")
class InstallmentPlanViewSet(viewsets.ModelViewSet):
queryset = InstallmentPlan.objects.all()
serializer_class = InstallmentPlanSerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self):
return InstallmentPlan.objects.all().order_by("-id")
class RecurringTransactionViewSet(viewsets.ModelViewSet):
queryset = RecurringTransaction.objects.all()
serializer_class = RecurringTransactionSerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self):
return RecurringTransaction.objects.all().order_by("-id")

26
app/apps/common/admin.py Normal file
View File

@@ -0,0 +1,26 @@
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
@admin.action(description=_("Make public"))
def make_public(modeladmin, request, queryset):
queryset.update(visibility="public")
@admin.action(description=_("Make private"))
def make_private(modeladmin, request, queryset):
queryset.update(visibility="private")
class SharedObjectModelAdmin(admin.ModelAdmin):
actions = [make_public, make_private]
list_display = ("__str__", "visibility", "owner", "get_shared_with")
@admin.display(description=_("Shared with users"))
def get_shared_with(self, obj):
return ", ".join([p.email for p in obj.shared_with.all()])
def get_queryset(self, request):
# Use the all_objects manager to show all transactions, including deleted ones
return self.model.all_objects.all()

View File

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

@@ -0,0 +1,15 @@
from functools import wraps
from django.conf import settings
from django.core.exceptions import PermissionDenied
def disabled_on_demo(view):
@wraps(view)
def _view(request, *args, **kwargs):
if settings.DEMO and not request.user.is_superuser:
raise PermissionDenied
return view(request, *args, **kwargs)
return _view

View File

@@ -0,0 +1,78 @@
from functools import wraps
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.http import HttpResponse
from django.shortcuts import redirect
from django.urls import reverse, NoReverseMatch
def is_superuser(view):
@wraps(view)
def _view(request, *args, **kwargs):
if not request.user.is_superuser:
raise PermissionDenied
return view(request, *args, **kwargs)
return _view
def htmx_login_required(function=None, login_url=None):
"""
Decorator that checks if the user is logged in.
Allows overriding the default login URL.
If the user is not logged in:
- If "hx-request" is present in the request header, it returns a 200 response
with a "HX-Redirect" header containing the determined login URL (including the "next" parameter).
- If "hx-request" is not present, it redirects to the determined login page normally.
Args:
function: The view function to decorate.
login_url: Optional. The URL or URL name to redirect to for login.
Defaults to settings.LOGIN_URL.
"""
def decorator(view_func):
# Simplified @wraps usage - it handles necessary attribute assignments by default
@wraps(view_func)
def wrapped_view(request, *args, **kwargs):
if request.user.is_authenticated:
return view_func(request, *args, **kwargs)
else:
# Determine the login URL
resolved_login_url = login_url
if not resolved_login_url:
resolved_login_url = settings.LOGIN_URL
# Try to reverse the URL name if it's not a path
try:
# Check if it looks like a URL path already
if "/" not in resolved_login_url and "." not in resolved_login_url:
login_url_path = reverse(resolved_login_url)
else:
login_url_path = resolved_login_url
except NoReverseMatch:
# If reverse fails, assume it's already a URL path
login_url_path = resolved_login_url
# Construct the full redirect path with 'next' parameter
# Ensure request.path is URL-encoded if needed, though Django usually handles this
redirect_path = f"{login_url_path}?next={request.get_full_path()}" # Use get_full_path() to include query params
if request.headers.get("hx-request"):
# For HTMX requests, return a 200 with the HX-Redirect header.
response = HttpResponse()
response["HX-Redirect"] = login_url_path
return response
else:
# For regular requests, redirect to the login page.
return redirect(redirect_path)
return wrapped_view
if function:
return decorator(function)
return decorator

View File

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

View File

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

108
app/apps/common/forms.py Normal file
View File

@@ -0,0 +1,108 @@
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()
class SharedObjectForm(forms.Form):
"""
Generic form for editing visibility and sharing settings
for models inheriting from SharedObject.
"""
owner = forms.ModelChoiceField(
queryset=User.objects.all(),
required=False,
label=_("Owner"),
widget=TomSelect(clear_button=False),
help_text=_(
"The owner of this object, if empty all users can see, edit and take ownership."
),
)
shared_with_users = forms.ModelMultipleChoiceField(
queryset=User.objects.all(),
required=False,
widget=TomSelectMultiple(clear_button=True),
label=_("Shared with users"),
help_text=_("Select users to share this object with"),
)
visibility = forms.ChoiceField(
choices=SharedObject.Visibility.choices,
required=True,
label=_("Visibility"),
widget=TomSelect(clear_button=False),
help_text=_(
"Private: Only shown for the owner and shared users. Only editable by the owner."
"<br/>"
"Public: Shown for all users. Only editable by the owner."
),
)
class Meta:
fields = ["visibility", "shared_with_users"]
def __init__(self, *args, **kwargs):
# Get the current user to filter available sharing options
self.user = kwargs.pop("user", None)
self.instance = kwargs.pop("instance", None)
super().__init__(*args, **kwargs)
# Pre-populate shared users if instance exists
if self.instance:
self.fields["shared_with_users"].initial = self.instance.shared_with.all()
self.fields["visibility"].initial = self.instance.visibility
self.fields["owner"].initial = self.instance.owner
# Set up crispy form helper
self.helper = FormHelper()
self.helper.form_method = "post"
self.helper.form_tag = False
self.helper.layout = Layout(
Field("owner"),
Field("visibility"),
HTML('<hr class="hr my-3">'),
Field("shared_with_users"),
FormActions(
NoClassSubmit("submit", _("Save"), css_class="btn btn-primary"),
),
)
def clean(self):
cleaned_data = super().clean()
owner = cleaned_data.get("owner")
shared_with_users = cleaned_data.get("shared_with_users", [])
# Raise validation error if owner is in shared_with_users
if owner and owner in shared_with_users:
self.add_error(
"shared_with_users",
ValidationError(
_("You cannot share this item with its owner."),
code="invalid_share",
),
)
return cleaned_data
def save(self):
instance = self.instance
instance.visibility = self.cleaned_data["visibility"]
instance.owner = self.cleaned_data["owner"]
instance.save()
# Clear and set shared_with users
instance.shared_with.set(self.cleaned_data.get("shared_with_users", []))
return instance

View File

@@ -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

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

View File

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

View File

@@ -56,6 +56,16 @@ def get_current_user():
if request:
return getattr(request, "user", None)
return getattr(_thread_locals, "user", None)
def write_current_user(user):
_thread_locals.user = user
def delete_current_user():
del _thread_locals.user
class ThreadLocalMiddleware(MiddlewareMixin):
"""Simple middleware that adds the request object in thread local storage."""

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

@@ -0,0 +1,103 @@
from django.db import models
from django.conf import settings
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from apps.common.middleware.thread_local import get_current_user
class SharedObjectManager(models.Manager):
def get_queryset(self):
"""Return only objects the user can access"""
user = get_current_user()
base_qs = super().get_queryset()
if user and user.is_authenticated:
return base_qs.filter(
Q(visibility="public")
| Q(owner=user)
| Q(shared_with=user)
| Q(visibility="private", owner=None)
).distinct()
return base_qs.filter(visibility="public")
class SharedObject(models.Model):
# Access control enum
class Visibility(models.TextChoices):
private = "private", _("Private")
is_paid = "public", _("Public")
# Core sharing fields
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="%(class)s_owned",
null=True,
blank=True,
verbose_name=_("Owner"),
)
visibility = models.CharField(
max_length=10,
choices=Visibility.choices,
default=Visibility.private,
verbose_name=_("Visibility"),
)
shared_with = models.ManyToManyField(
settings.AUTH_USER_MODEL,
related_name="%(class)s_shared",
blank=True,
verbose_name=_("Shared with users"),
)
# Use as abstract base class
class Meta:
abstract = True
indexes = [
models.Index(fields=["visibility"]),
]
def is_accessible_by(self, user):
"""Check if a user can access this object"""
return (
self.visibility == "public"
or self.owner == user
or (self.visibility == "shared" and user in self.shared_with.all())
)
def save(self, *args, **kwargs):
if not self.pk and not self.owner:
self.owner = get_current_user()
super().save(*args, **kwargs)
class OwnedObjectManager(models.Manager):
def get_queryset(self):
"""Return only objects the user can access"""
user = get_current_user()
base_qs = super().get_queryset()
if user and user.is_authenticated:
return base_qs.filter(Q(owner=user) | Q(owner=None)).distinct()
return base_qs
class OwnedObject(models.Model):
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="%(class)s_owned",
null=True,
blank=True,
)
# Use as abstract base class
class Meta:
abstract = True
def save(self, *args, **kwargs):
if not self.pk and not self.owner:
self.owner = get_current_user()
super().save(*args, **kwargs)

View File

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

View File

@@ -1,23 +1,34 @@
import logging
from packaging.version import parse as parse_version, InvalidVersion
from asgiref.sync import sync_to_async
from django.conf import settings
from django.core import management
from django.db import DEFAULT_DB_ALIAS
from django.core.cache import cache
from procrastinate import builtin_tasks
from procrastinate.contrib.django import app
import requests
logger = logging.getLogger(__name__)
@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,
)
@@ -30,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:
@@ -40,3 +55,84 @@ async def remove_expired_sessions(timestamp=None):
"Error while executing 'remove_expired_sessions' task",
exc_info=True,
)
@app.periodic(cron="0 8 * * *")
@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.
Runs daily at 8:00 AM.
"""
if not settings.DEMO:
return # Exit if not in demo mode
logger.info("Demo mode active. Starting daily data reset...")
try:
# 1. Flush the database (wipe all data)
logger.info("Flushing the database...")
management.call_command(
"flush", "--noinput", database=DEFAULT_DB_ALIAS, verbosity=1
)
logger.info("Database flushed successfully.")
# 2. Load data from the fixture
# TO-DO: Roll dates over based on today's date
fixture_name = "fixtures/demo_data.json"
logger.info(f"Loading data from fixture: {fixture_name}...")
management.call_command(
"loaddata", fixture_name, database=DEFAULT_DB_ALIAS, verbosity=1
)
logger.info(f"Data loaded successfully from {fixture_name}.")
logger.info("Daily demo data reset completed.")
except Exception as e:
logger.exception(f"Error during daily demo data reset: {e}")
raise
@app.periodic(cron="0 */12 * * *") # Every 12 hours
@app.task(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"
url = "https://api.github.com/repos/eitchtee/WYGIWYH/releases/latest"
try:
response = requests.get(url, timeout=60)
response.raise_for_status() # Raise an exception for bad status codes (4xx or 5xx)
data = response.json()
latest_version = data.get("tag_name")
if latest_version:
try:
current_v = parse_version(settings.APP_VERSION)
except InvalidVersion:
current_v = parse_version("0.0.0")
try:
latest_v = parse_version(latest_version)
except InvalidVersion:
latest_v = parse_version("0.0.0")
update_info = {
"update_available": False,
"current_version": str(current_v),
"latest_version": str(latest_v),
}
if latest_v > current_v:
update_info["update_available"] = True
# Cache the entire dictionary
cache.set("update_check", update_info, 60 * 60 * 25)
logger.info(f"Update check complete. Result: {update_info}")
else:
logger.warning("Could not find 'tag_name' in GitHub API response.")
except requests.exceptions.RequestException as e:
logger.error(f"Failed to fetch updates from GitHub: {e}")

View File

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

View File

@@ -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

@@ -15,10 +15,11 @@ from cachalot.api import invalidate
from apps.common.decorators.htmx import only_htmx
from apps.transactions.models import Transaction
from apps.common.decorators.user import htmx_login_required
@only_htmx
@login_required
@htmx_login_required
@require_http_methods(["GET"])
def toasts(request):
return render(request, "common/fragments/toasts.html")
@@ -90,6 +91,12 @@ def month_year_picker(request):
for date in all_months
]
today_url = (
reverse(url, kwargs={"month": current_date.month, "year": current_date.year})
if url
else ""
)
return render(
request,
"common/fragments/month_year_picker.html",
@@ -97,6 +104,7 @@ def month_year_picker(request):
"month_year_data": result,
"current_month": current_month,
"current_year": current_year,
"today_url": today_url,
},
)

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):
@@ -19,6 +18,8 @@ class AirDatePickerInput(widgets.DateInput):
format=None,
clear_button=True,
auto_close=True,
read_only=True,
toggle_selected=None,
*args,
**kwargs,
):
@@ -26,12 +27,18 @@ class AirDatePickerInput(widgets.DateInput):
super().__init__(attrs=attrs, format=format, *args, **kwargs)
self.clear_button = clear_button
self.auto_close = auto_close
self.read_only = read_only
self.toggle_selected = (
toggle_selected if isinstance(toggle_selected, bool) else self.clear_button
)
@staticmethod
def _get_current_language():
"""Get current language code in format compatible with AirDatepicker"""
lang_code = translation.get_language()
# AirDatepicker uses simple language codes
# AirDatepicker uses simple language codes, except for pt-br
if lang_code.lower() == "pt-br":
return "pt-BR"
return lang_code.split("-")[0]
def _get_format(self):
@@ -44,12 +51,18 @@ 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()
attrs["data-toggle-selected"] = str(self.toggle_selected).lower()
attrs["data-language"] = self._get_current_language()
attrs["data-date-format"] = django_to_airdatepicker_datetime(self._get_format())
if self.read_only:
attrs["readonly"] = True
return attrs
def format_value(self, value):
@@ -89,6 +102,8 @@ class AirDateTimePickerInput(widgets.DateTimeInput):
timepicker=True,
clear_button=True,
auto_close=True,
read_only=True,
toggle_selected=None,
*args,
**kwargs,
):
@@ -97,6 +112,10 @@ class AirDateTimePickerInput(widgets.DateTimeInput):
self.timepicker = timepicker
self.clear_button = clear_button
self.auto_close = auto_close
self.read_only = read_only
self.toggle_selected = (
toggle_selected if isinstance(toggle_selected, bool) else self.clear_button
)
@staticmethod
def _get_current_language():
@@ -123,11 +142,15 @@ class AirDateTimePickerInput(widgets.DateTimeInput):
attrs["data-now-button-txt"] = _("Now")
attrs["data-timepicker"] = str(self.timepicker).lower()
attrs["data-auto-close"] = str(self.auto_close).lower()
attrs["data-toggle-selected"] = str(self.toggle_selected).lower()
attrs["data-clear-button"] = str(self.clear_button).lower()
attrs["data-language"] = self._get_current_language()
attrs["data-date-format"] = date_format
attrs["data-time-format"] = time_format
if self.read_only:
attrs["readonly"] = True
return attrs
def format_value(self, value):
@@ -227,3 +250,56 @@ class AirMonthYearPickerInput(AirDatePickerInput):
except (ValueError, KeyError):
return None
return None
class AirYearPickerInput(AirDatePickerInput):
def __init__(self, attrs=None, format=None, *args, **kwargs):
super().__init__(attrs=attrs, format=format, *args, **kwargs)
# Store the display format for AirDatepicker
self.display_format = "yyyy"
# Store the Python format for internal use
self.python_format = "%Y"
def build_attrs(self, base_attrs, extra_attrs=None):
attrs = super().build_attrs(base_attrs, extra_attrs)
# Add data attributes for AirDatepicker configuration
attrs["data-now-button-txt"] = _("Today")
attrs["data-date-format"] = "yyyy"
return attrs
def format_value(self, value):
"""Format the value for display in the widget."""
if value:
self.attrs["data-value"] = (
value # We use this to dynamically select the initial date on AirDatePicker
)
if value is None:
return ""
if isinstance(value, str):
try:
value = datetime.datetime.strptime(value, "%Y-%m-%d").date()
except ValueError:
return value
if isinstance(value, (datetime.datetime, datetime.date)):
# Use Django's date translation
return f"{value.year}"
return value
def value_from_datadict(self, data, files, name):
"""Convert the value from the widget format back to a format Django can handle."""
value = super().value_from_datadict(data, files, name)
if value:
try:
# Split the value into month name and year
year_str = value
year = int(year_str)
if year:
# Return the first day of the month in Django's expected format
return datetime.date(year, 1, 1).strftime("%Y-%m-%d")
except (ValueError, KeyError):
return None
return None

View File

@@ -35,8 +35,8 @@ class ArbitraryDecimalDisplayNumberInput(forms.TextInput):
self.attrs.update(
{
"x-data": "",
"x-mask:dynamic": f"$money($input, '{get_format('DECIMAL_SEPARATOR')}', "
f"'{get_format('THOUSAND_SEPARATOR')}', '30')",
"x-mask:dynamic": f"$money($input, '{get_format('DECIMAL_SEPARATOR')}', '{get_format('THOUSAND_SEPARATOR')}', '30')",
"x-on:keyup": "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,5 @@
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 _
@@ -16,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
@@ -129,3 +130,28 @@ class TomSelect(widgets.Select):
class TomSelectMultiple(SelectMultiple, TomSelect):
pass
class TransactionSelect(TomSelect):
def __init__(self, income: bool = True, expense: bool = True, *args, **kwargs):
super().__init__(*args, **kwargs)
self.load_income = income
self.load_expense = expense
self.create = False
def build_attrs(self, base_attrs, extra_attrs=None):
attrs = super().build_attrs(base_attrs, extra_attrs)
if self.load_income and self.load_expense:
attrs["data-load"] = reverse("transactions_search")
elif self.load_income and not self.load_expense:
attrs["data-load"] = reverse(
"transactions_search", kwargs={"filter_type": "income"}
)
elif self.load_expense and not self.load_income:
attrs["data-load"] = reverse(
"transactions_search", kwargs={"filter_type": "expenses"}
)
return attrs

View File

@@ -1,14 +1,9 @@
import logging
from datetime import timedelta
from django.db.models import QuerySet
from django.utils import timezone
from apps.currencies.exchange_rates.providers import (
SynthFinanceProvider,
CoinGeckoFreeProvider,
CoinGeckoProProvider,
)
import apps.currencies.exchange_rates.providers as providers
from apps.currencies.models import ExchangeRateService, ExchangeRate, Currency
logger = logging.getLogger(__name__)
@@ -16,9 +11,12 @@ logger = logging.getLogger(__name__)
# Map service types to provider classes
PROVIDER_MAPPING = {
"synth_finance": SynthFinanceProvider,
"coingecko_free": CoinGeckoFreeProvider,
"coingecko_pro": CoinGeckoProProvider,
"coingecko_free": providers.CoinGeckoFreeProvider,
"coingecko_pro": providers.CoinGeckoProProvider,
"transitive": providers.TransitiveRateProvider,
"frankfurter": providers.FrankfurterProvider,
"twelvedata": providers.TwelveDataProvider,
"twelvedatamarkets": providers.TwelveDataMarketsProvider,
}
@@ -199,25 +197,70 @@ class ExchangeRateFetcher:
if provider.rates_inverted:
# If rates are inverted, we need to swap currencies
ExchangeRate.objects.create(
from_currency=to_currency,
to_currency=from_currency,
rate=rate,
date=timezone.now(),
)
if service.singleton:
# Try to get the last automatically created exchange rate
exchange_rate = (
ExchangeRate.objects.filter(
automatic=True,
from_currency=to_currency,
to_currency=from_currency,
)
.order_by("-date")
.first()
)
else:
exchange_rate = None
if not exchange_rate:
ExchangeRate.objects.create(
automatic=True,
from_currency=to_currency,
to_currency=from_currency,
rate=rate,
date=timezone.now(),
)
else:
exchange_rate.rate = rate
exchange_rate.date = timezone.now()
exchange_rate.save()
processed_pairs.add((to_currency.id, from_currency.id))
else:
# If rates are not inverted, we can use them as is
ExchangeRate.objects.create(
from_currency=from_currency,
to_currency=to_currency,
rate=rate,
date=timezone.now(),
)
if service.singleton:
# Try to get the last automatically created exchange rate
exchange_rate = (
ExchangeRate.objects.filter(
automatic=True,
from_currency=from_currency,
to_currency=to_currency,
)
.order_by("-date")
.first()
)
else:
exchange_rate = None
if not exchange_rate:
ExchangeRate.objects.create(
automatic=True,
from_currency=from_currency,
to_currency=to_currency,
rate=rate,
date=timezone.now(),
)
else:
exchange_rate.rate = rate
exchange_rate.date = timezone.now()
exchange_rate.save()
processed_pairs.add((from_currency.id, to_currency.id))
service.last_fetch = timezone.now()
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

@@ -3,80 +3,16 @@ import time
import requests
from decimal import Decimal
from typing import Tuple, List
from typing import Tuple, List, Optional, Dict
from django.db.models import QuerySet
from apps.currencies.models import Currency
from apps.currencies.models import Currency, ExchangeRate
from apps.currencies.exchange_rates.base import ExchangeRateProvider
logger = logging.getLogger(__name__)
class SynthFinanceProvider(ExchangeRateProvider):
"""Implementation for Synth Finance API (synthfinance.com)"""
BASE_URL = "https://api.synthfinance.com/rates/live"
rates_inverted = False # SynthFinance returns non-inverted rates
def __init__(self, api_key: str = None):
super().__init__(api_key)
self.session = requests.Session()
self.session.headers.update({"Authorization": f"Bearer {self.api_key}"})
def get_rates(
self, target_currencies: QuerySet, exchange_currencies: set
) -> List[Tuple[Currency, Currency, Decimal]]:
results = []
currency_groups = {}
for currency in target_currencies:
if currency.exchange_currency in exchange_currencies:
group = currency_groups.setdefault(currency.exchange_currency.code, [])
group.append(currency)
for base_currency, currencies in currency_groups.items():
try:
to_currencies = ",".join(
currency.code
for currency in currencies
if currency.code != base_currency
)
response = self.session.get(
f"{self.BASE_URL}",
params={"from": base_currency, "to": to_currencies},
)
response.raise_for_status()
data = response.json()
rates = data["data"]["rates"]
for currency in currencies:
if currency.code == base_currency:
rate = Decimal("1")
else:
rate = Decimal(str(rates[currency.code]))
# Return the rate as is, without inversion
results.append((currency.exchange_currency, currency, rate))
credits_used = data["meta"]["credits_used"]
credits_remaining = data["meta"]["credits_remaining"]
logger.info(
f"Synth Finance API call: {credits_used} credits used, {credits_remaining} remaining"
)
except requests.RequestException as e:
logger.error(
f"Error fetching rates from Synth Finance API for base {base_currency}: {e}"
)
except KeyError as e:
logger.error(
f"Unexpected response structure from Synth Finance API for base {base_currency}: {e}"
)
except Exception as e:
logger.error(
f"Unexpected error processing Synth Finance data for base {base_currency}: {e}"
)
return results
class CoinGeckoFreeProvider(ExchangeRateProvider):
"""Implementation for CoinGecko Free API"""
@@ -150,3 +86,420 @@ class CoinGeckoProProvider(CoinGeckoFreeProvider):
super().__init__(api_key)
self.session = requests.Session()
self.session.headers.update({"x-cg-pro-api-key": api_key})
class TransitiveRateProvider(ExchangeRateProvider):
"""Calculates exchange rates through paths of existing rates"""
rates_inverted = True
def __init__(self, api_key: str = None):
super().__init__(api_key) # API key not needed but maintaining interface
@classmethod
def requires_api_key(cls) -> bool:
return False
def get_rates(
self, target_currencies: QuerySet, exchange_currencies: set
) -> List[Tuple[Currency, Currency, Decimal]]:
results = []
# Get recent rates for building the graph
recent_rates = ExchangeRate.objects.all()
# Build currency graph
currency_graph = self._build_currency_graph(recent_rates)
for target in target_currencies:
if (
not target.exchange_currency
or target.exchange_currency not in exchange_currencies
):
continue
# Find path and calculate rate
from_id = target.exchange_currency.id
to_id = target.id
path, rate = self._find_conversion_path(currency_graph, from_id, to_id)
if path and rate:
path_codes = [Currency.objects.get(id=cid).code for cid in path]
logger.info(
f"Found conversion path: {' -> '.join(path_codes)}, rate: {rate}"
)
results.append((target.exchange_currency, target, rate))
else:
logger.debug(
f"No conversion path found for {target.exchange_currency.code}->{target.code}"
)
return results
@staticmethod
def _build_currency_graph(rates) -> Dict[int, Dict[int, Decimal]]:
"""Build a graph representation of currency relationships"""
graph = {}
for rate in rates:
# Add both directions to make the graph bidirectional
if rate.from_currency_id not in graph:
graph[rate.from_currency_id] = {}
graph[rate.from_currency_id][rate.to_currency_id] = rate.rate
if rate.to_currency_id not in graph:
graph[rate.to_currency_id] = {}
graph[rate.to_currency_id][rate.from_currency_id] = Decimal("1") / rate.rate
return graph
@staticmethod
def _find_conversion_path(
graph, from_id, to_id
) -> Tuple[Optional[list], Optional[Decimal]]:
"""Find the shortest path between currencies using breadth-first search"""
if from_id not in graph or to_id not in graph:
return None, None
queue = [(from_id, [from_id], Decimal("1"))]
visited = {from_id}
while queue:
current, path, current_rate = queue.pop(0)
if current == to_id:
return path, current_rate
for neighbor, rate in graph.get(current, {}).items():
if neighbor not in visited:
visited.add(neighbor)
queue.append((neighbor, path + [neighbor], current_rate * rate))
return None, None
class FrankfurterProvider(ExchangeRateProvider):
"""Implementation for the Frankfurter API (frankfurter.dev)"""
BASE_URL = "https://api.frankfurter.dev/v1/latest"
rates_inverted = (
False # Frankfurter returns non-inverted rates (e.g., 1 EUR = 1.1 USD)
)
def __init__(self, api_key: str = None):
"""
Initializes the provider. The Frankfurter API does not require an API key,
so the api_key parameter is ignored.
"""
super().__init__(api_key)
self.session = requests.Session()
@classmethod
def requires_api_key(cls) -> bool:
return False
def get_rates(
self, target_currencies: QuerySet, exchange_currencies: set
) -> List[Tuple[Currency, Currency, Decimal]]:
results = []
currency_groups = {}
# Group target currencies by their exchange (base) currency to minimize API calls
for currency in target_currencies:
if currency.exchange_currency in exchange_currencies:
group = currency_groups.setdefault(currency.exchange_currency.code, [])
group.append(currency)
# Make one API call for each base currency
for base_currency, currencies in currency_groups.items():
try:
# Create a comma-separated list of target currency codes
to_currencies = ",".join(
currency.code
for currency in currencies
if currency.code != base_currency
)
# If there are no target currencies other than the base, skip the API call
if not to_currencies:
# Handle the case where the only request is for the base rate (e.g., USD to USD)
for currency in currencies:
if currency.code == base_currency:
results.append(
(currency.exchange_currency, currency, Decimal("1"))
)
continue
response = self.session.get(
self.BASE_URL,
params={"base": base_currency, "symbols": to_currencies},
)
response.raise_for_status()
data = response.json()
rates = data["rates"]
# Process the returned rates
for currency in currencies:
if currency.code == base_currency:
# The rate for the base currency to itself is always 1
rate = Decimal("1")
else:
rate = Decimal(str(rates[currency.code]))
results.append((currency.exchange_currency, currency, rate))
except requests.RequestException as e:
logger.error(
f"Error fetching rates from Frankfurter API for base {base_currency}: {e}"
)
except KeyError as e:
logger.error(
f"Unexpected response structure from Frankfurter API for base {base_currency}: {e}"
)
except Exception as e:
logger.error(
f"Unexpected error processing Frankfurter data for base {base_currency}: {e}"
)
return results
class TwelveDataProvider(ExchangeRateProvider):
"""Implementation for the Twelve Data API (twelvedata.com)"""
BASE_URL = "https://api.twelvedata.com/exchange_rate"
rates_inverted = (
False # The API returns direct rates, e.g., for EUR/USD it's 1 EUR = X USD
)
def __init__(self, api_key: str):
"""
Initializes the provider with an API key and a requests session.
"""
super().__init__(api_key)
self.session = requests.Session()
@classmethod
def requires_api_key(cls) -> bool:
"""This provider requires an API key."""
return True
def get_rates(
self, target_currencies: QuerySet, exchange_currencies: set
) -> List[Tuple[Currency, Currency, Decimal]]:
"""
Fetches exchange rates from the Twelve Data API for the given currency pairs.
This provider makes one API call for each requested currency pair.
"""
results = []
for target_currency in target_currencies:
# Ensure the target currency's exchange currency is one we're interested in
if target_currency.exchange_currency not in exchange_currencies:
continue
base_currency = target_currency.exchange_currency
# The exchange rate for the same currency is always 1
if base_currency.code == target_currency.code:
rate = Decimal("1")
results.append((base_currency, target_currency, rate))
continue
# Construct the symbol in the format "BASE/TARGET", e.g., "EUR/USD"
symbol = f"{base_currency.code}/{target_currency.code}"
try:
params = {
"symbol": symbol,
"apikey": self.api_key,
}
response = self.session.get(self.BASE_URL, params=params)
response.raise_for_status() # Raise an HTTPError for bad responses (4xx or 5xx)
data = response.json()
# The API may return an error message in a JSON object
if "rate" not in data:
error_message = data.get("message", "Rate not found in response.")
logger.error(
f"Could not fetch rate for {symbol} from Twelve Data: {error_message}"
)
continue
# Convert the rate to a Decimal for precision
rate = Decimal(str(data["rate"]))
results.append((base_currency, target_currency, rate))
logger.info(f"Successfully fetched rate for {symbol} from Twelve Data.")
time.sleep(
60
) # We sleep every pair as to not step over TwelveData's minute limit
except requests.RequestException as e:
logger.error(
f"Error fetching rate from Twelve Data API for symbol {symbol}: {e}"
)
except KeyError as e:
logger.error(
f"Unexpected response structure from Twelve Data API for symbol {symbol}: Missing key {e}"
)
except Exception as e:
logger.error(
f"An unexpected error occurred while processing Twelve Data for {symbol}: {e}"
)
return results
class TwelveDataMarketsProvider(ExchangeRateProvider):
"""
Provides prices for market instruments (stocks, ETFs, etc.) using the Twelve Data API.
This provider performs a multi-step process:
1. Parses instrument codes which can be symbols, FIGI, CUSIP, or ISIN.
2. For CUSIPs, it defaults the currency to USD. For all others, it searches
for the instrument to determine its native trading currency.
3. Fetches the latest price for the instrument in its native currency.
4. Converts the price to the requested target exchange currency.
"""
SYMBOL_SEARCH_URL = "https://api.twelvedata.com/symbol_search"
PRICE_URL = "https://api.twelvedata.com/price"
EXCHANGE_RATE_URL = "https://api.twelvedata.com/exchange_rate"
rates_inverted = True
def __init__(self, api_key: str):
super().__init__(api_key)
self.session = requests.Session()
@classmethod
def requires_api_key(cls) -> bool:
return True
def _parse_code(self, raw_code: str) -> Tuple[str, str]:
"""Parses the raw code to determine its type and value."""
if raw_code.startswith("figi:"):
return "figi", raw_code.removeprefix("figi:")
if raw_code.startswith("cusip:"):
return "cusip", raw_code.removeprefix("cusip:")
if raw_code.startswith("isin:"):
return "isin", raw_code.removeprefix("isin:")
return "symbol", raw_code
def get_rates(
self, target_currencies: QuerySet, exchange_currencies: set
) -> List[Tuple[Currency, Currency, Decimal]]:
results = []
for asset in target_currencies:
if asset.exchange_currency not in exchange_currencies:
continue
code_type, code_value = self._parse_code(asset.code)
original_currency_code = None
try:
# Determine the instrument's native currency
if code_type == "cusip":
# CUSIP codes always default to USD
original_currency_code = "USD"
logger.info(f"Defaulting CUSIP {code_value} to USD currency.")
else:
# For all other types, find currency via symbol search
search_params = {"symbol": code_value, "apikey": "demo"}
search_res = self.session.get(
self.SYMBOL_SEARCH_URL, params=search_params
)
search_res.raise_for_status()
search_data = search_res.json()
if not search_data.get("data"):
logger.warning(
f"TwelveDataMarkets: Symbol search for '{code_value}' returned no results."
)
continue
instrument_data = search_data["data"][0]
original_currency_code = instrument_data.get("currency")
if not original_currency_code:
logger.error(
f"TwelveDataMarkets: Could not determine original currency for '{code_value}'."
)
continue
# Get the instrument's price in its native currency
price_params = {code_type: code_value, "apikey": self.api_key}
price_res = self.session.get(self.PRICE_URL, params=price_params)
price_res.raise_for_status()
price_data = price_res.json()
if "price" not in price_data:
error_message = price_data.get(
"message", "Price key not found in response"
)
logger.error(
f"TwelveDataMarkets: Could not get price for {code_type} '{code_value}': {error_message}"
)
continue
price_in_original_currency = Decimal(price_data["price"])
# Convert price to the target exchange currency
target_exchange_currency = asset.exchange_currency
if (
original_currency_code.upper()
== target_exchange_currency.code.upper()
):
final_price = price_in_original_currency
else:
rate_symbol = (
f"{original_currency_code}/{target_exchange_currency.code}"
)
rate_params = {"symbol": rate_symbol, "apikey": self.api_key}
rate_res = self.session.get(
self.EXCHANGE_RATE_URL, params=rate_params
)
rate_res.raise_for_status()
rate_data = rate_res.json()
if "rate" not in rate_data:
error_message = rate_data.get(
"message", "Rate key not found in response"
)
logger.error(
f"TwelveDataMarkets: Could not get conversion rate for '{rate_symbol}': {error_message}"
)
continue
conversion_rate = Decimal(str(rate_data["rate"]))
final_price = price_in_original_currency * conversion_rate
results.append((target_exchange_currency, asset, final_price))
logger.info(
f"Successfully processed price for {asset.code} as {final_price} {target_exchange_currency.code}"
)
time.sleep(
60
) # We sleep every pair as to not step over TwelveData's minute limit
except requests.RequestException as e:
logger.error(
f"TwelveDataMarkets: API request failed for {code_value}: {e}"
)
except (KeyError, IndexError) as e:
logger.error(
f"TwelveDataMarkets: Error processing API response for {code_value}: {e}"
)
except Exception as e:
logger.error(
f"TwelveDataMarkets: An unexpected error occurred for {code_value}: {e}"
)
return results

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"),
),
)
@@ -114,6 +107,7 @@ class ExchangeRateServiceForm(forms.ModelForm):
"fetch_interval",
"target_currencies",
"target_accounts",
"singleton",
]
def __init__(self, *args, **kwargs):
@@ -126,10 +120,11 @@ class ExchangeRateServiceForm(forms.ModelForm):
"name",
"service_type",
Switch("is_active"),
Switch("singleton"),
"api_key",
Row(
Column("interval_type", css_class="form-group col-md-6"),
Column("fetch_interval", css_class="form-group col-md-6"),
Column("interval_type"),
Column("fetch_interval"),
),
"target_currencies",
"target_accounts",
@@ -138,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.1.6 on 2025-03-02 01:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('currencies', '0011_remove_exchangerateservice_fetch_interval_hours_and_more'),
]
operations = [
migrations.AlterField(
model_name='exchangerateservice',
name='service_type',
field=models.CharField(choices=[('synth_finance', 'Synth Finance'), ('synth_finance_stock', 'Synth Finance Stock'), ('coingecko_free', 'CoinGecko (Demo/Free)'), ('coingecko_pro', 'CoinGecko (Pro)')], max_length=255, verbose_name='Service Type'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.6 on 2025-03-02 01:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('currencies', '0012_alter_exchangerateservice_service_type'),
]
operations = [
migrations.AlterField(
model_name='exchangerateservice',
name='service_type',
field=models.CharField(choices=[('synth_finance', 'Synth Finance'), ('synth_finance_stock', 'Synth Finance Stock'), ('coingecko_free', 'CoinGecko (Demo/Free)'), ('coingecko_pro', 'CoinGecko (Pro)'), ('transitive', 'Transitive (Calculated from Existing Rates)')], max_length=255, verbose_name='Service Type'),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.1.7 on 2025-03-09 21:54
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('currencies', '0013_alter_exchangerateservice_service_type'),
]
operations = [
migrations.AlterModelOptions(
name='currency',
options={'ordering': ['name', 'id'], 'verbose_name': 'Currency', 'verbose_name_plural': 'Currencies'},
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.4 on 2025-08-08 02:18
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('currencies', '0014_alter_currency_options'),
]
operations = [
migrations.AddField(
model_name='exchangerate',
name='automatic',
field=models.BooleanField(default=False, verbose_name='Automatic'),
),
migrations.AddField(
model_name='exchangerateservice',
name='singleton',
field=models.BooleanField(default=False, help_text='Create one exchange rate and keep updating it. Avoids database clutter.', verbose_name='Single exchange rate'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-08-08 02:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('currencies', '0015_exchangerate_automatic_exchangerateservice_singleton'),
]
operations = [
migrations.AlterField(
model_name='exchangerate',
name='automatic',
field=models.BooleanField(default=False, verbose_name='Auto'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.5 on 2025-08-16 22:18
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('currencies', '0016_alter_exchangerate_automatic'),
]
operations = [
migrations.AlterField(
model_name='exchangerateservice',
name='service_type',
field=models.CharField(choices=[('synth_finance', 'Synth Finance'), ('synth_finance_stock', 'Synth Finance Stock'), ('coingecko_free', 'CoinGecko (Demo/Free)'), ('coingecko_pro', 'CoinGecko (Pro)'), ('transitive', 'Transitive (Calculated from Existing Rates)'), ('frankfurter', 'Frankfurter')], max_length=255, verbose_name='Service Type'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.5 on 2025-08-17 03:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('currencies', '0017_alter_exchangerateservice_service_type'),
]
operations = [
migrations.AlterField(
model_name='exchangerateservice',
name='service_type',
field=models.CharField(choices=[('synth_finance', 'Synth Finance'), ('synth_finance_stock', 'Synth Finance Stock'), ('coingecko_free', 'CoinGecko (Demo/Free)'), ('coingecko_pro', 'CoinGecko (Pro)'), ('transitive', 'Transitive (Calculated from Existing Rates)'), ('frankfurter', 'Frankfurter'), ('twelvedata', 'TwelveData')], max_length=255, verbose_name='Service Type'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.5 on 2025-08-17 06:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('currencies', '0018_alter_exchangerateservice_service_type'),
]
operations = [
migrations.AlterField(
model_name='exchangerateservice',
name='service_type',
field=models.CharField(choices=[('synth_finance', 'Synth Finance'), ('synth_finance_stock', 'Synth Finance Stock'), ('coingecko_free', 'CoinGecko (Demo/Free)'), ('coingecko_pro', 'CoinGecko (Pro)'), ('transitive', 'Transitive (Calculated from Existing Rates)'), ('frankfurter', 'Frankfurter'), ('twelvedata', 'TwelveData'), ('twelvedatamarkets', 'TwelveData Markets')], max_length=255, verbose_name='Service Type'),
),
]

View File

@@ -0,0 +1,51 @@
# Generated by Django 5.2.5 on 2025-08-17 06:25
from django.db import migrations
# The new value we are migrating to
NEW_SERVICE_TYPE = "frankfurter"
# The old values we are deprecating
OLD_SERVICE_TYPE_TO_UPDATE = "synth_finance"
OLD_SERVICE_TYPE_TO_DELETE = "synth_finance_stock"
def forwards_func(apps, schema_editor):
"""
Forward migration:
- Deletes all ExchangeRateService instances with service_type 'synth_finance_stock'.
- Updates all ExchangeRateService instances with service_type 'synth_finance' to 'frankfurter'.
"""
ExchangeRateService = apps.get_model("currencies", "ExchangeRateService")
db_alias = schema_editor.connection.alias
# 1. Delete the SYNTH_FINANCE_STOCK entries
ExchangeRateService.objects.using(db_alias).filter(
service_type=OLD_SERVICE_TYPE_TO_DELETE
).delete()
# 2. Update the SYNTH_FINANCE entries to FRANKFURTER
ExchangeRateService.objects.using(db_alias).filter(
service_type=OLD_SERVICE_TYPE_TO_UPDATE
).update(service_type=NEW_SERVICE_TYPE, api_key=None)
def backwards_func(apps, schema_editor):
"""
Backward migration: This operation is not safely reversible.
- We cannot know which 'frankfurter' services were originally 'synth_finance'.
- The deleted 'synth_finance_stock' services cannot be recovered.
We will leave this function empty to allow migrating backwards without doing anything.
"""
pass
class Migration(migrations.Migration):
dependencies = [
# Add the previous migration file here
("currencies", "0019_alter_exchangerateservice_service_type"),
]
operations = [
migrations.RunPython(forwards_func, reverse_code=backwards_func),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.5 on 2025-08-17 06:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('currencies', '0020_migrate_synth_finance_services'),
]
operations = [
migrations.AlterField(
model_name='exchangerateservice',
name='service_type',
field=models.CharField(choices=[('coingecko_free', 'CoinGecko (Demo/Free)'), ('coingecko_pro', 'CoinGecko (Pro)'), ('transitive', 'Transitive (Calculated from Existing Rates)'), ('frankfurter', 'Frankfurter'), ('twelvedata', 'TwelveData'), ('twelvedatamarkets', 'TwelveData Markets')], max_length=255, verbose_name='Service Type'),
),
]

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,12 +32,18 @@ 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
class Meta:
verbose_name = _("Currency")
verbose_name_plural = _("Currencies")
ordering = ["name", "id"]
def clean(self):
super().clean()
@@ -69,6 +75,8 @@ class ExchangeRate(models.Model):
)
date = models.DateTimeField(verbose_name=_("Date and Time"))
automatic = models.BooleanField(verbose_name=_("Auto"), default=False)
class Meta:
verbose_name = _("Exchange Rate")
verbose_name_plural = _("Exchange Rates")
@@ -91,9 +99,12 @@ class ExchangeRateService(models.Model):
"""Configuration for exchange rate services"""
class ServiceType(models.TextChoices):
SYNTH_FINANCE = "synth_finance", "Synth Finance"
COINGECKO_FREE = "coingecko_free", "CoinGecko (Demo/Free)"
COINGECKO_PRO = "coingecko_pro", "CoinGecko (Pro)"
TRANSITIVE = "transitive", "Transitive (Calculated from Existing Rates)"
FRANKFURTER = "frankfurter", "Frankfurter"
TWELVEDATA = "twelvedata", "TwelveData"
TWELVEDATA_MARKETS = "twelvedatamarkets", "TwelveData Markets"
class IntervalType(models.TextChoices):
ON = "on", _("On")
@@ -125,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"),
@@ -145,6 +158,14 @@ class ExchangeRateService(models.Model):
blank=True,
)
singleton = models.BooleanField(
verbose_name=_("Single exchange rate"),
default=False,
help_text=_(
"Create one exchange rate and keep updating it. Avoids database clutter."
),
)
class Meta:
verbose_name = _("Exchange Rate Service")
verbose_name_plural = _("Exchange Rate Services")
@@ -204,11 +225,11 @@ class ExchangeRateService(models.Model):
}
)
hours = int(self.fetch_interval)
if hours < 0 or hours > 23:
if hours < 1 or hours > 24:
raise ValidationError(
{
"fetch_interval": _(
"'Every X hours' interval must be between 0 and 23."
"'Every X hours' interval must be between 1 and 24."
)
}
)
@@ -218,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": _(
@@ -229,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

@@ -11,9 +11,11 @@ from apps.common.decorators.htmx import only_htmx
from apps.currencies.forms import ExchangeRateForm, ExchangeRateServiceForm
from apps.currencies.models import ExchangeRate, ExchangeRateService
from apps.currencies.tasks import manual_fetch_exchange_rates
from apps.common.decorators.demo import disabled_on_demo
@login_required
@disabled_on_demo
@require_http_methods(["GET"])
def exchange_rates_services_index(request):
return render(
@@ -24,6 +26,7 @@ def exchange_rates_services_index(request):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET"])
def exchange_rates_services_list(request):
services = ExchangeRateService.objects.all()
@@ -37,6 +40,7 @@ def exchange_rates_services_list(request):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def exchange_rate_service_add(request):
if request.method == "POST":
@@ -63,6 +67,7 @@ def exchange_rate_service_add(request):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def exchange_rate_service_edit(request, pk):
service = get_object_or_404(ExchangeRateService, id=pk)
@@ -91,6 +96,7 @@ def exchange_rate_service_edit(request, pk):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["DELETE"])
def exchange_rate_service_delete(request, pk):
service = get_object_or_404(ExchangeRateService, id=pk)
@@ -109,6 +115,7 @@ def exchange_rate_service_delete(request, pk):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET"])
def exchange_rate_service_force_fetch(request):
manual_fetch_exchange_rates.defer()

View File

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

View File

@@ -1,14 +1,20 @@
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.utils.translation import gettext_lazy as _
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
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 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):
@@ -28,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",
)
@@ -37,22 +43,87 @@ 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"),
),
)
class DCAEntryForm(forms.ModelForm):
create_transaction = forms.BooleanField(
label=_("Create transaction"), initial=False, required=False
)
from_account = forms.ModelChoiceField(
queryset=Account.objects.filter(is_archived=False),
label=_("From Account"),
widget=TomSelect(clear_button=False, group_by="group"),
required=False,
)
to_account = forms.ModelChoiceField(
queryset=Account.objects.filter(is_archived=False),
label=_("To Account"),
widget=TomSelect(clear_button=False, group_by="group"),
required=False,
)
from_category = DynamicModelChoiceField(
create_field="name",
model=TransactionCategory,
required=False,
label=_("Category"),
queryset=TransactionCategory.objects.filter(active=True),
)
to_category = DynamicModelChoiceField(
create_field="name",
model=TransactionCategory,
required=False,
label=_("Category"),
queryset=TransactionCategory.objects.filter(active=True),
)
from_tags = DynamicModelMultipleChoiceField(
model=TransactionTag,
to_field_name="name",
create_field="name",
required=False,
label=_("Tags"),
queryset=TransactionTag.objects.filter(active=True),
)
to_tags = DynamicModelMultipleChoiceField(
model=TransactionTag,
to_field_name="name",
create_field="name",
required=False,
label=_("Tags"),
queryset=TransactionTag.objects.filter(active=True),
)
expense_transaction = DynamicModelChoiceField(
model=Transaction,
to_field_name="id",
label=_("Expense Transaction"),
required=False,
queryset=Transaction.objects.none(),
widget=TransactionSelect(clear_button=True, income=False, expense=True),
help_text=_("Type to search for a transaction to link to this entry"),
)
income_transaction = DynamicModelChoiceField(
model=Transaction,
to_field_name="id",
label=_("Income Transaction"),
required=False,
queryset=Transaction.objects.none(),
widget=TransactionSelect(clear_button=True, income=True, expense=False),
help_text=_("Type to search for a transaction to link to this entry"),
)
class Meta:
model = DCAEntry
fields = [
@@ -60,50 +131,220 @@ class DCAEntryForm(forms.ModelForm):
"amount_paid",
"amount_received",
"notes",
"expense_transaction",
"income_transaction",
]
widgets = {
"notes": forms.Textarea(attrs={"rows": 3}),
}
def __init__(self, *args, **kwargs):
strategy = kwargs.pop("strategy", None)
super().__init__(*args, **kwargs)
self.strategy = strategy if strategy else self.instance.strategy
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.layout = Layout(
"date",
Row(
Column("amount_paid", css_class="form-group col-md-6"),
Column("amount_received", css_class="form-group col-md-6"),
),
Row(
Column("expense_transaction", css_class="form-group col-md-6"),
Column("income_transaction", css_class="form-group col-md-6"),
Column("amount_paid"),
Column("amount_received"),
),
"notes",
Accordion(
AccordionGroup(
_("Create transaction"),
Switch("create_transaction"),
Row(
Column(
Row(
Column(
"from_account",
),
),
Row(
Column("from_category"),
Column("from_tags"),
),
),
css_class="p-1 mx-1 my-3 border rounded-3",
),
Row(
Column(
Row(
Column(
"to_account",
css_class="form-group",
),
),
Row(
Column("to_category"),
Column("to_tags"),
),
),
css_class="p-1 mx-1 my-3 border rounded-3",
),
active=False,
),
AccordionGroup(
_("Link transaction"),
"income_transaction",
"expense_transaction",
),
flush=False,
always_open=False,
css_class="mb-3",
),
)
if self.instance and self.instance.pk:
# decimal_places = self.instance.account.currency.decimal_places
# self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput(
# decimal_places=decimal_places
# )
self.helper.layout.append(
FormActions(
NoClassSubmit(
"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(
FormActions(
NoClassSubmit(
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
),
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
),
)
self.fields["amount_paid"].widget = ArbitraryDecimalDisplayNumberInput()
self.fields["amount_received"].widget = ArbitraryDecimalDisplayNumberInput()
self.fields["date"].widget = AirDatePickerInput(clear_button=False)
expense_transaction = None
income_transaction = None
if self.instance and self.instance.pk:
# Edit mode - get from instance
expense_transaction = self.instance.expense_transaction
income_transaction = self.instance.income_transaction
elif self.data.get("expense_transaction"):
# Form validation - get from submitted data
try:
expense_transaction = Transaction.objects.get(
id=self.data["expense_transaction"]
)
income_transaction = Transaction.objects.get(
id=self.data["income_transaction"]
)
except Transaction.DoesNotExist:
pass
# If we have a current transaction, ensure it's in the queryset
if income_transaction:
self.fields["income_transaction"].queryset = Transaction.objects.filter(
id=income_transaction.id
)
if expense_transaction:
self.fields["expense_transaction"].queryset = Transaction.objects.filter(
id=expense_transaction.id
)
self.fields["from_account"].queryset = Account.objects.filter(
is_archived=False,
)
self.fields["from_category"].queryset = TransactionCategory.objects.filter(
active=True
)
self.fields["from_tags"].queryset = TransactionTag.objects.filter(active=True)
self.fields["to_account"].queryset = Account.objects.filter(
is_archived=False,
)
self.fields["to_category"].queryset = TransactionCategory.objects.filter(
active=True
)
self.fields["to_tags"].queryset = TransactionTag.objects.filter(active=True)
def clean(self):
cleaned_data = super().clean()
if cleaned_data.get("create_transaction"):
from_account = cleaned_data.get("from_account")
to_account = cleaned_data.get("to_account")
if not from_account and not to_account:
raise forms.ValidationError(
{
"from_account": _("You must provide an account."),
"to_account": _("You must provide an account."),
}
)
elif not from_account and to_account:
raise forms.ValidationError(
{"from_account": _("You must provide an account.")}
)
elif not to_account and from_account:
raise forms.ValidationError(
{"to_account": _("You must provide an account.")}
)
if from_account == to_account:
raise forms.ValidationError(
_("From and To accounts must be different.")
)
return cleaned_data
def save(self, **kwargs):
instance = super().save(commit=False)
if self.cleaned_data.get("create_transaction"):
from_account = self.cleaned_data["from_account"]
to_account = self.cleaned_data["to_account"]
from_amount = instance.amount_paid
to_amount = instance.amount_received
date = instance.date
description = _("DCA for %(strategy_name)s") % {
"strategy_name": self.strategy.name
}
from_category = self.cleaned_data.get("from_category")
to_category = self.cleaned_data.get("to_category")
notes = self.cleaned_data.get("notes")
# Create "From" transaction
from_transaction = Transaction.objects.create(
account=from_account,
type=Transaction.Type.EXPENSE,
is_paid=True,
date=date,
amount=from_amount,
description=description,
category=from_category,
notes=notes,
)
from_transaction.tags.set(self.cleaned_data.get("from_tags", []))
# Create "To" transaction
to_transaction = Transaction.objects.create(
account=to_account,
type=Transaction.Type.INCOME,
is_paid=True,
date=date,
amount=to_amount,
description=description,
category=to_category,
notes=notes,
)
to_transaction.tags.set(self.cleaned_data.get("to_tags", []))
instance.expense_transaction = from_transaction
instance.income_transaction = to_transaction
else:
if instance.expense_transaction:
instance.expense_transaction.amount = instance.amount_paid
instance.expense_transaction.save()
if instance.income_transaction:
instance.income_transaction.amount = instance.amount_received
instance.income_transaction.save()
instance.strategy = self.strategy
instance.save()
return instance

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
@@ -11,6 +10,8 @@ from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx
from apps.dca.forms import DCAEntryForm, DCAStrategyForm
from apps.dca.models import DCAStrategy, DCAEntry
from apps.common.models import SharedObject
from apps.common.forms import SharedObjectForm
@login_required
@@ -21,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}
)
@@ -57,6 +58,16 @@ def strategy_add(request):
def strategy_edit(request, strategy_id):
dca_strategy = get_object_or_404(DCAStrategy, id=strategy_id)
if dca_strategy.owner and dca_strategy.owner != request.user:
messages.error(request, _("Only the owner can edit this"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
if request.method == "POST":
form = DCAStrategyForm(request.POST, instance=dca_strategy)
if form.is_valid():
@@ -85,9 +96,15 @@ def strategy_edit(request, strategy_id):
def strategy_delete(request, strategy_id):
dca_strategy = get_object_or_404(DCAStrategy, id=strategy_id)
dca_strategy.delete()
messages.success(request, _("DCA strategy deleted successfully"))
if (
dca_strategy.owner != request.user
and request.user in dca_strategy.shared_with.all()
):
dca_strategy.shared_with.remove(request.user)
messages.success(request, _("Item no longer shared with you"))
else:
dca_strategy.delete()
messages.success(request, _("DCA strategy deleted successfully"))
return HttpResponse(
status=204,
@@ -97,6 +114,65 @@ def strategy_delete(request, strategy_id):
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def strategy_take_ownership(request, strategy_id):
dca_strategy = get_object_or_404(DCAStrategy, id=strategy_id)
if not dca_strategy.owner:
dca_strategy.owner = request.user
dca_strategy.visibility = SharedObject.Visibility.private
dca_strategy.save()
messages.success(request, _("Ownership taken successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def strategy_share(request, pk):
obj = get_object_or_404(DCAStrategy, id=pk)
if obj.owner and obj.owner != request.user:
messages.error(request, _("Only the owner can edit this"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
if request.method == "POST":
form = SharedObjectForm(request.POST, instance=obj, user=request.user)
if form.is_valid():
form.save()
messages.success(request, _("Configuration saved successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
else:
form = SharedObjectForm(instance=obj, user=request.user)
return render(
request,
"dca/fragments/strategy/share.html",
{"form": form, "object": obj},
)
@login_required
def strategy_detail_index(request, strategy_id):
strategy = get_object_or_404(DCAStrategy, id=strategy_id)
@@ -155,11 +231,9 @@ def strategy_detail(request, strategy_id):
def strategy_entry_add(request, strategy_id):
strategy = get_object_or_404(DCAStrategy, id=strategy_id)
if request.method == "POST":
form = DCAEntryForm(request.POST)
form = DCAEntryForm(request.POST, strategy=strategy)
if form.is_valid():
entry = form.save(commit=False)
entry.strategy = strategy
entry.save()
form.save()
messages.success(request, _("Entry added successfully"))
return HttpResponse(
@@ -169,7 +243,7 @@ def strategy_entry_add(request, strategy_id):
},
)
else:
form = DCAEntryForm()
form = DCAEntryForm(strategy=strategy)
return render(
request,

View File

View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ExportConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.export_app"

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