Compare commits

...

248 Commits
0.1.6 ... 0.8.0

Author SHA1 Message Date
Herculino Trotta
448841dadc Merge pull request #104 from eitchtee/dev
fix: wrong filename
2025-01-29 00:15:32 -03:00
Herculino Trotta
1b6934694e fix: wrong filename 2025-01-29 00:14:45 -03:00
Herculino Trotta
d4d00ba02f Merge pull request #103 from eitchtee/dev
feat: reduce db queries when saving order on session
2025-01-29 00:14:18 -03:00
Herculino Trotta
19a65ac45f feat: reduce db queries when saving order on session 2025-01-29 00:12:47 -03:00
Herculino Trotta
b72e7bd707 Merge pull request #102
docker: set single container as new default
2025-01-29 00:12:40 -03:00
Herculino Trotta
190be3e813 docker: set single container as new default 2025-01-29 00:11:39 -03:00
Herculino Trotta
88300b314c Merge pull request #101 from eitchtee/eitchtee-patch-1
Update release.yml
2025-01-28 23:47:34 -03:00
Herculino Trotta
fab77c8d9f Update release.yml 2025-01-28 23:47:18 -03:00
Herculino Trotta
1ae7158d7e Merge pull request #100 from eitchtee/dev
docker: fix permission error
2025-01-28 23:46:11 -03:00
Herculino Trotta
05f0356288 docker: fix permission error 2025-01-28 23:45:01 -03:00
Herculino Trotta
b3cea17b8d Merge pull request #99
docker: add single-container support
2025-01-28 23:35:08 -03:00
Herculino Trotta
0b66b23f16 docker: add single-container support 2025-01-28 23:34:48 -03:00
Herculino Trotta
80fdf70f7d Add a nightly docker tag built whenever there's a push to main 2025-01-28 23:13:23 -03:00
Herculino Trotta
fa931b0db2 Merge pull request #98
feat: cleanup expired sessions every first day of month at 6am
2025-01-28 21:33:00 -03:00
Herculino Trotta
cab79b4203 feat: cleanup expired sessions every first day of month at 6am 2025-01-28 21:32:41 -03:00
Herculino Trotta
ddab3db6b5 Merge pull request #97
feat(import:v1): accept list as source, first valid one will be used.
2025-01-28 21:24:44 -03:00
Herculino Trotta
9fa704811c feat(import:v1): accept list as source, first valid one will be used. 2025-01-28 21:24:23 -03:00
Herculino Trotta
4c0d14def0 Merge pull request #96
feat: store selected "order by" on session
2025-01-28 20:05:46 -03:00
Herculino Trotta
43382d2ffe feat: store selected "order by" on session
Closes #95
2025-01-28 20:05:00 -03:00
Herculino Trotta
27d448afd6 feat: add locale files for de (german) 2025-01-28 14:03:38 -03:00
Herculino Trotta
1dd90974bd Merge pull request #93
refactor: remove toasts from login screen
2025-01-28 13:54:20 -03:00
Herculino Trotta
31cc8db3ac refactor: remove toasts from login screen
Fixes #91
2025-01-28 13:53:47 -03:00
Herculino Trotta
3d85a15ec9 Merge pull request #90
feat: enable bulk actions on specific transactions list (calendar, recurring and installment)
2025-01-27 22:46:19 -03:00
Herculino Trotta
90f98c2d15 feat: enable bulk actions on specific transactions list (calendar, recurring and installment) 2025-01-27 22:45:40 -03:00
Herculino Trotta
643855e60e Merge pull request #89 from eitchtee/dev
fix(calendar): tooltip error when transaction has no description and wrong color
2025-01-27 22:44:43 -03:00
Herculino Trotta
e0f7b532f8 fix(calendar): tooltip error when transaction has no description and wrong color 2025-01-27 22:44:05 -03:00
Herculino Trotta
b4d3e4b42f Merge pull request #88 from eitchtee/dev
feat: add "Clear cache" button to user menu
2025-01-27 21:50:48 -03:00
Herculino Trotta
9a7ccb0973 feat: add "Clear cache" button to user menu 2025-01-27 21:49:32 -03:00
Herculino Trotta
a9b67ff272 Merge pull request #87
fix(security): toasts and month_year_picker accessible without login
2025-01-27 21:42:36 -03:00
Herculino Trotta
233b9629a2 fix(security): toasts and month_year_picker accessible without login 2025-01-27 21:41:55 -03:00
Herculino Trotta
4180c177f1 Merge pull request #86
fix: cleanup_deleted_transactions task couldn't trigger
2025-01-27 21:34:15 -03:00
Herculino Trotta
f1bc04756f fix: cleanup_deleted_transactions task couldn't trigger 2025-01-27 21:33:46 -03:00
Herculino Trotta
13795c797f Merge pull request #85
feat: add number format user setting and improve date format handling
2025-01-27 13:31:28 -03:00
Herculino Trotta
331a7d5b18 locale: update translations 2025-01-27 13:30:06 -03:00
Herculino Trotta
81b8da30d6 feat: add number_format to user_settings form 2025-01-27 13:26:08 -03:00
Herculino Trotta
80bad240e7 refactor: remove custom_date filter 2025-01-27 13:25:47 -03:00
Herculino Trotta
187c56c96c refactor: remove user attr from datepicker
since monkey patched get_format already does what we want
2025-01-27 13:25:06 -03:00
Herculino Trotta
3796112d77 feat: monkey patch get_format to return usersettings 2025-01-27 13:22:21 -03:00
Herculino Trotta
958940089a feat: add number_format user setting 2025-01-27 13:20:12 -03:00
Herculino Trotta
a08548bb13 feat: add local access to user and request from anywhere 2025-01-27 13:19:28 -03:00
Herculino Trotta
7fe446e510 refactor: remove custom_date filter 2025-01-27 13:18:57 -03:00
Herculino Trotta
eccb0d15ee Merge pull request #83 from eitchtee/eitchtee-patch-1
Update README.md
2025-01-26 21:03:45 -03:00
Herculino Trotta
7ebd329706 Update README.md 2025-01-26 21:03:14 -03:00
Herculino Trotta
d3fcd5fe7e Merge pull request #82
fix datepicker datetime handling and action-bar
2025-01-26 20:56:53 -03:00
Herculino Trotta
b0a3acbdde fix: transactions action bar error on page change 2025-01-26 20:56:03 -03:00
Herculino Trotta
33ce38d74c feat(datepicker): improve value handling 2025-01-26 20:54:29 -03:00
Herculino Trotta
fa51a7fef9 fix(datepicker): wrong datetime format 2025-01-26 20:53:16 -03:00
Herculino Trotta
d7c072a35c fix(currencies): don't error out if from_currency or to_currency isn't set 2025-01-26 20:52:47 -03:00
Herculino Trotta
c88a6dcf3a Update README.md 2025-01-26 11:49:28 -03:00
Herculino Trotta
fcb54a0af2 Merge pull request #79 from DragonHeart69/main
Add new Dutch translations for v0.7.2
2025-01-26 11:20:35 -03:00
Herculino Trotta
eec2ced481 refactor(settings): drop SQL_ENGINE env variable as only postgres is supported 2025-01-26 11:19:38 -03:00
Herculino Trotta
58a6048857 fix(settings): respect SQL_PORT env variable, defaulting to 5432 if not available 2025-01-26 11:17:38 -03:00
Herculino Trotta
93774cca64 docker: update python image from slim-buster to slim-bookworm 2025-01-26 11:16:39 -03:00
Dimitri Decrock
679f49badc Add new Dutch translations for v0.7.2 2025-01-26 13:37:06 +01:00
Herculino Trotta
b535a12014 feat: enable Dutch (Nederlands) language choice 2025-01-25 15:55:42 -03:00
Herculino Trotta
72876bff43 Merge pull request #76 from DragonHeart69/main
1st edition of the Dutch translation
2025-01-25 15:36:38 -03:00
Dimitri Decrock
4411022027 delete merge 2025-01-25 19:36:51 +01:00
Dimitri Decrock
086210b39d Merge branch 'eitchtee-main' 2025-01-25 19:29:07 +01:00
Dimitri Decrock
73cb2d861b update 2025-01-25 19:26:37 +01:00
Dimitri Decrock
1c479ef85a Merge branch 'main' of https://github.com/eitchtee/WYGIWYH into eitchtee-main 2025-01-25 19:25:56 +01:00
Dimitri Decrock
51b2b11825 final translation Dutch 1st publication 2025-01-25 18:44:53 +01:00
Herculino Trotta
c9d1b5b5f3 Merge pull request #75
locale: update locales
2025-01-25 13:55:09 -03:00
Herculino Trotta
a22a95cb9f locale: update locales 2025-01-25 13:54:10 -03:00
Herculino Trotta
5c46a2c15e feat: pluralize toast for bulk edit 2025-01-25 13:48:32 -03:00
Herculino Trotta
4f091c601e Merge pull request #73
feat: add bulk duplicate action and toasts for existing actions
2025-01-25 13:44:55 -03:00
Herculino Trotta
0fac78d15a feat: add bulk duplicate action and toasts for existing actions 2025-01-25 13:44:39 -03:00
Herculino Trotta
aa171c0e76 Merge pull request #72
fix: clear internal_id when duplicating
2025-01-25 13:42:54 -03:00
Herculino Trotta
73ca418dc8 fix: clear internal_id when duplicating 2025-01-25 13:42:23 -03:00
Herculino Trotta
7c34f36ffb Merge pull request #71 from eitchtee/dev
feat: tidy up transactions action bar
2025-01-25 12:44:48 -03:00
Herculino Trotta
2b6be8c6ac feat: tidy up transactions action bar 2025-01-25 12:43:53 -03:00
Herculino Trotta
f643c41cf1 Merge pull request #70
feat: bulk edit selected transactions
2025-01-25 12:42:36 -03:00
Herculino Trotta
1ef7a780fb feat: bulk edit selected transactions 2025-01-25 12:41:55 -03:00
Herculino Trotta
c3a753d221 Merge pull request #69 from eitchtee/dev
feat: add new animation to transactions action bar
2025-01-25 12:39:51 -03:00
Herculino Trotta
c474b6cda9 feat: add new animation to transactions action bar 2025-01-25 12:37:30 -03:00
Herculino Trotta
aff3aa7ed2 feat: add new animation to transactions action bar 2025-01-25 12:37:24 -03:00
Dimitri Decrock
414a9bb88a 4d part Dutch translation 2025-01-25 14:23:23 +01:00
Herculino Trotta
5f202a3820 Merge pull request #68
feat(transactions): proper clear button for filters
2025-01-25 01:30:43 -03:00
Herculino Trotta
e71775292a feat(transactions): proper clear button for filters 2025-01-25 01:30:24 -03:00
Herculino Trotta
01aa8acb71 Merge pull request #67 from eitchtee/dev
refactor: add end slashes for some urls without
2025-01-24 22:56:20 -03:00
Herculino Trotta
d030f9686b refactor: add end slashes for some urls without 2025-01-24 22:55:36 -03:00
Herculino Trotta
56d7e41bc5 Merge pull request #66
feat: add new /add/ endpoint for quickly adding new transactions
2025-01-24 22:52:17 -03:00
Herculino Trotta
0857b44fc3 feat: add new /add/ endpoint for quickly adding new transactions 2025-01-24 22:50:39 -03:00
Herculino Trotta
d4b5afd8b2 Merge pull request #65
fix(transactions): unaligned type button
2025-01-24 22:49:42 -03:00
Herculino Trotta
9c4ba3a6de fix(transactions): unaligned type button 2025-01-24 22:48:24 -03:00
Herculino Trotta
ec8b0e21d8 Merge pull request #63
feat(transactions): new is_paid switch
2025-01-24 22:47:20 -03:00
Herculino Trotta
6c60c3659c feat(transactions): new is_paid switch 2025-01-24 22:47:00 -03:00
Herculino Trotta
a040b8acd2 Merge pull request #62
fix(transactions:filter): unaligned filter buttons
2025-01-24 22:42:20 -03:00
Herculino Trotta
e72d6cd1ea fix(transactions:filter): unaligned filter buttons 2025-01-24 22:42:01 -03:00
Herculino Trotta
3fb670ef00 Merge pull request #61 from eitchtee/dev
locale: update translations
2025-01-24 16:31:30 -03:00
Herculino Trotta
b9cd97f0b8 locale: update translations and remove dutch from available languages until translation is done 2025-01-24 16:30:31 -03:00
Herculino Trotta
011e0ad7c9 Merge pull request #60 from eitchtee/dev
fix: import preset not working behind nginx due to long url/csrf missing
2025-01-24 16:08:32 -03:00
Herculino Trotta
97465c07fe fix: import preset not working behind nginx due to long url/csrf missing 2025-01-24 16:06:47 -03:00
Dimitri Decrock
f6d1a42b35 Merge branch 'eitchtee:main' into main 2025-01-24 19:22:03 +01:00
Dimitri Decrock
eb25f8aeb3 3d part Dutch translation 2025-01-24 19:22:01 +01:00
Herculino Trotta
36cbe2935a Merge pull request #59
feat(pwa): better offline page and offline
2025-01-24 14:25:57 -03:00
Herculino Trotta
dbea78cd3c feat(pwa): better offline page and offline request handler 2025-01-24 14:22:30 -03:00
Herculino Trotta
d50c84f8e6 refactor: remove debug prints 2025-01-24 00:36:33 -03:00
Herculino Trotta
f2d32fd7e9 feat(import): final changes for release 2025-01-23 23:52:54 -03:00
Herculino Trotta
53175aacb9 feat(import:templates): change wrong name 2025-01-23 22:49:09 -03:00
Herculino Trotta
1dc03b0a84 feat(import:v1:service): respect create and type fields 2025-01-23 22:48:23 -03:00
Herculino Trotta
ba2d654f15 feat(accounts): make account names unique 2025-01-23 22:03:02 -03:00
Herculino Trotta
93d04572df feat(accounts): make account names unique 2025-01-23 22:02:45 -03:00
Herculino Trotta
38379ab2b1 feat(import): try to be more aggressive on cache invalidation 2025-01-23 21:12:13 -03:00
Herculino Trotta
928ad33111 feat(import): move required field check to end of process 2025-01-23 21:09:53 -03:00
Herculino Trotta
d0172b5524 feat(import): convert deduplicate fields field into list 2025-01-23 21:09:21 -03:00
Herculino Trotta
e4a2b83c83 feat: add new envs 2025-01-23 21:08:12 -03:00
Herculino Trotta
1c28dd5513 feat(import): show error if YAML is invalid 2025-01-23 21:08:03 -03:00
Herculino Trotta
1c713fac19 feat(import): add Nuconta preset 2025-01-23 21:07:48 -03:00
Herculino Trotta
096f24e0a2 feat(import): cleanup 2025-01-23 16:32:08 -03:00
Herculino Trotta
f1cd658972 Merge pull request #58
feat: beta import function
2025-01-23 14:34:02 -03:00
Herculino Trotta
a85221468a Merge remote-tracking branch 'origin/main' into 41-import-export-function
# Conflicts:
#	app/WYGIWYH/settings.py
2025-01-23 14:32:16 -03:00
Herculino Trotta
e3d3a7cf91 feat: add new envs 2025-01-23 14:30:59 -03:00
Herculino Trotta
4ef4609a96 fix(navbar): wrong active link for navbar import item 2025-01-23 14:24:31 -03:00
Herculino Trotta
962a8efa26 feat(navbar): add import to management menu 2025-01-23 14:04:58 -03:00
Herculino Trotta
d7de6c17a9 refactor: remove django-ace for now 2025-01-23 14:04:40 -03:00
Herculino Trotta
a805880e9b git: keep import_presets folder 2025-01-23 12:55:01 -03:00
Herculino Trotta
aaee602b71 refactor: remove django-ace for now 2025-01-23 12:54:26 -03:00
Herculino Trotta
7635b66638 Merge pull request #57
feat: PWA support
2025-01-23 12:50:17 -03:00
Herculino Trotta
bcc96588bf feat: PWA support 2025-01-23 12:49:50 -03:00
Herculino Trotta
cabd03e7e6 feat: presets 2025-01-23 11:43:35 -03:00
Dimitri Decrock
2ee64a534e 2nd part Dutch translation 2025-01-23 07:13:15 +01:00
Dimitri Decrock
14073d3555 Start with Dutch translation 2025-01-22 19:36:13 +01:00
Herculino Trotta
16fbead2f9 Merge remote-tracking branch 'origin/41-import-export-function' into 41-import-export-function 2025-01-22 10:44:36 -03:00
Herculino Trotta
ece44f2726 feat(import): more UI and endpoints 2025-01-22 10:43:19 -03:00
Herculino Trotta
a415e285ee feat(transactions): make deleted_at readonly on admin 2025-01-22 10:43:18 -03:00
Herculino Trotta
00b8727664 feat(transactions): add internal_id field to transactions 2025-01-22 10:43:18 -03:00
Herculino Trotta
6f096fd3ff feat(import): some views and urls 2025-01-22 10:43:18 -03:00
Herculino Trotta
07fcbe1f45 feat(import): some layouts 2025-01-22 10:43:18 -03:00
Herculino Trotta
0f14fd0c62 feat(import): test yaml_config before saving 2025-01-22 10:43:18 -03:00
Herculino Trotta
61d5aba67c feat(import): some layouts 2025-01-22 10:43:18 -03:00
Herculino Trotta
76df16e489 feat(import:v1:schema): add option for triggering rules 2025-01-22 10:43:18 -03:00
Herculino Trotta
34e6914d41 feat(transactions:tasks): add old deleted transactions cleanup task 2025-01-22 10:43:18 -03:00
Herculino Trotta
f2cc070505 feat(settings): add KEEP_DELETED_TRANSACTIONS_FOR variable 2025-01-22 10:43:18 -03:00
Herculino Trotta
18d8e8ed1a feat(import): add migrations 2025-01-22 10:43:18 -03:00
Herculino Trotta
2ff33526ae feat(import): disable cache when running 2025-01-22 10:43:18 -03:00
Herculino Trotta
8a127a9f4f feat(transactions): soft delete 2025-01-22 10:43:17 -03:00
Herculino Trotta
a52f682c4f feat(transactions): soft delete 2025-01-22 10:43:17 -03:00
Herculino Trotta
3440d4405e docker: add temp volume 2025-01-22 10:43:17 -03:00
Herculino Trotta
87345cf235 docs(requirements): add django_ace 2025-01-22 10:43:17 -03:00
Herculino Trotta
50efc51f87 feat(import): improve schema definition 2025-01-22 10:43:17 -03:00
Herculino Trotta
493bf268bb feat: rename app, some work on schema 2025-01-22 10:43:17 -03:00
Herculino Trotta
8992cd98b5 feat: add import app boilerplate 2025-01-22 10:43:17 -03:00
Herculino Trotta
f7c3a2f320 locale: add nl (Dutch) language files 2025-01-22 10:21:35 -03:00
Herculino Trotta
d96787cfeb feat(import): more UI and endpoints 2025-01-22 01:41:17 -03:00
Herculino Trotta
32b5864736 feat(transactions): make deleted_at readonly on admin 2025-01-20 23:10:11 -03:00
Herculino Trotta
02adfd828a feat(transactions): add internal_id field to transactions 2025-01-20 23:09:49 -03:00
Herculino Trotta
c14b666921 Merge pull request #54 from eitchtee/datepicker_today_button
feat(datepicker): bring back today/now button behavior
2025-01-20 22:15:44 -03:00
Herculino Trotta
5d2b9ae0b3 locale(pt-BR): update translation 2025-01-20 22:14:42 -03:00
Herculino Trotta
d5dfe5bba0 feat(datepicker): bring back today/now button behavior 2025-01-20 22:14:36 -03:00
Herculino Trotta
72ceec7452 Merge pull request #53 from eitchtee/50-date-notation
fix(datepicker): missing leading zeros on times
2025-01-20 21:49:14 -03:00
Herculino Trotta
eae0e00d1f fix(datepicker): missing leading zeros on times 2025-01-20 21:48:09 -03:00
Herculino Trotta
cc0125241f Merge pull request #52
locale(pt-BR): update translation
2025-01-20 19:47:26 -03:00
Herculino Trotta
e3bab503a0 locale(pt-BR): update translation 2025-01-20 19:46:50 -03:00
Herculino Trotta
c089c49b7d refactor: remove debug print 2025-01-20 19:40:33 -03:00
Herculino Trotta
b18273a562 Merge pull request #51
feat(app): allow changing date and datetime format as a user setting
2025-01-20 19:36:13 -03:00
Herculino Trotta
60fe4c9681 feat(app): allow changing date and datetime format as a user setting 2025-01-20 19:35:22 -03:00
Herculino Trotta
0fccdbe573 feat(import): some views and urls 2025-01-20 14:31:12 -03:00
Herculino Trotta
b9810ce062 feat(import): some layouts 2025-01-20 14:30:59 -03:00
Herculino Trotta
4cc32e3f57 feat(import): test yaml_config before saving 2025-01-20 14:30:40 -03:00
Herculino Trotta
8db13b082b feat(import): some layouts 2025-01-20 14:30:17 -03:00
Herculino Trotta
e73e1dfc25 feat(import:v1:schema): add option for triggering rules 2025-01-19 15:20:25 -03:00
Herculino Trotta
ae91c51967 feat(transactions:tasks): add old deleted transactions cleanup task 2025-01-19 15:17:18 -03:00
Herculino Trotta
3ef6b0ac5c feat(settings): add KEEP_DELETED_TRANSACTIONS_FOR variable 2025-01-19 15:16:47 -03:00
Herculino Trotta
ba0c54767c feat(import): add migrations 2025-01-19 13:56:29 -03:00
Herculino Trotta
2d8864773c feat(import): disable cache when running 2025-01-19 13:56:13 -03:00
Herculino Trotta
f96d8d2862 feat(transactions): soft delete 2025-01-19 13:55:25 -03:00
Herculino Trotta
3ccb0e19eb feat(transactions): soft delete 2025-01-19 13:55:17 -03:00
Herculino Trotta
238f205513 docker: add temp volume 2025-01-19 11:47:33 -03:00
Herculino Trotta
a94e0b4904 docs(requirements): add django_ace 2025-01-19 11:45:06 -03:00
Herculino Trotta
86dac632c4 feat(import): improve schema definition 2025-01-19 11:27:14 -03:00
Herculino Trotta
f68e954bc0 Merge remote-tracking branch 'origin/main' 2025-01-18 00:00:19 -03:00
Herculino Trotta
404036bafa feat(readme): add guide to build from source 2025-01-17 23:59:55 -03:00
Herculino Trotta
5e8074ea01 fix(readme): wrong comment about running app 2025-01-17 23:59:25 -03:00
Herculino Trotta
c9cc942a10 Merge pull request #46
feat: add a duplicate/clone action to each transaction
2025-01-17 23:54:04 -03:00
Herculino Trotta
315f4e1269 feat: add a duplicate/clone action to each transaction 2025-01-17 23:53:39 -03:00
Herculino Trotta
fbb26b8442 feat: rename app, some work on schema 2025-01-17 17:40:51 -03:00
Herculino Trotta
c171e0419a feat: add import app boilerplate 2025-01-16 14:09:33 -03:00
Herculino Trotta
b025ab7d24 docs: add more screenshots 2025-01-16 10:17:13 -03:00
Herculino Trotta
e2134e98a5 docs: Add information about running locally 2025-01-16 10:06:18 -03:00
Herculino Trotta
3f250338a3 Merge pull request #44 from eitchtee/new_datepicker
docker: remove YAML anchor and merge directives from docker-compose.prod.yml
2025-01-16 09:24:06 -03:00
Herculino Trotta
97c6b13d57 security: actually use SECRET_KEY env variable. You will get logged out. 2025-01-16 09:23:18 -03:00
Herculino Trotta
3dcee4dbf2 docker: remove YAML anchor and merge directives from docker-compose.prod.yml
Fixes #42
2025-01-16 09:22:08 -03:00
Herculino Trotta
09d14b44fe Merge pull request #39 from eitchtee/dev
feat(transactions): make description optional
2025-01-14 23:49:45 -03:00
Herculino Trotta
a5b78f7c83 Merge pull request #40 from eitchtee/new_datepicker
feat(datepicker): drop native datepickers in favor of AirDatePicker for better compatibility
2025-01-14 23:49:27 -03:00
Herculino Trotta
9543881aae Merge pull request #38 from eltociear/patch-1
docs: update README.md
2025-01-14 23:49:06 -03:00
Herculino Trotta
6955294283 feat(datepicker): drop native datepickers in favor of AirDatePicker for better compatibility
As Firefox (still) doesn't support month input type
2025-01-14 23:47:03 -03:00
Herculino Trotta
2b6a73af18 feat(transactions): make description optional 2025-01-14 10:04:46 -03:00
Ikko Eltociear Ashimine
526c2cb191 docs: update README.md
perfomance -> performance
2025-01-14 15:05:46 +09:00
Herculino Trotta
4fe62244cd docs(README): update README 2025-01-11 20:22:29 -03:00
Herculino Trotta
011e926e02 Merge pull request #37
locale(pt-BR): update translation
2025-01-11 13:42:11 -03:00
Herculino Trotta
cd1b872b27 locale(pt-BR): update translation 2025-01-11 13:41:40 -03:00
Herculino Trotta
3791edce63 Merge pull request #36
feat(recurring-transaction): when explicitly finishing, delete any upcoming unpaid transactions
2025-01-11 13:40:28 -03:00
Herculino Trotta
2cb8100129 feat(recurring-transaction): when explicitly finishing, delete any upcoming unpaid transactions 2025-01-11 13:40:10 -03:00
Herculino Trotta
e7e4ccafb6 Merge pull request #35 from eitchtee/dev
feat(recurring-transaction): when unpause start generating transactions from today or from existing date, whichever is higher
2025-01-11 13:39:26 -03:00
Herculino Trotta
afbbf7b25d feat(recurring-transaction): when unpause start generating transactions from today or from existing date, whichever is higher 2025-01-11 13:38:51 -03:00
Herculino Trotta
1eba2b8731 Merge pull request #34 from eitchtee/dev
feat(installment-plan): don't update paid transactions amount
2025-01-11 13:37:19 -03:00
Herculino Trotta
afe366c359 feat(installment-plan): don't update paid transactions amount 2025-01-11 13:35:52 -03:00
Herculino Trotta
3ee2bebc5c Merge pull request #33
feat(recurring-transaction): update unpaid transactions info when recurring transaction is updated
2025-01-11 13:35:14 -03:00
Herculino Trotta
b951e5f069 feat(recurring-transaction): update unpaid transactions info when recurring transaction is updated 2025-01-11 13:34:49 -03:00
Herculino Trotta
4005a83a0d Merge pull request #32
fix(calculator): rounding errors
2025-01-07 16:17:00 -03:00
Herculino Trotta
f81f1d83fd fix(calculator): rounding errors 2025-01-07 16:16:26 -03:00
Herculino Trotta
7816d6c55d Merge pull request #31
fix(transactions:action-bar): rounding errors when summing (again)
2025-01-06 00:50:41 -03:00
Herculino Trotta
6e3fdae4fe fix(transactions:action-bar): rounding errors when summing (again) 2025-01-06 00:50:17 -03:00
Herculino Trotta
e2da996217 Merge pull request #30
fix(networth): chart initializing multiple times resulting in weird animation
2025-01-06 00:14:48 -03:00
Herculino Trotta
cc2e2293ed fix(networth): chart initializing multiple times resulting in weird animation 2025-01-06 00:14:15 -03:00
Herculino Trotta
7060f07ccd Merge pull request #29
feat(calculator): localize result
2025-01-06 00:14:12 -03:00
Herculino Trotta
0adb991879 feat(calculator): localize result 2025-01-06 00:13:47 -03:00
Herculino Trotta
20e03df661 Merge pull request #28
fix(transactions:action-bar): rounding errors when summing
2025-01-06 00:11:55 -03:00
Herculino Trotta
71f59bfd68 fix(transactions:action-bar): rounding errors when summing 2025-01-06 00:10:40 -03:00
Herculino Trotta
6c76535f91 Merge pull request #27
fix(transactions:action-bar): min and max calculations take into account if value is income or expense
2025-01-05 15:53:21 -03:00
Herculino Trotta
5c8fbc9278 fix(transactions:action-bar): min and max calculations take into account if value is income or expense 2025-01-05 15:52:58 -03:00
Herculino Trotta
89b11421c2 Merge pull request #26
feat(transactions:action-bar): localize calculation results
2025-01-05 15:42:51 -03:00
Herculino Trotta
056fc4fced feat(transactions:action-bar): localize calculation results 2025-01-05 15:42:28 -03:00
Herculino Trotta
3f9765ec7b Merge pull request #25
refactor(transactions:action-bar): remove debug log
2025-01-05 15:22:52 -03:00
Herculino Trotta
0d9d13bf31 refactor(transactions:action-bar): remove debug log 2025-01-05 15:22:18 -03:00
Herculino Trotta
2f6c396eaf Merge pull request #24
fix(transactions:action-bar): sum button not copying correctly
2025-01-05 15:20:24 -03:00
Herculino Trotta
d12b920e54 fix(transactions:action-bar): sum button not copying correctly 2025-01-05 15:19:58 -03:00
Herculino Trotta
9edbf7bd5a Merge pull request #23
feat(transactions:action-bar): add more math options in a dropdown
2025-01-05 14:36:07 -03:00
Herculino Trotta
dbd3eea29a locale(pt-BR): update translation 2025-01-05 14:35:33 -03:00
Herculino Trotta
881fed1895 feat(transactions:action-bar): add more math options in a dropdown 2025-01-05 14:35:23 -03:00
Herculino Trotta
10a0ac42a2 Merge pull request #22
feat(api): add RecurringTransaction and InstallmentPlan endpoints
2025-01-05 11:14:03 -03:00
Herculino Trotta
1b47c12a22 feat(api): add RecurringTransaction and InstallmentPlan endpoints 2025-01-05 11:13:23 -03:00
Herculino Trotta
091f73bf8d feat(api): support string name and ids for installmentplan endpoint 2025-01-05 11:07:38 -03:00
Herculino Trotta
73fe17de64 feat(api): add auth permission to all api endpoint 2025-01-05 11:04:50 -03:00
Herculino Trotta
52af1b2260 Merge pull request #21
feat(api): add API endpoints to add DCA entries and strategies
2025-01-05 10:54:55 -03:00
Herculino Trotta
8efa087aee feat(api): add API endpoints to add DCA entries and strategies 2025-01-05 10:54:31 -03:00
Herculino Trotta
6f69f15474 Merge pull request #20
feat: archived tabs for categories, tags and entities
2025-01-05 01:46:01 -03:00
Herculino Trotta
905e80cffe fix: overflowing empty message 2025-01-05 01:45:11 -03:00
Herculino Trotta
baae6bb96a feat(entities): add tab to show archived entities 2025-01-05 01:43:24 -03:00
Herculino Trotta
f5132e24bd feat(tags): add tab to show archived tags 2025-01-05 01:36:30 -03:00
Herculino Trotta
41303f39a0 fix: typo 2025-01-05 01:35:34 -03:00
Herculino Trotta
0fc8b0ee49 feat(tags): add tab to show archived tags 2025-01-05 01:35:25 -03:00
Herculino Trotta
037014d024 feat(categories): add tab to show archived categories 2025-01-05 01:22:14 -03:00
Herculino Trotta
8c5a9efe05 Merge pull request #19 from eitchtee/dev
locale(pt-BR): update translation
2025-01-04 18:24:47 -03:00
Herculino Trotta
f940414b5c locale(pt-BR): update translation 2025-01-04 18:23:01 -03:00
Herculino Trotta
2d8e97a27e Merge pull request #18
feat: allow for deactivating Tags, Categories and Entities, hiding them from menus
2025-01-04 18:17:42 -03:00
Herculino Trotta
5ccb9ff152 locale: add lazy translations to missing ValidationErrors 2025-01-04 18:17:06 -03:00
Herculino Trotta
3c0a2d82ac feat: allow for deactivating Tags, Categories and Entities, hiding them from menus 2025-01-04 18:13:11 -03:00
Herculino Trotta
62f049cbb2 Merge pull request #17
feat(fields:forms:dynamic-select): support existing objects not currently on the queryset
2025-01-04 18:00:33 -03:00
Herculino Trotta
7a759be357 feat(fields:forms:dynamic-select): support existing objects not currently on the queryset
and add create_field to DynamicModelChoiceField
2025-01-04 17:59:59 -03:00
Herculino Trotta
6297e73307 Merge pull request #16
feat(transactions): properly sum income and expense when selected
2025-01-04 01:33:05 -03:00
Herculino Trotta
eb753bb30e feat(transactions): properly sum income and expense when selected
also added a flatTotal (old behavior) for future use
2025-01-04 01:32:09 -03:00
Herculino Trotta
1047fb23dd fix(networth): charts not changing between views 2025-01-03 17:50:41 -03:00
Herculino Trotta
c861b9ae07 fix(networth): charts not changing between views 2025-01-03 17:36:10 -03:00
Herculino Trotta
4be849f5de github(release): add gha cache 2024-12-28 02:42:43 -03:00
Herculino Trotta
3e73332a93 locale(pt-BR): update translation 2024-12-28 02:32:43 -03:00
Herculino Trotta
ae2217e760 feat(tools:currency-converter): add a button to invert the selected currencies 2024-12-28 00:56:15 -03:00
Herculino Trotta
e2bf699be0 feat(tools:currency-converter): make it responsive 2024-12-28 00:56:15 -03:00
166 changed files with 11138 additions and 857 deletions

View File

@@ -9,7 +9,6 @@ SECRET_KEY=<GENERATE A SAFE SECRET KEY AND PLACE IT HERE>
DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1]
OUTBOUND_PORT=9005
SQL_ENGINE=django.db.backends.postgresql
SQL_DATABASE=wygiwyh
SQL_USER=wygiwyh
SQL_PASSWORD=<INSERT A SAFE PASSWORD HERE>
@@ -18,3 +17,11 @@ SQL_PORT=5432
# Gunicorn
WEB_CONCURRENCY=4
# App Configs
# Enable this if you want to keep deleted transactions in the database
ENABLE_SOFT_DELETE=false
# If ENABLE_SOFT_DELETE is true, transactions deleted for more than KEEP_DELETED_TRANSACTIONS_FOR days will be truly deleted. Set to 0 to keep all.
KEEP_DELETED_TRANSACTIONS_FOR=365
TASK_WORKERS=1 # This only work if you're using the single container option. Increase to have more open queues via procrastinate, you probably don't need to increase this.

BIN
.github/img/all_transactions.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

BIN
.github/img/calendar.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
.github/img/monthly_view.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

BIN
.github/img/networth.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

BIN
.github/img/yearly.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -3,6 +3,8 @@ name: Release Pipeline
on:
release:
types: [created]
push:
branches: [ main ]
env:
IMAGE_NAME: wygiwyh
@@ -29,15 +31,21 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Cache Docker layers
uses: actions/cache@v3
- name: Build and push nightly image
if: github.event_name == 'push'
uses: docker/build-push-action@v6
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
context: .
file: ./docker/prod/django/Dockerfile
push: true
provenance: false
tags: ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:nightly
platforms: linux/amd64,linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push image
- name: Build and push release image
if: github.event_name == 'release'
uses: docker/build-push-action@v6
with:
context: .
@@ -48,10 +56,5 @@ jobs:
${{ 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=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
- name: Move cache
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
cache-from: type=gha
cache-to: type=gha,mode=max

273
README.md
View File

@@ -6,17 +6,21 @@
<br>
</h1>
<h4 align="center">An optionated and powerful finance tracker.</h4>
<h4 align="center">An opinionated and powerful finance tracker.</h4>
<p align="center">
<a href="#why-wygiwyh">Why</a> •
<a href="#key-features">Features</a> •
<a href="#how-to-use">Usage</a> •
<a href="#how-it-works">How</a>
<a href="#how-it-works">How</a>
<a href="#caveats-and-warnings">Caveats and Warnings</a> •
<a href="#built-with">Built with</a>
</p>
**WYGIWYH** (_What You Get Is What You Have_) is a powerful, principles-first finance tracker designed for people who prefer a no-budget, straightforward approach to managing their money. With features like multi-currency support, customizable transactions, and a built-in dollar-cost averaging tracker, WYGIWYH helps you take control of your finances with simplicity and flexibility.
<img src=".github/img/monthly_view.png" width="18%"></img> <img src=".github/img/yearly.png" width="18%"></img> <img src=".github/img/networth.png" width="18%"></img> <img src=".github/img/calendar.png" width="18%"></img> <img src=".github/img/all_transactions.png" width="18%"></img>
# Why WYGIWYH?
Managing money can feel unnecessarily complex, but it doesnt have to be. WYGIWYH (pronounced "wiggy-wih") is based on a simple principle:
@@ -53,10 +57,10 @@ To run this application, you'll need [Docker](https://docs.docker.com/engine/ins
From your command line:
```bash
# Clone this repository
# Create a folder for WYGIWYH (optional)
$ mkdir WYGIWYH
# Go into the repository
# Go into the folder
$ cd WYGIWYH
$ touch docker-compose.yml
@@ -75,6 +79,33 @@ $ docker compose up -d
$ docker compose exec -it web python manage.py createsuperuser
```
## Running locally
If you want to run WYGIWYH locally, on your env file:
1. Remove `URL`
2. Set `HTTPS_ENABLED` to `false`
3. Leave the default `DJANGO_ALLOWED_HOSTS` (localhost 127.0.0.1 [::1])
You can now access localhost:OUTBOUND_PORT
> [!NOTE]
> If you're planning on running this behind Tailscale or other similar service also add your machine given IP to `DJANGO_ALLOWED_HOSTS`
> [!NOTE]
> If you're going to use another IP that isn't localhost, add it to `DJANGO_ALLOWED_HOSTS`, without `http://`
## Building from source
Features are only added to main when ready, if you want to run the latest version, you must build from source.
All the required Dockerfiles are [here](https://github.com/eitchtee/WYGIWYH/tree/main/docker/prod).
## Unraid
[nwithan8](https://github.com/nwithan8) has kindly provided a Unraid template for WYGIWYH, have a look at the [unraid_templates](https://github.com/nwithan8/unraid_templates) repo.
WYGIWYH and WYGIWYH--Procrastinate should be available on the Unraid Store. You need both for all features.
# How it works
## Models
@@ -210,35 +241,61 @@ A Recurring Transaction is a helper model that generates recurring transactions
### Account
TO-DO
Accounts represent different financial entities where transactions occur. They have the following attributes:
- **Name**: A unique identifier for the account.
- **Group**: An optional [account group](#account-groups) the account belongs to for organizational purposes.
- **Currency**: The primary [currency](#currency) of the account.
- **Exchange Currency**: An optional currency used for exchange rate calculations.
- **Is Asset**: A boolean indicating if the account is considered an asset (counts towards net worth).
- **Is Archived**: A boolean indicating if the account is archived (doesn't show up in active lists or count towards net worth).
### Account Groups
TO-DO
Account Groups are used to organize accounts into logical categories. They consist of:
- **Name**: A unique identifier for the group.
### Currency
TO-DO
Currencies represent different monetary units. They include:
* **Code**: A unique identifier for the currency (e.g., USD, EUR).
* **Name**: The full name of the currency.
* **Decimal Place**: The number of decimal places used for the currency.
* **Prefix**: An optional symbol or text that comes before the amount.
* **Suffix**: An optional symbol or text that comes after the amount.
### Exchange Rate
TO-DO
Exchange Rates store conversion rates between currencies:
* **From Currency**: The source currency.
* **To Currency**: The target currency.
* **Rate**: The conversion rate.
* **Date**: The date the rate was recorded or is valid for.
### Category
TO-DO
Categories are used to classify transactions:
* **Name**: A unique identifier for the category.
* **Muted**: Muted categories won't count towards your monthly total.
* **Active**: A boolean indicating if the category is currently in use. This will disable its use on new transactions.
### Tag
TO-DO
Tags provide additional labeling for transactions:
* **Name**: A unique identifier for the tag.
* **Active**: A boolean indicating if the category is currently in use. This will disable its use on new transactions.
### Entity
TO-DO
Entities represent parties involved in transactions:
### Rule
TO-DO
* **Name**: A unique identifier for the entity.
* **Active**: A boolean indicating if the entity is currently in use. This will disable its use on new transactions.
---
@@ -264,37 +321,98 @@ This can be useful for savings accounts or other interest accruing investments.!
### Monthly
TO-DO
The Monthly view provides an overview of your financial activity for a specific month. It includes:
* Total income and expenses for the month
* Daily spending allowance calculation
* List of transactions for the month
> [!NOTE]
> Reference dates are taken into account here.
### Yearly by currency
TO-DO
This view gives you a yearly summary of your finances grouped by currency. It shows:
* Total income and expenses for each currency
* Monthly breakdown of income and expenses
### Yearly by account
TO-DO
Similar to the [yearly by currency](#yearly-by-currency) view, but groups the data by account instead.
### Calendar
TO-DO
The Calendar view presents your transactions in a monthly calendar format, allowing you to see your financial activity day by day. It includes:
* Visual representation of daily transaction totals
* Ability to view details of transactions for each day
> [!NOTE]
> Reference dates are **not** taken into account here.
### Networh
#### Current
TO-DO
The Current Net Worth view shows your present financial standing, including:
* Total value of all asset accounts
* Breakdown of assets by account and currency
* Historical net worth trend
#### Projected
TO-DO
The Projected Net Worth view estimates your future financial position based on current data and recurring transactions. It includes:
* Your total net worth with projected and current transactions
* Breakdown of assets by account and currency
* Historical and future net worth trend
### All Transactions
TO-DO
This view provides a comprehensive list of all transactions across all accounts. Features include:
* Advanced filtering and sorting options
* Detailed information
You can use this to see how much you spent on a given category, or a given day, etc..
### Configuration and Management
TO-DO
#### Management
The Management section in the navbar allows you to add and edit most elements of WYGIWYH, including:
* Accounts and Groups
* Currencies and Exchange Rates
* Categories, Tags and Entities
* Rules
#### User Settings
WYGIWYH allows users to personalize their experience through customizable settings. Each user can configure:
* **Language**: Choose your preferred interface language.
* **Timezone**: Set your local timezone for accurate date and time display.
* **Start Page**: Select which page you want to see first when you log in.
* **Sound Preferences**: Toggle sound effects on or off.
* **Amount Display**: Choose to show or hide monetary amounts by default.
To access and modify these settings:
1. Click on your username in the top-right corner of the page.
2. Select "Settings" from the dropdown menu.
3. Adjust your preferences as desired.
4. Click "Save" to apply your changes.
These settings ensure that WYGIWYH adapts to your personal preferences and working style.
#### Django Admin
From here you can also access Django's own admin site.
> [!WARNING]
> Most side effects aren't triggered from the admin.
> Only use it if you know what you're doing or were told by a developer to do so.
---
@@ -302,7 +420,7 @@ TO-DO
### Calculator
The calculator is a floating widget that can be toggled by clicking the calculator icon on the navbar.
The calculator is a floating widget that can be toggled by clicking the calculator icon on the navbar or by pressing <kbd>Alt</kbd> + <kbd>C</kbd> on any page.
It allows for any math expression supported by [math.js](https://mathjs.org).
@@ -336,16 +454,109 @@ You can add additional items by clicking the _Add_ button at the end of the page
### Currency Converter
TO-DO
The Currency Converter is a tool that allows you to quickly convert amounts between different currencies.
> [!NOTE]
> There's no external Exchange Rate fetching. This uses the Exchange Rates configured in the [Management](#configuration-and-management) page for [Exchange Rates](#exchange-rate)
## Automation
### API
WYGIWYH has a comprehensive API, it's documentation can be accessed on `<your-wygiwyh-url>/api/docs/`
> [!NOTE]
> While the API works, there's still much to be added to it to equipare functionality with the main web app.
### Transaction Rules
Transaction Rules are a powerful feature in WYGIWYH that allow for automatic modification of transactions based on specified criteria. This can save time and ensure consistency in your financial tracking.
Key Aspects of Transaction Rules:
* **Conditions**: Set specific criteria that a transaction must meet for the rule to apply. This can include attributes like description, amount, account, etc.
* **Actions**: Define what changes should be made to a transaction when the conditions are met. This can include setting categories, tags, or modifying other fields.
* **Activation Options**: Rules can be set to apply when transactions are created, updated, or both.
#### Actions and Conditions
When creating a new rule, you will need to add a Condition and, later, Actions.
Both use a limited subset of Python, via [SimpleEval](https://github.com/danthedeckie/simpleeval).
The Condition must evaluate to True or False, and the Action must evaluate to a value that will be set on the selected field.
You may use any of the available [variables](#available-variables) and [functions](#available-functions).
#### Available variables
* `account_name`
* `account_id`
* `account_group_name`
* `account_group_id`
* `is_asset_account`
* `is_archived_account`
* `category_name`
* `category_id`
* `tag_names`
* `tag_ids`
* `entities_names`
* `entities_ids`
* `is_expense`
* `is_income`
* `is_paid`
* `description`
* `amount`
* `notes`
* `date`
* `reference_date`
#### Available functions
* `relativedelta`
#### Examples
Add a tag to an income transaction if it happens in a specific account
```
If...
account_name == "My Investing Account" and is_income
Then...
Set Tags to
tag_names + ["Yield"]
```
---
Move credit card transactions to next month when they happen at a cutoff date
```
If...
account_name == "My credit card" and date.day >= 26 and reference_date.month == date.month
Then...
Set Reference Date to
reference_date + relativedelta(months=1)).replace(day=1)
```
# Caveats and Warnings
- I'm not an accountant, some terms and even calculations might be wrong. Make sure to open an issue if you see anything that could be improved.
- Pretty much all calculations are done at run time, this can lead to some performance degradation. On my personal instance, I have 3000+ transactions over 4+ years and 4000+ exchange rates, and load times average at around 500ms for each page, not bad overall.
- This isn't a budgeting or double-entry-accounting application, if you need those features there's a lot of options out there, if you really need them in WYGIWYH, open a discussion.
# Built with
WYGIWYH is possible thanks to a lot of amazing open source tools, to name a few:
- Django
- HTMX
- _hyperscript
- Procrastinate
- Bootstrap
- Tailwind
- Webpack
* Django
* HTMX
* _hyperscript
* Procrastinate
* Bootstrap
* Tailwind
* Webpack
* PostgreSQL
* Django REST framework
* Alpine.js

View File

@@ -26,7 +26,7 @@ ROOT_DIR = Path(__file__).resolve().parent.parent.parent
# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = "django-insecure-##6^&g49xwn7s67xc&33vf&=*4ibqfzn#xa*p-1sy8ag+zjjb9"
SECRET_KEY = os.getenv("SECRET_KEY", "")
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
@@ -64,6 +64,7 @@ INSTALLED_APPS = [
"apps.accounts.apps.AccountsConfig",
"apps.common.apps.CommonConfig",
"apps.net_worth.apps.NetWorthConfig",
"apps.import_app.apps.ImportConfig",
"apps.api.apps.ApiConfig",
"cachalot",
"rest_framework",
@@ -72,9 +73,11 @@ INSTALLED_APPS = [
"apps.rules.apps.RulesConfig",
"apps.calendar_view.apps.CalendarViewConfig",
"apps.dca.apps.DcaConfig",
"pwa",
]
MIDDLEWARE = [
"apps.common.middleware.thread_local.ThreadLocalMiddleware",
"debug_toolbar.middleware.DebugToolbarMiddleware",
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
@@ -124,12 +127,12 @@ WSGI_APPLICATION = "WYGIWYH.wsgi.application"
DATABASES = {
"default": {
"ENGINE": os.environ.get("SQL_ENGINE", "django.db.backends.sqlite3"),
"NAME": os.environ.get("SQL_DATABASE", BASE_DIR / "db.sqlite3"),
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ.get("SQL_DATABASE"),
"USER": os.environ.get("SQL_USER", "user"),
"PASSWORD": os.environ.get("SQL_PASSWORD", "password"),
"HOST": os.environ.get("SQL_HOST", "localhost"),
"PORT": "5432",
"PORT": os.environ.get("SQL_PORT", "5432"),
}
}
@@ -161,6 +164,7 @@ AUTH_USER_MODEL = "users.User"
LANGUAGE_CODE = "en"
LANGUAGES = (
("en", "English"),
("nl", "Nederlands"),
("pt-br", "Português (Brasil)"),
)
@@ -218,7 +222,7 @@ SESSION_COOKIE_SECURE = os.getenv("HTTPS_ENABLED", "false").lower() == "true"
DEBUG_TOOLBAR_CONFIG = {
"ROOT_TAG_EXTRA_ATTRS": "hx-preserve",
"SHOW_TOOLBAR_CALLBACK": lambda r: False, # disables it}
# "SHOW_TOOLBAR_CALLBACK": lambda r: False, # disables it
}
DEBUG_TOOLBAR_PANELS = [
"debug_toolbar.panels.history.HistoryPanel",
@@ -334,3 +338,53 @@ else:
}
CACHALOT_UNCACHABLE_TABLES = ("django_migrations", "procrastinate_jobs")
# PWA
PWA_APP_NAME = SITE_TITLE
PWA_APP_DESCRIPTION = "A simple and powerful finance tracker"
PWA_APP_THEME_COLOR = "#fbb700"
PWA_APP_BACKGROUND_COLOR = "#222222"
PWA_APP_DISPLAY = "standalone"
PWA_APP_SCOPE = "/"
PWA_APP_ORIENTATION = "any"
PWA_APP_START_URL = "/"
PWA_APP_STATUS_BAR_COLOR = "default"
PWA_APP_ICONS = [
{"src": "/static/img/favicon/android-icon-192x192.png", "sizes": "192x192"}
]
PWA_APP_ICONS_APPLE = [
{"src": "/static/img/favicon/apple-icon-180x180.png", "sizes": "180x180"}
]
PWA_APP_SPLASH_SCREEN = [
{
"src": "/static/img/pwa/splash-640x1136.png",
"media": "(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2)",
}
]
PWA_APP_DIR = "ltr"
PWA_APP_LANG = "en-US"
PWA_APP_SHORTCUTS = [
{
"name": "New Transaction",
"url": "/add/",
"description": "Add new transaction",
}
]
PWA_APP_SCREENSHOTS = [
{
"src": "/static/img/pwa/splash-750x1334.png",
"sizes": "750x1334",
"type": "image/png",
"form_factor": "wide",
},
{
"src": "/static/img/pwa/splash-750x1334.png",
"sizes": "750x1334",
"type": "image/png",
},
]
PWA_SERVICE_WORKER_PATH = BASE_DIR / "templates" / "pwa" / "serviceworker.js"
ENABLE_SOFT_DELETE = os.getenv("ENABLE_SOFT_DELETE", "false").lower() == "true"
KEEP_DELETED_TRANSACTIONS_FOR = int(os.getenv("KEEP_DELETED_ENTRIES_FOR", "365"))

View File

@@ -27,6 +27,7 @@ urlpatterns = [
path("hijack/", include("hijack.urls")),
path("__debug__/", include("debug_toolbar.urls")),
path("__reload__/", include("django_browser_reload.urls")),
path("", include("pwa.urls")),
# path("api/", include("rest_framework.urls")),
path("api/", include("apps.api.urls")),
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
@@ -47,4 +48,5 @@ urlpatterns = [
path("", include("apps.calendar_view.urls")),
path("", include("apps.dca.urls")),
path("", include("apps.mini_tools.urls")),
path("", include("apps.import_app.urls")),
]

View File

@@ -53,6 +53,7 @@ class AccountGroupForm(forms.ModelForm):
class AccountForm(forms.ModelForm):
group = DynamicModelChoiceField(
create_field="name",
label=_("Group"),
model=AccountGroup,
required=False,
@@ -112,6 +113,7 @@ class AccountBalanceForm(forms.Form):
max_digits=42, decimal_places=30, required=False, label=_("New balance")
)
category = DynamicModelChoiceField(
create_field="name",
model=TransactionCategory,
required=False,
label=_("Category"),

View File

@@ -0,0 +1,38 @@
from django.db import migrations, models
def make_names_unique(apps, schema_editor):
Account = apps.get_model("accounts", "Account")
# Get all accounts ordered by id
accounts = Account.objects.all().order_by("id")
# Track seen names
seen_names = {}
for account in accounts:
original_name = account.name
counter = seen_names.get(original_name, 0)
while account.name in seen_names:
counter += 1
account.name = f"{original_name} ({counter})"
seen_names[account.name] = counter
account.save()
def reverse_migration(apps, schema_editor):
# Can't restore original names, so do nothing
pass
class Migration(migrations.Migration):
dependencies = [
("accounts", "0006_rename_archived_account_is_archived_and_more"),
]
operations = [
migrations.RunPython(make_names_unique, reverse_migration),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.5 on 2025-01-24 00:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0007_make_account_names_unique'),
]
operations = [
migrations.AlterField(
model_name='account',
name='name',
field=models.CharField(max_length=255, unique=True, verbose_name='Name'),
),
]

View File

@@ -18,7 +18,7 @@ class AccountGroup(models.Model):
class Account(models.Model):
name = models.CharField(max_length=255, verbose_name=_("Name"))
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True)
group = models.ForeignKey(
AccountGroup,
on_delete=models.SET_NULL,

View File

@@ -2,9 +2,7 @@ from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from apps.accounts.forms import AccountGroupForm
@@ -89,7 +87,6 @@ def account_group_edit(request, pk):
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def account_group_delete(request, pk):
account_group = get_object_or_404(AccountGroup, id=pk)

View File

@@ -2,9 +2,7 @@ from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from apps.accounts.forms import AccountForm
@@ -89,7 +87,6 @@ def account_edit(request, pk):
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def account_delete(request, pk):
account = get_object_or_404(Account, id=pk)

View File

@@ -1,6 +1,7 @@
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,
@@ -25,13 +26,13 @@ class TransactionCategoryField(serializers.Field):
return TransactionCategory.objects.get(pk=data)
except TransactionCategory.DoesNotExist:
raise serializers.ValidationError(
"Category with this ID does not exist."
_("Category with this ID does not exist.")
)
elif isinstance(data, str):
category, created = TransactionCategory.objects.get_or_create(name=data)
return category
raise serializers.ValidationError(
"Invalid category data. Provide an ID or name."
_("Invalid category data. Provide an ID or name.")
)
@staticmethod
@@ -61,13 +62,13 @@ class TransactionTagField(serializers.Field):
tag = TransactionTag.objects.get(pk=item)
except TransactionTag.DoesNotExist:
raise serializers.ValidationError(
f"Tag with ID {item} does not exist."
_("Tag with this ID does not exist.")
)
elif isinstance(item, str):
tag, created = TransactionTag.objects.get_or_create(name=item)
else:
raise serializers.ValidationError(
"Invalid tag data. Provide an ID or name."
_("Invalid tag data. Provide an ID or name.")
)
tags.append(tag)
return tags
@@ -85,13 +86,13 @@ class TransactionEntityField(serializers.Field):
entity = TransactionEntity.objects.get(pk=item)
except TransactionTag.DoesNotExist:
raise serializers.ValidationError(
f"Entity with ID {item} does not exist."
_("Entity with this ID does not exist.")
)
elif isinstance(item, str):
entity, created = TransactionEntity.objects.get_or_create(name=item)
else:
raise serializers.ValidationError(
"Invalid entity data. Provide an ID or name."
_("Invalid entity data. Provide an ID or name.")
)
entities.append(entity)
return entities

View File

@@ -1,3 +1,4 @@
from .transactions import *
from .accounts import *
from .currencies import *
from .dca import *

View File

@@ -1,4 +1,5 @@
from rest_framework import serializers
from rest_framework.permissions import IsAuthenticated
from apps.api.serializers.currencies import CurrencySerializer
from apps.accounts.models import AccountGroup, Account
@@ -6,6 +7,8 @@ from apps.currencies.models import Currency
class AccountGroupSerializer(serializers.ModelSerializer):
permission_classes = [IsAuthenticated]
class Meta:
model = AccountGroup
fields = "__all__"
@@ -31,6 +34,8 @@ class AccountSerializer(serializers.ModelSerializer):
allow_null=True,
)
permission_classes = [IsAuthenticated]
class Meta:
model = Account
fields = [

View File

@@ -1,8 +1,12 @@
from rest_framework import serializers
from rest_framework.permissions import IsAuthenticated
from apps.currencies.models import Currency, ExchangeRate
class CurrencySerializer(serializers.ModelSerializer):
permission_classes = [IsAuthenticated]
class Meta:
model = Currency
fields = "__all__"
@@ -24,6 +28,8 @@ class ExchangeRateSerializer(serializers.ModelSerializer):
queryset=Currency.objects.all(), source="to_currency", write_only=True
)
permission_classes = [IsAuthenticated]
class Meta:
model = ExchangeRate
fields = "__all__"

View File

@@ -0,0 +1,85 @@
from rest_framework import serializers
from rest_framework.permissions import IsAuthenticated
from apps.dca.models import DCAEntry, DCAStrategy
class DCAEntrySerializer(serializers.ModelSerializer):
profit_loss = serializers.DecimalField(
max_digits=42, decimal_places=30, read_only=True
)
profit_loss_percentage = serializers.DecimalField(
max_digits=42, decimal_places=30, read_only=True
)
current_value = serializers.DecimalField(
max_digits=42, decimal_places=30, read_only=True
)
entry_price = serializers.DecimalField(
max_digits=42, decimal_places=30, read_only=True
)
permission_classes = [IsAuthenticated]
class Meta:
model = DCAEntry
fields = [
"id",
"strategy",
"date",
"amount_paid",
"amount_received",
"notes",
"created_at",
"updated_at",
"profit_loss",
"profit_loss_percentage",
"current_value",
"entry_price",
]
read_only_fields = ["created_at", "updated_at"]
class DCAStrategySerializer(serializers.ModelSerializer):
entries = DCAEntrySerializer(many=True, read_only=True)
total_invested = serializers.DecimalField(
max_digits=42, decimal_places=30, read_only=True
)
total_received = serializers.DecimalField(
max_digits=42, decimal_places=30, read_only=True
)
average_entry_price = serializers.DecimalField(
max_digits=42, decimal_places=30, read_only=True
)
total_entries = serializers.IntegerField(read_only=True)
current_total_value = serializers.DecimalField(
max_digits=42, decimal_places=30, read_only=True
)
total_profit_loss = serializers.DecimalField(
max_digits=42, decimal_places=30, read_only=True
)
total_profit_loss_percentage = serializers.DecimalField(
max_digits=42, decimal_places=30, read_only=True
)
permission_classes = [IsAuthenticated]
class Meta:
model = DCAStrategy
fields = [
"id",
"name",
"target_currency",
"payment_currency",
"notes",
"created_at",
"updated_at",
"entries",
"total_invested",
"total_received",
"average_entry_price",
"total_entries",
"current_total_value",
"total_profit_loss",
"total_profit_loss_percentage",
]
read_only_fields = ["created_at", "updated_at"]

View File

@@ -19,6 +19,7 @@ from apps.transactions.models import (
TransactionTag,
InstallmentPlan,
TransactionEntity,
RecurringTransaction,
)
@@ -47,11 +48,82 @@ class TransactionEntitySerializer(serializers.ModelSerializer):
class InstallmentPlanSerializer(serializers.ModelSerializer):
category = TransactionCategoryField(required=False)
tags = TransactionTagField(required=False)
entities = TransactionEntityField(required=False)
permission_classes = [IsAuthenticated]
class Meta:
model = InstallmentPlan
fields = "__all__"
fields = [
"id",
"account",
"type",
"description",
"number_of_installments",
"installment_start",
"installment_total_number",
"start_date",
"reference_date",
"end_date",
"recurrence",
"installment_amount",
"category",
"tags",
"entities",
"notes",
]
read_only_fields = ["installment_total_number", "end_date"]
def create(self, validated_data):
instance = super().create(validated_data)
instance.create_transactions()
return instance
def update(self, instance, validated_data):
instance = super().update(instance, validated_data)
instance.update_transactions()
return instance
class RecurringTransactionSerializer(serializers.ModelSerializer):
category = TransactionCategoryField(required=False)
tags = TransactionTagField(required=False)
entities = TransactionEntityField(required=False)
class Meta:
model = RecurringTransaction
fields = [
"id",
"is_paused",
"account",
"type",
"amount",
"description",
"category",
"tags",
"entities",
"notes",
"reference_date",
"start_date",
"end_date",
"recurrence_type",
"recurrence_interval",
"last_generated_date",
"last_generated_reference_date",
]
read_only_fields = ["last_generated_date", "last_generated_reference_date"]
def create(self, validated_data):
instance = super().create(validated_data)
instance.create_upcoming_transactions()
return instance
def update(self, instance, validated_data):
instance = super().update(instance, validated_data)
instance.update_unpaid_transactions()
return instance
class TransactionSerializer(serializers.ModelSerializer):

View File

@@ -9,10 +9,13 @@ router.register(r"categories", views.TransactionCategoryViewSet)
router.register(r"tags", views.TransactionTagViewSet)
router.register(r"entities", views.TransactionEntityViewSet)
router.register(r"installment-plans", views.InstallmentPlanViewSet)
router.register(r"recurring-transactions", views.RecurringTransactionViewSet)
router.register(r"account-groups", views.AccountGroupViewSet)
router.register(r"accounts", views.AccountViewSet)
router.register(r"currencies", views.CurrencyViewSet)
router.register(r"exchange-rates", views.ExchangeRateViewSet)
router.register(r"dca/strategies", views.DCAStrategyViewSet)
router.register(r"dca/entries", views.DCAEntryViewSet)
urlpatterns = [
path("", include(router.urls)),

View File

@@ -1,3 +1,4 @@
from .transactions import *
from .accounts import *
from .currencies import *
from .dca import *

41
app/apps/api/views/dca.py Normal file
View File

@@ -0,0 +1,41 @@
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from apps.dca.models import DCAStrategy, DCAEntry
from apps.api.serializers import DCAStrategySerializer, DCAEntrySerializer
class DCAStrategyViewSet(viewsets.ModelViewSet):
queryset = DCAStrategy.objects.all()
serializer_class = DCAStrategySerializer
@action(detail=True, methods=["get"])
def investment_frequency(self, request, pk=None):
strategy = self.get_object()
return Response(strategy.investment_frequency_data())
@action(detail=True, methods=["get"])
def price_comparison(self, request, pk=None):
strategy = self.get_object()
return Response(strategy.price_comparison_data())
@action(detail=True, methods=["get"])
def current_price(self, request, pk=None):
strategy = self.get_object()
price_data = strategy.current_price()
if price_data:
price, date = price_data
return Response({"price": price, "date": date})
return Response({"price": None, "date": None})
class DCAEntryViewSet(viewsets.ModelViewSet):
queryset = DCAEntry.objects.all()
serializer_class = DCAEntrySerializer
def get_queryset(self):
queryset = DCAEntry.objects.all()
strategy_id = self.request.query_params.get("strategy", None)
if strategy_id is not None:
queryset = queryset.filter(strategy_id=strategy_id)
return queryset

View File

@@ -1,4 +1,4 @@
from rest_framework import permissions, viewsets
from rest_framework import viewsets
from apps.api.serializers import (
TransactionSerializer,
@@ -6,6 +6,7 @@ from apps.api.serializers import (
TransactionTagSerializer,
InstallmentPlanSerializer,
TransactionEntitySerializer,
RecurringTransactionSerializer,
)
from apps.transactions.models import (
Transaction,
@@ -13,6 +14,7 @@ from apps.transactions.models import (
TransactionTag,
InstallmentPlan,
TransactionEntity,
RecurringTransaction,
)
from apps.rules.signals import transaction_updated, transaction_created
@@ -53,10 +55,7 @@ class InstallmentPlanViewSet(viewsets.ModelViewSet):
queryset = InstallmentPlan.objects.all()
serializer_class = InstallmentPlanSerializer
def perform_create(self, serializer):
instance = serializer.save()
instance.create_transactions()
def perform_update(self, serializer):
instance = serializer.save()
instance.create_transactions()
class RecurringTransactionViewSet(viewsets.ModelViewSet):
queryset = RecurringTransaction.objects.all()
serializer_class = RecurringTransactionSerializer

View File

@@ -1,6 +1,7 @@
from django import forms
from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils.translation import gettext_lazy as _
from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple
@@ -8,6 +9,12 @@ from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple
class DynamicModelChoiceField(forms.ModelChoiceField):
def __init__(self, model, *args, **kwargs):
self.model = model
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
@@ -18,8 +25,7 @@ class DynamicModelChoiceField(forms.ModelChoiceField):
if value in self.empty_values:
return None
try:
key = self.to_field_name or "pk"
return self.model.objects.get(**{key: value})
return self.model.objects.get(**{self.to_field_name: value})
except (ValueError, TypeError, self.model.DoesNotExist):
return value # Return the raw value; we'll handle creation in clean()
@@ -49,7 +55,9 @@ class DynamicModelChoiceField(forms.ModelChoiceField):
except self.model.DoesNotExist:
try:
with transaction.atomic():
instance = self.model.objects.create(name=value)
instance, _ = self.model.objects.update_or_create(
**{self.create_field: value}
)
self._created_instance = instance
return instance
except Exception as e:
@@ -111,12 +119,12 @@ class DynamicModelMultipleChoiceField(forms.ModelMultipleChoiceField):
"""
try:
with transaction.atomic():
new_instance = self.queryset.model(**{self.create_field: value})
new_instance.full_clean()
new_instance.save()
return new_instance
instance, _ = self.model.objects.update_or_create(
**{self.create_field: value}
)
return instance
except Exception as e:
raise ValidationError(f"Error creating new instance: {str(e)}")
raise ValidationError(_("Error creating new instance"))
def clean(self, value):
"""
@@ -152,6 +160,6 @@ class DynamicModelMultipleChoiceField(forms.ModelMultipleChoiceField):
try:
new_objects.append(self._create_new_instance(new_value))
except ValidationError as e:
raise ValidationError(f"Error creating '{new_value}': {str(e)}")
raise ValidationError(_("Error creating new instance"))
return existing_objects + new_objects

View File

@@ -1,9 +1,11 @@
import datetime
from django import forms
from django.db import models
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _
from apps.common.widgets.datepicker import AirMonthYearPickerInput
from apps.common.widgets.month_year import MonthYearWidget
@@ -18,7 +20,7 @@ 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.")
raise ValidationError(_("Invalid date format. Use YYYY-MM."))
def formfield(self, **kwargs):
kwargs["widget"] = MonthYearWidget
@@ -27,7 +29,7 @@ class MonthYearModelField(models.DateField):
class MonthYearFormField(forms.DateField):
widget = MonthYearWidget
widget = AirMonthYearPickerInput
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -42,7 +44,11 @@ class MonthYearFormField(forms.DateField):
date = datetime.datetime.strptime(value, "%Y-%m")
return date.replace(day=1).date()
except ValueError:
raise ValidationError(_("Invalid date format. Use YYYY-MM."))
try:
date = datetime.datetime.strptime(value, "%Y-%m-%d")
return date.replace(day=1).date()
except ValueError:
raise ValidationError(_("Invalid date format. Use YYYY-MM."))
def prepare_value(self, value):
if isinstance(value, datetime.date):

View File

@@ -0,0 +1,31 @@
from apps.common.middleware.thread_local import get_current_user
from django.utils.formats import get_format as original_get_format
def get_format(format_type=None, lang=None, use_l10n=None):
user = get_current_user()
if user and user.is_authenticated and hasattr(user, "settings"):
user_settings = user.settings
if format_type == "THOUSAND_SEPARATOR":
number_format = getattr(user_settings, "number_format", None)
if number_format == "DC":
return "."
elif number_format == "CD":
return ","
elif format_type == "DECIMAL_SEPARATOR":
number_format = getattr(user_settings, "number_format", None)
if number_format == "DC":
return ","
elif number_format == "CD":
return "."
elif format_type == "SHORT_DATE_FORMAT":
date_format = getattr(user_settings, "date_format", None)
if date_format and date_format != "SHORT_DATE_FORMAT":
return date_format
elif format_type == "SHORT_DATETIME_FORMAT":
datetime_format = getattr(user_settings, "datetime_format", None)
if datetime_format and datetime_format != "SHORT_DATETIME_FORMAT":
return datetime_format
return original_get_format(format_type, lang, use_l10n)

View File

@@ -1,14 +1,17 @@
import zoneinfo
from django.utils import formats
from django.utils import timezone, translation
from django.utils.translation import activate
from django.utils.functional import lazy
from apps.common.functions.format import get_format as custom_get_format
from apps.users.models import UserSettings
class LocalizationMiddleware:
def __init__(self, get_response):
self.get_response = get_response
self.patch_get_format()
def __call__(self, request):
tz = request.COOKIES.get("mytz")
@@ -33,9 +36,14 @@ class LocalizationMiddleware:
timezone.activate(zoneinfo.ZoneInfo("UTC"))
if user_language and user_language != "auto":
activate(user_language)
translation.activate(user_language)
else:
detected_language = translation.get_language_from_request(request)
activate(detected_language)
translation.activate(detected_language)
return self.get_response(request)
@staticmethod
def patch_get_format():
formats.get_format = custom_get_format
formats.get_format_lazy = lazy(custom_get_format, str, list, tuple)

View File

@@ -0,0 +1,73 @@
"""
threadlocals middleware
~~~~~~~~~~~~~~~~~~~~~~~
make the request object everywhere available (e.g. in model instance).
based on: http://code.djangoproject.com/wiki/CookBookThreadlocalsAndUser
Put this into your settings:
--------------------------------------------------------------------------
MIDDLEWARE_CLASSES = (
...
'django_tools.middlewares.ThreadLocal.ThreadLocalMiddleware',
...
)
--------------------------------------------------------------------------
Usage:
--------------------------------------------------------------------------
from django_tools.middlewares import ThreadLocal
# Get the current request object:
request = ThreadLocal.get_current_request()
# You can get the current user directly with:
user = ThreadLocal.get_current_user()
--------------------------------------------------------------------------
:copyleft: 2009-2017 by the django-tools team, see AUTHORS for more details.
:license: GNU GPL v3 or above, see LICENSE for more details.
"""
try:
from threading import local
except ImportError:
from django.utils._threading_local import local
try:
from django.utils.deprecation import MiddlewareMixin
except ImportError:
MiddlewareMixin = object # fallback for Django < 1.10
_thread_locals = local()
def get_current_request():
"""returns the request object for this thread"""
return getattr(_thread_locals, "request", None)
def get_current_user():
"""returns the current user, if exist, otherwise returns None"""
request = get_current_request()
if request:
return getattr(request, "user", None)
class ThreadLocalMiddleware(MiddlewareMixin):
"""Simple middleware that adds the request object in thread local storage."""
def process_request(self, request):
_thread_locals.request = request
def process_response(self, request, response):
if hasattr(_thread_locals, "request"):
del _thread_locals.request
return response
def process_exception(self, request, exception):
if hasattr(_thread_locals, "request"):
del _thread_locals.request

View File

@@ -1,5 +1,8 @@
import logging
from asgiref.sync import sync_to_async
from django.core import management
from procrastinate import builtin_tasks
from procrastinate.contrib.django import app
@@ -24,3 +27,16 @@ async def remove_old_jobs(context, timestamp):
exc_info=True,
)
raise e
@app.periodic(cron="0 6 1 * *")
@app.task(queueing_lock="remove_expired_sessions")
async def remove_expired_sessions(timestamp=None):
"""Cleanup expired sessions by using Django management command."""
try:
await sync_to_async(management.call_command)("clearsessions", verbosity=0)
except Exception:
logger.error(
"Error while executing 'remove_expired_sessions' task",
exc_info=True,
)

View File

@@ -1,6 +1,6 @@
from django import template
from django.utils.formats import get_format
from apps.common.functions.format import get_format
register = template.Library()

View File

@@ -0,0 +1,11 @@
import json
from django import template
register = template.Library()
@register.filter("json")
def convert_to_json(value):
return json.dumps(value)

View File

@@ -13,4 +13,9 @@ urlpatterns = [
views.month_year_picker,
name="month_year_picker",
),
path(
"cache/invalidate/",
views.invalidate_cache,
name="invalidate_cache",
),
]

View File

@@ -0,0 +1,161 @@
def django_to_python_datetime(django_format):
mapping = {
# Day
"j": "%d", # Day of the month without leading zeros
"d": "%d", # Day of the month with leading zeros
"D": "%a", # Day of the week, short version
"l": "%A", # Day of the week, full version
# Month
"n": "%m", # Month without leading zeros
"m": "%m", # Month with leading zeros
"M": "%b", # Month, short version
"F": "%B", # Month, full version
# Year
"y": "%y", # Year, 2 digits
"Y": "%Y", # Year, 4 digits
# Time
"g": "%I", # Hour (12-hour), without leading zeros
"G": "%H", # Hour (24-hour), without leading zeros
"h": "%I", # Hour (12-hour), with leading zeros
"H": "%H", # Hour (24-hour), with leading zeros
"i": "%M", # Minutes
"s": "%S", # Seconds
"a": "%p", # am/pm
"A": "%p", # AM/PM
"P": "%I:%M %p",
}
python_format = django_format
for django_code, python_code in mapping.items():
python_format = python_format.replace(django_code, python_code)
return python_format
def django_to_airdatepicker_datetime(django_format):
format_map = {
# Time
"h": "hh", # Hour (12-hour)
"H": "H", # Hour (24-hour)
"i": "m", # Minutes
"A": "AA", # AM/PM uppercase
"a": "aa", # am/pm lowercase
"P": "h:mm AA", # Localized time format (e.g., "2:30 PM")
# Date
"D": "E", # Short weekday name
"l": "EEEE", # Full weekday name
"j": "d", # Day of month without leading zero
"d": "dd", # Day of month with leading zero
"n": "M", # Month without leading zero
"m": "MM", # Month with leading zero
"M": "MMM", # Short month name
"F": "MMMM", # Full month name
"y": "yy", # Year, 2 digits
"Y": "yyyy", # Year, 4 digits
}
result = ""
i = 0
while i < len(django_format):
char = django_format[i]
if char == "\\": # Handle escaped characters
if i + 1 < len(django_format):
result += django_format[i + 1]
i += 2
continue
if char in format_map:
result += format_map[char]
else:
result += char
i += 1
return result
def django_to_airdatepicker_datetime_separated(django_format):
format_map = {
# Time formats
"h": "hh", # Hour (12-hour)
"H": "HH", # Hour (24-hour)
"i": "mm", # Minutes
"A": "AA", # AM/PM uppercase
"a": "aa", # am/pm lowercase
"P": "h:mm aa", # Localized time format
# Date formats
"D": "E", # Short weekday name
"l": "EEEE", # Full weekday name
"j": "d", # Day of month without leading zero
"d": "dd", # Day of month with leading zero
"n": "M", # Month without leading zero
"m": "MM", # Month with leading zero
"M": "MMM", # Short month name
"F": "MMMM", # Full month name
"y": "yy", # Year, 2 digits
"Y": "yyyy", # Year, 4 digits
}
# Define which characters belong to time format
time_chars = {"h", "H", "i", "A", "a", "P"}
date_chars = {"D", "l", "j", "d", "n", "m", "M", "F", "y", "Y"}
date_parts = []
time_parts = []
current_part = []
is_time = False
i = 0
while i < len(django_format):
char = django_format[i]
if char == "\\": # Handle escaped characters
if i + 1 < len(django_format):
current_part.append(django_format[i + 1])
i += 2
continue
if char in format_map:
if char in time_chars:
# If we were building a date part, save it and start a time part
if current_part and not is_time:
date_parts.append("".join(current_part))
current_part = []
is_time = True
current_part.append(format_map[char])
elif char in date_chars:
# If we were building a time part, save it and start a date part
if current_part and is_time:
time_parts.append("".join(current_part))
current_part = []
is_time = False
current_part.append(format_map[char])
else:
# Handle separators
if char in "/:.-":
current_part.append(char)
elif char == " ":
if current_part:
if is_time:
time_parts.append("".join(current_part))
else:
date_parts.append("".join(current_part))
current_part = []
current_part.append(char)
i += 1
# Don't forget the last part
if current_part:
if is_time:
time_parts.append("".join(current_part))
else:
date_parts.append("".join(current_part))
date_format = "".join(date_parts)
time_format = "".join(time_parts)
# Clean up multiple spaces while preserving necessary ones
date_format = " ".join(filter(None, date_format.split()))
time_format = " ".join(filter(None, time_format.split()))
return date_format, time_format

View File

@@ -1,17 +1,32 @@
from dateutil.relativedelta import relativedelta
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.db.models import Count
from django.db.models.functions import ExtractYear, ExtractMonth
from django.http import HttpResponse
from django.shortcuts import render
from django.urls import reverse
from django.utils import timezone
from django.views.decorators.http import require_http_methods
from django.utils.translation import gettext_lazy as _
from cachalot.api import invalidate
from apps.common.decorators.htmx import only_htmx
from apps.transactions.models import Transaction
@only_htmx
@login_required
@require_http_methods(["GET"])
def toasts(request):
return render(request, "common/fragments/toasts.html")
@only_htmx
@login_required
@require_http_methods(["GET"])
def month_year_picker(request):
field = request.GET.get("field", "reference_date")
for_ = request.GET.get("for", None)
@@ -84,3 +99,19 @@ def month_year_picker(request):
"current_year": current_year,
},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def invalidate_cache(request):
invalidate()
messages.success(request, _("Cache cleared successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated",
},
)

View File

@@ -0,0 +1,229 @@
import datetime
from django.forms import widgets
from django.utils import formats, translation, dates
from django.utils.translation import gettext_lazy as _
from apps.common.utils.django import (
django_to_python_datetime,
django_to_airdatepicker_datetime,
django_to_airdatepicker_datetime_separated,
)
from apps.common.functions.format import get_format
class AirDatePickerInput(widgets.DateInput):
def __init__(
self,
attrs=None,
format=None,
clear_button=True,
auto_close=True,
*args,
**kwargs,
):
attrs = attrs or {}
super().__init__(attrs=attrs, format=format, *args, **kwargs)
self.clear_button = clear_button
self.auto_close = auto_close
@staticmethod
def _get_current_language():
"""Get current language code in format compatible with AirDatepicker"""
lang_code = translation.get_language()
# AirDatepicker uses simple language codes
return lang_code.split("-")[0]
def _get_format(self):
"""Get the format string based on user settings or default"""
if self.format:
return self.format
return get_format("SHORT_DATE_FORMAT", use_l10n=True)
def build_attrs(self, base_attrs, extra_attrs=None):
attrs = super().build_attrs(base_attrs, extra_attrs)
attrs["data-now-button-txt"] = _("Today")
attrs["data-auto-close"] = str(self.auto_close).lower()
attrs["data-clear-button"] = str(self.clear_button).lower()
attrs["data-language"] = self._get_current_language()
attrs["data-date-format"] = django_to_airdatepicker_datetime(self._get_format())
return attrs
def format_value(self, value):
"""Format the value for display in the widget."""
if value:
self.attrs["data-value"] = value
if value is None:
return ""
if isinstance(value, (datetime.date, datetime.datetime)):
return formats.date_format(value, format=self._get_format(), use_l10n=True)
return str(value)
def value_from_datadict(self, data, files, name):
"""Parse the datetime string from the form data."""
value = super().value_from_datadict(data, files, name)
if value:
try:
# This does it's best to convert Django's PHP-Style date format to a datetime format and reformat the
# value to be read by Django. Probably could be improved
return datetime.datetime.strptime(
value.strip(),
django_to_python_datetime(self._get_format())
or django_to_python_datetime(get_format("SHORT_DATETIME_FORMAT")),
).strftime("%Y-%m-%d")
except (ValueError, TypeError) as e:
return value
return None
class AirDateTimePickerInput(widgets.DateTimeInput):
def __init__(
self,
attrs=None,
format=None,
timepicker=True,
clear_button=True,
auto_close=True,
*args,
**kwargs,
):
attrs = attrs or {}
super().__init__(attrs=attrs, format=format, *args, **kwargs)
self.timepicker = timepicker
self.clear_button = clear_button
self.auto_close = auto_close
@staticmethod
def _get_current_language():
"""Get current language code in format compatible with AirDatepicker"""
lang_code = translation.get_language()
# AirDatepicker uses simple language codes
return lang_code.split("-")[0]
def _get_format(self):
"""Get the format string based on user settings or default"""
if self.format:
return self.format
return get_format("SHORT_DATETIME_FORMAT", use_l10n=True)
def build_attrs(self, base_attrs, extra_attrs=None):
attrs = super().build_attrs(base_attrs, extra_attrs)
date_format, time_format = django_to_airdatepicker_datetime_separated(
self._get_format()
)
# Add data attributes for AirDatepicker configuration
attrs["data-now-button-txt"] = _("Now")
attrs["data-timepicker"] = str(self.timepicker).lower()
attrs["data-auto-close"] = str(self.auto_close).lower()
attrs["data-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
return attrs
def format_value(self, value):
"""Format the value for display in the widget."""
if value and isinstance(value, (datetime.date, datetime.datetime)):
self.attrs["data-value"] = datetime.datetime.strftime(
value, "%Y-%m-%dT%H:%M:00"
)
elif value and isinstance(value, str):
value = datetime.datetime.strptime(value, "%Y-%m-%d %H:%M:00")
self.attrs["data-value"] = datetime.datetime.strftime(
value, "%Y-%m-%dT%H:%M:00"
)
if value is None:
return ""
if isinstance(value, (datetime.date, datetime.datetime)):
return formats.date_format(value, format=self._get_format(), use_l10n=True)
return str(value)
def value_from_datadict(self, data, files, name):
"""Parse the datetime string from the form data."""
value = super().value_from_datadict(data, files, name)
if value:
try:
# This does it's best to convert Django's PHP-Style date format to a datetime format and reformat the
# value to be read by Django. Probably could be improved
return datetime.datetime.strptime(
value.strip(),
django_to_python_datetime(self._get_format())
or django_to_python_datetime(get_format("SHORT_DATETIME_FORMAT")),
).strftime("%Y-%m-%d %H:%M:%S")
except (ValueError, TypeError) as e:
return value
return None
class AirMonthYearPickerInput(AirDatePickerInput):
def __init__(self, attrs=None, format=None, *args, **kwargs):
super().__init__(attrs=attrs, format=format, *args, **kwargs)
# Store the display format for AirDatepicker
self.display_format = "MMMM yyyy"
# Store the Python format for internal use
self.python_format = "%B %Y"
@staticmethod
def _get_month_names():
"""Get month names using Django's date translation"""
return {dates.MONTHS[i]: i for i in range(1, 13)}
def build_attrs(self, base_attrs, extra_attrs=None):
attrs = super().build_attrs(base_attrs, extra_attrs)
# Add data attributes for AirDatepicker configuration
attrs["data-now-button-txt"] = _("Today")
attrs["data-date-format"] = "MMMM yyyy"
return attrs
def format_value(self, value):
"""Format the value for display in the widget."""
if value:
self.attrs["data-value"] = (
value # We use this to dynamically select the initial date on AirDatePicker
)
if value is None:
return ""
if isinstance(value, str):
try:
value = datetime.datetime.strptime(value, "%Y-%m-%d").date()
except ValueError:
return value
if isinstance(value, (datetime.datetime, datetime.date)):
# Use Django's date translation
month_name = dates.MONTHS[value.month]
return f"{month_name} {value.year}"
return value
def value_from_datadict(self, data, files, name):
"""Convert the value from the widget format back to a format Django can handle."""
value = super().value_from_datadict(data, files, name)
if value:
try:
# Split the value into month name and year
month_str, year_str = value.rsplit(" ", 1)
year = int(year_str)
# Get month number from translated month name
month_names = self._get_month_names()
month = month_names.get(month_str)
if month and year:
# Return the first day of the month in Django's expected format
return datetime.date(year, month, 1).strftime("%Y-%m-%d")
except (ValueError, KeyError):
return None
return None

View File

@@ -1,7 +1,9 @@
from decimal import Decimal, InvalidOperation
from django import forms
from django.utils.formats import get_format, number_format
from django.utils.formats import number_format
from apps.common.functions.format import get_format
def convert_to_decimal(value: str):

View File

@@ -6,9 +6,10 @@ from django.forms import CharField
from django.utils.translation import gettext_lazy as _
from apps.common.widgets.crispy.submit import NoClassSubmit
from apps.common.widgets.datepicker import AirDateTimePickerInput
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
from apps.currencies.models import Currency, ExchangeRate
from apps.common.widgets.tom_select import TomSelect
from apps.currencies.models import Currency, ExchangeRate
class CurrencyForm(forms.ModelForm):
@@ -64,9 +65,7 @@ class CurrencyForm(forms.ModelForm):
class ExchangeRateForm(forms.ModelForm):
date = forms.DateTimeField(
widget=forms.DateTimeInput(
attrs={"type": "datetime-local"}, format="%Y-%m-%dT%H:%M"
)
label=_("Date"),
)
class Meta:
@@ -82,6 +81,7 @@ class ExchangeRateForm(forms.ModelForm):
self.helper.layout = Layout("date", "from_currency", "to_currency", "rate")
self.fields["rate"].widget = ArbitraryDecimalDisplayNumberInput()
self.fields["date"].widget = AirDateTimePickerInput(clear_button=False)
if self.instance and self.instance.pk:
self.helper.layout.append(

View File

@@ -72,7 +72,9 @@ class ExchangeRate(models.Model):
def clean(self):
super().clean()
if self.from_currency == self.to_currency:
raise ValidationError(
{"to_currency": _("From and To currencies cannot be the same.")}
)
# Check if the attributes exist before comparing them
if hasattr(self, "from_currency") and hasattr(self, "to_currency"):
if self.from_currency == self.to_currency:
raise ValidationError(
{"to_currency": _("From and To currencies cannot be the same.")}
)

View File

@@ -2,9 +2,7 @@ from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx
@@ -89,7 +87,6 @@ def currency_edit(request, pk):
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def currency_delete(request, pk):
currency = get_object_or_404(Currency, id=pk)

View File

@@ -1,12 +1,11 @@
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import F, CharField, Value
from django.db.models import CharField, Value
from django.db.models.functions import Concat
from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx
@@ -135,7 +134,6 @@ def exchange_rate_edit(request, pk):
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def exchange_rate_delete(request, pk):
exchange_rate = get_object_or_404(ExchangeRate, id=pk)

View File

@@ -1,13 +1,14 @@
from crispy_forms.bootstrap import FormActions
from django import forms
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.common.widgets.crispy.submit import NoClassSubmit
from apps.common.widgets.datepicker import AirDatePickerInput
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
from apps.common.widgets.tom_select import TomSelect
from apps.dca.models import DCAStrategy, DCAEntry
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
from apps.common.widgets.crispy.submit import NoClassSubmit
class DCAStrategyForm(forms.ModelForm):
@@ -61,7 +62,6 @@ class DCAEntryForm(forms.ModelForm):
"notes",
]
widgets = {
"date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
"notes": forms.Textarea(attrs={"rows": 3}),
}
@@ -106,3 +106,4 @@ class DCAEntryForm(forms.ModelForm):
self.fields["amount_paid"].widget = ArbitraryDecimalDisplayNumberInput()
self.fields["amount_received"].widget = ArbitraryDecimalDisplayNumberInput()
self.fields["date"].widget = AirDatePickerInput(clear_button=False)

View File

@@ -6,12 +6,11 @@ from django.db.models.functions import TruncMonth
from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx
from apps.dca.models import DCAStrategy, DCAEntry
from apps.dca.forms import DCAEntryForm, DCAStrategyForm
from apps.dca.models import DCAStrategy, DCAEntry
@login_required
@@ -82,7 +81,6 @@ def strategy_edit(request, strategy_id):
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def strategy_delete(request, strategy_id):
dca_strategy = get_object_or_404(DCAStrategy, id=strategy_id)
@@ -209,7 +207,6 @@ def strategy_entry_edit(request, strategy_id, entry_id):
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def strategy_entry_delete(request, entry_id, strategy_id):
dca_entry = get_object_or_404(DCAEntry, id=entry_id, strategy__id=strategy_id)

View File

View File

@@ -0,0 +1,6 @@
from django.contrib import admin
from apps.import_app import models
# Register your models here.
admin.site.register(models.ImportRun)
admin.site.register(models.ImportProfile)

View File

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

View File

@@ -0,0 +1,64 @@
from crispy_forms.bootstrap import FormActions
from crispy_forms.helper import FormHelper
from crispy_forms.layout import (
Layout,
)
from django import forms
from django.utils.translation import gettext_lazy as _
from apps.import_app.models import ImportProfile
from apps.common.widgets.crispy.submit import NoClassSubmit
class ImportProfileForm(forms.ModelForm):
class Meta:
model = ImportProfile
fields = [
"name",
"version",
"yaml_config",
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.form_method = "post"
self.helper.layout = Layout("name", "version", "yaml_config")
if self.instance and self.instance.pk:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
),
),
)
else:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
),
),
)
class ImportRunFileUploadForm(forms.Form):
file = forms.FileField(label=_("Select a file"))
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.form_method = "post"
self.helper.layout = Layout(
"file",
FormActions(
NoClassSubmit(
"submit", _("Import"), css_class="btn btn-outline-primary w-100"
),
),
)

View File

@@ -0,0 +1,51 @@
# Generated by Django 5.1.5 on 2025-01-19 00:44
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('currencies', '0006_currency_exchange_currency'),
('transactions', '0028_transaction_internal_note'),
]
operations = [
migrations.CreateModel(
name='ImportProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('yaml_config', models.TextField(help_text='YAML configuration')),
('version', models.IntegerField(choices=[(1, 'Version 1')], default=1, verbose_name='Version')),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='ImportRun',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('QUEUED', 'Queued'), ('PROCESSING', 'Processing'), ('FAILED', 'Failed'), ('FINISHED', 'Finished')], default='QUEUED', max_length=10, verbose_name='Status')),
('file_name', models.CharField(help_text='File name', max_length=10000)),
('logs', models.TextField(blank=True)),
('processed_rows', models.IntegerField(default=0)),
('total_rows', models.IntegerField(default=0)),
('successful_rows', models.IntegerField(default=0)),
('skipped_rows', models.IntegerField(default=0)),
('failed_rows', models.IntegerField(default=0)),
('started_at', models.DateTimeField(null=True)),
('finished_at', models.DateTimeField(null=True)),
('categories', models.ManyToManyField(related_name='import_runs', to='transactions.transactioncategory')),
('currencies', models.ManyToManyField(related_name='import_runs', to='currencies.currency')),
('entities', models.ManyToManyField(related_name='import_runs', to='transactions.transactionentity')),
('profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='import_app.importprofile')),
('tags', models.ManyToManyField(related_name='import_runs', to='transactions.transactiontag')),
('transactions', models.ManyToManyField(related_name='import_runs', to='transactions.transaction')),
],
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.1.5 on 2025-01-23 03:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('import_app', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='importprofile',
name='name',
field=models.CharField(max_length=100, unique=True, verbose_name='Name'),
),
migrations.AlterField(
model_name='importprofile',
name='yaml_config',
field=models.TextField(verbose_name='YAML Configuration'),
),
]

View File

@@ -0,0 +1,87 @@
import yaml
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _
from apps.import_app.schemas import version_1
class ImportProfile(models.Model):
class Versions(models.IntegerChoices):
VERSION_1 = 1, "Version 1"
name = models.CharField(max_length=100, verbose_name=_("Name"), unique=True)
yaml_config = models.TextField(verbose_name=_("YAML Configuration"))
version = models.IntegerField(
choices=Versions,
default=Versions.VERSION_1,
verbose_name=_("Version"),
)
def __str__(self):
return self.name
class Meta:
ordering = ["name"]
def get_version_display(self):
version_number = self.Versions(self.version).name.split("_")[1]
return _("Version {number}").format(number=version_number)
def clean(self):
if self.version and self.version == self.Versions.VERSION_1:
try:
yaml_data = yaml.safe_load(self.yaml_config)
version_1.ImportProfileSchema(**yaml_data)
except Exception as e:
raise ValidationError(
{"yaml_config": _("Invalid YAML Configuration: ") + str(e)}
)
class ImportRun(models.Model):
class Status(models.TextChoices):
QUEUED = "QUEUED", _("Queued")
PROCESSING = "PROCESSING", _("Processing")
FAILED = "FAILED", _("Failed")
FINISHED = "FINISHED", _("Finished")
status = models.CharField(
max_length=10,
choices=Status,
default=Status.QUEUED,
verbose_name=_("Status"),
)
profile = models.ForeignKey(
ImportProfile,
on_delete=models.CASCADE,
)
file_name = models.CharField(
max_length=10000,
help_text=_("File name"),
)
transactions = models.ManyToManyField(
"transactions.Transaction", related_name="import_runs"
)
tags = models.ManyToManyField(
"transactions.TransactionTag", related_name="import_runs"
)
categories = models.ManyToManyField(
"transactions.TransactionCategory", related_name="import_runs"
)
entities = models.ManyToManyField(
"transactions.TransactionEntity", related_name="import_runs"
)
currencies = models.ManyToManyField(
"currencies.Currency", related_name="import_runs"
)
logs = models.TextField(blank=True)
processed_rows = models.IntegerField(default=0)
total_rows = models.IntegerField(default=0)
successful_rows = models.IntegerField(default=0)
skipped_rows = models.IntegerField(default=0)
failed_rows = models.IntegerField(default=0)
started_at = models.DateTimeField(null=True)
finished_at = models.DateTimeField(null=True)

View File

@@ -0,0 +1 @@
import apps.import_app.schemas.v1 as version_1

View File

@@ -0,0 +1,400 @@
from typing import Dict, List, Optional, Literal
from pydantic import BaseModel, Field, model_validator, field_validator
class CompareDeduplicationRule(BaseModel):
type: Literal["compare"]
fields: list[str] = Field(..., description="Compare fields for deduplication")
match_type: Literal["lax", "strict"] = "lax"
class ReplaceTransformationRule(BaseModel):
type: Literal["replace", "regex"] = Field(
..., description="Type of transformation: replace or regex"
)
pattern: str = Field(..., description="Pattern to match")
replacement: str = Field(..., description="Value to replace with")
exclusive: bool = Field(
default=False,
description="If it should match against the last transformation or the original value",
)
class DateFormatTransformationRule(BaseModel):
type: Literal["date_format"] = Field(
..., description="Type of transformation: date_format"
)
original_format: str = Field(..., description="Original date format")
new_format: str = Field(..., description="New date format to use")
class HashTransformationRule(BaseModel):
fields: List[str]
type: Literal["hash"]
class MergeTransformationRule(BaseModel):
fields: List[str]
type: Literal["merge"]
separator: str = Field(default=" ", description="Separator to use when merging")
class SplitTransformationRule(BaseModel):
type: Literal["split"]
separator: str = Field(default=",", description="Separator to use when splitting")
index: int | None = Field(
default=0, description="Index to return as value. Empty to return all."
)
class CSVImportSettings(BaseModel):
skip_errors: bool = Field(
default=False,
description="If True, errors during import will be logged and skipped",
)
file_type: Literal["csv"] = "csv"
delimiter: str = Field(default=",", description="CSV delimiter character")
encoding: str = Field(default="utf-8", description="File encoding")
skip_lines: int = Field(
default=0, description="Number of rows to skip at the beginning of the file"
)
trigger_transaction_rules: bool = True
importing: Literal[
"transactions", "accounts", "currencies", "categories", "tags", "entities"
]
class ColumnMapping(BaseModel):
source: Optional[str] | Optional[list[str]] = Field(
default=None,
description="CSV column header. If None, the field will be generated from transformations",
)
default: Optional[str] = None
required: bool = False
transformations: Optional[
List[
ReplaceTransformationRule
| DateFormatTransformationRule
| HashTransformationRule
| MergeTransformationRule
| SplitTransformationRule
]
] = Field(default_factory=list)
class TransactionAccountMapping(ColumnMapping):
target: Literal["account"] = Field(..., description="Transaction field to map to")
type: Literal["id", "name"] = "name"
coerce_to: Literal["str|int"] = Field("str|int", frozen=True)
required: bool = Field(True, frozen=True)
class TransactionTypeMapping(ColumnMapping):
target: Literal["type"] = Field(..., description="Transaction field to map to")
detection_method: Literal["sign", "always_income", "always_expense"] = "sign"
coerce_to: Literal["transaction_type"] = Field("transaction_type", frozen=True)
class TransactionIsPaidMapping(ColumnMapping):
target: Literal["is_paid"] = Field(..., description="Transaction field to map to")
detection_method: Literal["boolean", "always_paid", "always_unpaid"]
coerce_to: Literal["is_paid"] = Field("is_paid", frozen=True)
class TransactionDateMapping(ColumnMapping):
target: Literal["date"] = Field(..., description="Transaction field to map to")
format: List[str] | str
coerce_to: Literal["date"] = Field("date", frozen=True)
required: bool = Field(True, frozen=True)
class TransactionReferenceDateMapping(ColumnMapping):
target: Literal["reference_date"] = Field(
..., description="Transaction field to map to"
)
format: List[str] | str
coerce_to: Literal["date"] = Field("date", frozen=True)
class TransactionAmountMapping(ColumnMapping):
target: Literal["amount"] = Field(..., description="Transaction field to map to")
coerce_to: Literal["positive_decimal"] = Field("positive_decimal", frozen=True)
required: bool = Field(True, frozen=True)
class TransactionDescriptionMapping(ColumnMapping):
target: Literal["description"] = Field(
..., description="Transaction field to map to"
)
coerce_to: Literal["str"] = Field("str", frozen=True)
class TransactionNotesMapping(ColumnMapping):
target: Literal["notes"] = Field(..., description="Transaction field to map to")
coerce_to: Literal["str"] = Field("str", frozen=True)
class TransactionTagsMapping(ColumnMapping):
target: Literal["tags"] = Field(..., description="Transaction field to map to")
type: Literal["id", "name"] = "name"
create: bool = Field(
default=True, description="Create new tags if they doesn't exist"
)
coerce_to: Literal["list"] = Field("list", frozen=True)
class TransactionEntitiesMapping(ColumnMapping):
target: Literal["entities"] = Field(..., description="Transaction field to map to")
type: Literal["id", "name"] = "name"
create: bool = Field(
default=True, description="Create new entities if they doesn't exist"
)
coerce_to: Literal["list"] = Field("list", frozen=True)
class TransactionCategoryMapping(ColumnMapping):
target: Literal["category"] = Field(..., description="Transaction field to map to")
create: bool = Field(
default=True, description="Create category if it doesn't exist"
)
type: Literal["id", "name"] = "name"
coerce_to: Literal["str|int"] = Field("str|int", frozen=True)
class TransactionInternalNoteMapping(ColumnMapping):
target: Literal["internal_note"] = Field(
..., description="Transaction field to map to"
)
coerce_to: Literal["str"] = Field("str", frozen=True)
class TransactionInternalIDMapping(ColumnMapping):
target: Literal["internal_id"] = Field(
..., description="Transaction field to map to"
)
coerce_to: Literal["str"] = Field("str", frozen=True)
class CategoryNameMapping(ColumnMapping):
target: Literal["category_name"] = Field(
..., description="Category field to map to"
)
coerce_to: Literal["str"] = Field("str", frozen=True)
class CategoryMuteMapping(ColumnMapping):
target: Literal["category_mute"] = Field(
..., description="Category field to map to"
)
coerce_to: Literal["bool"] = Field("bool", frozen=True)
class CategoryActiveMapping(ColumnMapping):
target: Literal["category_active"] = Field(
..., description="Category field to map to"
)
coerce_to: Literal["bool"] = Field("bool", frozen=True)
class TagNameMapping(ColumnMapping):
target: Literal["tag_name"] = Field(..., description="Tag field to map to")
coerce_to: Literal["str"] = Field("str", frozen=True)
class TagActiveMapping(ColumnMapping):
target: Literal["tag_active"] = Field(..., description="Tag field to map to")
coerce_to: Literal["bool"] = Field("bool", frozen=True)
class EntityNameMapping(ColumnMapping):
target: Literal["entity_name"] = Field(..., description="Entity field to map to")
coerce_to: Literal["str"] = Field("str", frozen=True)
class EntityActiveMapping(ColumnMapping):
target: Literal["entity_active"] = Field(..., description="Entity field to map to")
coerce_to: Literal["bool"] = Field("bool", frozen=True)
class AccountNameMapping(ColumnMapping):
target: Literal["account_name"] = Field(..., description="Account field to map to")
coerce_to: Literal["str"] = Field("str", frozen=True)
class AccountGroupMapping(ColumnMapping):
target: Literal["account_group"] = Field(..., description="Account field to map to")
type: Literal["id", "name"]
coerce_to: Literal["str|int"] = Field("str|int", frozen=True)
class AccountCurrencyMapping(ColumnMapping):
target: Literal["account_currency"] = Field(
..., description="Account field to map to"
)
type: Literal["id", "name", "code"]
coerce_to: Literal["str|int"] = Field("str|int", frozen=True)
class AccountExchangeCurrencyMapping(ColumnMapping):
target: Literal["account_exchange_currency"] = Field(
..., description="Account field to map to"
)
type: Literal["id", "name", "code"]
coerce_to: Literal["str|int"] = Field("str|int", frozen=True)
class AccountIsAssetMapping(ColumnMapping):
target: Literal["account_is_asset"] = Field(
..., description="Account field to map to"
)
coerce_to: Literal["bool"] = Field("bool", frozen=True)
class AccountIsArchivedMapping(ColumnMapping):
target: Literal["account_is_archived"] = Field(
..., description="Account field to map to"
)
coerce_to: Literal["bool"] = Field("bool", frozen=True)
class CurrencyCodeMapping(ColumnMapping):
target: Literal["currency_code"] = Field(
..., description="Currency field to map to"
)
coerce_to: Literal["str"] = Field("str", frozen=True)
class CurrencyNameMapping(ColumnMapping):
target: Literal["currency_name"] = Field(
..., description="Currency field to map to"
)
coerce_to: Literal["str"] = Field("str", frozen=True)
class CurrencyDecimalPlacesMapping(ColumnMapping):
target: Literal["currency_decimal_places"] = Field(
..., description="Currency field to map to"
)
coerce_to: Literal["int"] = Field("int", frozen=True)
class CurrencyPrefixMapping(ColumnMapping):
target: Literal["currency_prefix"] = Field(
..., description="Currency field to map to"
)
coerce_to: Literal["str"] = Field("str", frozen=True)
class CurrencySuffixMapping(ColumnMapping):
target: Literal["currency_suffix"] = Field(
..., description="Currency field to map to"
)
coerce_to: Literal["str"] = Field("str", frozen=True)
class CurrencyExchangeMapping(ColumnMapping):
target: Literal["currency_exchange"] = Field(
..., description="Currency field to map to"
)
type: Literal["id", "name", "code"]
coerce_to: Literal["str|int"] = Field("str|int", frozen=True)
class ImportProfileSchema(BaseModel):
settings: CSVImportSettings
mapping: Dict[
str,
TransactionAccountMapping
| TransactionTypeMapping
| TransactionIsPaidMapping
| TransactionDateMapping
| TransactionReferenceDateMapping
| TransactionAmountMapping
| TransactionDescriptionMapping
| TransactionNotesMapping
| TransactionTagsMapping
| TransactionEntitiesMapping
| TransactionCategoryMapping
| TransactionInternalNoteMapping
| TransactionInternalIDMapping
| CategoryNameMapping
| CategoryMuteMapping
| CategoryActiveMapping
| TagNameMapping
| TagActiveMapping
| EntityNameMapping
| EntityActiveMapping
| AccountNameMapping
| AccountGroupMapping
| AccountCurrencyMapping
| AccountExchangeCurrencyMapping
| AccountIsAssetMapping
| AccountIsArchivedMapping
| CurrencyCodeMapping
| CurrencyNameMapping
| CurrencyDecimalPlacesMapping
| CurrencyPrefixMapping
| CurrencySuffixMapping
| CurrencyExchangeMapping,
]
deduplication: List[CompareDeduplicationRule] = Field(
default_factory=list,
description="Rules for deduplicating records during import",
)
@model_validator(mode="after")
def validate_mappings(self) -> "ImportProfileSchema":
import_type = self.settings.importing
# Define allowed mapping types for each import type
allowed_mappings = {
"transactions": (
TransactionAccountMapping,
TransactionTypeMapping,
TransactionIsPaidMapping,
TransactionDateMapping,
TransactionReferenceDateMapping,
TransactionAmountMapping,
TransactionDescriptionMapping,
TransactionNotesMapping,
TransactionTagsMapping,
TransactionEntitiesMapping,
TransactionCategoryMapping,
TransactionInternalNoteMapping,
TransactionInternalIDMapping,
),
"accounts": (
AccountNameMapping,
AccountGroupMapping,
AccountCurrencyMapping,
AccountExchangeCurrencyMapping,
AccountIsAssetMapping,
AccountIsArchivedMapping,
),
"currencies": (
CurrencyCodeMapping,
CurrencyNameMapping,
CurrencyDecimalPlacesMapping,
CurrencyPrefixMapping,
CurrencySuffixMapping,
CurrencyExchangeMapping,
),
"categories": (
CategoryNameMapping,
CategoryMuteMapping,
CategoryActiveMapping,
),
"tags": (TagNameMapping, TagActiveMapping),
"entities": (EntityNameMapping, EntityActiveMapping),
}
allowed_types = allowed_mappings[import_type]
for field_name, mapping in self.mapping.items():
if not isinstance(mapping, allowed_types):
raise ValueError(
f"Mapping type '{type(mapping).__name__}' is not allowed when importing {import_type}. "
f"Allowed types are: {', '.join(t.__name__ for t in allowed_types)}"
)
return self

View File

@@ -0,0 +1,3 @@
from apps.import_app.services.v1 import ImportService as ImportServiceV1
from apps.import_app.services.presets import PresetService

View File

@@ -0,0 +1,45 @@
import json
from pathlib import Path
from apps.import_app.models import ImportProfile
class PresetService:
PRESET_PATH = "/usr/src/app/import_presets"
@classmethod
def get_all_presets(cls):
presets = []
for folder in Path(cls.PRESET_PATH).iterdir():
if folder.is_dir():
manifest_path = folder / "manifest.json"
config_path = folder / "config.yml"
if manifest_path.exists() and config_path.exists():
with open(manifest_path) as f:
manifest = json.load(f)
with open(config_path) as f:
config = json.dumps(f.read())
try:
preset = {
"name": manifest.get("name", folder.name),
"description": manifest.get("description", ""),
"message": json.dumps(manifest.get("message", "")),
"authors": manifest.get("author", "").split(","),
"schema_version": (int(manifest.get("schema_version", 1))),
"folder_name": folder.name,
"config": config,
}
ImportProfile.Versions(
preset["schema_version"]
) # Check if schema version is valid
except Exception as e:
pass
else:
presets.append(preset)
return presets

View File

@@ -0,0 +1,642 @@
import csv
import hashlib
import logging
import os
import re
from datetime import datetime
from decimal import Decimal
from typing import Dict, Any, Literal, Union
import cachalot.api
import yaml
from cachalot.api import cachalot_disabled
from django.utils import timezone
from apps.accounts.models import Account, AccountGroup
from apps.currencies.models import Currency
from apps.import_app.models import ImportRun, ImportProfile
from apps.import_app.schemas import version_1
from apps.transactions.models import (
Transaction,
TransactionCategory,
TransactionTag,
TransactionEntity,
)
from apps.rules.signals import transaction_created
from apps.import_app.schemas.v1 import (
TransactionCategoryMapping,
TransactionAccountMapping,
TransactionTagsMapping,
TransactionEntitiesMapping,
)
logger = logging.getLogger(__name__)
class ImportService:
TEMP_DIR = "/usr/src/app/temp"
def __init__(self, import_run: ImportRun):
self.import_run: ImportRun = import_run
self.profile: ImportProfile = import_run.profile
self.config: version_1.ImportProfileSchema = self._load_config()
self.settings: version_1.CSVImportSettings = self.config.settings
self.deduplication: list[version_1.CompareDeduplicationRule] = (
self.config.deduplication
)
self.mapping: Dict[str, version_1.ColumnMapping] = self.config.mapping
# Ensure temp directory exists
os.makedirs(self.TEMP_DIR, exist_ok=True)
def _load_config(self) -> version_1.ImportProfileSchema:
yaml_data = yaml.safe_load(self.profile.yaml_config)
try:
config = version_1.ImportProfileSchema(**yaml_data)
except Exception as e:
self._log("error", f"Fatal error processing YAML config: {str(e)}")
self._update_status("FAILED")
raise e
else:
return config
def _log(self, level: str, message: str, **kwargs) -> None:
"""Add a log entry to the import run logs"""
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# Format additional context if present
context = ""
if kwargs:
context = " - " + ", ".join(f"{k}={v}" for k, v in kwargs.items())
log_line = f"[{timestamp}] {level.upper()}: {message}{context}\n"
# Append to existing logs
self.import_run.logs += log_line
self.import_run.save(update_fields=["logs"])
def _update_totals(
self,
field: Literal["total", "processed", "successful", "skipped", "failed"],
value: int,
) -> None:
if field == "total":
self.import_run.total_rows = value
self.import_run.save(update_fields=["total_rows"])
elif field == "processed":
self.import_run.processed_rows = value
self.import_run.save(update_fields=["processed_rows"])
elif field == "successful":
self.import_run.successful_rows = value
self.import_run.save(update_fields=["successful_rows"])
elif field == "skipped":
self.import_run.skipped_rows = value
self.import_run.save(update_fields=["skipped_rows"])
elif field == "failed":
self.import_run.failed_rows = value
self.import_run.save(update_fields=["failed_rows"])
def _increment_totals(
self,
field: Literal["total", "processed", "successful", "skipped", "failed"],
value: int,
) -> None:
if field == "total":
self.import_run.total_rows = self.import_run.total_rows + value
self.import_run.save(update_fields=["total_rows"])
elif field == "processed":
self.import_run.processed_rows = self.import_run.processed_rows + value
self.import_run.save(update_fields=["processed_rows"])
elif field == "successful":
self.import_run.successful_rows = self.import_run.successful_rows + value
self.import_run.save(update_fields=["successful_rows"])
elif field == "skipped":
self.import_run.skipped_rows = self.import_run.skipped_rows + value
self.import_run.save(update_fields=["skipped_rows"])
elif field == "failed":
self.import_run.failed_rows = self.import_run.failed_rows + value
self.import_run.save(update_fields=["failed_rows"])
def _update_status(
self, new_status: Literal["PROCESSING", "FAILED", "FINISHED"]
) -> None:
if new_status == "PROCESSING":
self.import_run.status = ImportRun.Status.PROCESSING
elif new_status == "FAILED":
self.import_run.status = ImportRun.Status.FAILED
elif new_status == "FINISHED":
self.import_run.status = ImportRun.Status.FINISHED
self.import_run.save(update_fields=["status"])
@staticmethod
def _transform_value(
value: str, mapping: version_1.ColumnMapping, row: Dict[str, str] = None
) -> Any:
transformed = value
for transform in mapping.transformations:
if transform.type == "hash":
# Collect all values to be hashed
values_to_hash = []
for field in transform.fields:
if field in row:
values_to_hash.append(str(row[field]))
# Create hash from concatenated values
if values_to_hash:
concatenated = "|".join(values_to_hash)
transformed = hashlib.sha256(concatenated.encode()).hexdigest()
elif transform.type == "replace":
if transform.exclusive:
transformed = value.replace(
transform.pattern, transform.replacement
)
else:
transformed = transformed.replace(
transform.pattern, transform.replacement
)
elif transform.type == "regex":
if transform.exclusive:
transformed = re.sub(
transform.pattern, transform.replacement, value
)
else:
transformed = re.sub(
transform.pattern, transform.replacement, transformed
)
elif transform.type == "date_format":
transformed = datetime.strptime(
transformed, transform.original_format
).strftime(transform.new_format)
elif transform.type == "merge":
values_to_merge = []
for field in transform.fields:
if field in row:
values_to_merge.append(str(row[field]))
transformed = transform.separator.join(values_to_merge)
elif transform.type == "split":
parts = transformed.split(transform.separator)
if transform.index is not None:
transformed = parts[transform.index] if parts else ""
else:
transformed = parts
return transformed
def _create_transaction(self, data: Dict[str, Any]) -> Transaction:
tags = []
entities = []
# Handle related objects first
if "category" in data:
if "category" in data:
category_name = data.pop("category")
category_mapping = next(
(
m
for m in self.mapping.values()
if isinstance(m, TransactionCategoryMapping)
and m.target == "category"
),
None,
)
try:
if category_mapping:
if category_mapping.type == "id":
category = TransactionCategory.objects.get(id=category_name)
else: # name
if getattr(category_mapping, "create", False):
category, _ = TransactionCategory.objects.get_or_create(
name=category_name
)
else:
category = TransactionCategory.objects.filter(
name=category_name
).first()
if category:
data["category"] = category
self.import_run.categories.add(category)
except (TransactionCategory.DoesNotExist, ValueError):
# Ignore if category doesn't exist and create is False or not set
data["category"] = None
if "account" in data:
account_id = data.pop("account")
account_mapping = next(
(
m
for m in self.mapping.values()
if isinstance(m, TransactionAccountMapping)
and m.target == "account"
),
None,
)
try:
if account_mapping and account_mapping.type == "id":
account = Account.objects.filter(id=account_id).first()
else: # name
account = Account.objects.filter(name=account_id).first()
if account:
data["account"] = account
except ValueError:
# Ignore if account doesn't exist
pass
if "tags" in data:
tag_names = data.pop("tags")
tags_mapping = next(
(
m
for m in self.mapping.values()
if isinstance(m, TransactionTagsMapping) and m.target == "tags"
),
None,
)
for tag_name in tag_names:
try:
if tags_mapping:
if tags_mapping.type == "id":
tag = TransactionTag.objects.filter(id=tag_name).first()
else: # name
if getattr(tags_mapping, "create", False):
tag, _ = TransactionTag.objects.get_or_create(
name=tag_name.strip()
)
else:
tag = TransactionTag.objects.filter(
name=tag_name.strip()
).first()
if tag:
tags.append(tag)
self.import_run.tags.add(tag)
except ValueError:
# Ignore if tag doesn't exist and create is False or not set
continue
if "entities" in data:
entity_names = data.pop("entities")
entities_mapping = next(
(
m
for m in self.mapping.values()
if isinstance(m, TransactionEntitiesMapping)
and m.target == "entities"
),
None,
)
for entity_name in entity_names:
try:
if entities_mapping:
if entities_mapping.type == "id":
entity = TransactionTag.objects.filter(
id=entity_name
).first()
else: # name
if getattr(entities_mapping, "create", False):
entity, _ = TransactionEntity.objects.get_or_create(
name=entity_name.strip()
)
else:
entity = TransactionEntity.objects.filter(
name=entity_name.strip()
).first()
if entity:
entities.append(entity)
self.import_run.entities.add(entity)
except ValueError:
# Ignore if entity doesn't exist and create is False or not set
continue
# Create the transaction
new_transaction = Transaction.objects.create(**data)
self.import_run.transactions.add(new_transaction)
# Add many-to-many relationships
if tags:
new_transaction.tags.set(tags)
if entities:
new_transaction.entities.set(entities)
if self.settings.trigger_transaction_rules:
transaction_created.send(sender=new_transaction)
return new_transaction
def _create_account(self, data: Dict[str, Any]) -> Account:
if "group" in data:
group_name = data.pop("group")
group, _ = AccountGroup.objects.get_or_create(name=group_name)
data["group"] = group
# Handle currency references
if "currency" in data:
currency = Currency.objects.get(code=data["currency"])
data["currency"] = currency
self.import_run.currencies.add(currency)
if "exchange_currency" in data:
exchange_currency = Currency.objects.get(code=data["exchange_currency"])
data["exchange_currency"] = exchange_currency
self.import_run.currencies.add(exchange_currency)
return Account.objects.create(**data)
def _create_currency(self, data: Dict[str, Any]) -> Currency:
# Handle exchange currency reference
if "exchange_currency" in data:
exchange_currency = Currency.objects.get(code=data["exchange_currency"])
data["exchange_currency"] = exchange_currency
self.import_run.currencies.add(exchange_currency)
currency = Currency.objects.create(**data)
self.import_run.currencies.add(currency)
return currency
def _create_category(self, data: Dict[str, Any]) -> TransactionCategory:
category = TransactionCategory.objects.create(**data)
self.import_run.categories.add(category)
return category
def _create_tag(self, data: Dict[str, Any]) -> TransactionTag:
tag = TransactionTag.objects.create(**data)
self.import_run.tags.add(tag)
return tag
def _create_entity(self, data: Dict[str, Any]) -> TransactionEntity:
entity = TransactionEntity.objects.create(**data)
self.import_run.entities.add(entity)
return entity
def _check_duplicate_transaction(self, transaction_data: Dict[str, Any]) -> bool:
for rule in self.deduplication:
if rule.type == "compare":
query = Transaction.all_objects.all().values("id")
# Build query conditions for each field in the rule
for field in rule.fields:
if field in transaction_data:
if rule.match_type == "strict":
query = query.filter(**{field: transaction_data[field]})
else: # lax matching
query = query.filter(
**{f"{field}__iexact": transaction_data[field]}
)
# If we found any matching transaction, it's a duplicate
if query.exists():
return True
return False
def _coerce_type(
self, value: str, mapping: version_1.ColumnMapping
) -> Union[str, int, bool, Decimal, datetime, list]:
if not value:
return None
coerce_to = mapping.coerce_to
return self._coerce_single_type(value, coerce_to, mapping)
@staticmethod
def _coerce_single_type(
value: str, coerce_to: str, mapping: version_1.ColumnMapping
) -> Union[str, int, bool, Decimal, datetime.date, list]:
if coerce_to == "str":
return str(value)
elif coerce_to == "int":
return int(value)
elif coerce_to == "str|int":
if hasattr(mapping, "type") and mapping.type == "id":
return int(value)
elif hasattr(mapping, "type") and mapping.type in ["name", "code"]:
return str(value)
else:
return str(value)
elif coerce_to == "bool":
return value.lower() in ["true", "1", "yes", "y", "on"]
elif coerce_to == "positive_decimal":
return abs(Decimal(value))
elif coerce_to == "date":
if isinstance(
mapping,
(
version_1.TransactionDateMapping,
version_1.TransactionReferenceDateMapping,
),
):
formats = (
mapping.format
if isinstance(mapping.format, list)
else [mapping.format]
)
for fmt in formats:
try:
return datetime.strptime(value, fmt).date()
except ValueError:
continue
raise ValueError(
f"Could not parse date '{value}' with any of the provided formats"
)
else:
raise ValueError(
"Date coercion is only supported for TransactionDateMapping and TransactionReferenceDateMapping"
)
elif coerce_to == "list":
return (
value
if isinstance(value, list)
else [item.strip() for item in value.split(",") if item.strip()]
)
elif coerce_to == "transaction_type":
if isinstance(mapping, version_1.TransactionTypeMapping):
if mapping.detection_method == "sign":
return (
Transaction.Type.EXPENSE
if value.startswith("-")
else Transaction.Type.INCOME
)
elif mapping.detection_method == "always_income":
return Transaction.Type.INCOME
elif mapping.detection_method == "always_expense":
return Transaction.Type.EXPENSE
raise ValueError("Invalid transaction type detection method")
elif coerce_to == "is_paid":
if isinstance(mapping, version_1.TransactionIsPaidMapping):
if mapping.detection_method == "boolean":
return value.lower() in ["true", "1", "yes", "y", "on"]
elif mapping.detection_method == "always_paid":
return True
elif mapping.detection_method == "always_unpaid":
return False
raise ValueError("Invalid is_paid detection method")
else:
raise ValueError(f"Unsupported coercion type: {coerce_to}")
def _map_row(self, row: Dict[str, str]) -> Dict[str, Any]:
mapped_data = {}
for field, mapping in self.mapping.items():
value = None
if isinstance(mapping.source, str):
value = row.get(mapping.source)
elif isinstance(mapping.source, list):
for source in mapping.source:
value = row.get(source)
if value is not None:
break
else:
# If source is None, use None as the initial value
value = None
# Use default_value if value is None
if value is None:
value = mapping.default
# Apply transformations
if mapping.transformations:
value = self._transform_value(value, mapping, row)
value = self._coerce_type(value, mapping)
if mapping.required and value is None:
raise ValueError(f"Required field {field} is missing")
if value is not None:
# Remove the prefix from the target field
target = mapping.target
if self.settings.importing == "transactions":
mapped_data[target] = value
else:
# Remove the model prefix (e.g., "account_" from "account_name")
field_name = target.split("_", 1)[1]
mapped_data[field_name] = value
return mapped_data
def _process_row(self, row: Dict[str, str], row_number: int) -> None:
try:
mapped_data = self._map_row(row)
if mapped_data:
# Handle different import types
if self.settings.importing == "transactions":
if self.deduplication and self._check_duplicate_transaction(
mapped_data
):
self._increment_totals("skipped", 1)
self._log("info", f"Skipped duplicate row {row_number}")
return
self._create_transaction(mapped_data)
elif self.settings.importing == "accounts":
self._create_account(mapped_data)
elif self.settings.importing == "currencies":
self._create_currency(mapped_data)
elif self.settings.importing == "categories":
self._create_category(mapped_data)
elif self.settings.importing == "tags":
self._create_tag(mapped_data)
elif self.settings.importing == "entities":
self._create_entity(mapped_data)
self._increment_totals("successful", value=1)
self._log("info", f"Successfully processed row {row_number}")
self._increment_totals("processed", value=1)
except Exception as e:
if not self.settings.skip_errors:
self._log("error", f"Fatal error processing row {row_number}: {str(e)}")
self._update_status("FAILED")
raise
else:
self._log("warning", f"Error processing row {row_number}: {str(e)}")
self._increment_totals("failed", value=1)
logger.error(f"Fatal error processing row {row_number}", exc_info=e)
def _process_csv(self, file_path):
# First pass: count rows
with open(file_path, "r", encoding=self.settings.encoding) as csv_file:
# Skip specified number of rows
for _ in range(self.settings.skip_lines):
next(csv_file)
reader = csv.DictReader(csv_file, delimiter=self.settings.delimiter)
self._update_totals("total", value=sum(1 for _ in reader))
with open(file_path, "r", encoding=self.settings.encoding) as csv_file:
# Skip specified number of rows
for _ in range(self.settings.skip_lines):
next(csv_file)
if self.settings.skip_lines:
self._log("info", f"Skipped {self.settings.skip_lines} initial lines")
reader = csv.DictReader(csv_file, delimiter=self.settings.delimiter)
self._log("info", f"Starting import with {self.import_run.total_rows} rows")
for row_number, row in enumerate(reader, start=1):
self._process_row(row, row_number)
def _validate_file_path(self, file_path: str) -> str:
"""
Validates that the file path is within the allowed temporary directory.
Returns the absolute path.
"""
abs_path = os.path.abspath(file_path)
if not abs_path.startswith(self.TEMP_DIR):
raise ValueError(f"Invalid file path. File must be in {self.TEMP_DIR}")
return abs_path
def process_file(self, file_path: str):
with cachalot_disabled():
# Validate and get absolute path
file_path = self._validate_file_path(file_path)
self._update_status("PROCESSING")
self.import_run.started_at = timezone.now()
self.import_run.save(update_fields=["started_at"])
self._log("info", "Starting import process")
try:
if self.settings.file_type == "csv":
self._process_csv(file_path)
self._update_status("FINISHED")
self._log(
"info",
f"Import completed successfully. "
f"Successful: {self.import_run.successful_rows}, "
f"Failed: {self.import_run.failed_rows}, "
f"Skipped: {self.import_run.skipped_rows}",
)
except Exception as e:
self._update_status("FAILED")
self._log("error", f"Import failed: {str(e)}")
raise Exception("Import failed")
finally:
self._log("info", "Cleaning up temporary files")
try:
if os.path.exists(file_path):
os.remove(file_path)
self._log("info", f"Deleted temporary file: {file_path}")
except OSError as e:
self._log("warning", f"Failed to delete temporary file: {str(e)}")
self.import_run.finished_at = timezone.now()
self.import_run.save(update_fields=["finished_at"])
cachalot.api.invalidate()

View File

@@ -0,0 +1,21 @@
import logging
import cachalot.api
from procrastinate.contrib.django import app
from apps.import_app.models import ImportRun
from apps.import_app.services import ImportServiceV1
logger = logging.getLogger(__name__)
@app.task
def process_import(import_run_id: int, file_path: str):
try:
import_run = ImportRun.objects.get(id=import_run_id)
import_service = ImportServiceV1(import_run)
import_service.process_file(file_path)
cachalot.api.invalidate()
except ImportRun.DoesNotExist:
cachalot.api.invalidate()
raise ValueError(f"ImportRun with id {import_run_id} not found")

View File

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

View File

@@ -0,0 +1,56 @@
from django.urls import path
import apps.import_app.views as views
urlpatterns = [
path("import/", views.import_view, name="import"),
path(
"import/presets/",
views.import_presets_list,
name="import_presets_list",
),
path(
"import/profiles/",
views.import_profile_index,
name="import_profiles_index",
),
path(
"import/profiles/list/",
views.import_profile_list,
name="import_profiles_list",
),
path(
"import/profiles/<int:profile_id>/delete/",
views.import_profile_delete,
name="import_profile_delete",
),
path(
"import/profiles/add/",
views.import_profile_add,
name="import_profiles_add",
),
path(
"import/profiles/<int:profile_id>/edit/",
views.import_profile_edit,
name="import_profile_edit",
),
path(
"import/profiles/<int:profile_id>/runs/list/",
views.import_runs_list,
name="import_profile_runs_list",
),
path(
"import/profiles/<int:profile_id>/runs/<int:run_id>/log/",
views.import_run_log,
name="import_run_log",
),
path(
"import/profiles/<int:profile_id>/runs/<int:run_id>/delete/",
views.import_run_delete,
name="import_run_delete",
),
path(
"import/profiles/<int:profile_id>/runs/add/",
views.import_run_add,
name="import_run_add",
),
]

View File

@@ -0,0 +1,227 @@
import shutil
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.files.storage import FileSystemStorage
from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404
from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx
from apps.import_app.forms import ImportRunFileUploadForm, ImportProfileForm
from apps.import_app.models import ImportRun, ImportProfile
from apps.import_app.services import PresetService
from apps.import_app.tasks import process_import
def import_view(request):
import_profile = ImportProfile.objects.get(id=2)
shutil.copyfile(
"/usr/src/app/apps/import_app/teste2.csv", "/usr/src/app/temp/teste2.csv"
)
ir = ImportRun.objects.create(profile=import_profile, file_name="teste.csv")
process_import.defer(
import_run_id=ir.id,
file_path="/usr/src/app/temp/teste2.csv",
)
return HttpResponse("Hello, world. You're at the polls page.")
@login_required
@require_http_methods(["GET"])
def import_presets_list(request):
presets = PresetService.get_all_presets()
return render(
request,
"import_app/fragments/profiles/list_presets.html",
{"presets": presets},
)
@login_required
@require_http_methods(["GET", "POST"])
def import_profile_index(request):
return render(
request,
"import_app/pages/profiles_index.html",
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def import_profile_list(request):
profiles = ImportProfile.objects.all()
return render(
request,
"import_app/fragments/profiles/list.html",
{"profiles": profiles},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def import_profile_add(request):
message = request.POST.get("message", None)
if request.method == "POST" and request.POST.get("submit"):
form = ImportProfileForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, _("Import Profile added successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
else:
form = ImportProfileForm(
initial={
"name": request.POST.get("name"),
"version": int(request.POST.get("version", 1)),
"yaml_config": request.POST.get("yaml_config"),
}
)
return render(
request,
"import_app/fragments/profiles/add.html",
{"form": form, "message": message},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def import_profile_edit(request, profile_id):
profile = get_object_or_404(ImportProfile, id=profile_id)
if request.method == "POST":
form = ImportProfileForm(request.POST, instance=profile)
if form.is_valid():
form.save()
messages.success(request, _("Import Profile update successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
else:
form = ImportProfileForm(instance=profile)
return render(
request,
"import_app/fragments/profiles/edit.html",
{"form": form, "profile": profile},
)
@only_htmx
@login_required
@require_http_methods(["DELETE"])
def import_profile_delete(request, profile_id):
profile = ImportProfile.objects.get(id=profile_id)
profile.delete()
messages.success(request, _("Import Profile deleted successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated",
},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def import_runs_list(request, profile_id):
profile = ImportProfile.objects.get(id=profile_id)
runs = ImportRun.objects.filter(profile=profile).order_by("-id")
return render(
request,
"import_app/fragments/runs/list.html",
{"profile": profile, "runs": runs},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def import_run_log(request, profile_id, run_id):
run = ImportRun.objects.get(profile__id=profile_id, id=run_id)
return render(
request,
"import_app/fragments/runs/log.html",
{"run": run},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def import_run_add(request, profile_id):
profile = ImportProfile.objects.get(id=profile_id)
if request.method == "POST":
form = ImportRunFileUploadForm(request.POST, request.FILES)
if form.is_valid():
uploaded_file = request.FILES["file"]
fs = FileSystemStorage(location="/usr/src/app/temp")
filename = fs.save(uploaded_file.name, uploaded_file)
file_path = fs.path(filename)
import_run = ImportRun.objects.create(profile=profile, file_name=filename)
# Defer the procrastinate task
process_import.defer(import_run_id=import_run.id, file_path=file_path)
messages.success(request, _("Import Run queued successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
else:
form = ImportRunFileUploadForm()
return render(
request,
"import_app/fragments/runs/add.html",
{"form": form, "profile": profile},
)
@only_htmx
@login_required
@require_http_methods(["DELETE"])
def import_run_delete(request, profile_id, run_id):
run = ImportRun.objects.get(profile__id=profile_id, id=run_id)
run.delete()
messages.success(request, _("Run deleted successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated",
},
)

View File

@@ -30,6 +30,8 @@ def index(request):
@login_required
@require_http_methods(["GET"])
def monthly_overview(request, month: int, year: int):
order = request.session.get("monthly_transactions_order", "default")
if month < 1 or month > 12:
from django.http import Http404
@@ -54,6 +56,7 @@ def monthly_overview(request, month: int, year: int):
"previous_month": previous_month,
"previous_year": previous_year,
"filter": f,
"order": order,
},
)
@@ -62,7 +65,12 @@ def monthly_overview(request, month: int, year: int):
@login_required
@require_http_methods(["GET"])
def transactions_list(request, month: int, year: int):
order = request.GET.get("order")
order = request.session.get("monthly_transactions_order", "default")
if "order" in request.GET:
order = request.GET["order"]
if order != request.session["monthly_transactions_order"]:
request.session["monthly_transactions_order"] = order
f = TransactionsFilter(request.GET)
transactions_filtered = (

View File

@@ -52,19 +52,4 @@ urlpatterns = [
views.transaction_rule_action_delete,
name="transaction_rule_action_delete",
),
# path(
# "rules/<int:installment_plan_id>/transactions/",
# views.installment_plan_transactions,
# name="rule_view",
# ),
# path(
# "rules/<int:installment_plan_id>/edit/",
# views.installment_plan_edit,
# name="rule_edit",
# ),
# path(
# "rules/<int:installment_plan_id>/delete/",
# views.installment_plan_delete,
# name="rule_delete",
# ),
]

View File

@@ -3,7 +3,6 @@ from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404, redirect
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx
@@ -118,7 +117,6 @@ def transaction_rule_view(request, transaction_rule_id):
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def transaction_rule_delete(request, transaction_rule_id):
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
@@ -201,7 +199,6 @@ def transaction_rule_action_edit(request, transaction_rule_action_id):
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def transaction_rule_action_delete(request, transaction_rule_action_id):
transaction_rule_action = get_object_or_404(

View File

@@ -12,15 +12,34 @@ from apps.transactions.models import (
@admin.register(Transaction)
class TransactionModelAdmin(admin.ModelAdmin):
def get_queryset(self, request):
# Use the all_objects manager to show all transactions, including deleted ones
return self.model.all_objects.all()
list_filter = ["deleted", "type", "is_paid", "date", "account"]
list_display = [
"date",
"description",
"type",
"account__name",
"amount",
"account__currency__code",
"date",
"reference_date",
"deleted",
]
readonly_fields = ["deleted_at"]
actions = ["hard_delete_selected"]
def hard_delete_selected(self, request, queryset):
for obj in queryset:
obj.hard_delete()
self.message_user(
request, f"Successfully hard deleted {queryset.count()} transactions."
)
hard_delete_selected.short_description = "Hard delete selected transactions"
class TransactionInline(admin.TabularInline):

View File

@@ -8,6 +8,7 @@ from django_filters import Filter
from apps.accounts.models import Account
from apps.common.fields.month_year import MonthYearFormField
from apps.common.widgets.datepicker import AirDatePickerInput
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
from apps.common.widgets.tom_select import TomSelectMultiple
from apps.currencies.models import Currency
@@ -87,13 +88,11 @@ class TransactionsFilter(django_filters.FilterSet):
date_start = django_filters.DateFilter(
field_name="date",
lookup_expr="gte",
widget=forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
label=_("Date from"),
)
date_end = django_filters.DateFilter(
field_name="date",
lookup_expr="lte",
widget=forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
label=_("Until"),
)
reference_date_start = MonthYearFilter(
@@ -183,3 +182,5 @@ class TransactionsFilter(django_filters.FilterSet):
self.form.fields["to_amount"].widget = ArbitraryDecimalDisplayNumberInput()
self.form.fields["from_amount"].widget = ArbitraryDecimalDisplayNumberInput()
self.form.fields["date_start"].widget = AirDatePickerInput()
self.form.fields["date_end"].widget = AirDatePickerInput()

View File

@@ -1,5 +1,5 @@
from crispy_bootstrap5.bootstrap5 import Switch
from crispy_forms.bootstrap import FormActions
from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion
from crispy_forms.bootstrap import FormActions, AccordionGroup
from crispy_forms.helper import FormHelper
from crispy_forms.layout import (
Layout,
@@ -8,6 +8,7 @@ from crispy_forms.layout import (
Field,
)
from django import forms
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from apps.accounts.models import Account
@@ -15,10 +16,11 @@ from apps.common.fields.forms.dynamic_select import (
DynamicModelChoiceField,
DynamicModelMultipleChoiceField,
)
from apps.common.fields.month_year import MonthYearFormField
from apps.common.widgets.crispy.submit import NoClassSubmit
from apps.common.widgets.datepicker import AirDatePickerInput, AirMonthYearPickerInput
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
from apps.common.widgets.tom_select import TomSelect
from apps.rules.signals import transaction_created, transaction_updated
from apps.transactions.models import (
Transaction,
TransactionCategory,
@@ -27,14 +29,15 @@ from apps.transactions.models import (
RecurringTransaction,
TransactionEntity,
)
from apps.rules.signals import transaction_created, transaction_updated
class TransactionForm(forms.ModelForm):
category = DynamicModelChoiceField(
create_field="name",
model=TransactionCategory,
required=False,
label=_("Category"),
queryset=TransactionCategory.objects.filter(active=True),
)
tags = DynamicModelMultipleChoiceField(
model=TransactionTag,
@@ -42,6 +45,7 @@ class TransactionForm(forms.ModelForm):
create_field="name",
required=False,
label=_("Tags"),
queryset=TransactionTag.objects.filter(active=True),
)
entities = DynamicModelMultipleChoiceField(
model=TransactionEntity,
@@ -55,7 +59,12 @@ class TransactionForm(forms.ModelForm):
label=_("Account"),
widget=TomSelect(clear_button=False, group_by="group"),
)
reference_date = MonthYearFormField(label=_("Reference Date"), required=False)
date = forms.DateField(label=_("Date"))
reference_date = forms.DateField(
widget=AirMonthYearPickerInput(), label=_("Reference Date"), required=False
)
class Meta:
model = Transaction
@@ -73,7 +82,6 @@ class TransactionForm(forms.ModelForm):
"entities",
]
widgets = {
"date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
"notes": forms.Textarea(attrs={"rows": 3}),
"account": TomSelect(clear_button=False, group_by="group"),
}
@@ -81,6 +89,24 @@ class TransactionForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# if editing a transaction display non-archived items and it's own item even if it's archived
if self.instance.id:
self.fields["account"].queryset = Account.objects.filter(
Q(is_archived=False) | Q(transactions=self.instance.id)
).distinct()
self.fields["category"].queryset = TransactionCategory.objects.filter(
Q(active=True) | Q(transaction=self.instance.id)
).distinct()
self.fields["tags"].queryset = TransactionTag.objects.filter(
Q(active=True) | Q(transaction=self.instance.id)
).distinct()
self.fields["entities"].queryset = TransactionEntity.objects.filter(
Q(active=True) | Q(transactions=self.instance.id)
).distinct()
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.form_method = "post"
@@ -89,15 +115,15 @@ class TransactionForm(forms.ModelForm):
"type",
template="transactions/widgets/income_expense_toggle_buttons.html",
),
Switch("is_paid"),
Field("is_paid", template="transactions/widgets/paid_toggle_button.html"),
Row(
Column("account", css_class="form-group col-md-6 mb-0"),
Column("entities", css_class="form-group col-md-6 mb-0"),
css_class="form-row",
),
Row(
Column("date", css_class="form-group col-md-6 mb-0"),
Column("reference_date", css_class="form-group col-md-6 mb-0"),
Column(Field("date"), css_class="form-group col-md-6 mb-0"),
Column(Field("reference_date"), css_class="form-group col-md-6 mb-0"),
css_class="form-row",
),
"description",
@@ -110,7 +136,48 @@ class TransactionForm(forms.ModelForm):
"notes",
)
self.helper_simple = FormHelper()
self.helper_simple.form_tag = False
self.helper_simple.form_method = "post"
self.helper_simple.layout = Layout(
Field(
"type",
template="transactions/widgets/income_expense_toggle_buttons.html",
),
Field("is_paid", template="transactions/widgets/paid_toggle_button.html"),
"account",
Row(
Column(Field("date"), css_class="form-group col-md-6 mb-0"),
Column(Field("reference_date"), css_class="form-group col-md-6 mb-0"),
css_class="form-row",
),
"description",
Field("amount", inputmode="decimal"),
BS5Accordion(
AccordionGroup(
_("More"),
"entities",
Row(
Column("category", css_class="form-group col-md-6 mb-0"),
Column("tags", css_class="form-group col-md-6 mb-0"),
css_class="form-row",
),
"notes",
active=False,
),
flush=False,
always_open=False,
css_class="mb-3",
),
FormActions(
NoClassSubmit(
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
),
),
)
self.fields["reference_date"].required = False
self.fields["date"].widget = AirDatePickerInput(clear_button=False)
if self.instance and self.instance.pk:
decimal_places = self.instance.account.currency.decimal_places
@@ -156,6 +223,43 @@ class TransactionForm(forms.ModelForm):
return instance
class BulkEditTransactionForm(TransactionForm):
is_paid = forms.NullBooleanField(required=False)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Make all fields optional
for field_name, field in self.fields.items():
field.required = False
del self.helper.layout[-1] # Remove button
del self.helper.layout[0:2] # Remove type, is_paid field
self.helper.layout.insert(
0,
Field(
"type",
template="transactions/widgets/unselectable_income_expense_toggle_buttons.html",
),
)
self.helper.layout.insert(
1,
Field(
"is_paid",
template="transactions/widgets/unselectable_paid_toggle_button.html",
),
)
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
),
),
)
class TransferForm(forms.Form):
from_account = forms.ModelChoiceField(
queryset=Account.objects.filter(is_archived=False),
@@ -181,14 +285,18 @@ class TransferForm(forms.Form):
)
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(
@@ -197,6 +305,7 @@ class TransferForm(forms.Form):
create_field="name",
required=False,
label=_("Tags"),
queryset=TransactionTag.objects.filter(active=True),
)
to_tags = DynamicModelMultipleChoiceField(
model=TransactionTag,
@@ -204,13 +313,15 @@ class TransferForm(forms.Form):
create_field="name",
required=False,
label=_("Tags"),
queryset=TransactionTag.objects.filter(active=True),
)
date = forms.DateField(
label=_("Date"),
widget=forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
date = forms.DateField(label=_("Date"))
reference_date = forms.DateField(
widget=AirMonthYearPickerInput(), label=_("Reference Date"), required=False
)
reference_date = MonthYearFormField(label=_("Reference Date"), required=False)
description = forms.CharField(max_length=500, label=_("Description"))
notes = forms.CharField(
required=False,
@@ -290,8 +401,8 @@ class TransferForm(forms.Form):
)
self.fields["from_amount"].widget = ArbitraryDecimalDisplayNumberInput()
self.fields["to_amount"].widget = ArbitraryDecimalDisplayNumberInput()
self.fields["date"].widget = AirDatePickerInput(clear_button=False)
def clean(self):
cleaned_data = super().clean()
@@ -299,7 +410,7 @@ class TransferForm(forms.Form):
to_account = cleaned_data.get("to_account")
if from_account == to_account:
raise forms.ValidationError("From and To accounts must be different.")
raise forms.ValidationError(_("From and To accounts must be different."))
return cleaned_data
@@ -358,11 +469,14 @@ class InstallmentPlanForm(forms.ModelForm):
create_field="name",
required=False,
label=_("Tags"),
queryset=TransactionTag.objects.filter(active=True),
)
category = DynamicModelChoiceField(
create_field="name",
model=TransactionCategory,
required=False,
label=_("Category"),
queryset=TransactionCategory.objects.filter(active=True),
)
entities = DynamicModelMultipleChoiceField(
model=TransactionEntity,
@@ -370,9 +484,13 @@ class InstallmentPlanForm(forms.ModelForm):
create_field="name",
required=False,
label=_("Entities"),
queryset=TransactionEntity.objects.filter(active=True),
)
type = forms.ChoiceField(choices=Transaction.Type.choices)
reference_date = MonthYearFormField(label=_("Reference Date"), required=False)
reference_date = forms.DateField(
widget=AirMonthYearPickerInput(), label=_("Reference Date"), required=False
)
class Meta:
model = InstallmentPlan
@@ -392,7 +510,6 @@ class InstallmentPlanForm(forms.ModelForm):
"entities",
]
widgets = {
"start_date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
"account": TomSelect(),
"recurrence": TomSelect(clear_button=False),
"notes": forms.Textarea(attrs={"rows": 3}),
@@ -401,6 +518,24 @@ class InstallmentPlanForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# if editing display non-archived items and it's own item even if it's archived
if self.instance.id:
self.fields["account"].queryset = Account.objects.filter(
Q(is_archived=False) | Q(installmentplan=self.instance.id)
).distinct()
self.fields["category"].queryset = TransactionCategory.objects.filter(
Q(active=True) | Q(installmentplan=self.instance.id)
).distinct()
self.fields["tags"].queryset = TransactionTag.objects.filter(
Q(active=True) | Q(installmentplan=self.instance.id)
).distinct()
self.fields["entities"].queryset = TransactionEntity.objects.filter(
Q(active=True) | Q(installmentplan=self.instance.id)
).distinct()
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.form_method = "post"
@@ -437,6 +572,7 @@ class InstallmentPlanForm(forms.ModelForm):
)
self.fields["installment_amount"].widget = ArbitraryDecimalDisplayNumberInput()
self.fields["start_date"].widget = AirDatePickerInput(clear_button=False)
if self.instance and self.instance.pk:
self.helper.layout.append(
@@ -470,7 +606,7 @@ class InstallmentPlanForm(forms.ModelForm):
class TransactionTagForm(forms.ModelForm):
class Meta:
model = TransactionTag
fields = ["name"]
fields = ["name", "active"]
labels = {"name": _("Tag name")}
def __init__(self, *args, **kwargs):
@@ -479,7 +615,7 @@ class TransactionTagForm(forms.ModelForm):
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.form_method = "post"
self.helper.layout = Layout(Field("name", css_class="mb-3"))
self.helper.layout = Layout(Field("name", css_class="mb-3"), Switch("active"))
if self.instance and self.instance.pk:
self.helper.layout.append(
@@ -502,7 +638,7 @@ class TransactionTagForm(forms.ModelForm):
class TransactionEntityForm(forms.ModelForm):
class Meta:
model = TransactionEntity
fields = ["name"]
fields = ["name", "active"]
labels = {"name": _("Entity name")}
def __init__(self, *args, **kwargs):
@@ -511,7 +647,7 @@ class TransactionEntityForm(forms.ModelForm):
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.form_method = "post"
self.helper.layout = Layout(Field("name", css_class="mb-3"))
self.helper.layout = Layout(Field("name"), Switch("active"))
if self.instance and self.instance.pk:
self.helper.layout.append(
@@ -534,7 +670,7 @@ class TransactionEntityForm(forms.ModelForm):
class TransactionCategoryForm(forms.ModelForm):
class Meta:
model = TransactionCategory
fields = ["name", "mute"]
fields = ["name", "mute", "active"]
labels = {"name": _("Category name")}
help_texts = {
"mute": _("Muted categories won't count towards your monthly total")
@@ -546,7 +682,7 @@ class TransactionCategoryForm(forms.ModelForm):
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.form_method = "post"
self.helper.layout = Layout(Field("name", css_class="mb-3"), Switch("mute"))
self.helper.layout = Layout(Field("name"), Switch("mute"), Switch("active"))
if self.instance and self.instance.pk:
self.helper.layout.append(
@@ -578,11 +714,14 @@ class RecurringTransactionForm(forms.ModelForm):
create_field="name",
required=False,
label=_("Tags"),
queryset=TransactionTag.objects.filter(active=True),
)
category = DynamicModelChoiceField(
create_field="name",
model=TransactionCategory,
required=False,
label=_("Category"),
queryset=TransactionCategory.objects.filter(active=True),
)
entities = DynamicModelMultipleChoiceField(
model=TransactionEntity,
@@ -590,9 +729,9 @@ class RecurringTransactionForm(forms.ModelForm):
create_field="name",
required=False,
label=_("Entities"),
queryset=TransactionEntity.objects.filter(active=True),
)
type = forms.ChoiceField(choices=Transaction.Type.choices)
reference_date = MonthYearFormField(label=_("Reference Date"), required=False)
class Meta:
model = RecurringTransaction
@@ -612,8 +751,7 @@ class RecurringTransactionForm(forms.ModelForm):
"entities",
]
widgets = {
"start_date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
"end_date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
"reference_date": AirMonthYearPickerInput(),
"recurrence_type": TomSelect(clear_button=False),
"notes": forms.Textarea(
attrs={
@@ -624,6 +762,25 @@ class RecurringTransactionForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# if editing display non-archived items and it's own item even if it's archived
if self.instance.id:
self.fields["account"].queryset = Account.objects.filter(
Q(is_archived=False) | Q(recurringtransaction=self.instance.id)
).distinct()
self.fields["category"].queryset = TransactionCategory.objects.filter(
Q(active=True) | Q(recurringtransaction=self.instance.id)
).distinct()
self.fields["tags"].queryset = TransactionTag.objects.filter(
Q(active=True) | Q(recurringtransaction=self.instance.id)
).distinct()
self.fields["entities"].queryset = TransactionEntity.objects.filter(
Q(active=True) | Q(recurringtransaction=self.instance.id)
).distinct()
self.helper = FormHelper()
self.helper.form_method = "post"
self.helper.form_tag = False
@@ -660,6 +817,8 @@ class RecurringTransactionForm(forms.ModelForm):
)
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
self.fields["start_date"].widget = AirDatePickerInput(clear_button=False)
self.fields["end_date"].widget = AirDatePickerInput()
if self.instance and self.instance.pk:
self.helper.layout.append(
@@ -694,5 +853,7 @@ class RecurringTransactionForm(forms.ModelForm):
instance = super().save(**kwargs)
if is_new:
instance.create_upcoming_transactions()
else:
instance.update_unpaid_transactions()
return instance

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.1.3 on 2025-01-04 19:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('transactions', '0024_installmentplan_entities_and_more'),
]
operations = [
migrations.AddField(
model_name='transactioncategory',
name='active',
field=models.BooleanField(default=True, help_text="Deactivated categories won't be able to be selected when creating new transactions", verbose_name='Active'),
),
migrations.AddField(
model_name='transactiontag',
name='active',
field=models.BooleanField(default=True, help_text="Deactivated tags won't be able to be selected when creating new transactions", verbose_name='Active'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.3 on 2025-01-04 19:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('transactions', '0025_transactioncategory_active_transactiontag_active'),
]
operations = [
migrations.AddField(
model_name='transactionentity',
name='active',
field=models.BooleanField(default=True, help_text="Deactivated entities won't be able to be selected when creating new transactions", verbose_name='Active'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2025-01-14 12:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('transactions', '0026_transactionentity_active'),
]
operations = [
migrations.AlterField(
model_name='transaction',
name='description',
field=models.CharField(blank=True, max_length=500, verbose_name='Description'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.5 on 2025-01-19 00:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('transactions', '0027_alter_transaction_description'),
]
operations = [
migrations.AddField(
model_name='transaction',
name='internal_note',
field=models.TextField(blank=True, verbose_name='Internal Note'),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.1.5 on 2025-01-19 14:59
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('transactions', '0028_transaction_internal_note'),
]
operations = [
migrations.AlterModelOptions(
name='transaction',
options={'default_manager_name': 'objects', 'verbose_name': 'Transaction', 'verbose_name_plural': 'Transactions'},
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.1.5 on 2025-01-19 14:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('transactions', '0029_alter_transaction_options'),
]
operations = [
migrations.AddField(
model_name='transaction',
name='deleted',
field=models.BooleanField(default=False, verbose_name='Deleted'),
),
migrations.AddField(
model_name='transaction',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.5 on 2025-01-19 15:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('transactions', '0030_transaction_deleted_transaction_deleted_at'),
]
operations = [
migrations.AlterField(
model_name='transaction',
name='deleted',
field=models.BooleanField(db_index=True, default=False, verbose_name='Deleted'),
),
]

View File

@@ -0,0 +1,25 @@
# Generated by Django 5.1.5 on 2025-01-19 16:48
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('transactions', '0031_alter_transaction_deleted'),
]
operations = [
migrations.AddField(
model_name='transaction',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='transaction',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 5.1.5 on 2025-01-21 01:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("transactions", "0032_transaction_created_at_transaction_updated_at"),
]
operations = [
migrations.AddField(
model_name="transaction",
name="internal_id",
field=models.TextField(
blank=True, null=True, unique=True, verbose_name="Internal ID"
),
),
]

View File

@@ -6,6 +6,7 @@ from django.db import models, transaction
from django.db.models import Q
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.conf import settings
from apps.common.fields.month_year import MonthYearModelField
from apps.common.functions.decimals import truncate_decimal
@@ -15,9 +16,63 @@ from apps.transactions.validators import validate_decimal_places, validate_non_n
logger = logging.getLogger()
class SoftDeleteQuerySet(models.QuerySet):
def delete(self):
if not settings.ENABLE_SOFT_DELETE:
# If soft deletion is disabled, perform a normal delete
return super().delete()
# Separate the queryset into already deleted and not deleted objects
already_deleted = self.filter(deleted=True)
not_deleted = self.filter(deleted=False)
# Use a transaction to ensure atomicity
with transaction.atomic():
# Perform hard delete on already deleted objects
hard_deleted_count = already_deleted._raw_delete(already_deleted.db)
# Perform soft delete on not deleted objects
soft_deleted_count = not_deleted.update(
deleted=True, deleted_at=timezone.now()
)
# Return a tuple of counts as expected by Django's delete method
return (
hard_deleted_count + soft_deleted_count,
{"Transaction": hard_deleted_count + soft_deleted_count},
)
def hard_delete(self):
return super().delete()
class SoftDeleteManager(models.Manager):
def get_queryset(self):
qs = SoftDeleteQuerySet(self.model, using=self._db)
return qs.filter(deleted=False)
class AllObjectsManager(models.Manager):
def get_queryset(self):
return SoftDeleteQuerySet(self.model, using=self._db)
class DeletedObjectsManager(models.Manager):
def get_queryset(self):
qs = SoftDeleteQuerySet(self.model, using=self._db)
return qs.filter(deleted=True)
class TransactionCategory(models.Model):
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True)
mute = models.BooleanField(default=False, verbose_name=_("Mute"))
active = models.BooleanField(
default=True,
verbose_name=_("Active"),
help_text=_(
"Deactivated categories won't be able to be selected when creating new transactions"
),
)
class Meta:
verbose_name = _("Transaction Category")
@@ -30,6 +85,13 @@ class TransactionCategory(models.Model):
class TransactionTag(models.Model):
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True)
active = models.BooleanField(
default=True,
verbose_name=_("Active"),
help_text=_(
"Deactivated tags won't be able to be selected when creating new transactions"
),
)
class Meta:
verbose_name = _("Transaction Tags")
@@ -42,8 +104,13 @@ class TransactionTag(models.Model):
class TransactionEntity(models.Model):
name = models.CharField(max_length=255, verbose_name=_("Name"))
# Add any other fields you might want for entities
active = models.BooleanField(
default=True,
verbose_name=_("Active"),
help_text=_(
"Deactivated entities won't be able to be selected when creating new transactions"
),
)
class Meta:
verbose_name = _("Entity")
@@ -82,7 +149,9 @@ class Transaction(models.Model):
validators=[validate_non_negative, validate_decimal_places],
)
description = models.CharField(max_length=500, verbose_name=_("Description"))
description = models.CharField(
max_length=500, verbose_name=_("Description"), blank=True
)
notes = models.TextField(blank=True, verbose_name=_("Notes"))
category = models.ForeignKey(
TransactionCategory,
@@ -120,11 +189,29 @@ class Transaction(models.Model):
related_name="transactions",
verbose_name=_("Recurring Transaction"),
)
internal_note = models.TextField(blank=True, verbose_name=_("Internal Note"))
internal_id = models.TextField(
blank=True, null=True, unique=True, verbose_name=_("Internal ID")
)
deleted = models.BooleanField(
default=False, verbose_name=_("Deleted"), db_index=True
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
deleted_at = models.DateTimeField(
null=True, blank=True, verbose_name=_("Deleted At")
)
objects = SoftDeleteManager.from_queryset(SoftDeleteQuerySet)()
all_objects = AllObjectsManager.from_queryset(SoftDeleteQuerySet)()
deleted_objects = DeletedObjectsManager.from_queryset(SoftDeleteQuerySet)()
class Meta:
verbose_name = _("Transaction")
verbose_name_plural = _("Transactions")
db_table = "transactions"
default_manager_name = "objects"
def save(self, *args, **kwargs):
self.amount = truncate_decimal(
@@ -139,6 +226,17 @@ class Transaction(models.Model):
self.full_clean()
super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
if settings.ENABLE_SOFT_DELETE:
self.deleted = True
self.deleted_at = timezone.now()
self.save()
else:
super().delete(*args, **kwargs)
def hard_delete(self, *args, **kwargs):
super().delete(*args, **kwargs)
def exchanged_amount(self):
if self.account.exchange_currency:
converted_amount, prefix, suffix, decimal_places = convert(
@@ -157,6 +255,10 @@ class Transaction(models.Model):
return None
def __str__(self):
type_display = self.get_type_display()
return f"{self.description} - {type_display} - {self.account} - {self.date}"
class InstallmentPlan(models.Model):
class Recurrence(models.TextChoices):
@@ -315,10 +417,15 @@ class InstallmentPlan(models.Model):
existing_transaction.type = self.type
existing_transaction.date = transaction_date
existing_transaction.reference_date = transaction_reference_date
existing_transaction.amount = self.installment_amount
existing_transaction.description = self.description
existing_transaction.category = self.category
existing_transaction.notes = self.notes
if (
not existing_transaction.is_paid
): # Don't update value for paid transactions
existing_transaction.amount = self.installment_amount
existing_transaction.save()
# Update tags
@@ -521,3 +628,33 @@ class RecurringTransaction(models.Model):
recurring_transaction.save(
update_fields=["last_generated_date", "last_generated_reference_date"]
)
def update_unpaid_transactions(self):
"""
Updates all unpaid transactions associated with this RecurringTransaction.
Only unpaid transactions (`is_paid=False`) are modified. Updates fields like
amount, description, category, notes, and many-to-many relationships (tags, entities).
"""
unpaid_transactions = self.transactions.filter(is_paid=False)
for existing_transaction in unpaid_transactions:
# Update fields based on RecurringTransaction
existing_transaction.amount = self.amount
existing_transaction.description = self.description
existing_transaction.category = self.category
existing_transaction.notes = self.notes
# Update many-to-many relationships
existing_transaction.tags.set(self.tags.all())
existing_transaction.entities.set(self.entities.all())
# Save updated transaction
existing_transaction.save()
def delete_unpaid_transactions(self):
"""
Deletes all unpaid transactions associated with this RecurringTransaction.
"""
today = timezone.localdate(timezone.now())
self.transactions.filter(is_paid=False, date__gt=today).delete()

View File

@@ -1,9 +1,13 @@
import logging
from datetime import timedelta
from cachalot.api import cachalot_disabled, invalidate
from django.utils import timezone
from django.conf import settings
from procrastinate.contrib.django import app
from apps.transactions.models import RecurringTransaction
from apps.transactions.models import RecurringTransaction, Transaction
logger = logging.getLogger(__name__)
@@ -19,3 +23,31 @@ def generate_recurring_transactions(timestamp=None):
exc_info=True,
)
raise e
@app.periodic(cron="10 1 * * *")
@app.task
def cleanup_deleted_transactions(timestamp=None):
with cachalot_disabled():
if settings.ENABLE_SOFT_DELETE and settings.KEEP_DELETED_TRANSACTIONS_FOR == 0:
return "KEEP_DELETED_TRANSACTIONS_FOR is 0, no cleanup performed."
if not settings.ENABLE_SOFT_DELETE:
# Hard delete all soft-deleted transactions
deleted_count, _ = Transaction.deleted_objects.all().hard_delete()
return (
f"Hard deleted {deleted_count} transactions (soft deletion disabled)."
)
# Calculate the cutoff date
cutoff_date = timezone.now() - timedelta(
days=settings.KEEP_DELETED_TRANSACTIONS_FOR
)
invalidate()
# Hard delete soft-deleted transactions older than the cutoff date
old_transactions = Transaction.deleted_objects.filter(deleted_at__lt=cutoff_date)
deleted_count, _ = old_transactions.hard_delete()
return f"Hard deleted {deleted_count} objects older than {settings.KEEP_DELETED_TRANSACTIONS_FOR} days."

View File

@@ -12,7 +12,7 @@ urlpatterns = [
name="transactions_all_summary",
),
path(
"transactions/actions/pay",
"transactions/actions/pay/",
views.bulk_pay_transactions,
name="transactions_bulk_pay",
),
@@ -27,32 +27,54 @@ urlpatterns = [
name="transactions_bulk_delete",
),
path(
"transaction/<int:transaction_id>/pay",
"transactions/actions/duplicate/",
views.bulk_clone_transactions,
name="transactions_bulk_clone",
),
path(
"transaction/<int:transaction_id>/pay/",
views.transaction_pay,
name="transaction_pay",
),
path(
"transaction/<int:transaction_id>/delete",
"transaction/<int:transaction_id>/delete/",
views.transaction_delete,
name="transaction_delete",
),
path(
"transaction/<int:transaction_id>/edit",
"transaction/<int:transaction_id>/edit/",
views.transaction_edit,
name="transaction_edit",
),
path(
"transaction/add",
"transactions/bulk-edit/",
views.transactions_bulk_edit,
name="transactions_bulk_edit",
),
path(
"transaction/<int:transaction_id>/clone/",
views.transaction_clone,
name="transaction_clone",
),
path(
"transaction/add/",
views.transaction_add,
name="transaction_add",
),
path(
"transactions/transfer",
"add/",
views.transaction_simple_add,
name="transaction_simple_add",
),
path(
"transactions/transfer/",
views.transactions_transfer,
name="transactions_transfer",
),
path("tags/", views.tags_index, name="tags_index"),
path("tags/list/", views.tags_list, name="tags_list"),
path("tags/table/active/", views.tags_table_active, name="tags_table_active"),
path("tags/table/archived/", views.tags_table_archived, name="tags_table_archived"),
path("tags/add/", views.tag_add, name="tag_add"),
path(
"tags/<int:tag_id>/edit/",
@@ -66,6 +88,16 @@ urlpatterns = [
),
path("entities/", views.entities_index, name="entities_index"),
path("entities/list/", views.entities_list, name="entities_list"),
path(
"entities/table/active/",
views.entities_table_active,
name="entities_table_active",
),
path(
"entities/table/archived/",
views.entities_table_archived,
name="entities_table_archived",
),
path("entities/add/", views.entity_add, name="entity_add"),
path(
"entities/<int:entity_id>/edit/",
@@ -79,6 +111,16 @@ urlpatterns = [
),
path("categories/", views.categories_index, name="categories_index"),
path("categories/list/", views.categories_list, name="categories_list"),
path(
"categories/table/active/",
views.categories_table_active,
name="categories_table_active",
),
path(
"categories/table/archived/",
views.categories_table_archived,
name="categories_table_archived",
),
path("categories/add/", views.category_add, name="category_add"),
path(
"categories/<int:category_id>/edit/",

View File

@@ -1,5 +1,9 @@
from copy import deepcopy
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.utils.translation import gettext_lazy as _, ngettext_lazy
from apps.common.decorators.htmx import only_htmx
from apps.transactions.models import Transaction
@@ -9,7 +13,19 @@ from apps.transactions.models import Transaction
@login_required
def bulk_pay_transactions(request):
selected_transactions = request.GET.getlist("transactions", [])
Transaction.objects.filter(id__in=selected_transactions).update(is_paid=True)
transactions = Transaction.objects.filter(id__in=selected_transactions)
count = transactions.count()
transactions.update(is_paid=True)
messages.success(
request,
ngettext_lazy(
"%(count)s transaction marked as paid",
"%(count)s transactions marked as paid",
count,
)
% {"count": count},
)
return HttpResponse(
status=204,
@@ -21,7 +37,19 @@ def bulk_pay_transactions(request):
@login_required
def bulk_unpay_transactions(request):
selected_transactions = request.GET.getlist("transactions", [])
Transaction.objects.filter(id__in=selected_transactions).update(is_paid=False)
transactions = Transaction.objects.filter(id__in=selected_transactions)
count = transactions.count()
transactions.update(is_paid=False)
messages.success(
request,
ngettext_lazy(
"%(count)s transaction marked as not paid",
"%(count)s transactions marked as not paid",
count,
)
% {"count": count},
)
return HttpResponse(
status=204,
@@ -33,7 +61,54 @@ def bulk_unpay_transactions(request):
@login_required
def bulk_delete_transactions(request):
selected_transactions = request.GET.getlist("transactions", [])
Transaction.objects.filter(id__in=selected_transactions).delete()
transactions = Transaction.objects.filter(id__in=selected_transactions)
count = transactions.count()
transactions.delete()
messages.success(
request,
ngettext_lazy(
"%(count)s transaction deleted successfully",
"%(count)s transactions deleted successfully",
count,
)
% {"count": count},
)
return HttpResponse(
status=204,
headers={"HX-Trigger": "updated"},
)
@only_htmx
@login_required
def bulk_clone_transactions(request):
selected_transactions = request.GET.getlist("transactions", [])
transactions = Transaction.objects.filter(id__in=selected_transactions)
count = transactions.count()
for transaction in transactions:
new_transaction = deepcopy(transaction)
new_transaction.pk = None
new_transaction.installment_plan = None
new_transaction.installment_id = None
new_transaction.recurring_transaction = None
new_transaction.internal_id = None
new_transaction.save()
new_transaction.tags.add(*transaction.tags.all())
new_transaction.entities.add(*transaction.entities.all())
messages.success(
request,
ngettext_lazy(
"%(count)s transaction duplicated successfully",
"%(count)s transactions duplicated successfully",
count,
)
% {"count": count},
)
return HttpResponse(
status=204,

View File

@@ -2,9 +2,7 @@ from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx
@@ -25,11 +23,33 @@ def categories_index(request):
@login_required
@require_http_methods(["GET"])
def categories_list(request):
categories = TransactionCategory.objects.all().order_by("id")
return render(
request,
"categories/fragments/list.html",
{"categories": categories},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def categories_table_active(request):
categories = TransactionCategory.objects.filter(active=True).order_by("id")
return render(
request,
"categories/fragments/table.html",
{"categories": categories, "active": True},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def categories_table_archived(request):
categories = TransactionCategory.objects.filter(active=False).order_by("id")
return render(
request,
"categories/fragments/table.html",
{"categories": categories, "active": False},
)
@@ -89,7 +109,6 @@ def category_edit(request, category_id):
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def category_delete(request, category_id):
category = get_object_or_404(TransactionCategory, id=category_id)

View File

@@ -3,7 +3,6 @@ from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx
@@ -24,11 +23,33 @@ def entities_index(request):
@login_required
@require_http_methods(["GET"])
def entities_list(request):
entities = TransactionEntity.objects.all().order_by("id")
return render(
request,
"entities/fragments/list.html",
{"entities": entities},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def entities_table_active(request):
entities = TransactionEntity.objects.filter(active=True).order_by("id")
return render(
request,
"entities/fragments/table.html",
{"entities": entities, "active": True},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def entities_table_archived(request):
entities = TransactionEntity.objects.filter(active=False).order_by("id")
return render(
request,
"entities/fragments/table.html",
{"entities": entities, "active": False},
)
@@ -88,7 +109,6 @@ def entity_edit(request, entity_id):
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def entity_delete(request, entity_id):
entity = get_object_or_404(TransactionEntity, id=entity_id)

View File

@@ -4,7 +4,6 @@ from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx
@@ -150,7 +149,6 @@ def installment_plan_refresh(request, installment_plan_id):
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def installment_plan_delete(request, installment_plan_id):
installment_plan = get_object_or_404(InstallmentPlan, id=installment_plan_id)

View File

@@ -1,5 +1,4 @@
from dateutil.relativedelta import relativedelta
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.db.models import Q
@@ -7,7 +6,6 @@ from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx
@@ -168,12 +166,26 @@ def recurring_transaction_toggle_pause(request, recurring_transaction_id):
)
current_paused = recurring_transaction.is_paused
recurring_transaction.is_paused = not current_paused
recurring_transaction.save(update_fields=["is_paused"])
if current_paused:
messages.success(request, _("Recurring transaction unpaused successfully"))
today = timezone.localdate(timezone.now())
recurring_transaction.last_generated_date = max(
recurring_transaction.last_generated_date, today
)
recurring_transaction.last_generated_reference_date = max(
recurring_transaction.last_generated_reference_date, today
)
recurring_transaction.save(
update_fields=[
"last_generated_date",
"last_generated_reference_date",
"is_paused",
]
)
generate_recurring_transactions.defer()
messages.success(request, _("Recurring transaction unpaused successfully"))
else:
recurring_transaction.save(update_fields=["is_paused"])
messages.success(request, _("Recurring transaction paused successfully"))
return HttpResponse(
@@ -188,7 +200,7 @@ def recurring_transaction_toggle_pause(request, recurring_transaction_id):
@login_required
@require_http_methods(["GET"])
def recurring_transaction_finish(request, recurring_transaction_id):
recurring_transaction = get_object_or_404(
recurring_transaction: RecurringTransaction = get_object_or_404(
RecurringTransaction, id=recurring_transaction_id
)
today = timezone.localdate(timezone.now()) - relativedelta(days=1)
@@ -197,6 +209,9 @@ def recurring_transaction_finish(request, recurring_transaction_id):
recurring_transaction.is_paused = True
recurring_transaction.save(update_fields=["end_date", "is_paused"])
# Delete all unpaid transactions associated with this RecurringTransaction
recurring_transaction.delete_unpaid_transactions()
messages.success(request, _("Recurring transaction finished successfully"))
return HttpResponse(
@@ -209,7 +224,6 @@ def recurring_transaction_finish(request, recurring_transaction_id):
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def recurring_transaction_delete(request, recurring_transaction_id):
recurring_transaction = get_object_or_404(

View File

@@ -3,7 +3,6 @@ from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx
@@ -24,11 +23,33 @@ def tags_index(request):
@login_required
@require_http_methods(["GET"])
def tags_list(request):
tags = TransactionTag.objects.all().order_by("id")
return render(
request,
"tags/fragments/list.html",
{"tags": tags},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def tags_table_active(request):
tags = TransactionTag.objects.filter(active=True).order_by("id")
return render(
request,
"tags/fragments/table.html",
{"tags": tags, "active": True},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def tags_table_archived(request):
tags = TransactionTag.objects.filter(active=False).order_by("id")
return render(
request,
"tags/fragments/table.html",
{"tags": tags, "active": False},
)
@@ -88,7 +109,6 @@ def tag_edit(request, tag_id):
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def tag_delete(request, tag_id):
tag = get_object_or_404(TransactionTag, id=tag_id)

View File

@@ -1,4 +1,5 @@
import datetime
from copy import deepcopy
from django.contrib import messages
from django.contrib.auth.decorators import login_required
@@ -6,14 +7,18 @@ from django.core.paginator import Paginator
from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.utils.translation import gettext_lazy as _, ngettext_lazy
from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx
from apps.common.utils.dicts import remove_falsey_entries
from apps.rules.signals import transaction_created, transaction_updated
from apps.transactions.filters import TransactionsFilter
from apps.transactions.forms import TransactionForm, TransferForm
from apps.transactions.forms import (
TransactionForm,
TransferForm,
BulkEditTransactionForm,
)
from apps.transactions.models import Transaction
from apps.transactions.utils.calculations import (
calculate_currency_totals,
@@ -53,7 +58,7 @@ def transaction_add(request):
initial={
"date": expected_date,
"type": transaction_type,
}
},
)
return render(
@@ -63,6 +68,48 @@ def transaction_add(request):
)
@login_required
@require_http_methods(["GET", "POST"])
def transaction_simple_add(request):
month = int(request.GET.get("month", timezone.localdate(timezone.now()).month))
year = int(request.GET.get("year", timezone.localdate(timezone.now()).year))
transaction_type = Transaction.Type(request.GET.get("type", "IN"))
now = timezone.localdate(timezone.now())
expected_date = datetime.datetime(
day=now.day if month == now.month and year == now.year else 1,
month=month,
year=year,
).date()
if request.method == "POST":
form = TransactionForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, _("Transaction added successfully"))
form = TransactionForm(
initial={
"date": expected_date,
"type": transaction_type,
},
)
else:
form = TransactionForm(
initial={
"date": expected_date,
"type": transaction_type,
},
)
return render(
request,
"transactions/pages/add.html",
{"form": form},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
@@ -91,7 +138,110 @@ def transaction_edit(request, transaction_id, **kwargs):
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["GET", "POST"])
def transactions_bulk_edit(request):
# Get selected transaction IDs from the URL parameter
transaction_ids = request.GET.getlist("transactions") or request.POST.getlist(
"transactions"
)
# Load the selected transactions
transactions = Transaction.objects.filter(id__in=transaction_ids)
count = transactions.count()
if request.method == "POST":
form = BulkEditTransactionForm(request.POST)
if form.is_valid():
# Apply changes from the form to all selected transactions
for transaction in transactions:
for field_name, value in form.cleaned_data.items():
if value or isinstance(
value, bool
): # Only update fields that have been filled in the form
if field_name == "tags":
transaction.tags.set(value)
elif field_name == "entities":
transaction.entities.set(value)
else:
setattr(transaction, field_name, value)
transaction.save()
transaction_updated.send(sender=transaction)
messages.success(
request,
ngettext_lazy(
"%(count)s transaction updated successfully",
"%(count)s transactions updated successfully",
count,
)
% {"count": count},
)
return HttpResponse(
status=204,
headers={"HX-Trigger": "updated, hide_offcanvas"},
)
else:
form = BulkEditTransactionForm(initial={"is_paid": None, "type": None})
context = {
"form": form,
"transactions": transactions,
}
return render(request, "transactions/fragments/bulk_edit.html", context)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def transaction_clone(request, transaction_id, **kwargs):
transaction = get_object_or_404(Transaction, id=transaction_id)
new_transaction = deepcopy(transaction)
new_transaction.pk = None
new_transaction.installment_plan = None
new_transaction.installment_id = None
new_transaction.recurring_transaction = None
new_transaction.internal_id = None
new_transaction.save()
new_transaction.tags.add(*transaction.tags.all())
new_transaction.entities.add(*transaction.entities.all())
messages.success(request, _("Transaction duplicated successfully"))
transaction_created.send(sender=transaction)
# THIS HAS BEEN DISABLE DUE TO HTMX INCOMPATIBILITY
# SEE https://github.com/bigskysoftware/htmx/issues/3115 and https://github.com/bigskysoftware/htmx/issues/2706
# if request.GET.get("edit") == "true":
# return HttpResponse(
# status=200,
# headers={
# "HX-Trigger": "updated",
# "HX-Push-Url": "false",
# "HX-Location": json.dumps(
# {
# "path": reverse(
# "transaction_edit",
# kwargs={"transaction_id": new_transaction.id},
# ),
# "target": "#generic-offcanvas",
# "swap": "innerHTML",
# }
# ),
# },
# )
# else:
# transaction_created.send(sender=transaction)
return HttpResponse(
status=204,
headers={"HX-Trigger": "updated"},
)
@only_htmx
@login_required
@require_http_methods(["DELETE"])
def transaction_delete(request, transaction_id, **kwargs):
transaction = get_object_or_404(Transaction, id=transaction_id)
@@ -134,7 +284,7 @@ def transactions_transfer(request):
initial={
"reference_date": expected_date,
"date": expected_date,
}
},
)
return render(request, "transactions/fragments/transfer.html", {"form": form})
@@ -163,15 +313,23 @@ def transaction_pay(request, transaction_id):
@login_required
@require_http_methods(["GET"])
def transaction_all_index(request):
order = request.session.get("all_transactions_order", "default")
f = TransactionsFilter(request.GET)
return render(request, "transactions/pages/transactions.html", {"filter": f})
return render(
request, "transactions/pages/transactions.html", {"filter": f, "order": order}
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def transaction_all_list(request):
order = request.GET.get("order")
order = request.session.get("all_transactions_order", "default")
if "order" in request.GET:
order = request.GET["order"]
if order != request.session["all_transactions_order"]:
request.session["all_transactions_order"] = order
transactions = Transaction.objects.prefetch_related(
"account",

View File

@@ -46,9 +46,72 @@ class LoginForm(AuthenticationForm):
class UserSettingsForm(forms.ModelForm):
DATE_FORMAT_CHOICES = [
("SHORT_DATE_FORMAT", _("Default")),
("d-m-Y", "20-01-2025"),
("m-d-Y", "01-20-2025"),
("Y-m-d", "2025-01-20"),
("d/m/Y", "20/01/2025"),
("m/d/Y", "01/20/2025"),
("Y/m/d", "2025/01/20"),
("d.m.Y", "20.01.2025"),
("m.d.Y", "01.20.2025"),
("Y.m.d", "2025.01.20"),
]
DATETIME_FORMAT_CHOICES = [
("SHORT_DATETIME_FORMAT", _("Default")),
("d-m-Y H:i", "20-01-2025 15:30"),
("m-d-Y H:i", "01-20-2025 15:30"),
("Y-m-d H:i", "2025-01-20 15:30"),
("d-m-Y h:i A", "20-01-2025 03:30 PM"),
("m-d-Y h:i A", "01-20-2025 03:30 PM"),
("Y-m-d h:i A", "2025-01-20 03:30 PM"),
("d/m/Y H:i", "20/01/2025 15:30"),
("m/d/Y H:i", "01/20/2025 15:30"),
("Y/m/d H:i", "2025/01/20 15:30"),
("d/m/Y h:i A", "20/01/2025 03:30 PM"),
("m/d/Y h:i A", "01/20/2025 03:30 PM"),
("Y/m/d h:i A", "2025/01/20 03:30 PM"),
("d.m.Y H:i", "20.01.2025 15:30"),
("m.d.Y H:i", "01.20.2025 15:30"),
("Y.m.d H:i", "2025.01.20 15:30"),
("d.m.Y h:i A", "20.01.2025 03:30 PM"),
("m.d.Y h:i A", "01.20.2025 03:30 PM"),
("Y.m.d h:i A", "2025.01.20 03:30 PM"),
]
NUMBER_FORMAT_CHOICES = [
("AA", _("Default")),
("DC", "1.234,50"),
("CD", "1,234.50"),
]
date_format = forms.ChoiceField(
choices=DATE_FORMAT_CHOICES, initial="SHORT_DATE_FORMAT", label=_("Date Format")
)
datetime_format = forms.ChoiceField(
choices=DATETIME_FORMAT_CHOICES,
initial="SHORT_DATETIME_FORMAT",
label=_("Datetime Format"),
)
number_format = forms.ChoiceField(
choices=NUMBER_FORMAT_CHOICES,
initial="AA",
label=_("Number Format"),
)
class Meta:
model = UserSettings
fields = ["language", "timezone", "start_page"]
fields = [
"language",
"timezone",
"start_page",
"date_format",
"datetime_format",
"number_format",
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -59,6 +122,9 @@ class UserSettingsForm(forms.ModelForm):
self.helper.layout = Layout(
"language",
"timezone",
"date_format",
"datetime_format",
"number_format",
"start_page",
FormActions(
NoClassSubmit(

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.1.5 on 2025-01-20 17:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0012_alter_usersettings_start_page'),
]
operations = [
migrations.AddField(
model_name='usersettings',
name='date_format',
field=models.CharField(default='SHORT_DATE_FORMAT', max_length=100),
),
migrations.AddField(
model_name='usersettings',
name='datetime_format',
field=models.CharField(default='SHORT_DATETIME_FORMAT', max_length=100),
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 5.1.5 on 2025-01-23 03:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0013_usersettings_date_format_and_more'),
]
operations = [
migrations.AlterField(
model_name='usersettings',
name='date_format',
field=models.CharField(default='SHORT_DATE_FORMAT', max_length=100, verbose_name='Date Format'),
),
migrations.AlterField(
model_name='usersettings',
name='datetime_format',
field=models.CharField(default='SHORT_DATETIME_FORMAT', max_length=100, verbose_name='Datetime Format'),
),
migrations.AlterField(
model_name='usersettings',
name='language',
field=models.CharField(choices=[('auto', 'Auto'), ('en', 'English'), ('nl', 'Nederlands'), ('pt-br', 'Português (Brasil)')], default='auto', max_length=10, verbose_name='Language'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.5 on 2025-01-24 19:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0014_alter_usersettings_date_format_and_more'),
]
operations = [
migrations.AlterField(
model_name='usersettings',
name='language',
field=models.CharField(choices=[('auto', 'Auto'), ('en', 'English'), ('pt-br', 'Português (Brasil)')], default='auto', max_length=10, verbose_name='Language'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.5 on 2025-01-25 18:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0015_alter_usersettings_language'),
]
operations = [
migrations.AlterField(
model_name='usersettings',
name='language',
field=models.CharField(choices=[('auto', 'Auto'), ('en', 'English'), ('nl', 'Nederlands'), ('pt-br', 'Português (Brasil)')], default='auto', max_length=10, verbose_name='Language'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.5 on 2025-01-27 12:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0016_alter_usersettings_language'),
]
operations = [
migrations.AddField(
model_name='usersettings',
name='number_format',
field=models.CharField(default='AA', max_length=2, verbose_name='Number Format'),
),
]

View File

@@ -1,8 +1,8 @@
import pytz
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import models
from django.contrib.auth.models import AbstractUser, Group
from django.db import models
from django.utils.translation import gettext_lazy as _
from apps.users.managers import UserManager
@@ -36,6 +36,18 @@ class UserSettings(models.Model):
hide_amounts = models.BooleanField(default=False)
mute_sounds = models.BooleanField(default=False)
date_format = models.CharField(
max_length=100, default="SHORT_DATE_FORMAT", verbose_name=_("Date Format")
)
datetime_format = models.CharField(
max_length=100,
default="SHORT_DATETIME_FORMAT",
verbose_name=_("Datetime Format"),
)
number_format = models.CharField(
max_length=2, default="AA", verbose_name=_("Number Format")
)
language = models.CharField(
max_length=10,
choices=(("auto", _("Auto")),) + settings.LANGUAGES,
@@ -57,3 +69,6 @@ class UserSettings(models.Model):
def __str__(self):
return f"{self.user.email}'s settings"
def clean(self):
super().clean()

View File

View File

@@ -0,0 +1,54 @@
settings:
file_type: csv
delimiter: ","
encoding: utf-8
skip_lines: 0
importing: transactions
trigger_transaction_rules: true
skip_errors: true
mapping:
account:
target: account
default: <NOME DA SUA CONTA>
type: name
date:
target: date
source: Data
format: "%d/%m/%Y"
amount:
target: amount
source: Valor
description:
target: description
source: Descrição
transformations:
- type: split
separator: " - "
index: 0
type:
source: "Valor"
target: "type"
detection_method: sign
notes:
target: notes
source: Notes
internal_id:
target: internal_id
source: Identificador
is_paid:
target: is_paid
detection_method: always_paid
deduplicate:
- type: compare
fields:
- internal_id
match_type: lax

View File

@@ -0,0 +1,7 @@
{
"author": "eitchtee",
"description": "Importe suas transações da conta corrente do Nubank",
"schema_version": 1,
"name": "Nubank - Conta Corrente",
"message": "Mude '<NOME DA SUA CONTA>' para o nome da sua Nuconta dentro do WYGIWYH"
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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